import asyncio import itertools import typing from .users import UserMethods from .. import utils, hints from ..requestiter import RequestIter from ..tl import types, functions, custom _MAX_CHUNK_SIZE = 100 if typing.TYPE_CHECKING: from .telegramclient import TelegramClient class _DialogsIter(RequestIter): async def _init( self, offset_date, offset_id, offset_peer, ignore_migrated, folder ): self.request = functions.messages.GetDialogsRequest( offset_date=offset_date, offset_id=offset_id, offset_peer=offset_peer, limit=1, hash=0, folder_id=folder ) if self.limit <= 0: # Special case, get a single dialog and determine count dialogs = await self.client(self.request) self.total = getattr(dialogs, 'count', len(dialogs.dialogs)) raise StopAsyncIteration self.seen = set() self.offset_date = offset_date self.ignore_migrated = ignore_migrated async def _load_next_chunk(self): self.request.limit = min(self.left, _MAX_CHUNK_SIZE) r = await self.client(self.request) self.total = getattr(r, 'count', len(r.dialogs)) entities = {utils.get_peer_id(x): x for x in itertools.chain(r.users, r.chats)} messages = {} for m in r.messages: m._finish_init(self.client, entities, None) messages[m.id] = m for d in r.dialogs: # We check the offset date here because Telegram may ignore it if self.offset_date: date = getattr(messages.get( d.top_message, None), 'date', None) if not date or date.timestamp() > self.offset_date.timestamp(): continue peer_id = utils.get_peer_id(d.peer) if peer_id not in self.seen: self.seen.add(peer_id) cd = custom.Dialog(self.client, d, entities, messages) if cd.dialog.pts: self.client._channel_pts[cd.id] = cd.dialog.pts if not self.ignore_migrated or getattr( cd.entity, 'migrated_to', None) is None: self.buffer.append(cd) if len(r.dialogs) < self.request.limit\ or not isinstance(r, types.messages.DialogsSlice): # Less than we requested means we reached the end, or # we didn't get a DialogsSlice which means we got all. return True # Don't set `request.offset_id` to the last message ID. # Why? It seems to cause plenty of dialogs to be skipped. # # By leaving it to 0 after the first iteration, even if # the user originally passed another ID, we ensure that # it will work correctly. self.request.offset_id = 0 self.request.exclude_pinned = True self.request.offset_date = r.messages[-1].date self.request.offset_peer =\ entities[utils.get_peer_id(r.dialogs[-1].peer)] class _DraftsIter(RequestIter): async def _init(self, **kwargs): r = await self.client(functions.messages.GetAllDraftsRequest()) self.buffer.extend(custom.Draft._from_update(self.client, u) for u in r.updates) async def _load_next_chunk(self): return [] class DialogMethods(UserMethods): # region Public methods def iter_dialogs( self: 'TelegramClient', limit: float = None, *, offset_date: 'hints.DateLike' = None, offset_id: int = 0, offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(), ignore_migrated: bool = False, folder: int = None, archived: bool = None ) -> _DialogsIter: """ Iterator over the dialogs (open conversations/subscribed channels). Args: limit (`int` | `None`): How many dialogs to be retrieved as maximum. Can be set to ``None`` to retrieve all dialogs. Note that this may take whole minutes if you have hundreds of dialogs, as Telegram will tell the library to slow down through a ``FloodWaitError``. offset_date (`datetime`, optional): The offset date to be used. offset_id (`int`, optional): The message ID to be used as an offset. offset_peer (:tl:`InputPeer`, optional): The peer to be used as an offset. ignore_migrated (`bool`, optional): Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel` should be included or not. By default all the chats in your dialogs are returned, but setting this to ``True`` will hide them in the same way official applications do. folder (`int`, optional): The folder from which the dialogs should be retrieved. If left unspecified, all dialogs (including those from folders) will be returned. If set to ``0``, all dialogs that don't belong to any folder will be returned. If set to a folder number like ``1``, only those from said folder will be returned. By default Telegram assigns the folder ID ``1`` to archived chats, so you should use that if you need to fetch the archived dialogs. archived (`bool`, optional): Alias for `folder`. If unspecified, all will be returned, ``False`` implies ``folder=0`` and ``True`` implies ``folder=1``. Yields: Instances of `telethon.tl.custom.dialog.Dialog`. Example: .. code-block:: python # Get all open conversation, print the title of the first dialogs = client.get_dialogs() first = dialogs[0] print(first.title) # Use the dialog somewhere else client.send_message(first, 'hi') # Get drafts drafts = client.get_drafts() # Getting only non-archived dialogs (both equivalent) non_archived = client.get_dialogs(folder=0) non_archived = client.get_dialogs(archived=False) # Getting only archived dialogs (both equivalent) archived = client.get_dialogs(folder=1) non_archived = client.get_dialogs(archived=True) """ if archived is not None: folder = 1 if archived else 0 return _DialogsIter( self, limit, offset_date=offset_date, offset_id=offset_id, offset_peer=offset_peer, ignore_migrated=ignore_migrated, folder=folder ) async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': """ Same as `iter_dialogs()`, but returns a `TotalList ` instead. """ return await self.iter_dialogs(*args, **kwargs).collect() def iter_drafts(self: 'TelegramClient') -> _DraftsIter: """ Iterator over all open draft messages. Instances of `telethon.tl.custom.draft.Draft` are yielded. You can call `telethon.tl.custom.draft.Draft.set_message` to change the message or `telethon.tl.custom.draft.Draft.delete` among other things. """ # TODO Passing a limit here makes no sense return _DraftsIter(self, None) async def get_drafts(self: 'TelegramClient') -> 'hints.TotalList': """ Same as `iter_drafts()`, but returns a list instead. """ return await self.iter_drafts().collect() async def archive( self: 'TelegramClient', entity: 'hints.EntitiesLike' = None, folder: typing.Union[int, typing.Sequence[int]] = 1, *, unpack=None ) -> types.Updates: """ Archives (or un-archives) one or more dialogs. Args: entity (entities): The entity or list of entities to move to the desired archive folder. folder (`int`, optional): The folder to which the dialog should be archived to. If you want to "un-archive" it, use ``folder=0``. You may also pass a list with the same length as `entities` if you want to control where each entity will go. unpack (`int`, optional): If you want to unpack an archived folder, set this parameter to the folder number that you want to delete. When you unpack a folder, all the dialogs inside are moved to the folder number 0. You can only use this parameter if the other two are not set. Returns: The :tl:`Updates` object that the request produces. Example: .. code-block:: python # Archiving the first 5 dialogs dialogs = client.get_dialogs(5) client.archive(dialogs) # Un-archiving the third dialog (archiving to folder 0) client.archive(dialog[2], 0) # Un-archiving all dialogs client.archive(unpack=1) """ if (entity is None) == (unpack is None): raise ValueError('You can only set either entities or unpack, not both') if unpack is not None: return await self(functions.folders.DeleteFolderRequest( folder_id=unpack )) if not utils.is_list_like(entity): entities = [await self.get_input_entity(entity)] else: entities = await asyncio.gather( *(self.get_input_entity(x) for x in entity), loop=self.loop) if not utils.is_list_like(folder): folder = [folder] * len(entities) elif len(entities) != len(folder): raise ValueError('Number of folders does not match number of entities') return await self(functions.folders.EditPeerFoldersRequest([ types.InputFolderPeer(x, folder_id=y) for x, y in zip(entities, folder) ])) def conversation( self: 'TelegramClient', entity: 'hints.EntityLike', *, timeout: float = 60, total_timeout: float = None, max_messages: int = 100, exclusive: bool = True, replies_are_responses: bool = True) -> custom.Conversation: """ Creates a `Conversation ` with the given entity. This is not the same as just sending a message to create a "dialog" with them, but rather a way to easily send messages and await for responses or other reactions. Refer to its documentation for more. Args: entity (`entity`): The entity with which a new conversation should be opened. timeout (`int` | `float`, optional): The default timeout (in seconds) *per action* to be used. You may also override this timeout on a per-method basis. By default each action can take up to 60 seconds (the value of this timeout). total_timeout (`int` | `float`, optional): The total timeout (in seconds) to use for the whole conversation. This takes priority over per-action timeouts. After these many seconds pass, subsequent actions will result in ``asyncio.TimeoutError``. max_messages (`int`, optional): The maximum amount of messages this conversation will remember. After these many messages arrive in the specified chat, subsequent actions will result in ``ValueError``. exclusive (`bool`, optional): By default, conversations are exclusive within a single chat. That means that while a conversation is open in a chat, you can't open another one in the same chat, unless you disable this flag. If you try opening an exclusive conversation for a chat where it's already open, it will raise ``AlreadyInConversationError``. replies_are_responses (`bool`, optional): Whether replies should be treated as responses or not. If the setting is enabled, calls to `conv.get_response ` and a subsequent call to `conv.get_reply ` will return different messages, otherwise they may return the same message. Consider the following scenario with one outgoing message, 1, and two incoming messages, the second one replying:: Hello! <1 2> (reply to 1) Hi! 3> (reply to 1) How are you? And the following code: .. code-block:: python async with client.conversation(chat) as conv: msg1 = await conv.send_message('Hello!') msg2 = await conv.get_response() msg3 = await conv.get_reply() With the setting enabled, ``msg2`` will be ``'Hi!'`` and ``msg3`` be ``'How are you?'`` since replies are also responses, and a response was already returned. With the setting disabled, both ``msg2`` and ``msg3`` will be ``'Hi!'`` since one is a response and also a reply. Returns: A `Conversation `. Example: .. code-block:: python # denotes outgoing messages you sent # denotes incoming response messages with bot.conversation(chat) as conv: # Hi! conv.send_message('Hi!') # Hello! hello = conv.get_response() # Please tell me your name conv.send_message('Please tell me your name') # ? name = conv.get_response().raw_text while not any(x.isalpha() for x in name): # Your name didn't have any letters! Try again conv.send_message("Your name didn't have any letters! Try again") # Lonami name = conv.get_response().raw_text # Thanks Lonami! conv.send_message('Thanks {}!'.format(name)) """ return custom.Conversation( self, entity, timeout=timeout, total_timeout=total_timeout, max_messages=max_messages, exclusive=exclusive, replies_are_responses=replies_are_responses ) # endregion