Work towards dialogs and drafts

This commit is contained in:
Lonami Exo 2023-10-19 21:36:54 +02:00
parent 864d5cd444
commit b8b9836cf7
10 changed files with 538 additions and 88 deletions

View File

@ -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 <https://t.me/WebpageBot>`_.
Message identifiers
-------------------

View File

@ -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 <https://t.me/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]],

View File

@ -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={},
)

View File

@ -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,26 +99,15 @@ 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,
@ -131,19 +120,8 @@ async def send_message(
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,
),
{},
)
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(

View File

@ -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]:

View File

@ -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")

View File

@ -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("")

View File

@ -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`.

View File

@ -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
)

View File

@ -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(