diff --git a/client/doc/concepts/messages.rst b/client/doc/concepts/messages.rst index 76b2177d..e1d8279e 100644 --- a/client/doc/concepts/messages.rst +++ b/client/doc/concepts/messages.rst @@ -73,6 +73,29 @@ To send a message with formatted text, use the ``markdown`` or ``html`` paramete When sending files, the format is appended to the name of the ``caption`` parameter, either ``caption_markdown`` or ``caption_html``. +Link previews +^^^^^^^^^^^^^ + +Link previews are treated as a type of media automatically generated by Telegram. +This means you cannot have both a link preview and other media in the same message. + +The ``link_preview`` parameter indicates whether link previews are *allowed* to be present. +If Telegram is unable to generate a link preview for any of the links, there won't be a link preview. + +By default, link previews are not enabled. +This is done to prevent sending things you did not explicitly intend to send. +Unlike the official clients, which do not have a GUI to "enable" the preview, you can easily enable them from code. + +Telegram will attempt to generate a preview for all links contained in the message in order. +You can use this to your advantage, and hide a link to a photo in the first space or invisible character like ``'\u2063'``. +Note that avid users *will* be able to find out the link. It is not secret! + +Link previews of photos won't show under the photos of the chat, +but it requires a server hosting the image, a public address, and Telegram to be able to generate a preview. + +To regenerate a preview, send the corresponding link to `@WebpageBot `_. + + Message identifiers ------------------- diff --git a/client/src/telethon/_impl/client/client/client.py b/client/src/telethon/_impl/client/client/client.py index 392d8412..458916c3 100644 --- a/client/src/telethon/_impl/client/client/client.py +++ b/client/src/telethon/_impl/client/client/client.py @@ -70,7 +70,7 @@ from .chats import ( set_banned_rights, set_default_rights, ) -from .dialogs import delete_dialog, get_dialogs, get_drafts +from .dialogs import delete_dialog, edit_draft, get_dialogs, get_drafts from .files import ( download, get_file_bytes, @@ -499,7 +499,7 @@ class Client: text: Optional[str] = None, markdown: Optional[str] = None, html: Optional[str] = None, - link_preview: Optional[bool] = None, + link_preview: bool = False, ) -> Message: """ Edit a message. @@ -1247,6 +1247,7 @@ class Client: title: Optional[str] = None, performer: Optional[str] = None, emoji: Optional[str] = None, + emoji_sticker: Optional[str] = None, width: Optional[int] = None, height: Optional[int] = None, round: bool = False, @@ -1396,6 +1397,7 @@ class Client: title=title, performer=performer, emoji=emoji, + emoji_sticker=emoji_sticker, width=width, height=height, round=round, @@ -1425,14 +1427,7 @@ class Client: :param text: See :ref:`formatting`. :param markdown: See :ref:`formatting`. :param html: See :ref:`formatting`. - - :param link_preview: - Whether the link preview is allowed. - - Setting this to :data:`True` does not guarantee a preview. - Telegram must be able to generate a preview from the first link in the message text. - - To regenerate the preview, send the link to `@WebpageBot `_. + :param link_preview: See :ref:`formatting`. :param reply_to: The message identifier of the message to reply to. @@ -1578,6 +1573,58 @@ class Client: def set_default_rights(self, chat: ChatLike, user: ChatLike) -> None: set_default_rights(self, chat, user) + async def edit_draft( + self, + chat: ChatLike, + text: Optional[str] = None, + *, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: bool = False, + reply_to: Optional[int] = None, + ) -> Draft: + """ + Set a draft message in a chat. + + This can also be used to clear the draft by setting the text to an empty string ``""``. + + :param chat: + The :term:`chat` where the draft will be saved to. + + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. + :param link_preview: See :ref:`formatting`. + + :param reply_to: + The message identifier of the message to reply to. + + :return: The created draft. + + .. rubric:: Example + + .. code-block:: python + + # Edit message to have text without formatting + await client.edit_message(chat, msg_id, text='New text') + + # Remove the link preview without changing the text + await client.edit_message(chat, msg_id, link_preview=False) + + .. seealso:: + + :meth:`telethon.types.Message.edit` + """ + return await edit_draft( + self, + chat, + text, + markdown=markdown, + html=html, + link_preview=link_preview, + reply_to=reply_to, + ) + def set_handler_filter( self, handler: Callable[[Event], Awaitable[Any]], diff --git a/client/src/telethon/_impl/client/client/dialogs.py b/client/src/telethon/_impl/client/client/dialogs.py index 41a6c77c..f35473eb 100644 --- a/client/src/telethon/_impl/client/client/dialogs.py +++ b/client/src/telethon/_impl/client/client/dialogs.py @@ -1,10 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import time +from typing import TYPE_CHECKING, Optional from ...tl import functions, types from ..types import AsyncList, ChatLike, Dialog, Draft -from ..utils import build_chat_map +from ..utils import build_chat_map, build_msg_map +from .messages import parse_message if TYPE_CHECKING: from .client import Client @@ -40,8 +42,11 @@ class DialogList(AsyncList[Dialog]): assert isinstance(result, (types.messages.Dialogs, types.messages.DialogsSlice)) chat_map = build_chat_map(result.users, result.chats) + msg_map = build_msg_map(self._client, result.messages, chat_map) - self._buffer.extend(Dialog._from_raw(d, chat_map) for d in result.dialogs) + self._buffer.extend( + Dialog._from_raw(self._client, d, chat_map, msg_map) for d in result.dialogs + ) def get_dialogs(self: Client) -> AsyncList[Dialog]: @@ -93,7 +98,7 @@ class DraftList(AsyncList[Draft]): chat_map = build_chat_map(result.users, result.chats) self._buffer.extend( - Draft._from_raw(u, chat_map) + Draft._from_raw_update(self._client, u, chat_map) for u in result.updates if isinstance(u, types.UpdateDraftMessage) ) @@ -104,3 +109,47 @@ class DraftList(AsyncList[Draft]): def get_drafts(self: Client) -> AsyncList[Draft]: return DraftList(self) + + +async def edit_draft( + self: Client, + chat: ChatLike, + text: Optional[str] = None, + *, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: bool = False, + reply_to: Optional[int] = None, +) -> Draft: + packed = await self._resolve_to_packed(chat) + peer = (await self._resolve_to_packed(chat))._to_input_peer() + message, entities = parse_message( + text=text, markdown=markdown, html=html, allow_empty=False + ) + assert isinstance(message, str) + + result = await self( + functions.messages.save_draft( + no_webpage=not link_preview, + reply_to_msg_id=reply_to, + top_msg_id=None, + peer=peer, + message=message, + entities=entities, + ) + ) + assert result + + return Draft._from_raw( + client=self, + peer=packed._to_peer(), + top_msg_id=0, + draft=types.DraftMessage( + no_webpage=not link_preview, + reply_to_msg_id=reply_to, + message=message, + entities=entities, + date=int(time.time()), + ), + chat_map={}, + ) diff --git a/client/src/telethon/_impl/client/client/messages.py b/client/src/telethon/_impl/client/client/messages.py index 252669ce..d63ec284 100644 --- a/client/src/telethon/_impl/client/client/messages.py +++ b/client/src/telethon/_impl/client/client/messages.py @@ -46,7 +46,7 @@ async def send_message( *, markdown: Optional[str] = None, html: Optional[str] = None, - link_preview: Optional[bool] = None, + link_preview: bool = False, reply_to: Optional[int] = None, ) -> Message: packed = await self._resolve_to_packed(chat) @@ -99,51 +99,29 @@ async def send_message( ) ) if isinstance(result, types.UpdateShortSentMessage): - return Message._from_raw( + return Message._from_defaults( self, - types.Message( - out=result.out, - mentioned=False, - media_unread=False, - silent=False, - post=False, - from_scheduled=False, - legacy=False, - edit_hide=False, - pinned=False, - noforwards=False, - id=result.id, - from_id=types.PeerUser(user_id=self._session.user.id) - if self._session.user - else None, - peer_id=packed._to_peer(), - fwd_from=None, - via_bot_id=None, - reply_to=types.MessageReplyHeader( - reply_to_scheduled=False, - forum_topic=False, - reply_to_msg_id=reply_to, - reply_to_peer_id=None, - reply_to_top_id=None, - ) - if reply_to - else None, - date=result.date, - message=message if isinstance(message, str) else (message.text or ""), - media=result.media, - reply_markup=None, - entities=result.entities, - views=None, - forwards=None, - replies=None, - edit_date=None, - post_author=None, - grouped_id=None, - reactions=None, - restriction_reason=None, - ttl_period=result.ttl_period, - ), {}, + out=result.out, + id=result.id, + from_id=types.PeerUser(user_id=self._session.user.id) + if self._session.user + else None, + peer_id=packed._to_peer(), + reply_to=types.MessageReplyHeader( + reply_to_scheduled=False, + forum_topic=False, + reply_to_msg_id=reply_to, + reply_to_peer_id=None, + reply_to_top_id=None, + ) + if reply_to + else None, + date=result.date, + message=message if isinstance(message, str) else (message.text or ""), + media=result.media, + entities=result.entities, + ttl_period=result.ttl_period, ) else: return self._build_message_map(result, peer).with_random_id(random_id) @@ -157,7 +135,7 @@ async def edit_message( text: Optional[str] = None, markdown: Optional[str] = None, html: Optional[str] = None, - link_preview: Optional[bool] = None, + link_preview: bool = False, ) -> Message: peer = (await self._resolve_to_packed(chat))._to_input_peer() message, entities = parse_message( diff --git a/client/src/telethon/_impl/client/types/chat/group.py b/client/src/telethon/_impl/client/types/chat/group.py index 9a3b03c3..1e8d9ec2 100644 --- a/client/src/telethon/_impl/client/types/chat/group.py +++ b/client/src/telethon/_impl/client/types/chat/group.py @@ -52,7 +52,7 @@ class Group(Chat, metaclass=NoPublicConstructor): This property is always present, but may be the empty string. """ - return self._raw.title + return getattr(self._raw, "title", None) or "" @property def username(self) -> Optional[str]: diff --git a/client/src/telethon/_impl/client/types/dialog.py b/client/src/telethon/_impl/client/types/dialog.py index 76989789..3502f71f 100644 --- a/client/src/telethon/_impl/client/types/dialog.py +++ b/client/src/telethon/_impl/client/types/dialog.py @@ -1,9 +1,17 @@ -from typing import Dict, Self +from __future__ import annotations -from ...tl import abcs +from typing import TYPE_CHECKING, Dict, Optional, Self, Union + +from ...tl import abcs, types +from ..utils import peer_id from .chat import Chat +from .draft import Draft +from .message import Message from .meta import NoPublicConstructor +if TYPE_CHECKING: + from ..client import Client + class Dialog(metaclass=NoPublicConstructor): """ @@ -16,12 +24,76 @@ class Dialog(metaclass=NoPublicConstructor): You can obtain dialogs with methods such as :meth:`telethon.Client.get_dialogs`. """ - __slots__ = ("_raw", "_chat_map") - - def __init__(self, raw: abcs.Dialog, chat_map: Dict[int, Chat]) -> None: + def __init__( + self, + client: Client, + raw: Union[types.Dialog, types.DialogFolder], + chat_map: Dict[int, Chat], + msg_map: Dict[int, Message], + ) -> None: + self._client = client self._raw = raw self._chat_map = chat_map + self._msg_map = msg_map @classmethod - def _from_raw(cls, dialog: abcs.Dialog, chat_map: Dict[int, Chat]) -> Self: - return cls._create(dialog, chat_map) + def _from_raw( + cls, + client: Client, + dialog: abcs.Dialog, + chat_map: Dict[int, Chat], + msg_map: Dict[int, Message], + ) -> Self: + assert isinstance(dialog, (types.Dialog, types.DialogFolder)) + return cls._create(client, dialog, chat_map, msg_map) + + @property + def chat(self) -> Chat: + """ + The chat where messages are sent in this dialog. + """ + return self._chat_map[peer_id(self._raw.peer)] + + @property + def draft(self) -> Optional[Draft]: + """ + The message draft within this dialog, if any. + + This property does not update when the draft changes. + """ + if isinstance(self._raw, types.Dialog) and self._raw.draft: + return Draft._from_raw( + self._client, + self._raw.peer, + self._raw.top_message, + self._raw.draft, + self._chat_map, + ) + else: + return None + + @property + def latest_message(self) -> Optional[Message]: + """ + The latest message sent or received in this dialog, if any. + + This property does not update when new messages arrive. + """ + return self._msg_map.get(self._raw.top_message) + + @property + def unread_count(self) -> int: + """ + The amount of unread messages in this dialog. + + This property does not update when messages are read or sent. + """ + if isinstance(self._raw, types.Dialog): + return self._raw.unread_count + elif isinstance(self._raw, types.DialogPeerFolder): + return ( + self._raw.unread_unmuted_messages_count + + self._raw.unread_muted_messages_count + ) + else: + raise RuntimeError("unexpected case") diff --git a/client/src/telethon/_impl/client/types/draft.py b/client/src/telethon/_impl/client/types/draft.py index 391f420f..2cd5bdb3 100644 --- a/client/src/telethon/_impl/client/types/draft.py +++ b/client/src/telethon/_impl/client/types/draft.py @@ -1,9 +1,19 @@ -from typing import Dict, Self +from __future__ import annotations -from ...tl import types +import datetime +from typing import TYPE_CHECKING, Dict, Optional, Self + +from ...session import PackedChat +from ...tl import abcs, functions, types +from ..parsers import generate_html_message, generate_markdown_message +from ..utils import expand_peer, generate_random_id, peer_id from .chat import Chat +from .message import Message from .meta import NoPublicConstructor +if TYPE_CHECKING: + from ..client import Client + class Draft(metaclass=NoPublicConstructor): """ @@ -12,19 +22,226 @@ class Draft(metaclass=NoPublicConstructor): You can obtain drafts with methods such as :meth:`telethon.Client.get_drafts`. """ - __slots__ = ("_raw", "_chat_map") - def __init__( - self, raw: types.UpdateDraftMessage, chat_map: Dict[int, Chat] + self, + client: Client, + peer: abcs.Peer, + top_msg_id: Optional[int], + raw: abcs.DraftMessage, + chat_map: Dict[int, Chat], ) -> None: + assert isinstance(raw, (types.DraftMessage, types.DraftMessageEmpty)) + self._client = client + self._peer = peer self._raw = raw + self._top_msg_id = top_msg_id self._chat_map = chat_map @classmethod - def _from_raw( - cls, draft: types.UpdateDraftMessage, chat_map: Dict[int, Chat] + def _from_raw_update( + cls, client: Client, draft: types.UpdateDraftMessage, chat_map: Dict[int, Chat] ) -> Self: - return cls._create(draft, chat_map) + return cls._create(client, draft.peer, draft.top_msg_id, draft.draft, chat_map) + + @classmethod + def _from_raw( + cls, + client: Client, + peer: abcs.Peer, + top_msg_id: int, + draft: abcs.DraftMessage, + chat_map: Dict[int, Chat], + ) -> Self: + return cls._create(client, peer, top_msg_id, draft, chat_map) + + @property + def chat(self) -> Chat: + """ + The chat where the draft will be sent to. + """ + return self._chat_map.get(peer_id(self._peer)) or expand_peer( + self._peer, broadcast=None + ) + + @property + def link_preview(self) -> bool: + """ + :data:`True` if the link preview is allowed to exist when sending the message. + """ + return not getattr(self._raw, "no_webpage", False) + + @property + def replied_message_id(self) -> Optional[int]: + """ + Get the message identifier of message this draft will reply to once sent. + """ + return getattr(self._raw, "reply_to_msg_id") or None + + @property + def text(self) -> Optional[str]: + """ + The :attr:`~Message.text` of the message that will be sent. + """ + return getattr(self._raw, "message", None) + + @property + def text_html(self) -> Optional[str]: + """ + The :attr:`~Message.text_html` of the message that will be sent. + """ + if text := getattr(self._raw, "message", None): + return generate_html_message( + text, getattr(self._raw, "entities", None) or [] + ) + else: + return None + + @property + def text_markdown(self) -> Optional[str]: + """ + The :attr:`~Message.text_markdown` of the message that will be sent. + """ + if text := getattr(self._raw, "message", None): + return generate_markdown_message( + text, getattr(self._raw, "entities", None) or [] + ) + else: + return None + + @property + def date(self) -> Optional[datetime.datetime]: + """ + The date when the draft was last updated. + """ + date = getattr(self._raw, "date", None) + return ( + datetime.datetime.fromtimestamp(date, tz=datetime.timezone.utc) + if date is not None + else None + ) + + async def edit( + self, + text: Optional[str] = None, + *, + markdown: Optional[str] = None, + html: Optional[str] = None, + link_preview: bool = False, + reply_to: Optional[int] = None, + ) -> Draft: + """ + Replace the current draft with a new one. + + :param text: See :ref:`formatting`. + :param markdown: See :ref:`formatting`. + :param html: See :ref:`formatting`. + :param link_preview: See :ref:`formatting`. + + :param reply_to: + The message identifier of the message to reply to. + + :return: The edited draft. + + .. rubric:: Example + + .. code-block:: python + + new_draft = await old_draft.edit('new text', link_preview=False) + """ + return await self._client.edit_draft( + await self._packed_chat(), + text, + markdown=markdown, + html=html, + link_preview=link_preview, + reply_to=reply_to, + ) + + async def _packed_chat(self) -> PackedChat: + packed = None + if chat := self._chat_map.get(peer_id(self._peer)): + packed = chat.pack() + if packed is None: + packed = await self._client.resolve_to_packed(peer_id(self._peer)) + return packed + + async def send(self) -> Message: + """ + Send the contents of this draft to the chat. + + The draft will be cleared after being sent. + + :return: The sent message. + + .. rubric:: Example + + .. code-block:: python + + await draft.send(clear=False) + """ + + packed = await self._packed_chat() + peer = packed._to_input_peer() + + reply_to = self.replied_message_id + message = getattr(self._raw, "message", "") + entities = getattr(self._raw, "entities", None) + random_id = generate_random_id() + + result = await self._client( + functions.messages.send_message( + no_webpage=not self.link_preview, + silent=False, + background=False, + clear_draft=True, + noforwards=False, + update_stickersets_order=False, + peer=peer, + reply_to=types.InputReplyToMessage( + reply_to_msg_id=reply_to, top_msg_id=None + ) + if reply_to + else None, + message=message, + random_id=random_id, + reply_markup=None, + entities=entities, + schedule_date=None, + send_as=None, + ) + ) + if isinstance(result, types.UpdateShortSentMessage): + return Message._from_defaults( + self._client, + {}, + out=result.out, + id=result.id, + from_id=types.PeerUser(user_id=self._client._session.user.id) + if self._client._session.user + else None, + peer_id=packed._to_peer(), + reply_to=types.MessageReplyHeader( + reply_to_scheduled=False, + forum_topic=False, + reply_to_msg_id=reply_to, + reply_to_peer_id=None, + reply_to_top_id=None, + ) + if reply_to + else None, + date=result.date, + message=message, + media=result.media, + entities=result.entities, + ttl_period=result.ttl_period, + ) + else: + return self._client._build_message_map(result, peer).with_random_id( + random_id + ) async def delete(self) -> None: - pass + """ + Clear the contents of this draft to delete it. + """ + await self.edit("") diff --git a/client/src/telethon/_impl/client/types/message.py b/client/src/telethon/_impl/client/types/message.py index 78cfb79b..cea77e6a 100644 --- a/client/src/telethon/_impl/client/types/message.py +++ b/client/src/telethon/_impl/client/types/message.py @@ -1,11 +1,11 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Dict, Optional, Self, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Self, Union from ...tl import abcs, types from ..parsers import generate_html_message, generate_markdown_message -from ..utils import expand_peer, peer_id +from ..utils import adapt_date, expand_peer, peer_id from .chat import Chat, ChatLike from .file import File from .meta import NoPublicConstructor @@ -40,6 +40,52 @@ class Message(metaclass=NoPublicConstructor): ) -> Self: return cls._create(client, message, chat_map) + @classmethod + def _from_defaults( + cls, + client: Client, + chat_map: Dict[int, Chat], + id: int, + peer_id: abcs.Peer, + date: int, + message: str, + **kwargs: Any, + ) -> Self: + default_kwargs: Dict[str, Any] = { + "out": False, + "mentioned": False, + "media_unread": False, + "silent": False, + "post": False, + "from_scheduled": False, + "legacy": False, + "edit_hide": False, + "pinned": False, + "noforwards": False, + "id": id, + "from_id": None, + "peer_id": peer_id, + "fwd_from": None, + "via_bot_id": None, + "reply_to": None, + "date": date, + "message": message, + "media": None, + "reply_markup": None, + "entities": None, + "views": None, + "forwards": None, + "replies": None, + "edit_date": None, + "post_author": None, + "grouped_id": None, + "reactions": None, + "restriction_reason": None, + "ttl_period": None, + } + default_kwargs.update(kwargs) + return cls._create(client, types.Message(**default_kwargs), chat_map) + @property def id(self) -> int: """ @@ -86,12 +132,7 @@ class Message(metaclass=NoPublicConstructor): @property def date(self) -> Optional[datetime.datetime]: - date = getattr(self._raw, "date", None) - return ( - datetime.datetime.fromtimestamp(date, tz=datetime.timezone.utc) - if date is not None - else None - ) + return adapt_date(getattr(self._raw, "date", None)) @property def chat(self) -> Chat: @@ -236,7 +277,7 @@ class Message(metaclass=NoPublicConstructor): text: Optional[str] = None, markdown: Optional[str] = None, html: Optional[str] = None, - link_preview: Optional[bool] = None, + link_preview: bool = False, ) -> Message: """ Alias for :meth:`telethon.Client.edit_message`. diff --git a/client/src/telethon/_impl/client/utils.py b/client/src/telethon/_impl/client/utils.py index 673c54b2..b82175db 100644 --- a/client/src/telethon/_impl/client/utils.py +++ b/client/src/telethon/_impl/client/utils.py @@ -1,11 +1,17 @@ +from __future__ import annotations + +import datetime import itertools import sys import time from collections import defaultdict -from typing import DefaultDict, Dict, List, Optional, Union +from typing import TYPE_CHECKING, DefaultDict, Dict, List, Optional, Union from ..tl import abcs, types -from .types import Channel, Chat, Group, User +from .types import Channel, Chat, Group, Message, User + +if TYPE_CHECKING: + from .client import Client _last_id = 0 @@ -51,6 +57,15 @@ def build_chat_map(users: List[abcs.User], chats: List[abcs.Chat]) -> Dict[int, return result +def build_msg_map( + client: Client, messages: List[abcs.Message], chat_map: Dict[int, Chat] +) -> Dict[int, Message]: + return { + msg.id: msg + for msg in (Message._from_raw(client, m, chat_map) for m in messages) + } + + def peer_id(peer: abcs.Peer) -> int: if isinstance(peer, types.PeerUser): return peer.user_id @@ -83,3 +98,11 @@ def expand_peer(peer: abcs.Peer, *, broadcast: Optional[bool]) -> Chat: return Channel._from_raw(channel) if broadcast else Group._from_raw(channel) else: raise RuntimeError("unexpected case") + + +def adapt_date(date: Optional[int]) -> Optional[datetime.datetime]: + return ( + datetime.datetime.fromtimestamp(date, tz=datetime.timezone.utc) + if date is not None + else None + ) diff --git a/client/src/telethon/_impl/mtproto/mtp/encrypted.py b/client/src/telethon/_impl/mtproto/mtp/encrypted.py index 6affa1ec..84e9cb8b 100644 --- a/client/src/telethon/_impl/mtproto/mtp/encrypted.py +++ b/client/src/telethon/_impl/mtproto/mtp/encrypted.py @@ -208,7 +208,7 @@ class Encrypted(Mtp): ) if self._msg_count == 1: - container_msg_id = Single + container_msg_id: Union[Type[Single], int] = Single else: container_msg_id = self._get_new_msg_id() self._buffer[HEADER_LEN : HEADER_LEN + CONTAINER_HEADER_LEN] = struct.pack(