From 6bdc9e77ee61f8f88801c8b68ca1ff3205904658 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 3 Mar 2024 15:22:37 +0100 Subject: [PATCH] [#349] Refactored/rewritten `telegram` plugin. 1. `chat.telegram` -> `telegram` plugin. 2. Merged `backend.chat.telegram` logic into `telegram` plugin. 3. Rewritten the architecture of the integration to adapt to the new asyncio API introduced in the latest versions of telegram-bot-api. Closes: #349 --- docs/source/backends.rst | 1 - docs/source/events.rst | 2 +- .../platypush/backend/chat.telegram.rst | 5 - .../source/platypush/events/chat.telegram.rst | 5 - docs/source/platypush/events/telegram.rst | 5 + .../platypush/plugins/chat.telegram.rst | 5 - docs/source/platypush/plugins/telegram.rst | 5 + .../platypush/responses/chat.telegram.rst | 5 - docs/source/plugins.rst | 2 +- docs/source/responses.rst | 1 - platypush/backend/chat/__init__.py | 0 platypush/backend/chat/telegram/__init__.py | 164 --- platypush/backend/chat/telegram/manifest.yaml | 19 - platypush/message/event/chat/telegram.py | 55 - platypush/message/event/telegram.py | 87 ++ platypush/message/response/chat/__init__.py | 0 platypush/message/response/chat/telegram.py | 172 --- platypush/plugins/__init__.py | 6 +- platypush/plugins/chat/__init__.py | 3 +- platypush/plugins/chat/telegram/__init__.py | 1041 ---------------- platypush/plugins/chat/telegram/manifest.yaml | 7 - platypush/plugins/telegram/__init__.py | 1063 +++++++++++++++++ platypush/plugins/telegram/_bridge.py | 91 ++ platypush/plugins/telegram/_model.py | 72 ++ platypush/plugins/telegram/_service.py | 299 +++++ platypush/plugins/telegram/_utils.py | 21 + platypush/plugins/telegram/manifest.yaml | 15 + platypush/schemas/telegram.py | 186 +++ 28 files changed, 1852 insertions(+), 1485 deletions(-) delete mode 100644 docs/source/platypush/backend/chat.telegram.rst delete mode 100644 docs/source/platypush/events/chat.telegram.rst create mode 100644 docs/source/platypush/events/telegram.rst delete mode 100644 docs/source/platypush/plugins/chat.telegram.rst create mode 100644 docs/source/platypush/plugins/telegram.rst delete mode 100644 docs/source/platypush/responses/chat.telegram.rst delete mode 100644 platypush/backend/chat/__init__.py delete mode 100644 platypush/backend/chat/telegram/__init__.py delete mode 100644 platypush/backend/chat/telegram/manifest.yaml delete mode 100644 platypush/message/event/chat/telegram.py create mode 100644 platypush/message/event/telegram.py delete mode 100644 platypush/message/response/chat/__init__.py delete mode 100644 platypush/message/response/chat/telegram.py delete mode 100644 platypush/plugins/chat/telegram/__init__.py delete mode 100644 platypush/plugins/chat/telegram/manifest.yaml create mode 100644 platypush/plugins/telegram/__init__.py create mode 100644 platypush/plugins/telegram/_bridge.py create mode 100644 platypush/plugins/telegram/_model.py create mode 100644 platypush/plugins/telegram/_service.py create mode 100644 platypush/plugins/telegram/_utils.py create mode 100644 platypush/plugins/telegram/manifest.yaml create mode 100644 platypush/schemas/telegram.py diff --git a/docs/source/backends.rst b/docs/source/backends.rst index cfe4c1eddc..778614dcb4 100644 --- a/docs/source/backends.rst +++ b/docs/source/backends.rst @@ -6,7 +6,6 @@ Backends :maxdepth: 1 :caption: Backends: - platypush/backend/chat.telegram.rst platypush/backend/http.rst platypush/backend/midi.rst platypush/backend/music.mopidy.rst diff --git a/docs/source/events.rst b/docs/source/events.rst index e9ecfcee42..92c5ff3b22 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -13,7 +13,6 @@ Events platypush/events/bluetooth.rst platypush/events/camera.rst platypush/events/chat.slack.rst - platypush/events/chat.telegram.rst platypush/events/clipboard.rst platypush/events/custom.rst platypush/events/dbus.rst @@ -66,6 +65,7 @@ Events platypush/events/sound.rst platypush/events/stt.rst platypush/events/sun.rst + platypush/events/telegram.rst platypush/events/tensorflow.rst platypush/events/torrent.rst platypush/events/trello.rst diff --git a/docs/source/platypush/backend/chat.telegram.rst b/docs/source/platypush/backend/chat.telegram.rst deleted file mode 100644 index 25b9e66a0d..0000000000 --- a/docs/source/platypush/backend/chat.telegram.rst +++ /dev/null @@ -1,5 +0,0 @@ -``chat.telegram`` -=================================== - -.. automodule:: platypush.backend.chat.telegram - :members: diff --git a/docs/source/platypush/events/chat.telegram.rst b/docs/source/platypush/events/chat.telegram.rst deleted file mode 100644 index f65c5f2994..0000000000 --- a/docs/source/platypush/events/chat.telegram.rst +++ /dev/null @@ -1,5 +0,0 @@ -``chat.telegram`` -========================================= - -.. automodule:: platypush.message.event.chat.telegram - :members: diff --git a/docs/source/platypush/events/telegram.rst b/docs/source/platypush/events/telegram.rst new file mode 100644 index 0000000000..5cb3558f71 --- /dev/null +++ b/docs/source/platypush/events/telegram.rst @@ -0,0 +1,5 @@ +``telegram`` +============ + +.. automodule:: platypush.message.event.telegram + :members: diff --git a/docs/source/platypush/plugins/chat.telegram.rst b/docs/source/platypush/plugins/chat.telegram.rst deleted file mode 100644 index f35a5d6bc3..0000000000 --- a/docs/source/platypush/plugins/chat.telegram.rst +++ /dev/null @@ -1,5 +0,0 @@ -``chat.telegram`` -=================================== - -.. automodule:: platypush.plugins.chat.telegram - :members: diff --git a/docs/source/platypush/plugins/telegram.rst b/docs/source/platypush/plugins/telegram.rst new file mode 100644 index 0000000000..4f2639d50d --- /dev/null +++ b/docs/source/platypush/plugins/telegram.rst @@ -0,0 +1,5 @@ +``telegram`` +============ + +.. automodule:: platypush.plugins.telegram + :members: diff --git a/docs/source/platypush/responses/chat.telegram.rst b/docs/source/platypush/responses/chat.telegram.rst deleted file mode 100644 index 397dd2de35..0000000000 --- a/docs/source/platypush/responses/chat.telegram.rst +++ /dev/null @@ -1,5 +0,0 @@ -``chat.telegram`` -============================================ - -.. automodule:: platypush.message.response.chat.telegram - :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 6b0bdff40c..1a63faaab9 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -22,7 +22,6 @@ Plugins platypush/plugins/camera.ir.mlx90640.rst platypush/plugins/camera.pi.rst platypush/plugins/camera.pi.legacy.rst - platypush/plugins/chat.telegram.rst platypush/plugins/clipboard.rst platypush/plugins/config.rst platypush/plugins/csv.rst @@ -128,6 +127,7 @@ Plugins platypush/plugins/switchbot.rst platypush/plugins/system.rst platypush/plugins/tcp.rst + platypush/plugins/telegram.rst platypush/plugins/tensorflow.rst platypush/plugins/todoist.rst platypush/plugins/torrent.rst diff --git a/docs/source/responses.rst b/docs/source/responses.rst index 0b929f6b49..4baff0c484 100644 --- a/docs/source/responses.rst +++ b/docs/source/responses.rst @@ -8,7 +8,6 @@ Responses platypush/responses/camera.rst platypush/responses/camera.android.rst - platypush/responses/chat.telegram.rst platypush/responses/google.drive.rst platypush/responses/pihole.rst platypush/responses/printer.cups.rst diff --git a/platypush/backend/chat/__init__.py b/platypush/backend/chat/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/platypush/backend/chat/telegram/__init__.py b/platypush/backend/chat/telegram/__init__.py deleted file mode 100644 index 26bfa725e0..0000000000 --- a/platypush/backend/chat/telegram/__init__.py +++ /dev/null @@ -1,164 +0,0 @@ -import re - -from typing import Type, Optional, Union, List - -from platypush.backend import Backend -from platypush.context import get_plugin -from platypush.message.event.chat.telegram import ( - MessageEvent, - CommandMessageEvent, - TextMessageEvent, - PhotoMessageEvent, - VideoMessageEvent, - ContactMessageEvent, - DocumentMessageEvent, - LocationMessageEvent, - GroupChatCreatedEvent, -) -from platypush.plugins.chat.telegram import ChatTelegramPlugin - - -class ChatTelegramBackend(Backend): - """ - Telegram bot that listens for messages and updates. - - Requires: - - * The :class:`platypush.plugins.chat.telegram.ChatTelegramPlugin` plugin configured - - """ - - def __init__( - self, authorized_chat_ids: Optional[List[Union[str, int]]] = None, **kwargs - ): - """ - :param authorized_chat_ids: Optional list of chat_id/user_id which are authorized to send messages to - the bot. If nothing is specified then no restrictions are applied. - """ - - super().__init__(**kwargs) - self.authorized_chat_ids = set(authorized_chat_ids or []) - self._plugin: ChatTelegramPlugin = get_plugin('chat.telegram') # type: ignore - - def _authorize(self, msg): - if not self.authorized_chat_ids: - return - - if msg.chat.type == 'private' and msg.chat.id not in self.authorized_chat_ids: - self.logger.info( - 'Received message from unauthorized chat_id %s', msg.chat.id - ) - self._plugin.send_message( - chat_id=msg.chat.id, - text='You are not allowed to send messages to this bot', - ) - raise PermissionError - - def _msg_hook(self, cls: Type[MessageEvent]): - # noinspection PyUnusedLocal - def hook(update, _): - msg = update.effective_message - - try: - self._authorize(msg) - self.bus.post( - cls( - chat_id=update.effective_chat.id, - message=self._plugin.parse_msg(msg).output, - user=self._plugin.parse_user(update.effective_user).output, - ) - ) - except PermissionError: - pass - - return hook - - def _group_hook(self): - def hook(update, context): - msg = update.effective_message - if msg.group_chat_created: - self.bus.post( - GroupChatCreatedEvent( - chat_id=update.effective_chat.id, - message=self._plugin.parse_msg(msg).output, - user=self._plugin.parse_user(update.effective_user).output, - ) - ) - elif msg.photo: - self._msg_hook(PhotoMessageEvent)(update, context) - elif msg.video: - self._msg_hook(VideoMessageEvent)(update, context) - elif msg.contact: - self._msg_hook(ContactMessageEvent)(update, context) - elif msg.location: - self._msg_hook(LocationMessageEvent)(update, context) - elif msg.document: - self._msg_hook(DocumentMessageEvent)(update, context) - elif msg.text: - if msg.text.startswith('/'): - self._command_hook()(update, context) - else: - self._msg_hook(TextMessageEvent)(update, context) - - return hook - - def _command_hook(self): - def hook(update, _): - msg = update.effective_message - m = re.match(r'\s*/([0-9a-zA-Z_-]+)\s*(.*)', msg.text) - if not m: - self.logger.warning('Invalid command: %s', msg.text) - return - - cmd = m.group(1).lower() - args = [arg for arg in re.split(r'\s+', m.group(2)) if len(arg)] - - try: - self._authorize(msg) - self.bus.post( - CommandMessageEvent( - chat_id=update.effective_chat.id, - command=cmd, - cmdargs=args, - message=self._plugin.parse_msg(msg).output, - user=self._plugin.parse_user(update.effective_user).output, - ) - ) - except PermissionError: - pass - - return hook - - def run(self): - from telegram.ext import MessageHandler, Filters - - super().run() - telegram = self._plugin.get_telegram() - dispatcher = telegram.dispatcher - - dispatcher.add_handler(MessageHandler(Filters.group, self._group_hook())) - dispatcher.add_handler( - MessageHandler(Filters.text, self._msg_hook(TextMessageEvent)) - ) - dispatcher.add_handler( - MessageHandler(Filters.photo, self._msg_hook(PhotoMessageEvent)) - ) - dispatcher.add_handler( - MessageHandler(Filters.video, self._msg_hook(VideoMessageEvent)) - ) - dispatcher.add_handler( - MessageHandler(Filters.contact, self._msg_hook(ContactMessageEvent)) - ) - dispatcher.add_handler( - MessageHandler(Filters.location, self._msg_hook(LocationMessageEvent)) - ) - dispatcher.add_handler( - MessageHandler(Filters.document, self._msg_hook(DocumentMessageEvent)) - ) - dispatcher.add_handler(MessageHandler(Filters.command, self._command_hook())) - - self.logger.info('Initialized Telegram backend') - telegram.start_polling() - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/chat/telegram/manifest.yaml b/platypush/backend/chat/telegram/manifest.yaml deleted file mode 100644 index 70698426ae..0000000000 --- a/platypush/backend/chat/telegram/manifest.yaml +++ /dev/null @@ -1,19 +0,0 @@ -manifest: - events: - platypush.message.event.chat.telegram.CommandMessageEvent: when a command message - is received. - platypush.message.event.chat.telegram.ContactMessageEvent: when a contact is received. - platypush.message.event.chat.telegram.DocumentMessageEvent: when a document is - received. - platypush.message.event.chat.telegram.GroupChatCreatedEvent: when the bot is invited - to a new group. - platypush.message.event.chat.telegram.LocationMessageEvent: when a location is - received. - platypush.message.event.chat.telegram.PhotoMessageEvent: when a photo is received. - platypush.message.event.chat.telegram.TextMessageEvent: when a text message is - received. - platypush.message.event.chat.telegram.VideoMessageEvent: when a video is received. - install: - pip: [] - package: platypush.backend.chat.telegram - type: backend diff --git a/platypush/message/event/chat/telegram.py b/platypush/message/event/chat/telegram.py deleted file mode 100644 index f3fb3d3f3b..0000000000 --- a/platypush/message/event/chat/telegram.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import List, Optional - -from platypush.message.event import Event - - -class TelegramEvent(Event): - def __init__(self, *args, chat_id: int, **kwargs): - super().__init__(*args, chat_id=chat_id, **kwargs) - - -class MessageEvent(TelegramEvent): - """ - Event triggered when a new message is received by the Telegram bot. - """ - def __init__(self, *args, message, user, **kwargs): - super().__init__(*args, message=message, user=user, **kwargs) - - -class CommandMessageEvent(MessageEvent): - """ - Event triggered when a new message is received by the Telegram bot. - """ - def __init__(self, command: str, cmdargs: Optional[List[str]] = None, *args, **kwargs): - super().__init__(*args, command=command, cmdargs=(cmdargs or []), **kwargs) - - -class TextMessageEvent(MessageEvent): - pass - - -class PhotoMessageEvent(MessageEvent): - pass - - -class VideoMessageEvent(MessageEvent): - pass - - -class ContactMessageEvent(MessageEvent): - pass - - -class LocationMessageEvent(MessageEvent): - pass - - -class DocumentMessageEvent(MessageEvent): - pass - - -class GroupChatCreatedEvent(MessageEvent): - pass - - -# vim:sw=4:ts=4:et: diff --git a/platypush/message/event/telegram.py b/platypush/message/event/telegram.py new file mode 100644 index 0000000000..65308e0dbf --- /dev/null +++ b/platypush/message/event/telegram.py @@ -0,0 +1,87 @@ +from typing import List, Optional + +from platypush.message.event import Event + + +class TelegramEvent(Event): + """ + Base class for all the Telegram events. + """ + + def __init__(self, *args, chat_id: int, **kwargs): + super().__init__(*args, chat_id=chat_id, **kwargs) + + +class MessageEvent(TelegramEvent): + """ + Event triggered when a new message is received by the Telegram bot. + """ + + def __init__( # pylint: disable=useless-parent-delegation + self, *args, message: Optional[dict], user: Optional[dict], **kwargs + ): + """ + :param message: .. schema:: telegram.TelegramMessageSchema + :param user: .. schema:: telegram.TelegramUserSchema + """ + super().__init__(*args, message=message, user=user, **kwargs) + + +class CommandMessageEvent(MessageEvent): + """ + Event triggered when a new message is received by the Telegram bot. + """ + + def __init__( # pylint: disable=useless-parent-delegation + self, *args, command: str, cmdargs: Optional[List[str]] = None, **kwargs + ): + """ + :param command: Command name. + :param cmdargs: Command arguments. + """ + super().__init__(*args, command=command, cmdargs=(cmdargs or []), **kwargs) + + +class TextMessageEvent(MessageEvent): + """ + Event triggered when a new text message is received by the Telegram bot. + """ + + +class PhotoMessageEvent(MessageEvent): + """ + Event triggered when a new photo message is received by the Telegram bot. + """ + + +class VideoMessageEvent(MessageEvent): + """ + Event triggered when a new video message is received by the Telegram bot. + """ + + +class ContactMessageEvent(MessageEvent): + """ + Event triggered when a new contact message is received by the Telegram bot. + """ + + +class LocationMessageEvent(MessageEvent): + """ + Event triggered when a new location message is received by the Telegram bot. + """ + + +class DocumentMessageEvent(MessageEvent): + """ + Event triggered when a new document message is received by the Telegram bot. + """ + + +class GroupChatCreatedEvent(MessageEvent): + """ + Event triggered when a new group chat is created. + """ + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/response/chat/__init__.py b/platypush/message/response/chat/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/platypush/message/response/chat/telegram.py b/platypush/message/response/chat/telegram.py deleted file mode 100644 index 5bacb3df56..0000000000 --- a/platypush/message/response/chat/telegram.py +++ /dev/null @@ -1,172 +0,0 @@ -import datetime - -from typing import Optional, List - -from platypush.message.response import Response - - -class TelegramMessageResponse(Response): - def __init__(self, - message_id: int, - chat_id: int, - creation_date: Optional[datetime.datetime], - chat_username: Optional[str] = None, - chat_firstname: Optional[str] = None, - chat_lastname: Optional[str] = None, - from_user_id: Optional[int] = None, - from_username: Optional[str] = None, - from_firstname: Optional[str] = None, - from_lastname: Optional[str] = None, - text: Optional[str] = None, - caption: Optional[str] = None, - edit_date: Optional[datetime.datetime] = None, - forward_date: Optional[datetime.datetime] = None, - forward_from_message_id: Optional[int] = None, - photo_file_id: Optional[str] = None, - photo_file_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - document_file_id: Optional[str] = None, - document_file_name: Optional[str] = None, - document_file_size: Optional[str] = None, - document_mime_type: Optional[str] = None, - audio_file_id: Optional[str] = None, - audio_file_size: Optional[str] = None, - audio_mime_type: Optional[str] = None, - audio_performer: Optional[str] = None, - audio_title: Optional[str] = None, - audio_duration: Optional[str] = None, - location_latitude: Optional[float] = None, - location_longitude: Optional[float] = None, - contact_phone_number: Optional[str] = None, - contact_first_name: Optional[str] = None, - contact_last_name: Optional[str] = None, - contact_user_id: Optional[int] = None, - contact_vcard: Optional[str] = None, - video_file_id: Optional[str] = None, - video_file_size: Optional[int] = None, - video_width: Optional[int] = None, - video_height: Optional[int] = None, - video_mime_type: Optional[str] = None, - video_duration: Optional[str] = None, - link: Optional[str] = None, - media_group_id: Optional[int] = None, - *args, **kwargs): - super().__init__(*args, output={ - 'message_id': message_id, - 'chat_id': chat_id, - 'chat_username': chat_username, - 'chat_firstname': chat_firstname, - 'chat_lastname': chat_lastname, - 'from_user_id': from_user_id, - 'from_username': from_username, - 'from_firstname': from_firstname, - 'from_lastname': from_lastname, - 'text': text, - 'caption': caption, - 'creation_date': creation_date, - 'edit_date': edit_date, - 'forward_from_message_id': forward_from_message_id, - 'forward_date': forward_date, - 'photo_file_id': photo_file_id, - 'photo_file_size': photo_file_size, - 'photo_width': photo_width, - 'photo_height': photo_height, - 'document_file_id': document_file_id, - 'document_file_name': document_file_name, - 'document_file_size': document_file_size, - 'document_mime_type': document_mime_type, - 'audio_file_id': audio_file_id, - 'audio_file_size': audio_file_size, - 'audio_performer': audio_performer, - 'audio_title': audio_title, - 'audio_duration': audio_duration, - 'audio_mime_type': audio_mime_type, - 'video_file_id': video_file_id, - 'video_file_size': video_file_size, - 'video_width': video_width, - 'video_height': video_height, - 'video_duration': video_duration, - 'video_mime_type': video_mime_type, - 'link': link, - 'location_latitude': location_latitude, - 'location_longitude': location_longitude, - 'contact_phone_number': contact_phone_number, - 'contact_first_name': contact_first_name, - 'contact_last_name': contact_last_name, - 'contact_user_id': contact_user_id, - 'contact_vcard': contact_vcard, - 'media_group_id': media_group_id, - }, **kwargs) - - -class TelegramFileResponse(Response): - def __init__(self, - file_id: str, - file_path: str, - file_size: int, - *args, **kwargs): - super().__init__(*args, output={ - 'file_id': file_id, - 'file_path': file_path, - 'file_size': file_size, - }, **kwargs) - - -class TelegramChatResponse(Response): - # noinspection PyShadowingBuiltins - def __init__(self, - chat_id: int, - link: str, - username: str, - invite_link: Optional[str], - title: Optional[str] = None, - description: Optional[str] = None, - type: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - *args, **kwargs): - super().__init__(*args, output={ - 'chat_id': chat_id, - 'link': link, - 'invite_link': invite_link, - 'username': username, - 'title': title, - 'description': description, - 'type': type, - 'first_name': first_name, - 'last_name': last_name, - }, **kwargs) - - -class TelegramUserResponse(Response): - # noinspection PyShadowingBuiltins - def __init__(self, - user_id: int, - username: str, - is_bot: bool, - first_name: str, - last_name: Optional[str] = None, - language_code: Optional[str] = None, - link: Optional[str] = None, - *args, **kwargs): - super().__init__(*args, output={ - 'user_id': user_id, - 'username': username, - 'is_bot': is_bot, - 'link': link, - 'language_code': language_code, - 'first_name': first_name, - 'last_name': last_name, - }, **kwargs) - - -class TelegramUsersResponse(Response): - # noinspection PyShadowingBuiltins - def __init__(self, - users: List[TelegramUserResponse], - *args, **kwargs): - super().__init__(*args, output=[user.output for user in users], **kwargs) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index 0fa7d62b69..4692aed8d3 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -221,7 +221,9 @@ class RunnablePlugin(Plugin): except Exception as e: self.logger.warning('Could not join thread on stop: %s', e) - self.logger.info('%s stopped', self.__class__.__name__) + self.logger.info( + 'Stopped plugin: [%s]', get_plugin_name_by_class(self.__class__) + ) def _runner(self): """ @@ -231,7 +233,7 @@ class RunnablePlugin(Plugin): return self.logger.info( - 'Starting plugin [%s]', get_plugin_name_by_class(self.__class__) + 'Starting plugin: [%s]', get_plugin_name_by_class(self.__class__) ) while not self.should_stop(): diff --git a/platypush/plugins/chat/__init__.py b/platypush/plugins/chat/__init__.py index 3e657a4e44..6bd75ef913 100644 --- a/platypush/plugins/chat/__init__.py +++ b/platypush/plugins/chat/__init__.py @@ -5,6 +5,7 @@ class ChatPlugin(Plugin): """ Base class for chat plugins. """ + @action - def send_message(self, *args, **kwargs): + def send_message(self, *_, **__): raise NotImplementedError() diff --git a/platypush/plugins/chat/telegram/__init__.py b/platypush/plugins/chat/telegram/__init__.py deleted file mode 100644 index 504f21217a..0000000000 --- a/platypush/plugins/chat/telegram/__init__.py +++ /dev/null @@ -1,1041 +0,0 @@ -import datetime -import os - -from threading import RLock -from typing import Optional, Union - -from telegram.ext import Updater -from telegram.message import Message as TelegramMessage -from telegram.user import User as TelegramUser - -from platypush.message.response.chat.telegram import ( - TelegramMessageResponse, - TelegramFileResponse, - TelegramChatResponse, - TelegramUserResponse, - TelegramUsersResponse, -) -from platypush.plugins import action -from platypush.plugins.chat import ChatPlugin - - -class Resource: - def __init__( - self, - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - ): - assert file_id or url or path, 'You need to specify either file_id, url or path' - self.file_id = file_id - self.url = url - self.path = path - self._file = None - - def __enter__(self): - if self.path: - self._file = open( # noqa - os.path.abspath(os.path.expanduser(self.path)), 'rb' - ) - return self._file - - return self.file_id or self.url - - def __exit__(self, *_, **__): - if self._file: - self._file.close() - - -class ChatTelegramPlugin(ChatPlugin): - """ - Plugin to programmatically send Telegram messages through a Telegram bot. In order to send messages to contacts, - groups or channels you'll first need to register a bot. To do so: - - 1. Open a Telegram conversation with the `@BotFather `_. - 2. Send ``/start`` followed by ``/newbot``. Choose a display name and a username for your bot. - 3. Copy the provided API token in the configuration of this plugin. - 4. Open a conversation with your newly created bot. - - """ - - def __init__(self, api_token: str, **kwargs): - """ - :param api_token: API token as returned by the `@BotFather `_ - """ - super().__init__(**kwargs) - - self._api_token = api_token - self._telegram_lock = RLock() - self._telegram: Optional[Updater] = None - - def get_telegram(self) -> Updater: - with self._telegram_lock: - if self._telegram: - return self._telegram - - self._telegram = Updater(self._api_token, use_context=True) - return self._telegram - - @staticmethod - def parse_msg(msg: TelegramMessage) -> TelegramMessageResponse: - return TelegramMessageResponse( - message_id=msg.message_id, - chat_id=msg.chat_id, - chat_username=msg.chat.username, - chat_firstname=msg.chat.first_name, - chat_lastname=msg.chat.last_name, - from_user_id=msg.from_user.id if msg.from_user else None, - from_username=msg.from_user.username if msg.from_user else None, - from_firstname=msg.from_user.first_name if msg.from_user else None, - from_lastname=msg.from_user.last_name if msg.from_user else None, - text=msg.text, - caption=msg.caption, - creation_date=msg.date, - edit_date=msg.edit_date, - forward_date=msg.forward_date, - forward_from_message_id=msg.forward_from_message_id, - photo_file_id=msg.photo[0].file_id if msg.photo else None, - photo_file_size=msg.photo[0].file_size if msg.photo else None, - photo_width=msg.photo[0].width if msg.photo else None, - photo_height=msg.photo[0].height if msg.photo else None, - document_file_id=msg.document.file_id if msg.document else None, - document_file_name=msg.document.file_name if msg.document else None, - document_file_size=msg.document.file_size if msg.document else None, - document_mime_type=msg.document.mime_type if msg.document else None, - audio_file_id=msg.audio.file_id if msg.audio else None, - audio_file_size=msg.audio.file_size if msg.audio else None, - audio_performer=msg.audio.performer if msg.audio else None, - audio_title=msg.audio.title if msg.audio else None, - audio_duration=msg.audio.duration if msg.audio else None, - audio_mime_type=msg.audio.mime_type if msg.audio else None, - video_file_id=msg.video.file_id if msg.video else None, - video_file_size=msg.video.file_size if msg.video else None, - video_duration=msg.video.duration if msg.video else None, - video_width=msg.video.width if msg.video else None, - video_height=msg.video.height if msg.video else None, - video_mime_type=msg.video.mime_type if msg.video else None, - location_latitude=msg.location.latitude if msg.location else None, - location_longitude=msg.location.longitude if msg.location else None, - contact_phone_number=msg.contact.phone_number if msg.contact else None, - contact_first_name=msg.contact.first_name if msg.contact else None, - contact_last_name=msg.contact.last_name if msg.contact else None, - contact_user_id=msg.contact.user_id if msg.contact else None, - contact_vcard=msg.contact.vcard if msg.contact else None, - link=msg.link, - media_group_id=msg.media_group_id, - ) - - @staticmethod - def parse_user(user: TelegramUser) -> TelegramUserResponse: - return TelegramUserResponse( - user_id=user.id, - username=user.username, - is_bot=user.is_bot, - first_name=user.first_name, - last_name=user.last_name, - language_code=user.language_code, - link=user.link, - ) - - @action - def send_message( - self, - chat_id: Union[str, int], - text: str, - parse_mode: Optional[str] = None, - disable_web_page_preview: bool = False, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - ) -> TelegramMessageResponse: - """ - Send a message to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param text: Text to be sent. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_web_page_preview: If True then web previews for URLs will be disabled. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - """ - - telegram = self.get_telegram() - msg = telegram.bot.send_message( - chat_id=chat_id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - ) - - return self.parse_msg(msg) - - @action - def send_photo( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send a picture to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param caption: Optional caption for the picture. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_photo( - chat_id=chat_id, - photo=resource, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode, - ) - - return self.parse_msg(msg) - - @action - def send_audio( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - caption: Optional[str] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - duration: Optional[float] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send audio to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param caption: Optional caption for the picture. - :param performer: Optional audio performer. - :param title: Optional audio title. - :param duration: Duration of the audio in seconds. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_audio( - chat_id=chat_id, - audio=resource, - caption=caption, - disable_notification=disable_notification, - performer=performer, - title=title, - duration=duration, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode, - ) - - return self.parse_msg(msg) - - @action - def send_document( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - filename: Optional[str] = None, - caption: Optional[str] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send a document to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param filename: Name of the file as it will be shown in Telegram. - :param caption: Optional caption for the picture. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_document( - chat_id=chat_id, - document=resource, - filename=filename, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode, - ) - - return self.parse_msg(msg) - - @action - def send_video( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - duration: Optional[int] = None, - caption: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send a video to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param duration: Duration in seconds. - :param caption: Optional caption for the picture. - :param width: Video width. - :param height: Video height. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_video( - chat_id=chat_id, - video=resource, - duration=duration, - caption=caption, - width=width, - height=height, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode, - ) - - return self.parse_msg(msg) - - @action - def send_animation( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - duration: Optional[int] = None, - caption: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send an animation (GIF or H.264/MPEG-4 AVC video without sound) to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param duration: Duration in seconds. - :param caption: Optional caption for the picture. - :param width: Video width. - :param height: Video height. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_animation( - chat_id=chat_id, - animation=resource, - duration=duration, - caption=caption, - width=width, - height=height, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode, - ) - - return self.parse_msg(msg) - - @action - def send_voice( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - caption: Optional[str] = None, - duration: Optional[float] = None, - parse_mode: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send audio to a chat as a voice file. For this to work, your audio must be in an .ogg file encoded with OPUS - (other formats may be sent as Audio or Document). - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param caption: Optional caption for the picture. - :param duration: Duration of the voice in seconds. - :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown or HTML content. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_voice( - chat_id=chat_id, - voice=resource, - caption=caption, - disable_notification=disable_notification, - duration=duration, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - parse_mode=parse_mode, - ) - - return self.parse_msg(msg) - - @action - def send_video_note( - self, - chat_id: Union[str, int], - file_id: Optional[int] = None, - url: Optional[str] = None, - path: Optional[str] = None, - duration: Optional[int] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send a video note to a chat. As of v.4.0, Telegram clients support rounded square mp4 videos of up to - 1 minute long. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param file_id: Set it if the file already exists on Telegram servers and has a file_id. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param url: Set it if you want to send a file from a remote URL. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param path: Set it if you want to send a file from the local filesystem. - Note that you'll have to specify either ``file_id``, ``url`` or ``path``. - - :param duration: Duration in seconds. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(file_id=file_id, url=url, path=path) as resource: - msg = telegram.bot.send_video_note( - chat_id=chat_id, - video=resource, - duration=duration, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - ) - - return self.parse_msg(msg) - - @action - def send_location( - self, - chat_id: Union[str, int], - latitude: float, - longitude: float, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send a location to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param latitude: Latitude - :param longitude: Longitude - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - msg = telegram.bot.send_location( - chat_id=chat_id, - latitude=latitude, - longitude=longitude, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - ) - - return self.parse_msg(msg) - - @action - def send_venue( - self, - chat_id: Union[str, int], - latitude: float, - longitude: float, - title: str, - address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send the address of a venue to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param latitude: Latitude - :param longitude: Longitude - :param title: Venue name. - :param address: Venue address. - :param foursquare_id: Foursquare ID. - :param foursquare_type: Foursquare type. - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - msg = telegram.bot.send_venue( - chat_id=chat_id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - foursquare_type=foursquare_type, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - ) - - return self.parse_msg(msg) - - @action - def send_contact( - self, - chat_id: Union[str, int], - phone_number: str, - first_name: str, - last_name: Optional[str] = None, - vcard: Optional[str] = None, - disable_notification: bool = False, - reply_to_message_id: Optional[int] = None, - timeout: int = 20, - ) -> TelegramMessageResponse: - """ - Send a contact to a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param phone_number: Phone number. - :param first_name: First name. - :param last_name: Last name. - :param vcard: Additional contact info in vCard format (0-2048 bytes). - :param disable_notification: If True then no notification will be sent to the users. - :param reply_to_message_id: If set then the message will be sent as a response to the specified message. - :param timeout: Upload timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - msg = telegram.bot.send_contact( - chat_id=chat_id, - phone_number=phone_number, - first_name=first_name, - last_name=last_name, - vcard=vcard, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - timeout=timeout, - ) - - return self.parse_msg(msg) - - @action - def get_file(self, file_id: str, timeout: int = 20) -> TelegramFileResponse: - """ - Get the info and URL of an uploaded file by file_id. - - :param file_id: File ID. - :param timeout: Upload timeout (default: 20 seconds). - """ - - telegram = self.get_telegram() - file = telegram.bot.get_file(file_id, timeout=timeout) - return TelegramFileResponse( - file_id=file.file_id, file_path=file.file_path, file_size=file.file_size - ) - - @action - def get_chat( - self, chat_id: Union[int, str], timeout: int = 20 - ) -> TelegramChatResponse: - """ - Get the info about a Telegram chat. - - :param chat_id: Chat ID. - :param timeout: Upload timeout (default: 20 seconds). - """ - - telegram = self.get_telegram() - chat = telegram.bot.get_chat(chat_id, timeout=timeout) - return TelegramChatResponse( - chat_id=chat.id, - link=chat.link, - username=chat.username, - invite_link=chat.invite_link, - title=chat.title, - description=chat.description, - type=chat.type, - first_name=chat.first_name, - last_name=chat.last_name, - ) - - @action - def get_chat_user( - self, chat_id: Union[int, str], user_id: int, timeout: int = 20 - ) -> TelegramUserResponse: - """ - Get the info about a user connected to a chat. - - :param chat_id: Chat ID. - :param user_id: User ID. - :param timeout: Upload timeout (default: 20 seconds). - """ - - telegram = self.get_telegram() - user = telegram.bot.get_chat_member(chat_id, user_id, timeout=timeout) - return TelegramUserResponse( - user_id=user.user.id, - link=user.user.link, - username=user.user.username, - first_name=user.user.first_name, - last_name=user.user.last_name, - is_bot=user.user.is_bot, - language_code=user.user.language_code, - ) - - @action - def get_chat_administrators( - self, chat_id: Union[int, str], timeout: int = 20 - ) -> TelegramUsersResponse: - """ - Get the list of the administrators of a chat. - - :param chat_id: Chat ID. - :param timeout: Upload timeout (default: 20 seconds). - """ - - telegram = self.get_telegram() - admins = telegram.bot.get_chat_administrators(chat_id, timeout=timeout) - return TelegramUsersResponse( - [ - TelegramUserResponse( - user_id=user.user.id, - link=user.user.link, - username=user.user.username, - first_name=user.user.first_name, - last_name=user.user.last_name, - is_bot=user.user.is_bot, - language_code=user.user.language_code, - ) - for user in admins - ] - ) - - @action - def get_chat_members_count( - self, chat_id: Union[int, str], timeout: int = 20 - ) -> int: - """ - Get the number of users in a chat. - - :param chat_id: Chat ID. - :param timeout: Upload timeout (default: 20 seconds). - """ - telegram = self.get_telegram() - return telegram.bot.get_chat_members_count(chat_id, timeout=timeout) - - @action - def kick_chat_member( - self, - chat_id: Union[str, int], - user_id: int, - until_date: Optional[datetime.datetime] = None, - timeout: int = 20, - ): - """ - Kick a user from a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param user_id: Unique user ID. - :param until_date: End date for the ban. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.kick_chat_member( - chat_id=chat_id, user_id=user_id, until_date=until_date, timeout=timeout - ) - - @action - def unban_chat_member( - self, chat_id: Union[str, int], user_id: int, timeout: int = 20 - ): - """ - Lift the ban from a chat member. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param user_id: Unique user ID. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.unban_chat_member( - chat_id=chat_id, user_id=user_id, timeout=timeout - ) - - @action - def promote_chat_member( - self, - chat_id: Union[str, int], - user_id: int, - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - timeout: int = 20, - ): - """ - Promote or demote a member. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param user_id: Unique user ID. - :param can_change_info: Pass True if the user can change channel info. - :param can_post_messages: Pass True if the user can post messages. - :param can_edit_messages: Pass True if the user can edit messages. - :param can_delete_messages: Pass True if the user can delete messages. - :param can_invite_users: Pass True if the user can invite other users to the channel/group. - :param can_restrict_members: Pass True if the user can restrict the permissions of other users. - :param can_promote_members: Pass True if the user can promote mebmers. - :param can_pin_messages: Pass True if the user can pin messages. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.promote_chat_member( - chat_id=chat_id, - user_id=user_id, - can_change_info=can_change_info, - can_post_messages=can_post_messages, - can_edit_messages=can_edit_messages, - can_delete_messages=can_delete_messages, - can_invite_users=can_invite_users, - can_restrict_members=can_restrict_members, - can_promote_members=can_promote_members, - can_pin_messages=can_pin_messages, - timeout=timeout, - ) - - @action - def set_chat_title(self, chat_id: Union[str, int], title: str, timeout: int = 20): - """ - Set the title of a channel/group. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param title: New chat title. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.set_chat_title(chat_id=chat_id, description=title, timeout=timeout) - - @action - def set_chat_description( - self, chat_id: Union[str, int], description: str, timeout: int = 20 - ): - """ - Set the description of a channel/group. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param description: New chat description. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.set_chat_description( - chat_id=chat_id, description=description, timeout=timeout - ) - - @action - def set_chat_photo(self, chat_id: Union[str, int], path: str, timeout: int = 20): - """ - Set the photo of a channel/group. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param path: Path of the new image. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - - with Resource(path=path) as resource: - telegram.bot.set_chat_photo( - chat_id=chat_id, photo=resource, timeout=timeout - ) - - @action - def delete_chat_photo(self, chat_id: Union[str, int], timeout: int = 20): - """ - Delete the photo of a channel/group. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.delete_chat_photo(chat_id=chat_id, timeout=timeout) - - @action - def pin_chat_message( - self, - chat_id: Union[str, int], - message_id: int, - disable_notification: Optional[bool] = None, - timeout: int = 20, - ): - """ - Pin a message in a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param message_id: Message ID. - :param disable_notification: If True then no notification will be sent to the users. - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.pin_chat_message( - chat_id=chat_id, - message_id=message_id, - disable_notification=disable_notification, - timeout=timeout, - ) - - @action - def unpin_chat_message(self, chat_id: Union[str, int], timeout: int = 20): - """ - Unpin the message of a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.unpin_chat_message(chat_id=chat_id, timeout=timeout) - - @action - def leave_chat(self, chat_id: Union[str, int], timeout: int = 20): - """ - Leave a chat. - - :param chat_id: Chat ID. Can be either a numerical ID or a unique identifier in the format ``@channelname``. - In order to get your own Telegram chat_id open a conversation with - `@IDBot `_ and type ``/start`` followed by ``/getid``. Similar procedures - also exist to get a group or channel chat_id - just Google for "Telegram get channel/group chat_id". - - :param timeout: Request timeout (default: 20 seconds) - """ - - telegram = self.get_telegram() - telegram.bot.leave_chat(chat_id=chat_id, timeout=timeout) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/chat/telegram/manifest.yaml b/platypush/plugins/chat/telegram/manifest.yaml deleted file mode 100644 index 8f33fecf60..0000000000 --- a/platypush/plugins/chat/telegram/manifest.yaml +++ /dev/null @@ -1,7 +0,0 @@ -manifest: - events: {} - install: - pip: - - python-telegram-bot - package: platypush.plugins.chat.telegram - type: plugin diff --git a/platypush/plugins/telegram/__init__.py b/platypush/plugins/telegram/__init__.py new file mode 100644 index 0000000000..f1a0d5bc48 --- /dev/null +++ b/platypush/plugins/telegram/__init__.py @@ -0,0 +1,1063 @@ +import datetime +import logging + +from multiprocessing import Queue as PQueue +from queue import Empty, Queue as TQueue +from typing import Dict, List, Optional, Union +from uuid import UUID + +from platypush.plugins import RunnablePlugin, action +from platypush.plugins.chat import ChatPlugin +from platypush.schemas.telegram import ( + TelegramChatSchema, + TelegramFileSchema, +) +from platypush.utils import wait_for_either + +from ._bridge import ResultBridge +from ._model import Command, Resource, END_OF_SERVICE +from ._service import TelegramService +from ._utils import dump_msg, dump_user + + +class TelegramPlugin(ChatPlugin, RunnablePlugin): + """ + Plugin to programmatically send Telegram messages through a Telegram bot. + In order to send messages to contacts, groups or channels you'll first need + to register a bot. To do so: + + 1. Open a Telegram conversation with the `@BotFather + `_. + 2. Send ``/start`` followed by ``/newbot``. Choose a display name and a + username for your bot. + 3. Copy the provided API token in the configuration of this plugin. + 4. Open a conversation with your newly created bot. + + """ + + _DEFAULT_TIMEOUT = 20 + + def __init__( + self, + api_token: str, + authorized_chat_ids: Optional[List[Union[str, int]]] = None, + **kwargs, + ): + """ + :param api_token: API token as returned by the `@BotFather + `_ + :param authorized_chat_ids: Optional list of chat_id/user_id which are + authorized to send messages to the bot. If nothing is specified + then no restrictions are applied. + """ + super().__init__(**kwargs) + self._authorized_chat_ids = set(authorized_chat_ids or []) + self._api_token = api_token + self._cmd_queue = PQueue() + self._result_queue = PQueue() + self._service: Optional[TelegramService] = None + self._response_queues: Dict[UUID, TQueue] = {} + self._result_bridge: Optional[ResultBridge] = None + + # Set httpx logging to WARNING, or it will log a line for every request + logging.getLogger("httpx").setLevel(logging.WARNING) + + def _exec( + self, cmd: str, *args, timeout: Optional[float] = _DEFAULT_TIMEOUT, **kwargs + ): + assert self._service, "Telegram service not running" + cmd_obj = Command(cmd, args=args, kwargs=kwargs, timeout=timeout) + self._response_queues[cmd_obj.id] = TQueue() + self._cmd_queue.put_nowait(cmd_obj) + + try: + result = self._response_queues[cmd_obj.id].get(timeout=timeout) + except (TimeoutError, Empty) as e: + raise TimeoutError(f"Timeout while executing command {cmd}") from e + finally: + self._response_queues.pop(cmd_obj.id, None) + + assert not isinstance( + result, Exception + ), f'Error while executing command {cmd}: {result}' + + return result + + @property + def response_queues(self): + return self._response_queues + + @property + def result_queue(self): + return self._result_queue + + @action + def send_message( # pylint: disable=arguments-differ + self, + chat_id: Union[str, int], + text: str, + *_, + parse_mode: Optional[str] = None, + disable_web_page_preview: bool = False, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + **__, + ) -> dict: + """ + Send a message to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param text: Text to be sent. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_web_page_preview: If True then web previews for URLs + will be disabled. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Request timeout (default: 20 seconds). + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_message', + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) + ) + + @action + def send_photo( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send a picture to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or + ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param caption: Optional caption for the picture. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_photo', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='photo', + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) + ) + + @action + def send_audio( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + caption: Optional[str] = None, + performer: Optional[str] = None, + title: Optional[str] = None, + duration: Optional[float] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send audio to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or + ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param caption: Optional caption for the picture. + :param performer: Optional audio performer. + :param title: Optional audio title. + :param duration: Duration of the audio in seconds. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_audio', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='audio', + caption=caption, + disable_notification=disable_notification, + performer=performer, + title=title, + duration=duration, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) + ) + + @action + def send_document( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + filename: Optional[str] = None, + caption: Optional[str] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send a document to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or + ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param filename: Name of the file as it will be shown in Telegram. + :param caption: Optional caption for the picture. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_document', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='document', + filename=filename, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) + ) + + @action + def send_video( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + duration: Optional[int] = None, + caption: Optional[str] = None, + width: Optional[int] = None, + height: Optional[int] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send a video to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or + ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param duration: Duration in seconds. + :param caption: Optional caption for the picture. + :param width: Video width. + :param height: Video height. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_video', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='video', + duration=duration, + caption=caption, + width=width, + height=height, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) + ) + + @action + def send_animation( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + duration: Optional[int] = None, + caption: Optional[str] = None, + width: Optional[int] = None, + height: Optional[int] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send an animation (GIF or H.264/MPEG-4 AVC video without sound) to a + chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or + ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param duration: Duration in seconds. + :param caption: Optional caption for the picture. + :param width: Video width. + :param height: Video height. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_animation', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='animation', + duration=duration, + caption=caption, + width=width, + height=height, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) + ) + + @action + def send_voice( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + caption: Optional[str] = None, + duration: Optional[float] = None, + parse_mode: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send audio to a chat as a voice file. For this to work, your audio must + be in an .ogg file encoded with OPUS (other formats may be sent as + Audio or Document). + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param caption: Optional caption for the picture. + :param duration: Duration of the voice in seconds. + :param parse_mode: Set to 'Markdown' or 'HTML' to send either Markdown + or HTML content. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_voice', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='voice', + caption=caption, + disable_notification=disable_notification, + duration=duration, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + parse_mode=parse_mode, + ) + ) + + @action + def send_video_note( + self, + chat_id: Union[str, int], + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + duration: Optional[int] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send a video note to a chat. As of v.4.0, Telegram clients support + rounded square mp4 videos of up to 1 minute long. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param file_id: Set it if the file already exists on Telegram servers + and has a file_id. Note that you'll have to specify either + ``file_id``, ``url`` or ``path``. + :param url: Set it if you want to send a file from a remote URL. Note + that you'll have to specify either ``file_id``, ``url`` or + ``path``. + :param path: Set it if you want to send a file from the local + filesystem. Note that you'll have to specify either ``file_id``, + ``url`` or ``path``. + :param duration: Duration in seconds. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_video_note', + chat_id=chat_id, + resource=Resource(file_id=file_id, url=url, path=path), + resource_attr='video', + duration=duration, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) + ) + + @action + def send_location( + self, + chat_id: Union[str, int], + latitude: float, + longitude: float, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send a location to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param latitude: Latitude + :param longitude: Longitude + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_location', + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) + ) + + @action + def send_venue( + self, + chat_id: Union[str, int], + latitude: float, + longitude: float, + title: str, + address: str, + foursquare_id: Optional[str] = None, + foursquare_type: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send the address of a venue to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param latitude: Latitude + :param longitude: Longitude + :param title: Venue name. + :param address: Venue address. + :param foursquare_id: Foursquare ID. + :param foursquare_type: Foursquare type. + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_venue', + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + foursquare_type=foursquare_type, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) + ) + + @action + def send_contact( + self, + chat_id: Union[str, int], + phone_number: str, + first_name: str, + last_name: Optional[str] = None, + vcard: Optional[str] = None, + disable_notification: bool = False, + reply_to_message_id: Optional[int] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Send a contact to a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param phone_number: Phone number. + :param first_name: First name. + :param last_name: Last name. + :param vcard: Additional contact info in vCard format (0-2048 bytes). + :param disable_notification: If True then no notification will be sent + to the users. + :param reply_to_message_id: If set then the message will be sent as a + response to the specified message. + :param timeout: Upload timeout (default: 20 seconds) + :return: .. schema:: telegram.TelegramMessageSchema + """ + return dump_msg( + self._exec( + 'send_contact', + chat_id=chat_id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + vcard=vcard, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + timeout=timeout, + ) + ) + + @action + def get_file(self, file_id: str, timeout: Optional[float] = None) -> dict: + """ + Get the info and URL of an uploaded file by file_id. + + :param file_id: File ID. + :param timeout: Upload timeout (default: 20 seconds). + :return: .. schema:: telegram.TelegramFileSchema + """ + return dict( + TelegramFileSchema().dump(self._exec('get_file', file_id, timeout=timeout)) + ) + + @action + def get_chat( + self, chat_id: Union[int, str], timeout: Optional[float] = _DEFAULT_TIMEOUT + ) -> dict: + """ + Get the info about a Telegram chat. + + :param chat_id: Chat ID. + :param timeout: Upload timeout (default: 20 seconds). + """ + return dict( + TelegramChatSchema().dump(self._exec('get_chat', chat_id, timeout=timeout)) + ) + + @action + def get_chat_user( + self, + chat_id: Union[int, str], + user_id: int, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ) -> dict: + """ + Get the info about a user connected to a chat. + + :param chat_id: Chat ID. + :param user_id: User ID. + :param timeout: Upload timeout (default: 20 seconds). + """ + return dump_user( + self._exec('get_chat_member', chat_id, user_id, timeout=timeout) + ) + + @action + def get_chat_administrators( + self, chat_id: Union[int, str], timeout: Optional[float] = _DEFAULT_TIMEOUT + ) -> List[dict]: + """ + Get the list of the administrators of a chat. + + :param chat_id: Chat ID. + :param timeout: Upload timeout (default: 20 seconds). + :return: .. schema:: telegram.TelegramUserSchema(many=True) + """ + return [ + dump_user(user.user) + for user in self._exec('get_chat_administrators', chat_id, timeout=timeout) + ] + + @action + def get_chat_members_count( + self, chat_id: Union[int, str], timeout: Optional[float] = _DEFAULT_TIMEOUT + ) -> int: + """ + Get the number of users in a chat. + + :param chat_id: Chat ID. + :param timeout: Upload timeout (default: 20 seconds). + """ + return self._exec('get_chat_members_count', chat_id, timeout=timeout) + + @action + def kick_chat_member( + self, + chat_id: Union[str, int], + user_id: int, + until_date: Optional[datetime.datetime] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Kick a user from a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param user_id: Unique user ID. + :param until_date: End date for the ban. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'kick_chat_member', + chat_id=chat_id, + user_id=user_id, + until_date=until_date, + timeout=timeout, + ) + + @action + def unban_chat_member( + self, + chat_id: Union[str, int], + user_id: int, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Lift the ban from a chat member. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param user_id: Unique user ID. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'unban_chat_member', chat_id=chat_id, user_id=user_id, timeout=timeout + ) + + @action + def promote_chat_member( + self, + chat_id: Union[str, int], + user_id: int, + can_change_info: Optional[bool] = None, + can_post_messages: Optional[bool] = None, + can_edit_messages: Optional[bool] = None, + can_delete_messages: Optional[bool] = None, + can_invite_users: Optional[bool] = None, + can_restrict_members: Optional[bool] = None, + can_promote_members: Optional[bool] = None, + can_pin_messages: Optional[bool] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Promote or demote a member. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param user_id: Unique user ID. + :param can_change_info: Pass True if the user can change channel info. + :param can_post_messages: Pass True if the user can post messages. + :param can_edit_messages: Pass True if the user can edit messages. + :param can_delete_messages: Pass True if the user can delete messages. + :param can_invite_users: Pass True if the user can invite other users + to the channel/group. + :param can_restrict_members: Pass True if the user can restrict the + permissions of other users. + :param can_promote_members: Pass True if the user can promote mebmers. + :param can_pin_messages: Pass True if the user can pin messages. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'promote_chat_member', + chat_id=chat_id, + user_id=user_id, + can_change_info=can_change_info, + can_post_messages=can_post_messages, + can_edit_messages=can_edit_messages, + can_delete_messages=can_delete_messages, + can_invite_users=can_invite_users, + can_restrict_members=can_restrict_members, + can_promote_members=can_promote_members, + can_pin_messages=can_pin_messages, + timeout=timeout, + ) + + @action + def set_chat_title( + self, + chat_id: Union[str, int], + title: str, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Set the title of a channel/group. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param title: New chat title. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'set_chat_title', chat_id=chat_id, description=title, timeout=timeout + ) + + @action + def set_chat_description( + self, + chat_id: Union[str, int], + description: str, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Set the description of a channel/group. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param description: New chat description. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'set_chat_description', + chat_id=chat_id, + description=description, + timeout=timeout, + ) + + @action + def set_chat_photo( + self, + chat_id: Union[str, int], + path: str, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Set the photo of a channel/group. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param path: Path of the new image. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'set_chat_photo', + chat_id=chat_id, + resource=Resource(path=path), + resource_attr='photo', + timeout=timeout, + ) + + @action + def delete_chat_photo( + self, chat_id: Union[str, int], timeout: Optional[float] = _DEFAULT_TIMEOUT + ): + """ + Delete the photo of a channel/group. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec('delete_chat_photo', chat_id=chat_id, timeout=timeout) + + @action + def pin_chat_message( + self, + chat_id: Union[str, int], + message_id: int, + disable_notification: Optional[bool] = None, + timeout: Optional[float] = _DEFAULT_TIMEOUT, + ): + """ + Pin a message in a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param message_id: Message ID. + :param disable_notification: If True then no notification will be sent + to the users. + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec( + 'pin_chat_message', + chat_id=chat_id, + message_id=message_id, + disable_notification=disable_notification, + timeout=timeout, + ) + + @action + def unpin_chat_message( + self, chat_id: Union[str, int], timeout: Optional[float] = _DEFAULT_TIMEOUT + ): + """ + Unpin the message of a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. In order to get your own + Telegram chat_id open a conversation with `@IDBot + `_ and type ``/start`` followed by + ``/getid``. Similar procedures also exist to get a group or channel + chat_id - just Google for "Telegram get channel/group chat_id". + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec('unpin_chat_message', chat_id=chat_id, timeout=timeout) + + @action + def leave_chat( + self, chat_id: Union[str, int], timeout: Optional[float] = _DEFAULT_TIMEOUT + ): + """ + Leave a chat. + + :param chat_id: Chat ID. Can be either a numerical ID or a unique + identifier in the format ``@channelname``. + In order to get your own Telegram chat_id open a conversation with + `@IDBot `_ and type ``/start`` followed + by ``/getid``. Similar procedures also exist to get a group or + channel chat_id - just Google for "Telegram get channel/group + chat_id". + :param timeout: Request timeout (default: 20 seconds) + """ + self._exec('leave_chat', chat_id=chat_id, timeout=timeout) + + def main(self): + self._result_bridge = ResultBridge(self) + self._result_bridge.start() + + while not self.should_stop(): + self._service = TelegramService( + self._api_token, + cmd_queue=self._cmd_queue, + result_queue=self._result_queue, + authorized_chat_ids=self._authorized_chat_ids, + ) + + self._service.start() + wait_for_either(self._should_stop, self._service.stop_event) + self.wait_stop(10) + + def stop(self): + # Send an END_OF_SERVICE to the result queue to signal that the service + # is stopping + self._cmd_queue.put_nowait(END_OF_SERVICE) + + if self._service: + self._service.stop() + self._service = None + + if self._result_bridge and self._result_bridge.is_alive(): + self._result_bridge.join() + self._result_bridge = None + + super().stop() + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/telegram/_bridge.py b/platypush/plugins/telegram/_bridge.py new file mode 100644 index 0000000000..dc728c6fba --- /dev/null +++ b/platypush/plugins/telegram/_bridge.py @@ -0,0 +1,91 @@ +import logging + +from threading import Thread + +from ._model import Command + + +class CommandBridge(Thread): + """ + The command bridge is a thread that listens for commands on a command + queue, proxies them to the Telegram service and returns the result back to + the response queue. + + This is required because the Telegram service runs in a separate process - + a requirement because of the Telegram bot API constraints, which requires + the asyncio event loop to run in the main thread. + """ + + def __init__(self, service, *_, **__): + from ._service import TelegramService + + super().__init__(name="telegram-service-bridge") + self.logger = logging.getLogger("platypush:telegram:bridge") + self._service: TelegramService = service + + def _exec(self, cmd: Command): + try: + result = self._service.exec( + cmd.cmd, *cmd.args, **cmd.kwargs, timeout=cmd.timeout + ) + except Exception as e: + result = e + + self._service.result_queue.put_nowait((cmd, result)) + + def run(self): + super().run() + + while self._service.is_running(): + try: + cmd = self._service.cmd_queue.get() + except Exception as e: + self.logger.warning("Error while reading command queue: %s", e) + continue + + if cmd is None or cmd.is_end_of_service(): + break + + self._exec(cmd) + + +class ResultBridge(Thread): + """ + The result bridge is a thread that listens for results on a result queue and + proxies them to the response queue of the Telegram service. + + This is required because the Telegram service runs in a separate process - + a requirement because of the Telegram bot API constraints, which requires + the asyncio event loop to run in the main thread. + """ + + def __init__(self, plugin, *_, **__): + from . import TelegramPlugin + + super().__init__(name="telegram-service-result-bridge") + self.logger = logging.getLogger("platypush:telegram:result-bridge") + self._plugin: TelegramPlugin = plugin + + def run(self): + super().run() + + while not self._plugin.should_stop(): + try: + ret = self._plugin.result_queue.get() + except Exception as e: + self.logger.warning("Error while reading result queue: %s", e) + continue + + if not ret: + break + + cmd, result = ret + if cmd is None or cmd.is_end_of_service(): + break + + q = self._plugin.response_queues.get(cmd.id) + if q: + q.put_nowait(result) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/telegram/_model.py b/platypush/plugins/telegram/_model.py new file mode 100644 index 0000000000..d13fe97277 --- /dev/null +++ b/platypush/plugins/telegram/_model.py @@ -0,0 +1,72 @@ +import os +from dataclasses import dataclass, field +from typing import Any, Iterable, Optional +from uuid import UUID, uuid4 + + +class Resource: + """ + Class to handle resources (files) to be sent through the Telegram API. + """ + + def __init__( + self, + file_id: Optional[int] = None, + url: Optional[str] = None, + path: Optional[str] = None, + ): + assert file_id or url or path, 'You need to specify either file_id, url or path' + self.file_id = file_id + self.url = url + self.path = path + self.fd = None + + def __enter__(self): + """ + Context manager to open the file and return the file descriptor. + """ + if self.path: + self.fd = open(os.path.abspath(os.path.expanduser(self.path)), 'rb') # noqa + return self.fd + + return self.file_id or self.url + + def __exit__(self, *_, **__): + """ + If the file was opened, close it. + """ + if self.fd: + self.fd.close() + self.fd = None + + +@dataclass +class Command: + """ + Dataclass to represent a command to be executed on the Telegram service. + """ + + cmd: str + args: Iterable = field(default_factory=list) + kwargs: dict = field(default_factory=dict) + timeout: Optional[float] = None + id: UUID = field(default_factory=uuid4) + + def is_end_of_service(self) -> bool: + """ + Check if a command is the end-of-service command. + """ + return is_end_of_service(self) + + +def is_end_of_service(cmd: Any) -> bool: + """ + Check if a command is the end-of-service command. + """ + return isinstance(cmd, Command) and cmd.cmd == END_OF_SERVICE.cmd + + +END_OF_SERVICE = Command('stop') + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/telegram/_service.py b/platypush/plugins/telegram/_service.py new file mode 100644 index 0000000000..469ef57b6f --- /dev/null +++ b/platypush/plugins/telegram/_service.py @@ -0,0 +1,299 @@ +import asyncio as aio +import logging +import os +import re + +from multiprocessing import Event, Process, Queue +from typing import Callable, Coroutine, Optional, Set, Type, Union + +from telegram import Message, Update +from telegram.ext import ( + Application, + ApplicationBuilder, + ContextTypes, + MessageHandler, + filters, +) + +from platypush.context import get_bus +from platypush.message.event.telegram import ( + MessageEvent, + CommandMessageEvent, + TextMessageEvent, + PhotoMessageEvent, + VideoMessageEvent, + ContactMessageEvent, + DocumentMessageEvent, + LocationMessageEvent, + GroupChatCreatedEvent, +) + +from ._bridge import CommandBridge +from ._model import END_OF_SERVICE, Resource +from ._utils import dump_msg, dump_user + + +class TelegramService(Process): + """ + Background service to handle Telegram messages and events. + """ + + def __init__( + self, + api_token: str, + cmd_queue: Queue, + result_queue: Queue, + authorized_chat_ids: Set[Union[str, int]], + *_, + **__, + ): + super().__init__(name="telegram-service") + self.logger = logging.getLogger("platypush:telegram") + self.authorized_chat_ids = set(authorized_chat_ids or []) + self._cmd_queue = cmd_queue + self._result_queue = result_queue + self._loop = aio.new_event_loop() + self._app = self._build_app(api_token) + self._service_running = Event() + self._service_stopped = Event() + self._cmd_bridge = CommandBridge(self) + + def _build_app(self, api_token: str) -> Application: + app = ApplicationBuilder().token(api_token).build() + app.add_handler(MessageHandler(filters.ChatType.GROUPS, self._group_hook())) + + app.add_handler( + MessageHandler( + filters.TEXT & (~filters.COMMAND), self._msg_hook(TextMessageEvent) + ) + ) + + app.add_handler( + MessageHandler(filters.PHOTO, self._msg_hook(PhotoMessageEvent)) + ) + + app.add_handler( + MessageHandler(filters.VIDEO, self._msg_hook(VideoMessageEvent)) + ) + + app.add_handler( + MessageHandler(filters.CONTACT, self._msg_hook(ContactMessageEvent)) + ) + + app.add_handler( + MessageHandler(filters.LOCATION, self._msg_hook(LocationMessageEvent)) + ) + + app.add_handler( + MessageHandler(filters.Document.ALL, self._msg_hook(DocumentMessageEvent)) + ) + + app.add_handler(MessageHandler(filters.COMMAND, self._command_hook())) + return app + + async def _authorize(self, msg: Message, context: ContextTypes.DEFAULT_TYPE): + if not self.authorized_chat_ids: + return + + if msg.chat.type == 'private' and msg.chat.id not in self.authorized_chat_ids: + self.logger.info( + 'Received message from unauthorized chat_id %s', msg.chat.id + ) + + await context.bot.send_message( + chat_id=msg.chat.id, + text='You are not allowed to send messages to this bot', + ) + + raise PermissionError + + def _msg_hook(self, cls: Type[MessageEvent]): + async def hook(update: Update, context: ContextTypes.DEFAULT_TYPE): + msg = update.effective_message + if not msg: + return + + try: + await self._authorize(msg, context) + self.bus.post( + cls( + chat_id=( + update.effective_chat.id if update.effective_chat else None + ), + message=dump_msg(msg) if msg else None, + user=( + dump_user(update.effective_user) + if update.effective_user + else None + ), + ) + ) + except PermissionError: + pass + + return hook + + def _group_hook(self): + async def hook(update: Update, context): + msg = update.effective_message + if not msg: + return + + if msg.group_chat_created: + self.bus.post( + GroupChatCreatedEvent( + chat_id=( + update.effective_chat.id if update.effective_chat else None + ), + message=dump_msg(msg), + user=( + dump_user(update.effective_user) + if update.effective_user + else None + ), + ) + ) + elif msg.photo: + await self._msg_hook(PhotoMessageEvent)(update, context) + elif msg.video: + await self._msg_hook(VideoMessageEvent)(update, context) + elif msg.contact: + await self._msg_hook(ContactMessageEvent)(update, context) + elif msg.location: + await self._msg_hook(LocationMessageEvent)(update, context) + elif msg.document: + await self._msg_hook(DocumentMessageEvent)(update, context) + elif msg.text: + if msg.text.startswith('/'): + await self._command_hook()(update, context) + else: + await self._msg_hook(TextMessageEvent)(update, context) + + return hook + + def _command_hook(self): + async def hook(update: Update, context: ContextTypes.DEFAULT_TYPE): + msg = update.effective_message + if not (msg and msg.text): + return + + m = re.match(r'\s*/([0-9a-zA-Z_-]+)\s*(.*)', msg.text) + if not m: + self.logger.warning('Invalid command: %s', msg.text) + return + + cmd = m.group(1).lower() + args = [arg for arg in re.split(r'\s+', m.group(2)) if len(arg)] + + try: + await self._authorize(msg, context) + self.bus.post( + CommandMessageEvent( + chat_id=( + update.effective_chat.id if update.effective_chat else None + ), + command=cmd, + cmdargs=args, + message=dump_msg(msg), + user=( + dump_user(update.effective_user) + if update.effective_user + else None + ), + ) + ) + except PermissionError: + pass + + return hook + + def _exec( + self, + method: Callable[..., Coroutine], + *args, + timeout: Optional[float] = None, + **kwargs, + ): + fut = aio.run_coroutine_threadsafe(method(*args, **kwargs), self._loop) + return fut.result(timeout=timeout) + + def exec( + self, + cmd: str, + *args, + timeout: Optional[float] = None, + resource: Optional[Resource] = None, + resource_attr: Optional[str] = None, + **kwargs, + ): + method = getattr(self._app.bot, cmd, None) + assert method, f"Method {cmd} not found" + + if resource: + assert resource_attr, f"Resource attribute not specified for command {cmd}" + with resource as file: + kwargs[resource_attr] = file + return self._exec( + method, + *args, + timeout=timeout, + **kwargs, + ) + + return self._exec(method, *args, timeout=timeout, **kwargs) + + def _run(self): + self._app.run_polling() + + def run(self): + super().run() + self._service_running.set() + self._service_stopped.clear() + aio.set_event_loop(self._loop) + self._cmd_bridge.start() + + try: + self._run() + except Exception as e: + self.logger.error("Telegram polling error: %s", e, exc_info=True) + finally: + self._service_running.clear() + self._service_stopped.set() + self._cmd_queue.put_nowait(END_OF_SERVICE) + + def stop(self): + self._cmd_queue.put_nowait(END_OF_SERVICE) + self._app.stop_running() + + if self.is_alive() and self.pid != os.getpid(): + self.terminate() + self.join(timeout=5) + + if self.is_alive(): + self.kill() + + @property + def bus(self): + return get_bus() + + @property + def cmd_queue(self): + return self._cmd_queue + + @property + def result_queue(self): + return self._result_queue + + @property + def stop_event(self): + return self._service_stopped + + def is_running(self): + return self._service_running.is_set() + + @property + def run_event(self): + return self._service_running + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/telegram/_utils.py b/platypush/plugins/telegram/_utils.py new file mode 100644 index 0000000000..22f525f555 --- /dev/null +++ b/platypush/plugins/telegram/_utils.py @@ -0,0 +1,21 @@ +import logging + +from telegram import Message as TelegramMessage, User as TelegramUser + +from platypush.schemas.telegram import ( + TelegramMessageSchema, + TelegramUserSchema, +) + +log = logging.getLogger(__name__) + + +def dump_msg(msg: TelegramMessage) -> dict: + return dict(TelegramMessageSchema().dump(msg)) + + +def dump_user(user: TelegramUser) -> dict: + return dict(TelegramUserSchema().dump(user)) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/telegram/manifest.yaml b/platypush/plugins/telegram/manifest.yaml new file mode 100644 index 0000000000..a274d0c71f --- /dev/null +++ b/platypush/plugins/telegram/manifest.yaml @@ -0,0 +1,15 @@ +manifest: + events: + - platypush.message.event.telegram.CommandMessageEvent + - platypush.message.event.telegram.ContactMessageEvent + - platypush.message.event.telegram.DocumentMessageEvent + - platypush.message.event.telegram.GroupChatCreatedEvent + - platypush.message.event.telegram.LocationMessageEvent + - platypush.message.event.telegram.PhotoMessageEvent + - platypush.message.event.telegram.TextMessageEvent + - platypush.message.event.telegram.VideoMessageEvent + install: + pip: + - python-telegram-bot + package: platypush.plugins.telegram + type: plugin diff --git a/platypush/schemas/telegram.py b/platypush/schemas/telegram.py new file mode 100644 index 0000000000..dba0c6f4d2 --- /dev/null +++ b/platypush/schemas/telegram.py @@ -0,0 +1,186 @@ +from marshmallow import Schema, pre_dump +from marshmallow.fields import Boolean, Integer, Date, Float, String + + +class TelegramMessageSchema(Schema): + """ + Schema for Telegram messages. + """ + + message_id = Integer(required=True) + chat_id = Integer(required=True) + creation_date = Date() + chat_username = String() + chat_firstname = String() + chat_lastname = String() + from_user_id = Integer() + from_username = String() + from_firstname = String() + from_lastname = String() + text = String() + caption = String() + edit_date = Date() + forward_from_message_id = Integer() + forward_date = Date() + photo_file_id = String() + photo_file_size = Integer() + photo_width = Integer() + photo_height = Integer() + document_file_id = String() + document_file_name = String() + document_file_size = Integer() + document_mime_type = String() + audio_file_id = String() + audio_file_size = Integer() + audio_mime_type = String() + audio_performer = String() + audio_title = String() + audio_duration = Integer() + location_latitude = Float() + location_longitude = Float() + contact_phone_number = String() + contact_first_name = String() + contact_last_name = String() + contact_user_id = Integer() + contact_vcard = String() + video_file_id = String() + video_file_size = Integer() + video_width = Integer() + video_height = Integer() + video_mime_type = String() + video_duration = Integer() + link = String() + media_group_id = String() + + @pre_dump + def pre_dump(self, msg, **_) -> dict: + ret = { + 'message_id': msg.message_id, + 'chat_id': msg.chat_id, + 'chat_username': msg.chat.username, + 'chat_firstname': msg.chat.first_name, + 'chat_lastname': msg.chat.last_name, + 'text': msg.text, + 'caption': msg.caption, + 'creation_date': msg.date, + 'edit_date': msg.edit_date, + 'forward_date': msg.forward_date, + 'forward_from_message_id': msg.forward_from_message_id, + 'link': msg.link, + 'media_group_id': msg.media_group_id, + } + + if msg.from_user: + ret.update( + { + 'from_user_id': msg.from_user.id, + 'from_username': msg.from_user.username, + 'from_firstname': msg.from_user.first_name, + 'from_lastname': msg.from_user.last_name, + } + ) + + if msg.photo: + ret.update( + { + 'photo_file_id': msg.photo[-1].file_id, + 'photo_file_size': msg.photo[-1].file_size, + 'photo_width': msg.photo[-1].width, + 'photo_height': msg.photo[-1].height, + } + ) + + if msg.document: + ret.update( + { + 'document_file_id': msg.document.file_id, + 'document_file_name': msg.document.file_name, + 'document_file_size': msg.document.file_size, + 'document_mime_type': msg.document.mime_type, + } + ) + + if msg.audio: + ret.update( + { + 'audio_file_id': msg.audio.file_id, + 'audio_file_size': msg.audio.file_size, + 'audio_mime_type': msg.audio.mime_type, + 'audio_performer': msg.audio.performer, + 'audio_title': msg.audio.title, + 'audio_duration': msg.audio.duration, + } + ) + + if msg.video: + ret.update( + { + 'video_file_id': msg.video.file_id, + 'video_file_size': msg.video.file_size, + 'video_width': msg.video.width, + 'video_height': msg.video.height, + 'video_mime_type': msg.video.mime_type, + 'video_duration': msg.video.duration, + } + ) + + if msg.location: + ret.update( + { + 'location_latitude': msg.location.latitude, + 'location_longitude': msg.location.longitude, + } + ) + + if msg.contact: + ret.update( + { + 'contact_phone_number': msg.contact.phone_number, + 'contact_first_name': msg.contact.first_name, + 'contact_last_name': msg.contact.last_name, + 'contact_user_id': msg.contact.user_id, + 'contact_vcard': msg.contact.vcard, + } + ) + + return ret + + +class TelegramFileSchema(Schema): + """ + Schema for Telegram files. + """ + + file_id = String(required=True) + file_path = String(required=True) + file_size = Integer(required=True) + + +class TelegramUserSchema(Schema): + """ + Schema for Telegram users. + """ + + user_id = Integer(required=True, data_key='id') + username = String(required=True) + is_bot = Boolean(required=True) + first_name = String(required=True) + last_name = String() + language_code = String() + link = String() + + +class TelegramChatSchema(Schema): + """ + Schema for Telegram chats. + """ + + chat_id = Integer(required=True) + link = String(required=True) + username = String(required=True) + invite_link = String() + title = String() + description = String() + type = String() + first_name = String() + last_name = String()