dispatcher.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. """Processes incoming events and dispatches them to appropriate handlers"""
  2. # Friendly Telegram (telegram userbot)
  3. # Copyright (C) 2018-2022 The Authors
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. # This program is distributed in the hope that it will be useful,
  9. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. # GNU Affero General Public License for more details.
  12. # You should have received a copy of the GNU Affero General Public License
  13. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  14. # ©️ Dan Gazizullin, 2021-2023
  15. # This file is a part of Hikka Userbot
  16. # 🌐 https://github.com/hikariatama/Hikka
  17. # You can redistribute it and/or modify it under the terms of the GNU AGPLv3
  18. # 🔑 https://www.gnu.org/licenses/agpl-3.0.html
  19. import asyncio
  20. import collections
  21. import contextlib
  22. import copy
  23. import inspect
  24. import logging
  25. import re
  26. import sys
  27. import traceback
  28. import typing
  29. from hikkatl import events
  30. from hikkatl.errors import FloodWaitError, RPCError
  31. from hikkatl.tl.types import Message
  32. from . import main, security, utils
  33. from .database import Database
  34. from .loader import Modules
  35. from .tl_cache import CustomTelegramClient
  36. logger = logging.getLogger(__name__)
  37. # Keys for layout switch
  38. ru_keys = 'ёйцукенгшщзхъфывапролджэячсмитьбю.Ё"№;%:?ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭ/ЯЧСМИТЬБЮ,'
  39. en_keys = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./~@#$%^&QWERTYUIOP{}ASDFGHJKL:\"|ZXCVBNM<>?"
  40. ALL_TAGS = [
  41. "no_commands",
  42. "only_commands",
  43. "out",
  44. "in",
  45. "only_messages",
  46. "editable",
  47. "no_media",
  48. "only_media",
  49. "only_photos",
  50. "only_videos",
  51. "only_audios",
  52. "only_docs",
  53. "only_stickers",
  54. "only_inline",
  55. "only_channels",
  56. "only_groups",
  57. "only_pm",
  58. "no_pm",
  59. "no_channels",
  60. "no_groups",
  61. "no_inline",
  62. "no_stickers",
  63. "no_docs",
  64. "no_audios",
  65. "no_videos",
  66. "no_photos",
  67. "no_forwards",
  68. "no_reply",
  69. "no_mention",
  70. "mention",
  71. "only_reply",
  72. "only_forwards",
  73. "startswith",
  74. "endswith",
  75. "contains",
  76. "regex",
  77. "filter",
  78. "from_id",
  79. "chat_id",
  80. "thumb_url",
  81. "alias",
  82. "aliases",
  83. ]
  84. def _decrement_ratelimit(delay, data, key, severity):
  85. def inner():
  86. data[key] = max(0, data[key] - severity)
  87. asyncio.get_event_loop().call_later(delay, inner)
  88. class CommandDispatcher:
  89. def __init__(
  90. self,
  91. modules: Modules,
  92. client: CustomTelegramClient,
  93. db: Database,
  94. ):
  95. self._modules = modules
  96. self._client = client
  97. self.client = client
  98. self._db = db
  99. self._ratelimit_storage_user = collections.defaultdict(int)
  100. self._ratelimit_storage_chat = collections.defaultdict(int)
  101. self._ratelimit_max_user = db.get(__name__, "ratelimit_max_user", 30)
  102. self._ratelimit_max_chat = db.get(__name__, "ratelimit_max_chat", 100)
  103. self.security = security.SecurityManager(client, db)
  104. self.check_security = self.security.check
  105. self._me = self._client.hikka_me.id
  106. self._cached_usernames = [
  107. (
  108. self._client.hikka_me.username.lower()
  109. if self._client.hikka_me.username
  110. else str(self._client.hikka_me.id)
  111. )
  112. ]
  113. self._cached_usernames.extend(
  114. getattr(self._client.hikka_me, "usernames", None) or []
  115. )
  116. self.raw_handlers = []
  117. async def _handle_ratelimit(self, message: Message, func: callable) -> bool:
  118. if await self.security.check(message, security.OWNER):
  119. return True
  120. func = getattr(func, "__func__", func)
  121. ret = True
  122. chat = self._ratelimit_storage_chat[message.chat_id]
  123. if message.sender_id:
  124. user = self._ratelimit_storage_user[message.sender_id]
  125. severity = (5 if getattr(func, "ratelimit", False) else 2) * (
  126. (user + chat) // 30 + 1
  127. )
  128. user += severity
  129. self._ratelimit_storage_user[message.sender_id] = user
  130. if user > self._ratelimit_max_user:
  131. ret = False
  132. else:
  133. self._ratelimit_storage_chat[message.chat_id] = chat
  134. _decrement_ratelimit(
  135. self._ratelimit_max_user * severity,
  136. self._ratelimit_storage_user,
  137. message.sender_id,
  138. severity,
  139. )
  140. else:
  141. severity = (5 if getattr(func, "ratelimit", False) else 2) * (
  142. chat // 15 + 1
  143. )
  144. chat += severity
  145. if chat > self._ratelimit_max_chat:
  146. ret = False
  147. _decrement_ratelimit(
  148. self._ratelimit_max_chat * severity,
  149. self._ratelimit_storage_chat,
  150. message.chat_id,
  151. severity,
  152. )
  153. return ret
  154. def _handle_grep(self, message: Message) -> Message:
  155. # Allow escaping grep with double stick
  156. if "||grep" in message.text or "|| grep" in message.text:
  157. message.raw_text = re.sub(r"\|\| ?grep", "| grep", message.raw_text)
  158. message.text = re.sub(r"\|\| ?grep", "| grep", message.text)
  159. message.message = re.sub(r"\|\| ?grep", "| grep", message.message)
  160. return message
  161. grep = False
  162. if not re.search(r".+\| ?grep (.+)", message.raw_text):
  163. return message
  164. grep = re.search(r".+\| ?grep (.+)", message.raw_text).group(1)
  165. message.text = re.sub(r"\| ?grep.+", "", message.text)
  166. message.raw_text = re.sub(r"\| ?grep.+", "", message.raw_text)
  167. message.message = re.sub(r"\| ?grep.+", "", message.message)
  168. ungrep = False
  169. if re.search(r"-v (.+)", grep):
  170. ungrep = re.search(r"-v (.+)", grep).group(1)
  171. grep = re.sub(r"(.+) -v .+", r"\g<1>", grep)
  172. grep = utils.escape_html(grep).strip() if grep else False
  173. ungrep = utils.escape_html(ungrep).strip() if ungrep else False
  174. old_edit = message.edit
  175. old_reply = message.reply
  176. old_respond = message.respond
  177. def process_text(text: str) -> str:
  178. nonlocal grep, ungrep
  179. res = []
  180. for line in text.split("\n"):
  181. if (
  182. grep
  183. and grep in utils.remove_html(line)
  184. and (not ungrep or ungrep not in utils.remove_html(line))
  185. ):
  186. res.append(
  187. utils.remove_html(line, escape=True).replace(
  188. grep, f"<u>{grep}</u>"
  189. )
  190. )
  191. if not grep and ungrep and ungrep not in utils.remove_html(line):
  192. res.append(utils.remove_html(line, escape=True))
  193. cont = (
  194. (f"contain <b>{grep}</b>" if grep else "")
  195. + (" and" if grep and ungrep else "")
  196. + ((" do not contain <b>" + ungrep + "</b>") if ungrep else "")
  197. )
  198. if res:
  199. text = f"<i>💬 Lines that {cont}:</i>\n" + "\n".join(res)
  200. else:
  201. text = f"💬 <i>No lines that {cont}</i>"
  202. return text
  203. async def my_edit(text, *args, **kwargs):
  204. text = process_text(text)
  205. kwargs["parse_mode"] = "HTML"
  206. return await old_edit(text, *args, **kwargs)
  207. async def my_reply(text, *args, **kwargs):
  208. text = process_text(text)
  209. kwargs["parse_mode"] = "HTML"
  210. return await old_reply(text, *args, **kwargs)
  211. async def my_respond(text, *args, **kwargs):
  212. text = process_text(text)
  213. kwargs["parse_mode"] = "HTML"
  214. kwargs.setdefault("reply_to", utils.get_topic(message))
  215. return await old_respond(text, *args, **kwargs)
  216. message.edit = my_edit
  217. message.reply = my_reply
  218. message.respond = my_respond
  219. message.hikka_grepped = True
  220. return message
  221. async def _handle_command(
  222. self,
  223. event: typing.Union[events.NewMessage, events.MessageDeleted],
  224. watcher: bool = False,
  225. ) -> typing.Union[bool, typing.Tuple[Message, str, str, callable]]:
  226. if not hasattr(event, "message") or not hasattr(event.message, "message"):
  227. return False
  228. prefix = self._db.get(main.__name__, "command_prefix", False) or "."
  229. change = str.maketrans(ru_keys + en_keys, en_keys + ru_keys)
  230. message = utils.censor(event.message)
  231. if not event.message.message:
  232. return False
  233. if (
  234. message.out
  235. and len(message.message) > 2
  236. and (
  237. message.message.startswith(prefix * 2)
  238. and any(s != prefix for s in message.message)
  239. or message.message.startswith(str.translate(prefix * 2, change))
  240. and any(s != str.translate(prefix, change) for s in message.message)
  241. )
  242. ):
  243. # Allow escaping commands using .'s
  244. if not watcher:
  245. await message.edit(
  246. message.message[1:],
  247. parse_mode=lambda s: (
  248. s,
  249. utils.relocate_entities(message.entities, -1, message.message)
  250. or (),
  251. ),
  252. )
  253. return False
  254. if (
  255. event.message.message.startswith(str.translate(prefix, change))
  256. and str.translate(prefix, change) != prefix
  257. ):
  258. message.message = str.translate(message.message, change)
  259. message.text = str.translate(message.text, change)
  260. elif not event.message.message.startswith(prefix):
  261. return False
  262. if (
  263. event.sticker
  264. or event.dice
  265. or event.audio
  266. or event.via_bot_id
  267. or getattr(event, "reactions", False)
  268. ):
  269. return False
  270. blacklist_chats = self._db.get(main.__name__, "blacklist_chats", [])
  271. whitelist_chats = self._db.get(main.__name__, "whitelist_chats", [])
  272. whitelist_modules = self._db.get(main.__name__, "whitelist_modules", [])
  273. if utils.get_chat_id(message) in blacklist_chats or (
  274. whitelist_chats and utils.get_chat_id(message) not in whitelist_chats
  275. ):
  276. return False
  277. if not message.message or len(message.message) == 1:
  278. return False # Message is just the prefix
  279. initiator = getattr(event, "sender_id", 0)
  280. command = message.message[1:].strip().split(maxsplit=1)[0]
  281. tag = command.split("@", maxsplit=1)
  282. if len(tag) == 2:
  283. if tag[1] == "me":
  284. if not message.out:
  285. return False
  286. elif tag[1].lower() not in self._cached_usernames:
  287. return False
  288. elif (
  289. event.out
  290. or event.mentioned
  291. and event.message is not None
  292. and event.message.message is not None
  293. and not any(
  294. f"@{username}" not in command.lower()
  295. for username in self._cached_usernames
  296. )
  297. ):
  298. pass
  299. elif (
  300. not event.is_private
  301. and not self._db.get(main.__name__, "no_nickname", False)
  302. and command not in self._db.get(main.__name__, "nonickcmds", [])
  303. and initiator not in self._db.get(main.__name__, "nonickusers", [])
  304. and not self.security.check_tsec(initiator, command)
  305. and utils.get_chat_id(event)
  306. not in self._db.get(main.__name__, "nonickchats", [])
  307. ):
  308. return False
  309. txt, func = self._modules.dispatch(tag[0])
  310. if (
  311. not func
  312. or not await self._handle_ratelimit(message, func)
  313. or not await self.security.check(
  314. message,
  315. func,
  316. usernames=self._cached_usernames,
  317. )
  318. ):
  319. return False
  320. if message.is_channel and message.edit_date and not message.is_group:
  321. async for event in self._client.iter_admin_log(
  322. utils.get_chat_id(message),
  323. limit=10,
  324. edit=True,
  325. ):
  326. if event.action.prev_message.id == message.id:
  327. if event.user_id != self._client.tg_id:
  328. logger.debug("Ignoring edit in channel")
  329. return False
  330. break
  331. if (
  332. message.is_channel
  333. and message.is_group
  334. and message.chat.title.startswith("hikka-")
  335. and message.chat.title != "hikka-logs"
  336. ):
  337. if not watcher:
  338. logger.warning("Ignoring message in datachat \\ logging chat")
  339. return False
  340. message.message = prefix + txt + message.message[len(prefix + command) :]
  341. if (
  342. f"{str(utils.get_chat_id(message))}.{func.__self__.__module__}"
  343. in blacklist_chats
  344. or whitelist_modules
  345. and f"{utils.get_chat_id(message)}.{func.__self__.__module__}"
  346. not in whitelist_modules
  347. ):
  348. return False
  349. if await self._handle_tags(event, func):
  350. return False
  351. if self._db.get(main.__name__, "grep", False) and not watcher:
  352. message = self._handle_grep(message)
  353. return message, prefix, txt, func
  354. async def handle_raw(self, event: events.Raw):
  355. """Handle raw events."""
  356. for handler in self.raw_handlers:
  357. if isinstance(event, tuple(handler.updates)):
  358. try:
  359. await handler(event)
  360. except Exception as e:
  361. logger.exception("Error in raw handler %s: %s", handler.id, e)
  362. async def handle_command(
  363. self,
  364. event: typing.Union[events.NewMessage, events.MessageDeleted],
  365. ):
  366. """Handle all commands"""
  367. message = await self._handle_command(event)
  368. if not message:
  369. return
  370. message, _, _, func = message
  371. asyncio.ensure_future(
  372. self.future_dispatcher(
  373. func,
  374. message,
  375. self.command_exc,
  376. )
  377. )
  378. async def command_exc(self, _, message: Message):
  379. """Handle command exceptions."""
  380. exc = sys.exc_info()[1]
  381. logger.exception("Command failed", extra={"stack": inspect.stack()})
  382. if isinstance(exc, RPCError):
  383. if isinstance(exc, FloodWaitError):
  384. hours = exc.seconds // 3600
  385. minutes = (exc.seconds % 3600) // 60
  386. seconds = exc.seconds % 60
  387. hours = f"{hours} hours, " if hours else ""
  388. minutes = f"{minutes} minutes, " if minutes else ""
  389. seconds = f"{seconds} seconds" if seconds else ""
  390. fw_time = f"{hours}{minutes}{seconds}"
  391. txt = (
  392. self._client.loader.lookup("translations")
  393. .strings("fw_error")
  394. .format(
  395. utils.escape_html(message.message),
  396. fw_time,
  397. type(exc.request).__name__,
  398. )
  399. )
  400. else:
  401. txt = (
  402. "<emoji document_id=5877477244938489129>🚫</emoji> <b>Call"
  403. f" </b><code>{utils.escape_html(message.message)}</code><b> failed"
  404. " due to RPC (Telegram) error:</b>"
  405. f" <code>{utils.escape_html(str(exc))}</code>"
  406. )
  407. txt = (
  408. self._client.loader.lookup("translations")
  409. .strings("rpc_error")
  410. .format(
  411. utils.escape_html(message.message),
  412. utils.escape_html(str(exc)),
  413. )
  414. )
  415. else:
  416. if not self._db.get(main.__name__, "inlinelogs", True):
  417. txt = (
  418. "<emoji document_id=5877477244938489129>🚫</emoji><b> Call</b>"
  419. f" <code>{utils.escape_html(message.message)}</code><b>"
  420. " failed!</b>"
  421. )
  422. else:
  423. exc = "\n".join(traceback.format_exc().splitlines()[1:])
  424. txt = (
  425. "<emoji document_id=5877477244938489129>🚫</emoji><b> Call</b>"
  426. f" <code>{utils.escape_html(message.message)}</code><b>"
  427. " failed!</b>\n\n<b>🧾"
  428. f" Logs:</b>\n<code>{utils.escape_html(exc)}</code>"
  429. )
  430. with contextlib.suppress(Exception):
  431. await (message.edit if message.out else message.reply)(txt)
  432. async def watcher_exc(self, *_):
  433. logger.exception("Error running watcher", extra={"stack": inspect.stack()})
  434. async def _handle_tags(
  435. self,
  436. event: typing.Union[events.NewMessage, events.MessageDeleted],
  437. func: callable,
  438. ) -> bool:
  439. return bool(await self._handle_tags_ext(event, func))
  440. async def _handle_tags_ext(
  441. self,
  442. event: typing.Union[events.NewMessage, events.MessageDeleted],
  443. func: callable,
  444. ) -> str:
  445. """
  446. Handle tags.
  447. :param event: The event to handle.
  448. :param func: The function to handle.
  449. :return: The reason for the tag to fail.
  450. """
  451. m = event if isinstance(event, Message) else getattr(event, "message", event)
  452. reverse_mapping = {
  453. "out": lambda: getattr(m, "out", True),
  454. "in": lambda: not getattr(m, "out", True),
  455. "only_messages": lambda: isinstance(m, Message),
  456. "editable": (
  457. lambda: not getattr(m, "out", False)
  458. and not getattr(m, "fwd_from", False)
  459. and not getattr(m, "sticker", False)
  460. and not getattr(m, "via_bot_id", False)
  461. ),
  462. "no_media": lambda: (
  463. not isinstance(m, Message) or not getattr(m, "media", False)
  464. ),
  465. "only_media": lambda: isinstance(m, Message) and getattr(m, "media", False),
  466. "only_photos": lambda: utils.mime_type(m).startswith("image/"),
  467. "only_videos": lambda: utils.mime_type(m).startswith("video/"),
  468. "only_audios": lambda: utils.mime_type(m).startswith("audio/"),
  469. "only_stickers": lambda: getattr(m, "sticker", False),
  470. "only_docs": lambda: getattr(m, "document", False),
  471. "only_inline": lambda: getattr(m, "via_bot_id", False),
  472. "only_channels": lambda: (
  473. getattr(m, "is_channel", False) and not getattr(m, "is_group", False)
  474. ),
  475. "no_channels": lambda: not getattr(m, "is_channel", False),
  476. "no_groups": (
  477. lambda: not getattr(m, "is_group", False)
  478. or getattr(m, "private", False)
  479. or getattr(m, "is_channel", False)
  480. ),
  481. "only_groups": (
  482. lambda: getattr(m, "is_group", False)
  483. or not getattr(m, "private", False)
  484. and not getattr(m, "is_channel", False)
  485. ),
  486. "no_pm": lambda: not getattr(m, "private", False),
  487. "only_pm": lambda: getattr(m, "private", False),
  488. "no_inline": lambda: not getattr(m, "via_bot_id", False),
  489. "no_stickers": lambda: not getattr(m, "sticker", False),
  490. "no_docs": lambda: not getattr(m, "document", False),
  491. "no_audios": lambda: not utils.mime_type(m).startswith("audio/"),
  492. "no_videos": lambda: not utils.mime_type(m).startswith("video/"),
  493. "no_photos": lambda: not utils.mime_type(m).startswith("image/"),
  494. "no_forwards": lambda: not getattr(m, "fwd_from", False),
  495. "no_reply": lambda: not getattr(m, "reply_to_msg_id", False),
  496. "only_forwards": lambda: getattr(m, "fwd_from", False),
  497. "only_reply": lambda: getattr(m, "reply_to_msg_id", False),
  498. "mention": lambda: getattr(m, "mentioned", False),
  499. "no_mention": lambda: not getattr(m, "mentioned", False),
  500. "startswith": lambda: (
  501. isinstance(m, Message) and m.raw_text.startswith(func.startswith)
  502. ),
  503. "endswith": lambda: (
  504. isinstance(m, Message) and m.raw_text.endswith(func.endswith)
  505. ),
  506. "contains": lambda: isinstance(m, Message) and func.contains in m.raw_text,
  507. "filter": lambda: callable(func.filter) and func.filter(m),
  508. "from_id": lambda: getattr(m, "sender_id", None) == func.from_id,
  509. "chat_id": lambda: utils.get_chat_id(m) == (
  510. func.chat_id
  511. if not str(func.chat_id).startswith("-100")
  512. else int(str(func.chat_id)[4:])
  513. ),
  514. "regex": lambda: (
  515. isinstance(m, Message) and re.search(func.regex, m.raw_text)
  516. ),
  517. }
  518. return (
  519. "no_commands"
  520. if getattr(func, "no_commands", False)
  521. and await self._handle_command(event, watcher=True)
  522. else (
  523. "only_commands"
  524. if getattr(func, "only_commands", False)
  525. and not await self._handle_command(event, watcher=True)
  526. else next(
  527. (
  528. tag
  529. for tag in ALL_TAGS
  530. if getattr(func, tag, False)
  531. and tag in reverse_mapping
  532. and not reverse_mapping[tag]()
  533. ),
  534. None,
  535. )
  536. )
  537. )
  538. async def handle_incoming(
  539. self,
  540. event: typing.Union[events.NewMessage, events.MessageDeleted],
  541. ):
  542. """Handle all incoming messages"""
  543. message = utils.censor(getattr(event, "message", event))
  544. blacklist_chats = self._db.get(main.__name__, "blacklist_chats", [])
  545. whitelist_chats = self._db.get(main.__name__, "whitelist_chats", [])
  546. whitelist_modules = self._db.get(main.__name__, "whitelist_modules", [])
  547. if utils.get_chat_id(message) in blacklist_chats or (
  548. whitelist_chats and utils.get_chat_id(message) not in whitelist_chats
  549. ):
  550. logger.debug("Message is blacklisted")
  551. return
  552. for func in self._modules.watchers:
  553. bl = self._db.get(main.__name__, "disabled_watchers", {})
  554. modname = str(func.__self__.__class__.strings["name"])
  555. if (
  556. modname in bl
  557. and isinstance(message, Message)
  558. and (
  559. "*" in bl[modname]
  560. or utils.get_chat_id(message) in bl[modname]
  561. or "only_chats" in bl[modname]
  562. and message.is_private
  563. or "only_pm" in bl[modname]
  564. and not message.is_private
  565. or "out" in bl[modname]
  566. and not message.out
  567. or "in" in bl[modname]
  568. and message.out
  569. )
  570. or f"{str(utils.get_chat_id(message))}.{func.__self__.__module__}"
  571. in blacklist_chats
  572. or whitelist_modules
  573. and f"{str(utils.get_chat_id(message))}.{func.__self__.__module__}"
  574. not in whitelist_modules
  575. or await self._handle_tags(event, func)
  576. ):
  577. logger.debug(
  578. "Ignored watcher of module %s because of %s",
  579. modname,
  580. await self._handle_tags_ext(event, func),
  581. )
  582. continue
  583. # Avoid weird AttributeErrors in weird dochub modules by settings placeholder
  584. # of attributes
  585. for placeholder in {"text", "raw_text", "out"}:
  586. try:
  587. if not hasattr(message, placeholder):
  588. setattr(message, placeholder, "")
  589. except UnicodeDecodeError:
  590. pass
  591. # Run watcher via ensure_future so in case user has a lot
  592. # of watchers with long actions, they can run simultaneously
  593. asyncio.ensure_future(
  594. self.future_dispatcher(
  595. func,
  596. message,
  597. self.watcher_exc,
  598. )
  599. )
  600. async def future_dispatcher(
  601. self,
  602. func: callable,
  603. message: Message,
  604. exception_handler: callable,
  605. *args,
  606. ):
  607. # Will be used to determine, which client caused logging messages
  608. # parsed via inspect.stack()
  609. _hikka_client_id_logging_tag = copy.copy(self.client.tg_id) # noqa: F841
  610. try:
  611. await func(message)
  612. except Exception as e:
  613. await exception_handler(e, message, *args)