diff --git a/cmd/bots/aiogramecho/README.md b/cmd/bots/aiogramecho/README.md index c6d5548d..0febb3a7 100644 --- a/cmd/bots/aiogramecho/README.md +++ b/cmd/bots/aiogramecho/README.md @@ -10,6 +10,21 @@ $env:TELESRV_BOT_TOKEN = ":" 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 diff --git a/cmd/bots/aiogramecho/echo.py b/cmd/bots/aiogramecho/echo.py index 42380f74..78b0d172 100644 --- a/cmd/bots/aiogramecho/echo.py +++ b/cmd/bots/aiogramecho/echo.py @@ -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 and /file/bot", ) 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() diff --git a/cmd/bots/aiogramecho/test_echo.py b/cmd/bots/aiogramecho/test_echo.py new file mode 100644 index 00000000..166e471d --- /dev/null +++ b/cmd/bots/aiogramecho/test_echo.py @@ -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() diff --git a/cmd/bots/ptbecho/README.md b/cmd/bots/ptbecho/README.md index 200018a5..50f64b9f 100644 --- a/cmd/bots/ptbecho/README.md +++ b/cmd/bots/ptbecho/README.md @@ -17,6 +17,21 @@ In a group with BotFather privacy enabled, send a command such as: /ping hello from group ``` +## Ephemeral echo(Bot API 10.2) + +先通过 `setMyCommands` 把 `private` 注册为 `is_ephemeral=true`,然后保持本示例轮询。 +在 TDesktop Layer 228 的群组里发送: + +```text +/private@你的Bot用户名 +``` + +本示例从 PTB 的 `Message.api_kwargs` 读取 `ephemeral_message_id`,并在 15 秒 action +窗口内用 `receiver_user_id` 与 `reply_parameters.ephemeral_message_id` 回复 +`ephemeral echo: ...`。请求失败时不会降级成普通群消息。Alice 应看到两条带 +可见性提示的消息:自己发出的命令显示“Only visible to @Bot”,bot 回复显示 +“Only visible to you”;Bob 不应看到其中任何一条。 + 主动发送一条消息并退出: ```powershell diff --git a/cmd/bots/ptbecho/echo.py b/cmd/bots/ptbecho/echo.py index f8f917e3..82bf75f8 100644 --- a/cmd/bots/ptbecho/echo.py +++ b/cmd/bots/ptbecho/echo.py @@ -16,6 +16,14 @@ to the bot, mentions, or messages otherwise visible to bots. In a group, send: /ping hello +For a command registered with ``is_ephemeral=true``, send: + + /private@YourBotUsername + +The incoming Bot API message has ``message_id=0`` and carries the transient +identifier in ``api_kwargs`` until python-telegram-bot exposes the Bot API 10.2 +fields directly. The demo replies through the same ephemeral action window. + The same program can also send proactive messages: python cmd/bots/ptbecho/echo.py \ @@ -70,6 +78,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--base-url", default=os.getenv("TELESRV_BOT_API_BASE_URL", "http://127.0.0.1:8081/bot")) parser.add_argument("--base-file-url", default=os.getenv("TELESRV_BOT_API_BASE_FILE_URL", "http://127.0.0.1:8081/file/bot")) parser.add_argument("--prefix", default="echo: ") + parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ") parser.add_argument("--drop-pending", action="store_true", help="Drop pending updates before polling") parser.add_argument("--timeout", type=int, default=30, help="getUpdates long-poll timeout seconds") parser.add_argument( @@ -117,6 +126,38 @@ async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await echo(update, context) +def ephemeral_message_id(message: object) -> int | None: + """Read a Bot API 10.2 field without depending on a PTB release cycle.""" + raw = getattr(message, "ephemeral_message_id", None) + if raw is None: + raw = (getattr(message, "api_kwargs", 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: object, bot: Bot, prefix: str, ephemeral_prefix: str): + text = getattr(message, "text", None) or getattr(message, "caption", None) or "" + if not text: + return None + transient_id = ephemeral_message_id(message) + if transient_id is None: + return await message.reply_text(prefix + text) + + sender = getattr(message, "from_user", None) + if sender is None: + LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id) + return None + return await bot.send_message( + chat_id=message.chat_id, + text=ephemeral_prefix + text, + api_kwargs={ + "receiver_user_id": sender.id, + "reply_parameters": {"ephemeral_message_id": transient_id}, + }, + ) + + async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_message is None or update.effective_chat is None: return @@ -124,13 +165,20 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not text: return prefix = context.application.bot_data.get("prefix", "echo: ") - sent = await update.effective_message.reply_text(prefix + text) + ephemeral_prefix = context.application.bot_data.get("ephemeral_prefix", "ephemeral echo: ") + transient_id = ephemeral_message_id(update.effective_message) + sent = await send_echo(update.effective_message, context.bot, prefix, ephemeral_prefix) + if sent is None: + return LOG.info( - "echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r", + "echoed update_id=%s chat_id=%s message_id=%s ephemeral_message_id=%s " + "sent_message_id=%s sent_ephemeral_message_id=%s text=%r", update.update_id, update.effective_chat.id, update.effective_message.message_id, + transient_id, sent.message_id, + ephemeral_message_id(sent), text, ) @@ -239,6 +287,7 @@ def build_app(args: argparse.Namespace) -> Application: .build() ) app.bot_data["prefix"] = args.prefix + app.bot_data["ephemeral_prefix"] = args.ephemeral_prefix app.bot_data["base_url"] = args.base_url app.bot_data["send_chat_id"] = args.send_chat_id app.bot_data["send_text"] = args.send_text @@ -247,6 +296,7 @@ def build_app(args: argparse.Namespace) -> Application: app.bot_data["buttons_chat_id"] = args.buttons_chat_id app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("ping", ping)) + app.add_handler(CommandHandler("private", echo)) app.add_handler(CommandHandler("buttons", buttons)) app.add_handler(CallbackQueryHandler(callback)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo)) diff --git a/cmd/bots/ptbecho/test_echo.py b/cmd/bots/ptbecho/test_echo.py new file mode 100644 index 00000000..3fe4a9a6 --- /dev/null +++ b/cmd/bots/ptbecho/test_echo.py @@ -0,0 +1,70 @@ +import unittest +from types import SimpleNamespace + +import echo + + +class FakeMessage: + def __init__(self, *, text="hello", ephemeral_id=None): + self.text = text + self.caption = None + self.chat_id = -1000000000002 + self.from_user = SimpleNamespace(id=1780243200) + self.api_kwargs = {} + if ephemeral_id is not None: + self.api_kwargs["ephemeral_message_id"] = ephemeral_id + self.replies = [] + + async def reply_text(self, text): + self.replies.append(text) + return SimpleNamespace(message_id=1, api_kwargs={}) + + +class FakeBot: + def __init__(self): + self.calls = [] + + async def send_message(self, **kwargs): + self.calls.append(kwargs) + return SimpleNamespace( + message_id=0, + api_kwargs={"ephemeral_message_id": 88}, + ) + + +class EchoTest(unittest.IsolatedAsyncioTestCase): + async def test_ephemeral_echo_uses_receiver_and_transient_reply(self): + message = FakeMessage(text="/private@TetrisBot", ephemeral_id=77) + bot = FakeBot() + + await echo.send_echo(message, bot, "echo: ", "ephemeral echo: ") + + self.assertEqual(message.replies, []) + self.assertEqual( + bot.calls, + [{ + "chat_id": -1000000000002, + "text": "ephemeral echo: /private@TetrisBot", + "api_kwargs": { + "receiver_user_id": 1780243200, + "reply_parameters": {"ephemeral_message_id": 77}, + }, + }], + ) + + async def test_normal_echo_stays_on_standard_reply_path(self): + message = FakeMessage() + bot = FakeBot() + + await echo.send_echo(message, bot, "echo: ", "ephemeral echo: ") + + self.assertEqual(message.replies, ["echo: hello"]) + self.assertEqual(bot.calls, []) + + def test_invalid_ephemeral_id_is_not_accepted(self): + self.assertIsNone(echo.ephemeral_message_id(FakeMessage(ephemeral_id=0))) + self.assertIsNone(echo.ephemeral_message_id(FakeMessage(ephemeral_id=True))) + + +if __name__ == "__main__": + unittest.main()