diff --git a/readthedocs/extra/advanced-usage/update-modes.rst b/readthedocs/extra/advanced-usage/update-modes.rst new file mode 100644 index 00000000..83495ef7 --- /dev/null +++ b/readthedocs/extra/advanced-usage/update-modes.rst @@ -0,0 +1,144 @@ +.. _update-modes: + +============ +Update Modes +============ + + +The library can run in four distinguishable modes: + +- With no extra threads at all. +- With an extra thread that receives everything as soon as possible (default). +- With several worker threads that run your update handlers. +- A mix of the above. + +Since this section is about updates, we'll describe the simplest way to +work with them. + + +Using multiple workers +********************** + +When you create your client, simply pass a number to the +``update_workers`` parameter: + + ``client = TelegramClient('session', api_id, api_hash, update_workers=2)`` + +You can set any amount of workers you want. The more you put, the more +update handlers that can be called "at the same time". One or two should +suffice most of the time, since setting more will not make things run +faster most of the times (actually, it could slow things down). + +The next thing you want to do is to add a method that will be called when +an `Update`__ arrives: + + .. code-block:: python + + def callback(update): + print('I received', update) + + client.add_update_handler(callback) + # do more work here, or simply sleep! + +That's it! This is the old way to listen for raw updates, with no further +processing. If this feels annoying for you, remember that you can always +use :ref:`working-with-updates` but maybe use this for some other cases. + +Now let's do something more interesting. Every time an user talks to use, +let's reply to them with the same text reversed: + + .. code-block:: python + + from telethon.tl.types import UpdateShortMessage, PeerUser + + def replier(update): + if isinstance(update, UpdateShortMessage) and not update.out: + client.send_message(PeerUser(update.user_id), update.message[::-1]) + + + client.add_update_handler(replier) + input('Press enter to stop this!') + client.disconnect() + +We only ask you one thing: don't keep this running for too long, or your +contacts will go mad. + + +Spawning no worker at all +************************* + +All the workers do is loop forever and poll updates from a queue that is +filled from the ``ReadThread``, responsible for reading every item off +the network. If you only need a worker and the ``MainThread`` would be +doing no other job, this is the preferred way. You can easily do the same +as the workers like so: + + .. code-block:: python + + while True: + try: + update = client.updates.poll() + if not update: + continue + + print('I received', update) + except KeyboardInterrupt: + break + + client.disconnect() + +Note that ``poll`` accepts a ``timeout=`` parameter, and it will return +``None`` if other thread got the update before you could or if the timeout +expired, so it's important to check ``if not update``. + +This can coexist with the rest of ``N`` workers, or you can set it to ``0`` +additional workers: + + ``client = TelegramClient('session', api_id, api_hash, update_workers=0)`` + +You **must** set it to ``0`` (or other number), as it defaults to ``None`` +and there is a different. ``None`` workers means updates won't be processed +*at all*, so you must set it to some value (``0`` or greater) if you want +``client.updates.poll()`` to work. + + +Using the main thread instead the ``ReadThread`` +************************************************ + +If you have no work to do on the ``MainThread`` and you were planning to have +a ``while True: sleep(1)``, don't do that. Instead, don't spawn the secondary +``ReadThread`` at all like so: + + .. code-block:: python + + client = TelegramClient( + ... + spawn_read_thread=False + ) + +And then ``.idle()`` from the ``MainThread``: + + ``client.idle()`` + +You can stop it with :kbd:`Control+C`, and you can configure the signals +to be used in a similar fashion to `Python Telegram Bot`__. + +As a complete example: + + .. code-block:: python + + def callback(update): + print('I received', update) + + client = TelegramClient('session', api_id, api_hash, + update_workers=1, spawn_read_thread=False) + + client.connect() + client.add_update_handler(callback) + client.idle() # ends with Ctrl+C + + +This is the preferred way to use if you're simply going to listen for updates. + +__ https://lonamiwebs.github.io/Telethon/types/update.html +__ https://github.com/python-telegram-bot/python-telegram-bot/blob/4b3315db6feebafb94edcaa803df52bb49999ced/telegram/ext/updater.py#L460 diff --git a/readthedocs/extra/basic/working-with-updates.rst b/readthedocs/extra/basic/working-with-updates.rst index 72155d86..a6c0a529 100644 --- a/readthedocs/extra/basic/working-with-updates.rst +++ b/readthedocs/extra/basic/working-with-updates.rst @@ -5,144 +5,130 @@ Working with Updates ==================== -.. note:: - - There are plans to make working with updates more friendly. Stay tuned! +The library comes with the :mod:`events` module. *Events* are an abstraction +over what Telegram calls `updates`__, and are meant to ease simple and common +usage when dealing with them, since there are many updates. Let's dive in! .. contents:: -The library can run in four distinguishable modes: - -- With no extra threads at all. -- With an extra thread that receives everything as soon as possible (default). -- With several worker threads that run your update handlers. -- A mix of the above. - -Since this section is about updates, we'll describe the simplest way to -work with them. - - -Using multiple workers -********************** - -When you create your client, simply pass a number to the -``update_workers`` parameter: - - ``client = TelegramClient('session', api_id, api_hash, update_workers=4)`` - -4 workers should suffice for most cases (this is also the default on -`Python Telegram Bot`__). You can set this value to more, or even less -if you need. - -The next thing you want to do is to add a method that will be called when -an `Update`__ arrives: +Getting Started +*************** .. code-block:: python - def callback(update): - print('I received', update) + from telethon import TelegramClient, events - client.add_update_handler(callback) - # do more work here, or simply sleep! + client = TelegramClient(..., update_workers=1, spawn_read_thread=False) + client.start() -That's it! Now let's do something more interesting. -Every time an user talks to use, let's reply to them with the same -text reversed: + @client.on(events.NewMessage) + def my_event_handler(event): + if 'hello' in event.raw_text: + event.reply('hi!') + + client.idle() + + +Not much, but there might be some things unclear. What does this code do? .. code-block:: python - from telethon.tl.types import UpdateShortMessage, PeerUser + from telethon import TelegramClient, events - def replier(update): - if isinstance(update, UpdateShortMessage) and not update.out: - client.send_message(PeerUser(update.user_id), update.message[::-1]) + client = TelegramClient(..., update_workers=1, spawn_read_thread=False) + client.start() - client.add_update_handler(replier) - input('Press enter to stop this!') - client.disconnect() - -We only ask you one thing: don't keep this running for too long, or your -contacts will go mad. - - -Spawning no worker at all -************************* - -All the workers do is loop forever and poll updates from a queue that is -filled from the ``ReadThread``, responsible for reading every item off -the network. If you only need a worker and the ``MainThread`` would be -doing no other job, this is the preferred way. You can easily do the same -as the workers like so: +This is normal initialization (of course, pass session name, API ID and hash). +Nothing we don't know already. .. code-block:: python - while True: - try: - update = client.updates.poll() - if not update: - continue - - print('I received', update) - except KeyboardInterrupt: - break - - client.disconnect() - -Note that ``poll`` accepts a ``timeout=`` parameter, and it will return -``None`` if other thread got the update before you could or if the timeout -expired, so it's important to check ``if not update``. - -This can coexist with the rest of ``N`` workers, or you can set it to ``0`` -additional workers: - - ``client = TelegramClient('session', api_id, api_hash, update_workers=0)`` - -You **must** set it to ``0`` (or other number), as it defaults to ``None`` -and there is a different. ``None`` workers means updates won't be processed -*at all*, so you must set it to some value (``0`` or greater) if you want -``client.updates.poll()`` to work. + @client.on(events.NewMessage) -Using the main thread instead the ``ReadThread`` -************************************************ - -If you have no work to do on the ``MainThread`` and you were planning to have -a ``while True: sleep(1)``, don't do that. Instead, don't spawn the secondary -``ReadThread`` at all like so: +This Python decorator will attach itself to the ``my_event_handler`` +definition, and basically means that *on* a ``NewMessage`` *event*, +the callback function you're about to define will be called: .. code-block:: python - client = TelegramClient( - ... - spawn_read_thread=False - ) + def my_event_handler(event): + if 'hello' in event.raw_text: + event.reply('hi!') -And then ``.idle()`` from the ``MainThread``: - ``client.idle()`` - -You can stop it with :kbd:`Control+C`, and you can configure the signals -to be used in a similar fashion to `Python Telegram Bot`__. - -As a complete example: +If a ``NewMessage`` event occurs, and ``'hello'`` is in the text of the +message, we ``reply`` to the event with a ``'hi!'`` message. .. code-block:: python - def callback(update): - print('I received', update) - - client = TelegramClient('session', api_id, api_hash, - update_workers=1, spawn_read_thread=False) - - client.connect() - client.add_update_handler(callback) - client.idle() # ends with Ctrl+C - client.disconnect() + client.idle() + + +Finally, this tells the client that we're done with our code, and want +to listen for all these events to occur. Of course, you might want to +do other things instead idling. For this refer to :ref:`update-modes`. + + +More on events +************** + +The ``NewMessage`` event has much more than what was shown. You can access +the ``.sender`` of the message through that member, or even see if the message +had ``.media``, a ``.photo`` or a ``.document`` (which you could download with +for example ``client.download_media(event.photo)``. + +If you don't want to ``.reply`` as a reply, you can use the ``.respond()`` +method instead. Of course, there are more events such as ``ChatAction`` or +``UserUpdate``, and they're all used in the same way. Simply add the +``@client.on(events.XYZ)`` decorator on the top of your handler and you're +done! The event that will be passed always is of type ``XYZ.Event`` (for +instance, ``NewMessage.Event``), except for the ``Raw`` event which just +passes the ``Update`` object. + +You can put the same event on many handlers, and even different events on +the same handler. You can also have a handler work on only specific chats, +for example: + + + .. code-block:: python + + import ast + import random + + + @client.on(events.NewMessage(chats='TelethonOffTopic', incoming=True)) + def normal_handler(event): + if 'roll' in event.raw_text: + event.reply(str(random.randint(1, 6))) + + + @client.on(events.NewMessage(chats='TelethonOffTopic', outgoing=True)) + def admin_handler(event): + if event.raw_text.startswith('eval'): + expression = event.raw_text.replace('eval', '').strip() + event.reply(str(ast.literal_eval(expression))) + + +You can pass one or more chats to the ``chats`` parameter (as a list or tuple), +and only events from there will be processed. You can also specify whether you +want to handle incoming or outgoing messages (those you receive or those you +send). In this example, people can say ``'roll'`` and you will reply with a +random number, while if you say ``'eval 4+4'``, you will reply with the +solution. Try it! + + +Events module +************* + +.. automodule:: telethon.events + :members: + :undoc-members: + :show-inheritance: + -__ https://python-telegram-bot.org/ __ https://lonamiwebs.github.io/Telethon/types/update.html -__ https://github.com/python-telegram-bot/python-telegram-bot/blob/4b3315db6feebafb94edcaa803df52bb49999ced/telegram/ext/updater.py#L460 diff --git a/readthedocs/index.rst b/readthedocs/index.rst index 74c3b8e6..c1d2b6ec 100644 --- a/readthedocs/index.rst +++ b/readthedocs/index.rst @@ -49,6 +49,7 @@ heavy job for you, so you can focus on developing an application. extra/advanced-usage/accessing-the-full-api extra/advanced-usage/sessions + extra/advanced-usage/update-modes .. _Examples: diff --git a/readthedocs/telethon.events.rst b/readthedocs/telethon.events.rst new file mode 100644 index 00000000..071a39bf --- /dev/null +++ b/readthedocs/telethon.events.rst @@ -0,0 +1,4 @@ +telethon\.events package +======================== + + diff --git a/readthedocs/telethon.rst b/readthedocs/telethon.rst index e7a30c42..96becc9b 100644 --- a/readthedocs/telethon.rst +++ b/readthedocs/telethon.rst @@ -26,6 +26,14 @@ telethon\.telegram\_client module :undoc-members: :show-inheritance: +telethon\.events package +------------------------ + +.. toctree:: + + telethon.events + + telethon\.update\_state module ------------------------------ diff --git a/telethon/events/__init__.py b/telethon/events/__init__.py new file mode 100644 index 00000000..40678998 --- /dev/null +++ b/telethon/events/__init__.py @@ -0,0 +1,868 @@ +import abc +import datetime +import itertools + +from .. import utils +from ..errors import RPCError +from ..extensions import markdown +from ..tl import types, functions + + +class _EventBuilder(abc.ABC): + @abc.abstractmethod + def build(self, update): + """Builds an event for the given update if possible, or returns None""" + + @abc.abstractmethod + def resolve(self, client): + """Helper method to allow event builders to be resolved before usage""" + + +class _EventCommon(abc.ABC): + """Intermediate class with common things to all events""" + + def __init__(self, chat_peer=None, msg_id=None, broadcast=False): + self._client = None + self._chat_peer = chat_peer + self._message_id = msg_id + self._input_chat = None + self._chat = None + + self.is_private = isinstance(chat_peer, types.PeerUser) + self.is_group = ( + isinstance(chat_peer, (types.PeerChat, types.PeerChannel)) + and not broadcast + ) + self.is_channel = isinstance(chat_peer, types.PeerChannel) + + def _get_input_entity(self, msg_id, entity_id, chat=None): + """ + Helper function to call GetMessages on the give msg_id and + return the input entity whose ID is the given entity ID. + + If ``chat`` is present it must be an InputPeer. + """ + try: + if isinstance(chat, types.InputPeerChannel): + result = self._client( + functions.channels.GetMessagesRequest(chat, [msg_id]) + ) + else: + result = self._client( + functions.messages.GetMessagesRequest([msg_id]) + ) + except RPCError: + return + entity = { + utils.get_peer_id(x): x for x in itertools.chain( + getattr(result, 'chats', []), + getattr(result, 'users', [])) + }.get(entity_id) + if entity: + return utils.get_input_peer(entity) + + @property + def input_chat(self): + """ + The (:obj:`InputPeer`) (group, megagroup or channel) on which + the event occurred. This doesn't have the title or anything, + but is useful if you don't need those to avoid further + requests. + + Note that this might be ``None`` if the library can't find it. + """ + + if self._input_chat is None and self._chat_peer is not None: + try: + self._input_chat = self._client.get_input_entity( + self._chat_peer + ) + except (ValueError, TypeError): + # The library hasn't seen this chat, get the message + if not isinstance(self._chat_peer, types.PeerChannel): + # TODO For channels, getDifference? Maybe looking + # in the dialogs (which is already done) is enough. + if self._message_id is not None: + self._input_chat = self._get_input_entity( + self._message_id, + utils.get_peer_id(self._chat_peer) + ) + return self._input_chat + + @property + def chat(self): + """ + The (:obj:`User` | :obj:`Chat` | :obj:`Channel`, optional) on which + the event occurred. This property will make an API call the first time + to get the most up to date version of the chat, so use with care as + there is no caching besides local caching yet. + """ + if self._chat is None and self.input_chat: + self._chat = self._client.get_entity(self._input_chat) + return self._chat + + +class Raw(_EventBuilder): + """ + Represents a raw event. The event is the update itself. + """ + def resolve(self, client): + pass + + def build(self, update): + return update + + +# Classes defined here are actually Event builders +# for their inner Event classes. Inner ._client is +# set later by the creator TelegramClient. +class NewMessage(_EventBuilder): + """ + Represents a new message event builder. + + Args: + incoming (:obj:`bool`, optional): + If set to ``True``, only **incoming** messages will be handled. + Mutually exclusive with ``outgoing`` (can only set one of either). + + outgoing (:obj:`bool`, optional): + If set to ``True``, only **outgoing** messages will be handled. + Mutually exclusive with ``incoming`` (can only set one of either). + + chats (:obj:`entity`, optional): + May be one or more entities (username/peer/etc.). By default, + only matching chats will be handled. + + blacklist_chats (:obj:`bool`, optional): + Whether to treat the the list of chats as a blacklist (if + it matches it will NOT be handled) or a whitelist (default). + """ + def __init__(self, incoming=None, outgoing=None, + chats=None, blacklist_chats=False): + if incoming and outgoing: + raise ValueError('Can only set either incoming or outgoing') + + self.incoming = incoming + self.outgoing = outgoing + self.chats = chats + self.blacklist_chats = blacklist_chats + + def resolve(self, client): + if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str): + self.chats = set(utils.get_peer_id(x) + for x in client.get_input_entity(self.chats)) + elif self.chats is not None: + self.chats = {utils.get_peer_id( + client.get_input_entity(self.chats))} + + def build(self, update): + if isinstance(update, + (types.UpdateNewMessage, types.UpdateNewChannelMessage)): + if not isinstance(update.message, types.Message): + return # We don't care about MessageService's here + event = NewMessage.Event(update.message) + elif isinstance(update, types.UpdateShortMessage): + event = NewMessage.Event(types.Message( + out=update.out, + mentioned=update.mentioned, + media_unread=update.media_unread, + silent=update.silent, + id=update.id, + to_id=types.PeerUser(update.user_id), + message=update.message, + date=update.date, + fwd_from=update.fwd_from, + via_bot_id=update.via_bot_id, + reply_to_msg_id=update.reply_to_msg_id, + entities=update.entities + )) + else: + return + + # Short-circuit if we let pass all events + if all(x is None for x in (self.incoming, self.outgoing, self.chats)): + return event + + if self.incoming and event.message.out: + return + if self.outgoing and not event.message.out: + return + + if self.chats is not None: + inside = utils.get_peer_id(event.message.to_id) in self.chats + if inside == self.blacklist_chats: + # If this chat matches but it's a blacklist ignore. + # If it doesn't match but it's a whitelist ignore. + return + + # Tests passed so return the event + return event + + class Event(_EventCommon): + """ + Represents the event of a new message. + + Members: + message (:obj:`Message`): + This is the original ``Message`` object. + + is_private (:obj:`bool`): + True if the message was sent as a private message. + + is_group (:obj:`bool`): + True if the message was sent on a group or megagroup. + + is_channel (:obj:`bool`): + True if the message was sent on a megagroup or channel. + + is_reply (:obj:`str`): + Whether the message is a reply to some other or not. + """ + def __init__(self, message): + super().__init__(chat_peer=message.to_id, + msg_id=message.id, broadcast=bool(message.post)) + + self.message = message + self._text = None + + self._input_chat = None + self._chat = None + self._input_sender = None + self._sender = None + + self.is_reply = bool(message.reply_to_msg_id) + self._reply_message = None + + def respond(self, *args, **kwargs): + """ + Responds to the message (not as a reply). This is a shorthand for + ``client.send_message(event.chat, ...)``. + """ + return self._client.send_message(self.input_chat, *args, **kwargs) + + def reply(self, *args, **kwargs): + """ + Replies to the message (as a reply). This is a shorthand for + ``client.send_message(event.chat, ..., reply_to=event.message.id)``. + """ + return self._client.send_message(self.input_chat, + reply_to=self.message.id, + *args, **kwargs) + + @property + def input_sender(self): + """ + This (:obj:`InputPeer`) is the input version of the user who + sent the message. Similarly to ``input_chat``, this doesn't have + things like username or similar, but still useful in some cases. + + Note that this might not be available if the library can't + find the input chat. + """ + if self._input_sender is None: + try: + self._input_sender = self._client.get_input_entity( + self.message.from_id + ) + except (ValueError, TypeError): + if isinstance(self.message.to_id, types.PeerChannel): + # We can rely on self.input_chat for this + self._input_sender = self._get_input_entity( + self.message.id, + self.message.from_id, + chat=self.input_chat + ) + + return self._input_sender + + @property + def sender(self): + """ + This (:obj:`User`) will make an API call the first time to get + the most up to date version of the sender, so use with care as + there is no caching besides local caching yet. + + ``input_sender`` needs to be available (often the case). + """ + if self._sender is None and self.input_sender: + self._sender = self._client.get_entity(self._input_sender) + return self._sender + + @property + def text(self): + """ + The message text, markdown-formatted. + """ + if self._text is None: + if not self.message.entities: + return self.message.message + self._text = markdown.unparse(self.message.message, + self.message.entities or []) + return self._text + + @property + def raw_text(self): + """ + The raw message text, ignoring any formatting. + """ + return self.message.message + + @property + def reply_message(self): + """ + This (:obj:`Message`, optional) will make an API call the first + time to get the full ``Message`` object that one was replying to, + so use with care as there is no caching besides local caching yet. + """ + if not self.message.reply_to_msg_id: + return None + + if self._reply_message is None: + if isinstance(self.input_chat, types.InputPeerChannel): + r = self._client(functions.channels.GetMessagesRequest( + self.input_chat, [self.message.reply_to_msg_id] + )) + else: + r = self._client(functions.messages.GetMessagesRequest( + [self.message.reply_to_msg_id] + )) + if not isinstance(r, types.messages.MessagesNotModified): + self._reply_message = r.messages[0] + + return self._reply_message + + @property + def forward(self): + """ + The unmodified (:obj:`MessageFwdHeader`, optional). + """ + return self.message.fwd_from + + @property + def media(self): + """ + The unmodified (:obj:`MessageMedia`, optional). + """ + return self.message.media + + @property + def photo(self): + """ + If the message media is a photo, + this returns the (:obj:`Photo`) object. + """ + if isinstance(self.message.media, types.MessageMediaPhoto): + photo = self.message.media.photo + if isinstance(photo, types.Photo): + return photo + + @property + def document(self): + """ + If the message media is a document, + this returns the (:obj:`Document`) object. + """ + if isinstance(self.message.media, types.MessageMediaDocument): + doc = self.message.media.document + if isinstance(doc, types.Document): + return doc + + @property + def out(self): + """ + Whether the message is outgoing (i.e. you sent it from + another session) or incoming (i.e. someone else sent it). + """ + return self.message.out + + +class ChatAction(_EventBuilder): + """ + Represents an action in a chat (such as user joined, left, or new pin). + + Args: + chats (:obj:`entity`, optional): + May be one or more entities (username/peer/etc.). By default, + only matching chats will be handled. + + blacklist_chats (:obj:`bool`, optional): + Whether to treat the the list of chats as a blacklist (if + it matches it will NOT be handled) or a whitelist (default). + + """ + def __init__(self, chats=None, blacklist_chats=False): + # TODO This can probably be reused in all builders + self.chats = chats + self.blacklist_chats = blacklist_chats + + def resolve(self, client): + if hasattr(self.chats, '__iter__') and not isinstance(self.chats, str): + self.chats = set(utils.get_peer_id(x) + for x in client.get_input_entity(self.chats)) + elif self.chats is not None: + self.chats = {utils.get_peer_id( + client.get_input_entity(self.chats))} + + def build(self, update): + if isinstance(update, types.UpdateChannelPinnedMessage): + # Telegram sends UpdateChannelPinnedMessage and then + # UpdateNewChannelMessage with MessageActionPinMessage. + event = ChatAction.Event(types.PeerChannel(update.channel_id), + new_pin=update.id) + + elif isinstance(update, types.UpdateChatParticipantAdd): + event = ChatAction.Event(types.PeerChat(update.chat_id), + added_by=update.inviter_id or True, + users=update.user_id) + + elif isinstance(update, types.UpdateChatParticipantDelete): + event = ChatAction.Event(types.PeerChat(update.chat_id), + kicked_by=True, + users=update.user_id) + + elif (isinstance(update, ( + types.UpdateNewMessage, types.UpdateNewChannelMessage)) + and isinstance(update.message, types.MessageService)): + msg = update.message + action = update.message.action + if isinstance(action, types.MessageActionChatJoinedByLink): + event = ChatAction.Event(msg.to_id, + added_by=True, + users=msg.from_id) + elif isinstance(action, types.MessageActionChatAddUser): + event = ChatAction.Event(msg.to_id, + added_by=msg.from_id or True, + users=action.users) + elif isinstance(action, types.MessageActionChatDeleteUser): + event = ChatAction.Event(msg.to_id, + kicked_by=msg.from_id or True, + users=action.user_id) + elif isinstance(action, types.MessageActionChatCreate): + event = ChatAction.Event(msg.to_id, + users=action.users, + created=True, + new_title=action.title) + elif isinstance(action, types.MessageActionChannelCreate): + event = ChatAction.Event(msg.to_id, + created=True, + new_title=action.title) + elif isinstance(action, types.MessageActionChatEditTitle): + event = ChatAction.Event(msg.to_id, + new_title=action.title) + elif isinstance(action, types.MessageActionChatEditPhoto): + event = ChatAction.Event(msg.to_id, + new_photo=action.photo) + elif isinstance(action, types.MessageActionChatDeletePhoto): + event = ChatAction.Event(msg.to_id, + new_photo=True) + else: + return + else: + return + + if self.chats is None: + return event + else: + inside = utils.get_peer_id(event._chat_peer) in self.chats + if inside == self.blacklist_chats: + # If this chat matches but it's a blacklist ignore. + # If it doesn't match but it's a whitelist ignore. + return + + return event + + class Event(_EventCommon): + """ + Represents the event of a new chat action. + + Members: + new_pin (:obj:`bool`): + ``True`` if the pin has changed (new pin or removed). + + new_photo (:obj:`bool`): + ``True`` if there's a new chat photo (or it was removed). + + photo (:obj:`Photo`, optional): + The new photo (or ``None`` if it was removed). + + + user_added (:obj:`bool`): + ``True`` if the user was added by some other. + + user_joined (:obj:`bool`): + ``True`` if the user joined on their own. + + user_left (:obj:`bool`): + ``True`` if the user left on their own. + + user_kicked (:obj:`bool`): + ``True`` if the user was kicked by some other. + + created (:obj:`bool`, optional): + ``True`` if this chat was just created. + + new_title (:obj:`bool`, optional): + The new title string for the chat, if applicable. + """ + def __init__(self, chat_peer, new_pin=None, new_photo=None, + added_by=None, kicked_by=None, created=None, + users=None, new_title=None): + super().__init__(chat_peer=chat_peer, msg_id=new_pin) + + self.new_pin = isinstance(new_pin, int) + self._pinned_message = new_pin + + self.new_photo = new_photo is not None + self.photo = \ + new_photo if isinstance(new_photo, types.Photo) else None + + self._added_by = None + self._kicked_by = None + self.user_added, self.user_joined, self.user_left,\ + self.user_kicked = (False, False, False, False) + + if added_by is True: + self.user_joined = True + elif added_by: + self.user_added = True + self._added_by = added_by + + if kicked_by is True: + self.user_left = True + elif kicked_by: + self.user_kicked = True + self._kicked_by = kicked_by + + self.created = bool(created) + self._user_peers = users if isinstance(users, list) else [users] + self._users = None + self.new_title = new_title + + @property + def pinned_message(self): + """ + If ``new_pin`` is ``True``, this returns the (:obj:`Message`) + object that was pinned. + """ + if self._pinned_message == 0: + return None + + if isinstance(self._pinned_message, int) and self.input_chat: + r = self._client(functions.channels.GetMessagesRequest( + self._input_chat, [self._pinned_message] + )) + try: + self._pinned_message = next( + x for x in r.messages + if isinstance(x, types.Message) + and x.id == self._pinned_message + ) + except StopIteration: + pass + + if isinstance(self._pinned_message, types.Message): + return self._pinned_message + + @property + def added_by(self): + """ + The user who added ``users``, if applicable (``None`` otherwise). + """ + if self._added_by and not isinstance(self._added_by, types.User): + self._added_by = self._client.get_entity(self._added_by) + return self._added_by + + @property + def kicked_by(self): + """ + The user who kicked ``users``, if applicable (``None`` otherwise). + """ + if self._kicked_by and not isinstance(self._kicked_by, types.User): + self._kicked_by = self._client.get_entity(self._kicked_by) + return self._kicked_by + + @property + def user(self): + """ + The single user that takes part in this action (e.g. joined). + + Might be ``None`` if the information can't be retrieved or + there is no user taking part. + """ + try: + return next(self.users) + except (StopIteration, TypeError): + return None + + @property + def users(self): + """ + A list of users that take part in this action (e.g. joined). + + Might be empty if the information can't be retrieved or there + are no users taking part. + """ + if self._users is None and self._user_peers: + try: + self._users = self._client.get_entity(self._user_peers) + except (TypeError, ValueError): + self._users = [] + + return self._users + + +class UserUpdate(_EventBuilder): + """ + Represents an user update (gone online, offline, joined Telegram). + """ + + def build(self, update): + if isinstance(update, types.UpdateUserStatus): + event = UserUpdate.Event(update.user_id, + status=update.status) + else: + return + + return event + + def resolve(self, client): + pass + + class Event(_EventCommon): + """ + Represents the event of an user status update (last seen, joined). + + Members: + online (:obj:`bool`, optional): + ``True`` if the user is currently online, ``False`` otherwise. + Might be ``None`` if this information is not present. + + last_seen (:obj:`datetime`, optional): + Exact date when the user was last seen if known. + + until (:obj:`datetime`, optional): + Until when will the user remain online. + + within_months (:obj:`bool`): + ``True`` if the user was seen within 30 days. + + within_weeks (:obj:`bool`): + ``True`` if the user was seen within 7 days. + + recently (:obj:`bool`): + ``True`` if the user was seen within a day. + + action (:obj:`SendMessageAction`, optional): + The "typing" action if any the user is performing if any. + + cancel (:obj:`bool`): + ``True`` if the action was cancelling other actions. + + typing (:obj:`bool`): + ``True`` if the action is typing a message. + + recording (:obj:`bool`): + ``True`` if the action is recording something. + + uploading (:obj:`bool`): + ``True`` if the action is uploading something. + + playing (:obj:`bool`): + ``True`` if the action is playing a game. + + audio (:obj:`bool`): + ``True`` if what's being recorded/uploaded is an audio. + + round (:obj:`bool`): + ``True`` if what's being recorded/uploaded is a round video. + + video (:obj:`bool`): + ``True`` if what's being recorded/uploaded is an video. + + document (:obj:`bool`): + ``True`` if what's being uploaded is document. + + geo (:obj:`bool`): + ``True`` if what's being uploaded is a geo. + + photo (:obj:`bool`): + ``True`` if what's being uploaded is a photo. + + contact (:obj:`bool`): + ``True`` if what's being uploaded (selected) is a contact. + """ + def __init__(self, user_id, status=None, typing=None): + super().__init__(types.PeerUser(user_id)) + + self.online = None if status is None else \ + isinstance(status, types.UserStatusOnline) + + self.last_seen = status.was_online if \ + isinstance(status, types.UserStatusOffline) else None + + self.until = status.expires if \ + isinstance(status, types.UserStatusOnline) else None + + if self.last_seen: + diff = datetime.datetime.now() - self.last_seen + if diff < datetime.timedelta(days=30): + self.within_months = True + if diff < datetime.timedelta(days=7): + self.within_weeks = True + if diff < datetime.timedelta(days=1): + self.recently = True + else: + self.within_months = self.within_weeks = self.recently = False + if isinstance(status, (types.UserStatusOnline, + types.UserStatusRecently)): + self.within_months = self.within_weeks = True + self.recently = True + elif isinstance(status, types.UserStatusLastWeek): + self.within_months = self.within_weeks = True + elif isinstance(status, types.UserStatusLastMonth): + self.within_months = True + + self.action = typing + if typing: + self.cancel = self.typing = self.recording = self.uploading = \ + self.playing = False + self.audio = self.round = self.video = self.document = \ + self.geo = self.photo = self.contact = False + + if isinstance(typing, types.SendMessageCancelAction): + self.cancel = True + elif isinstance(typing, types.SendMessageTypingAction): + self.typing = True + elif isinstance(typing, types.SendMessageGamePlayAction): + self.playing = True + elif isinstance(typing, types.SendMessageGeoLocationAction): + self.geo = True + elif isinstance(typing, types.SendMessageRecordAudioAction): + self.recording = self.audio = True + elif isinstance(typing, types.SendMessageRecordRoundAction): + self.recording = self.round = True + elif isinstance(typing, types.SendMessageRecordVideoAction): + self.recording = self.video = True + elif isinstance(typing, types.SendMessageChooseContactAction): + self.uploading = self.contact = True + elif isinstance(typing, types.SendMessageUploadAudioAction): + self.uploading = self.audio = True + elif isinstance(typing, types.SendMessageUploadDocumentAction): + self.uploading = self.document = True + elif isinstance(typing, types.SendMessageUploadPhotoAction): + self.uploading = self.photo = True + elif isinstance(typing, types.SendMessageUploadRoundAction): + self.uploading = self.round = True + elif isinstance(typing, types.SendMessageUploadVideoAction): + self.uploading = self.video = True + + @property + def user(self): + """Alias around the chat (conversation).""" + return self.chat + + +class MessageChanged(_EventBuilder): + """ + Represents a message changed (edited or deleted). + """ + + def build(self, update): + if isinstance(update, (types.UpdateEditMessage, + types.UpdateEditChannelMessage)): + event = MessageChanged.Event(edit_msg=update.message) + elif isinstance(update, (types.UpdateDeleteMessages, + types.UpdateDeleteChannelMessages)): + event = MessageChanged.Event( + deleted_ids=update.messages, + peer=types.PeerChannel(update.channel_id) + ) + else: + return + + return event + + def resolve(self, client): + pass + + class Event(_EventCommon): + """ + Represents the event of an user status update (last seen, joined). + + Members: + edited (:obj:`bool`): + ``True`` if the message was edited. + + message (:obj:`Message`, optional): + The new edited message, if any. + + deleted (:obj:`bool`): + ``True`` if the message IDs were deleted. + + deleted_ids (:obj:`List[int]`): + A list containing the IDs of the messages that were deleted. + + input_sender (:obj:`InputPeer`): + This is the input version of the user who edited the message. + Similarly to ``input_chat``, this doesn't have things like + username or similar, but still useful in some cases. + + Note that this might not be available if the library can't + find the input chat. + + sender (:obj:`User`): + This property will make an API call the first time to get the + most up to date version of the sender, so use with care as + there is no caching besides local caching yet. + + ``input_sender`` needs to be available (often the case). + """ + def __init__(self, edit_msg=None, deleted_ids=None, peer=None): + super().__init__(peer if not edit_msg else edit_msg.to_id) + + self.edited = bool(edit_msg) + self.message = edit_msg + self.deleted = bool(deleted_ids) + self.deleted_ids = deleted_ids or [] + self._input_sender = None + self._sender = None + + @property + def input_sender(self): + """ + This (:obj:`InputPeer`) is the input version of the user who + sent the message. Similarly to ``input_chat``, this doesn't have + things like username or similar, but still useful in some cases. + + Note that this might not be available if the library can't + find the input chat. + """ + # TODO Code duplication + if self._input_sender is None and self.message: + try: + self._input_sender = self._client.get_input_entity( + self.message.from_id + ) + except (ValueError, TypeError): + if isinstance(self.message.to_id, types.PeerChannel): + # We can rely on self.input_chat for this + self._input_sender = self._get_input_entity( + self.message.id, + self.message.from_id, + chat=self.input_chat + ) + + return self._input_sender + + @property + def sender(self): + """ + This (:obj:`User`) will make an API call the first time to get + the most up to date version of the sender, so use with care as + there is no caching besides local caching yet. + + ``input_sender`` needs to be available (often the case). + """ + if self._sender is None and self.input_sender: + self._sender = self._client.get_entity(self._input_sender) + return self._sender diff --git a/telethon/telegram_client.py b/telethon/telegram_client.py index 9dda06af..c4f5d722 100644 --- a/telethon/telegram_client.py +++ b/telethon/telegram_client.py @@ -160,6 +160,8 @@ class TelegramClient(TelegramBareClient): **kwargs ) + self._event_builders = [] + # Some fields to easy signing in. Let {phone: hash} be # a dictionary because the user may change their mind. self._phone_code_hash = {} @@ -1623,6 +1625,41 @@ class TelegramClient(TelegramBareClient): # endregion + # region Event handling + + def on(self, event): + """ + + Turns the given entity into a valid Telegram user or chat. + + Args: + event (:obj:`_EventBuilder` | :obj:`type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + """ + if isinstance(event, type): + event = event() + + event.resolve(self) + + def decorator(f): + self._event_builders.append((event, f)) + return f + + if self._on_handler not in self.updates.handlers: + self.add_update_handler(self._on_handler) + + return decorator + + def _on_handler(self, update): + for builder, callback in self._event_builders: + event = builder.build(update) + if event: + event._client = self + callback(event) + + # endregion + # region Small utilities to make users' life easier def get_entity(self, entity):