Telethon/telethon/client/messages.py
2018-12-25 16:50:11 +01:00

847 lines
33 KiB
Python

import asyncio
import itertools
import logging
import time
from async_generator import async_generator, yield_
from .messageparse import MessageParseMethods
from .uploads import UploadMethods
from .buttons import ButtonMethods
from .. import helpers, utils, errors
from ..tl import types, functions
__log__ = logging.getLogger(__name__)
class MessageMethods(UploadMethods, ButtonMethods, MessageParseMethods):
# region Public methods
# region Message retrieval
@async_generator
async def iter_messages(
self, entity, limit=None, *, offset_date=None, offset_id=0,
max_id=0, min_id=0, add_offset=0, search=None, filter=None,
from_user=None, batch_size=100, wait_time=None, ids=None,
reverse=False, _total=None):
"""
Iterator over the message history for the specified entity.
If either `search`, `filter` or `from_user` are provided,
:tl:`messages.Search` will be used instead of :tl:`messages.getHistory`.
Args:
entity (`entity`):
The entity from whom to retrieve the message history.
It may be ``None`` to perform a global search, or
to get messages by their ID from no particular chat.
Note that some of the offsets will not work if this
is the case.
Note that if you want to perform a global search,
you **must** set a non-empty `search` string.
limit (`int` | `None`, optional):
Number of messages to be retrieved. Due to limitations with
the API retrieving more than 3000 messages will take longer
than half a minute (or even more based on previous calls).
The limit may also be ``None``, which would eventually return
the whole history.
offset_date (`datetime`):
Offset date (messages *previous* to this date will be
retrieved). Exclusive.
offset_id (`int`):
Offset message ID (only messages *previous* to the given
ID will be retrieved). Exclusive.
max_id (`int`):
All the messages with a higher (newer) ID or equal to this will
be excluded.
min_id (`int`):
All the messages with a lower (older) ID or equal to this will
be excluded.
add_offset (`int`):
Additional message offset (all of the specified offsets +
this offset = older messages).
search (`str`):
The string to be used as a search query.
filter (:tl:`MessagesFilter` | `type`):
The filter to use when returning messages. For instance,
:tl:`InputMessagesFilterPhotos` would yield only messages
containing photos.
from_user (`entity`):
Only messages from this user will be returned.
This parameter will be ignored if it is not an user.
batch_size (`int`):
Messages will be returned in chunks of this size (100 is
the maximum). While it makes no sense to modify this value,
you are still free to do so.
wait_time (`int`):
Wait time between different :tl:`GetHistoryRequest`. Use this
parameter to avoid hitting the ``FloodWaitError`` as needed.
If left to ``None``, it will default to 1 second only if
the limit is higher than 3000.
ids (`int`, `list`):
A single integer ID (or several IDs) for the message that
should be returned. This parameter takes precedence over
the rest (which will be ignored if this is set). This can
for instance be used to get the message with ID 123 from
a channel. Note that if the message doesn't exist, ``None``
will appear in its place, so that zipping the list of IDs
with the messages can match one-to-one.
.. note::
At the time of writing, Telegram will **not** return
:tl:`MessageEmpty` for :tl:`InputMessageReplyTo` IDs that
failed (i.e. the message is not replying to any, or is
replying to a deleted message). This means that it is
**not** possible to match messages one-by-one, so be
careful if you use non-integers in this parameter.
reverse (`bool`, optional):
If set to ``True``, the messages will be returned in reverse
order (from oldest to newest, instead of the default newest
to oldest). This also means that the meaning of `offset_id`
and `offset_date` parameters is reversed, although they will
still be exclusive. `min_id` becomes equivalent to `offset_id`
instead of being `max_id` as well since messages are returned
in ascending order.
You cannot use this if both `entity` and `ids` are ``None``.
_total (`list`, optional):
A single-item list to pass the total parameter by reference.
Yields:
Instances of `telethon.tl.custom.message.Message`.
Notes:
Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to
be around 30 seconds per 3000 messages, therefore a sleep of 1
second is the default for this limit (or above). You may need
an higher limit, so you're free to set the ``batch_size`` that
you think may be good.
"""
# Note that entity being ``None`` is intended to get messages by
# ID under no specific chat, and also to request a global search.
if entity:
entity = await self.get_input_entity(entity)
if ids:
if not utils.is_list_like(ids):
ids = (ids,)
if reverse:
ids = list(reversed(ids))
async for x in self._iter_ids(entity, ids, total=_total):
await yield_(x)
return
# Telegram doesn't like min_id/max_id. If these IDs are low enough
# (starting from last_id - 100), the request will return nothing.
#
# We can emulate their behaviour locally by setting offset = max_id
# and simply stopping once we hit a message with ID <= min_id.
if reverse:
offset_id = max(offset_id, min_id)
if offset_id and max_id:
if max_id - offset_id <= 1:
return
if not max_id:
max_id = float('inf')
else:
offset_id = max(offset_id, max_id)
if offset_id and min_id:
if offset_id - min_id <= 1:
return
if reverse:
if offset_id:
offset_id += 1
else:
offset_id = 1
if not from_user:
from_id = None
else:
from_id = await self.get_input_entity(from_user)
if not isinstance(from_id, (
types.InputPeerUser, types.InputPeerSelf)):
from_id = None # Ignore from_user unless it's a user
limit = float('inf') if limit is None else int(limit)
if not entity:
if reverse:
raise ValueError('Cannot reverse global search')
reverse = None
request = functions.messages.SearchGlobalRequest(
q=search or '',
offset_date=offset_date,
offset_peer=types.InputPeerEmpty(),
offset_id=offset_id,
limit=1
)
elif search is not None or filter or from_id:
if filter is None:
filter = types.InputMessagesFilterEmpty()
request = functions.messages.SearchRequest(
peer=entity,
q=search or '',
filter=filter() if isinstance(filter, type) else filter,
min_date=None,
max_date=offset_date,
offset_id=offset_id,
add_offset=add_offset,
limit=0, # Search actually returns 0 items if we ask it to
max_id=0,
min_id=0,
hash=0,
from_id=from_id
)
if not isinstance(entity, (
types.InputPeerUser, types.InputPeerSelf)):
from_id = None
else:
# Telegram completely ignores `from_id` in private
# chats, so we need to do this check client-side.
if isinstance(from_id, types.InputPeerSelf):
from_id = await self.get_peer_id('me')
else:
from_id = from_id.user_id
else:
request = functions.messages.GetHistoryRequest(
peer=entity,
limit=1,
offset_date=offset_date,
offset_id=offset_id,
min_id=0,
max_id=0,
add_offset=add_offset,
hash=0
)
if limit == 0:
if not _total:
return
# No messages, but we still need to know the total message count
result = await self(request)
if isinstance(result, types.messages.MessagesNotModified):
_total[0] = result.count
else:
_total[0] = getattr(result, 'count', len(result.messages))
return
if wait_time is None:
wait_time = 1 if limit > 3000 else 0
have = 0
last_id = 0 if reverse else float('inf')
# Telegram has a hard limit of 100.
# We don't need to fetch 100 if the limit is less.
batch_size = min(max(batch_size, 1), min(100, limit))
# Use a negative offset to work around reversing the results
if reverse:
request.add_offset -= batch_size
while have < limit:
start = time.time()
request.limit = min(limit - have, batch_size)
if reverse and request.limit != batch_size:
# Last batch needs special care if we're on reverse
request.add_offset += batch_size - request.limit + 1
r = await self(request)
if _total:
_total[0] = getattr(r, 'count', len(r.messages))
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
messages = reversed(r.messages) if reverse else r.messages
for message in messages:
if (isinstance(message, types.MessageEmpty)
or from_id and message.from_id != from_id):
continue
if reverse is None:
pass
elif reverse:
if message.id <= last_id or message.id >= max_id:
return
else:
if message.id >= last_id or message.id <= min_id:
return
# There has been reports that on bad connections this method
# was returning duplicated IDs sometimes. Using ``last_id``
# is an attempt to avoid these duplicates, since the message
# IDs are returned in descending order (or asc if reverse).
last_id = message.id
message._finish_init(self, entities, entity)
await yield_(message)
have += 1
if len(r.messages) < request.limit:
break
# Find the first message that's not empty (in some rare cases
# it can happen that the last message is :tl:`MessageEmpty`)
last_message = None
messages = r.messages if reverse else reversed(r.messages)
for m in messages:
if not isinstance(m, types.MessageEmpty):
last_message = m
break
if last_message is None:
# There are some cases where all the messages we get start
# being empty. This can happen on migrated mega-groups if
# the history was cleared, and we're using search. Telegram
# acts incredibly weird sometimes. Messages are returned but
# only "empty", not their contents. If this is the case we
# should just give up since there won't be any new Message.
break
else:
request.offset_id = last_message.id
if isinstance(request, functions.messages.SearchRequest):
request.max_date = last_message.date
else:
# getHistory and searchGlobal call it offset_date
request.offset_date = last_message.date
if isinstance(request, functions.messages.SearchGlobalRequest):
request.offset_peer = last_message.input_chat
elif reverse:
# We want to skip the one we already have
request.add_offset -= 1
await asyncio.sleep(
max(wait_time - (time.time() - start), 0), loop=self._loop)
async def get_messages(self, *args, **kwargs):
"""
Same as `iter_messages`, but returns a
`TotalList <telethon.helpers.TotalList>` instead.
If the `limit` is not set, it will be 1 by default unless both
`min_id` **and** `max_id` are set (as *named* arguments), in
which case the entire range will be returned.
This is so because any integer limit would be rather arbitrary and
it's common to only want to fetch one message, but if a range is
specified it makes sense that it should return the entirety of it.
If `ids` is present in the *named* arguments and is not a list,
a single :tl:`Message` will be returned for convenience instead
of a list.
"""
total = [0]
kwargs['_total'] = total
if len(args) == 1 and 'limit' not in kwargs:
if 'min_id' in kwargs and 'max_id' in kwargs:
kwargs['limit'] = None
else:
kwargs['limit'] = 1
msgs = helpers.TotalList()
async for x in self.iter_messages(*args, **kwargs):
msgs.append(x)
msgs.total = total[0]
if 'ids' in kwargs and not utils.is_list_like(kwargs['ids']):
# Check for empty list to handle InputMessageReplyTo
return msgs[0] if msgs else None
return msgs
# endregion
# region Message sending/editing/deleting
async def send_message(
self, entity, message='', *, reply_to=None,
parse_mode=(), link_preview=True, file=None,
force_document=False, clear_draft=False, buttons=None,
silent=None):
"""
Sends the given message to the specified entity (user/chat/channel).
The default parse mode is the same as the official applications
(a custom flavour of markdown). ``**bold**, `code` or __italic__``
are available. In addition you can send ``[links](https://example.com)``
and ``[mentions](@username)`` (or using IDs like in the Bot API:
``[mention](tg://user?id=123456789)``) and ``pre`` blocks with three
backticks.
Sending a ``/start`` command with a parameter (like ``?start=data``)
is also done through this method. Simply send ``'/start data'`` to
the bot.
Args:
entity (`entity`):
To who will it be sent.
message (`str` | :tl:`Message`):
The message to be sent, or another message object to resend.
The maximum length for a message is 35,000 bytes or 4,096
characters. Longer messages will not be sliced automatically,
and you should slice them manually if the text to send is
longer than said length.
reply_to (`int` | :tl:`Message`, optional):
Whether to reply to a message or not. If an integer is provided,
it should be the ID of the message that it should reply to.
parse_mode (`object`, optional):
See the `TelegramClient.parse_mode` property for allowed
values. Markdown parsing will be used by default.
link_preview (`bool`, optional):
Should the link preview be shown?
file (`file`, optional):
Sends a message with a file attached (e.g. a photo,
video, audio or document). The ``message`` may be empty.
force_document (`bool`, optional):
Whether to send the given file as a document or not.
clear_draft (`bool`, optional):
Whether the existing draft should be cleared or not.
Has no effect when sending a file.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`, :tl:`KeyboardButton`):
The matrix (list of lists), row list or button to be shown
after sending the message. This parameter will only work if
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
All the following limits apply together:
* There can be 100 buttons at most (any more are ignored).
* There can be 8 buttons per row at most (more are ignored).
* The maximum callback data per button is 64 bytes.
* The maximum data that can be embedded in total is just
over 4KB, shared between inline callback data and text.
silent (`bool`, optional):
Whether the message should notify people in a broadcast
channel or not. Defaults to ``False``, which means it will
notify them. Set it to ``True`` to alter this behaviour.
Returns:
The sent `custom.Message <telethon.tl.custom.message.Message>`.
"""
if file is not None:
return await self.send_file(
entity, file, caption=message, reply_to=reply_to,
parse_mode=parse_mode, force_document=force_document,
buttons=buttons
)
elif not message:
raise ValueError(
'The message cannot be empty unless a file is provided'
)
entity = await self.get_input_entity(entity)
if isinstance(message, types.Message):
if buttons is None:
markup = message.reply_markup
else:
markup = self.build_reply_markup(buttons)
if silent is None:
silent = message.silent
if (message.media and not isinstance(
message.media, types.MessageMediaWebPage)):
return await self.send_file(
entity,
message.media,
caption=message.message,
silent=silent,
reply_to=reply_to,
buttons=markup,
entities=message.entities
)
request = functions.messages.SendMessageRequest(
peer=entity,
message=message.message or '',
silent=silent,
reply_to_msg_id=utils.get_message_id(reply_to),
reply_markup=markup,
entities=message.entities,
clear_draft=clear_draft,
no_webpage=not isinstance(
message.media, types.MessageMediaWebPage)
)
message = message.message
else:
message, msg_ent = await self._parse_message_text(message,
parse_mode)
request = functions.messages.SendMessageRequest(
peer=entity,
message=message,
entities=msg_ent,
no_webpage=not link_preview,
reply_to_msg_id=utils.get_message_id(reply_to),
clear_draft=clear_draft,
silent=silent,
reply_markup=self.build_reply_markup(buttons)
)
result = await self(request)
if isinstance(result, types.UpdateShortSentMessage):
to_id, cls = utils.resolve_id(utils.get_peer_id(entity))
message = types.Message(
id=result.id,
to_id=cls(to_id),
message=message,
date=result.date,
out=result.out,
media=result.media,
entities=result.entities
)
message._finish_init(self, {}, entity)
return message
return self._get_response_message(request, result, entity)
async def forward_messages(self, entity, messages, from_peer=None,
*, silent=None):
"""
Forwards the given message(s) to the specified entity.
Args:
entity (`entity`):
To which entity the message(s) will be forwarded.
messages (`list` | `int` | :tl:`Message`):
The message(s) to forward, or their integer IDs.
from_peer (`entity`):
If the given messages are integer IDs and not instances
of the ``Message`` class, this *must* be specified in
order for the forward to work.
silent (`bool`, optional):
Whether the message should notify people in a broadcast
channel or not. Defaults to ``False``, which means it will
notify them. Set it to ``True`` to alter this behaviour.
Returns:
The list of forwarded `telethon.tl.custom.message.Message`,
or a single one if a list wasn't provided as input.
Note that if all messages are invalid (i.e. deleted) the call
will fail with ``MessageIdInvalidError``. If only some are
invalid, the list will have ``None`` instead of those messages.
"""
single = not utils.is_list_like(messages)
if single:
messages = (messages,)
if not from_peer:
try:
# On private chats (to_id = PeerUser), if the message is
# not outgoing, we actually need to use "from_id" to get
# the conversation on which the message was sent.
from_peer = next(
m.from_id
if not m.out and isinstance(m.to_id, types.PeerUser)
else m.to_id for m in messages
if isinstance(m, types.Message)
)
except StopIteration:
raise ValueError(
'from_chat must be given if integer IDs are used'
)
req = functions.messages.ForwardMessagesRequest(
from_peer=from_peer,
id=[m if isinstance(m, int) else m.id for m in messages],
to_peer=entity,
silent=silent
)
result = await self(req)
if isinstance(result, (types.Updates, types.UpdatesCombined)):
entities = {utils.get_peer_id(x): x
for x in itertools.chain(result.users, result.chats)}
else:
entities = {}
random_to_id = {}
id_to_message = {}
for update in result.updates:
if isinstance(update, types.UpdateMessageID):
random_to_id[update.random_id] = update.id
elif isinstance(update, (
types.UpdateNewMessage, types.UpdateNewChannelMessage)):
update.message._finish_init(self, entities, entity)
id_to_message[update.message.id] = update.message
# Trying to forward only deleted messages causes `MESSAGE_ID_INVALID`
# but forwarding valid and invalid messages in the same call makes the
# call succeed, although the API won't return those messages thus
# `random_to_id[rnd]` would `KeyError`. Check the key beforehand.
result = [id_to_message[random_to_id[rnd]]
if rnd in random_to_id else None
for rnd in req.random_id]
return result[0] if single else result
async def edit_message(
self, entity, message=None, text=None,
*, parse_mode=(), link_preview=True, file=None,
buttons=None):
"""
Edits the given message ID (to change its contents or disable preview).
Args:
entity (`entity` | :tl:`Message`):
From which chat to edit the message. This can also be
the message to be edited, and the entity will be inferred
from it, so the next parameter will be assumed to be the
message text.
message (`int` | :tl:`Message` | `str`):
The ID of the message (or :tl:`Message` itself) to be edited.
If the `entity` was a :tl:`Message`, then this message will be
treated as the new text.
text (`str`, optional):
The new text of the message. Does nothing if the `entity`
was a :tl:`Message`.
parse_mode (`object`, optional):
See the `TelegramClient.parse_mode` property for allowed
values. Markdown parsing will be used by default.
link_preview (`bool`, optional):
Should the link preview be shown?
file (`str` | `bytes` | `file` | `media`, optional):
The file object that should replace the existing media
in the message.
buttons (`list`, `custom.Button <telethon.tl.custom.button.Button>`, :tl:`KeyboardButton`):
The matrix (list of lists), row list or button to be shown
after sending the message. This parameter will only work if
you have signed in as a bot. You can also pass your own
:tl:`ReplyMarkup` here.
Examples:
>>> client = ...
>>> message = client.send_message('username', 'hello')
>>>
>>> client.edit_message('username', message, 'hello!')
>>> # or
>>> client.edit_message('username', message.id, 'Hello')
>>> # or
>>> client.edit_message(message, 'Hello!')
Raises:
``MessageAuthorRequiredError`` if you're not the author of the
message but tried editing it anyway.
``MessageNotModifiedError`` if the contents of the message were
not modified at all.
Returns:
The edited `telethon.tl.custom.message.Message`.
"""
if isinstance(entity, types.Message):
text = message # Shift the parameters to the right
message = entity
entity = entity.to_id
entity = await self.get_input_entity(entity)
text, msg_entities = await self._parse_message_text(text, parse_mode)
file_handle, media = await self._file_to_media(file)
request = functions.messages.EditMessageRequest(
peer=entity,
id=utils.get_message_id(message),
message=text,
no_webpage=not link_preview,
entities=msg_entities,
media=media,
reply_markup=self.build_reply_markup(buttons)
)
msg = self._get_response_message(request, await self(request), entity)
await self._cache_media(msg, file, file_handle)
return msg
async def delete_messages(self, entity, message_ids, *, revoke=True):
"""
Deletes a message from a chat, optionally "for everyone".
Args:
entity (`entity`):
From who the message will be deleted. This can actually
be ``None`` for normal chats, but **must** be present
for channels and megagroups.
message_ids (`list` | `int` | :tl:`Message`):
The IDs (or ID) or messages to be deleted.
revoke (`bool`, optional):
Whether the message should be deleted for everyone or not.
By default it has the opposite behaviour of official clients,
and it will delete the message for everyone.
This has no effect on channels or megagroups.
Returns:
A list of :tl:`AffectedMessages`, each item being the result
for the delete calls of the messages in chunks of 100 each.
"""
if not utils.is_list_like(message_ids):
message_ids = (message_ids,)
message_ids = (
m.id if isinstance(m, (
types.Message, types.MessageService, types.MessageEmpty))
else int(m) for m in message_ids
)
entity = await self.get_input_entity(entity) if entity else None
if isinstance(entity, types.InputPeerChannel):
return await self([functions.channels.DeleteMessagesRequest(
entity, list(c)) for c in utils.chunks(message_ids)])
else:
return await self([functions.messages.DeleteMessagesRequest(
list(c), revoke) for c in utils.chunks(message_ids)])
# endregion
# region Miscellaneous
async def send_read_acknowledge(
self, entity, message=None, *, max_id=None, clear_mentions=False):
"""
Sends a "read acknowledge" (i.e., notifying the given peer that we've
read their messages, also known as the "double check").
This effectively marks a message as read (or more than one) in the
given conversation.
If neither message nor maximum ID are provided, all messages will be
marked as read by assuming that ``max_id = 0``.
Args:
entity (`entity`):
The chat where these messages are located.
message (`list` | :tl:`Message`):
Either a list of messages or a single message.
max_id (`int`):
Overrides messages, until which message should the
acknowledge should be sent.
clear_mentions (`bool`):
Whether the mention badge should be cleared (so that
there are no more mentions) or not for the given entity.
If no message is provided, this will be the only action
taken.
"""
if max_id is None:
if not message:
max_id = 0
else:
if utils.is_list_like(message):
max_id = max(msg.id for msg in message)
else:
max_id = message.id
entity = await self.get_input_entity(entity)
if clear_mentions:
await self(functions.messages.ReadMentionsRequest(entity))
if max_id is None:
return True
if max_id is not None:
if isinstance(entity, types.InputPeerChannel):
return await self(functions.channels.ReadHistoryRequest(
entity, max_id=max_id))
else:
return await self(functions.messages.ReadHistoryRequest(
entity, max_id=max_id))
return False
# endregion
# endregion
# region Private methods
@async_generator
async def _iter_ids(self, entity, ids, total):
"""
Special case for `iter_messages` when it should only fetch some IDs.
"""
if total:
total[0] = len(ids)
from_id = None # By default, no need to validate from_id
if isinstance(entity, (types.InputChannel, types.InputPeerChannel)):
try:
r = await self(
functions.channels.GetMessagesRequest(entity, ids))
except errors.MessageIdsEmptyError:
# All IDs were invalid, use a dummy result
r = types.messages.MessagesNotModified(len(ids))
else:
r = await self(functions.messages.GetMessagesRequest(ids))
if entity:
from_id = utils.get_peer_id(entity)
if isinstance(r, types.messages.MessagesNotModified):
for _ in ids:
await yield_(None)
return
entities = {utils.get_peer_id(x): x
for x in itertools.chain(r.users, r.chats)}
# Telegram seems to return the messages in the order in which
# we asked them for, so we don't need to check it ourselves,
# unless some messages were invalid in which case Telegram
# may decide to not send them at all.
#
# The passed message IDs may not belong to the desired entity
# since the user can enter arbitrary numbers which can belong to
# arbitrary chats. Validate these unless ``from_id is None``.
for message in r.messages:
if isinstance(message, types.MessageEmpty) or (
from_id and message.chat_id != from_id):
await yield_(None)
else:
message._finish_init(self, entities, entity)
await yield_(message)
# endregion