Queer European MD passionate about IT

custombot.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  1. """WARNING: this is only a legacy module, written for backward compatibility.
  2. For newer versions use `bot.py`.
  3. This module used to rely on third party `telepot` library by Nick Lee
  4. (@Nickoala).
  5. The `telepot` repository was archived in may 2019 and will no longer be listed
  6. in requirements. To run legacy code, install telepot manually.
  7. `pip install telepot`
  8. Subclass of third party telepot.aio.Bot, providing the following features.
  9. - It prevents hitting Telegram flood limits by waiting
  10. between text and photo messages.
  11. - It provides command, parser, button and other decorators to associate
  12. common Telegram actions with custom handlers.
  13. - It supports multiple bots running in the same script
  14. and allows communications between them
  15. as well as complete independency from each other.
  16. - Each bot is associated with a sqlite database
  17. using dataset, a third party library.
  18. Please note that you need Python3.5+ to run async code
  19. Check requirements.txt for third party dependencies.
  20. """
  21. # Standard library modules
  22. import asyncio
  23. from collections import OrderedDict
  24. import datetime
  25. import inspect
  26. import logging
  27. import os
  28. # Third party modules
  29. import davtelepot.bot
  30. # Project modules
  31. from .utilities import (
  32. get_secure_key, extract, sleep_until
  33. )
  34. class Bot(davtelepot.bot.Bot):
  35. """Legacy adapter for backward compatibility.
  36. Old description:
  37. telepot.aio.Bot (async Telegram bot framework) convenient subclass.
  38. === General functioning ===
  39. - While Bot.run() coroutine is executed, HTTP get requests are made
  40. to Telegram servers asking for new messages for each Bot instance.
  41. - Each message causes the proper Bot instance method coroutine
  42. to be awaited, according to its flavour (see routing_table)
  43. -- For example, chat messages cause `Bot().on_chat_message(message)`
  44. to be awaited.
  45. - This even-processing coroutine ensures the proper handling function
  46. a future and returns.
  47. -- That means that simpler tasks are completed before slower ones,
  48. since handling functions are not awaited but scheduled
  49. by `asyncio.ensure_future(handling_function(...))`
  50. -- For example, chat text messages are handled by
  51. `handle_text_message`, which looks for the proper function
  52. to elaborate the request (in bot's commands and parsers)
  53. - The handling function evaluates an answer, depending on the message
  54. content, and eventually provides a reply
  55. -- For example, `handle_text_message` sends its
  56. answer via `send_message`
  57. - All Bot.instances run simultaneously and faster requests
  58. are completed earlier.
  59. - All uncaught events are ignored.
  60. """
  61. def __init__(self, token, db_name=None, **kwargs):
  62. """Instantiate Bot instance, given a token and a db name."""
  63. davtelepot.bot.Bot.__init__(
  64. self,
  65. token=token,
  66. database_url=db_name,
  67. **kwargs
  68. )
  69. self.message_handlers['pinned_message'] = self.handle_pinned_message
  70. self.message_handlers['photo'] = self.handle_photo_message
  71. self.message_handlers['location'] = self.handle_location
  72. self.custom_photo_parsers = dict()
  73. self.custom_location_parsers = dict()
  74. self.to_be_obscured = []
  75. self.to_be_destroyed = []
  76. self.chat_actions = dict(
  77. pinned=OrderedDict()
  78. )
  79. @property
  80. def unauthorized_message(self):
  81. """Return this if user is unauthorized to make a request.
  82. This property is deprecated: use `authorization_denied_message`
  83. instead.
  84. """
  85. return self.authorization_denied_message
  86. @property
  87. def maintenance(self):
  88. """Check whether bot is under maintenance.
  89. This property is deprecated: use `under_maintenance` instead.
  90. """
  91. return self.under_maintenance
  92. @classmethod
  93. def set_class_unauthorized_message(csl, unauthorized_message):
  94. """Set class unauthorized message.
  95. This method is deprecated: use `set_class_authorization_denied_message`
  96. instead.
  97. """
  98. return csl.set_class_authorization_denied_message(unauthorized_message)
  99. def set_unauthorized_message(self, unauthorized_message):
  100. """Set instance unauthorized message.
  101. This method is deprecated: use `set_authorization_denied_message`
  102. instead.
  103. """
  104. return self.set_authorization_denied_message(unauthorized_message)
  105. def set_authorization_function(self, authorization_function):
  106. """Set a custom authorization_function.
  107. It should evaluate True if user is authorized to perform a specific
  108. action and False otherwise.
  109. It should take update and role and return a Boolean.
  110. Default authorization_function always evaluates True.
  111. """
  112. def _authorization_function(update, authorization_level,
  113. user_record=None):
  114. privileges = authorization_level # noqa: W0612, this variable
  115. # is used by locals()
  116. return authorization_function(
  117. **{
  118. name: argument
  119. for name, argument in locals().items()
  120. if name in inspect.signature(
  121. authorization_function
  122. ).parameters
  123. }
  124. )
  125. self.authorization_function = _authorization_function
  126. def set_maintenance(self, maintenance_message):
  127. """Put the bot under maintenance or end it.
  128. This method is deprecated: use `change_maintenance_status` instead.
  129. """
  130. bot_in_maintenance = self.change_maintenance_status(
  131. maintenance_message=maintenance_message
  132. )
  133. if bot_in_maintenance:
  134. return (
  135. "<i>Bot has just been put under maintenance!</i>\n\n"
  136. "Until further notice, it will reply to users "
  137. "with the following message:\n\n{}"
  138. ).format(
  139. self.maintenance_message
  140. )
  141. return "<i>Maintenance ended!</i>"
  142. def set_get_chat_id_function(self, get_chat_id_function):
  143. """Set a custom get_chat_id function.
  144. This method is deprecated: use `set_chat_id_getter` instead.
  145. """
  146. return self.set_chat_id_getter(get_chat_id_function)
  147. async def on_chat_message(self, update, user_record=None):
  148. """Handle text message.
  149. This method is deprecated: use `text_message_handler` instead.
  150. """
  151. return await self.text_message_handler(
  152. update=update,
  153. user_record=user_record
  154. )
  155. def set_inline_result_handler(self, user_id, result_id, func):
  156. """Associate a `func` with a `result_id` for `user_id`.
  157. This method is deprecated: use `set_chosen_inline_result_handler`
  158. instead.
  159. """
  160. if not asyncio.iscoroutinefunction(func):
  161. async def _func(update):
  162. return func(update)
  163. else:
  164. _func = func
  165. return self.set_chosen_inline_result_handler(
  166. user_id=user_id,
  167. result_id=result_id,
  168. handler=_func
  169. )
  170. async def handle_pinned_message(self, update, user_record=None):
  171. """Handle pinned message chat action."""
  172. if self.maintenance:
  173. return
  174. answerer = None
  175. for criteria, handler in self.chat_actions['pinned'].items():
  176. if criteria(update):
  177. answerer = handler['function']
  178. break
  179. if answerer is None:
  180. return
  181. elif asyncio.iscoroutinefunction(answerer):
  182. answer = await answerer(update)
  183. else:
  184. answer = answerer(update)
  185. if answer:
  186. try:
  187. return await self.send_message(
  188. answer=answer,
  189. chat_id=update['chat']['id']
  190. )
  191. except Exception as e:
  192. logging.error(
  193. "Failed to process answer:\n{}".format(
  194. e
  195. ),
  196. exc_info=True
  197. )
  198. return
  199. async def handle_photo_message(self, update, user_record=None):
  200. """Handle photo chat message."""
  201. user_id = update['from']['id'] if 'from' in update else None
  202. answerer, answer = None, None
  203. if self.maintenance:
  204. if update['chat']['id'] > 0:
  205. answer = self.maintenance_message
  206. elif user_id in self.custom_photo_parsers:
  207. answerer = self.custom_photo_parsers[user_id]
  208. del self.custom_photo_parsers[user_id]
  209. if answerer:
  210. if asyncio.iscoroutinefunction(answerer):
  211. answer = await answerer(update)
  212. else:
  213. answer = answerer(update)
  214. if answer:
  215. try:
  216. return await self.send_message(answer=answer, chat_id=update)
  217. except Exception as e:
  218. logging.error(
  219. "Failed to process answer:\n{}".format(
  220. e
  221. ),
  222. exc_info=True
  223. )
  224. return
  225. async def handle_location(self, *args, **kwargs):
  226. """Handle location sent by user.
  227. This method is deprecated: use `location_message_handler` instead.
  228. """
  229. return await super().location_message_handler(*args, **kwargs)
  230. def set_custom_parser(self, parser, update=None, user=None):
  231. """Set a custom parser for the user.
  232. This method is deprecated: use `set_individual_text_message_handler`
  233. instead.
  234. """
  235. return self.set_individual_text_message_handler(
  236. handler=parser,
  237. update=update,
  238. user_id=user
  239. )
  240. def set_custom_photo_parser(self, parser, update=None, user=None):
  241. """Set a custom photo parser for the user.
  242. Any photo chat update coming from the user will be handled by
  243. this custom parser instead of default parsers.
  244. Custom photo parsers last one single use, but their handler can
  245. call this function to provide multiple tries.
  246. """
  247. if user and type(user) is int:
  248. pass
  249. elif type(update) is int:
  250. user = update
  251. elif type(user) is dict:
  252. user = (
  253. user['from']['id']
  254. if 'from' in user
  255. and 'id' in user['from']
  256. else None
  257. )
  258. elif not user and type(update) is dict:
  259. user = (
  260. update['from']['id']
  261. if 'from' in update
  262. and 'id' in update['from']
  263. else None
  264. )
  265. else:
  266. raise TypeError(
  267. 'Invalid user.\nuser: {}\nupdate: {}'.format(
  268. user,
  269. update
  270. )
  271. )
  272. if not type(user) is int:
  273. raise TypeError(
  274. 'User {} is not an int id'.format(
  275. user
  276. )
  277. )
  278. if not callable(parser):
  279. raise TypeError(
  280. 'Parser {} is not a callable'.format(
  281. parser.__name__
  282. )
  283. )
  284. self.custom_photo_parsers[user] = parser
  285. return
  286. def set_custom_location_parser(self, parser, update=None, user=None):
  287. """Set a custom location parser for the user.
  288. Any location chat update coming from the user will be handled by
  289. this custom parser instead of default parsers.
  290. Custom location parsers last one single use, but their handler can
  291. call this function to provide multiple tries.
  292. """
  293. if user and type(user) is int:
  294. pass
  295. elif type(update) is int:
  296. user = update
  297. elif type(user) is dict:
  298. user = (
  299. user['from']['id']
  300. if 'from' in user
  301. and 'id' in user['from']
  302. else None
  303. )
  304. elif not user and type(update) is dict:
  305. user = (
  306. update['from']['id']
  307. if 'from' in update
  308. and 'id' in update['from']
  309. else None
  310. )
  311. else:
  312. raise TypeError(
  313. 'Invalid user.\nuser: {}\nupdate: {}'.format(
  314. user,
  315. update
  316. )
  317. )
  318. if not type(user) is int:
  319. raise TypeError(
  320. 'User {} is not an int id'.format(
  321. user
  322. )
  323. )
  324. if not callable(parser):
  325. raise TypeError(
  326. 'Parser {} is not a callable'.format(
  327. parser.__name__
  328. )
  329. )
  330. self.custom_location_parsers[user] = parser
  331. return
  332. def command(self, command, aliases=None, show_in_keyboard=False,
  333. reply_keyboard_button=None, descr="", auth='admin',
  334. description=None,
  335. help_section=None,
  336. authorization_level=None):
  337. """Define a bot command.
  338. `descr` and `auth` parameters are deprecated: use `description` and
  339. `authorization_level` instead.
  340. """
  341. authorization_level = authorization_level or auth
  342. description = description or descr
  343. return super().command(
  344. command=command,
  345. aliases=aliases,
  346. reply_keyboard_button=reply_keyboard_button,
  347. show_in_keyboard=show_in_keyboard,
  348. description=description,
  349. help_section=help_section,
  350. authorization_level=authorization_level
  351. )
  352. def parser(self, condition, descr='', auth='admin', argument='text',
  353. description=None,
  354. authorization_level=None):
  355. """Define a message parser.
  356. `descr` and `auth` parameters are deprecated: use `description` and
  357. `authorization_level` instead.
  358. """
  359. authorization_level = authorization_level or auth
  360. description = description or descr
  361. return super().parser(
  362. condition=condition,
  363. description=description,
  364. authorization_level=authorization_level,
  365. argument=argument
  366. )
  367. def pinned(self, condition, descr='', auth='admin'):
  368. """Handle pinned messages.
  369. Decorator: `@bot.pinned(condition)`
  370. If condition evaluates True when run on a pinned_message update,
  371. such decorated function gets called on update.
  372. Conditions are evaluated in order; when one is True,
  373. others will be skipped.
  374. `descr` is a description
  375. `auth` is the lowest authorization level needed to run the command
  376. """
  377. if not callable(condition):
  378. raise TypeError(
  379. 'Condition {c} is not a callable'.format(
  380. c=condition.__name__
  381. )
  382. )
  383. def decorator(func):
  384. if asyncio.iscoroutinefunction(func):
  385. async def decorated(message):
  386. logging.info(
  387. "PINNED MESSAGE MATCHING({c}) @{n} FROM({f})".format(
  388. c=condition.__name__,
  389. n=self.name,
  390. f=(
  391. message['from']
  392. if 'from' in message
  393. else message['chat']
  394. )
  395. )
  396. )
  397. if self.authorization_function(message, auth):
  398. return await func(message)
  399. return
  400. else:
  401. def decorated(message):
  402. logging.info(
  403. "PINNED MESSAGE MATCHING({c}) @{n} FROM({f})".format(
  404. c=condition.__name__,
  405. n=self.name,
  406. f=(
  407. message['from']
  408. if 'from' in message
  409. else message['chat']
  410. )
  411. )
  412. )
  413. if self.authorization_function(message, auth):
  414. return func(message)
  415. return
  416. self.chat_actions['pinned'][condition] = dict(
  417. function=decorated,
  418. descr=descr,
  419. auth=auth
  420. )
  421. return decorator
  422. def button(self, data=None, descr='', auth='admin',
  423. authorization_level=None, prefix=None, description=None,
  424. separator=None):
  425. """Define a bot button.
  426. `descr` and `auth` parameters are deprecated: use `description` and
  427. `authorization_level` instead.
  428. `data` parameter renamed `prefix`.
  429. """
  430. authorization_level = authorization_level or auth
  431. description = description or descr
  432. prefix = prefix or data
  433. return super().button(
  434. prefix=prefix,
  435. separator=separator,
  436. description=description,
  437. authorization_level=authorization_level,
  438. )
  439. def query(self, condition, descr='', auth='admin', description=None,
  440. authorization_level=None):
  441. """Define an inline query.
  442. `descr` and `auth` parameters are deprecated: use `description` and
  443. `authorization_level` instead.
  444. """
  445. authorization_level = authorization_level or auth
  446. description = description or descr
  447. return super().query(
  448. condition=condition,
  449. description=description,
  450. authorization_level=authorization_level,
  451. )
  452. async def edit_message(self, update, *args, **kwargs):
  453. """Edit given update with given *args and **kwargs.
  454. This method is deprecated: use `edit_message_text` instead.
  455. """
  456. return await self.edit_message_text(
  457. *args,
  458. update=update,
  459. **kwargs
  460. )
  461. async def send_message(self, answer=dict(), chat_id=None, text='',
  462. parse_mode="HTML", disable_web_page_preview=None,
  463. disable_notification=None, reply_to_message_id=None,
  464. reply_markup=None, update=dict(),
  465. reply_to_update=False, send_default_keyboard=True):
  466. """Send a message.
  467. This method is deprecated: use `super().send_message` instead.
  468. """
  469. if update is None:
  470. update = dict()
  471. parameters = dict()
  472. for parameter, value in locals().items():
  473. if parameter in ['self', 'answer', 'parameters', '__class__']:
  474. continue
  475. if parameter in answer:
  476. parameters[parameter] = answer[parameter]
  477. else:
  478. parameters[parameter] = value
  479. if type(parameters['chat_id']) is dict:
  480. parameters['update'] = parameters['chat_id']
  481. del parameters['chat_id']
  482. return await super().send_message(**parameters)
  483. async def send_photo(self, chat_id=None, answer=dict(),
  484. photo=None, caption='', parse_mode='HTML',
  485. disable_notification=None, reply_to_message_id=None,
  486. reply_markup=None, use_stored=True,
  487. second_chance=False, use_stored_file_id=None,
  488. update=dict(), reply_to_update=False,
  489. send_default_keyboard=True):
  490. """Send a photo.
  491. This method is deprecated: use `super().send_photo` instead.
  492. """
  493. if update is None:
  494. update = dict()
  495. if use_stored is not None:
  496. use_stored_file_id = use_stored
  497. parameters = dict()
  498. for parameter, value in locals().items():
  499. if parameter in ['self', 'answer', 'parameters', '__class__',
  500. 'second_chance', 'use_stored']:
  501. continue
  502. if parameter in answer:
  503. parameters[parameter] = answer[parameter]
  504. else:
  505. parameters[parameter] = value
  506. if type(parameters['chat_id']) is dict:
  507. parameters['update'] = parameters['chat_id']
  508. del parameters['chat_id']
  509. return await super().send_photo(**parameters)
  510. async def send_and_destroy(self, chat_id, answer,
  511. timer=60, mode='text', **kwargs):
  512. """Send a message or photo and delete it after `timer` seconds."""
  513. if mode == 'text':
  514. sent_message = await self.send_message(
  515. chat_id=chat_id,
  516. answer=answer,
  517. **kwargs
  518. )
  519. elif mode == 'pic':
  520. sent_message = await self.send_photo(
  521. chat_id=chat_id,
  522. answer=answer,
  523. **kwargs
  524. )
  525. if sent_message is None:
  526. return
  527. self.to_be_destroyed.append(sent_message)
  528. await asyncio.sleep(timer)
  529. if await self.delete_message(sent_message):
  530. self.to_be_destroyed.remove(sent_message)
  531. return
  532. async def wait_and_obscure(self, update, when, inline_message_id):
  533. """Obscure messages which can't be deleted.
  534. Obscure an inline_message `timer` seconds after sending it,
  535. by editing its text or caption.
  536. At the moment Telegram won't let bots delete sent inline query results.
  537. """
  538. if type(when) is int:
  539. when = datetime.datetime.now() + datetime.timedelta(seconds=when)
  540. assert type(when) is datetime.datetime, (
  541. "when must be a datetime instance or a number of seconds (int) "
  542. "to be awaited"
  543. )
  544. if 'inline_message_id' not in update:
  545. logging.info(
  546. "This inline query result owns no inline_keyboard, so it "
  547. "can't be modified"
  548. )
  549. return
  550. inline_message_id = update['inline_message_id']
  551. self.to_be_obscured.append(inline_message_id)
  552. await sleep_until(when)
  553. try:
  554. await self.editMessageCaption(
  555. inline_message_id=inline_message_id,
  556. text="Time over"
  557. )
  558. except Exception:
  559. try:
  560. await self.editMessageText(
  561. inline_message_id=inline_message_id,
  562. text="Time over"
  563. )
  564. except Exception as e:
  565. logging.error(
  566. "Couldn't obscure message\n{}\n\n{}".format(
  567. inline_message_id,
  568. e
  569. )
  570. )
  571. self.to_be_obscured.remove(inline_message_id)
  572. return
  573. async def save_picture(self, update, file_name=None, path='img/',
  574. extension='jpg'):
  575. """Store `update` picture as `path`/`file_name`.`extension`."""
  576. if not path.endswith('/'):
  577. path = '{p}/'.format(
  578. p=path
  579. )
  580. if not os.path.isdir(path):
  581. path = '{path}/img/'.format(
  582. path=self.path
  583. )
  584. if file_name is None:
  585. file_name = get_secure_key(length=6)
  586. if file_name.endswith('.'):
  587. file_name = file_name[:-1]
  588. complete_file_name = '{path}{name}.{ext}'.format(
  589. path=self.path,
  590. name=file_name,
  591. ext=extension
  592. )
  593. while os.path.isfile(complete_file_name):
  594. file_name += get_secure_key(length=1)
  595. complete_file_name = '{path}{name}.{ext}'.format(
  596. path=self.path,
  597. name=file_name,
  598. ext=extension
  599. )
  600. try:
  601. await self.download_file(
  602. update['photo'][-1]['file_id'],
  603. complete_file_name
  604. )
  605. except Exception as e:
  606. return dict(
  607. result=1, # Error
  608. file_name=None,
  609. error=e
  610. )
  611. return dict(
  612. result=0, # Success
  613. file_name=complete_file_name,
  614. error=None
  615. )
  616. def stop_bots(self):
  617. """Exit script with code 0.
  618. This method is deprecated: use `Bot.stop` instead.
  619. """
  620. self.__class__.stop(
  621. message=f"Stopping bots via bot `@{self.name}` method.",
  622. final_state=0
  623. )
  624. def restart_bots(self):
  625. """Restart the script exiting with code 65.
  626. This method is deprecated: use `Bot.stop` instead.
  627. """
  628. self.__class__.stop(
  629. message=f"Restarting bots via bot `@{self.name}` method.",
  630. final_state=65
  631. )
  632. async def delete_and_obscure_messages(self):
  633. """Run after stop, before the script exits.
  634. Await final tasks, obscure and delete pending messages,
  635. log current operation (stop/restart).
  636. """
  637. for message in self.to_be_destroyed:
  638. try:
  639. await self.delete_message(message)
  640. except Exception as e:
  641. logging.error(
  642. "Couldn't delete message\n{}\n\n{}".format(
  643. message,
  644. e
  645. )
  646. )
  647. for inline_message_id in self.to_be_obscured:
  648. try:
  649. await self.editMessageCaption(
  650. inline_message_id,
  651. text="Time over"
  652. )
  653. except Exception:
  654. try:
  655. await self.editMessageText(
  656. inline_message_id=inline_message_id,
  657. text="Time over"
  658. )
  659. except Exception as e:
  660. logging.error(
  661. "Couldn't obscure message\n{}\n\n{}".format(
  662. inline_message_id,
  663. e
  664. )
  665. )
  666. @classmethod
  667. def run(cls, loop=None, *args, **kwargs):
  668. """Call this method to run the async bots.
  669. This method is deprecated: use `super(Bot, cls).run` instead.
  670. `loop` must not be determined outside that method.
  671. """
  672. for bot in cls.bots:
  673. bot.additional_task('AFTER')(bot.delete_and_obscure_messages)
  674. return super(Bot, cls).run(*args, **kwargs)