log.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. """Main logging part"""
  2. # ©️ Dan Gazizullin, 2021-2023
  3. # This file is a part of Hikka Userbot
  4. # 🌐 https://github.com/hikariatama/Hikka
  5. # You can redistribute it and/or modify it under the terms of the GNU AGPLv3
  6. # 🔑 https://www.gnu.org/licenses/agpl-3.0.html
  7. import asyncio
  8. import contextlib
  9. import inspect
  10. import io
  11. import json
  12. import linecache
  13. import logging
  14. import os
  15. import re
  16. import sys
  17. import traceback
  18. import typing
  19. from logging.handlers import RotatingFileHandler
  20. import telethon
  21. from aiogram.utils.exceptions import NetworkError
  22. from . import utils
  23. from .tl_cache import CustomTelegramClient
  24. from .types import BotInlineCall, Module
  25. from .web.debugger import WebDebugger
  26. # Monkeypatch linecache to make interactive line debugger available
  27. # in werkzeug web debugger
  28. # This is weird, but the only adequate approach
  29. # https://github.com/pallets/werkzeug/blob/3115aa6a6276939f5fd6efa46282e0256ff21f1a/src/werkzeug/debug/tbtools.py#L382-L416
  30. old = linecache.getlines
  31. def getlines(filename: str, module_globals=None) -> str:
  32. """
  33. Get the lines for a Python source file from the cache.
  34. Update the cache if it doesn't contain an entry for this file already.
  35. Modified version of original `linecache.getlines`, which returns the
  36. source code of Hikka and Dragon modules properly. This is needed for
  37. interactive line debugger in werkzeug web debugger.
  38. """
  39. try:
  40. if filename.startswith("<") and filename.endswith(">"):
  41. module = filename[1:-1].split(maxsplit=1)[-1]
  42. if (
  43. module.startswith("hikka.modules")
  44. or module.startswith("dragon.modules")
  45. ) and module in sys.modules:
  46. return list(
  47. map(
  48. lambda x: f"{x}\n",
  49. sys.modules[module].__loader__.get_source().splitlines(),
  50. )
  51. )
  52. except Exception:
  53. logging.debug("Can't get lines for %s", filename, exc_info=True)
  54. return old(filename, module_globals)
  55. linecache.getlines = getlines
  56. def override_text(exception: Exception) -> typing.Optional[str]:
  57. """Returns error-specific description if available, else `None`"""
  58. if isinstance(exception, NetworkError):
  59. return "✈️ <b>You have problems with internet connection on your server.</b>"
  60. return None
  61. class HikkaException:
  62. def __init__(
  63. self,
  64. message: str,
  65. local_vars: str,
  66. full_stack: str,
  67. sysinfo: typing.Optional[
  68. typing.Tuple[object, Exception, traceback.TracebackException]
  69. ] = None,
  70. ):
  71. self.message = message
  72. self.local_vars = local_vars
  73. self.full_stack = full_stack
  74. self.sysinfo = sysinfo
  75. self.debug_url = None
  76. @classmethod
  77. def from_exc_info(
  78. cls,
  79. exc_type: object,
  80. exc_value: Exception,
  81. tb: traceback.TracebackException,
  82. stack: typing.Optional[typing.List[inspect.FrameInfo]] = None,
  83. ) -> "HikkaException":
  84. def to_hashable(dictionary: dict) -> dict:
  85. dictionary = dictionary.copy()
  86. for key, value in dictionary.items():
  87. if isinstance(value, dict):
  88. dictionary[key] = to_hashable(value)
  89. else:
  90. try:
  91. if (
  92. getattr(getattr(value, "__class__", None), "__name__", None)
  93. == "Database"
  94. ):
  95. dictionary[key] = "<Database>"
  96. elif isinstance(
  97. value,
  98. (telethon.TelegramClient, CustomTelegramClient),
  99. ):
  100. dictionary[key] = f"<{value.__class__.__name__}>"
  101. elif len(str(value)) > 512:
  102. dictionary[key] = f"{str(value)[:512]}..."
  103. else:
  104. dictionary[key] = str(value)
  105. except Exception:
  106. dictionary[key] = f"<{value.__class__.__name__}>"
  107. return dictionary
  108. full_stack = traceback.format_exc().replace(
  109. "Traceback (most recent call last):\n", ""
  110. )
  111. line_regex = r' File "(.*?)", line ([0-9]+), in (.+)'
  112. def format_line(line: str) -> str:
  113. filename_, lineno_, name_ = re.search(line_regex, line).groups()
  114. with contextlib.suppress(Exception):
  115. filename_ = os.path.basename(filename_)
  116. return (
  117. f"👉 <code>{utils.escape_html(filename_)}:{lineno_}</code> <b>in</b>"
  118. f" <code>{utils.escape_html(name_)}</code>"
  119. )
  120. filename, lineno, name = next(
  121. (
  122. re.search(line_regex, line).groups()
  123. for line in reversed(full_stack.splitlines())
  124. if re.search(line_regex, line)
  125. ),
  126. (None, None, None),
  127. )
  128. full_stack = "\n".join(
  129. [
  130. format_line(line)
  131. if re.search(line_regex, line)
  132. else f"<code>{utils.escape_html(line)}</code>"
  133. for line in full_stack.splitlines()
  134. ]
  135. )
  136. with contextlib.suppress(Exception):
  137. filename = os.path.basename(filename)
  138. caller = utils.find_caller(stack or inspect.stack())
  139. cause_mod = (
  140. "🪬 <b>Possible cause: method"
  141. f" </b><code>{utils.escape_html(caller.__name__)}</code><b> of module"
  142. f" </b><code>{utils.escape_html(caller.__self__.__class__.__name__)}</code>\n"
  143. if caller and hasattr(caller, "__self__") and hasattr(caller, "__name__")
  144. else ""
  145. )
  146. return HikkaException(
  147. message=override_text(exc_value)
  148. or (
  149. f"<b>🚫 Error!</b>\n{cause_mod}\n<b>🗄 Where:</b>"
  150. f" <code>{utils.escape_html(filename)}:{lineno}</code><b>"
  151. f" in </b><code>{utils.escape_html(name)}</code>\n<b>❓ What:</b>"
  152. f" <code>{utils.escape_html(''.join(traceback.format_exception_only(exc_type, exc_value)).strip())}</code>"
  153. ),
  154. local_vars=(
  155. f"<code>{utils.escape_html(json.dumps(to_hashable(tb.tb_frame.f_locals), indent=4))}</code>"
  156. ),
  157. full_stack=full_stack,
  158. sysinfo=(exc_type, exc_value, tb),
  159. )
  160. class TelegramLogsHandler(logging.Handler):
  161. """
  162. Keeps 2 buffers.
  163. One for dispatched messages.
  164. One for unused messages.
  165. When the length of the 2 together is 100
  166. truncate to make them 100 together,
  167. first trimming handled then unused.
  168. """
  169. def __init__(self, targets: list, capacity: int):
  170. super().__init__(0)
  171. self.buffer = []
  172. self.handledbuffer = []
  173. self._queue = []
  174. self._mods = {}
  175. self.tg_buff = []
  176. self.force_send_all = False
  177. self.tg_level = 20
  178. self.web_debugger = None
  179. self.targets = targets
  180. self.capacity = capacity
  181. self.lvl = logging.NOTSET
  182. self._send_lock = asyncio.Lock()
  183. def install_tg_log(self, mod: Module):
  184. if getattr(self, "_task", False):
  185. self._task.cancel()
  186. self._mods[mod.tg_id] = mod
  187. if mod.db.get(__name__, "debugger", False):
  188. self.web_debugger = WebDebugger()
  189. self._task = asyncio.ensure_future(self.queue_poller())
  190. async def queue_poller(self):
  191. while True:
  192. await self.sender()
  193. await asyncio.sleep(3)
  194. def setLevel(self, level: int):
  195. self.lvl = level
  196. def dump(self):
  197. """Return a list of logging entries"""
  198. return self.handledbuffer + self.buffer
  199. def dumps(
  200. self,
  201. lvl: int = 0,
  202. client_id: typing.Optional[int] = None,
  203. ) -> typing.List[str]:
  204. """Return all entries of minimum level as list of strings"""
  205. return [
  206. self.targets[0].format(record)
  207. for record in (self.buffer + self.handledbuffer)
  208. if record.levelno >= lvl
  209. and (not record.hikka_caller or client_id == record.hikka_caller)
  210. ]
  211. async def _show_full_trace(
  212. self,
  213. call: BotInlineCall,
  214. bot: "aiogram.Bot", # type: ignore
  215. item: HikkaException,
  216. ):
  217. chunks = (
  218. item.message
  219. + "\n\n<b>🦝 Locals:</b>\n"
  220. + item.local_vars
  221. + "\n\n"
  222. + "<b>🪐 Full trace:</b>\n"
  223. + item.full_stack
  224. )
  225. chunks = list(utils.smart_split(*telethon.extensions.html.parse(chunks), 4096))
  226. await call.edit(
  227. chunks[0],
  228. reply_markup=self._gen_web_debug_button(item),
  229. )
  230. for chunk in chunks[1:]:
  231. await bot.send_message(chat_id=call.chat_id, text=chunk)
  232. def _gen_web_debug_button(self, item: HikkaException) -> list:
  233. if not item.sysinfo:
  234. return []
  235. if not (url := item.debug_url):
  236. try:
  237. url = self.web_debugger.feed(*item.sysinfo)
  238. except Exception:
  239. url = None
  240. item.debug_url = url
  241. return [
  242. {
  243. "text": "🐞 Web debugger",
  244. "url": url,
  245. }
  246. if self.web_debugger
  247. else {
  248. "text": "🪲 Start debugger",
  249. "callback": self._start_debugger,
  250. "args": (item,),
  251. }
  252. ]
  253. async def _start_debugger(self, call: "InlineCall", item: HikkaException): # type: ignore
  254. if not self.web_debugger:
  255. self.web_debugger = WebDebugger()
  256. await self.web_debugger.proxy_ready.wait()
  257. url = self.web_debugger.feed(*item.sysinfo)
  258. item.debug_url = url
  259. await call.edit(
  260. item.message,
  261. reply_markup=self._gen_web_debug_button(item),
  262. )
  263. await call.answer(
  264. "Web debugger started. You can get PIN using .debugger command. \n⚠️ !DO"
  265. " NOT GIVE IT TO ANYONE! ⚠️",
  266. show_alert=True,
  267. )
  268. async def sender(self):
  269. async with self._send_lock:
  270. self._queue = {
  271. client_id: utils.chunks(
  272. utils.escape_html(
  273. "".join(
  274. [
  275. item[0]
  276. for item in self.tg_buff
  277. if isinstance(item[0], str)
  278. and (
  279. not item[1]
  280. or item[1] == client_id
  281. or self.force_send_all
  282. )
  283. ]
  284. )
  285. ),
  286. 4096,
  287. )
  288. for client_id in self._mods
  289. }
  290. self._exc_queue = {
  291. client_id: [
  292. self._mods[client_id].inline.bot.send_message(
  293. self._mods[client_id]._logchat,
  294. item[0].message,
  295. reply_markup=self._mods[client_id].inline.generate_markup(
  296. [
  297. {
  298. "text": "🪐 Full trace",
  299. "callback": self._show_full_trace,
  300. "args": (
  301. self._mods[client_id].inline.bot,
  302. item[0],
  303. ),
  304. "disable_security": True,
  305. },
  306. *self._gen_web_debug_button(item[0]),
  307. ],
  308. ),
  309. )
  310. for item in self.tg_buff
  311. if isinstance(item[0], HikkaException)
  312. and (not item[1] or item[1] == client_id or self.force_send_all)
  313. ]
  314. for client_id in self._mods
  315. }
  316. for exceptions in self._exc_queue.values():
  317. for exc in exceptions:
  318. await exc
  319. self.tg_buff = []
  320. for client_id in self._mods:
  321. if client_id not in self._queue:
  322. continue
  323. if len(self._queue[client_id]) > 5:
  324. logfile = io.BytesIO(
  325. "".join(self._queue[client_id]).encode("utf-8")
  326. )
  327. logfile.name = "hikka-logs.txt"
  328. logfile.seek(0)
  329. await self._mods[client_id].inline.bot.send_document(
  330. self._mods[client_id]._logchat,
  331. logfile,
  332. caption=(
  333. "<b>🧳 Journals are too big to be sent as separate"
  334. " messages</b>"
  335. ),
  336. )
  337. self._queue[client_id] = []
  338. continue
  339. while self._queue[client_id]:
  340. if chunk := self._queue[client_id].pop(0):
  341. asyncio.ensure_future(
  342. self._mods[client_id].inline.bot.send_message(
  343. self._mods[client_id]._logchat,
  344. f"<code>{chunk}</code>",
  345. disable_notification=True,
  346. )
  347. )
  348. def emit(self, record: logging.LogRecord):
  349. try:
  350. caller = next(
  351. (
  352. frame_info.frame.f_locals["_hikka_client_id_logging_tag"]
  353. for frame_info in inspect.stack()
  354. if isinstance(
  355. getattr(getattr(frame_info, "frame", None), "f_locals", {}).get(
  356. "_hikka_client_id_logging_tag"
  357. ),
  358. int,
  359. )
  360. ),
  361. False,
  362. )
  363. if not isinstance(caller, int):
  364. caller = None
  365. except Exception:
  366. caller = None
  367. record.hikka_caller = caller
  368. if record.levelno >= self.tg_level:
  369. if record.exc_info:
  370. self.tg_buff += [
  371. (
  372. HikkaException.from_exc_info(
  373. *record.exc_info,
  374. stack=record.__dict__.get("stack", None),
  375. ),
  376. caller,
  377. )
  378. ]
  379. else:
  380. self.tg_buff += [
  381. (
  382. _tg_formatter.format(record),
  383. caller,
  384. )
  385. ]
  386. if len(self.buffer) + len(self.handledbuffer) >= self.capacity:
  387. if self.handledbuffer:
  388. del self.handledbuffer[0]
  389. else:
  390. del self.buffer[0]
  391. self.buffer.append(record)
  392. if record.levelno >= self.lvl >= 0:
  393. self.acquire()
  394. try:
  395. for precord in self.buffer:
  396. for target in self.targets:
  397. if record.levelno >= target.level:
  398. target.handle(precord)
  399. self.handledbuffer = (
  400. self.handledbuffer[-(self.capacity - len(self.buffer)) :]
  401. + self.buffer
  402. )
  403. self.buffer = []
  404. finally:
  405. self.release()
  406. _main_formatter = logging.Formatter(
  407. fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  408. datefmt="%Y-%m-%d %H:%M:%S",
  409. style="%",
  410. )
  411. _tg_formatter = logging.Formatter(
  412. fmt="[%(levelname)s] %(name)s: %(message)s\n",
  413. datefmt=None,
  414. style="%",
  415. )
  416. rotating_handler = RotatingFileHandler(
  417. filename="hikka.log",
  418. mode="a",
  419. maxBytes=10 * 1024 * 1024,
  420. backupCount=1,
  421. encoding="utf-8",
  422. delay=0,
  423. )
  424. rotating_handler.setFormatter(_main_formatter)
  425. def init():
  426. handler = logging.StreamHandler()
  427. handler.setLevel(logging.INFO)
  428. handler.setFormatter(_main_formatter)
  429. logging.getLogger().handlers = []
  430. logging.getLogger().addHandler(
  431. TelegramLogsHandler((handler, rotating_handler), 7000)
  432. )
  433. logging.getLogger().setLevel(logging.NOTSET)
  434. logging.getLogger("telethon").setLevel(logging.WARNING)
  435. logging.getLogger("matplotlib").setLevel(logging.WARNING)
  436. logging.getLogger("aiohttp").setLevel(logging.WARNING)
  437. logging.getLogger("aiogram").setLevel(logging.WARNING)
  438. logging.getLogger("pyrogram").setLevel(logging.WARNING)
  439. logging.captureWarnings(True)