log.py 17 KB

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