utils.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. # █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
  2. # █▀█ █ █ █ █▀█ █▀▄ █
  3. # © Copyright 2022
  4. # https://t.me/hikariatama
  5. #
  6. # 🔒 Licensed under the GNU AGPLv3
  7. # 🌐 https://www.gnu.org/licenses/agpl-3.0.html
  8. import asyncio
  9. import inspect
  10. import logging
  11. import contextlib
  12. import io
  13. import os
  14. from copy import deepcopy
  15. from typing import List, Optional, Union
  16. from urllib.parse import urlparse
  17. import functools
  18. from aiogram.types import (
  19. CallbackQuery,
  20. InlineKeyboardButton,
  21. InlineKeyboardMarkup,
  22. InputMediaAnimation,
  23. InputMediaDocument,
  24. InputMediaAudio,
  25. InputMediaPhoto,
  26. InputMediaVideo,
  27. InputFile,
  28. )
  29. from aiogram.utils.exceptions import (
  30. MessageIdInvalid,
  31. MessageNotModified,
  32. RetryAfter,
  33. BadRequest,
  34. )
  35. from .. import utils
  36. from .._types import Module
  37. from .types import InlineUnit, InlineCall
  38. from telethon.utils import resolve_inline_message_id
  39. logger = logging.getLogger(__name__)
  40. class Utils(InlineUnit):
  41. def _generate_markup(
  42. self,
  43. markup_obj: Union[str, list],
  44. ) -> Union[None, InlineKeyboardMarkup]:
  45. """Generate markup for form or list of `dict`s"""
  46. if not markup_obj:
  47. return None
  48. if isinstance(markup_obj, InlineKeyboardMarkup):
  49. return markup_obj
  50. markup = InlineKeyboardMarkup()
  51. map_ = (
  52. self._units[markup_obj]["buttons"]
  53. if isinstance(markup_obj, str)
  54. else markup_obj
  55. )
  56. map_ = self._normalize_markup(map_)
  57. setup_callbacks = False
  58. for row in map_:
  59. for button in row:
  60. if not isinstance(button, dict):
  61. logger.error(
  62. f"Button {button} is not a `dict`, but `{type(button)}` in"
  63. f" {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. f"Button {button} has no `message` to answer with"
  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. f"properly. {button}"
  178. )
  179. except KeyError:
  180. logger.exception(
  181. "Error while forming markup! Probably, you "
  182. "passed wrong type combination for button. "
  183. "Contact developer of module."
  184. )
  185. return False
  186. markup.row(*line)
  187. return markup
  188. generate_markup = _generate_markup
  189. async def _close_unit_handler(self, call: InlineCall):
  190. await call.delete()
  191. async def _unload_unit_handler(self, call: InlineCall):
  192. await call.unload()
  193. async def _answer_unit_handler(self, call: InlineCall, text: str, show_alert: bool):
  194. await call.answer(text, show_alert=show_alert)
  195. async def check_inline_security(self, *, func: callable, user: int) -> bool:
  196. """Checks if user with id `user` is allowed to run function `func`"""
  197. return await self._client.dispatcher.security.check(
  198. func=func,
  199. user=user,
  200. message=None,
  201. )
  202. def _find_caller_sec_map(self) -> Union[callable, None]:
  203. try:
  204. for stack_entry in inspect.stack():
  205. if hasattr(stack_entry, "function") and (
  206. stack_entry.function.endswith("cmd")
  207. or stack_entry.function.endswith("_inline_handler")
  208. ):
  209. logger.debug(f"Found caller: {stack_entry.function}")
  210. return next(
  211. lambda: self._client.dispatcher.security.get_flags(
  212. getattr(
  213. cls_,
  214. stack_entry.function,
  215. ),
  216. )
  217. for name, cls_ in stack_entry.frame.f_globals.items()
  218. if name.endswith("Mod") and issubclass(cls_, Module)
  219. )
  220. except Exception:
  221. logger.debug("Can't parse security mask in form", exc_info=True)
  222. return None
  223. def _normalize_markup(self, reply_markup: Union[dict, list]) -> list:
  224. if isinstance(reply_markup, dict):
  225. return [[reply_markup]]
  226. if isinstance(reply_markup, list) and any(
  227. isinstance(i, dict) for i in reply_markup
  228. ):
  229. return [reply_markup]
  230. return reply_markup
  231. async def _edit_unit(
  232. self,
  233. text: str,
  234. reply_markup: List[List[dict]] = None,
  235. *,
  236. photo: Optional[str] = None,
  237. file: Optional[str] = None,
  238. video: Optional[str] = None,
  239. audio: Optional[Union[dict, str]] = None,
  240. gif: Optional[str] = None,
  241. mime_type: Optional[str] = None,
  242. force_me: Union[bool, None] = None,
  243. disable_security: Union[bool, None] = None,
  244. always_allow: Union[List[int], None] = None,
  245. disable_web_page_preview: bool = True,
  246. query: CallbackQuery = None,
  247. unit_id: str = None,
  248. inline_message_id: Optional[str] = None,
  249. chat_id: Optional[int] = None,
  250. message_id: Optional[int] = None,
  251. ) -> bool:
  252. """
  253. Edits unit message
  254. :param text: Text of message
  255. :param reply_markup: Inline keyboard
  256. :param photo: Url to a valid photo to attach to message
  257. :param file: Url to a valid file to attach to message
  258. :param video: Url to a valid video to attach to message
  259. :param audio: Url to a valid audio to attach to message
  260. :param gif: Url to a valid gif to attach to message
  261. :param mime_type: Mime type of file
  262. :param force_me: Allow only userbot owner to interact with buttons
  263. :param disable_security: Disable security check for buttons
  264. :param always_allow: List of user ids, which will always be allowed
  265. :param disable_web_page_preview: Disable web page preview
  266. :param query: Callback query
  267. :return: Status of edit"""
  268. reply_markup = self._validate_markup(reply_markup) or []
  269. if not isinstance(text, str):
  270. logger.error("Invalid type for `text`")
  271. return False
  272. if file and not mime_type:
  273. logger.error(
  274. "You must pass `mime_type` along with `file` field\n"
  275. "It may be either 'application/zip' or 'application/pdf'"
  276. )
  277. return False
  278. if isinstance(audio, str):
  279. audio = {"url": audio}
  280. media_params = [
  281. photo is None,
  282. gif is None,
  283. file is None,
  284. video is None,
  285. audio is None,
  286. ]
  287. if media_params.count(False) > 1:
  288. logger.error("You passed two or more exclusive parameters simultaneously")
  289. return False
  290. if unit_id is not None and unit_id in self._units:
  291. unit = self._units[unit_id]
  292. unit["buttons"] = reply_markup
  293. if isinstance(force_me, bool):
  294. unit["force_me"] = force_me
  295. if isinstance(disable_security, bool):
  296. unit["disable_security"] = disable_security
  297. if isinstance(always_allow, list):
  298. unit["always_allow"] = always_allow
  299. else:
  300. unit = {}
  301. if not chat_id or not message_id:
  302. inline_message_id = (
  303. inline_message_id
  304. or unit.get("inline_message_id", False)
  305. or getattr(query, "inline_message_id", None)
  306. )
  307. if not chat_id and not message_id and not inline_message_id:
  308. logger.warning(
  309. "Attempted to edit message with no `inline_message_id`. "
  310. "Possible reasons:\n"
  311. "- Form was sent without buttons and due to "
  312. "the limits of Telegram API can't be edited\n"
  313. "- There is an in-userbot error, which you should report"
  314. )
  315. return False
  316. # If passed `photo` is gif
  317. try:
  318. path = urlparse(photo).path
  319. ext = os.path.splitext(path)[1]
  320. except Exception:
  321. ext = None
  322. if photo is not None and ext in {".gif", ".mp4"}:
  323. gif = deepcopy(photo)
  324. photo = None
  325. media = next(
  326. (media for media in [photo, file, video, audio, gif] if media), None
  327. )
  328. if isinstance(media, bytes):
  329. media = io.BytesIO(media)
  330. media.name = "upload.mp4"
  331. if isinstance(media, io.BytesIO):
  332. media = InputFile(media)
  333. if file:
  334. media = InputMediaDocument(media, caption=text, parse_mode="HTML")
  335. elif photo:
  336. media = InputMediaPhoto(media, caption=text, parse_mode="HTML")
  337. elif audio:
  338. if isinstance(audio, dict):
  339. media = InputMediaAudio(
  340. audio["url"],
  341. title=audio.get("title"),
  342. performer=audio.get("performer"),
  343. duration=audio.get("duration"),
  344. caption=text,
  345. parse_mode="HTML",
  346. )
  347. else:
  348. media = InputMediaAudio(
  349. audio,
  350. caption=text,
  351. parse_mode="HTML",
  352. )
  353. elif video:
  354. media = InputMediaVideo(media, caption=text, parse_mode="HTML")
  355. elif gif:
  356. media = InputMediaAnimation(media, caption=text, parse_mode="HTML")
  357. if media is None:
  358. try:
  359. await self.bot.edit_message_text(
  360. text,
  361. **(
  362. {"inline_message_id": inline_message_id}
  363. if inline_message_id
  364. else {"chat_id": chat_id, "message_id": message_id}
  365. ),
  366. disable_web_page_preview=disable_web_page_preview,
  367. reply_markup=self.generate_markup(
  368. reply_markup
  369. if isinstance(reply_markup, list)
  370. else unit.get("buttons", [])
  371. ),
  372. )
  373. except MessageNotModified:
  374. if query:
  375. with contextlib.suppress(Exception):
  376. await query.answer()
  377. return False
  378. except RetryAfter as e:
  379. logger.info(f"Sleeping {e.timeout}s on aiogram FloodWait...")
  380. await asyncio.sleep(e.timeout)
  381. return await self._edit_unit(**utils.get_kwargs())
  382. except MessageIdInvalid:
  383. with contextlib.suppress(Exception):
  384. await query.answer(
  385. "I should have edited some message, but it is deleted :("
  386. )
  387. return False
  388. except BadRequest as e:
  389. if "There is no text in the message to edit" not in str(e):
  390. raise
  391. try:
  392. await self.bot.edit_message_caption(
  393. caption=text,
  394. **(
  395. {"inline_message_id": inline_message_id}
  396. if inline_message_id
  397. else {"chat_id": chat_id, "message_id": message_id}
  398. ),
  399. reply_markup=self.generate_markup(
  400. reply_markup
  401. if isinstance(reply_markup, list)
  402. else unit.get("buttons", [])
  403. ),
  404. )
  405. except Exception:
  406. return False
  407. else:
  408. return True
  409. else:
  410. return True
  411. try:
  412. await self.bot.edit_message_media(
  413. **(
  414. {"inline_message_id": inline_message_id}
  415. if inline_message_id
  416. else {"chat_id": chat_id, "message_id": message_id}
  417. ),
  418. media=media,
  419. reply_markup=self.generate_markup(
  420. reply_markup
  421. if isinstance(reply_markup, list)
  422. else unit.get("buttons", [])
  423. ),
  424. )
  425. except RetryAfter as e:
  426. logger.info(f"Sleeping {e.timeout}s on aiogram FloodWait...")
  427. await asyncio.sleep(e.timeout)
  428. return await self._edit_unit(**utils.get_kwargs())
  429. except MessageIdInvalid:
  430. with contextlib.suppress(Exception):
  431. await query.answer(
  432. "I should have edited some message, but it is deleted :("
  433. )
  434. return False
  435. else:
  436. return True
  437. async def _delete_unit_message(
  438. self,
  439. call: CallbackQuery = None,
  440. unit_id: str = None,
  441. ) -> bool:
  442. """Params `self`, `unit_id` are for internal use only, do not try to pass them"""
  443. if getattr(getattr(call, "message", None), "chat", None):
  444. try:
  445. await self.bot.delete_message(
  446. chat_id=call.message.chat.id,
  447. message_id=call.message.message_id,
  448. )
  449. except Exception:
  450. return False
  451. return True
  452. if not unit_id and hasattr(call, "unit_id") and call.unit_id:
  453. unit_id = call.unit_id
  454. try:
  455. message_id, peer, _, _ = resolve_inline_message_id(
  456. self._units[unit_id]["inline_message_id"]
  457. )
  458. await self._client.delete_messages(peer, [message_id])
  459. await self._unload_unit(None, unit_id)
  460. except Exception:
  461. return False
  462. return True
  463. async def _unload_unit(
  464. self,
  465. call: CallbackQuery = None,
  466. unit_id: str = None,
  467. ) -> bool:
  468. """Params `self`, `unit_id` are for internal use only, do not try to pass them"""
  469. try:
  470. if "on_unload" in self._units[unit_id] and callable(
  471. self._units[unit_id]["on_unload"]
  472. ):
  473. self._units[unit_id]["on_unload"]()
  474. if unit_id in self._units:
  475. del self._units[unit_id]
  476. else:
  477. return False
  478. except Exception:
  479. return False
  480. return True
  481. def build_pagination(
  482. self,
  483. callback: callable,
  484. total_pages: int,
  485. unit_id: Optional[str] = None,
  486. current_page: Optional[int] = None,
  487. ) -> List[dict]:
  488. # Based on https://github.com/pystorage/pykeyboard/blob/master/pykeyboard/inline_pagination_keyboard.py#L4
  489. if current_page is None:
  490. current_page = self._units[unit_id]["current_index"] + 1
  491. if total_pages <= 5:
  492. return [
  493. [
  494. {"text": number, "args": (number - 1,), "callback": callback}
  495. if number != current_page
  496. else {
  497. "text": f"· {number} ·",
  498. "args": (number - 1,),
  499. "callback": callback,
  500. }
  501. for number in range(1, total_pages + 1)
  502. ]
  503. ]
  504. if current_page <= 3:
  505. return [
  506. [
  507. {
  508. "text": f"· {number} ·",
  509. "args": (number - 1,),
  510. "callback": callback,
  511. }
  512. if number == current_page
  513. else {
  514. "text": f"{number} ›",
  515. "args": (number - 1,),
  516. "callback": callback,
  517. }
  518. if number == 4
  519. else {
  520. "text": f"{total_pages} »",
  521. "args": (total_pages - 1,),
  522. "callback": callback,
  523. }
  524. if number == 5
  525. else {
  526. "text": number,
  527. "args": (number - 1,),
  528. "callback": callback,
  529. }
  530. for number in range(1, 6)
  531. ]
  532. ]
  533. if current_page > total_pages - 3:
  534. return [
  535. [
  536. {"text": "« 1", "args": (0,), "callback": callback},
  537. {
  538. "text": f"‹ {total_pages - 3}",
  539. "args": (total_pages - 4,),
  540. "callback": callback,
  541. },
  542. ]
  543. + [
  544. {
  545. "text": f"· {number} ·",
  546. "args": (number - 1,),
  547. "callback": callback,
  548. }
  549. if number == current_page
  550. else {
  551. "text": number,
  552. "args": (number - 1,),
  553. "callback": callback,
  554. }
  555. for number in range(total_pages - 2, total_pages + 1)
  556. ]
  557. ]
  558. return [
  559. [
  560. {"text": "« 1", "args": (0,), "callback": callback},
  561. {
  562. "text": f"‹ {current_page - 1}",
  563. "args": (current_page - 2,),
  564. "callback": callback,
  565. },
  566. {
  567. "text": f"· {current_page} ·",
  568. "args": (current_page - 1,),
  569. "callback": callback,
  570. },
  571. {
  572. "text": f"{current_page + 1} ›",
  573. "args": (current_page,),
  574. "callback": callback,
  575. },
  576. {
  577. "text": f"{total_pages} »",
  578. "args": (total_pages - 1,),
  579. "callback": callback,
  580. },
  581. ]
  582. ]
  583. def _validate_markup(
  584. self,
  585. buttons: Optional[Union[List[List[dict]], List[dict], dict]],
  586. ) -> list:
  587. if buttons is None:
  588. buttons = []
  589. if not isinstance(buttons, (list, dict)):
  590. logger.error(
  591. "Reply markup ommited because passed type is not valid"
  592. f" ({type(buttons)})"
  593. )
  594. return None
  595. buttons = self._normalize_markup(buttons)
  596. if not all(all(isinstance(button, dict) for button in row) for row in buttons):
  597. logger.error(
  598. "Reply markup ommited because passed invalid type for one of the"
  599. " buttons"
  600. )
  601. return None
  602. if not all(
  603. all(
  604. "url" in button
  605. or "callback" in button
  606. or "input" in button
  607. or "data" in button
  608. or "action" in button
  609. for button in row
  610. )
  611. for row in buttons
  612. ):
  613. logger.error(
  614. "Invalid button specified. "
  615. "Button must contain one of the following fields:\n"
  616. " - `url`\n"
  617. " - `callback`\n"
  618. " - `input`\n"
  619. " - `data`\n"
  620. " - `action`"
  621. )
  622. return None
  623. return buttons