From 334a847de78f22dbbcf67f2e68c573d0d4b7c35d Mon Sep 17 00:00:00 2001 From: Lonami Exo Date: Mon, 13 Sep 2021 20:37:29 +0200 Subject: [PATCH] Make custom.Message functional --- readthedocs/misc/v2-migration-guide.rst | 26 ++ telethon/_client/chats.py | 12 +- telethon/_client/dialogs.py | 8 +- telethon/_client/messageparse.py | 17 +- telethon/_client/messages.py | 13 +- telethon/_client/telegramclient.py | 3 + telethon/events/album.py | 6 +- telethon/events/chataction.py | 1 + telethon/events/newmessage.py | 1 + telethon/types/_custom/message.py | 424 ++++++++++++------------ 10 files changed, 261 insertions(+), 250 deletions(-) diff --git a/readthedocs/misc/v2-migration-guide.rst b/readthedocs/misc/v2-migration-guide.rst index 3815f2dd..483c5287 100644 --- a/readthedocs/misc/v2-migration-guide.rst +++ b/readthedocs/misc/v2-migration-guide.rst @@ -119,6 +119,32 @@ The following modules have been moved inside ``_misc``: // TODO review telethon/__init__.py isn't exposing more than it should +The custom.Message class and the way it is used has changed +----------------------------------------------------------- + +It no longer inherits ``TLObject``, and rather than trying to mimick Telegram's ``Message`` +constructor, it now takes two parameters: a ``TelegramClient`` instance and a ``_tl.Message``. +As a benefit, you can now more easily reconstruct instances of this type from a previously-stored +``_tl.Message`` instance. + +There are no public attributes. Instead, they are now properties which forward the values into and +from the private ``_message`` field. As a benefit, the documentation will now be easier to follow. +However, you can no longer use ``del`` on these. + +The ``_tl.Message.media`` attribute will no longer be ``None`` when using raw API if the media was +``messageMediaEmpty``. As a benefit, you can now actually distinguish between no media and empty +media. The ``Message.media`` property as returned by friendly methods will still be ``None`` on +empty media. + +The ``telethon.tl.patched`` hack has been removed. + +In order to avoid breaking more code than strictly necessary, ``.raw_text`` will remain a synonym +of ``.message``, and ``.text`` will still be the text formatted through the ``client.parse_mode``. +However, you're encouraged to change uses of ``.raw_text`` with ``.message``, and ``.text`` with +either ``.md_text`` or ``.html_text`` as needed. This is because both ``.text`` and ``.raw_text`` +may disappear in future versions, and their behaviour is not immediately obvious. + + The Conversation API has been removed ------------------------------------- diff --git a/telethon/_client/chats.py b/telethon/_client/chats.py index 0ff93c02..7eb6a2a1 100644 --- a/telethon/_client/chats.py +++ b/telethon/_client/chats.py @@ -291,16 +291,16 @@ class _AdminLogIter(requestiter.RequestIter): for ev in r.events: if isinstance(ev.action, _tl.ChannelAdminLogEventActionEditMessage): - ev.action.prev_message._finish_init( - self.client, entities, self.entity) + ev.action.prev_message = _custom.Message._new( + self.client, ev.action.prev_message, entities, self.entity) - ev.action.new_message._finish_init( - self.client, entities, self.entity) + ev.action.new_message = _custom.Message._new( + self.client, ev.action.new_message, entities, self.entity) elif isinstance(ev.action, _tl.ChannelAdminLogEventActionDeleteMessage): - ev.action.message._finish_init( - self.client, entities, self.entity) + ev.action.message = _custom.Message._new( + self.client, ev.action.message, entities, self.entity) self.buffer.append(_custom.AdminLogEvent(ev, entities)) diff --git a/telethon/_client/dialogs.py b/telethon/_client/dialogs.py index 850e3ac9..b293edca 100644 --- a/telethon/_client/dialogs.py +++ b/telethon/_client/dialogs.py @@ -58,10 +58,10 @@ class _DialogsIter(requestiter.RequestIter): for x in itertools.chain(r.users, r.chats) if not isinstance(x, (_tl.UserEmpty, _tl.ChatEmpty))} - messages = {} - for m in r.messages: - m._finish_init(self.client, entities, None) - messages[_dialog_message_key(m.peer_id, m.id)] = m + messages = { + _dialog_message_key(m.peer_id, m.id): _custom.Message._new(self.client, m, entities, None) + for m in r.messages + } for d in r.dialogs: # We check the offset date here because Telegram may ignore it diff --git a/telethon/_client/messageparse.py b/telethon/_client/messageparse.py index 9f1f3b70..69d438fd 100644 --- a/telethon/_client/messageparse.py +++ b/telethon/_client/messageparse.py @@ -3,6 +3,7 @@ import re import typing from .. import helpers, utils, _tl +from ..types import _custom if typing.TYPE_CHECKING: from .telegramclient import TelegramClient @@ -94,7 +95,7 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): elif isinstance(update, ( _tl.UpdateNewChannelMessage, _tl.UpdateNewMessage)): - update.message._finish_init(self, entities, input_chat) + update.message = _custom.Message._new(self, update.message, entities, input_chat) # Pinning a message with `updatePinnedMessage` seems to # always produce a service message we can't map so return @@ -110,7 +111,7 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): elif (isinstance(update, _tl.UpdateEditMessage) and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): - update.message._finish_init(self, entities, input_chat) + update.message = _custom.Message._new(self, update.message, entities, input_chat) # Live locations use `sendMedia` but Telegram responds with # `updateEditMessage`, which means we won't have `id` field. @@ -123,28 +124,24 @@ def _get_response_message(self: 'TelegramClient', request, result, input_chat): and utils.get_peer_id(request.peer) == utils.get_peer_id(update.message.peer_id)): if request.id == update.message.id: - update.message._finish_init(self, entities, input_chat) - return update.message + return _custom.Message._new(self, update.message, entities, input_chat) elif isinstance(update, _tl.UpdateNewScheduledMessage): - update.message._finish_init(self, entities, input_chat) # Scheduled IDs may collide with normal IDs. However, for a # single request there *shouldn't* be a mix between "some # scheduled and some not". - id_to_message[update.message.id] = update.message + id_to_message[update.message.id] = _custom.Message._new(self, update.message, entities, input_chat) elif isinstance(update, _tl.UpdateMessagePoll): if request.media.poll.id == update.poll_id: - m = _tl.Message( + return _custom.Message._new(self, _tl.Message( id=request.id, peer_id=utils.get_peer(request.peer), media=_tl.MessageMediaPoll( poll=update.poll, results=update.results ) - ) - m._finish_init(self, entities, input_chat) - return m + ), entities, input_chat) if request is None: return id_to_message diff --git a/telethon/_client/messages.py b/telethon/_client/messages.py index 5dae6eb4..3b63d35b 100644 --- a/telethon/_client/messages.py +++ b/telethon/_client/messages.py @@ -5,6 +5,7 @@ import warnings from .. import errors, hints, _tl from .._misc import helpers, utils, requestiter +from ..types import _custom _MAX_CHUNK_SIZE = 100 @@ -200,8 +201,7 @@ class _MessagesIter(requestiter.RequestIter): # is an attempt to avoid these duplicates, since the message # IDs are returned in descending order (or asc if reverse). self.last_id = message.id - message._finish_init(self.client, entities, self.entity) - self.buffer.append(message) + self.buffer.append(_custom.Message._new(self.client, message, entities, self.entity)) if len(r.messages) < self.request.limit: return True @@ -315,8 +315,7 @@ class _IDsIter(requestiter.RequestIter): from_id and message.peer_id != from_id): self.buffer.append(None) else: - message._finish_init(self.client, entities, self._entity) - self.buffer.append(message) + self.buffer.append(_custom.Message._new(self.client, message, entities, self._entity)) def iter_messages( @@ -498,7 +497,7 @@ async def send_message( result = await self(request) if isinstance(result, _tl.UpdateShortSentMessage): - message = _tl.Message( + return _custom.Message._new(self, _tl.Message( id=result.id, peer_id=await self._get_peer(entity), message=message, @@ -508,9 +507,7 @@ async def send_message( entities=result.entities, reply_markup=request.reply_markup, ttl_period=result.ttl_period - ) - message._finish_init(self, {}, entity) - return message + ), {}, entity) return self._get_response_message(request, result, entity) diff --git a/telethon/_client/telegramclient.py b/telethon/_client/telegramclient.py index 85b28cd3..5c84a990 100644 --- a/telethon/_client/telegramclient.py +++ b/telethon/_client/telegramclient.py @@ -3783,6 +3783,9 @@ class TelegramClient: async def _handle_auto_reconnect(self: 'TelegramClient'): return await updates._handle_auto_reconnect(**locals()) + def _self_id(self: 'TelegramClient') -> typing.Optional[int]: + return users._self_id(**locals()) + # endregion Private # TODO re-patch everything to remove the intermediate calls diff --git a/telethon/events/album.py b/telethon/events/album.py index fdc3c02c..d8dacb98 100644 --- a/telethon/events/album.py +++ b/telethon/events/album.py @@ -168,8 +168,10 @@ class Album(EventBuilder): self._sender, self._input_sender = utils._get_entity_pair( self.sender_id, self._entities, client._entity_cache) - for msg in self.messages: - msg._finish_init(client, self._entities, None) + self.messages = [ + _custom.Message._new(client, m, self._entities, None) + for m in self.messages + ] if len(self.messages) == 1: # This will require hacks to be a proper album event diff --git a/telethon/events/chataction.py b/telethon/events/chataction.py index d330656a..75f19075 100644 --- a/telethon/events/chataction.py +++ b/telethon/events/chataction.py @@ -1,6 +1,7 @@ from .common import EventBuilder, EventCommon, name_inner_event from .._misc import utils from .. import _tl +from ..types import _custom @name_inner_event diff --git a/telethon/events/newmessage.py b/telethon/events/newmessage.py index cfe7b88a..58e1a425 100644 --- a/telethon/events/newmessage.py +++ b/telethon/events/newmessage.py @@ -3,6 +3,7 @@ import re from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set from .._misc import utils from .. import _tl +from ..types import _custom @name_inner_event diff --git a/telethon/types/_custom/message.py b/telethon/types/_custom/message.py index 2be48847..88a1a615 100644 --- a/telethon/types/_custom/message.py +++ b/telethon/types/_custom/message.py @@ -9,9 +9,22 @@ from ..._misc import utils, tlobject from ... import errors, _tl +def _fwd(field, doc): + def fget(self): + try: + return self._message.__dict__[field] + except KeyError: + return None + + def fset(self, value): + self._message.__dict__[field] = value + + return property(fget, fset, None, doc) + + # TODO Figure out a way to have the code generator error on missing fields # Maybe parsing the init function alone if that's possible. -class Message(ChatGetter, SenderGetter, tlobject.TLObject): +class Message(ChatGetter, SenderGetter): """ This custom class aggregates both :tl:`Message` and :tl:`MessageService` to ease accessing their members. @@ -20,219 +33,192 @@ class Message(ChatGetter, SenderGetter, tlobject.TLObject): ` and `SenderGetter ` which means you have access to all their sender and chat properties and methods. - - Members: - out (`bool`): - Whether the message is outgoing (i.e. you sent it from - another session) or incoming (i.e. someone else sent it). - - Note that messages in your own chat are always incoming, - but this member will be `True` if you send a message - to your own chat. Messages you forward to your chat are - *not* considered outgoing, just like official clients - display them. - - mentioned (`bool`): - Whether you were mentioned in this message or not. - Note that replies to your own messages also count - as mentions. - - media_unread (`bool`): - Whether you have read the media in this message - or not, e.g. listened to the voice note media. - - silent (`bool`): - Whether the message should notify people with sound or not. - Previously used in channels, but since 9 August 2019, it can - also be `used in private chats - `_. - - post (`bool`): - Whether this message is a post in a broadcast - channel or not. - - from_scheduled (`bool`): - Whether this message was originated from a previously-scheduled - message or not. - - legacy (`bool`): - Whether this is a legacy message or not. - - edit_hide (`bool`): - Whether the edited mark of this message is edited - should be hidden (e.g. in GUI clients) or shown. - - pinned (`bool`): - Whether this message is currently pinned or not. - - id (`int`): - The ID of this message. This field is *always* present. - Any other member is optional and may be `None`. - - from_id (:tl:`Peer`): - The peer who sent this message, which is either - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. - This value will be `None` for anonymous messages. - - peer_id (:tl:`Peer`): - The peer to which this message was sent, which is either - :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This - will always be present except for empty messages. - - fwd_from (:tl:`MessageFwdHeader`): - The original forward header if this message is a forward. - You should probably use the `forward` property instead. - - via_bot_id (`int`): - The ID of the bot used to send this message - through its inline mode (e.g. "via @like"). - - reply_to (:tl:`MessageReplyHeader`): - The original reply header if this message is replying to another. - - date (`datetime`): - The UTC+0 `datetime` object indicating when this message - was sent. This will always be present except for empty - messages. - - message (`str`): - The string text of the message for `Message - ` instances, - which will be `None` for other types of messages. - - media (:tl:`MessageMedia`): - The media sent with this message if any (such as - photos, videos, documents, gifs, stickers, etc.). - - You may want to access the `photo`, `document` - etc. properties instead. - - If the media was not present or it was :tl:`MessageMediaEmpty`, - this member will instead be `None` for convenience. - - reply_markup (:tl:`ReplyMarkup`): - The reply markup for this message (which was sent - either via a bot or by a bot). You probably want - to access `buttons` instead. - - entities (List[:tl:`MessageEntity`]): - The list of markup entities in this message, - such as bold, italics, code, hyperlinks, etc. - - views (`int`): - The number of views this message from a broadcast - channel has. This is also present in forwards. - - forwards (`int`): - The number of times this message has been forwarded. - - replies (`int`): - The number of times another message has replied to this message. - - edit_date (`datetime`): - The date when this message was last edited. - - post_author (`str`): - The display name of the message sender to - show in messages sent to broadcast channels. - - grouped_id (`int`): - If this message belongs to a group of messages - (photo albums or video albums), all of them will - have the same value here. - - restriction_reason (List[:tl:`RestrictionReason`]) - An optional list of reasons why this message was restricted. - If the list is `None`, this message has not been restricted. - - ttl_period (`int`): - The Time To Live period configured for this message. - The message should be erased from wherever it's stored (memory, a - local database, etc.) when - ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. - - action (:tl:`MessageAction`): - The message action object of the message for :tl:`MessageService` - instances, which will be `None` for other types of messages. """ + # region Forwarded properties + + out = _fwd('out', """ + Whether the message is outgoing (i.e. you sent it from + another session) or incoming (i.e. someone else sent it). + + Note that messages in your own chat are always incoming, + but this member will be `True` if you send a message + to your own chat. Messages you forward to your chat are + *not* considered outgoing, just like official clients + display them. + """) + + mentioned = _fwd('mentioned', """ + Whether you were mentioned in this message or not. + Note that replies to your own messages also count + as mentions. + """) + + media_unread = _fwd('media_unread', """ + Whether you have read the media in this message + or not, e.g. listened to the voice note media. + """) + + silent = _fwd('silent', """ + Whether the message should notify people with sound or not. + Previously used in channels, but since 9 August 2019, it can + also be `used in private chats + `_. + """) + + post = _fwd('post', """ + Whether this message is a post in a broadcast + channel or not. + """) + + from_scheduled = _fwd('from_scheduled', """ + Whether this message was originated from a previously-scheduled + message or not. + """) + + legacy = _fwd('legacy', """ + Whether this is a legacy message or not. + """) + + edit_hide = _fwd('edit_hide', """ + Whether the edited mark of this message is edited + should be hidden (e.g. in GUI clients) or shown. + """) + + pinned = _fwd('pinned', """ + Whether this message is currently pinned or not. + """) + + id = _fwd('id', """ + The ID of this message. This field is *always* present. + Any other member is optional and may be `None`. + """) + + from_id = _fwd('from_id', """ + The peer who sent this message, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. + This value will be `None` for anonymous messages. + """) + + peer_id = _fwd('peer_id', """ + The peer to which this message was sent, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This + will always be present except for empty messages. + """) + + fwd_from = _fwd('fwd_from', """ + The original forward header if this message is a forward. + You should probably use the `forward` property instead. + """) + + via_bot_id = _fwd('via_bot_id', """ + The ID of the bot used to send this message + through its inline mode (e.g. "via @like"). + """) + + reply_to = _fwd('reply_to', """ + The original reply header if this message is replying to another. + """) + + date = _fwd('date', """ + The UTC+0 `datetime` object indicating when this message + was sent. This will always be present except for empty + messages. + """) + + message = _fwd('message', """ + The string text of the message for `Message + ` instances, + which will be `None` for other types of messages. + """) + + @property + def media(self): + """ + The media sent with this message if any (such as + photos, videos, documents, gifs, stickers, etc.). + + You may want to access the `photo`, `document` + etc. properties instead. + + If the media was not present or it was :tl:`MessageMediaEmpty`, + this member will instead be `None` for convenience. + """ + try: + media = self._message.media + except AttributeError: + return None + + return None if media.CONSTRUCTOR_ID == 0x3ded6320 else media + + @media.setter + def media(self, value): + self._message.media = value + + reply_markup = _fwd('reply_markup', """ + The reply markup for this message (which was sent + either via a bot or by a bot). You probably want + to access `buttons` instead. + """) + + entities = _fwd('entities', """ + The list of markup entities in this message, + such as bold, italics, code, hyperlinks, etc. + """) + + views = _fwd('views', """ + The number of views this message from a broadcast + channel has. This is also present in forwards. + """) + + forwards = _fwd('forwards', """ + The number of times this message has been forwarded. + """) + + replies = _fwd('replies', """ + The number of times another message has replied to this message. + """) + + edit_date = _fwd('edit_date', """ + The date when this message was last edited. + """) + + post_author = _fwd('post_author', """ + The display name of the message sender to + show in messages sent to broadcast channels. + """) + + grouped_id = _fwd('grouped_id', """ + If this message belongs to a group of messages + (photo albums or video albums), all of them will + have the same value here. + + restriction_reason (List[:tl:`RestrictionReason`]) + An optional list of reasons why this message was restricted. + If the list is `None`, this message has not been restricted. + """) + + ttl_period = _fwd('ttl_period', """ + The Time To Live period configured for this message. + The message should be erased from wherever it's stored (memory, a + local database, etc.) when + ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. + """) + + action = _fwd('action', """ + The message action object of the message for :tl:`MessageService` + instances, which will be `None` for other types of messages. + """) + + # endregion + # region Initialization - def __init__( - # Common to all - self, id: int, - - # Common to Message and MessageService (mandatory) - peer_id: _tl.TypePeer = None, - date: Optional[datetime] = None, - - # Common to Message and MessageService (flags) - out: Optional[bool] = None, - mentioned: Optional[bool] = None, - media_unread: Optional[bool] = None, - silent: Optional[bool] = None, - post: Optional[bool] = None, - from_id: Optional[_tl.TypePeer] = None, - reply_to: Optional[_tl.TypeMessageReplyHeader] = None, - ttl_period: Optional[int] = None, - - # For Message (mandatory) - message: Optional[str] = None, - - # For Message (flags) - fwd_from: Optional[_tl.TypeMessageFwdHeader] = None, - via_bot_id: Optional[int] = None, - media: Optional[_tl.TypeMessageMedia] = None, - reply_markup: Optional[_tl.TypeReplyMarkup] = None, - entities: Optional[List[_tl.TypeMessageEntity]] = None, - views: Optional[int] = None, - edit_date: Optional[datetime] = None, - post_author: Optional[str] = None, - grouped_id: Optional[int] = None, - from_scheduled: Optional[bool] = None, - legacy: Optional[bool] = None, - edit_hide: Optional[bool] = None, - pinned: Optional[bool] = None, - restriction_reason: Optional[_tl.TypeRestrictionReason] = None, - forwards: Optional[int] = None, - replies: Optional[_tl.TypeMessageReplies] = None, - - # For MessageAction (mandatory) - action: Optional[_tl.TypeMessageAction] = None - ): - # Common properties to messages, then to service (in the order they're defined in the `.tl`) - self.out = bool(out) - self.mentioned = mentioned - self.media_unread = media_unread - self.silent = silent - self.post = post - self.from_scheduled = from_scheduled - self.legacy = legacy - self.edit_hide = edit_hide - self.id = id - self.from_id = from_id - self.peer_id = peer_id - self.fwd_from = fwd_from - self.via_bot_id = via_bot_id - self.reply_to = reply_to - self.date = date - self.message = message - self.media = None if isinstance(media, _tl.MessageMediaEmpty) else media - self.reply_markup = reply_markup - self.entities = entities - self.views = views - self.forwards = forwards - self.replies = replies - self.edit_date = edit_date - self.pinned = pinned - self.post_author = post_author - self.grouped_id = grouped_id - self.restriction_reason = restriction_reason - self.ttl_period = ttl_period - self.action = action + def __init__(self, client, message): + self._client = client + self._message = message # Convenient storage for custom functions - # TODO This is becoming a bit of bloat self._client = None self._text = None self._file = None @@ -246,28 +232,25 @@ class Message(ChatGetter, SenderGetter, tlobject.TLObject): self._linked_chat = None sender_id = None - if from_id is not None: - sender_id = utils.get_peer_id(from_id) - elif peer_id: + if self.from_id is not None: + sender_id = utils.get_peer_id(self.from_id) + elif self.peer_id: # If the message comes from a Channel, let the sender be it # ...or... # incoming messages in private conversations no longer have from_id # (layer 119+), but the sender can only be the chat we're in. - if post or (not out and isinstance(peer_id, _tl.PeerUser)): - sender_id = utils.get_peer_id(peer_id) + if self.post or (not self.out and isinstance(self.peer_id, _tl.PeerUser)): + sender_id = utils.get_peer_id(self.peer_id) # Note that these calls would reset the client - ChatGetter.__init__(self, peer_id, broadcast=post) + ChatGetter.__init__(self, self.peer_id, broadcast=self.post) SenderGetter.__init__(self, sender_id) self._forward = None - def _finish_init(self, client, entities, input_chat): - """ - Finishes the initialization of this message by setting - the client that sent the message and making use of the - known entities. - """ + @classmethod + def _new(cls, client, message, entities, input_chat): + self = cls(client, message) self._client = client # Make messages sent to ourselves outgoing unless they're forwarded. @@ -314,6 +297,7 @@ class Message(ChatGetter, SenderGetter, tlobject.TLObject): self._linked_chat = entities.get(utils.get_peer_id( _tl.PeerChannel(self.replies.channel_id))) + return self # endregion Initialization