refactor(telegram): msgspec schemas and parsing (#156)

This commit is contained in:
banteg
2026-01-16 19:21:26 +04:00
committed by GitHub
parent 190b2f6d6e
commit c85ab2e2a2
10 changed files with 505 additions and 412 deletions
+25 -41
View File
@@ -1,53 +1,37 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from .api_schemas import (
CallbackQuery,
import msgspec CallbackQueryMessage,
Chat,
ChatMember,
Document,
File,
ForumTopic,
Message,
MessageReply,
PhotoSize,
Sticker,
Update,
User,
Video,
Voice,
)
__all__ = [ __all__ = [
"CallbackQuery",
"CallbackQueryMessage",
"Chat", "Chat",
"ChatMember", "ChatMember",
"Document",
"File", "File",
"ForumTopic", "ForumTopic",
"Message", "Message",
"MessageReply",
"PhotoSize",
"Sticker",
"Update", "Update",
"User", "User",
"Video",
"Voice",
] ]
class User(msgspec.Struct, forbid_unknown_fields=False):
id: int
username: str | None = None
first_name: str | None = None
last_name: str | None = None
class Chat(msgspec.Struct, forbid_unknown_fields=False):
id: int
type: str
is_forum: bool | None = None
class ChatMember(msgspec.Struct, forbid_unknown_fields=False):
status: str
can_manage_topics: bool | None = None
class Message(msgspec.Struct, forbid_unknown_fields=False):
message_id: int
message_thread_id: int | None = None
text: str | None = None
class File(msgspec.Struct, forbid_unknown_fields=False):
file_path: str
class ForumTopic(msgspec.Struct, forbid_unknown_fields=False):
message_thread_id: int
class Update(msgspec.Struct, forbid_unknown_fields=False):
update_id: int
message: dict[str, Any] | None = None
callback_query: dict[str, Any] | None = None
+152
View File
@@ -0,0 +1,152 @@
"""Msgspec models for Telegram Bot API payloads (subset used by takopi).
Derived from telegram-api.html in the repository.
"""
from __future__ import annotations
import msgspec
__all__ = [
"CallbackQuery",
"CallbackQueryMessage",
"Chat",
"ChatMember",
"Document",
"File",
"ForumTopic",
"Message",
"MessageReply",
"PhotoSize",
"Sticker",
"Update",
"User",
"Video",
"Voice",
"decode_update",
"decode_updates",
]
class User(msgspec.Struct, forbid_unknown_fields=False):
id: int
is_bot: bool | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
class Chat(msgspec.Struct, forbid_unknown_fields=False):
id: int
type: str
title: str | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
is_forum: bool | None = None
class PhotoSize(msgspec.Struct, forbid_unknown_fields=False):
file_id: str
width: int
height: int
file_size: int | None = None
class Document(msgspec.Struct, forbid_unknown_fields=False):
file_id: str
file_name: str | None = None
mime_type: str | None = None
file_size: int | None = None
class Video(msgspec.Struct, forbid_unknown_fields=False):
file_id: str
file_name: str | None = None
mime_type: str | None = None
file_size: int | None = None
class Voice(msgspec.Struct, forbid_unknown_fields=False):
file_id: str
duration: int | None = None
mime_type: str | None = None
file_size: int | None = None
class Sticker(msgspec.Struct, forbid_unknown_fields=False):
file_id: str
file_size: int | None = None
class MessageReply(msgspec.Struct, forbid_unknown_fields=False):
message_id: int
text: str | None = None
from_: User | None = msgspec.field(default=None, name="from")
class Message(msgspec.Struct, forbid_unknown_fields=False):
message_id: int
chat: Chat
message_thread_id: int | None = None
from_: User | None = msgspec.field(default=None, name="from")
text: str | None = None
caption: str | None = None
reply_to_message: MessageReply | None = None
forward_from: User | None = None
forward_from_chat: Chat | None = None
forward_from_message_id: int | None = None
forward_sender_name: str | None = None
forward_signature: str | None = None
forward_date: int | None = None
media_group_id: str | None = None
is_automatic_forward: bool | None = None
is_topic_message: bool | None = None
voice: Voice | None = None
document: Document | None = None
video: Video | None = None
photo: list[PhotoSize] | None = None
sticker: Sticker | None = None
class CallbackQueryMessage(msgspec.Struct, forbid_unknown_fields=False):
message_id: int
chat: Chat
class CallbackQuery(msgspec.Struct, forbid_unknown_fields=False):
id: str
from_: User = msgspec.field(name="from")
message: CallbackQueryMessage | None = None
data: str | None = None
class Update(msgspec.Struct, forbid_unknown_fields=False):
update_id: int
message: Message | None = None
callback_query: CallbackQuery | None = None
class File(msgspec.Struct, forbid_unknown_fields=False):
file_path: str
class ChatMember(msgspec.Struct, forbid_unknown_fields=False):
status: str
can_manage_topics: bool | None = None
class ForumTopic(msgspec.Struct, forbid_unknown_fields=False):
message_thread_id: int
_UPDATE_DECODER = msgspec.json.Decoder(Update)
_UPDATES_DECODER = msgspec.json.Decoder(list[Update])
def decode_update(payload: str | bytes) -> Update:
return _UPDATE_DECODER.decode(payload)
def decode_updates(payload: str | bytes) -> list[Update]:
return _UPDATES_DECODER.decode(payload)
+12 -22
View File
@@ -264,35 +264,25 @@ async def wait_for_chat(token: str) -> ChatInfo:
continue continue
if not updates: if not updates:
continue continue
offset = updates[-1].update_id + 1
update = updates[-1] update = updates[-1]
offset = update.update_id + 1
msg = update.message msg = update.message
if not isinstance(msg, dict): if msg is None:
continue continue
sender = msg.get("from") sender = msg.from_
if isinstance(sender, dict) and sender.get("is_bot") is True: if sender is not None and sender.is_bot is True:
continue continue
chat = msg.get("chat") chat = msg.chat
if not isinstance(chat, dict): if chat is None:
continue
chat_id = chat.get("id")
if not isinstance(chat_id, int):
continue continue
chat_id = chat.id
return ChatInfo( return ChatInfo(
chat_id=chat_id, chat_id=chat_id,
username=chat.get("username") username=chat.username,
if isinstance(chat.get("username"), str) title=chat.title,
else None, first_name=chat.first_name,
title=chat.get("title") if isinstance(chat.get("title"), str) else None, last_name=chat.last_name,
first_name=chat.get("first_name") chat_type=chat.type,
if isinstance(chat.get("first_name"), str)
else None,
last_name=chat.get("last_name")
if isinstance(chat.get("last_name"), str)
else None,
chat_type=chat.get("type")
if isinstance(chat.get("type"), str)
else None,
) )
finally: finally:
await bot.close() await bot.close()
+117 -179
View File
@@ -1,12 +1,20 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from collections.abc import AsyncIterator, Callable, Iterable from collections.abc import AsyncIterator, Callable, Iterable
import anyio import anyio
import msgspec
from ..logging import get_logger from ..logging import get_logger
from .api_models import Update from .api_schemas import (
CallbackQuery,
Document,
Message,
PhotoSize,
Sticker,
Update,
Video,
)
from .client_api import BotClient from .client_api import BotClient
from .types import ( from .types import (
TelegramCallbackQuery, TelegramCallbackQuery,
@@ -20,23 +28,20 @@ logger = get_logger(__name__)
def parse_incoming_update( def parse_incoming_update(
update: Update | dict[str, Any], update: Update,
*, *,
chat_id: int | None = None, chat_id: int | None = None,
chat_ids: set[int] | None = None, chat_ids: set[int] | None = None,
) -> TelegramIncomingUpdate | None: ) -> TelegramIncomingUpdate | None:
if isinstance(update, Update): if update.message is not None:
msg = update.message return _parse_incoming_message(
callback_query = update.callback_query update.message,
else: chat_id=chat_id,
msg = update.get("message") chat_ids=chat_ids,
callback_query = update.get("callback_query") )
if update.callback_query is not None:
if isinstance(msg, dict):
return _parse_incoming_message(msg, chat_id=chat_id, chat_ids=chat_ids)
if isinstance(callback_query, dict):
return _parse_callback_query( return _parse_callback_query(
callback_query, update.callback_query,
chat_id=chat_id, chat_id=chat_id,
chat_ids=chat_ids, chat_ids=chat_ids,
) )
@@ -44,167 +49,71 @@ def parse_incoming_update(
def _parse_incoming_message( def _parse_incoming_message(
msg: dict[str, Any], msg: Message,
*, *,
chat_id: int | None = None, chat_id: int | None = None,
chat_ids: set[int] | None = None, chat_ids: set[int] | None = None,
) -> TelegramIncomingMessage | None: ) -> TelegramIncomingMessage | None:
def _parse_document_payload(payload: dict[str, Any]) -> TelegramDocument | None: raw_text = msg.text
file_id = payload.get("file_id") caption = msg.caption
if not isinstance(file_id, str) or not file_id: text = raw_text if raw_text is not None else caption
return None
return TelegramDocument(
file_id=file_id,
file_name=payload.get("file_name")
if isinstance(payload.get("file_name"), str)
else None,
mime_type=payload.get("mime_type")
if isinstance(payload.get("mime_type"), str)
else None,
file_size=payload.get("file_size")
if isinstance(payload.get("file_size"), int)
and not isinstance(payload.get("file_size"), bool)
else None,
raw=payload,
)
raw_text = msg.get("text")
text = raw_text if isinstance(raw_text, str) else None
caption = msg.get("caption")
if text is None and isinstance(caption, str):
text = caption
if text is None: if text is None:
text = "" text = ""
file_command = False file_command = False
if isinstance(text, str): stripped = text.lstrip()
stripped = text.lstrip() if stripped.startswith("/"):
if stripped.startswith("/"): token = stripped.split(maxsplit=1)[0]
token = stripped.split(maxsplit=1)[0] file_command = token.startswith("/file")
file_command = token.startswith("/file")
voice_payload: TelegramVoice | None = None voice_payload: TelegramVoice | None = None
voice = msg.get("voice") if msg.voice is not None:
if isinstance(voice, dict): voice_payload = TelegramVoice(
file_id = voice.get("file_id") file_id=msg.voice.file_id,
if not isinstance(file_id, str) or not file_id: mime_type=msg.voice.mime_type,
file_id = None file_size=msg.voice.file_size,
if file_id is not None: duration=msg.voice.duration,
voice_payload = TelegramVoice( raw=msgspec.to_builtins(msg.voice),
file_id=file_id, )
mime_type=voice.get("mime_type") if raw_text is None and caption is None:
if isinstance(voice.get("mime_type"), str) text = ""
else None,
file_size=voice.get("file_size")
if isinstance(voice.get("file_size"), int)
and not isinstance(voice.get("file_size"), bool)
else None,
duration=voice.get("duration")
if isinstance(voice.get("duration"), int)
and not isinstance(voice.get("duration"), bool)
else None,
raw=voice,
)
if not isinstance(raw_text, str) and not isinstance(caption, str):
text = ""
document_payload: TelegramDocument | None = None document_payload: TelegramDocument | None = None
document = msg.get("document") if msg.document is not None:
if isinstance(document, dict): document_payload = _document_from_media(msg.document)
document_payload = _parse_document_payload(document) if document_payload is None and msg.video is not None:
document_payload = _document_from_media(msg.video)
if document_payload is None: if document_payload is None:
video = msg.get("video") best = _best_photo(msg.photo)
if isinstance(video, dict): if best is not None:
document_payload = _parse_document_payload(video) document_payload = _document_from_photo(best)
if document_payload is None: if document_payload is None and file_command and msg.sticker is not None:
photo = msg.get("photo") document_payload = _document_from_sticker(msg.sticker)
if isinstance(photo, list): has_text = raw_text is not None or caption is not None
best: dict[str, Any] | None = None
best_score = -1
for item in photo:
if not isinstance(item, dict):
continue
file_id = item.get("file_id")
if not isinstance(file_id, str) or not file_id:
continue
size = item.get("file_size")
if isinstance(size, int) and not isinstance(size, bool):
score = size
else:
width = item.get("width")
height = item.get("height")
if isinstance(width, int) and isinstance(height, int):
score = width * height
else:
score = 0
if score > best_score:
best_score = score
best = item
if best is not None:
document_payload = _parse_document_payload(best)
if document_payload is None and file_command:
sticker = msg.get("sticker")
if isinstance(sticker, dict):
document_payload = _parse_document_payload(sticker)
has_text = isinstance(raw_text, str) or isinstance(caption, str)
if not has_text and voice_payload is None and document_payload is None: if not has_text and voice_payload is None and document_payload is None:
return None return None
chat = msg.get("chat") msg_chat_id = msg.chat.id
if not isinstance(chat, dict): chat_type = msg.chat.type
return None is_forum = msg.chat.is_forum
msg_chat_id = chat.get("id")
if not isinstance(msg_chat_id, int):
return None
chat_type = chat.get("type") if isinstance(chat.get("type"), str) else None
is_forum = chat.get("is_forum")
if not isinstance(is_forum, bool):
is_forum = None
allowed = chat_ids allowed = chat_ids
if allowed is None and chat_id is not None: if allowed is None and chat_id is not None:
allowed = {chat_id} allowed = {chat_id}
if allowed is not None and msg_chat_id not in allowed: if allowed is not None and msg_chat_id not in allowed:
return None return None
message_id = msg.get("message_id") reply = msg.reply_to_message
if not isinstance(message_id, int): reply_to_message_id = reply.message_id if reply is not None else None
return None reply_to_text = reply.text if reply is not None else None
reply = msg.get("reply_to_message") reply_to_is_bot = (
reply_to_message_id = None reply.from_.is_bot if reply is not None and reply.from_ is not None else None
reply_to_text = None
reply_to_is_bot = None
reply_to_username = None
if isinstance(reply, dict):
reply_to_message_id = (
reply.get("message_id")
if isinstance(reply.get("message_id"), int)
else None
)
reply_to_text = (
reply.get("text") if isinstance(reply.get("text"), str) else None
)
reply_from = reply.get("from")
if isinstance(reply_from, dict):
is_bot = reply_from.get("is_bot")
if isinstance(is_bot, bool):
reply_to_is_bot = is_bot
username = reply_from.get("username")
if isinstance(username, str):
reply_to_username = username
sender = msg.get("from")
sender_id = (
sender.get("id")
if isinstance(sender, dict) and isinstance(sender.get("id"), int)
else None
) )
media_group_id = msg.get("media_group_id") reply_to_username = (
if not isinstance(media_group_id, str): reply.from_.username if reply is not None and reply.from_ is not None else None
media_group_id = None )
thread_id = msg.get("message_thread_id") sender_id = msg.from_.id if msg.from_ is not None else None
if isinstance(thread_id, bool) or not isinstance(thread_id, int): media_group_id = msg.media_group_id
thread_id = None thread_id = msg.message_thread_id
is_topic_message = msg.get("is_topic_message") is_topic_message = msg.is_topic_message
if not isinstance(is_topic_message, bool):
is_topic_message = None
return TelegramIncomingMessage( return TelegramIncomingMessage(
transport="telegram", transport="telegram",
chat_id=msg_chat_id, chat_id=msg_chat_id,
message_id=message_id, message_id=msg.message_id,
text=text, text=text,
reply_to_message_id=reply_to_message_id, reply_to_message_id=reply_to_message_id,
reply_to_text=reply_to_text, reply_to_text=reply_to_text,
@@ -218,51 +127,80 @@ def _parse_incoming_message(
is_forum=is_forum, is_forum=is_forum,
voice=voice_payload, voice=voice_payload,
document=document_payload, document=document_payload,
raw=msg, raw=msgspec.to_builtins(msg),
) )
def _parse_callback_query( def _parse_callback_query(
query: dict[str, Any], query: CallbackQuery,
*, *,
chat_id: int | None = None, chat_id: int | None = None,
chat_ids: set[int] | None = None, chat_ids: set[int] | None = None,
) -> TelegramCallbackQuery | None: ) -> TelegramCallbackQuery | None:
callback_id = query.get("id") callback_id = query.id
if not isinstance(callback_id, str) or not callback_id: msg = query.message
return None if msg is None:
msg = query.get("message")
if not isinstance(msg, dict):
return None
chat = msg.get("chat")
if not isinstance(chat, dict):
return None
msg_chat_id = chat.get("id")
if not isinstance(msg_chat_id, int):
return None return None
msg_chat_id = msg.chat.id
allowed = chat_ids allowed = chat_ids
if allowed is None and chat_id is not None: if allowed is None and chat_id is not None:
allowed = {chat_id} allowed = {chat_id}
if allowed is not None and msg_chat_id not in allowed: if allowed is not None and msg_chat_id not in allowed:
return None return None
message_id = msg.get("message_id") data = query.data
if not isinstance(message_id, int): sender_id = query.from_.id if query.from_ is not None else None
return None
data = query.get("data") if isinstance(query.get("data"), str) else None
sender = query.get("from")
sender_id = (
sender.get("id")
if isinstance(sender, dict) and isinstance(sender.get("id"), int)
else None
)
return TelegramCallbackQuery( return TelegramCallbackQuery(
transport="telegram", transport="telegram",
chat_id=msg_chat_id, chat_id=msg_chat_id,
message_id=message_id, message_id=msg.message_id,
callback_query_id=callback_id, callback_query_id=callback_id,
data=data, data=data,
sender_id=sender_id, sender_id=sender_id,
raw=query, raw=msgspec.to_builtins(query),
)
def _best_photo(photos: list[PhotoSize] | None) -> PhotoSize | None:
if not photos:
return None
best = None
best_score = -1
for item in photos:
size = item.file_size
score = size if size is not None else item.width * item.height
if score > best_score:
best_score = score
best = item
return best
def _document_from_media(media: Document | Video) -> TelegramDocument:
return TelegramDocument(
file_id=media.file_id,
file_name=media.file_name,
mime_type=media.mime_type,
file_size=media.file_size,
raw=msgspec.to_builtins(media),
)
def _document_from_photo(photo: PhotoSize) -> TelegramDocument:
return TelegramDocument(
file_id=photo.file_id,
file_name=None,
mime_type=None,
file_size=photo.file_size,
raw=msgspec.to_builtins(photo),
)
def _document_from_sticker(sticker: Sticker) -> TelegramDocument:
return TelegramDocument(
file_id=sticker.file_id,
file_name=None,
mime_type=None,
file_size=sticker.file_size,
raw=msgspec.to_builtins(sticker),
) )
+3 -3
View File
@@ -141,7 +141,7 @@ class FakeBot(BotClient):
"replace_message_id": replace_message_id, "replace_message_id": replace_message_id,
} }
) )
return Message(message_id=1) return Message(message_id=1, chat=Chat(id=chat_id, type="private"))
async def send_document( async def send_document(
self, self,
@@ -164,7 +164,7 @@ class FakeBot(BotClient):
"caption": caption, "caption": caption,
} }
) )
return Message(message_id=2) return Message(message_id=2, chat=Chat(id=chat_id, type="private"))
async def edit_message_text( async def edit_message_text(
self, self,
@@ -188,7 +188,7 @@ class FakeBot(BotClient):
"wait": wait, "wait": wait,
} }
) )
return Message(message_id=message_id) return Message(message_id=message_id, chat=Chat(id=chat_id, type="private"))
async def delete_message(self, chat_id: int, message_id: int) -> bool: async def delete_message(self, chat_id: int, message_id: int) -> bool:
self.delete_calls.append({"chat_id": chat_id, "message_id": message_id}) self.delete_calls.append({"chat_id": chat_id, "message_id": message_id})
+31 -9
View File
@@ -9,7 +9,7 @@ from rich.text import Text
from takopi.config import ConfigError from takopi.config import ConfigError
from takopi.telegram import onboarding from takopi.telegram import onboarding
from takopi.telegram.api_models import Update, User from takopi.telegram.api_models import Chat, Message, Update, User
from takopi.telegram.client import TelegramRetryAfter from takopi.telegram.client import TelegramRetryAfter
@@ -311,35 +311,57 @@ async def test_get_bot_info_gives_up(monkeypatch) -> None:
@pytest.mark.anyio @pytest.mark.anyio
async def test_wait_for_chat_filters_updates(monkeypatch) -> None: async def test_wait_for_chat_filters_updates(monkeypatch) -> None:
updates = [ updates = [
[Update(update_id=1, message={"from": {"is_bot": True}, "chat": {"id": 1}})], [
Update(
update_id=1,
message=Message(
message_id=1,
from_=User(id=1, is_bot=True),
chat=Chat(id=1, type="private"),
),
)
],
None, None,
[], [],
[Update(update_id=2, message=None)], [Update(update_id=2, message=None)],
[ [
Update( Update(
update_id=3, update_id=3,
message={"from": {"is_bot": True}, "chat": {"id": 2}}, message=Message(
message_id=3,
from_=User(id=2, is_bot=True),
chat=Chat(id=2, type="private"),
),
) )
], ],
[ [
Update( Update(
update_id=4, update_id=4,
message={"from": {"is_bot": False}, "chat": "nope"}, message=Message(
message_id=4,
from_=User(id=3, is_bot=True),
chat=Chat(id=3, type="private"),
),
) )
], ],
[ [
Update( Update(
update_id=5, update_id=5,
message={"from": {"is_bot": False}, "chat": {"id": "bad"}}, message=Message(
message_id=5,
from_=User(id=4, is_bot=True),
chat=Chat(id=4, type="private"),
),
) )
], ],
[ [
Update( Update(
update_id=6, update_id=6,
message={ message=Message(
"from": {"is_bot": False}, message_id=6,
"chat": {"id": 7, "username": "bob", "type": "private"}, from_=User(id=5, is_bot=False),
}, chat=Chat(id=7, username="bob", type="private"),
),
) )
], ],
] ]
+2 -2
View File
@@ -14,7 +14,7 @@ from takopi.telegram.commands.topics import _handle_topic_command
import takopi.telegram.loop as telegram_loop import takopi.telegram.loop as telegram_loop
import takopi.telegram.topics as telegram_topics import takopi.telegram.topics as telegram_topics
from takopi.directives import parse_directives from takopi.directives import parse_directives
from takopi.telegram.api_models import File, ForumTopic, Message, Update, User from takopi.telegram.api_models import Chat, File, ForumTopic, Message, Update, User
from takopi.settings import TelegramFilesSettings, TelegramTopicsSettings from takopi.settings import TelegramFilesSettings, TelegramTopicsSettings
from takopi.telegram.bridge import ( from takopi.telegram.bridge import (
TelegramBridgeConfig, TelegramBridgeConfig,
@@ -462,7 +462,7 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
) )
if not wait: if not wait:
return None return None
return Message(message_id=message_id) return Message(message_id=message_id, chat=Chat(id=chat_id, type="private"))
async def delete_message( async def delete_message(
self, self,
+3 -3
View File
@@ -71,9 +71,9 @@ async def test_client_methods_build_params_and_decode() -> None:
payloads = { payloads = {
"getUpdates": [{"update_id": 1}], "getUpdates": [{"update_id": 1}],
"getFile": {"file_path": "path"}, "getFile": {"file_path": "path"},
"sendMessage": {"message_id": 1}, "sendMessage": {"message_id": 1, "chat": {"id": 1, "type": "private"}},
"sendDocument": {"message_id": 2}, "sendDocument": {"message_id": 2, "chat": {"id": 1, "type": "private"}},
"editMessageText": {"message_id": 3}, "editMessageText": {"message_id": 3, "chat": {"id": 1, "type": "private"}},
"deleteMessage": True, "deleteMessage": True,
"setMyCommands": True, "setMyCommands": True,
"getMe": {"id": 7}, "getMe": {"id": 7},
+156 -149
View File
@@ -3,23 +3,37 @@ from takopi.telegram import (
TelegramIncomingMessage, TelegramIncomingMessage,
parse_incoming_update, parse_incoming_update,
) )
from takopi.telegram.api_models import (
CallbackQuery,
CallbackQueryMessage,
Chat,
Document,
Message,
MessageReply,
PhotoSize,
Sticker,
Update,
User,
Video,
Voice,
)
def test_parse_incoming_update_maps_fields() -> None: def test_parse_incoming_update_maps_fields() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"text": "hello", text="hello",
"chat": {"id": 123, "type": "supergroup", "is_forum": True}, chat=Chat(id=123, type="supergroup", is_forum=True),
"from": {"id": 99}, from_=User(id=99),
"reply_to_message": { reply_to_message=MessageReply(
"message_id": 5, message_id=5,
"text": "prev", text="prev",
"from": {"id": 77, "is_bot": True, "username": "ReplyBot"}, from_=User(id=77, is_bot=True, username="ReplyBot"),
}, ),
}, ),
} )
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -39,50 +53,49 @@ def test_parse_incoming_update_maps_fields() -> None:
assert msg.is_forum is True assert msg.is_forum is True
assert msg.voice is None assert msg.voice is None
assert msg.document is None assert msg.document is None
assert msg.raw == update["message"] assert msg.raw
assert msg.raw["message_id"] == 10
def test_parse_incoming_update_filters_non_matching_chat() -> None: def test_parse_incoming_update_filters_non_matching_chat() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"text": "hello", text="hello",
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
}, ),
} )
assert parse_incoming_update(update, chat_id=999) is None assert parse_incoming_update(update, chat_id=999) is None
def test_parse_incoming_update_filters_non_text_and_non_voice() -> None: def test_parse_incoming_update_filters_non_text_and_non_voice() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"location": {"latitude": 1.0, "longitude": 2.0}, ),
}, )
}
assert parse_incoming_update(update, chat_id=123) is None assert parse_incoming_update(update, chat_id=123) is None
def test_parse_incoming_update_voice_message() -> None: def test_parse_incoming_update_voice_message() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"voice": { voice=Voice(
"file_id": "voice-id", file_id="voice-id",
"file_unique_id": "uniq", duration=3,
"duration": 3, mime_type="audio/ogg",
"mime_type": "audio/ogg", file_size=1234,
"file_size": 1234, ),
}, ),
}, )
}
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -96,21 +109,20 @@ def test_parse_incoming_update_voice_message() -> None:
def test_parse_incoming_update_document_message() -> None: def test_parse_incoming_update_document_message() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"caption": "/file put incoming/doc.txt", caption="/file put incoming/doc.txt",
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"document": { document=Document(
"file_id": "doc-id", file_id="doc-id",
"file_unique_id": "uniq", file_name="doc.txt",
"file_name": "doc.txt", mime_type="text/plain",
"mime_type": "text/plain", file_size=4321,
"file_size": 4321, ),
}, ),
}, )
}
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -124,30 +136,28 @@ def test_parse_incoming_update_document_message() -> None:
def test_parse_incoming_update_photo_message() -> None: def test_parse_incoming_update_photo_message() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"caption": "/file put incoming/photo.jpg", caption="/file put incoming/photo.jpg",
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"photo": [ photo=[
{ PhotoSize(
"file_id": "small", file_id="small",
"file_unique_id": "uniq-small", file_size=100,
"file_size": 100, width=90,
"width": 90, height=90,
"height": 90, ),
}, PhotoSize(
{ file_id="large",
"file_id": "large", file_size=1000,
"file_unique_id": "uniq-large", width=800,
"file_size": 1000, height=600,
"width": 800, ),
"height": 600,
},
], ],
}, ),
} )
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -160,23 +170,22 @@ def test_parse_incoming_update_photo_message() -> None:
def test_parse_incoming_update_media_group_id() -> None: def test_parse_incoming_update_media_group_id() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"media_group_id": "group-1", media_group_id="group-1",
"photo": [ photo=[
{ PhotoSize(
"file_id": "large", file_id="large",
"file_unique_id": "uniq-large", file_size=1000,
"file_size": 1000, width=800,
"width": 800, height=600,
"height": 600, )
}
], ],
}, ),
} )
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -185,21 +194,20 @@ def test_parse_incoming_update_media_group_id() -> None:
def test_parse_incoming_update_video_message() -> None: def test_parse_incoming_update_video_message() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"caption": "/file put incoming/video.mp4", caption="/file put incoming/video.mp4",
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"video": { video=Video(
"file_id": "video-id", file_id="video-id",
"file_unique_id": "uniq", file_name="video.mp4",
"file_name": "video.mp4", mime_type="video/mp4",
"mime_type": "video/mp4", file_size=4242,
"file_size": 4242, ),
}, ),
}, )
}
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -213,19 +221,18 @@ def test_parse_incoming_update_video_message() -> None:
def test_parse_incoming_update_sticker_message() -> None: def test_parse_incoming_update_sticker_message() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"caption": "/file put incoming/sticker.webp", caption="/file put incoming/sticker.webp",
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
"sticker": { sticker=Sticker(
"file_id": "sticker-id", file_id="sticker-id",
"file_unique_id": "uniq", file_size=2468,
"file_size": 2468, ),
}, ),
}, )
}
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert msg is not None assert msg is not None
@@ -239,18 +246,18 @@ def test_parse_incoming_update_sticker_message() -> None:
def test_parse_incoming_update_callback_query() -> None: def test_parse_incoming_update_callback_query() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"callback_query": { callback_query=CallbackQuery(
"id": "cbq-1", id="cbq-1",
"data": "takopi:cancel", data="takopi:cancel",
"from": {"id": 321}, from_=User(id=321),
"message": { message=CallbackQueryMessage(
"message_id": 55, message_id=55,
"chat": {"id": 123}, chat=Chat(id=123, type="private"),
}, ),
}, ),
} )
msg = parse_incoming_update(update, chat_id=123) msg = parse_incoming_update(update, chat_id=123)
assert isinstance(msg, TelegramCallbackQuery) assert isinstance(msg, TelegramCallbackQuery)
@@ -263,16 +270,16 @@ def test_parse_incoming_update_callback_query() -> None:
def test_parse_incoming_update_topic_fields() -> None: def test_parse_incoming_update_topic_fields() -> None:
update = { update = Update(
"update_id": 1, update_id=1,
"message": { message=Message(
"message_id": 10, message_id=10,
"text": "hello", text="hello",
"message_thread_id": 77, message_thread_id=77,
"is_topic_message": True, is_topic_message=True,
"chat": {"id": -100, "type": "supergroup", "is_forum": True}, chat=Chat(id=-100, type="supergroup", is_forum=True),
}, ),
} )
msg = parse_incoming_update(update, chat_id=-100) msg = parse_incoming_update(update, chat_id=-100)
assert isinstance(msg, TelegramIncomingMessage) assert isinstance(msg, TelegramIncomingMessage)
+4 -4
View File
@@ -3,7 +3,7 @@ from typing import Any
import anyio import anyio
import pytest import pytest
from takopi.telegram.api_models import File, Message, Update, User from takopi.telegram.api_models import Chat, File, Message, Update, User
from takopi.telegram.client import BotClient, TelegramClient, TelegramRetryAfter from takopi.telegram.client import BotClient, TelegramClient, TelegramRetryAfter
@@ -39,7 +39,7 @@ class FakeBot(BotClient):
_ = reply_markup _ = reply_markup
_ = replace_message_id _ = replace_message_id
self.calls.append("send_message") self.calls.append("send_message")
return Message(message_id=1) return Message(message_id=1, chat=Chat(id=chat_id, type="private"))
async def send_document( async def send_document(
self, self,
@@ -61,7 +61,7 @@ class FakeBot(BotClient):
caption, caption,
) )
self.calls.append("send_document") self.calls.append("send_document")
return Message(message_id=1) return Message(message_id=1, chat=Chat(id=chat_id, type="private"))
async def edit_message_text( async def edit_message_text(
self, self,
@@ -86,7 +86,7 @@ class FakeBot(BotClient):
self._edit_attempts += 1 self._edit_attempts += 1
raise TelegramRetryAfter(self.retry_after) raise TelegramRetryAfter(self.retry_after)
self._edit_attempts += 1 self._edit_attempts += 1
return Message(message_id=message_id) return Message(message_id=message_id, chat=Chat(id=chat_id, type="private"))
async def delete_message( async def delete_message(
self, self,