feat: sync ephemeral bot demo commands

Sync telesrv 172a86e (feat(bot-demo): echo ephemeral group commands).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-20 16:44:09 +08:00
parent f49c817def
commit 7de1766941
6 changed files with 258 additions and 4 deletions

View file

@ -10,6 +10,21 @@ $env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
python .\cmd\bots\aiogramecho\echo.py --drop-pending
```
## Ephemeral echo(Bot API 10.2)
先通过 `setMyCommands` 把 `private` 注册为 `is_ephemeral=true`,再在 TDesktop Layer 228
的群组中发送:
```text
/private@你的Bot用户名
```
aiogram 3.30.0 原生解析 `ephemeral_message_id`。示例在 15 秒 action 窗口内携带
`receiver_user_id` 和 `ReplyParameters(ephemeral_message_id=...)` 回复
`ephemeral echo: ...`;失败不会降级成普通消息。Alice 应看到两条带
可见性提示的消息:自己发出的命令显示“Only visible to @Bot”,bot 回复显示
“Only visible to you”;Bob 不应看到其中任何一条。
发送三种语义色的 reply keyboard 与 inline callback 按钮:
```powershell

View file

@ -17,6 +17,7 @@ from aiogram.types import (
InlineKeyboardMarkup,
KeyboardButton,
Message,
ReplyParameters,
ReplyKeyboardMarkup,
)
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
@ -45,6 +46,7 @@ def parse_args() -> argparse.Namespace:
help="API server origin; aiogram adds /bot<TOKEN> and /file/bot<TOKEN>",
)
parser.add_argument("--prefix", default="aiogram echo: ")
parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ")
parser.add_argument("--drop-pending", action="store_true")
parser.add_argument("--mode", choices=("polling", "webhook"), default=os.getenv("TELESRV_BOT_MODE", "polling"))
parser.add_argument(
@ -157,6 +159,35 @@ async def send_button_messages(bot: Bot, chat_id: int, icon_id: str | None) -> N
)
def ephemeral_message_id(message: object) -> int | None:
"""Read the native aiogram field, retaining an extra-field fallback."""
raw = getattr(message, "ephemeral_message_id", None)
if raw is None:
raw = (getattr(message, "model_extra", None) or {}).get("ephemeral_message_id")
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
return raw
return None
async def send_echo(message: Message, prefix: str, ephemeral_prefix: str):
text = message.text or message.caption or ""
if not text:
return None
transient_id = ephemeral_message_id(message)
if transient_id is None:
return await message.answer(prefix + text)
if message.from_user is None:
LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id)
return None
return await message.bot.send_message(
chat_id=message.chat.id,
text=ephemeral_prefix + text,
receiver_user_id=message.from_user.id,
reply_parameters=ReplyParameters(ephemeral_message_id=transient_id),
)
def build_dispatcher(args: argparse.Namespace) -> Dispatcher:
router = Router(name="telesrv-aiogramecho")
@ -170,7 +201,17 @@ def build_dispatcher(args: argparse.Namespace) -> Dispatcher:
@router.message(Command("ping"))
async def ping(message: Message) -> None:
await message.answer(args.prefix + (message.text or ""))
await send_echo(message, args.prefix, args.ephemeral_prefix)
@router.message(Command("private"))
async def private(message: Message) -> None:
sent = await send_echo(message, args.prefix, args.ephemeral_prefix)
LOG.info(
"echoed ephemeral chat_id=%s ephemeral_message_id=%s sent_ephemeral_message_id=%s",
message.chat.id,
ephemeral_message_id(message),
ephemeral_message_id(sent) if sent is not None else None,
)
@router.callback_query(F.data.startswith("aiogram-"))
async def callback(query: CallbackQuery) -> None:
@ -185,7 +226,7 @@ def build_dispatcher(args: argparse.Namespace) -> Dispatcher:
@router.message(F.text)
async def echo(message: Message) -> None:
await message.answer(args.prefix + (message.text or ""))
await send_echo(message, args.prefix, args.ephemeral_prefix)
LOG.info("echoed chat_id=%s message_id=%s", message.chat.id, message.message_id)
dispatcher = Dispatcher()

View file

@ -0,0 +1,63 @@
import unittest
from types import SimpleNamespace
import echo
class FakeBot:
def __init__(self):
self.calls = []
async def send_message(self, **kwargs):
self.calls.append(kwargs)
return SimpleNamespace(message_id=0, ephemeral_message_id=88)
class FakeMessage:
def __init__(self, *, text="hello", ephemeral_id=None):
self.text = text
self.caption = None
self.chat = SimpleNamespace(id=-1000000000002)
self.from_user = SimpleNamespace(id=1780243200)
self.ephemeral_message_id = ephemeral_id
self.model_extra = {}
self.bot = FakeBot()
self.answers = []
async def answer(self, text):
self.answers.append(text)
return SimpleNamespace(message_id=1, ephemeral_message_id=None)
class EchoTest(unittest.IsolatedAsyncioTestCase):
async def test_ephemeral_echo_uses_receiver_and_transient_reply(self):
message = FakeMessage(text="/private@TetrisBot", ephemeral_id=77)
await echo.send_echo(message, "aiogram echo: ", "ephemeral echo: ")
self.assertEqual(message.answers, [])
self.assertEqual(len(message.bot.calls), 1)
call = message.bot.calls[0]
self.assertEqual(call["chat_id"], -1000000000002)
self.assertEqual(call["text"], "ephemeral echo: /private@TetrisBot")
self.assertEqual(call["receiver_user_id"], 1780243200)
self.assertEqual(call["reply_parameters"].ephemeral_message_id, 77)
async def test_normal_echo_stays_on_standard_answer_path(self):
message = FakeMessage()
await echo.send_echo(message, "aiogram echo: ", "ephemeral echo: ")
self.assertEqual(message.answers, ["aiogram echo: hello"])
self.assertEqual(message.bot.calls, [])
def test_extra_field_fallback_and_invalid_values(self):
message = FakeMessage()
message.model_extra = {"ephemeral_message_id": 66}
self.assertEqual(echo.ephemeral_message_id(message), 66)
message.model_extra = {"ephemeral_message_id": True}
self.assertIsNone(echo.ephemeral_message_id(message))
if __name__ == "__main__":
unittest.main()