utils.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  1. # ©️ Dan Gazizullin, 2021-2023
  2. # This file is a part of Hikka Userbot
  3. # 🌐 https://github.com/hikariatama/Hikka
  4. # You can redistribute it and/or modify it under the terms of the GNU AGPLv3
  5. # 🔑 https://www.gnu.org/licenses/agpl-3.0.html
  6. import asyncio
  7. import contextlib
  8. import functools
  9. import io
  10. import logging
  11. import os
  12. import re
  13. import typing
  14. from copy import deepcopy
  15. from urllib.parse import urlparse
  16. from aiogram.types import (
  17. CallbackQuery,
  18. InlineKeyboardButton,
  19. InlineKeyboardMarkup,
  20. InputFile,
  21. InputMediaAnimation,
  22. InputMediaAudio,
  23. InputMediaDocument,
  24. InputMediaPhoto,
  25. InputMediaVideo,
  26. )
  27. from aiogram.utils.exceptions import (
  28. BadRequest,
  29. MessageIdInvalid,
  30. MessageNotModified,
  31. RetryAfter,
  32. )
  33. from telethon.utils import resolve_inline_message_id
  34. from .. import utils
  35. from ..types import HikkaReplyMarkup
  36. from .types import InlineCall, InlineUnit
  37. logger = logging.getLogger(__name__)
  38. class Utils(InlineUnit):
  39. def _generate_markup(
  40. self,
  41. markup_obj: typing.Optional[typing.Union[HikkaReplyMarkup, str]],
  42. ) -> typing.Optional[InlineKeyboardMarkup]:
  43. """Generate markup for form or list of `dict`s"""
  44. if not markup_obj:
  45. return None
  46. if isinstance(markup_obj, InlineKeyboardMarkup):
  47. return markup_obj
  48. markup = InlineKeyboardMarkup()
  49. map_ = (
  50. self._units[markup_obj]["buttons"]
  51. if isinstance(markup_obj, str)
  52. else markup_obj
  53. )
  54. map_ = self._normalize_markup(map_)
  55. setup_callbacks = False
  56. for row in map_:
  57. for button in row:
  58. if not isinstance(button, dict):
  59. logger.error(
  60. "Button %s is not a `dict`, but `%s` in %s",
  61. button,
  62. type(button),
  63. map_,
  64. )
  65. return None
  66. if "callback" not in button:
  67. if button.get("action") == "close":
  68. button["callback"] = self._close_unit_handler
  69. if button.get("action") == "unload":
  70. button["callback"] = self._unload_unit_handler
  71. if button.get("action") == "answer":
  72. if not button.get("message"):
  73. logger.error(
  74. "Button %s has no `message` to answer with", button
  75. )
  76. return None
  77. button["callback"] = functools.partial(
  78. self._answer_unit_handler,
  79. show_alert=button.get("show_alert", False),
  80. text=button["message"],
  81. )
  82. if "callback" in button and "_callback_data" not in button:
  83. button["_callback_data"] = utils.rand(30)
  84. setup_callbacks = True
  85. if "input" in button and "_switch_query" not in button:
  86. button["_switch_query"] = utils.rand(10)
  87. for row in map_:
  88. line = []
  89. for button in row:
  90. try:
  91. if "url" in button:
  92. if not utils.check_url(button["url"]):
  93. logger.warning(
  94. "Button have not been added to form, "
  95. "because its url is invalid"
  96. )
  97. continue
  98. line += [
  99. InlineKeyboardButton(
  100. button["text"],
  101. url=button["url"],
  102. )
  103. ]
  104. elif "callback" in button:
  105. line += [
  106. InlineKeyboardButton(
  107. button["text"],
  108. callback_data=button["_callback_data"],
  109. )
  110. ]
  111. if setup_callbacks:
  112. self._custom_map[button["_callback_data"]] = {
  113. "handler": button["callback"],
  114. **(
  115. {"always_allow": button["always_allow"]}
  116. if button.get("always_allow", False)
  117. else {}
  118. ),
  119. **(
  120. {"args": button["args"]}
  121. if button.get("args", False)
  122. else {}
  123. ),
  124. **(
  125. {"kwargs": button["kwargs"]}
  126. if button.get("kwargs", False)
  127. else {}
  128. ),
  129. **(
  130. {"force_me": True}
  131. if button.get("force_me", False)
  132. else {}
  133. ),
  134. **(
  135. {"disable_security": True}
  136. if button.get("disable_security", False)
  137. else {}
  138. ),
  139. }
  140. elif "input" in button:
  141. line += [
  142. InlineKeyboardButton(
  143. button["text"],
  144. switch_inline_query_current_chat=button["_switch_query"]
  145. + " ",
  146. )
  147. ]
  148. elif "data" in button:
  149. line += [
  150. InlineKeyboardButton(
  151. button["text"],
  152. callback_data=button["data"],
  153. )
  154. ]
  155. elif "switch_inline_query_current_chat" in button:
  156. line += [
  157. InlineKeyboardButton(
  158. button["text"],
  159. switch_inline_query_current_chat=button[
  160. "switch_inline_query_current_chat"
  161. ],
  162. )
  163. ]
  164. elif "switch_inline_query" in button:
  165. line += [
  166. InlineKeyboardButton(
  167. button["text"],
  168. switch_inline_query_current_chat=button[
  169. "switch_inline_query"
  170. ],
  171. )
  172. ]
  173. else:
  174. logger.warning(
  175. "Button have not been added to "
  176. "form, because it is not structured "
  177. "properly. %s",
  178. button,
  179. )
  180. except KeyError:
  181. logger.exception(
  182. "Error while forming markup! Probably, you "
  183. "passed wrong type combination for button. "
  184. "Contact developer of module."
  185. )
  186. return False
  187. markup.row(*line)
  188. return markup
  189. generate_markup = _generate_markup
  190. async def _close_unit_handler(self, call: InlineCall):
  191. await call.delete()
  192. async def _unload_unit_handler(self, call: InlineCall):
  193. await call.unload()
  194. async def _answer_unit_handler(self, call: InlineCall, text: str, show_alert: bool):
  195. await call.answer(text, show_alert=show_alert)
  196. async def check_inline_security(self, *, func: typing.Callable, user: int) -> bool:
  197. """Checks if user with id `user` is allowed to run function `func`"""
  198. return await self._client.dispatcher.security.check(
  199. message=None,
  200. func=func,
  201. user_id=user,
  202. )
  203. def _find_caller_sec_map(self) -> typing.Optional[typing.Callable[[], int]]:
  204. try:
  205. caller = utils.find_caller()
  206. if not caller:
  207. return None
  208. logger.debug("Found caller: %s", caller)
  209. return lambda: self._client.dispatcher.security.get_flags(
  210. getattr(caller, "__self__", caller),
  211. )
  212. except Exception:
  213. logger.debug("Can't parse security mask in form", exc_info=True)
  214. return None
  215. def _normalize_markup(
  216. self, reply_markup: HikkaReplyMarkup
  217. ) -> typing.List[typing.List[typing.Dict[str, typing.Any]]]:
  218. if isinstance(reply_markup, dict):
  219. return [[reply_markup]]
  220. if isinstance(reply_markup, list) and any(
  221. isinstance(i, dict) for i in reply_markup
  222. ):
  223. return [reply_markup]
  224. return reply_markup
  225. def sanitise_text(self, text: str) -> str:
  226. """
  227. Replaces all animated emojis in text with normal ones,
  228. bc aiogram doesn't support them
  229. :param text: text to sanitise
  230. :return: sanitised text
  231. """
  232. return re.sub(r"</?emoji.*?>", "", text)
  233. async def _edit_unit(
  234. self,
  235. text: typing.Optional[str] = None,
  236. reply_markup: typing.Optional[HikkaReplyMarkup] = None,
  237. *,
  238. photo: typing.Optional[str] = None,
  239. file: typing.Optional[str] = None,
  240. video: typing.Optional[str] = None,
  241. audio: typing.Optional[typing.Union[dict, str]] = None,
  242. gif: typing.Optional[str] = None,
  243. mime_type: typing.Optional[str] = None,
  244. force_me: typing.Optional[bool] = None,
  245. disable_security: typing.Optional[bool] = None,
  246. always_allow: typing.Optional[typing.List[int]] = None,
  247. disable_web_page_preview: bool = True,
  248. query: typing.Optional[CallbackQuery] = None,
  249. unit_id: typing.Optional[str] = None,
  250. inline_message_id: typing.Optional[str] = None,
  251. chat_id: typing.Optional[int] = None,
  252. message_id: typing.Optional[int] = None,
  253. ) -> bool:
  254. """
  255. Edits unit message
  256. :param text: Text of message
  257. :param reply_markup: Inline keyboard
  258. :param photo: Url to a valid photo to attach to message
  259. :param file: Url to a valid file to attach to message
  260. :param video: Url to a valid video to attach to message
  261. :param audio: Url to a valid audio to attach to message
  262. :param gif: Url to a valid gif to attach to message
  263. :param mime_type: Mime type of file
  264. :param force_me: Allow only userbot owner to interact with buttons
  265. :param disable_security: Disable security check for buttons
  266. :param always_allow: List of user ids, which will always be allowed
  267. :param disable_web_page_preview: Disable web page preview
  268. :param query: Callback query
  269. :return: Status of edit
  270. """
  271. reply_markup = self._validate_markup(reply_markup) or []
  272. if text is not None and not isinstance(text, str):
  273. logger.error(
  274. "Invalid type for `text`. Expected `str`, got `%s`", type(text)
  275. )
  276. return False
  277. if file and not mime_type:
  278. logger.error(
  279. "You must pass `mime_type` along with `file` field\n"
  280. "It may be either 'application/zip' or 'application/pdf'"
  281. )
  282. return False
  283. if isinstance(audio, str):
  284. audio = {"url": audio}
  285. if isinstance(text, str):
  286. text = self.sanitise_text(text)
  287. media_params = [
  288. photo is None,
  289. gif is None,
  290. file is None,
  291. video is None,
  292. audio is None,
  293. ]
  294. if media_params.count(False) > 1:
  295. logger.error("You passed two or more exclusive parameters simultaneously")
  296. return False
  297. if unit_id is not None and unit_id in self._units:
  298. unit = self._units[unit_id]
  299. unit["buttons"] = reply_markup
  300. if isinstance(force_me, bool):
  301. unit["force_me"] = force_me
  302. if isinstance(disable_security, bool):
  303. unit["disable_security"] = disable_security
  304. if isinstance(always_allow, list):
  305. unit["always_allow"] = always_allow
  306. else:
  307. unit = {}
  308. if not chat_id or not message_id:
  309. inline_message_id = (
  310. inline_message_id
  311. or unit.get("inline_message_id", False)
  312. or getattr(query, "inline_message_id", None)
  313. )
  314. if not chat_id and not message_id and not inline_message_id:
  315. logger.warning(
  316. "Attempted to edit message with no `inline_message_id`. "
  317. "Possible reasons:\n"
  318. "- Form was sent without buttons and due to "
  319. "the limits of Telegram API can't be edited\n"
  320. "- There is an in-userbot error, which you should report"
  321. )
  322. return False
  323. # If passed `photo` is gif
  324. try:
  325. path = urlparse(photo).path
  326. ext = os.path.splitext(path)[1]
  327. except Exception:
  328. ext = None
  329. if photo is not None and ext in {".gif", ".mp4"}:
  330. gif = deepcopy(photo)
  331. photo = None
  332. media = next(
  333. (media for media in [photo, file, video, audio, gif] if media), None
  334. )
  335. if isinstance(media, bytes):
  336. media = io.BytesIO(media)
  337. media.name = "upload.mp4"
  338. if isinstance(media, io.BytesIO):
  339. media = InputFile(media)
  340. if file:
  341. media = InputMediaDocument(media, caption=text, parse_mode="HTML")
  342. elif photo:
  343. media = InputMediaPhoto(media, caption=text, parse_mode="HTML")
  344. elif audio:
  345. if isinstance(audio, dict):
  346. media = InputMediaAudio(
  347. audio["url"],
  348. title=audio.get("title"),
  349. performer=audio.get("performer"),
  350. duration=audio.get("duration"),
  351. caption=text,
  352. parse_mode="HTML",
  353. )
  354. else:
  355. media = InputMediaAudio(
  356. audio,
  357. caption=text,
  358. parse_mode="HTML",
  359. )
  360. elif video:
  361. media = InputMediaVideo(media, caption=text, parse_mode="HTML")
  362. elif gif:
  363. media = InputMediaAnimation(media, caption=text, parse_mode="HTML")
  364. if media is None and text is None and reply_markup:
  365. try:
  366. await self.bot.edit_message_reply_markup(
  367. **(
  368. {"inline_message_id": inline_message_id}
  369. if inline_message_id
  370. else {"chat_id": chat_id, "message_id": message_id}
  371. ),
  372. reply_markup=self.generate_markup(reply_markup),
  373. )
  374. except Exception:
  375. return False
  376. return True
  377. if media is None and text is None:
  378. logger.error("You must pass either `text` or `media` or `reply_markup`")
  379. return False
  380. if media is None:
  381. try:
  382. await self.bot.edit_message_text(
  383. text,
  384. **(
  385. {"inline_message_id": inline_message_id}
  386. if inline_message_id
  387. else {"chat_id": chat_id, "message_id": message_id}
  388. ),
  389. disable_web_page_preview=disable_web_page_preview,
  390. reply_markup=self.generate_markup(
  391. reply_markup
  392. if isinstance(reply_markup, list)
  393. else unit.get("buttons", [])
  394. ),
  395. )
  396. except MessageNotModified:
  397. if query:
  398. with contextlib.suppress(Exception):
  399. await query.answer()
  400. return False
  401. except RetryAfter as e:
  402. logger.info("Sleeping %ss on aiogram FloodWait...", e.timeout)
  403. await asyncio.sleep(e.timeout)
  404. return await self._edit_unit(**utils.get_kwargs())
  405. except MessageIdInvalid:
  406. with contextlib.suppress(Exception):
  407. await query.answer(
  408. "I should have edited some message, but it is deleted :("
  409. )
  410. return False
  411. except BadRequest as e:
  412. if "There is no text in the message to edit" not in str(e):
  413. raise
  414. try:
  415. await self.bot.edit_message_caption(
  416. caption=text,
  417. **(
  418. {"inline_message_id": inline_message_id}
  419. if inline_message_id
  420. else {"chat_id": chat_id, "message_id": message_id}
  421. ),
  422. reply_markup=self.generate_markup(
  423. reply_markup
  424. if isinstance(reply_markup, list)
  425. else unit.get("buttons", [])
  426. ),
  427. )
  428. except Exception:
  429. return False
  430. else:
  431. return True
  432. else:
  433. return True
  434. try:
  435. await self.bot.edit_message_media(
  436. **(
  437. {"inline_message_id": inline_message_id}
  438. if inline_message_id
  439. else {"chat_id": chat_id, "message_id": message_id}
  440. ),
  441. media=media,
  442. reply_markup=self.generate_markup(
  443. reply_markup
  444. if isinstance(reply_markup, list)
  445. else unit.get("buttons", [])
  446. ),
  447. )
  448. except RetryAfter as e:
  449. logger.info("Sleeping %ss on aiogram FloodWait...", e.timeout)
  450. await asyncio.sleep(e.timeout)
  451. return await self._edit_unit(**utils.get_kwargs())
  452. except MessageIdInvalid:
  453. with contextlib.suppress(Exception):
  454. await query.answer(
  455. "I should have edited some message, but it is deleted :("
  456. )
  457. return False
  458. else:
  459. return True
  460. async def _delete_unit_message(
  461. self,
  462. call: typing.Optional[CallbackQuery] = None,
  463. unit_id: typing.Optional[str] = None,
  464. chat_id: typing.Optional[int] = None,
  465. message_id: typing.Optional[int] = None,
  466. ) -> bool:
  467. """Params `self`, `unit_id` are for internal use only, do not try to pass them
  468. """
  469. if getattr(getattr(call, "message", None), "chat", None):
  470. try:
  471. await self.bot.delete_message(
  472. chat_id=call.message.chat.id,
  473. message_id=call.message.message_id,
  474. )
  475. except Exception:
  476. return False
  477. return True
  478. if chat_id and message_id:
  479. try:
  480. await self.bot.delete_message(chat_id=chat_id, message_id=message_id)
  481. except Exception:
  482. return False
  483. return True
  484. if not unit_id and hasattr(call, "unit_id") and call.unit_id:
  485. unit_id = call.unit_id
  486. try:
  487. message_id, peer, _, _ = resolve_inline_message_id(
  488. self._units[unit_id]["inline_message_id"]
  489. )
  490. await self._client.delete_messages(peer, [message_id])
  491. await self._unload_unit(unit_id)
  492. except Exception:
  493. return False
  494. return True
  495. async def _unload_unit(self, unit_id: str) -> bool:
  496. """Params `self`, `unit_id` are for internal use only, do not try to pass them
  497. """
  498. try:
  499. if "on_unload" in self._units[unit_id] and callable(
  500. self._units[unit_id]["on_unload"]
  501. ):
  502. self._units[unit_id]["on_unload"]()
  503. if unit_id in self._units:
  504. del self._units[unit_id]
  505. else:
  506. return False
  507. except Exception:
  508. return False
  509. return True
  510. def build_pagination(
  511. self,
  512. callback: typing.Callable[[int], typing.Awaitable[typing.Any]],
  513. total_pages: int,
  514. unit_id: typing.Optional[str] = None,
  515. current_page: typing.Optional[int] = None,
  516. ) -> typing.List[typing.List[typing.Dict[str, typing.Any]]]:
  517. # Based on https://github.com/pystorage/pykeyboard/blob/master/pykeyboard/inline_pagination_keyboard.py#L4
  518. if current_page is None:
  519. current_page = self._units[unit_id]["current_index"] + 1
  520. if total_pages <= 5:
  521. return [
  522. [
  523. {"text": number, "args": (number - 1,), "callback": callback}
  524. if number != current_page
  525. else {
  526. "text": f"· {number} ·",
  527. "args": (number - 1,),
  528. "callback": callback,
  529. }
  530. for number in range(1, total_pages + 1)
  531. ]
  532. ]
  533. if current_page <= 3:
  534. return [
  535. [
  536. {
  537. "text": f"· {number} ·",
  538. "args": (number - 1,),
  539. "callback": callback,
  540. }
  541. if number == current_page
  542. else {
  543. "text": f"{number} ›",
  544. "args": (number - 1,),
  545. "callback": callback,
  546. }
  547. if number == 4
  548. else {
  549. "text": f"{total_pages} »",
  550. "args": (total_pages - 1,),
  551. "callback": callback,
  552. }
  553. if number == 5
  554. else {
  555. "text": number,
  556. "args": (number - 1,),
  557. "callback": callback,
  558. }
  559. for number in range(1, 6)
  560. ]
  561. ]
  562. if current_page > total_pages - 3:
  563. return [
  564. [
  565. {"text": "« 1", "args": (0,), "callback": callback},
  566. {
  567. "text": f"‹ {total_pages - 3}",
  568. "args": (total_pages - 4,),
  569. "callback": callback,
  570. },
  571. ]
  572. + [
  573. {
  574. "text": f"· {number} ·",
  575. "args": (number - 1,),
  576. "callback": callback,
  577. }
  578. if number == current_page
  579. else {
  580. "text": number,
  581. "args": (number - 1,),
  582. "callback": callback,
  583. }
  584. for number in range(total_pages - 2, total_pages + 1)
  585. ]
  586. ]
  587. return [
  588. [
  589. {"text": "« 1", "args": (0,), "callback": callback},
  590. {
  591. "text": f"‹ {current_page - 1}",
  592. "args": (current_page - 2,),
  593. "callback": callback,
  594. },
  595. {
  596. "text": f"· {current_page} ·",
  597. "args": (current_page - 1,),
  598. "callback": callback,
  599. },
  600. {
  601. "text": f"{current_page + 1} ›",
  602. "args": (current_page,),
  603. "callback": callback,
  604. },
  605. {
  606. "text": f"{total_pages} »",
  607. "args": (total_pages - 1,),
  608. "callback": callback,
  609. },
  610. ]
  611. ]
  612. def _validate_markup(
  613. self,
  614. buttons: typing.Optional[HikkaReplyMarkup],
  615. ) -> typing.List[typing.List[typing.Dict[str, typing.Any]]]:
  616. if buttons is None:
  617. buttons = []
  618. if not isinstance(buttons, (list, dict)):
  619. logger.error(
  620. "Reply markup ommited because passed type is not valid (%s)",
  621. type(buttons),
  622. )
  623. return None
  624. buttons = self._normalize_markup(buttons)
  625. if not all(all(isinstance(button, dict) for button in row) for row in buttons):
  626. logger.error(
  627. "Reply markup ommited because passed invalid type for one of the"
  628. " buttons"
  629. )
  630. return None
  631. if not all(
  632. all(
  633. "url" in button
  634. or "callback" in button
  635. or "input" in button
  636. or "data" in button
  637. or "action" in button
  638. for button in row
  639. )
  640. for row in buttons
  641. ):
  642. logger.error(
  643. "Invalid button specified. "
  644. "Button must contain one of the following fields:\n"
  645. " - `url`\n"
  646. " - `callback`\n"
  647. " - `input`\n"
  648. " - `data`\n"
  649. " - `action`"
  650. )
  651. return None
  652. return buttons