From bf965f610c782445b304897750cfa20639a1efaf Mon Sep 17 00:00:00 2001 From: A Date: Sun, 19 Jul 2026 20:38:48 +0800 Subject: [PATCH] feat: sync bot keyboards and callbacks Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery). Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming. --- cmd/bots/aiogramecho/README.md | 63 +++ cmd/bots/aiogramecho/echo.py | 273 +++++++++ cmd/bots/aiogramecho/requirements.txt | 1 + cmd/bots/ptbecho/README.md | 13 + cmd/bots/ptbecho/echo.py | 131 ++++- cmd/telesrv/main.go | 5 +- .../0110_bot_api_callback_queries.down.sql | 20 + .../0110_bot_api_callback_queries.up.sql | 35 ++ .../0111_bot_api_polling_state.down.sql | 6 + .../0111_bot_api_polling_state.up.sql | 12 + .../0112_bot_api_poll_lease.down.sql | 4 + .../migrations/0112_bot_api_poll_lease.up.sql | 10 + .../0113_bot_api_inline_callbacks.down.sql | 27 + .../0113_bot_api_inline_callbacks.up.sql | 49 ++ .../migrations/0115_bot_api_webhooks.down.sql | 1 + .../migrations/0115_bot_api_webhooks.up.sql | 27 + .../0116_bot_requested_peer_filters.down.sql | 2 + .../0116_bot_requested_peer_filters.up.sql | 2 + .../0119_bot_requested_peer_metadata.down.sql | 4 + .../0119_bot_requested_peer_metadata.up.sql | 4 + internal/app/channels/service.go | 13 + internal/app/dialogs/read_model_cache.go | 15 +- internal/app/files/photos.go | 35 ++ internal/app/messages/service.go | 34 ++ internal/botapi/inline.go | 529 +++++++++++++++++- internal/botapi/projection.go | 431 +++++++++++++- internal/botapi/server.go | 380 ++++++++++++- internal/botapi/server_test.go | 483 ++++++++++++++++ internal/botapi/webhook.go | 259 +++++++++ internal/botapi/webhook_test.go | 127 +++++ internal/domain/bot.go | 22 +- internal/domain/botapi_update.go | 26 + internal/domain/botapi_webhook.go | 21 + internal/domain/channel.go | 60 +- internal/domain/media.go | 17 + internal/domain/message.go | 4 +- internal/domain/message_markup.go | 266 ++++++++- internal/domain/message_markup_test.go | 21 +- internal/domain/update_event.go | 4 + internal/rpc/botapi_gateway.go | 162 +++++- internal/rpc/botapi_gateway_test.go | 178 +++++- internal/rpc/botapi_update_queue.go | 191 ++++++- .../botapi_update_queue_projection_test.go | 70 +++ internal/rpc/bots_callback.go | 229 +++++++- internal/rpc/bots_longtail.go | 9 +- internal/rpc/callback_registry.go | 160 +++++- internal/rpc/convert_channels_core.go | 35 +- internal/rpc/convert_markup.go | 425 +++++++++++++- internal/rpc/convert_markup_test.go | 171 ++++++ internal/rpc/convert_messages.go | 43 +- internal/rpc/deps.go | 1 + internal/rpc/messages_bot_longtail.go | 269 ++++++++- .../rpc/messages_bot_longtail_rpc_test.go | 44 ++ internal/rpc/messages_send.go | 9 +- internal/rpc/router.go | 4 +- internal/rpc/send_media.go | 10 +- internal/rpc/update_peer_refs.go | 8 + internal/store/bot_callback.go | 36 ++ internal/store/botapi_update.go | 26 + internal/store/memory/botapi_update.go | 372 +++++++++++- internal/store/memory/botapi_update_test.go | 191 +++++++ internal/store/memory/message_helpers.go | 34 +- internal/store/memory/message_history.go | 27 + internal/store/memory/message_markup_test.go | 43 ++ internal/store/memory/message_send.go | 3 +- internal/store/memory/message_test.go | 59 ++ .../store/postgres/bot_integration_test.go | 16 + internal/store/postgres/bot_miniapp.go | 35 +- internal/store/postgres/botapi_update.go | 521 ++++++++++++++++- .../botapi_update_integration_test.go | 296 +++++++++- internal/store/postgres/media.go | 50 ++ internal/store/postgres/media_codec.go | 9 +- .../store/postgres/media_integration_test.go | 4 + internal/store/postgres/message_history.go | 21 + .../postgres/message_markup_codec_test.go | 42 ++ internal/store/postgres/message_send.go | 2 +- .../postgres/message_send_integration_test.go | 64 +++ internal/store/postgres/sqlcgen/models.go | 30 +- internal/store/redisstore/bot_callback.go | 150 +++++ .../bot_callback_integration_test.go | 76 +++ 80 files changed, 7212 insertions(+), 349 deletions(-) create mode 100644 cmd/bots/aiogramecho/README.md create mode 100644 cmd/bots/aiogramecho/echo.py create mode 100644 cmd/bots/aiogramecho/requirements.txt create mode 100644 deploy/migrations/0110_bot_api_callback_queries.down.sql create mode 100644 deploy/migrations/0110_bot_api_callback_queries.up.sql create mode 100644 deploy/migrations/0111_bot_api_polling_state.down.sql create mode 100644 deploy/migrations/0111_bot_api_polling_state.up.sql create mode 100644 deploy/migrations/0112_bot_api_poll_lease.down.sql create mode 100644 deploy/migrations/0112_bot_api_poll_lease.up.sql create mode 100644 deploy/migrations/0113_bot_api_inline_callbacks.down.sql create mode 100644 deploy/migrations/0113_bot_api_inline_callbacks.up.sql create mode 100644 deploy/migrations/0115_bot_api_webhooks.down.sql create mode 100644 deploy/migrations/0115_bot_api_webhooks.up.sql create mode 100644 deploy/migrations/0116_bot_requested_peer_filters.down.sql create mode 100644 deploy/migrations/0116_bot_requested_peer_filters.up.sql create mode 100644 deploy/migrations/0119_bot_requested_peer_metadata.down.sql create mode 100644 deploy/migrations/0119_bot_requested_peer_metadata.up.sql create mode 100644 internal/botapi/webhook.go create mode 100644 internal/botapi/webhook_test.go create mode 100644 internal/domain/botapi_webhook.go create mode 100644 internal/rpc/botapi_update_queue_projection_test.go create mode 100644 internal/rpc/convert_markup_test.go create mode 100644 internal/store/bot_callback.go create mode 100644 internal/store/memory/botapi_update_test.go create mode 100644 internal/store/memory/message_markup_test.go create mode 100644 internal/store/postgres/message_markup_codec_test.go create mode 100644 internal/store/redisstore/bot_callback.go create mode 100644 internal/store/redisstore/bot_callback_integration_test.go diff --git a/cmd/bots/aiogramecho/README.md b/cmd/bots/aiogramecho/README.md new file mode 100644 index 00000000..c6d5548d --- /dev/null +++ b/cmd/bots/aiogramecho/README.md @@ -0,0 +1,63 @@ +# aiogram 3 echo demo + +该示例使用标准 aiogram 3 API,仅把 API server 指向 telesrv。aiogram 的 +`TelegramAPIServer.from_base()` 会自动拼出 telesrv 已支持的 +`/bot/` 与 `/file/bot/`。 + +```powershell +python -m pip install -r .\cmd\bots\aiogramecho\requirements.txt +$env:TELESRV_BOT_TOKEN = ":" +python .\cmd\bots\aiogramecho\echo.py --drop-pending +``` + +发送三种语义色的 reply keyboard 与 inline callback 按钮: + +```powershell +python .\cmd\bots\aiogramecho\echo.py ` + --buttons-chat-id 1780243200 ` + --drop-pending +``` + +可选的 `--button-icon-id ` 同时验证按钮自定义 emoji +图标。Telegram 官方会按 bot owner Premium / Fragment 权限限制图标使用;颜色只接受 +`primary`(蓝)、`success`(绿)、`danger`(红),不接受任意 RGB。 + +只主动发消息、不启动轮询: + +```powershell +python .\cmd\bots\aiogramecho\echo.py ` + --send-only ` + --send-chat-id 1780243200 ` + --send-text "hello from aiogram" +``` + +默认 API server 是 `http://127.0.0.1:8081`,可用 `--base-url` 或 +`TELESRV_BOT_API_SERVER` 覆盖。不要在这里追加 `/bot`;这与 ptbecho 的 +`--base-url http://127.0.0.1:8081/bot` 参数格式不同。 + +轮询模式会回答本示例的 `aiogram-*` callback,也会对同一测试 bot 先前由 +其它 demo 创建的 inline callback 给出兜底确认,避免 TDesktop 按钮一直转圈。 + +## Webhook 模式 + +telesrv 现在会持久化 webhook 配置,通过跨实例租约投递,并且只在目标返回 2xx +后推进 `update_id`。aiogram 可监听本机 HTTP,由 Caddy/Nginx/Tunnel 提供公网 HTTPS: + +```powershell +$env:TELESRV_BOT_WEBHOOK_URL = "https://bot.example.com/webhook" +$env:TELESRV_BOT_WEBHOOK_SECRET = "replace_with_a_random_secret" +python .\cmd\bots\aiogramecho\echo.py ` + --mode webhook ` + --listen-host 127.0.0.1 ` + --listen-port 8082 ` + --webhook-path /webhook ` + --drop-pending +``` + +公网 URL 必须是 HTTPS,端口限 Telegram 标准的 443/80/88/8443;本机监听地址 +可以是 HTTP,因为 TLS 通常在反向代理终止。`secret_token` 会由 telesrv 放入 +`X-Telegram-Bot-Api-Secret-Token`,aiogram 会自动校验。若希望进程退出时删除配置, +再加 `--delete-webhook-on-exit`;默认保留配置,以免普通重启造成更新丢窗。 + +同一个 token 的 polling 与 webhook 互斥;切回轮询时直接以默认模式启动,示例会先 +调用 `deleteWebhook`,再开始 `getUpdates`。 diff --git a/cmd/bots/aiogramecho/echo.py b/cmd/bots/aiogramecho/echo.py new file mode 100644 index 00000000..42380f74 --- /dev/null +++ b/cmd/bots/aiogramecho/echo.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""aiogram 3 echo/button demo for the telesrv Bot API endpoint.""" + +import argparse +import asyncio +import logging +import os + +from aiogram import Bot, Dispatcher, F, Router +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.client.telegram import TelegramAPIServer +from aiogram.enums import ButtonStyle +from aiogram.filters import Command, CommandStart +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + Message, + ReplyKeyboardMarkup, +) +from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application +from aiohttp import web + + +LOG = logging.getLogger("aiogramecho") + + +def env_int(name: str) -> int | None: + raw = os.getenv(name) + if not raw: + return None + try: + return int(raw) + except ValueError as exc: + raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="aiogram 3 echo bot against telesrv") + parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN")) + parser.add_argument( + "--base-url", + default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"), + help="API server origin; aiogram adds /bot and /file/bot", + ) + parser.add_argument("--prefix", default="aiogram 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( + "--webhook-url", + default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""), + help="Public HTTPS URL including the webhook path", + ) + parser.add_argument( + "--webhook-path", + default=os.getenv("TELESRV_BOT_WEBHOOK_PATH", "/webhook"), + help="Local aiohttp route, normally the path part of --webhook-url", + ) + parser.add_argument("--webhook-secret", default=os.getenv("TELESRV_BOT_WEBHOOK_SECRET", "telesrv-aiogram-demo")) + parser.add_argument("--listen-host", default=os.getenv("TELESRV_BOT_LISTEN_HOST", "127.0.0.1")) + parser.add_argument("--listen-port", type=int, default=env_int("TELESRV_BOT_LISTEN_PORT") or 8082) + parser.add_argument("--delete-webhook-on-exit", action="store_true") + parser.add_argument("--timeout", type=int, default=30) + parser.add_argument("--send-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_CHAT_ID")) + parser.add_argument("--send-text", default=os.getenv("TELESRV_BOT_DEMO_SEND_TEXT", "")) + parser.add_argument("--buttons-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID")) + parser.add_argument( + "--button-icon-id", + default=os.getenv("TELESRV_BOT_DEMO_BUTTON_ICON_ID"), + help="Optional custom emoji document id used as the button icon", + ) + parser.add_argument("--send-only", action="store_true") + parser.add_argument("--log-level", default="INFO") + args = parser.parse_args() + if not args.token: + parser.error("missing --token or TELESRV_BOT_TOKEN") + if args.timeout < 0 or args.timeout > 50: + parser.error("--timeout must be between 0 and 50") + if args.send_text and args.send_chat_id is None: + parser.error("--send-chat-id is required with --send-text") + if args.send_only and not args.send_text and args.buttons_chat_id is None: + parser.error("--send-only requires --send-text or --buttons-chat-id") + if args.button_icon_id: + try: + if int(args.button_icon_id) <= 0: + raise ValueError + except ValueError as exc: + raise SystemExit("--button-icon-id must be a positive integer") from exc + if args.mode == "webhook" and not args.webhook_url: + parser.error("--webhook-url or TELESRV_BOT_WEBHOOK_URL is required in webhook mode") + if not args.webhook_path.startswith("/") or "?" in args.webhook_path or "#" in args.webhook_path: + parser.error("--webhook-path must be an absolute path without query or fragment") + if args.listen_port < 1 or args.listen_port > 65535: + parser.error("--listen-port must be between 1 and 65535") + return args + + +def reply_keyboard(icon_id: str | None) -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton(text="Primary", style=ButtonStyle.PRIMARY, icon_custom_emoji_id=icon_id), + KeyboardButton(text="Success", style=ButtonStyle.SUCCESS, icon_custom_emoji_id=icon_id), + KeyboardButton(text="Danger", style=ButtonStyle.DANGER, icon_custom_emoji_id=icon_id), + ] + ], + resize_keyboard=True, + one_time_keyboard=True, + input_field_placeholder="Tap a colored reply button", + ) + + +def inline_keyboard(icon_id: str | None) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Primary", + callback_data="aiogram-primary", + style=ButtonStyle.PRIMARY, + icon_custom_emoji_id=icon_id, + ), + InlineKeyboardButton( + text="Success", + callback_data="aiogram-success", + style=ButtonStyle.SUCCESS, + icon_custom_emoji_id=icon_id, + ), + InlineKeyboardButton( + text="Danger", + callback_data="aiogram-danger", + style=ButtonStyle.DANGER, + icon_custom_emoji_id=icon_id, + ), + ] + ] + ) + + +async def send_button_messages(bot: Bot, chat_id: int, icon_id: str | None) -> None: + reply = await bot.send_message( + chat_id=chat_id, + text="TELESRV_AIOGRAM_REPLY_STYLES_20260719", + reply_markup=reply_keyboard(icon_id), + ) + inline = await bot.send_message( + chat_id=chat_id, + text="TELESRV_AIOGRAM_INLINE_STYLES_20260719", + reply_markup=inline_keyboard(icon_id), + ) + LOG.info( + "sent styled buttons chat_id=%s reply_message_id=%s inline_message_id=%s", + chat_id, + reply.message_id, + inline.message_id, + ) + + +def build_dispatcher(args: argparse.Namespace) -> Dispatcher: + router = Router(name="telesrv-aiogramecho") + + @router.message(CommandStart()) + async def start(message: Message) -> None: + await message.answer("send /ping , /buttons, or any private text") + + @router.message(Command("buttons")) + async def buttons(message: Message) -> None: + await send_button_messages(message.bot, message.chat.id, args.button_icon_id) + + @router.message(Command("ping")) + async def ping(message: Message) -> None: + await message.answer(args.prefix + (message.text or "")) + + @router.callback_query(F.data.startswith("aiogram-")) + async def callback(query: CallbackQuery) -> None: + await query.answer(f"telesrv {query.data} callback OK") + LOG.info("answered callback query_id=%s data=%r", query.id, query.data) + + @router.callback_query() + async def fallback_callback(query: CallbackQuery) -> None: + """Keep the echo demo responsive for buttons created by another demo.""" + await query.answer("telesrv callback OK") + LOG.info("answered fallback callback query_id=%s data=%r", query.id, query.data) + + @router.message(F.text) + async def echo(message: Message) -> None: + await message.answer(args.prefix + (message.text or "")) + LOG.info("echoed chat_id=%s message_id=%s", message.chat.id, message.message_id) + + dispatcher = Dispatcher() + dispatcher.include_router(router) + return dispatcher + + +def build_bot(args: argparse.Namespace) -> Bot: + session = AiohttpSession(api=TelegramAPIServer.from_base(args.base_url.rstrip("/"))) + return Bot(token=args.token, session=session) + + +async def run(args: argparse.Namespace) -> None: + bot = build_bot(args) + runner: web.AppRunner | None = None + try: + me = await bot.get_me() + LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url) + if args.send_chat_id is not None and args.send_text: + sent = await bot.send_message(chat_id=args.send_chat_id, text=args.send_text) + LOG.info("sent proactive chat_id=%s message_id=%s", args.send_chat_id, sent.message_id) + if args.buttons_chat_id is not None: + await send_button_messages(bot, args.buttons_chat_id, args.button_icon_id) + if args.send_only: + return + dispatcher = build_dispatcher(args) + allowed_updates = ["message", "edited_message", "callback_query"] + if args.mode == "polling": + await bot.delete_webhook(drop_pending_updates=args.drop_pending) + await dispatcher.start_polling( + bot, + allowed_updates=allowed_updates, + polling_timeout=args.timeout, + close_bot_session=False, + ) + return + + application = web.Application() + SimpleRequestHandler( + dispatcher=dispatcher, + bot=bot, + secret_token=args.webhook_secret, + ).register(application, path=args.webhook_path) + setup_application(application, dispatcher, bot=bot) + await bot.set_webhook( + url=args.webhook_url, + secret_token=args.webhook_secret, + allowed_updates=allowed_updates, + drop_pending_updates=args.drop_pending, + ) + runner = web.AppRunner(application) + await runner.setup() + site = web.TCPSite(runner, host=args.listen_host, port=args.listen_port) + await site.start() + LOG.info( + "webhook listening on http://%s:%s%s, public_url=%s", + args.listen_host, + args.listen_port, + args.webhook_path, + args.webhook_url, + ) + await asyncio.Event().wait() + finally: + if args.mode == "webhook" and args.delete_webhook_on_exit: + try: + await bot.delete_webhook() + except Exception: # pragma: no cover - best-effort shutdown logging + LOG.exception("failed to delete webhook during shutdown") + if runner is not None: + await runner.cleanup() + await bot.session.close() + + +def main() -> int: + args = parse_args() + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + asyncio.run(run(args)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cmd/bots/aiogramecho/requirements.txt b/cmd/bots/aiogramecho/requirements.txt new file mode 100644 index 00000000..1b94ec4e --- /dev/null +++ b/cmd/bots/aiogramecho/requirements.txt @@ -0,0 +1 @@ +aiogram==3.30.0 diff --git a/cmd/bots/ptbecho/README.md b/cmd/bots/ptbecho/README.md index 72c8eb9a..200018a5 100644 --- a/cmd/bots/ptbecho/README.md +++ b/cmd/bots/ptbecho/README.md @@ -34,11 +34,24 @@ python .\cmd\bots\ptbecho\echo.py ` --send-text "ptbecho is online" ``` +发送 reply keyboard 与 inline callback 两条验证消息并保持 polling: + +```powershell +python .\cmd\bots\ptbecho\echo.py ` + --buttons-chat-id 1780243200 +``` + +reply keyboard 与 inline keyboard 都会各显示蓝/绿/红三种语义色;点击 reply +button 会按普通文本消息进入 echo 链,点击 inline button 会由 `callback_query` handler 调用 +`answerCallbackQuery` 并显示 `telesrv inline callback OK`。也可以在私聊中发送 +`/buttons` 生成同样的两条消息。 + 可选参数: - `--send-count N`:连续主动发送 N 条。 - `--send-interval SEC`:连续发送之间的间隔。 - `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。 +- `--buttons-chat-id` / `TELESRV_BOT_DEMO_BUTTONS_CHAT_ID`:发送两类键盘验证消息并监听 callback。 本地超级群 chat id 使用 Bot API 形式 `-100`;例如 channel id 为 `2` 时是 `-1000000000002`。 diff --git a/cmd/bots/ptbecho/echo.py b/cmd/bots/ptbecho/echo.py index 6e3d5a84..f8f917e3 100644 --- a/cmd/bots/ptbecho/echo.py +++ b/cmd/bots/ptbecho/echo.py @@ -32,11 +32,18 @@ import os import signal from typing import Iterable -from telegram import Bot, Update -from telegram.constants import ChatAction +from telegram import ( + Bot, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + ReplyKeyboardMarkup, + Update, +) from telegram.ext import ( Application, ApplicationBuilder, + CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, @@ -78,6 +85,12 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--send-count", type=int, default=1, help="Number of proactive messages to send") parser.add_argument("--send-interval", type=float, default=1.0, help="Seconds between proactive sends") + parser.add_argument( + "--buttons-chat-id", + type=int, + default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"), + help="Send reply/inline keyboard validation messages to this chat on startup", + ) parser.add_argument("--send-only", action="store_true", help="Send proactive messages and exit without polling") parser.add_argument("--log-level", default="INFO") args = parser.parse_args() @@ -87,11 +100,10 @@ def parse_args() -> argparse.Namespace: parser.error("--send-count must be >= 1") if args.send_interval < 0: parser.error("--send-interval must be >= 0") - wants_send = args.send_only or bool(args.send_text) - if wants_send and args.send_chat_id is None: + if args.send_text and args.send_chat_id is None: parser.error("--send-chat-id is required when --send-text or --send-only is used") - if args.send_only and not args.send_text: - parser.error("--send-only requires --send-text") + if args.send_only and not args.send_text and args.buttons_chat_id is None: + parser.error("--send-only requires --send-text or --buttons-chat-id") return args @@ -112,7 +124,6 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not text: return prefix = context.application.bot_data.get("prefix", "echo: ") - await context.bot.send_chat_action(chat_id=update.effective_chat.id, action=ChatAction.TYPING) sent = await update.effective_message.reply_text(prefix + text) LOG.info( "echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r", @@ -124,6 +135,26 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) +async def buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.effective_chat is None: + return + await send_button_messages(context.bot, update.effective_chat.id) + + +async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None: + return + await query.answer("telesrv inline callback OK") + LOG.info( + "answered callback query_id=%s chat_id=%s message_id=%s data=%r", + query.id, + query.message.chat_id if query.message else None, + query.message.message_id if query.message else None, + query.data, + ) + + async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None: for index in range(count): sent = await bot.send_message(chat_id=chat_id, text=text) @@ -139,25 +170,63 @@ async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, in await asyncio.sleep(interval) +async def send_button_messages(bot: Bot, chat_id: int) -> None: + reply = await bot.send_message( + chat_id=chat_id, + text="TELESRV_REPLY_KEYBOARD_20260719", + reply_markup=ReplyKeyboardMarkup( + [[ + KeyboardButton("Primary", api_kwargs={"style": "primary"}), + KeyboardButton("Success", api_kwargs={"style": "success"}), + KeyboardButton("Danger", api_kwargs={"style": "danger"}), + ]], + resize_keyboard=True, + one_time_keyboard=True, + input_field_placeholder="Tap the reply button", + ), + ) + inline = await bot.send_message( + chat_id=chat_id, + text="TELESRV_INLINE_CALLBACK_20260719", + reply_markup=InlineKeyboardMarkup( + [[ + InlineKeyboardButton("Primary", callback_data="telesrv-primary", api_kwargs={"style": "primary"}), + InlineKeyboardButton("Success", callback_data="telesrv-success", api_kwargs={"style": "success"}), + InlineKeyboardButton("Danger", callback_data="telesrv-danger", api_kwargs={"style": "danger"}), + ]], + ), + ) + LOG.info( + "sent keyboard validation chat_id=%s reply_message_id=%s inline_message_id=%s", + chat_id, + reply.message_id, + inline.message_id, + ) + + async def send_on_startup(app: Application) -> None: chat_id = app.bot_data.get("send_chat_id") text = app.bot_data.get("send_text") - if chat_id is None or not text: - return - await send_active_messages( - app.bot, - chat_id=chat_id, - text=text, - count=int(app.bot_data.get("send_count", 1)), - interval=float(app.bot_data.get("send_interval", 1.0)), - ) + if chat_id is not None and text: + await send_active_messages( + app.bot, + chat_id=chat_id, + text=text, + count=int(app.bot_data.get("send_count", 1)), + interval=float(app.bot_data.get("send_interval", 1.0)), + ) + buttons_chat_id = app.bot_data.get("buttons_chat_id") + if buttons_chat_id is not None: + await send_button_messages(app.bot, int(buttons_chat_id)) async def post_init(app: Application) -> None: me = await app.bot.get_me() LOG.info("listening as @%s (%s), bot_api=%s", me.username or me.id, me.id, app.bot_data["base_url"]) - if app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text"): - app.create_task(send_on_startup(app), name="ptbecho-proactive-send") + if (app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text")) or app.bot_data.get( + "buttons_chat_id" + ) is not None: + await send_on_startup(app) def build_app(args: argparse.Namespace) -> Application: @@ -175,8 +244,11 @@ def build_app(args: argparse.Namespace) -> Application: app.bot_data["send_text"] = args.send_text app.bot_data["send_count"] = args.send_count app.bot_data["send_interval"] = args.send_interval + 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("buttons", buttons)) + app.add_handler(CallbackQueryHandler(callback)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo)) return app @@ -185,18 +257,21 @@ async def run_send_only(args: argparse.Namespace) -> None: bot = Bot(token=args.token, base_url=args.base_url, base_file_url=args.base_file_url) me = await bot.get_me() LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url) - await send_active_messages( - bot, - chat_id=args.send_chat_id, - text=args.send_text, - count=args.send_count, - interval=args.send_interval, - ) + if args.send_chat_id is not None and args.send_text: + await send_active_messages( + bot, + chat_id=args.send_chat_id, + text=args.send_text, + count=args.send_count, + interval=args.send_interval, + ) + if args.buttons_chat_id is not None: + await send_button_messages(bot, args.buttons_chat_id) -def stop_signals() -> Iterable[int]: +def stop_signals() -> Iterable[int] | None: if os.name == "nt": - return (signal.SIGINT, signal.SIGTERM) + return None return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) @@ -214,7 +289,7 @@ def main() -> int: app = build_app(args) app.run_polling( - allowed_updates=["message", "edited_message"], + allowed_updates=["message", "edited_message", "callback_query"], drop_pending_updates=args.drop_pending, poll_interval=0.0, timeout=args.timeout, diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 003652dc..a6da58eb 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -362,6 +362,7 @@ func run(logger *zap.Logger) error { dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout)) bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool) botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool) + botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) @@ -436,7 +437,7 @@ func run(logger *zap.Logger) error { return fmt.Errorf("seed appearance: %w", err) } else if !stats.Skipped { logger.Info("外观种子导入完成", - zap.String("source", "orange-live"), + zap.String("source", "default-seed"), zap.Int("wallpapers", stats.Wallpapers), zap.Int("documents", stats.Documents), zap.Int("blobs", stats.Blobs), @@ -792,6 +793,7 @@ func run(logger *zap.Logger) error { Updates: updatesService, BootstrapUpdates: bootstrapUpdateStore, BotAPIUpdates: botAPIUpdateStore, + BotCallbacks: botCallbackStore, Contacts: contactsService, Dialogs: dialogsService, Chatlists: chatlistsService, @@ -900,6 +902,7 @@ func run(logger *zap.Logger) error { } }() go router.RunInlineBotPushSubscriber(ctx) + go router.RunBotCallbackAnswerSubscriber(ctx) if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil { return fmt.Errorf("start bot api: %w", err) } diff --git a/deploy/migrations/0110_bot_api_callback_queries.down.sql b/deploy/migrations/0110_bot_api_callback_queries.down.sql new file mode 100644 index 00000000..de7bebfb --- /dev/null +++ b/deploy/migrations/0110_bot_api_callback_queries.down.sql @@ -0,0 +1,20 @@ +DELETE FROM public.bot_api_updates WHERE update_kind = 'callback_query'; + +DROP INDEX IF EXISTS public.bot_api_updates_callback_query_unique; +DROP INDEX IF EXISTS public.bot_api_updates_message_source_unique; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_callback_shape_check, + DROP CONSTRAINT bot_api_updates_kind_check; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_kind_check + CHECK ((update_kind)::text = ANY (ARRAY['message'::text, 'edited_message'::text])), + ADD CONSTRAINT bot_api_updates_source_unique + UNIQUE (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts); + +ALTER TABLE public.bot_api_updates + DROP COLUMN callback_data, + DROP COLUMN callback_chat_instance, + DROP COLUMN callback_user_id, + DROP COLUMN callback_query_id; diff --git a/deploy/migrations/0110_bot_api_callback_queries.up.sql b/deploy/migrations/0110_bot_api_callback_queries.up.sql new file mode 100644 index 00000000..ecc636a2 --- /dev/null +++ b/deploy/migrations/0110_bot_api_callback_queries.up.sql @@ -0,0 +1,35 @@ +ALTER TABLE public.bot_api_updates + ADD COLUMN callback_query_id bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_user_id bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_chat_instance bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_data bytea; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_kind_check, + DROP CONSTRAINT bot_api_updates_source_unique; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_kind_check + CHECK ((update_kind)::text = ANY (ARRAY['message'::text, 'edited_message'::text, 'callback_query'::text])), + ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK ( + (update_kind = 'callback_query' + AND callback_query_id <> 0 + AND callback_user_id > 0 + AND callback_chat_instance <> 0 + AND COALESCE(octet_length(callback_data), 0) <= 64 + AND source_pts = 0) + OR + (update_kind IN ('message', 'edited_message') + AND callback_query_id = 0 + AND callback_user_id = 0 + AND callback_chat_instance = 0 + AND callback_data IS NULL) + ); + +CREATE UNIQUE INDEX bot_api_updates_message_source_unique + ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) + WHERE update_kind IN ('message', 'edited_message'); + +CREATE UNIQUE INDEX bot_api_updates_callback_query_unique + ON public.bot_api_updates (bot_user_id, callback_query_id) + WHERE update_kind = 'callback_query'; diff --git a/deploy/migrations/0111_bot_api_polling_state.down.sql b/deploy/migrations/0111_bot_api_polling_state.down.sql new file mode 100644 index 00000000..646d666b --- /dev/null +++ b/deploy/migrations/0111_bot_api_polling_state.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS public.bot_api_updates_created_retention_idx; + +ALTER TABLE public.bot_api_update_states + DROP CONSTRAINT IF EXISTS bot_api_update_states_allowed_updates_check, + DROP COLUMN IF EXISTS cursor_initialized, + DROP COLUMN IF EXISTS allowed_updates; diff --git a/deploy/migrations/0111_bot_api_polling_state.up.sql b/deploy/migrations/0111_bot_api_polling_state.up.sql new file mode 100644 index 00000000..31c42386 --- /dev/null +++ b/deploy/migrations/0111_bot_api_polling_state.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE public.bot_api_update_states + ADD COLUMN allowed_updates text[], + ADD COLUMN cursor_initialized boolean NOT NULL DEFAULT false; + +ALTER TABLE public.bot_api_update_states + ADD CONSTRAINT bot_api_update_states_allowed_updates_check CHECK ( + allowed_updates IS NULL + OR array_position(allowed_updates, NULL) IS NULL + ); + +CREATE INDEX bot_api_updates_created_retention_idx + ON public.bot_api_updates (created_at, id); diff --git a/deploy/migrations/0112_bot_api_poll_lease.down.sql b/deploy/migrations/0112_bot_api_poll_lease.down.sql new file mode 100644 index 00000000..8cf6daeb --- /dev/null +++ b/deploy/migrations/0112_bot_api_poll_lease.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.bot_api_update_states + DROP CONSTRAINT IF EXISTS bot_api_update_states_poll_lease_check, + DROP COLUMN IF EXISTS poll_expires_at, + DROP COLUMN IF EXISTS poll_owner; diff --git a/deploy/migrations/0112_bot_api_poll_lease.up.sql b/deploy/migrations/0112_bot_api_poll_lease.up.sql new file mode 100644 index 00000000..436bef63 --- /dev/null +++ b/deploy/migrations/0112_bot_api_poll_lease.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE public.bot_api_update_states + ADD COLUMN poll_owner text NOT NULL DEFAULT '', + ADD COLUMN poll_expires_at timestamptz; + +ALTER TABLE public.bot_api_update_states + ADD CONSTRAINT bot_api_update_states_poll_lease_check CHECK ( + (poll_owner = '' AND poll_expires_at IS NULL) + OR + (poll_owner <> '' AND poll_expires_at IS NOT NULL) + ); diff --git a/deploy/migrations/0113_bot_api_inline_callbacks.down.sql b/deploy/migrations/0113_bot_api_inline_callbacks.down.sql new file mode 100644 index 00000000..bd5f671f --- /dev/null +++ b/deploy/migrations/0113_bot_api_inline_callbacks.down.sql @@ -0,0 +1,27 @@ +DELETE FROM public.bot_api_updates +WHERE update_kind = 'callback_query' AND callback_inline_message_id <> 0; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_callback_shape_check, + DROP CONSTRAINT bot_api_updates_peer_type_check, + DROP CONSTRAINT bot_api_updates_peer_id_check, + DROP CONSTRAINT bot_api_updates_message_id_check; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_peer_type_check CHECK (peer_type IN ('user', 'channel')), + ADD CONSTRAINT bot_api_updates_peer_id_check CHECK (peer_id > 0), + ADD CONSTRAINT bot_api_updates_message_id_check CHECK (message_id > 0), + ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK ( + (update_kind = 'callback_query' AND callback_query_id <> 0 AND callback_user_id > 0 + AND callback_chat_instance <> 0 AND COALESCE(octet_length(callback_data), 0) <= 64 + AND source_pts = 0) + OR + (update_kind IN ('message', 'edited_message') AND callback_query_id = 0 + AND callback_user_id = 0 AND callback_chat_instance = 0 AND callback_data IS NULL) + ); + +ALTER TABLE public.bot_api_updates + DROP COLUMN callback_inline_access_hash, + DROP COLUMN callback_inline_message_id, + DROP COLUMN callback_inline_owner_id, + DROP COLUMN callback_inline_dc_id; diff --git a/deploy/migrations/0113_bot_api_inline_callbacks.up.sql b/deploy/migrations/0113_bot_api_inline_callbacks.up.sql new file mode 100644 index 00000000..24c47255 --- /dev/null +++ b/deploy/migrations/0113_bot_api_inline_callbacks.up.sql @@ -0,0 +1,49 @@ +ALTER TABLE public.bot_api_updates + ADD COLUMN callback_inline_dc_id integer NOT NULL DEFAULT 0, + ADD COLUMN callback_inline_owner_id bigint NOT NULL DEFAULT 0, + ADD COLUMN callback_inline_message_id integer NOT NULL DEFAULT 0, + ADD COLUMN callback_inline_access_hash bigint NOT NULL DEFAULT 0; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_callback_shape_check, + DROP CONSTRAINT bot_api_updates_peer_type_check, + DROP CONSTRAINT bot_api_updates_peer_id_check, + DROP CONSTRAINT bot_api_updates_message_id_check; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_peer_type_check CHECK ( + peer_type IN ('user', 'channel') + OR (update_kind = 'callback_query' AND peer_type = '') + ), + ADD CONSTRAINT bot_api_updates_peer_id_check CHECK ( + peer_id > 0 + OR (update_kind = 'callback_query' AND peer_id = 0) + ), + ADD CONSTRAINT bot_api_updates_message_id_check CHECK ( + message_id > 0 + OR (update_kind = 'callback_query' AND message_id = 0) + ), + ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK ( + (update_kind = 'callback_query' + AND callback_query_id <> 0 + AND callback_user_id > 0 + AND callback_chat_instance <> 0 + AND COALESCE(octet_length(callback_data), 0) <= 64 + AND source_pts = 0 + AND ( + (peer_type IN ('user', 'channel') AND peer_id > 0 AND message_id > 0 + AND callback_inline_dc_id = 0 AND callback_inline_owner_id = 0 + AND callback_inline_message_id = 0 AND callback_inline_access_hash = 0) + OR + (peer_type = '' AND peer_id = 0 AND message_id = 0 + AND callback_inline_dc_id > 0 AND callback_inline_owner_id > 0 + AND callback_inline_message_id > 0 AND callback_inline_access_hash <> 0) + )) + OR + (update_kind IN ('message', 'edited_message') + AND peer_type IN ('user', 'channel') AND peer_id > 0 AND message_id > 0 + AND callback_query_id = 0 AND callback_user_id = 0 + AND callback_chat_instance = 0 AND callback_data IS NULL + AND callback_inline_dc_id = 0 AND callback_inline_owner_id = 0 + AND callback_inline_message_id = 0 AND callback_inline_access_hash = 0) + ); diff --git a/deploy/migrations/0115_bot_api_webhooks.down.sql b/deploy/migrations/0115_bot_api_webhooks.down.sql new file mode 100644 index 00000000..ba6b648d --- /dev/null +++ b/deploy/migrations/0115_bot_api_webhooks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.bot_api_webhooks; diff --git a/deploy/migrations/0115_bot_api_webhooks.up.sql b/deploy/migrations/0115_bot_api_webhooks.up.sql new file mode 100644 index 00000000..e7022039 --- /dev/null +++ b/deploy/migrations/0115_bot_api_webhooks.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE public.bot_api_webhooks ( + bot_user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE, + url text NOT NULL, + secret_token varchar(256) NOT NULL DEFAULT '', + max_connections integer NOT NULL DEFAULT 40, + allowed_updates text[], + failure_count integer NOT NULL DEFAULT 0, + last_error_date integer NOT NULL DEFAULT 0, + last_error_message varchar(512) NOT NULL DEFAULT '', + next_attempt_at timestamptz NOT NULL DEFAULT now(), + delivery_owner text NOT NULL DEFAULT '', + delivery_expires_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT bot_api_webhooks_url_check CHECK (length(url) BETWEEN 1 AND 2048), + CONSTRAINT bot_api_webhooks_max_connections_check CHECK (max_connections BETWEEN 1 AND 100), + CONSTRAINT bot_api_webhooks_failure_count_check CHECK (failure_count >= 0), + CONSTRAINT bot_api_webhooks_allowed_updates_check CHECK ( + allowed_updates IS NULL OR array_position(allowed_updates, NULL) IS NULL + ), + CONSTRAINT bot_api_webhooks_delivery_lease_check CHECK ( + (delivery_owner = '' AND delivery_expires_at IS NULL) + OR (delivery_owner <> '' AND delivery_expires_at IS NOT NULL) + ) +); + +CREATE INDEX bot_api_webhooks_due_idx + ON public.bot_api_webhooks (next_attempt_at, bot_user_id); diff --git a/deploy/migrations/0116_bot_requested_peer_filters.down.sql b/deploy/migrations/0116_bot_requested_peer_filters.down.sql new file mode 100644 index 00000000..8b6bf50e --- /dev/null +++ b/deploy/migrations/0116_bot_requested_peer_filters.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.webview_requested_buttons + DROP COLUMN IF EXISTS peer_filter; diff --git a/deploy/migrations/0116_bot_requested_peer_filters.up.sql b/deploy/migrations/0116_bot_requested_peer_filters.up.sql new file mode 100644 index 00000000..c07d6dce --- /dev/null +++ b/deploy/migrations/0116_bot_requested_peer_filters.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.webview_requested_buttons + ADD COLUMN peer_filter jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/deploy/migrations/0119_bot_requested_peer_metadata.down.sql b/deploy/migrations/0119_bot_requested_peer_metadata.down.sql new file mode 100644 index 00000000..d132243e --- /dev/null +++ b/deploy/migrations/0119_bot_requested_peer_metadata.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.webview_requested_buttons + DROP COLUMN IF EXISTS photo_requested, + DROP COLUMN IF EXISTS username_requested, + DROP COLUMN IF EXISTS name_requested; diff --git a/deploy/migrations/0119_bot_requested_peer_metadata.up.sql b/deploy/migrations/0119_bot_requested_peer_metadata.up.sql new file mode 100644 index 00000000..e81cda0e --- /dev/null +++ b/deploy/migrations/0119_bot_requested_peer_metadata.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.webview_requested_buttons + ADD COLUMN name_requested boolean NOT NULL DEFAULT false, + ADD COLUMN username_requested boolean NOT NULL DEFAULT false, + ADD COLUMN photo_requested boolean NOT NULL DEFAULT false; diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index 43330a04..4affbb43 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -1276,6 +1276,9 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send if req.UserID != userID { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.SendChannelMessageResult{}, err + } if req.RandomID != 0 && !req.IdempotencyPreflighted { fingerprint, err := store.ChannelSendFingerprint(req) if err != nil { @@ -1343,6 +1346,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } + if req.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.EditChannelMessageResult{}, err + } + } return s.channels.EditChannelMessage(ctx, req) } @@ -1359,6 +1367,11 @@ func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req dom if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } + if req.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.EditChannelMessageResult{}, err + } + } req.ViaBotEditBotID = botID return s.channels.EditChannelMessage(ctx, req) } diff --git a/internal/app/dialogs/read_model_cache.go b/internal/app/dialogs/read_model_cache.go index 0e9aa838..9f6dcce6 100644 --- a/internal/app/dialogs/read_model_cache.go +++ b/internal/app/dialogs/read_model_cache.go @@ -461,7 +461,14 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa if in == nil { return nil } - out := &domain.MessageReplyMarkup{} + out := &domain.MessageReplyMarkup{ + Type: in.Type, + Resize: in.Resize, + SingleUse: in.SingleUse, + Selective: in.Selective, + Persistent: in.Persistent, + Placeholder: in.Placeholder, + } if len(in.Inline) > 0 { out.Inline = make([][]domain.MarkupButton, len(in.Inline)) for i, row := range in.Inline { @@ -472,6 +479,12 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa } } } + if len(in.Keyboard) > 0 { + out.Keyboard = make([][]domain.MarkupButton, len(in.Keyboard)) + for i, row := range in.Keyboard { + out.Keyboard[i] = append([]domain.MarkupButton(nil), row...) + } + } return out } diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go index b6f74436..2b40afcb 100644 --- a/internal/app/files/photos.go +++ b/internal/app/files/photos.go @@ -98,6 +98,41 @@ func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, e return s.media.GetPhoto(ctx, id) } +type photoBatchStore interface { + GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) +} + +// GetPhotos loads immutable photo metadata in caller order without requiring +// one storage round-trip per requested-peer response. PostgreSQL implements the +// optional batch primitive; lightweight stores retain a bounded fallback. +func (s *Service) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) { + if s == nil || s.media == nil || len(ids) == 0 { + return nil, nil + } + if batch, ok := s.media.(photoBatchStore); ok { + return batch.GetPhotos(ctx, ids) + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]domain.Photo, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + photo, found, err := s.media.GetPhoto(ctx, id) + if err != nil { + return nil, err + } + if found { + out = append(out, photo) + } + } + return out, nil +} + // GetDocument 按 id 返回已存储文档(贴纸 / 文件)。 func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { return s.media.GetDocument(ctx, id) diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index 478b042c..8099b7b5 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -100,6 +100,9 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain. if req.SenderUserID != userID { return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid } + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.SendPrivateTextResult{}, err + } if req.RandomID != 0 && !req.IdempotencyPreflighted { fingerprint, err := store.PrivateSendFingerprint(req) if err != nil { @@ -263,6 +266,32 @@ func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (dom return s.projectMessageUsers(ctx, userID, list) } +type messageByUIDStore interface { + GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) +} + +// GetMessageByUID translates a shared private message id into one owner's exact box row. +// It is intentionally an optional capability so lightweight MessageStore test doubles that +// never exercise callback translation do not need a meaningless implementation. +func (s *Service) GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) { + if s == nil || userID == 0 || uid == 0 { + return domain.Message{}, false, nil + } + provider, ok := s.messages.(messageByUIDStore) + if !ok { + return domain.Message{}, false, nil + } + msg, found, err := provider.GetByUID(ctx, userID, uid) + if err != nil || !found { + return domain.Message{}, found, err + } + list, err := s.projectMessageUsers(ctx, userID, domain.MessageList{Messages: []domain.Message{msg}}) + if err != nil || len(list.Messages) != 1 { + return domain.Message{}, false, err + } + return list.Messages[0], true, nil +} + // GetHistory 返回当前账号某个 peer 的历史消息。 func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { return s.list(ctx, userID, filter) @@ -406,6 +435,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit if req.OwnerUserID == 0 { req.OwnerUserID = userID } + if req.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil { + return domain.EditMessageResult{OwnerUserID: userID}, err + } + } return s.messages.EditMessage(ctx, req) } diff --git a/internal/botapi/inline.go b/internal/botapi/inline.go index 610ad490..42f61344 100644 --- a/internal/botapi/inline.go +++ b/internal/botapi/inline.go @@ -36,7 +36,7 @@ func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) { if err != nil { return domain.BotInlineResult{}, err } - markup, err := replyMarkupFromAPI(payload.ReplyMarkup) + markup, err := inlineReplyMarkupFromAPI(payload.ReplyMarkup) if err != nil { return domain.BotInlineResult{}, err } @@ -165,6 +165,64 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) if len(raw) == 0 || string(raw) == "null" { return nil, nil } + var shape map[string]json.RawMessage + if err := json.Unmarshal(raw, &shape); err != nil { + return nil, errors.New("BUTTON_INVALID") + } + constructors := 0 + for _, key := range []string{"inline_keyboard", "keyboard", "remove_keyboard", "force_reply"} { + if _, ok := shape[key]; ok { + constructors++ + } + } + if constructors != 1 { + return nil, errors.New("BUTTON_INVALID") + } + if _, ok := shape["inline_keyboard"]; ok { + return inlineKeyboardMarkupFromAPI(raw) + } + if _, ok := shape["keyboard"]; ok { + return replyKeyboardMarkupFromAPI(raw) + } + if _, ok := shape["remove_keyboard"]; ok { + var payload apiReplyKeyboardRemove + if err := json.Unmarshal(raw, &payload); err != nil || !payload.RemoveKeyboard { + return nil, errors.New("BUTTON_INVALID") + } + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: payload.Selective} + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + return out, nil + } + var payload apiForceReply + if err := json.Unmarshal(raw, &payload); err != nil || !payload.ForceReply { + return nil, errors.New("BUTTON_INVALID") + } + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupForceReply, + SingleUse: true, + Selective: payload.Selective, + Placeholder: payload.InputFieldPlaceholder, + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + return out, nil +} + +func inlineReplyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { + markup, err := replyMarkupFromAPI(raw) + if err != nil || markup == nil { + return markup, err + } + if markup.Kind() != domain.MessageReplyMarkupInline { + return nil, errors.New("BUTTON_INVALID") + } + return markup, nil +} + +func inlineKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { var payload apiInlineKeyboardMarkup if err := json.Unmarshal(raw, &payload); err != nil { return nil, errors.New("BUTTON_INVALID") @@ -172,7 +230,7 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) if len(payload.InlineKeyboard) == 0 { return nil, nil } - out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))} + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))} for _, row := range payload.InlineKeyboard { domainRow := make([]domain.MarkupButton, 0, len(row)) for _, button := range row { @@ -193,19 +251,132 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) return out, nil } -func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) { - if button.URL != "" { - return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil +func replyKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { + var payload apiReplyKeyboardMarkup + if err := json.Unmarshal(raw, &payload); err != nil || len(payload.Keyboard) == 0 { + return nil, errors.New("BUTTON_INVALID") } - if button.CallbackData != nil { - if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen { + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: make([][]domain.MarkupButton, 0, len(payload.Keyboard)), + Resize: payload.ResizeKeyboard, + SingleUse: payload.OneTimeKeyboard, + Selective: payload.Selective, + Persistent: payload.IsPersistent, + Placeholder: payload.InputFieldPlaceholder, + } + for _, row := range payload.Keyboard { + domainRow := make([]domain.MarkupButton, 0, len(row)) + for _, button := range row { + if button.Text == "" { + return nil, errors.New("BUTTON_INVALID") + } + if button.Unsupported { + return nil, errors.New("BUTTON_TYPE_INVALID") + } + style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet) + if err != nil { + return nil, err + } + item := domain.MarkupButton{Type: domain.MarkupButtonText, Text: button.Text, Style: style, IconCustomEmojiID: icon} + switch button.Kind { + case "request_contact": + item.Type = domain.MarkupButtonRequestPhone + case "request_location": + item.Type = domain.MarkupButtonRequestLocation + case "request_poll": + item.Type, item.PollType = domain.MarkupButtonRequestPoll, button.PollType + case "request_users": + item.Type, item.ButtonID, item.RequestPeerType = domain.MarkupButtonRequestPeer, button.RequestID, "user" + item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = button.MaxQuantity, button.RequestName, button.RequestUsername, button.RequestPhoto + item.RequestPeerFilter = button.RequestPeerFilter + case "request_chat": + item.Type, item.ButtonID = domain.MarkupButtonRequestPeer, button.RequestID + if button.ChatIsChannel { + item.RequestPeerType = "broadcast" + } else { + item.RequestPeerType = "chat" + } + item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = 1, button.RequestTitle, button.RequestUsername, button.RequestPhoto + item.RequestPeerFilter = button.RequestPeerFilter + case "web_app": + item.Type, item.URL = domain.MarkupButtonSimpleWebView, button.WebAppURL + } + domainRow = append(domainRow, item) + } + out.Keyboard = append(out.Keyboard, domainRow) + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + return out, nil +} + +func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) { + if button.Unsupported { + return domain.MarkupButton{}, errors.New("BUTTON_TYPE_INVALID") + } + constructors := 0 + if button.URLSet { + constructors++ + } + if button.CallbackDataSet { + constructors++ + } + if button.WebAppSet { + constructors++ + } + if button.SwitchInlineSet { + constructors++ + } + if button.CopyTextSet { + constructors++ + } + if constructors != 1 { + return domain.MarkupButton{}, errors.New("BUTTON_INVALID") + } + style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet) + if err != nil { + return domain.MarkupButton{}, err + } + if button.URLSet { + return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil + } + if button.CallbackDataSet { + if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen { return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID") } - return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil + return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(button.CallbackData), Style: style, IconCustomEmojiID: icon}, nil + } + if button.WebAppSet { + return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: button.Text, URL: button.WebAppURL, Style: style, IconCustomEmojiID: icon}, nil + } + if button.SwitchInlineSet { + return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: button.Text, Query: button.SwitchInlineQuery, SamePeer: button.SwitchInlineSamePeer, PeerTypes: append([]string(nil), button.SwitchInlinePeerTypes...), Style: style, IconCustomEmojiID: icon}, nil + } + if button.CopyTextSet { + return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: button.Text, CopyText: button.CopyText, Style: style, IconCustomEmojiID: icon}, nil } return domain.MarkupButton{}, errors.New("BUTTON_INVALID") } +func markupButtonDecorationFromAPI(rawStyle, rawIcon string, iconSet bool) (domain.MarkupButtonStyle, int64, error) { + style := domain.MarkupButtonStyle(strings.TrimSpace(rawStyle)) + switch style { + case "", domain.MarkupButtonStylePrimary, domain.MarkupButtonStyleDanger, domain.MarkupButtonStyleSuccess: + default: + return "", 0, errors.New("BUTTON_INVALID") + } + if !iconSet { + return style, 0, nil + } + icon, err := strconv.ParseInt(strings.TrimSpace(rawIcon), 10, 64) + if err != nil || icon <= 0 { + return "", 0, errors.New("BUTTON_INVALID") + } + return style, icon, nil +} + func replyMarkupErrFromDomain(err error) error { switch { case errors.Is(err, domain.ErrButtonURLInvalid): @@ -287,8 +458,342 @@ type apiInlineKeyboardMarkup struct { InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"` } -type apiInlineKeyboardButton struct { - Text string `json:"text"` - URL string `json:"url"` - CallbackData *string `json:"callback_data"` +type apiReplyKeyboardMarkup struct { + Keyboard [][]apiKeyboardButton `json:"keyboard"` + IsPersistent bool `json:"is_persistent"` + ResizeKeyboard bool `json:"resize_keyboard"` + OneTimeKeyboard bool `json:"one_time_keyboard"` + InputFieldPlaceholder string `json:"input_field_placeholder"` + Selective bool `json:"selective"` +} + +type apiKeyboardButton struct { + Text string + Style string + IconCustomEmojiID string + IconCustomEmojiIDSet bool + Unsupported bool + Kind string + PollType string + RequestID int + MaxQuantity int + RequestName bool + RequestUsername bool + RequestPhoto bool + RequestTitle bool + ChatIsChannel bool + WebAppURL string + RequestPeerFilter *domain.BotRequestPeerFilter +} + +type apiChatAdministratorRights struct { + IsAnonymous bool `json:"is_anonymous"` + CanManageChat bool `json:"can_manage_chat"` + CanDeleteMessages bool `json:"can_delete_messages"` + CanManageVideoChats bool `json:"can_manage_video_chats"` + CanRestrictMembers bool `json:"can_restrict_members"` + CanPromoteMembers bool `json:"can_promote_members"` + CanChangeInfo bool `json:"can_change_info"` + CanInviteUsers bool `json:"can_invite_users"` + CanPostStories bool `json:"can_post_stories"` + CanEditStories bool `json:"can_edit_stories"` + CanDeleteStories bool `json:"can_delete_stories"` + CanPostMessages bool `json:"can_post_messages"` + CanEditMessages bool `json:"can_edit_messages"` + CanPinMessages bool `json:"can_pin_messages"` + CanManageTopics bool `json:"can_manage_topics"` + CanManageDirectMessages bool `json:"can_manage_direct_messages"` +} + +func domainRequestAdminRights(in *apiChatAdministratorRights) *domain.BotRequestAdminRights { + if in == nil { + return nil + } + return &domain.BotRequestAdminRights{ + Anonymous: in.IsAnonymous, ManageChat: in.CanManageChat, DeleteMessages: in.CanDeleteMessages, + ManageVideoChats: in.CanManageVideoChats, RestrictMembers: in.CanRestrictMembers, + PromoteMembers: in.CanPromoteMembers, ChangeInfo: in.CanChangeInfo, InviteUsers: in.CanInviteUsers, + PostStories: in.CanPostStories, EditStories: in.CanEditStories, DeleteStories: in.CanDeleteStories, + PostMessages: in.CanPostMessages, EditMessages: in.CanEditMessages, PinMessages: in.CanPinMessages, + ManageTopics: in.CanManageTopics, ManageDirectMessages: in.CanManageDirectMessages, + } +} + +func (b *apiKeyboardButton) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, "\"") { + b.Kind = "text" + return json.Unmarshal([]byte(trimmed), &b.Text) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + text, ok := fields["text"] + if !ok || json.Unmarshal(text, &b.Text) != nil { + return errors.New("invalid keyboard button text") + } + if raw, ok := fields["style"]; ok { + if err := json.Unmarshal(raw, &b.Style); err != nil { + return err + } + } + if raw, ok := fields["icon_custom_emoji_id"]; ok { + b.IconCustomEmojiIDSet = true + if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil { + return err + } + } + actions := 0 + if raw, ok := fields["request_contact"]; ok { + var enabled bool + if json.Unmarshal(raw, &enabled) != nil || !enabled { + b.Unsupported = true + } else { + b.Kind = "request_contact" + actions++ + } + } + if raw, ok := fields["request_location"]; ok { + var enabled bool + if json.Unmarshal(raw, &enabled) != nil || !enabled { + b.Unsupported = true + } else { + b.Kind = "request_location" + actions++ + } + } + if raw, ok := fields["request_poll"]; ok { + var poll struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &poll) != nil { + b.Unsupported = true + } else { + b.Kind, b.PollType = "request_poll", poll.Type + actions++ + } + } + if raw, ok := fields["request_users"]; ok { + var request struct { + RequestID int `json:"request_id"` + UserIsBot *bool `json:"user_is_bot"` + UserIsPremium *bool `json:"user_is_premium"` + MaxQuantity int `json:"max_quantity"` + RequestName bool `json:"request_name"` + RequestUsername bool `json:"request_username"` + RequestPhoto bool `json:"request_photo"` + } + if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 { + b.Unsupported = true + } else { + b.Kind, b.RequestID, b.MaxQuantity, b.RequestName, b.RequestUsername, b.RequestPhoto = "request_users", request.RequestID, request.MaxQuantity, request.RequestName, request.RequestUsername, request.RequestPhoto + b.RequestPeerFilter = &domain.BotRequestPeerFilter{} + if request.UserIsBot != nil { + b.RequestPeerFilter.UserIsBotSet, b.RequestPeerFilter.UserIsBot = true, *request.UserIsBot + } + if request.UserIsPremium != nil { + b.RequestPeerFilter.UserIsPremiumSet, b.RequestPeerFilter.UserIsPremium = true, *request.UserIsPremium + } + if b.MaxQuantity == 0 { + b.MaxQuantity = 1 + } + actions++ + } + } + if raw, ok := fields["request_chat"]; ok { + var request struct { + RequestID int `json:"request_id"` + ChatIsChannel bool `json:"chat_is_channel"` + ChatIsForum *bool `json:"chat_is_forum"` + ChatHasUsername *bool `json:"chat_has_username"` + ChatIsCreated bool `json:"chat_is_created"` + UserAdministratorRights *apiChatAdministratorRights `json:"user_administrator_rights"` + BotAdministratorRights *apiChatAdministratorRights `json:"bot_administrator_rights"` + BotIsMember bool `json:"bot_is_member"` + RequestTitle bool `json:"request_title"` + RequestUsername bool `json:"request_username"` + RequestPhoto bool `json:"request_photo"` + } + if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 || (request.ChatIsChannel && (request.ChatIsForum != nil || request.BotIsMember)) { + b.Unsupported = true + } else { + b.Kind, b.RequestID, b.ChatIsChannel, b.RequestTitle, b.RequestUsername, b.RequestPhoto = "request_chat", request.RequestID, request.ChatIsChannel, request.RequestTitle, request.RequestUsername, request.RequestPhoto + b.RequestPeerFilter = &domain.BotRequestPeerFilter{ + ChatIsCreated: request.ChatIsCreated, BotIsMember: request.BotIsMember, + UserAdminRights: domainRequestAdminRights(request.UserAdministratorRights), + BotAdminRights: domainRequestAdminRights(request.BotAdministratorRights), + } + if request.ChatIsForum != nil { + b.RequestPeerFilter.ChatIsForumSet, b.RequestPeerFilter.ChatIsForum = true, *request.ChatIsForum + } + if request.ChatHasUsername != nil { + b.RequestPeerFilter.ChatHasUsernameSet, b.RequestPeerFilter.ChatHasUsername = true, *request.ChatHasUsername + } + actions++ + } + } + if raw, ok := fields["web_app"]; ok { + var app struct { + URL string `json:"url"` + } + if json.Unmarshal(raw, &app) != nil { + b.Unsupported = true + } else { + b.Kind, b.WebAppURL = "web_app", app.URL + actions++ + } + } + if actions == 0 { + b.Kind = "text" + } + if actions > 1 { + b.Unsupported = true + } + for key := range fields { + switch key { + case "text", "style", "icon_custom_emoji_id", "request_contact", "request_location", "request_poll", "request_users", "request_chat", "web_app": + default: + b.Unsupported = true + } + } + return nil +} + +type apiReplyKeyboardRemove struct { + RemoveKeyboard bool `json:"remove_keyboard"` + Selective bool `json:"selective"` +} + +type apiForceReply struct { + ForceReply bool `json:"force_reply"` + InputFieldPlaceholder string `json:"input_field_placeholder"` + Selective bool `json:"selective"` +} + +type apiInlineKeyboardButton struct { + Text string + URL string + URLSet bool + CallbackData string + CallbackDataSet bool + Style string + IconCustomEmojiID string + IconCustomEmojiIDSet bool + Unsupported bool + WebAppURL string + WebAppSet bool + SwitchInlineQuery string + SwitchInlineSet bool + SwitchInlineSamePeer bool + SwitchInlinePeerTypes []string + CopyText string + CopyTextSet bool +} + +func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + text, ok := fields["text"] + if !ok || json.Unmarshal(text, &b.Text) != nil { + return errors.New("invalid inline keyboard button text") + } + if raw, ok := fields["url"]; ok { + b.URLSet = true + if err := json.Unmarshal(raw, &b.URL); err != nil { + return err + } + } + if raw, ok := fields["callback_data"]; ok { + b.CallbackDataSet = true + if err := json.Unmarshal(raw, &b.CallbackData); err != nil { + return err + } + } + if raw, ok := fields["web_app"]; ok { + b.WebAppSet = true + var app struct { + URL string `json:"url"` + } + if json.Unmarshal(raw, &app) != nil { + return errors.New("invalid web app") + } + b.WebAppURL = app.URL + } + switchActions := 0 + if raw, ok := fields["switch_inline_query"]; ok { + switchActions++ + b.SwitchInlineSet = true + if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil { + return errors.New("invalid switch inline query") + } + } + if raw, ok := fields["switch_inline_query_current_chat"]; ok { + switchActions++ + b.SwitchInlineSet, b.SwitchInlineSamePeer = true, true + if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil { + return errors.New("invalid switch inline query") + } + } + if raw, ok := fields["switch_inline_query_chosen_chat"]; ok { + switchActions++ + b.SwitchInlineSet = true + var chosen struct { + Query string `json:"query"` + AllowUserChats bool `json:"allow_user_chats"` + AllowBotChats bool `json:"allow_bot_chats"` + AllowGroupChats bool `json:"allow_group_chats"` + AllowChannelChats bool `json:"allow_channel_chats"` + } + if json.Unmarshal(raw, &chosen) != nil { + return errors.New("invalid switch inline query") + } + b.SwitchInlineQuery = chosen.Query + if chosen.AllowUserChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypePM) + } + if chosen.AllowBotChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBotPM) + } + if chosen.AllowGroupChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup) + } + if chosen.AllowChannelChats { + b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBroadcast) + } + } + if switchActions > 1 { + b.Unsupported = true + } + if raw, ok := fields["copy_text"]; ok { + b.CopyTextSet = true + var copy struct { + Text string `json:"text"` + } + if json.Unmarshal(raw, ©) != nil { + return errors.New("invalid copy text") + } + b.CopyText = copy.Text + } + if raw, ok := fields["style"]; ok { + if err := json.Unmarshal(raw, &b.Style); err != nil { + return err + } + } + if raw, ok := fields["icon_custom_emoji_id"]; ok { + b.IconCustomEmojiIDSet = true + if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil { + return err + } + } + for key := range fields { + switch key { + case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id": + default: + b.Unsupported = true + } + } + return nil } diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 5595eddd..707e9dfc 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -2,12 +2,14 @@ package botapi import ( "encoding/base64" + "encoding/binary" "encoding/json" "errors" "strconv" "strings" "telesrv/internal/domain" + "telesrv/internal/store" ) func apiInt(raw string, fallback int) int { @@ -33,33 +35,42 @@ func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) { return messageEntitiesFromAPI(payload) } -func allowedUpdates(raw string) map[string]struct{} { +func parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) { raw = strings.TrimSpace(raw) if raw == "" { - return nil + return nil, errors.New("ALLOWED_UPDATES_INVALID") } var items []string if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil + return nil, errors.New("ALLOWED_UPDATES_INVALID") } - out := make(map[string]struct{}, len(items)) + if len(items) > 100 { + return nil, errors.New("ALLOWED_UPDATES_INVALID") + } + seen := make(map[domain.BotAPIUpdateKind]struct{}, len(items)) + out := make([]domain.BotAPIUpdateKind, 0, len(items)) for _, item := range items { item = strings.TrimSpace(item) - if item != "" { - out[item] = struct{}{} + if item == "" || len(item) > 64 { + return nil, errors.New("ALLOWED_UPDATES_INVALID") + } + kind := domain.BotAPIUpdateKind(item) + if _, ok := seen[kind]; !ok { + seen[kind] = struct{}{} + out = append(out, kind) } } - return out + return out, nil } -func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any { +func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any { if limit <= 0 || limit > 100 { limit = 100 } out := make([]map[string]any, 0, min(len(events), limit)) for _, event := range events { - item, kind, ok := apiUpdate(event) - if !ok || !updateAllowed(kind, allowed) { + item, _, ok := apiUpdate(event) + if !ok { continue } out = append(out, item) @@ -73,14 +84,6 @@ func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit return out } -func updateAllowed(kind string, allowed map[string]struct{}) bool { - if len(allowed) == 0 { - return true - } - _, ok := allowed[kind] - return ok -} - func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { if event.Pts <= 0 { return nil, "", false @@ -92,7 +95,7 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { } return map[string]any{ "update_id": event.Pts, - "message": apiMessage(event.Message, event.Users), + "message": apiMessage(event.Message, event.Users, event.Channels), }, "message", true case domain.UpdateEventEditMessage: if !apiMessageProjectable(event.Message) { @@ -100,18 +103,90 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { } return map[string]any{ "update_id": event.Pts, - "edited_message": apiMessage(event.Message, event.Users), + "edited_message": apiMessage(event.Message, event.Users, event.Channels), }, "edited_message", true + case domain.UpdateEventBotCallbackQuery: + callback := event.BotCallbackQuery + if callback == nil || callback.ID == 0 || callback.UserID == 0 { + return nil, "", false + } + var from domain.User + for _, user := range event.Users { + if user.ID == callback.UserID { + from = user + break + } + } + if from.ID == 0 { + from = domain.User{ID: callback.UserID} + } + query := map[string]any{ + "id": strconv.FormatInt(callback.ID, 10), + "from": apiUser(from), + "chat_instance": strconv.FormatInt(callback.ChatInstance, 10), + "data": string(callback.Data), + } + if callback.InlineMessage != nil { + inlineMessageID, ok := encodeBotAPIInlineMessageID(*callback.InlineMessage) + if !ok || callback.MessageID != 0 || callback.Peer != (domain.Peer{}) { + return nil, "", false + } + query["inline_message_id"] = inlineMessageID + } else { + if callback.MessageID <= 0 || event.Message.ID != callback.MessageID { + return nil, "", false + } + query["message"] = apiMessage(event.Message, event.Users, event.Channels) + } + return map[string]any{ + "update_id": event.Pts, + "callback_query": query, + }, "callback_query", true default: return nil, "", false } } +const botAPIInlineMessageIDVersion byte = 1 + +// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an +// opaque, fixed-size Bot API token. AccessHash remains the authorization boundary; the +// version byte lets us reject rather than reinterpret future shapes. +func encodeBotAPIInlineMessageID(id domain.BotInlineMessageID) (string, bool) { + if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 { + return "", false + } + buf := make([]byte, 1+4+8+4+8) + buf[0] = botAPIInlineMessageIDVersion + binary.LittleEndian.PutUint32(buf[1:5], uint32(id.DCID)) + binary.LittleEndian.PutUint64(buf[5:13], uint64(id.OwnerID)) + binary.LittleEndian.PutUint32(buf[13:17], uint32(id.ID)) + binary.LittleEndian.PutUint64(buf[17:25], uint64(id.AccessHash)) + return base64.RawURLEncoding.EncodeToString(buf), true +} + +func decodeBotAPIInlineMessageID(raw string) (domain.BotInlineMessageID, error) { + buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil || len(buf) != 25 || buf[0] != botAPIInlineMessageIDVersion { + return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID") + } + id := domain.BotInlineMessageID{ + DCID: int(binary.LittleEndian.Uint32(buf[1:5])), + OwnerID: int64(binary.LittleEndian.Uint64(buf[5:13])), + ID: int(binary.LittleEndian.Uint32(buf[13:17])), + AccessHash: int64(binary.LittleEndian.Uint64(buf[17:25])), + } + if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 { + return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID") + } + return id, nil +} + func apiMessageProjectable(msg domain.Message) bool { if msg.Out || msg.ID <= 0 { return false } - return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0 + return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0 } func apiUser(u domain.User) map[string]any { @@ -133,11 +208,17 @@ func apiUser(u domain.User) map[string]any { return out } -func apiMessage(msg domain.Message, users []domain.User) map[string]any { +func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domain.Channel) map[string]any { userByID := map[int64]domain.User{} for _, u := range users { userByID[u.ID] = u } + channelByID := map[int64]domain.Channel{} + if len(channelLists) > 0 { + for _, channel := range channelLists[0] { + channelByID[channel.ID] = channel + } + } out := map[string]any{ "message_id": msg.ID, "date": msg.Date, @@ -153,17 +234,25 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any { } out["from"] = apiUser(from) } - media := apiMessageMedia(msg.Media) + media := apiMessageMedia(msg.Media, userByID, channelByID) if msg.Body != "" { - if len(media) > 0 { + if _, photo := media["photo"]; photo { out["caption"] = msg.Body + } else if _, document := media["document"]; document { + out["caption"] = msg.Body + } else if poll, ok := media["poll"].(map[string]any); ok { + poll["description"] = msg.Body } else { out["text"] = msg.Body } } if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 { - if len(media) > 0 { + if _, photo := media["photo"]; photo { out["caption_entities"] = entities + } else if _, document := media["document"]; document { + out["caption_entities"] = entities + } else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" { + poll["description_entities"] = entities } else { out["entities"] = entities } @@ -309,6 +398,11 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { if markup.IsZero() { return nil } + // Bot API Message.reply_markup is InlineKeyboardMarkup only. ReplyKeyboardMarkup, + // ReplyKeyboardRemove and ForceReply are send parameters, not message response fields. + if markup.Kind() != domain.MessageReplyMarkupInline { + return nil + } rows := make([][]map[string]any, 0, len(markup.Inline)) for _, row := range markup.Inline { if len(row) == 0 { @@ -317,11 +411,43 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { apiRow := make([]map[string]any, 0, len(row)) for _, button := range row { item := map[string]any{"text": button.Text} + if button.Style != "" { + item["style"] = string(button.Style) + } + if button.IconCustomEmojiID > 0 { + item["icon_custom_emoji_id"] = strconv.FormatInt(button.IconCustomEmojiID, 10) + } switch button.Type { case domain.MarkupButtonURL: item["url"] = button.URL case domain.MarkupButtonCallback: item["callback_data"] = string(button.Data) + case domain.MarkupButtonWebView: + item["web_app"] = map[string]any{"url": button.URL} + case domain.MarkupButtonSwitchInline: + switch { + case button.SamePeer: + item["switch_inline_query_current_chat"] = button.Query + case len(button.PeerTypes) > 0: + chosen := map[string]any{"query": button.Query} + for _, peerType := range button.PeerTypes { + switch peerType { + case store.InlineQueryPeerTypePM: + chosen["allow_user_chats"] = true + case store.InlineQueryPeerTypeBotPM: + chosen["allow_bot_chats"] = true + case store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup: + chosen["allow_group_chats"] = true + case store.InlineQueryPeerTypeBroadcast: + chosen["allow_channel_chats"] = true + } + } + item["switch_inline_query_chosen_chat"] = chosen + default: + item["switch_inline_query"] = button.Query + } + case domain.MarkupButtonCopy: + item["copy_text"] = map[string]any{"text": button.CopyText} default: continue } @@ -337,7 +463,7 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any { return map[string]any{"inline_keyboard": rows} } -func apiMessageMedia(media *domain.MessageMedia) map[string]any { +func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, channels map[int64]domain.Channel) map[string]any { if media.IsZero() { return nil } @@ -356,11 +482,264 @@ func apiMessageMedia(media *domain.MessageMedia) map[string]any { return nil } return map[string]any{"document": apiDocument(*media.Document)} + case domain.MessageMediaKindContact: + if media.Contact == nil { + return nil + } + contact := map[string]any{ + "phone_number": media.Contact.PhoneNumber, + "first_name": media.Contact.FirstName, + } + if media.Contact.LastName != "" { + contact["last_name"] = media.Contact.LastName + } + if media.Contact.Vcard != "" { + contact["vcard"] = media.Contact.Vcard + } + if media.Contact.UserID != 0 { + contact["user_id"] = media.Contact.UserID + } + return map[string]any{"contact": contact} + case domain.MessageMediaKindGeo: + if media.Geo == nil { + return nil + } + return map[string]any{"location": apiLocation(*media.Geo, nil)} + case domain.MessageMediaKindVenue: + if media.Venue == nil { + return nil + } + return map[string]any{"venue": apiVenue(*media.Venue)} + case domain.MessageMediaKindGeoLive: + if media.GeoLive == nil { + return nil + } + return map[string]any{"location": apiLocation(media.GeoLive.Geo, media.GeoLive)} + case domain.MessageMediaKindPoll: + if media.Poll == nil { + return nil + } + return map[string]any{"poll": apiPoll(*media.Poll, users)} + case domain.MessageMediaKindService: + if media.ServiceAction == nil { + return nil + } + switch media.ServiceAction.Kind { + case domain.MessageServiceActionWebViewDataSent: + if media.ServiceAction.WebViewData == nil { + return nil + } + return map[string]any{"web_app_data": map[string]any{ + "data": media.ServiceAction.WebViewData.Data, "button_text": media.ServiceAction.WebViewData.ButtonText, + }} + case domain.MessageServiceActionRequestedPeer: + return apiRequestedPeer(media.ServiceAction.RequestedPeer, users, channels) + default: + return nil + } default: return nil } } +func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any { + out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long} + if geo.AccuracyRadius > 0 { + out["horizontal_accuracy"] = float64(geo.AccuracyRadius) + } + if live != nil { + if live.Period > 0 { + out["live_period"] = live.Period + } + if live.Heading > 0 { + out["heading"] = live.Heading + } + if live.ProximityNotificationRadius > 0 { + out["proximity_alert_radius"] = live.ProximityNotificationRadius + } + } + return out +} + +func apiVenue(venue domain.MessageVenue) map[string]any { + out := map[string]any{ + "location": apiLocation(venue.Geo, nil), "title": venue.Title, "address": venue.Address, + } + switch strings.ToLower(venue.Provider) { + case "foursquare": + if venue.VenueID != "" { + out["foursquare_id"] = venue.VenueID + } + if venue.VenueType != "" { + out["foursquare_type"] = venue.VenueType + } + case "gplaces", "google": + if venue.VenueID != "" { + out["google_place_id"] = venue.VenueID + } + if venue.VenueType != "" { + out["google_place_type"] = venue.VenueType + } + } + return out +} + +func apiPoll(poll domain.MessagePoll, users map[int64]domain.User) map[string]any { + resultByOption := make(map[string]domain.MessagePollAnswerVoters) + totalVoters := 0 + if poll.Results != nil { + totalVoters = poll.Results.TotalVoters + for _, result := range poll.Results.Voters { + resultByOption[string(result.Option)] = result + } + } + options := make([]map[string]any, 0, len(poll.Answers)) + correct := make([]int, 0, len(poll.Answers)) + for index, answer := range poll.Answers { + persistentID := base64.RawURLEncoding.EncodeToString(answer.Option) + if persistentID == "" { + persistentID = strconv.Itoa(index) + } + result := resultByOption[string(answer.Option)] + option := map[string]any{ + "persistent_id": persistentID, "text": answer.Text, "voter_count": result.Voters, + } + if entities := apiMessageEntities(answer.Entities, users); len(entities) > 0 { + option["text_entities"] = entities + } + if answer.Media != nil { + if projected := apiPollMedia(answer.Media); len(projected) > 0 { + option["media"] = projected + } + } + if result.Correct { + correct = append(correct, index) + } + options = append(options, option) + } + pollType := "regular" + if poll.Quiz { + pollType = "quiz" + } + out := map[string]any{ + "id": strconv.FormatInt(poll.ID, 10), "question": poll.Question, + "options": options, "total_voter_count": totalVoters, "is_closed": poll.Closed, + "is_anonymous": !poll.PublicVoters, "type": pollType, + "allows_multiple_answers": poll.MultipleChoice, "allows_revoting": !poll.RevotingDisabled, + } + if entities := apiMessageEntities(poll.QuestionEntities, users); len(entities) > 0 { + out["question_entities"] = entities + } + if len(correct) > 0 { + out["correct_option_ids"] = correct + } + if poll.Results != nil && poll.Results.Solution != "" { + out["explanation"] = poll.Results.Solution + if entities := apiMessageEntities(poll.Results.SolutionEntities, users); len(entities) > 0 { + out["explanation_entities"] = entities + } + } + if poll.ClosePeriod > 0 { + out["open_period"] = poll.ClosePeriod + } + if poll.CloseDate > 0 { + out["close_date"] = poll.CloseDate + } + if poll.AttachedMedia != nil { + if projected := apiPollMedia(poll.AttachedMedia); len(projected) > 0 { + out["media"] = projected + } + } + return out +} + +func apiPollMedia(media *domain.MessageMedia) map[string]any { + if media.IsZero() { + return nil + } + switch media.Kind { + case domain.MessageMediaKindPhoto: + if media.Photo != nil { + if sizes := apiPhotoSizes(*media.Photo); len(sizes) > 0 { + return map[string]any{"photo": sizes} + } + } + case domain.MessageMediaKindDocument: + if media.Document != nil { + return map[string]any{"document": apiDocument(*media.Document)} + } + case domain.MessageMediaKindGeo: + if media.Geo != nil { + return map[string]any{"location": apiLocation(*media.Geo, nil)} + } + case domain.MessageMediaKindVenue: + if media.Venue != nil { + return map[string]any{"venue": apiVenue(*media.Venue)} + } + } + return nil +} + +func apiRequestedPeer(action *domain.MessageRequestedPeerAction, _ map[int64]domain.User, _ map[int64]domain.Channel) map[string]any { + if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 { + return nil + } + allUsers := true + details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details)) + for _, detail := range action.Details { + details[detail.Peer] = detail + } + for _, peer := range action.Peers { + if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return nil + } + allUsers = allUsers && peer.Type == domain.PeerTypeUser + } + if allUsers { + shared := make([]map[string]any, 0, len(action.Peers)) + for _, peer := range action.Peers { + item := map[string]any{"user_id": peer.ID} + detail := details[peer] + if action.NameRequested { + if detail.FirstName != "" { + item["first_name"] = detail.FirstName + } + if detail.LastName != "" { + item["last_name"] = detail.LastName + } + } + if action.UsernameRequested && detail.Username != "" { + item["username"] = detail.Username + } + if action.PhotoRequested && detail.Photo != nil { + if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 { + item["photo"] = photo + } + } + shared = append(shared, item) + } + return map[string]any{"users_shared": map[string]any{"request_id": action.ButtonID, "users": shared}} + } + if len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeChannel { + return nil + } + peer := action.Peers[0] + shared := map[string]any{"request_id": action.ButtonID, "chat_id": -1000000000000 - peer.ID} + detail := details[peer] + if action.NameRequested && detail.Title != "" { + shared["title"] = detail.Title + } + if action.UsernameRequested && detail.Username != "" { + shared["username"] = detail.Username + } + if action.PhotoRequested && detail.Photo != nil { + if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 { + shared["photo"] = photo + } + } + return map[string]any{"chat_shared": shared} +} + func apiPhotoSizes(photo domain.Photo) []map[string]any { return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":") } diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 8ffae5b2..f4219fd9 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -10,8 +10,10 @@ import ( "io" "net" "net/http" + neturl "net/url" "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -41,6 +43,7 @@ type GatewayService interface { BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error) BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) + BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error) BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error) @@ -51,6 +54,29 @@ type GatewayUpdateWaiter interface { WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool } +type GatewayUpdateControl interface { + BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error + BotAPIDropPendingUpdates(ctx context.Context, botID int64) error + BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) +} + +type GatewayPollLease interface { + AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error +} + +type GatewayWebhookControl interface { + BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error + BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error + BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) + ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) + AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error + RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error + RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error + ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error +} + func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, gateway GatewayService, logger *zap.Logger) (*http.Server, error) { if strings.TrimSpace(addr) == "" { return nil, nil @@ -58,7 +84,7 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic if logger == nil { logger = zap.NewNop() } - handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger} + handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger, webhookClient: newWebhookHTTPClient()} srv := &http.Server{ Addr: addr, Handler: handler.routes(), @@ -76,6 +102,9 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic logger.Warn("Bot API 网关退出", zap.Error(err)) } }() + if webhooks, ok := gateway.(GatewayWebhookControl); ok { + go runWebhookDispatcher(ctx, webhooks, gateway, handler.webhookClient, logger.Named("webhook")) + } go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -86,11 +115,37 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic } type handler struct { - bots BotsService - users UsersService - webapps WebAppService - gateway GatewayService - logger *zap.Logger + bots BotsService + users UsersService + webapps WebAppService + gateway GatewayService + logger *zap.Logger + polls botAPIPollRegistry + webhookClient *http.Client +} + +type botAPIPollRegistry struct { + mu sync.Mutex + active map[int64]struct{} +} + +func (p *botAPIPollRegistry) acquire(botID int64) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.active == nil { + p.active = make(map[int64]struct{}) + } + if _, exists := p.active[botID]; exists { + return false + } + p.active[botID] = struct{}{} + return true +} + +func (p *botAPIPollRegistry) release(botID int64) { + p.mu.Lock() + delete(p.active, botID) + p.mu.Unlock() } const ( @@ -148,11 +203,11 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) { case "getfile": h.getFile(w, r, botID) case "deletewebhook": - writeAPIOK(w, true) + h.deleteWebhook(w, r, botID) case "getwebhookinfo": - writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0}) + h.getWebhookInfo(w, r, botID) case "setwebhook": - h.setWebhook(w, r) + h.setWebhook(w, r, botID) case "setchatmenubutton": h.setChatMenuButton(w, r, botID) case "getchatmenubutton": @@ -216,7 +271,14 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") return } - offset, _ := strconv.ParseInt(strings.TrimSpace(values["offset"]), 10, 64) + var offset int64 + if raw := strings.TrimSpace(values["offset"]); raw != "" { + offset, err = strconv.ParseInt(raw, 10, 64) + if err != nil || offset < -10000 { + writeAPIError(w, http.StatusBadRequest, "OFFSET_INVALID") + return + } + } limit := apiInt(values["limit"], 100) if limit <= 0 { limit = 100 @@ -231,7 +293,56 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 if timeoutSeconds > 50 { timeoutSeconds = 50 } - allowed := allowedUpdates(values["allowed_updates"]) + if !h.polls.acquire(botID) { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer h.polls.release(botID) + if leases, ok := h.gateway.(GatewayPollLease); ok { + owner := randomBotAPIOwner() + leaseTTL := time.Duration(timeoutSeconds)*time.Second + 30*time.Second + acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, leaseTTL) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner); err != nil { + h.logger.Warn("release bot api poll lease", zap.Int64("bot_user_id", botID), zap.Error(err)) + } + }() + } + if webhooks, ok := h.gateway.(GatewayWebhookControl); ok { + if _, configured, err := webhooks.BotAPIWebhook(r.Context(), botID); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } else if configured { + writeAPIError(w, http.StatusConflict, "CONFLICT: can't use getUpdates method while webhook is active") + return + } + } + if raw, present := values["allowed_updates"]; present { + allowed, err := parseAllowedUpdates(raw) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + control, ok := h.gateway.(GatewayUpdateControl) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "ALLOWED_UPDATES_UNSUPPORTED") + return + } + if err := control.BotAPISetAllowedUpdates(r.Context(), botID, allowed); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + } deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second) for { version := botAPIUpdateWaitVersion(h.gateway, botID) @@ -240,7 +351,7 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") return } - updates := apiUpdates(events, allowed, limit) + updates := apiUpdates(events, limit) if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) { writeAPIOK(w, updates) return @@ -249,6 +360,84 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64 } } +func randomBotAPIOwner() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err == nil { + return fmt.Sprintf("%x", raw[:]) + } + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) +} + +func (h *handler) deleteWebhook(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + control, ok := h.gateway.(GatewayWebhookControl) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED") + return + } + leaseOwner := randomBotAPIOwner() + if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } else if found { + acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, leaseOwner, 30*time.Second) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active") + return + } + defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, leaseOwner) }() + } + if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, true) +} + +func (h *handler) getWebhookInfo(w http.ResponseWriter, r *http.Request, botID int64) { + pending := 0 + if control, ok := h.gateway.(GatewayUpdateControl); ok { + var err error + pending, err = control.BotAPIPendingUpdateCount(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + } + result := map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": pending} + if control, ok := h.gateway.(GatewayWebhookControl); ok { + config, found, err := control.BotAPIWebhook(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if found { + result["url"] = config.URL + result["max_connections"] = config.MaxConnections + if config.AllowedUpdates != nil { + allowed := make([]string, 0, len(config.AllowedUpdates)) + for _, kind := range config.AllowedUpdates { + allowed = append(allowed, string(kind)) + } + result["allowed_updates"] = allowed + } + if config.LastErrorDate > 0 { + result["last_error_date"] = config.LastErrorDate + result["last_error_message"] = config.LastErrorMessage + } + } + } + writeAPIOK(w, result) +} + func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 { waiter, ok := gateway.(GatewayUpdateWaiter) if !ok { @@ -381,14 +570,22 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") return } - chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) - if err != nil || chatID == 0 { - writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") - return - } - messageID := apiInt(values["message_id"], 0) - if messageID <= 0 { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID") + rawInlineID := strings.TrimSpace(values["inline_message_id"]) + var chatID int64 + messageID := 0 + if rawInlineID == "" { + chatID, err = strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) + if err != nil || chatID == 0 { + writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") + return + } + messageID = apiInt(values["message_id"], 0) + if messageID <= 0 { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID") + return + } + } else if strings.TrimSpace(values["chat_id"]) != "" || strings.TrimSpace(values["message_id"]) != "" { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID") return } if strings.TrimSpace(values["parse_mode"]) != "" { @@ -403,12 +600,26 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID var markup *domain.MessageReplyMarkup _, setReplyMarkup := values["reply_markup"] if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { - markup, err = replyMarkupFromAPI(json.RawMessage(raw)) + markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw)) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return } } + if rawInlineID != "" { + inlineID, err := decodeBotAPIInlineMessageID(rawInlineID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, ok) + return + } msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) @@ -557,17 +768,136 @@ func (h *handler) downloadFile(w http.ResponseWriter, r *http.Request) { } } -func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request) { - values, err := requestValues(r) +func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request, botID int64) { + values, files, err := requestValuesWithFiles(r) if err != nil { writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") return } - if strings.TrimSpace(values["url"]) == "" { + control, ok := h.gateway.(GatewayWebhookControl) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED") + return + } + rawURL := strings.TrimSpace(values["url"]) + if rawURL == "" { + if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } writeAPIOK(w, true) return } - writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_NOT_IMPLEMENTED") + if err := validateWebhookURL(rawURL); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if strings.TrimSpace(values["certificate"]) != "" || len(files) != 0 { + writeAPIError(w, http.StatusBadRequest, "CERTIFICATE_PINNING_UNSUPPORTED") + return + } + if strings.TrimSpace(values["ip_address"]) != "" { + writeAPIError(w, http.StatusBadRequest, "IP_ADDRESS_UNSUPPORTED") + return + } + secret := strings.TrimSpace(values["secret_token"]) + if !validWebhookSecret(secret) { + writeAPIError(w, http.StatusBadRequest, "SECRET_TOKEN_INVALID") + return + } + maxConnections := apiInt(values["max_connections"], 40) + if maxConnections < 1 || maxConnections > 100 { + writeAPIError(w, http.StatusBadRequest, "MAX_CONNECTIONS_INVALID") + return + } + var allowed []domain.BotAPIUpdateKind + _, allowedUpdatesSet := values["allowed_updates"] + if raw, present := values["allowed_updates"]; present { + allowed, err = parseAllowedUpdates(raw) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if len(allowed) == 0 { + allowed = nil + } + } + if !h.polls.acquire(botID) { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer h.polls.release(botID) + if leases, ok := h.gateway.(GatewayPollLease); ok { + owner := randomBotAPIOwner() + acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, 30*time.Second) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active") + return + } + defer func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner) + }() + } + webhookOwner := randomBotAPIOwner() + if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } else if found { + acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, webhookOwner, 30*time.Second) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !acquired { + writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active") + return + } + defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, webhookOwner) }() + } + if err := control.BotAPISetWebhook(r.Context(), domain.BotAPIWebhook{ + BotUserID: botID, URL: rawURL, SecretToken: secret, + MaxConnections: maxConnections, AllowedUpdates: allowed, AllowedUpdatesSet: allowedUpdatesSet, + }, apiBool(values["drop_pending_updates"])); err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, true) +} + +func validateWebhookURL(raw string) error { + if len(raw) > 2048 { + return errors.New("WEBHOOK_URL_INVALID") + } + u, err := neturl.ParseRequestURI(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { + return errors.New("WEBHOOK_URL_INVALID") + } + if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" { + return errors.New("WEBHOOK_PORT_NOT_ALLOWED") + } + return nil +} + +func validWebhookSecret(secret string) bool { + if secret == "" { + return true + } + if len(secret) > 256 { + return false + } + for _, r := range secret { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + continue + } + return false + } + return true } func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) { diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index c6039595..b9d6ba7f 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -10,6 +10,7 @@ import ( "reflect" "strings" "testing" + "time" "telesrv/internal/domain" "telesrv/internal/store" @@ -328,6 +329,312 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) { } } +func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + markup := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}, {Type: domain.MarkupButtonText, Text: "Status"}}}, + Resize: true, + SingleUse: true, + Persistent: true, + Placeholder: "Choose an action", + } + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true}, + sendMessage: domain.Message{ + ID: 10, OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, + Date: 1700000003, Body: "pick", Out: true, ReplyMarkup: markup, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{ + "chat_id":2001, + "text":"pick", + "reply_markup":{ + "keyboard":[["Help",{"text":"Status"}]], + "resize_keyboard":true, + "one_time_keyboard":true, + "is_persistent":true, + "input_field_placeholder":"Choose an action" + } + }`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if gateway.sendMarkup == nil || gateway.sendMarkup.Kind() != domain.MessageReplyMarkupKeyboard || + len(gateway.sendMarkup.Keyboard) != 1 || len(gateway.sendMarkup.Keyboard[0]) != 2 || + gateway.sendMarkup.Keyboard[0][0].Text != "Help" || !gateway.sendMarkup.Resize || + !gateway.sendMarkup.SingleUse || !gateway.sendMarkup.Persistent || gateway.sendMarkup.Placeholder != "Choose an action" { + t.Fatalf("gateway reply keyboard = %#v", gateway.sendMarkup) + } + var resp struct { + OK bool `json:"ok"` + Result struct { + ReplyMarkup json.RawMessage `json:"reply_markup"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + // Bot API Message.reply_markup only contains InlineKeyboardMarkup; reply keyboards are + // accepted send parameters but are deliberately absent from the returned Message object. + if !resp.OK || len(resp.Result.ReplyMarkup) != 0 { + t.Fatalf("reply keyboard response = %s", rec.Body.String()) + } +} + +func TestGetUpdatesProjectsCallbackQuery(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + callback := &domain.BotCallbackQuery{ + ID: 123456, BotUserID: 1001, UserID: 2001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, MessageID: 9, + ChatInstance: 9988, Data: []byte("confirm"), + } + gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{ + UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 77, Date: 1700000004, + Peer: callback.Peer, BotCallbackQuery: callback, + Message: domain.Message{ + ID: 9, OwnerUserID: 1001, Peer: callback.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Date: 1700000003, + Body: "tap", Out: true, + }, + Users: []domain.User{{ID: 1001, FirstName: "Echo", Bot: true}, {ID: 2001, FirstName: "Alice"}}, + }}} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{"allowed_updates":["callback_query"]}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var resp struct { + OK bool `json:"ok"` + Result []struct { + UpdateID int `json:"update_id"` + CallbackQuery struct { + ID string `json:"id"` + Data string `json:"data"` + ChatInstance string `json:"chat_instance"` + From struct { + ID int64 `json:"id"` + } `json:"from"` + Message struct { + MessageID int `json:"message_id"` + } `json:"message"` + } `json:"callback_query"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK || len(resp.Result) != 1 || resp.Result[0].UpdateID != 77 || + resp.Result[0].CallbackQuery.ID != "123456" || resp.Result[0].CallbackQuery.Data != "confirm" || + resp.Result[0].CallbackQuery.ChatInstance != "9988" || resp.Result[0].CallbackQuery.From.ID != 2001 || + resp.Result[0].CallbackQuery.Message.MessageID != 9 { + t.Fatalf("callback update response = %s", rec.Body.String()) + } +} + +func TestInlineCallbackProjectsOpaqueIDAndEditMessageTextUsesIt(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 998877} + callback := &domain.BotCallbackQuery{ + ID: 123456, BotUserID: 1001, UserID: 2001, + ChatInstance: 9988, Data: []byte("inline"), InlineMessage: inline, + } + gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{ + UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 78, Date: 1700000004, + BotCallbackQuery: callback, Users: []domain.User{{ID: 2001, FirstName: "Alice"}}, + }}} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("getUpdates status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Result []struct { + CallbackQuery struct { + InlineMessageID string `json:"inline_message_id"` + Message json.RawMessage `json:"message"` + } `json:"callback_query"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || len(response.Result) != 1 { + t.Fatalf("callback response=%s err=%v", rec.Body.String(), err) + } + inlineToken := response.Result[0].CallbackQuery.InlineMessageID + decoded, err := decodeBotAPIInlineMessageID(inlineToken) + if err != nil || decoded != *inline || len(response.Result[0].CallbackQuery.Message) != 0 { + t.Fatalf("inline token=%q decoded=%#v message=%s err=%v", inlineToken, decoded, response.Result[0].CallbackQuery.Message, err) + } + edit := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"inline_message_id":"`+inlineToken+`","text":"updated"}`) + if edit.Code != http.StatusOK || !gateway.editInlineCalled || gateway.editInlineID != *inline { + t.Fatalf("edit status=%d body=%s called=%v id=%#v", edit.Code, edit.Body.String(), gateway.editInlineCalled, gateway.editInlineID) + } +} + +func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) { + tests := []struct { + name string + raw string + kind domain.MessageReplyMarkupType + err string + }{ + {name: "remove", raw: `{"remove_keyboard":true,"selective":true}`, kind: domain.MessageReplyMarkupHide}, + {name: "force", raw: `{"force_reply":true,"input_field_placeholder":"Answer"}`, kind: domain.MessageReplyMarkupForceReply}, + {name: "contact", raw: `{"keyboard":[[{"text":"Phone","request_contact":true}]]}`, kind: domain.MessageReplyMarkupKeyboard}, + {name: "filtered users", raw: `{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2,"request_name":true}}]]}`, kind: domain.MessageReplyMarkupKeyboard}, + {name: "filtered chat", raw: `{"keyboard":[[{"text":"Forum","request_chat":{"request_id":8,"chat_is_channel":false,"chat_is_forum":true,"chat_has_username":false,"chat_is_created":true,"bot_is_member":true,"user_administrator_rights":{"can_manage_chat":true,"can_delete_messages":true},"bot_administrator_rights":{"can_manage_chat":true}}}]]}`, kind: domain.MessageReplyMarkupKeyboard}, + {name: "unsupported legacy user request", raw: `{"keyboard":[[{"text":"User","request_user":{"request_id":1}}]]}`, err: "BUTTON_TYPE_INVALID"}, + {name: "multiple constructors", raw: `{"keyboard":[["A"]],"inline_keyboard":[[{"text":"B","callback_data":"b"}]]}`, err: "BUTTON_INVALID"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + markup, err := replyMarkupFromAPI(json.RawMessage(tt.raw)) + if tt.err != "" { + if err == nil || err.Error() != tt.err { + t.Fatalf("error = %v, want %s", err, tt.err) + } + return + } + if err != nil || markup == nil || markup.Kind() != tt.kind { + t.Fatalf("markup = %#v err=%v, want kind %s", markup, err, tt.kind) + } + }) + } + if _, err := inlineReplyMarkupFromAPI(json.RawMessage(`{"keyboard":[["A"]]}`)); err == nil || err.Error() != "BUTTON_INVALID" { + t.Fatalf("inline-only parser error = %v, want BUTTON_INVALID", err) + } + if _, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Bad","url":"https://example.com","callback_data":"x"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" { + t.Fatalf("multi-constructor inline button error = %v, want BUTTON_INVALID", err) + } + filtered, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2}}]]}`)) + if err != nil || filtered == nil { + t.Fatalf("filtered users markup=%#v err=%v", filtered, err) + } + filter := filtered.Keyboard[0][0].RequestPeerFilter + if filter == nil || !filter.UserIsBotSet || filter.UserIsBot || !filter.UserIsPremiumSet || !filter.UserIsPremium { + t.Fatalf("filtered users = %#v", filter) + } + webApp, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"App","web_app":{"url":"https://example.com"}}]]}`)) + if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView { + t.Fatalf("web_app inline button = %#v err=%v", webApp, err) + } +} + +func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) { + reply, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Run","style":"primary","icon_custom_emoji_id":"123"}]]}`)) + if err != nil { + t.Fatalf("reply markup: %v", err) + } + button := reply.Keyboard[0][0] + if button.Style != domain.MarkupButtonStylePrimary || button.IconCustomEmojiID != 123 { + t.Fatalf("reply button = %#v", button) + } + inline, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Delete","callback_data":"delete","style":"danger","icon_custom_emoji_id":"456"}]]}`)) + if err != nil { + t.Fatalf("inline markup: %v", err) + } + button = inline.Inline[0][0] + if button.Style != domain.MarkupButtonStyleDanger || button.IconCustomEmojiID != 456 { + t.Fatalf("inline button = %#v", button) + } + projected := apiReplyMarkup(inline) + rows := projected["inline_keyboard"].([][]map[string]any) + if rows[0][0]["style"] != "danger" || rows[0][0]["icon_custom_emoji_id"] != "456" { + t.Fatalf("projected inline button = %#v", rows[0][0]) + } + if _, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Bad","style":"rainbow"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" { + t.Fatalf("invalid style error = %v", err) + } +} + +func TestDeleteWebhookDropsPendingAndWebhookInfoReportsCount(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{pendingCount: 7} + h := (&handler{bots: bots, gateway: gateway}).routes() + info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`) + if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"pending_update_count":7`) { + t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String()) + } + drop := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{"drop_pending_updates":true}`) + if drop.Code != http.StatusOK || !gateway.dropPending { + t.Fatalf("deleteWebhook status=%d body=%s drop=%v", drop.Code, drop.Body.String(), gateway.dropPending) + } +} + +func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{pendingCount: 3} + h := (&handler{bots: bots, gateway: gateway}).routes() + set := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{ + "url":"https://bot.example.test/hook", + "secret_token":"safe_secret-1", + "max_connections":12, + "allowed_updates":["message","callback_query"], + "drop_pending_updates":true + }`) + if set.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != "https://bot.example.test/hook" || + gateway.webhook.SecretToken != "safe_secret-1" || gateway.webhook.MaxConnections != 12 || + len(gateway.webhook.AllowedUpdates) != 2 || !gateway.webhook.AllowedUpdatesSet || !gateway.webhookDrop { + t.Fatalf("setWebhook status=%d body=%s config=%#v", set.Code, set.Body.String(), gateway.webhook) + } + info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`) + if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"url":"https://bot.example.test/hook"`) || + !strings.Contains(info.Body.String(), `"max_connections":12`) || !strings.Contains(info.Body.String(), `"pending_update_count":3`) { + t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String()) + } + reconfigure := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{"url":"https://bot.example.test/new"}`) + if reconfigure.Code != http.StatusOK || gateway.webhook.AllowedUpdatesSet { + t.Fatalf("omitted allowed_updates status=%d body=%s config=%#v", reconfigure.Code, reconfigure.Body.String(), gateway.webhook) + } + poll := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`) + if poll.Code != http.StatusConflict || !strings.Contains(poll.Body.String(), "webhook is active") { + t.Fatalf("getUpdates status=%d body=%s", poll.Code, poll.Body.String()) + } + del := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{}`) + if del.Code != http.StatusOK || !gateway.webhookDeleted || gateway.webhookFound { + t.Fatalf("deleteWebhook status=%d body=%s deleted=%v", del.Code, del.Body.String(), gateway.webhookDeleted) + } +} + +func TestSetWebhookRejectsUnsafeParameters(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes() + tests := []struct { + body string + want string + }{ + {`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"}, + {`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"}, + {`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"}, + {`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"}, + } + for _, tt := range tests { + rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", tt.body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tt.want) { + t.Fatalf("setWebhook body=%s status=%d response=%s want=%s", tt.body, rec.Code, rec.Body.String(), tt.want) + } + } +} + +func TestBotAPIPollRegistryRejectsConcurrentPoller(t *testing.T) { + var polls botAPIPollRegistry + if !polls.acquire(1001) { + t.Fatal("first poller was rejected") + } + if polls.acquire(1001) { + t.Fatal("second poller for same bot was accepted") + } + if !polls.acquire(1002) { + t.Fatal("different bot poller was rejected") + } + polls.release(1001) + if !polls.acquire(1001) { + t.Fatal("poller remained locked after release") + } +} + func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} gateway := &fakeBotAPIGateway{ @@ -554,6 +861,108 @@ func TestAPIUpdateProjectsCaptionlessMediaMessage(t *testing.T) { } } +func TestAPIMessageProjectsReplyKeyboardResponses(t *testing.T) { + base := domain.Message{ + ID: 10, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000010, + } + t.Run("contact", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{ + PhoneNumber: "+12025550123", FirstName: "Alice", LastName: "Example", Vcard: "VCARD", UserID: 2001, + }} + contact := apiMessage(msg, nil)["contact"].(map[string]any) + if contact["phone_number"] != "+12025550123" || contact["user_id"] != int64(2001) { + t.Fatalf("contact=%#v", contact) + } + }) + t.Run("locations", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{Lat: 1.5, Long: 2.5, AccuracyRadius: 7}} + location := apiMessage(msg, nil)["location"].(map[string]any) + if location["latitude"] != 1.5 || location["horizontal_accuracy"] != float64(7) { + t.Fatalf("location=%#v", location) + } + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{ + Geo: domain.MessageGeoPoint{Lat: 3.5, Long: 4.5}, Period: 60, Heading: 90, ProximityNotificationRadius: 25, + }} + location = apiMessage(msg, nil)["location"].(map[string]any) + if location["live_period"] != 60 || location["heading"] != 90 || location["proximity_alert_radius"] != 25 { + t.Fatalf("live location=%#v", location) + } + }) + t.Run("venue", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: domain.MessageGeoPoint{Lat: 1, Long: 2}, Title: "Cafe", Address: "Main St", + Provider: "foursquare", VenueID: "place-1", VenueType: "food/cafe", + }} + venue := apiMessage(msg, nil)["venue"].(map[string]any) + if venue["title"] != "Cafe" || venue["foursquare_id"] != "place-1" { + t.Fatalf("venue=%#v", venue) + } + }) + t.Run("poll", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{ + ID: 77, Question: "Pick", Quiz: true, RevotingDisabled: true, + Answers: []domain.MessagePollAnswer{{Text: "A", Option: []byte{1}}, {Text: "B", Option: []byte{2}}}, + Results: &domain.MessagePollResults{TotalVoters: 3, Voters: []domain.MessagePollAnswerVoters{ + {Option: []byte{1}, Voters: 1}, {Option: []byte{2}, Voters: 2, Correct: true}, + }, Solution: "Because B"}, + }} + poll := apiMessage(msg, nil)["poll"].(map[string]any) + options := poll["options"].([]map[string]any) + correct := poll["correct_option_ids"].([]int) + if poll["id"] != "77" || poll["type"] != "quiz" || poll["allows_revoting"] != false || + len(options) != 2 || options[1]["voter_count"] != 2 || len(correct) != 1 || correct[0] != 1 { + t.Fatalf("poll=%#v", poll) + } + }) + t.Run("web_app_data", func(t *testing.T) { + msg := base + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ButtonText: "Open", Data: `{"ok":true}`}, + }} + data := apiMessage(msg, nil)["web_app_data"].(map[string]any) + if data["button_text"] != "Open" || data["data"] != `{"ok":true}` { + t.Fatalf("web_app_data=%#v", data) + } + }) + t.Run("shared_peers", func(t *testing.T) { + msg := base + sharedPhoto := domain.Photo{ID: 9001, Sizes: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096, + }}} + msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 42, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 3001}}, + Details: []domain.MessageRequestedPeerDetails{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3001}, FirstName: "Shared", Username: "shared_user", Photo: &sharedPhoto, + }}, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + }, + }} + projected := apiMessage(msg, nil) + usersShared := projected["users_shared"].(map[string]any) + sharedUsers := usersShared["users"].([]map[string]any) + if usersShared["request_id"] != 42 || sharedUsers[0]["user_id"] != int64(3001) || sharedUsers[0]["username"] != "shared_user" || len(sharedUsers[0]["photo"].([]map[string]any)) != 1 { + t.Fatalf("users_shared=%#v", usersShared) + } + msg.Media.ServiceAction.RequestedPeer.Peers = []domain.Peer{{Type: domain.PeerTypeChannel, ID: 55}} + msg.Media.ServiceAction.RequestedPeer.Details = []domain.MessageRequestedPeerDetails{{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 55}, Title: "Shared Chat", Username: "shared_chat", + }} + projected = apiMessage(msg, nil) + chatShared := projected["chat_shared"].(map[string]any) + if chatShared["request_id"] != 42 || chatShared["chat_id"] != int64(-1000000000055) || chatShared["title"] != "Shared Chat" { + t.Fatalf("chat_shared=%#v", chatShared) + } + }) +} + func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder { t.Helper() token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret) @@ -649,11 +1058,21 @@ type fakeBotAPIGateway struct { editCalled bool editSetMarkup bool editMessage domain.Message + editInlineCalled bool + editInlineID domain.BotInlineMessageID deleteCalled bool callbackCalled bool callbackID string fileLocationKey string fileChunks map[string]domain.FileChunk + allowedUpdates []domain.BotAPIUpdateKind + dropPending bool + pendingCount int + webhook domain.BotAPIWebhook + webhookFound bool + webhookDeleted bool + webhookDrop bool + webhookConfirmed int64 } func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) { @@ -666,6 +1085,65 @@ func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset return append([]domain.UpdateEvent(nil), f.updates...), nil } +func (f *fakeBotAPIGateway) BotAPISetAllowedUpdates(_ context.Context, _ int64, allowed []domain.BotAPIUpdateKind) error { + f.allowedUpdates = append([]domain.BotAPIUpdateKind(nil), allowed...) + return nil +} + +func (f *fakeBotAPIGateway) BotAPIDropPendingUpdates(context.Context, int64) error { + f.dropPending = true + return nil +} + +func (f *fakeBotAPIGateway) BotAPIPendingUpdateCount(context.Context, int64) (int, error) { + return f.pendingCount, nil +} + +func (f *fakeBotAPIGateway) BotAPISetWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error { + f.webhook, f.webhookFound, f.webhookDrop = config, true, dropPending + return nil +} + +func (f *fakeBotAPIGateway) BotAPIDeleteWebhook(_ context.Context, _ int64, dropPending bool) error { + f.webhook, f.webhookFound, f.webhookDeleted, f.webhookDrop = domain.BotAPIWebhook{}, false, true, dropPending + if dropPending { + f.dropPending = true + } + return nil +} + +func (f *fakeBotAPIGateway) BotAPIWebhook(context.Context, int64) (domain.BotAPIWebhook, bool, error) { + return f.webhook, f.webhookFound, nil +} + +func (f *fakeBotAPIGateway) ListDueBotAPIWebhooks(context.Context, int) ([]domain.BotAPIWebhook, error) { + if !f.webhookFound { + return nil, nil + } + return []domain.BotAPIWebhook{f.webhook}, nil +} + +func (f *fakeBotAPIGateway) AcquireBotAPIWebhookLease(context.Context, int64, string, time.Duration) (bool, error) { + return true, nil +} + +func (f *fakeBotAPIGateway) ReleaseBotAPIWebhookLease(context.Context, int64, string) error { + return nil +} + +func (f *fakeBotAPIGateway) RecordBotAPIWebhookFailure(context.Context, int64, string, time.Time, string) error { + return nil +} + +func (f *fakeBotAPIGateway) RecordBotAPIWebhookSuccess(context.Context, int64, string, time.Time) error { + return nil +} + +func (f *fakeBotAPIGateway) ConfirmBotAPIWebhookDelivery(_ context.Context, _ int64, updateID int64) error { + f.webhookConfirmed = updateID + return nil +} + func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) { f.sendCalled = true f.sendBotID = botID @@ -695,6 +1173,11 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat return f.editMessage, nil } +func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, _ string, _ []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) { + f.editInlineCalled, f.editInlineID = true, inlineMessageID + return true, nil +} + func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) { f.deleteCalled = true return true, nil diff --git a/internal/botapi/webhook.go b/internal/botapi/webhook.go new file mode 100644 index 00000000..8a480258 --- /dev/null +++ b/internal/botapi/webhook.go @@ -0,0 +1,259 @@ +package botapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const ( + webhookScanInterval = 250 * time.Millisecond + webhookLeaseTTL = 30 * time.Second + webhookIdleDelay = time.Hour + webhookBotWorkers = 16 + webhookHTTPWorkers = 64 + webhookDueBatch = 64 +) + +type webhookDispatcher struct { + control GatewayWebhookControl + gateway GatewayService + client *http.Client + logger *zap.Logger + botSem chan struct{} + httpSem chan struct{} +} + +func newWebhookHTTPClient() *http.Client { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 256, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + } + return &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + // A redirect could leak X-Telegram-Bot-Api-Secret-Token to another host. + return http.ErrUseLastResponse + }, + } +} + +func runWebhookDispatcher(ctx context.Context, control GatewayWebhookControl, gateway GatewayService, client *http.Client, logger *zap.Logger) { + if control == nil || gateway == nil { + return + } + if client == nil { + client = newWebhookHTTPClient() + } + if logger == nil { + logger = zap.NewNop() + } + d := &webhookDispatcher{ + control: control, gateway: gateway, client: client, logger: logger, + botSem: make(chan struct{}, webhookBotWorkers), httpSem: make(chan struct{}, webhookHTTPWorkers), + } + d.scan(ctx) + ticker := time.NewTicker(webhookScanInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.scan(ctx) + } + } +} + +func (d *webhookDispatcher) scan(ctx context.Context) { + configs, err := d.control.ListDueBotAPIWebhooks(ctx, webhookDueBatch) + if err != nil { + d.logger.Warn("list due bot api webhooks", zap.Error(err)) + return + } + for _, config := range configs { + select { + case d.botSem <- struct{}{}: + go func(config domain.BotAPIWebhook) { + defer func() { <-d.botSem }() + d.deliver(ctx, config) + }(config) + default: + return + } + } +} + +func (d *webhookDispatcher) deliver(parent context.Context, candidate domain.BotAPIWebhook) { + ctx, cancel := context.WithTimeout(parent, webhookLeaseTTL) + defer cancel() + owner := randomBotAPIOwner() + acquired, err := d.control.AcquireBotAPIWebhookLease(ctx, candidate.BotUserID, owner, webhookLeaseTTL) + if err != nil { + d.logger.Warn("acquire bot api webhook lease", zap.Int64("bot_user_id", candidate.BotUserID), zap.Error(err)) + return + } + if !acquired { + return + } + released := false + defer func() { + if released { + return + } + releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer releaseCancel() + _ = d.control.ReleaseBotAPIWebhookLease(releaseCtx, candidate.BotUserID, owner) + }() + + // Re-read after taking the lease so a stale due-list row can never deliver to + // a URL that has since been deleted or replaced. + config, found, err := d.control.BotAPIWebhook(ctx, candidate.BotUserID) + if err != nil || !found { + return + } + events, err := d.gateway.BotAPIUpdates(ctx, config.BotUserID, 0) + if err != nil { + d.fail(ctx, config, owner, fmt.Errorf("load updates: %w", err)) + released = true + return + } + if len(events) == 0 { + err = d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, time.Now().Add(webhookIdleDelay)) + if err != nil { + d.logger.Warn("idle bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err)) + } + released = err == nil + return + } + + limit := config.MaxConnections + if limit <= 0 || limit > 100 { + limit = 40 + } + if limit > len(events) { + limit = len(events) + } + type delivery struct { + index int + updateID int64 + err error + } + results := make(chan delivery, limit) + for i := 0; i < limit; i++ { + item, _, ok := apiUpdate(events[i]) + if !ok { + results <- delivery{index: i, updateID: int64(events[i].Pts), err: errors.New("update projection failed")} + continue + } + payload, err := json.Marshal(item) + if err != nil { + results <- delivery{index: i, updateID: int64(events[i].Pts), err: err} + continue + } + go func(index int, updateID int64, payload []byte) { + select { + case d.httpSem <- struct{}{}: + defer func() { <-d.httpSem }() + case <-ctx.Done(): + results <- delivery{index: index, updateID: updateID, err: ctx.Err()} + return + } + results <- delivery{index: index, updateID: updateID, err: d.post(ctx, config, payload)} + }(i, int64(events[i].Pts), payload) + } + deliveries := make([]delivery, limit) + for i := 0; i < limit; i++ { + result := <-results + deliveries[result.index] = result + } + confirmedID := int64(0) + var firstErr error + for _, result := range deliveries { + if result.err != nil { + firstErr = result.err + break + } + confirmedID = result.updateID + } + if confirmedID > 0 { + if err := d.control.ConfirmBotAPIWebhookDelivery(ctx, config.BotUserID, confirmedID); err != nil { + firstErr = fmt.Errorf("confirm update %d: %w", confirmedID, err) + } + } + if firstErr != nil { + d.fail(ctx, config, owner, firstErr) + released = true + return + } + nextAttempt := time.Now() + if limit == len(events) && len(events) < 100 { + nextAttempt = nextAttempt.Add(webhookIdleDelay) + } + if err := d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, nextAttempt); err != nil { + d.logger.Warn("complete bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err)) + return + } + released = true +} + +func (d *webhookDispatcher) post(ctx context.Context, config domain.BotAPIWebhook, payload []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.URL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if config.SecretToken != "" { + req.Header.Set("X-Telegram-Bot-Api-Secret-Token", config.SecretToken) + } + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned HTTP %d", resp.StatusCode) + } + return nil +} + +func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhook, owner string, cause error) { + exponent := config.FailureCount + if exponent < 0 { + exponent = 0 + } + if exponent > 8 { + exponent = 8 + } + delay := time.Second * time.Duration(1< 5*time.Minute { + delay = 5 * time.Minute + } + // Small deterministic jitter prevents synchronized retries without a global RNG lock. + delay += time.Duration(config.BotUserID&255) * time.Millisecond + message := cause.Error() + if err := d.control.RecordBotAPIWebhookFailure(ctx, config.BotUserID, owner, time.Now().Add(delay), message); err != nil { + d.logger.Warn("record bot api webhook failure", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err)) + return + } + d.logger.Debug("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message)) +} diff --git a/internal/botapi/webhook_test.go b/internal/botapi/webhook_test.go new file mode 100644 index 00000000..f31ad385 --- /dev/null +++ b/internal/botapi/webhook_test.go @@ -0,0 +1,127 @@ +package botapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +type recordingWebhookGateway struct { + *fakeBotAPIGateway + mu sync.Mutex + failure string + failureNext time.Time + successNext time.Time + recordedOwner string +} + +func (g *recordingWebhookGateway) RecordBotAPIWebhookFailure(_ context.Context, _ int64, owner string, next time.Time, message string) error { + g.mu.Lock() + g.recordedOwner, g.failure, g.failureNext = owner, message, next + g.mu.Unlock() + return nil +} + +func (g *recordingWebhookGateway) RecordBotAPIWebhookSuccess(_ context.Context, _ int64, owner string, next time.Time) error { + g.mu.Lock() + g.recordedOwner, g.successNext = owner, next + g.mu.Unlock() + return nil +} + +func webhookEvents(ids ...int) []domain.UpdateEvent { + out := make([]domain.UpdateEvent, 0, len(ids)) + for _, id := range ids { + out = append(out, domain.UpdateEvent{ + UserID: 1001, Type: domain.UpdateEventNewMessage, Pts: id, Date: 1700000000 + id, + Message: domain.Message{ + ID: id, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000000 + id, Body: "message", Out: false, + }, + Users: []domain.User{{ID: 2001, FirstName: "Alice"}}, + }) + } + return out +} + +func TestWebhookDispatcherPostsInParallelWithSecretAndConfirmsContiguousBatch(t *testing.T) { + var mu sync.Mutex + received := make(map[int]bool) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Telegram-Bot-Api-Secret-Token"); got != "secret_1" { + t.Errorf("secret header = %q", got) + } + var update struct { + UpdateID int `json:"update_id"` + } + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + t.Errorf("decode webhook: %v", err) + } + mu.Lock() + received[update.UpdateID] = true + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + base := &fakeBotAPIGateway{ + updates: webhookEvents(11, 12, 13), + webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, SecretToken: "secret_1", MaxConnections: 3}, + webhookFound: true, + } + gateway := &recordingWebhookGateway{fakeBotAPIGateway: base} + d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)} + d.deliver(context.Background(), base.webhook) + + mu.Lock() + count := len(received) + mu.Unlock() + if count != 3 || base.webhookConfirmed != 13 { + t.Fatalf("received=%v confirmed=%d", received, base.webhookConfirmed) + } + gateway.mu.Lock() + successNext, failure := gateway.successNext, gateway.failure + gateway.mu.Unlock() + if !successNext.After(time.Now().Add(30*time.Minute)) || failure != "" { + t.Fatalf("success next=%v failure=%q", successNext, failure) + } +} + +func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var update struct { + UpdateID int `json:"update_id"` + } + _ = json.NewDecoder(r.Body).Decode(&update) + if update.UpdateID == 22 { + http.Error(w, "retry", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + base := &fakeBotAPIGateway{ + updates: webhookEvents(21, 22, 23), + webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, MaxConnections: 3}, + webhookFound: true, + } + gateway := &recordingWebhookGateway{fakeBotAPIGateway: base} + d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)} + d.deliver(context.Background(), base.webhook) + + gateway.mu.Lock() + failure, retryAt := gateway.failure, gateway.failureNext + gateway.mu.Unlock() + if base.webhookConfirmed != 21 || failure != "webhook returned HTTP 503" || !retryAt.After(time.Now()) { + t.Fatalf("confirmed=%d failure=%q retry=%v", base.webhookConfirmed, failure, retryAt) + } +} diff --git a/internal/domain/bot.go b/internal/domain/bot.go index 3e2aedbd..86e010a3 100644 --- a/internal/domain/bot.go +++ b/internal/domain/bot.go @@ -204,15 +204,19 @@ type BotAttachMenuState struct { // BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。 type BotRequestedWebViewButton struct { - WebAppReqID string - BotUserID int64 - UserID int64 - ButtonID int - Text string - PeerType string - MaxQuantity int - CreatedAt time.Time - ExpiresAt time.Time + WebAppReqID string + BotUserID int64 + UserID int64 + ButtonID int + Text string + PeerType string + MaxQuantity int + PeerFilter *BotRequestPeerFilter + NameRequested bool + UsernameRequested bool + PhotoRequested bool + CreatedAt time.Time + ExpiresAt time.Time } // BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答 diff --git a/internal/domain/botapi_update.go b/internal/domain/botapi_update.go index 08b8cc07..50da0b98 100644 --- a/internal/domain/botapi_update.go +++ b/internal/domain/botapi_update.go @@ -6,8 +6,32 @@ type BotAPIUpdateKind string const ( BotAPIUpdateMessage BotAPIUpdateKind = "message" BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message" + BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query" ) +// BotCallbackQuery is the protocol-neutral payload shared by MTProto +// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection. +type BotCallbackQuery struct { + ID int64 + BotUserID int64 + UserID int64 + Peer Peer + MessageID int + ChatInstance int64 + Data []byte + InlineMessage *BotInlineMessageID +} + +// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64. +// It can be projected both to MTProto and to Bot API's opaque +// inline_message_id without leaking tg types into the store boundary. +type BotInlineMessageID struct { + DCID int + OwnerID int64 + ID int + AccessHash int64 +} + // BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id // and is global across all bots, matching Telegram Bot API's monotonic offset // contract without reusing MTProto pts from user/channel logs. @@ -19,6 +43,7 @@ type BotAPIUpdate struct { MessageID int SourcePts int Date int + Callback *BotCallbackQuery } // EnqueueBotAPIUpdateRequest describes a message-like update that should be @@ -30,4 +55,5 @@ type EnqueueBotAPIUpdateRequest struct { MessageID int SourcePts int Date int + Callback *BotCallbackQuery } diff --git a/internal/domain/botapi_webhook.go b/internal/domain/botapi_webhook.go new file mode 100644 index 00000000..150d041a --- /dev/null +++ b/internal/domain/botapi_webhook.go @@ -0,0 +1,21 @@ +package domain + +import "time" + +// BotAPIWebhook is durable delivery configuration and observable retry state. +// The token secret is never stored here: authentication remains owned by BotProfile. +type BotAPIWebhook struct { + BotUserID int64 + URL string + SecretToken string + MaxConnections int + AllowedUpdates []BotAPIUpdateKind + // AllowedUpdatesSet distinguishes an explicitly supplied (possibly empty) + // setWebhook parameter from omission, which must preserve the previous + // getUpdates/setWebhook policy atomically at the store boundary. + AllowedUpdatesSet bool + FailureCount int + LastErrorDate int + LastErrorMessage string + NextAttemptAt time.Time +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go index ff3affe9..e9f34f33 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -188,20 +188,23 @@ const ( // ChannelAdminRights is a domain-only representation of Telegram admin rights. type ChannelAdminRights struct { - ChangeInfo bool - PostMessages bool - EditMessages bool - DeleteMessages bool - PostStories bool - EditStories bool - DeleteStories bool - BanUsers bool - InviteUsers bool - PinMessages bool - AddAdmins bool - ManageCall bool - Anonymous bool - ManageRanks bool + ChangeInfo bool + PostMessages bool + EditMessages bool + DeleteMessages bool + PostStories bool + EditStories bool + DeleteStories bool + BanUsers bool + InviteUsers bool + PinMessages bool + AddAdmins bool + ManageCall bool + ManageChat bool + ManageTopics bool + Anonymous bool + ManageRanks bool + ManageLinkedPeers bool // ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的 // 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。 ManageDirectMessages bool @@ -210,19 +213,22 @@ type ChannelAdminRights struct { // CreatorChannelAdminRights returns the full rights set clients expect on creator projections. func CreatorChannelAdminRights() ChannelAdminRights { return ChannelAdminRights{ - ChangeInfo: true, - PostMessages: true, - EditMessages: true, - DeleteMessages: true, - PostStories: true, - EditStories: true, - DeleteStories: true, - BanUsers: true, - InviteUsers: true, - PinMessages: true, - AddAdmins: true, - ManageCall: true, - ManageRanks: true, + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + PostStories: true, + EditStories: true, + DeleteStories: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + ManageChat: true, + ManageTopics: true, + ManageRanks: true, + ManageLinkedPeers: true, } } diff --git a/internal/domain/media.go b/internal/domain/media.go index 01b2cead..bc9fc57e 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -610,6 +610,23 @@ type MessageWebViewDataAction struct { type MessageRequestedPeerAction struct { ButtonID int `json:"button_id"` Peers []Peer `json:"peers"` + // Details is the immutable, permission-gated snapshot delivered to the bot. + // It is kept separate from Peers because the sender-side MTProto action only + // exposes peer identities, while the bot-side/Bot API view may additionally + // expose the requested name, username, and profile photo. + Details []MessageRequestedPeerDetails `json:"details,omitempty"` + NameRequested bool `json:"name_requested,omitempty"` + UsernameRequested bool `json:"username_requested,omitempty"` + PhotoRequested bool `json:"photo_requested,omitempty"` +} + +type MessageRequestedPeerDetails struct { + Peer Peer `json:"peer"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + Photo *Photo `json:"photo,omitempty"` } // MessageServiceAction 是私聊服务消息动作的协议中立表示。 diff --git a/internal/domain/message.go b/internal/domain/message.go index c35efca7..3df48d84 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -151,7 +151,7 @@ type Message struct { // (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。 // 转发不携带特效(新消息恒 0)。仅私聊;群/频道不渲染。 Effect int64 - // ReplyMarkup 是 bot 消息携带的 inline keyboard 快照(P3)。仅 bot 出站消息可 + // ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可 // 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。 ReplyMarkup *MessageReplyMarkup // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 @@ -283,7 +283,7 @@ type SendPrivateTextRequest struct { // BusinessAutomationKind is internal app-layer metadata used to suppress // recursive greeting/away automation for server-generated replies. BusinessAutomationKind BusinessAutomationKind - // ReplyMarkup 是 bot 出站消息的 inline keyboard 快照(P3);普通用户发送恒 nil。 + // ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。 ReplyMarkup *MessageReplyMarkup // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 RichMessage *MessageRichMessage diff --git a/internal/domain/message_markup.go b/internal/domain/message_markup.go index d25093a9..35c992d9 100644 --- a/internal/domain/message_markup.go +++ b/internal/domain/message_markup.go @@ -18,6 +18,10 @@ const ( MaxCallbackDataLen = 64 // MaxMarkupButtonTextLen 是按钮文本长度上限(rune 计数)。 MaxMarkupButtonTextLen = 256 + // MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。 + MaxReplyKeyboardButtonTextLen = 64 + // MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。 + MaxReplyKeyboardPlaceholderLen = 64 // MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。 MaxBotCallbackAnswerLen = 200 // MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64)。 @@ -38,20 +42,81 @@ var ( ErrStartParamInvalid = errors.New("start param invalid") ) -// MarkupButtonType 标识 P3 支持的 inline 按钮类型。 +// MarkupButtonType 标识消息键盘按钮类型。 type MarkupButtonType string const ( + // MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。 + MarkupButtonText MarkupButtonType = "text" // MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。 MarkupButtonCallback MarkupButtonType = "callback" // MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。 - MarkupButtonURL MarkupButtonType = "url" + MarkupButtonURL MarkupButtonType = "url" + MarkupButtonRequestPhone MarkupButtonType = "request_phone" + MarkupButtonRequestLocation MarkupButtonType = "request_location" + MarkupButtonRequestPoll MarkupButtonType = "request_poll" + MarkupButtonRequestPeer MarkupButtonType = "request_peer" + MarkupButtonWebView MarkupButtonType = "webview" + MarkupButtonSimpleWebView MarkupButtonType = "simple_webview" + MarkupButtonSwitchInline MarkupButtonType = "switch_inline" + MarkupButtonCopy MarkupButtonType = "copy" ) -// MarkupButton 是一颗 inline keyboard 按钮(P3 仅 callback/url)。 +// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram +// intentionally exposes semantic colors instead of arbitrary RGB values. +type MarkupButtonStyle string + +const ( + MarkupButtonStylePrimary MarkupButtonStyle = "primary" + MarkupButtonStyleDanger MarkupButtonStyle = "danger" + MarkupButtonStyleSuccess MarkupButtonStyle = "success" +) + +// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without +// importing protocol types into persisted message state. +type BotRequestAdminRights struct { + Anonymous bool `json:"anonymous,omitempty"` + ManageChat bool `json:"manage_chat,omitempty"` + DeleteMessages bool `json:"delete_messages,omitempty"` + ManageVideoChats bool `json:"manage_video_chats,omitempty"` + RestrictMembers bool `json:"restrict_members,omitempty"` + PromoteMembers bool `json:"promote_members,omitempty"` + ChangeInfo bool `json:"change_info,omitempty"` + InviteUsers bool `json:"invite_users,omitempty"` + PostStories bool `json:"post_stories,omitempty"` + EditStories bool `json:"edit_stories,omitempty"` + DeleteStories bool `json:"delete_stories,omitempty"` + PostMessages bool `json:"post_messages,omitempty"` + EditMessages bool `json:"edit_messages,omitempty"` + PinMessages bool `json:"pin_messages,omitempty"` + ManageTopics bool `json:"manage_topics,omitempty"` + ManageDirectMessages bool `json:"manage_direct_messages,omitempty"` +} + +type BotRequestPeerFilter struct { + UserIsBotSet bool `json:"user_is_bot_set,omitempty"` + UserIsBot bool `json:"user_is_bot,omitempty"` + UserIsPremiumSet bool `json:"user_is_premium_set,omitempty"` + UserIsPremium bool `json:"user_is_premium,omitempty"` + ChatHasUsernameSet bool `json:"chat_has_username_set,omitempty"` + ChatHasUsername bool `json:"chat_has_username,omitempty"` + ChatIsForumSet bool `json:"chat_is_forum_set,omitempty"` + ChatIsForum bool `json:"chat_is_forum,omitempty"` + ChatIsCreated bool `json:"chat_is_created,omitempty"` + BotIsMember bool `json:"bot_is_member,omitempty"` + UserAdminRights *BotRequestAdminRights `json:"user_admin_rights,omitempty"` + BotAdminRights *BotRequestAdminRights `json:"bot_admin_rights,omitempty"` +} + +// MarkupButton 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮; +// inline keyboard 当前接受 callback/url。 type MarkupButton struct { Type MarkupButtonType `json:"type"` Text string `json:"text"` + // Style is one of primary/danger/success. Empty means the client default. + Style MarkupButtonStyle `json:"style,omitempty"` + // IconCustomEmojiID is the optional custom emoji rendered before Text. + IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"` // Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位)。json 自动 base64 // 编解码,保证经 JSONB 列字节级 round-trip(updateBotCallbackQuery.data 须原样)。 Data []byte `json:"data,omitempty"` @@ -60,11 +125,68 @@ type MarkupButton struct { // RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password, // 2FA SRP 校验 P3 stub)。 RequiresPassword bool `json:"requires_password,omitempty"` + // PollType is empty, "regular", or "quiz" for request_poll. + PollType string `json:"poll_type,omitempty"` + // ButtonID and request-peer fields preserve Bot API request_id and the + // client-side chooser shape. RequestPeerType is user/chat/broadcast. + ButtonID int `json:"button_id,omitempty"` + RequestPeerType string `json:"request_peer_type,omitempty"` + MaxQuantity int `json:"max_quantity,omitempty"` + NameRequested bool `json:"name_requested,omitempty"` + UsernameRequested bool `json:"username_requested,omitempty"` + PhotoRequested bool `json:"photo_requested,omitempty"` + RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"` + Query string `json:"query,omitempty"` + SamePeer bool `json:"same_peer,omitempty"` + PeerTypes []string `json:"peer_types,omitempty"` + CopyText string `json:"copy_text,omitempty"` } -// MessageReplyMarkup 是消息携带的 inline keyboard 快照(P3 仅 ReplyInlineMarkup)。 +// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。 +type MessageReplyMarkupType string + +const ( + MessageReplyMarkupInline MessageReplyMarkupType = "inline" + MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard" + MessageReplyMarkupHide MessageReplyMarkupType = "hide" + MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply" +) + +// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline +// 非空表示 0110 之前已经持久化的合法 inline keyboard,Kind 会将其解释为 inline。 type MessageReplyMarkup struct { - Inline [][]MarkupButton `json:"inline,omitempty"` + Type MessageReplyMarkupType `json:"type,omitempty"` + Inline [][]MarkupButton `json:"inline,omitempty"` + Keyboard [][]MarkupButton `json:"keyboard,omitempty"` + Resize bool `json:"resize,omitempty"` + SingleUse bool `json:"single_use,omitempty"` + Selective bool `json:"selective,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Placeholder string `json:"placeholder,omitempty"` +} + +// Kind 返回 markup constructor;兼容已落库的无 Type inline 快照。 +func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType { + if m == nil { + return "" + } + if m.Type != "" { + return m.Type + } + if len(m.Inline) > 0 { + return MessageReplyMarkupInline + } + return "" +} + +// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。 +func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool { + switch m.Kind() { + case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply: + return true + default: + return false + } } // IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。 @@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool { if m == nil { return true } - for _, row := range m.Inline { - if len(row) > 0 { - return false + switch m.Kind() { + case MessageReplyMarkupInline: + for _, row := range m.Inline { + if len(row) > 0 { + return false + } } + return true + case MessageReplyMarkupKeyboard: + for _, row := range m.Keyboard { + if len(row) > 0 { + return false + } + } + return true + case MessageReplyMarkupHide, MessageReplyMarkupForceReply: + return false + default: + return true } - return true } -// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮,校验须先于落库(I9)。 -// 空 markup 合法(视为清空/无键盘)。 +// ValidateReplyMarkup 校验 markup constructor、结构与按钮,校验须先于落库(I9)。 +// 空 inline markup 合法(视为清空/无键盘)。 func ValidateReplyMarkup(m *MessageReplyMarkup) error { if m == nil { return nil } - if len(m.Inline) > MaxMarkupRows { + kind := m.Kind() + if kind == "" { + if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" { + return ErrButtonInvalid + } + return nil + } + switch kind { + case MessageReplyMarkupInline: + if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" { + return ErrButtonInvalid + } + return validateMarkupRows(m.Inline, false) + case MessageReplyMarkupKeyboard: + if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen { + return ErrButtonInvalid + } + if len(m.Keyboard) == 0 { + return ErrButtonInvalid + } + return validateMarkupRows(m.Keyboard, true) + case MessageReplyMarkupHide: + if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" { + return ErrButtonInvalid + } + return nil + case MessageReplyMarkupForceReply: + if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen { + return ErrButtonInvalid + } + return nil + default: + return ErrButtonInvalid + } +} + +func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error { + if len(rows) > MaxMarkupRows { return ErrButtonInvalid } total := 0 - for _, row := range m.Inline { - if len(row) > MaxMarkupButtonsPerRow { + for _, row := range rows { + if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow { return ErrButtonInvalid } total += len(row) @@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error { return ErrButtonInvalid } for i := range row { - if err := validateMarkupButton(row[i]); err != nil { + if err := validateMarkupButton(row[i], replyKeyboard); err != nil { return err } } @@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error { return nil } -func validateMarkupButton(b MarkupButton) error { +func validateMarkupButton(b MarkupButton, replyKeyboard bool) error { text := strings.TrimSpace(b.Text) if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen { return ErrButtonInvalid } + switch b.Style { + case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess: + default: + return ErrButtonInvalid + } + if b.IconCustomEmojiID < 0 { + return ErrButtonInvalid + } + if replyKeyboard { + if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen { + return ErrButtonInvalid + } + switch b.Type { + case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation: + case MarkupButtonRequestPoll: + if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" { + return ErrButtonInvalid + } + case MarkupButtonRequestPeer: + if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 || + (b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") { + return ErrButtonInvalid + } + if b.RequestPeerFilter != nil { + filter := b.RequestPeerFilter + if b.RequestPeerType == "user" { + if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember || + filter.UserAdminRights != nil || filter.BotAdminRights != nil { + return ErrButtonInvalid + } + } else { + if filter.UserIsBotSet || filter.UserIsPremiumSet || + (b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) { + return ErrButtonInvalid + } + } + } + case MarkupButtonSimpleWebView: + if err := validateButtonURL(b.URL); err != nil { + return err + } + default: + return ErrButtonTypeInvalid + } + if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" { + return ErrButtonInvalid + } + return nil + } switch b.Type { case MarkupButtonCallback: if len(b.Data) > MaxCallbackDataLen { @@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error { if err := validateButtonURL(b.URL); err != nil { return err } + case MarkupButtonWebView: + if err := validateButtonURL(b.URL); err != nil { + return err + } + case MarkupButtonSwitchInline: + if utf8.RuneCountInString(b.Query) > 256 { + return ErrButtonInvalid + } + case MarkupButtonCopy: + if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 { + return ErrButtonInvalid + } default: // webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。 return ErrButtonTypeInvalid diff --git a/internal/domain/message_markup_test.go b/internal/domain/message_markup_test.go index d9d69ffb..38444c4d 100644 --- a/internal/domain/message_markup_test.go +++ b/internal/domain/message_markup_test.go @@ -26,7 +26,20 @@ func TestValidateReplyMarkup(t *testing.T) { {"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid}, {"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid}, {"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid}, - {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid}, + {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid}, + {"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil}, + {"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil}, + {"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil}, + {"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid}, + {"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid}, + {"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid}, + {"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid}, + {"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid}, + {"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid}, + {"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil}, + {"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil}, + {"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid}, + {"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) { if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() { t.Fatal("markup with a button must not be zero") } + if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() { + t.Fatal("reply keyboard with a button must not be zero") + } + if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() { + t.Fatal("hide keyboard constructor must not be zero") + } } diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index c7d687e6..890ab5ed 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -12,6 +12,9 @@ const ( UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox" UpdateEventReadMessageContents UpdateEventType = "read_message_contents" UpdateEventEditMessage UpdateEventType = "edit_message" + // UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号 + // pts/difference/outbox。 + UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query" // UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览 // 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建, // 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。 @@ -110,6 +113,7 @@ type UpdateEvent struct { QuickReplies []QuickReply QuickReply QuickReply QuickReplyMessage QuickReplyMessage + BotCallbackQuery *BotCallbackQuery } // LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有 diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index a0d307a1..df2732c7 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -8,7 +8,10 @@ import ( "time" "unicode/utf8" + "github.com/iamxvbaba/td/tg" + "telesrv/internal/domain" + "telesrv/internal/store" ) var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1} @@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) ( return r.enrichUpdateEvents(ctx, botID, diff.Events), nil } +func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error { + if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 { + return nil + } + return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed) +} + +func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error { + if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 { + return nil + } + return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID) +} + +func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) { + if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 { + return 0, nil + } + return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID) +} + +func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) { + leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore) + if !ok || botID <= 0 { + return true, nil + } + return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl) +} + +func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error { + leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore) + if !ok || botID <= 0 { + return nil + } + return leases.ReleaseBotAPIPollLease(ctx, botID, owner) +} + +func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return errors.New("WEBHOOK_UNSUPPORTED") + } + return webhooks.SetBotAPIWebhook(ctx, config, dropPending) +} + +func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return errors.New("WEBHOOK_UNSUPPORTED") + } + return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending) +} + +func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return domain.BotAPIWebhook{}, false, nil + } + return webhooks.BotAPIWebhook(ctx, botID) +} + +func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil, nil + } + return webhooks.ListDueBotAPIWebhooks(ctx, limit) +} + +func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return false, nil + } + return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl) +} + +func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil + } + return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner) +} + +func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil + } + return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message) +} + +func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error { + webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore) + if !ok { + return nil + } + return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt) +} + +func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error { + if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 { + return nil + } + return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID) +} + // BotAPISendMessage sends a text message as a bot through the normal private // or channel message state machine. Positive chat_id is a user private chat; // -1000000000000-channel_id is a supergroup/channel chat. @@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex if !ok { return domain.Message{}, errors.New("CHAT_ID_INVALID") } + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return domain.Message{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil { + return domain.Message{}, err + } if text == "" { return domain.Message{}, errors.New("MESSAGE_EMPTY") } @@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, if !ok { return domain.Message{}, errors.New("CHAT_ID_INVALID") } + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return domain.Message{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil { + return domain.Message{}, err + } if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength { return domain.Message{}, errors.New("MESSAGE_TOO_LONG") } @@ -400,6 +523,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64, return self.Message, nil } +func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) { + if r == nil || botID == 0 || !r.userIsBot(ctx, botID) { + return false, errors.New("BOT_INVALID") + } + if text == "" { + return false, errors.New("MESSAGE_EMPTY") + } + if utf8.RuneCountInString(text) > domain.MaxMessageTextLength { + return false, errors.New("MESSAGE_TOO_LONG") + } + if err := domain.ValidateReplyMarkup(replyMarkup); err != nil { + return false, replyMarkupErr(err) + } + req := &tg.MessagesEditInlineBotMessageRequest{ + ID: tgInputBotInlineMessageID(inlineMessageID), + NoWebpage: disableWebPagePreview, + } + req.SetMessage(text) + if len(entities) > 0 { + req.SetEntities(tgMessageEntities(entities)) + } + if setReplyMarkup { + wire := tgReplyMarkup(replyMarkup) + if wire == nil { + wire = &tg.ReplyInlineMarkup{} + } + req.SetReplyMarkup(wire) + } + return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req) +} + // BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so // the target user's MTProto clients observe the normal delete update. func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) { @@ -440,12 +594,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal if cacheTime < 0 { cacheTime = 0 } - r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{ + resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{ Alert: showAlert, Message: text, URL: url, CacheTime: cacheTime, }) + if resolveErr != nil { + return false, resolveErr + } + if !resolved { + return false, errors.New("QUERY_ID_INVALID") + } return true, nil } diff --git a/internal/rpc/botapi_gateway_test.go b/internal/rpc/botapi_gateway_test.go index 277a4ad6..902ec78b 100644 --- a/internal/rpc/botapi_gateway_test.go +++ b/internal/rpc/botapi_gateway_test.go @@ -2,11 +2,14 @@ package rpc import ( "context" + "strconv" "strings" "testing" "time" "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" "go.uber.org/zap/zaptest" appbots "telesrv/internal/app/bots" @@ -17,6 +20,170 @@ import ( "telesrv/internal/store/memory" ) +func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) { + fixture := newBotAPIReceiveFixture(t, false) + data := []byte("private-confirm") + markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ + Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data, + }}}} + sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{ + SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID, + RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if _, err := fixture.router.resolveBotCallbackQuery( + fixture.ctx, + fixture.owner.ID, + domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID}, + sent.RecipientMessage.ID, + []byte("forged-callback-data"), + ); !tgerr.Is(err, "DATA_INVALID") { + t.Fatalf("forged callback data err = %v, want DATA_INVALID", err) + } + ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second) + defer cancel() + answerCh := make(chan struct { + answer *tg.MessagesBotCallbackAnswer + err error + }, 1) + go func() { + req := &tg.MessagesGetBotCallbackAnswerRequest{ + Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash}, + MsgID: sent.RecipientMessage.ID, + } + req.SetData(data) + answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req) + answerCh <- struct { + answer *tg.MessagesBotCallbackAnswer + err error + }{answer: answer, err: err} + }() + + event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID) + if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out { + t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID) + } + callback := event.BotCallbackQuery + if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) || + callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) { + t.Fatalf("callback = %+v", callback) + } + if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok { + t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err) + } + select { + case result := <-answerCh: + if result.err != nil || result.answer == nil || result.answer.Message != "accepted" { + t.Fatalf("callback answer = %+v err=%v", result.answer, result.err) + } + case <-ctx.Done(): + t.Fatal("callback answer did not unblock requester") + } +} + +func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) { + fixture := newBotAPIReceiveFixture(t, false) + if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") { + t.Fatalf("unknown answer = ok=%v err=%v", ok, err) + } + item := domain.BotAPIUpdate{ + ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, + Date: 100, + Callback: &domain.BotCallbackQuery{ + ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3, + }, + } + if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok { + t.Fatal("callback at answer deadline remained deliverable") + } +} + +func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) { + now := time.Unix(200, 0) + inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988} + item := domain.BotAPIUpdate{ + ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()), + Callback: &domain.BotCallbackQuery{ + ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99, + Data: []byte("inline"), InlineMessage: inline, + }, + } + event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now) + if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) || + event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline { + t.Fatalf("inline callback event=%#v ok=%v", event, ok) + } +} + +func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) { + fixture := newBotAPIReceiveFixture(t, false) + data := []byte("group-confirm") + markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{ + Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data, + }}}} + sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{ + UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002, + Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second) + defer cancel() + answerCh := make(chan error, 1) + go func() { + req := &tg.MessagesGetBotCallbackAnswerRequest{ + Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash}, + MsgID: sent.Message.ID, + } + req.SetData(data) + _, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req) + answerCh <- err + }() + + event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID) + callback := event.BotCallbackQuery + if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) || + callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out { + t.Fatalf("group callback event = %+v", event) + } + if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil { + t.Fatalf("BotAPIAnswerCallbackQuery: %v", err) + } + select { + case err := <-answerCh: + if err != nil { + t.Fatalf("group callback answer: %v", err) + } + case <-ctx.Done(): + t.Fatal("group callback answer did not unblock requester") + } +} + +func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent { + t.Helper() + for { + events, err := router.BotAPIUpdates(ctx, botID, 0) + if err != nil { + t.Fatalf("BotAPIUpdates: %v", err) + } + for _, event := range events { + if event.Type == domain.UpdateEventBotCallbackQuery { + return event + } + } + select { + case <-ctx.Done(): + t.Fatal("callback query did not reach Bot API queue") + case <-time.After(10 * time.Millisecond): + } + } +} + func TestBotAPISendMessageToSupergroupChatID(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) { }, zaptest.NewLogger(t), clock.System) chatID := -botAPIChannelChatIDBase - created.Channel.ID - msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0) + replyKeyboard := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}}, + Resize: true, + } + msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0) if err != nil { t.Fatalf("BotAPISendMessage: %v", err) } @@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) { if err != nil { t.Fatalf("GetHistory: %v", err) } - if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body { + if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body || + history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard || + history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" { t.Fatalf("history messages = %+v, want bot channel message", history.Messages) } if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) { diff --git a/internal/rpc/botapi_update_queue.go b/internal/rpc/botapi_update_queue.go index 5aec6a53..f549f0fa 100644 --- a/internal/rpc/botapi_update_queue.go +++ b/internal/rpc/botapi_update_queue.go @@ -2,11 +2,16 @@ package rpc import ( "context" + "errors" + "time" "telesrv/internal/domain" ) -const botAPIGetUpdatesLimit = 100 +const ( + botAPIGetUpdatesLimit = 100 + botAPIMaxNegativeOffset = 10000 +) type botAPIChannelBotMemberProvider interface { ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) @@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in return nil, nil } fromID := int64(1) - if offset > 0 { + var items []domain.BotAPIUpdate + if offset < 0 { + if offset < -botAPIMaxNegativeOffset { + return nil, errors.New("OFFSET_INVALID") + } + var err error + items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit) + if err != nil { + return nil, err + } + if len(items) > 0 && items[0].ID > 1 { + if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil { + return nil, err + } + } + } else if offset > 0 { if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil { return nil, err } - fromID = offset - } else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil { - return nil, err - } else if found { - fromID = confirmed + 1 } - items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit) - if err != nil { - return nil, err + if offset >= 0 { + confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID) + if err != nil { + return nil, err + } + if found { + fromID = confirmed + 1 + } + items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit) + if err != nil { + return nil, err + } } if len(items) == 0 { return nil, nil } - events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items) + events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now()) if leadingSkipped > 0 { if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil { return nil, err @@ -46,13 +70,16 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in return r.enrichUpdateEvents(ctx, botID, events), nil } -func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) { +func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) { privateIDs := make([]int, 0) privateSeen := make(map[int]struct{}) channelIDs := make(map[int64][]int) channelSeen := make(map[int64]map[int]struct{}) for _, item := range items { - if _, ok := botAPIQueuedUpdateKind(botID, item); !ok { + if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok { + continue + } + if item.Callback != nil && item.Callback.InlineMessage != nil { continue } switch item.Peer.Type { @@ -79,7 +106,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item events := make([]domain.UpdateEvent, 0, len(items)) leadingSkipped := int64(0) for _, item := range items { - event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages) + event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now) if !ok { if len(events) == 0 { leadingSkipped = item.ID @@ -101,7 +128,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i } out := make(map[int]domain.Message, len(list.Messages)) for _, msg := range list.Messages { - if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) { + if msg.ID <= 0 || msg.OwnerUserID != botID { continue } out[msg.ID] = msg @@ -127,10 +154,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i if msg.ID <= 0 || msg.Deleted || msg.Action != nil { continue } - projected := botAPIMessageFromChannel(botID, msg) - if projected.Out || !botAPIMessageProjectable(projected) { - continue - } byID[msg.ID] = msg } if len(byID) > 0 { @@ -140,14 +163,37 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i return out } -func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) { - if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 { +func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) { + if item.ID <= 0 || item.BotUserID != botID { return "", false } eventType, ok := botAPIUpdateEventType(item.Kind) if !ok { return "", false } + if item.Kind == domain.BotAPIUpdateCallbackQuery { + if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) { + return "", false + } + cb := item.Callback + if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 || + cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen { + return "", false + } + if cb.InlineMessage != nil { + inline := cb.InlineMessage + if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) || + inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 { + return "", false + } + return eventType, true + } + if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID { + return "", false + } + } else if item.MessageID <= 0 { + return "", false + } switch item.Peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: if item.Peer.ID <= 0 { @@ -159,17 +205,48 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat return eventType, true } -func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) { - eventType, ok := botAPIQueuedUpdateKind(botID, item) +func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) { + eventType, ok := botAPIQueuedUpdateKind(botID, item, now) if !ok { return domain.UpdateEvent{}, false } + if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + inline := *item.Callback.InlineMessage + callback.InlineMessage = &inline + return domain.UpdateEvent{ + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + Date: item.Date, + BotCallbackQuery: &callback, + }, true + } switch item.Peer.Type { case domain.PeerTypeUser: msg, found := privateMessages[item.MessageID] if !found { return domain.UpdateEvent{}, false } + if eventType == domain.UpdateEventBotCallbackQuery { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + return domain.UpdateEvent{ + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + Date: item.Date, + Peer: item.Peer, + Message: msg, + BotCallbackQuery: &callback, + }, true + } + if msg.Out || !botAPIMessageProjectable(msg) { + return domain.UpdateEvent{}, false + } msg.Pts = int(item.ID) return domain.UpdateEvent{ UserID: botID, @@ -186,6 +263,23 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, return domain.UpdateEvent{}, false } projected := botAPIMessageFromChannel(botID, msg) + if eventType == domain.UpdateEventBotCallbackQuery { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + return domain.UpdateEvent{ + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + Date: item.Date, + Peer: item.Peer, + Message: projected, + BotCallbackQuery: &callback, + }, true + } + if projected.Out || !botAPIMessageProjectable(projected) { + return domain.UpdateEvent{}, false + } projected.Pts = int(item.ID) return domain.UpdateEvent{ UserID: botID, @@ -207,6 +301,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType return domain.UpdateEventNewMessage, true case domain.BotAPIUpdateEditedMessage: return domain.UpdateEventEditMessage, true + case domain.BotAPIUpdateCallbackQuery: + return domain.UpdateEventBotCallbackQuery, true default: return "", false } @@ -410,7 +506,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool { return media.Photo != nil case domain.MessageMediaKindDocument: return media.Document != nil + case domain.MessageMediaKindContact: + return media.Contact != nil + case domain.MessageMediaKindGeo: + return media.Geo != nil + case domain.MessageMediaKindVenue: + return media.Venue != nil + case domain.MessageMediaKindPoll: + return media.Poll != nil + case domain.MessageMediaKindGeoLive: + return media.GeoLive != nil + case domain.MessageMediaKindService: + if media.ServiceAction == nil { + return false + } + switch media.ServiceAction.Kind { + case domain.MessageServiceActionWebViewDataSent: + return media.ServiceAction.WebViewData != nil + case domain.MessageServiceActionRequestedPeer: + return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer) + default: + return false + } default: return false } } + +func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool { + if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity { + return false + } + details := make(map[domain.Peer]struct{}, len(action.Details)) + for _, detail := range action.Details { + if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) { + return false + } + details[detail.Peer] = struct{}{} + } + requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested + allUsers := true + for _, peer := range action.Peers { + if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return false + } + if requiresDetails { + if _, ok := details[peer]; !ok { + return false + } + } + allUsers = allUsers && peer.Type == domain.PeerTypeUser + } + return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel) +} diff --git a/internal/rpc/botapi_update_queue_projection_test.go b/internal/rpc/botapi_update_queue_projection_test.go new file mode 100644 index 00000000..d86d99d8 --- /dev/null +++ b/internal/rpc/botapi_update_queue_projection_test.go @@ -0,0 +1,70 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestBotAPIMessageMediaProjectableReplyKeyboardResponses(t *testing.T) { + validRequestedUsers := &domain.MessageRequestedPeerAction{ + ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeUser, ID: 1002}}, + } + tests := []struct { + name string + media *domain.MessageMedia + want bool + }{ + {"contact", &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{}}, true}, + {"geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{}}, true}, + {"venue", &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{}}, true}, + {"poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{}}, true}, + {"live geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{}}, true}, + {"web app", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, WebViewData: &domain.MessageWebViewDataAction{}, + }}, true}, + {"requested users", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: validRequestedUsers, + }}, true}, + {"requested disclosure without snapshot", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}}, NameRequested: true, + }, + }}, false}, + {"mixed requested peers", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}}, + }, + }}, false}, + {"unrelated service", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionPhoneCall, Call: &domain.MessagePhoneCallAction{}, + }}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := botAPIMessageMediaProjectable(tt.media); got != tt.want { + t.Fatalf("projectable=%v want=%v media=%#v", got, tt.want, tt.media) + } + }) + } +} + +func TestCollectMessagePeerRefsIncludesRequestedPeers(t *testing.T) { + users := map[int64]struct{}{} + channels := map[int64]struct{}{} + collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ButtonID: 1, Peers: []domain.Peer{ + {Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}, + }}, + }, + }}, 0, users, channels) + if _, ok := users[1001]; !ok { + t.Fatalf("requested user refs=%v", users) + } + if _, ok := channels[55]; !ok { + t.Fatalf("requested channel refs=%v", channels) + } +} diff --git a/internal/rpc/bots_callback.go b/internal/rpc/bots_callback.go index 948393e8..65c79d52 100644 --- a/internal/rpc/bots_callback.go +++ b/internal/rpc/bots_callback.go @@ -1,12 +1,14 @@ package rpc import ( + "bytes" "context" "time" "unicode/utf8" "github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tgerr" + "go.uber.org/zap" "telesrv/internal/domain" ) @@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") } func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") } -// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery -// 推给 bot,挂起等待 bot 的 setBotCallbackAnswer,或超时回 BOT_RESPONSE_TIMEOUT。 +type privateMessageByUIDService interface { + GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) +} + +// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query +// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的 +// setBotCallbackAnswer/answerCallbackQuery,或超时回 BOT_RESPONSE_TIMEOUT。 func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes if err != nil { return nil, err } - // callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。 - if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) { - return nil, dataInvalidErr() - } - botUserID := peer.ID // game 按钮(getBotCallbackAnswer.game)P3 不支持:返回空答案(客户端不弹任何东西), // 不挂起、不推送(避免给 bot 投递无法处理的 game query)。 if req.Game { @@ -46,32 +48,64 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes if !hasData { return nil, dataInvalidErr() } + if len(data) > domain.MaxCallbackDataLen { + return nil, dataInvalidErr() + } // 校验目标消息存在于请求者自己的盒、且对端正是该 bot。 if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { return nil, messageIDInvalidErr() } - msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID) + callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data) if err != nil { + return nil, err + } + botUserID := callback.BotUserID + + queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout) + if err != nil { + r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err)) return nil, internalErr() } - if !ok || msg.Peer != peer { - return nil, messageIDInvalidErr() + defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID) + callback.ID = queryID + + // Bot API callback_query shares the dedicated durable update_id queue with message and + // edited_message. The callback answer waiter itself remains ephemeral/process-local. + if r.deps.BotAPIUpdates != nil { + if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: botUserID, + Kind: domain.BotAPIUpdateCallbackQuery, + Peer: callback.Peer, + MessageID: callback.MessageID, + Date: int(r.clock.Now().Unix()), + Callback: &callback, + }); err != nil { + r.log.Warn("enqueue bot api callback query", + zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err)) + return nil, internalErr() + } else if created { + r.notifyBotAPIUpdate(botUserID) + } } - queryID, pending := r.callbacks.register(botUserID, userID) - defer r.callbacks.deregister(queryID) - - // updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference):仅在线推给 - // bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。 - // MsgID 透传请求者侧的 box id(P3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo)。 - update := &tg.UpdateBotCallbackQuery{ - QueryID: queryID, - UserID: userID, - Peer: &tg.PeerUser{UserID: userID}, - MsgID: req.MsgID, - ChatInstance: chatInstanceFor(botUserID, userID), + // updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference);私聊 MessageID + // 已翻译为 bot 视角 box id,channel 使用共享 message id。 + var update tg.UpdateClass + if callback.InlineMessage != nil { + inline := &tg.UpdateInlineBotCallbackQuery{ + QueryID: queryID, UserID: userID, + MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance, + } + inline.SetData(data) + update = inline + } else { + direct := &tg.UpdateBotCallbackQuery{ + QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer), + MsgID: callback.MessageID, ChatInstance: callback.ChatInstance, + } + direct.SetData(data) + update = direct } - update.SetData(data) r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{ Updates: []tg.UpdateClass{update}, Date: int(r.clock.Now().Unix()), @@ -79,14 +113,149 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout) defer cancel() - select { - case ans := <-pending.ch: - return tgBotCallbackAnswer(ans), nil - case <-waitCtx.Done(): - return nil, botResponseTimeoutErr() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + select { + case ans := <-pending.ch: + return tgBotCallbackAnswer(ans), nil + case <-ticker.C: + ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID) + if err != nil { + r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err)) + continue + } + if found { + return tgBotCallbackAnswer(ans), nil + } + case <-waitCtx.Done(): + return nil, botResponseTimeoutErr() + } } } +// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message +// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline +// ids and therefore remain an explicit blocked path instead of being misrouted here. +func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) { + if peer.Type == domain.PeerTypeUser { + msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID) + if err != nil { + return domain.BotCallbackQuery{}, internalErr() + } + if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + if msg.ViaBotID != 0 { + if !r.userIsBot(ctx, msg.ViaBotID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64) + if !ok { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.ViaBotID, UserID: userID, + ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...), + InlineMessage: domainInlineMessageID(inlineID), + }, nil + } + if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + provider, ok := r.deps.Messages.(privateMessageByUIDService) + if !ok || msg.UID == 0 { + return domain.BotCallbackQuery{}, internalErr() + } + botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID) + if err != nil { + return domain.BotCallbackQuery{}, internalErr() + } + if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID || + botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.From.ID, + UserID: userID, + Peer: botMessage.Peer, + MessageID: botMessage.ID, + ChatInstance: chatInstanceFor(msg.From.ID, userID), + Data: append([]byte(nil), data...), + }, nil + } + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return domain.BotCallbackQuery{}, peerIDInvalidErr() + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID}) + if err != nil { + return domain.BotCallbackQuery{}, channelInvalidErr(err) + } + if len(history.Messages) != 1 { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + msg := history.Messages[0] + if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + if msg.ViaBotID != 0 { + if !r.userIsBot(ctx, msg.ViaBotID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64) + if !ok { + return domain.BotCallbackQuery{}, messageIDInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.ViaBotID, UserID: userID, + ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...), + InlineMessage: domainInlineMessageID(inlineID), + }, nil + } + if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) { + return domain.BotCallbackQuery{}, dataInvalidErr() + } + return domain.BotCallbackQuery{ + BotUserID: msg.SenderUserID, + UserID: userID, + Peer: peer, + MessageID: msg.ID, + ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer), + Data: append([]byte(nil), data...), + }, nil +} + +func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID { + if id == nil { + return nil + } + return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash} +} + +func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass { + return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash} +} + +func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool { + if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline { + return false + } + for _, row := range markup.Inline { + for _, button := range row { + if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) { + return true + } + } + } + return false +} + // onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的 // getBotCallbackAnswer。仅属主 bot 可解挂(callerBotID==pending.botUserID,I6)。 func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) { @@ -106,7 +275,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes } // resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否 // 被等待者接收无关紧要(官方恒返回 true),但非属主必须拒绝投递(防钓鱼弹窗)。 - r.callbacks.resolve(botID, req.QueryID, ans) + if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil { + return false, internalErr() + } return true, nil } diff --git a/internal/rpc/bots_longtail.go b/internal/rpc/bots_longtail.go index 174f9f01..7cfbe292 100644 --- a/internal/rpc/bots_longtail.go +++ b/internal/rpc/bots_longtail.go @@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg case *tg.InputKeyboardButtonRequestPeer: out.ButtonID = b.ButtonID out.Text = strings.TrimSpace(b.Text) - out.PeerType = requestPeerTypeName(b.PeerType) + out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType) out.MaxQuantity = b.MaxQuantity + out.NameRequested = b.NameRequested + out.UsernameRequested = b.UsernameRequested + out.PhotoRequested = b.PhotoRequested case *tg.KeyboardButtonRequestPeer: out.ButtonID = b.ButtonID out.Text = strings.TrimSpace(b.Text) - out.PeerType = requestPeerTypeName(b.PeerType) + out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType) out.MaxQuantity = b.MaxQuantity default: return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() @@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key return &tg.KeyboardButtonRequestPeer{ Text: button.Text, ButtonID: button.ButtonID, - PeerType: tgRequestPeerType(button.PeerType), + PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter), MaxQuantity: button.MaxQuantity, } } diff --git a/internal/rpc/callback_registry.go b/internal/rpc/callback_registry.go index 436fc6bd..99034c45 100644 --- a/internal/rpc/callback_registry.go +++ b/internal/rpc/callback_registry.go @@ -1,22 +1,29 @@ package rpc import ( + "context" "crypto/rand" "encoding/binary" + "fmt" "hash/fnv" "sync" "sync/atomic" + "time" + + "go.uber.org/zap" "telesrv/internal/domain" + "telesrv/internal/store" ) -// callbackRegistry 是 bot callback query 的进程内挂起表:messages.getBotCallbackAnswer -// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待;bot 经 -// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道 -// (getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。 +// callbackRegistry keeps local waiter channels and mirrors ownership/answers to +// a short-lived shared store. The shared CAS is the source of truth when wired: +// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes +// without accepting two answers or trusting a process-local owner map. type callbackRegistry struct { mu sync.Mutex pending map[int64]*pendingCallback + shared store.BotCallbackRegistryStore } type pendingCallback struct { @@ -26,35 +33,77 @@ type pendingCallback struct { userID int64 } -func newCallbackRegistry() *callbackRegistry { - return &callbackRegistry{pending: make(map[int64]*pendingCallback)} +func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry { + var sharedStore store.BotCallbackRegistryStore + if len(shared) > 0 { + sharedStore = shared[0] + } + return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore} } // register 登记一次挂起的 callback,返回全局唯一 query_id 与接收通道。调用方必须 // defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。 func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) { - p := &pendingCallback{ - ch: make(chan domain.BotCallbackAnswer, 1), - done: make(chan struct{}), - botUserID: botUserID, - userID: userID, - } - c.mu.Lock() - defer c.mu.Unlock() - var queryID int64 - for { - queryID = randomNonZeroInt64() - if _, exists := c.pending[queryID]; !exists { - break + queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout) + return queryID, pending +} + +func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) { + for attempts := 0; attempts < 32; attempts++ { + p := &pendingCallback{ + ch: make(chan domain.BotCallbackAnswer, 1), + done: make(chan struct{}), + botUserID: botUserID, + userID: userID, } + c.mu.Lock() + queryID := randomNonZeroInt64() + if _, exists := c.pending[queryID]; exists { + c.mu.Unlock() + continue + } + c.pending[queryID] = p + c.mu.Unlock() + if c.shared == nil { + return queryID, p, nil + } + created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{ + QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now, + }, ttl) + if err != nil { + c.removeLocal(queryID) + return 0, nil, err + } + if created { + return queryID, p, nil + } + c.removeLocal(queryID) } - c.pending[queryID] = p - return queryID, p + return 0, nil, fmt.Errorf("allocate bot callback query id") } // deregister 移除挂起条目并关闭 done(超时/解挂后必调,幂等)。关闭 done 让仍在 // select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 ch(TOCTOU)。 func (c *callbackRegistry) deregister(queryID int64) { + c.deregisterContext(context.Background(), 0, queryID) +} + +func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) { + c.mu.Lock() + if p, ok := c.pending[queryID]; ok { + if botUserID == 0 { + botUserID = p.botUserID + } + delete(c.pending, queryID) + close(p.done) + } + c.mu.Unlock() + if c.shared != nil && botUserID > 0 { + _ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID) + } +} + +func (c *callbackRegistry) removeLocal(queryID int64) { c.mu.Lock() if p, ok := c.pending[queryID]; ok { delete(c.pending, queryID) @@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int { // resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂(callerBotID // 必须等于注册时的 botUserID,I6)。返回是否成功投递(query 未注册/已超时/非属主 → false)。 func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool { + resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans) + return resolved +} + +func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) { + if c.shared != nil { + resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans) + if err != nil || !resolved { + return resolved, err + } + c.deliver(callerBotID, queryID, ans) + return true, nil + } + return c.deliver(callerBotID, queryID, ans), nil +} + +func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool { c.mu.Lock() p, ok := c.pending[queryID] if !ok || p.botUserID != callerBotID { @@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal return true } +func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) { + if c.shared == nil { + return domain.BotCallbackAnswer{}, false, nil + } + return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID) +} + +func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) { + if r == nil || r.callbacks == nil || r.callbacks.shared == nil { + return + } + for ctx.Err() == nil { + err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) { + r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer) + }) + if ctx.Err() != nil { + return + } + if err != nil && r.log != nil { + r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err)) + } + timer := time.NewTimer(time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + // randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止 // 无限重试——熵源异常时退化为单调序列兜底(query_id 只需进程内唯一,register 的 // 撞键复核会再保证唯一性),绝不卡住整个 registry。 @@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 { } return v } + +// chatInstanceForPeer extends the stable hash to non-private chats without allowing a +// channel id to collide with a numerically equal private user id. +func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 { + h := fnv.New64a() + var buf [17]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID)) + switch peer.Type { + case domain.PeerTypeChannel: + buf[16] = 2 + default: + buf[16] = 1 + } + _, _ = h.Write(buf[:]) + v := int64(h.Sum64()) + if v == 0 { + return 1 + } + return v +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index 60a0021b..615dbf7a 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -800,21 +800,23 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights { return tg.ChatAdminRights{ - ChangeInfo: rights.ChangeInfo, - PostMessages: rights.PostMessages, - EditMessages: rights.EditMessages, - DeleteMessages: rights.DeleteMessages, - PostStories: rights.PostStories, - EditStories: rights.EditStories, - DeleteStories: rights.DeleteStories, - BanUsers: rights.BanUsers, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - AddAdmins: rights.AddAdmins, - Anonymous: rights.Anonymous, - ManageCall: rights.ManageCall, - Other: true, - ManageRanks: rights.ManageRanks, + ChangeInfo: rights.ChangeInfo, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + PostStories: rights.PostStories, + EditStories: rights.EditStories, + DeleteStories: rights.DeleteStories, + BanUsers: rights.BanUsers, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + AddAdmins: rights.AddAdmins, + Anonymous: rights.Anonymous, + ManageCall: rights.ManageCall, + Other: true, + ManageTopics: rights.ManageTopics, + ManageRanks: rights.ManageRanks, + ManageLinkedPeers: rights.ManageLinkedPeers, // manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum, // 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。 ManageDirectMessages: rights.ManageDirectMessages, @@ -840,7 +842,10 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh AddAdmins: rights.AddAdmins, Anonymous: rights.Anonymous, ManageCall: rights.ManageCall, + ManageChat: rights.Other, + ManageTopics: rights.ManageTopics, ManageRanks: rights.ManageRanks, + ManageLinkedPeers: rights.ManageLinkedPeers, ManageDirectMessages: rights.ManageDirectMessages, } } diff --git a/internal/rpc/convert_markup.go b/internal/rpc/convert_markup.go index 127df4fd..ef248d2e 100644 --- a/internal/rpc/convert_markup.go +++ b/internal/rpc/convert_markup.go @@ -1,6 +1,7 @@ package rpc import ( + "context" "errors" "github.com/iamxvbaba/td/tg" @@ -9,9 +10,30 @@ import ( "telesrv/internal/domain" ) +// validateReplyMarkupForPeer enforces the Bot API/TL boundary that reply keyboards control +// a chat input field and are not supported in broadcast channels. Inline keyboards remain +// valid in both megagroups and broadcasts. +func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error { + if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel { + return nil + } + if r == nil || r.deps.Channels == nil { + return channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID) + if err != nil { + return channelInvalidErr(err) + } + if view.Channel.Broadcast && !view.Channel.Megagroup { + return replyMarkupInvalidErr() + } + return nil +} + // P3 reply_markup 错误码(对齐官方)。 func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") } func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") } +func buttonTypeInvalidErr() error { return tgerr.New(400, "BUTTON_TYPE_INVALID") } func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") } // replyMarkupErr 把 domain 校验错误映射为客户端错误码。 @@ -21,18 +43,21 @@ func replyMarkupErr(err error) error { return buttonDataInvalidErr() case errors.Is(err, domain.ErrButtonURLInvalid): return buttonURLInvalidErr() - case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid): + case errors.Is(err, domain.ErrButtonTypeInvalid): + return buttonTypeInvalidErr() + case errors.Is(err, domain.ErrButtonInvalid): return buttonInvalidErr() default: return replyMarkupInvalidErr() } } -// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义: +// domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markup(inline +// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。 +// 语义: // - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil,不报错——对齐 // 官方「普通用户 markup 无效」,I1)。 -// - 仅 ReplyInlineMarkup 被处理;reply keyboard 家族(自定义键盘/隐藏/强制回复) -// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4)。 +// - 仅 ReplyInlineMarkup 被处理;bot 的 reply keyboard 家族在这些上下文中显式拒绝。 // - inline 行内按钮仅 callback / url;其它按钮类型(webview/game/url_auth/ // request_* 等)→ ErrButtonTypeInvalid(拒绝整条发送,绝不半实现下发)。 // - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。 @@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (* } inline, ok := markup.(*tg.ReplyInlineMarkup) if !ok { - // reply keyboard / hide / force-reply:P3 不支持,丢弃。 - return nil, nil + return nil, domain.ErrButtonTypeInvalid } parsed, err := domainInlineMarkup(inline) if err != nil { @@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (* return parsed, nil } +// domainOutgoingReplyMarkupForSender 解析普通 sendMessage/sendMedia 的完整 reply markup。 +// 非 bot 携带 markup 仍按官方权限边界静默丢弃;bot 的未知/未实现按钮则拒绝整条消息, +// 避免客户端看到一个被服务端悄悄改形的键盘。 +func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) { + if markup == nil || !senderIsBot { + return nil, nil + } + switch v := markup.(type) { + case *tg.ReplyInlineMarkup: + return domainReplyMarkupForSender(v, true) + case *tg.ReplyKeyboardMarkup: + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: make([][]domain.MarkupButton, 0, len(v.Rows)), + Resize: v.Resize, + SingleUse: v.SingleUse, + Selective: v.Selective, + Persistent: v.Persistent, + Placeholder: v.Placeholder, + } + for _, row := range v.Rows { + domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) + for _, button := range row.Buttons { + parsed, err := domainReplyKeyboardButton(button) + if err != nil { + return nil, err + } + domainRow = append(domainRow, parsed) + } + out.Keyboard = append(out.Keyboard, domainRow) + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, err + } + return out, nil + case *tg.ReplyKeyboardHide: + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: v.Selective} + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, err + } + return out, nil + case *tg.ReplyKeyboardForceReply: + out := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupForceReply, + SingleUse: v.SingleUse, + Selective: v.Selective, + Placeholder: v.Placeholder, + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, err + } + return out, nil + default: + return nil, domain.ErrButtonTypeInvalid + } +} + +func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) { + style, icon, err := domainMarkupButtonStyle(button) + if err != nil { + return domain.MarkupButton{}, err + } + base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon} + switch b := button.(type) { + case *tg.KeyboardButton: + base.Type, base.Text = domain.MarkupButtonText, b.Text + case *tg.KeyboardButtonRequestPhone: + base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text + case *tg.KeyboardButtonRequestGeoLocation: + base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text + case *tg.KeyboardButtonRequestPoll: + base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text + if quiz, ok := b.GetQuiz(); ok { + if quiz { + base.PollType = "quiz" + } else { + base.PollType = "regular" + } + } + case *tg.KeyboardButtonRequestPeer: + base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text + base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity + base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType) + case *tg.KeyboardButtonSimpleWebView: + base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL + default: + return domain.MarkupButton{}, domain.ErrButtonTypeInvalid + } + return base, nil +} + func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) { - out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))} + out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))} for _, row := range inline.Rows { domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) for _, btn := range row.Buttons { @@ -75,31 +190,119 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku } func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) { + style, icon, err := domainMarkupButtonStyle(btn) + if err != nil { + return domain.MarkupButton{}, err + } switch b := btn.(type) { case *tg.KeyboardButtonCallback: return domain.MarkupButton{ - Type: domain.MarkupButtonCallback, - Text: b.Text, - Data: append([]byte(nil), b.Data...), - RequiresPassword: b.RequiresPassword, + Type: domain.MarkupButtonCallback, + Text: b.Text, + Style: style, + IconCustomEmojiID: icon, + Data: append([]byte(nil), b.Data...), + RequiresPassword: b.RequiresPassword, }, nil case *tg.KeyboardButtonURL: return domain.MarkupButton{ - Type: domain.MarkupButtonURL, - Text: b.Text, - URL: b.URL, + Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL, + Style: style, IconCustomEmojiID: icon, }, nil + case *tg.KeyboardButtonWebView: + return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil + case *tg.KeyboardButtonSwitchInline: + peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes) + if err != nil { + return domain.MarkupButton{}, domain.ErrButtonInvalid + } + return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil + case *tg.KeyboardButtonCopy: + return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil default: // webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。 return domain.MarkupButton{}, domain.ErrButtonTypeInvalid } } -// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。 +func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) { + style, ok := btn.GetStyle() + if !ok { + return "", 0, nil + } + colors := 0 + var out domain.MarkupButtonStyle + if style.GetBgPrimary() { + colors++ + out = domain.MarkupButtonStylePrimary + } + if style.GetBgDanger() { + colors++ + out = domain.MarkupButtonStyleDanger + } + if style.GetBgSuccess() { + colors++ + out = domain.MarkupButtonStyleSuccess + } + icon, hasIcon := style.GetIcon() + if colors > 1 || (hasIcon && icon <= 0) || (colors == 0 && !hasIcon) { + return "", 0, domain.ErrButtonInvalid + } + return out, icon, nil +} + +func tgMarkupButtonStyle(btn domain.MarkupButton) (tg.KeyboardButtonStyle, bool) { + var out tg.KeyboardButtonStyle + switch btn.Style { + case domain.MarkupButtonStylePrimary: + out.SetBgPrimary(true) + case domain.MarkupButtonStyleDanger: + out.SetBgDanger(true) + case domain.MarkupButtonStyleSuccess: + out.SetBgSuccess(true) + } + if btn.IconCustomEmojiID > 0 { + out.SetIcon(btn.IconCustomEmojiID) + } + return out, btn.Style != "" || btn.IconCustomEmojiID > 0 +} + +// tgReplyMarkup 把存储的协议中立快照还原为对应 ReplyMarkup constructor。 func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass { if m.IsZero() { return nil } + switch m.Kind() { + case domain.MessageReplyMarkupKeyboard: + rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard)) + for _, row := range m.Keyboard { + buttons := make([]tg.KeyboardButtonClass, 0, len(row)) + for _, btn := range row { + buttons = append(buttons, tgReplyKeyboardButton(btn)) + } + rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons}) + } + return &tg.ReplyKeyboardMarkup{ + Resize: m.Resize, + SingleUse: m.SingleUse, + Selective: m.Selective, + Persistent: m.Persistent, + Rows: rows, + Placeholder: m.Placeholder, + } + case domain.MessageReplyMarkupHide: + return &tg.ReplyKeyboardHide{Selective: m.Selective} + case domain.MessageReplyMarkupForceReply: + return &tg.ReplyKeyboardForceReply{ + SingleUse: m.SingleUse, + Selective: m.Selective, + Placeholder: m.Placeholder, + } + case domain.MessageReplyMarkupInline: + // Continue below. + default: + return nil + } rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline)) for _, row := range m.Inline { buttons := make([]tg.KeyboardButtonClass, 0, len(row)) @@ -114,12 +317,202 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass { func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass { switch btn.Type { case domain.MarkupButtonURL: - return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL} + out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL} + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out + case domain.MarkupButtonWebView: + out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL} + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out + case domain.MarkupButtonSwitchInline: + out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer} + if len(btn.PeerTypes) > 0 { + out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes)) + } + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out + case domain.MarkupButtonCopy: + out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText} + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } + return out default: // callback out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data} if btn.RequiresPassword { out.SetRequiresPassword(true) } + if style, ok := tgMarkupButtonStyle(btn); ok { + out.SetStyle(style) + } return out } } + +func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass { + var out tg.KeyboardButtonClass + switch btn.Type { + case domain.MarkupButtonRequestPhone: + out = &tg.KeyboardButtonRequestPhone{Text: btn.Text} + case domain.MarkupButtonRequestLocation: + out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text} + case domain.MarkupButtonRequestPoll: + button := &tg.KeyboardButtonRequestPoll{Text: btn.Text} + if btn.PollType == "quiz" { + button.SetQuiz(true) + } else if btn.PollType == "regular" { + button.SetQuiz(false) + } + out = button + case domain.MarkupButtonRequestPeer: + out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity} + case domain.MarkupButtonSimpleWebView: + out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL} + default: + out = &tg.KeyboardButton{Text: btn.Text} + } + if style, ok := tgMarkupButtonStyle(btn); ok { + if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok { + setter.SetStyle(style) + } + } + return out +} + +func domainRequestPeerFilter(peerType tg.RequestPeerTypeClass) (string, *domain.BotRequestPeerFilter) { + filter := &domain.BotRequestPeerFilter{} + switch v := peerType.(type) { + case *tg.RequestPeerTypeUser: + if value, ok := v.GetBot(); ok { + filter.UserIsBotSet, filter.UserIsBot = true, value + } + if value, ok := v.GetPremium(); ok { + filter.UserIsPremiumSet, filter.UserIsPremium = true, value + } + if !filter.UserIsBotSet && !filter.UserIsPremiumSet { + return "user", nil + } + return "user", filter + case *tg.RequestPeerTypeChat: + filter.ChatIsCreated, filter.BotIsMember = v.Creator, v.BotParticipant + if value, ok := v.GetHasUsername(); ok { + filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value + } + if value, ok := v.GetForum(); ok { + filter.ChatIsForumSet, filter.ChatIsForum = true, value + } + if rights, ok := v.GetUserAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.UserAdminRights = &mapped + } + if rights, ok := v.GetBotAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.BotAdminRights = &mapped + } + if botRequestPeerFilterZero(filter) { + return "chat", nil + } + return "chat", filter + case *tg.RequestPeerTypeBroadcast: + filter.ChatIsCreated = v.Creator + if value, ok := v.GetHasUsername(); ok { + filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value + } + if rights, ok := v.GetUserAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.UserAdminRights = &mapped + } + if rights, ok := v.GetBotAdminRights(); ok { + mapped := domainBotRequestAdminRights(rights) + filter.BotAdminRights = &mapped + } + if botRequestPeerFilterZero(filter) { + return "broadcast", nil + } + return "broadcast", filter + default: + return "", nil + } +} + +func botRequestPeerFilterZero(filter *domain.BotRequestPeerFilter) bool { + return filter == nil || (!filter.UserIsBotSet && !filter.UserIsPremiumSet && !filter.ChatHasUsernameSet && + !filter.ChatIsForumSet && !filter.ChatIsCreated && !filter.BotIsMember && filter.UserAdminRights == nil && filter.BotAdminRights == nil) +} + +func tgRequestPeerTypeWithFilter(kind string, filter *domain.BotRequestPeerFilter) tg.RequestPeerTypeClass { + switch kind { + case "chat": + out := &tg.RequestPeerTypeChat{} + if filter != nil { + out.Creator, out.BotParticipant = filter.ChatIsCreated, filter.BotIsMember + if filter.ChatHasUsernameSet { + out.SetHasUsername(filter.ChatHasUsername) + } + if filter.ChatIsForumSet { + out.SetForum(filter.ChatIsForum) + } + if filter.UserAdminRights != nil { + out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights)) + } + if filter.BotAdminRights != nil { + out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights)) + } + } + return out + case "broadcast": + out := &tg.RequestPeerTypeBroadcast{} + if filter != nil { + out.Creator = filter.ChatIsCreated + if filter.ChatHasUsernameSet { + out.SetHasUsername(filter.ChatHasUsername) + } + if filter.UserAdminRights != nil { + out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights)) + } + if filter.BotAdminRights != nil { + out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights)) + } + } + return out + default: + out := &tg.RequestPeerTypeUser{} + if filter != nil { + if filter.UserIsBotSet { + out.SetBot(filter.UserIsBot) + } + if filter.UserIsPremiumSet { + out.SetPremium(filter.UserIsPremium) + } + } + return out + } +} + +func domainBotRequestAdminRights(rights tg.ChatAdminRights) domain.BotRequestAdminRights { + return domain.BotRequestAdminRights{ + Anonymous: rights.Anonymous, ManageChat: rights.Other, DeleteMessages: rights.DeleteMessages, + ManageVideoChats: rights.ManageCall, RestrictMembers: rights.BanUsers, PromoteMembers: rights.AddAdmins, + ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories, + EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics, + ManageDirectMessages: rights.ManageDirectMessages, + } +} + +func tgBotRequestAdminRights(rights domain.BotRequestAdminRights) tg.ChatAdminRights { + return tg.ChatAdminRights{ + Anonymous: rights.Anonymous, Other: rights.ManageChat, DeleteMessages: rights.DeleteMessages, + ManageCall: rights.ManageVideoChats, BanUsers: rights.RestrictMembers, AddAdmins: rights.PromoteMembers, + ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories, + EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics, + ManageDirectMessages: rights.ManageDirectMessages, + } +} diff --git a/internal/rpc/convert_markup_test.go b/internal/rpc/convert_markup_test.go new file mode 100644 index 00000000..a5336e79 --- /dev/null +++ b/internal/rpc/convert_markup_test.go @@ -0,0 +1,171 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/tg" + + "telesrv/internal/domain" +) + +func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) { + in := &tg.ReplyKeyboardMarkup{ + Resize: true, + SingleUse: true, + Selective: true, + Persistent: true, + Placeholder: "Choose", + Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButton{Text: "Help"}, + func() *tg.KeyboardButton { + button := &tg.KeyboardButton{Text: "Status"} + style := tg.KeyboardButtonStyle{} + style.SetBgPrimary(true) + style.SetIcon(123456) + button.SetStyle(style) + return button + }(), + }}}, + } + got, err := domainOutgoingReplyMarkupForSender(in, true) + if err != nil { + t.Fatalf("domainOutgoingReplyMarkupForSender: %v", err) + } + if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 || + len(got.Keyboard[0]) != 2 || got.Keyboard[0][0].Text != "Help" || !got.Resize || + !got.SingleUse || !got.Selective || !got.Persistent || got.Placeholder != "Choose" { + t.Fatalf("domain markup = %#v", got) + } + if got.Keyboard[0][1].Style != domain.MarkupButtonStylePrimary || got.Keyboard[0][1].IconCustomEmojiID != 123456 { + t.Fatalf("second button decoration = %#v", got.Keyboard[0][1]) + } + wire, ok := tgReplyMarkup(got).(*tg.ReplyKeyboardMarkup) + if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 { + t.Fatalf("wire markup = %#v", wire) + } + if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" { + t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1]) + } else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 { + t.Fatalf("second button style = %#v ok=%v", style, ok) + } + if !wire.Resize || !wire.SingleUse || !wire.Selective || !wire.Persistent || wire.Placeholder != "Choose" { + t.Fatalf("wire flags = %#v", wire) + } +} + +func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) { + button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")} + style := tg.KeyboardButtonStyle{} + style.SetBgDanger(true) + button.SetStyle(style) + got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true) + if err != nil { + t.Fatalf("domainReplyMarkupForSender: %v", err) + } + if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger { + t.Fatalf("domain style = %#v", got.Inline[0][0]) + } + wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback) + if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() { + t.Fatalf("wire style = %#v ok=%v", roundTrip, ok) + } +} + +func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) { + hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true) + if err != nil { + t.Fatalf("hide parse: %v", err) + } + if wire, ok := tgReplyMarkup(hide).(*tg.ReplyKeyboardHide); !ok || !wire.Selective { + t.Fatalf("hide wire = %#v", wire) + } + force, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardForceReply{ + SingleUse: true, Selective: true, Placeholder: "Answer", + }, true) + if err != nil { + t.Fatalf("force parse: %v", err) + } + if wire, ok := tgReplyMarkup(force).(*tg.ReplyKeyboardForceReply); !ok || !wire.SingleUse || !wire.Selective || wire.Placeholder != "Answer" { + t.Fatalf("force wire = %#v", wire) + } +} + +func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) { + markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}}, + }}}, true) + if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 || + markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone { + t.Fatalf("request_phone markup = %#v err=%v", markup, err) + } + wire, ok := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup) + if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 { + t.Fatalf("request_phone wire = %#v", wire) + } + if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok { + t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0]) + } + if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil { + t.Fatal("inline-only edit/result parser must reject reply-keyboard constructors") + } +} + +func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) { + userType := &tg.RequestPeerTypeUser{} + userType.SetBot(false) + userType.SetPremium(true) + chatType := &tg.RequestPeerTypeChat{Creator: true, BotParticipant: true} + chatType.SetHasUsername(false) + chatType.SetForum(true) + chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true}) + in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2}, + &tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1}, + }}}} + markup, err := domainOutgoingReplyMarkupForSender(in, true) + if err != nil { + t.Fatalf("parse request peer filters: %v", err) + } + userFilter := markup.Keyboard[0][0].RequestPeerFilter + chatFilter := markup.Keyboard[0][1].RequestPeerFilter + if userFilter == nil || !userFilter.UserIsBotSet || userFilter.UserIsBot || !userFilter.UserIsPremiumSet || !userFilter.UserIsPremium { + t.Fatalf("user filter = %#v", userFilter) + } + if chatFilter == nil || !chatFilter.ChatIsCreated || !chatFilter.BotIsMember || !chatFilter.ChatHasUsernameSet || + chatFilter.ChatHasUsername || !chatFilter.ChatIsForumSet || !chatFilter.ChatIsForum || + chatFilter.UserAdminRights == nil || !chatFilter.UserAdminRights.DeleteMessages || !chatFilter.UserAdminRights.ManageTopics { + t.Fatalf("chat filter = %#v", chatFilter) + } + wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup) + wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser) + if bot, ok := wireUser.GetBot(); !ok || bot { + t.Fatalf("wire user bot=%v ok=%v", bot, ok) + } + if premium, ok := wireUser.GetPremium(); !ok || !premium { + t.Fatalf("wire user premium=%v ok=%v", premium, ok) + } + wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat) + if !wireChat.Creator || !wireChat.BotParticipant { + t.Fatalf("wire chat = %#v", wireChat) + } + if hasUsername, ok := wireChat.GetHasUsername(); !ok || hasUsername { + t.Fatalf("wire has_username=%v ok=%v", hasUsername, ok) + } + if rights, ok := wireChat.GetUserAdminRights(); !ok || !rights.DeleteMessages || !rights.ManageTopics { + t.Fatalf("wire rights=%#v ok=%v", rights, ok) + } +} + +func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) { + button := &tg.InputKeyboardButtonRequestPeer{ + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3, + } + got, err := domainRequestedButtonFromTG(1001, nil, button) + if err != nil { + t.Fatal(err) + } + if !got.NameRequested || !got.UsernameRequested || !got.PhotoRequested || got.MaxQuantity != 3 { + t.Fatalf("requested button=%#v", got) + } +} diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index a24e7883..8715cb1b 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass { msg.SetInvertMedia(true) } } - // reply_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。 + // reply_markup(bot reply/inline keyboard):仅普通 tg.Message 携带(service 消息不带)。 if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { msg.SetReplyMarkup(markup) } @@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass { if msg.Out { return &tg.MessageActionRequestedPeerSentMe{ ButtonID: shared.ButtonID, - Peers: tgRequestedPeers(shared.Peers), + Peers: tgRequestedPeers(shared), } } return &tg.MessageActionRequestedPeer{ @@ -294,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass { return out } -func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass { - out := make([]tg.RequestedPeerClass, 0, len(peers)) - for _, peer := range peers { +func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass { + if action == nil { + return nil + } + details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details)) + for _, detail := range action.Details { + details[detail.Peer] = detail + } + out := make([]tg.RequestedPeerClass, 0, len(action.Peers)) + for _, peer := range action.Peers { + detail := details[peer] switch peer.Type { case domain.PeerTypeUser: - out = append(out, &tg.RequestedPeerUser{UserID: peer.ID}) + item := &tg.RequestedPeerUser{UserID: peer.ID} + if action.NameRequested { + item.SetFirstName(detail.FirstName) + item.SetLastName(detail.LastName) + } + if action.UsernameRequested { + item.SetUsername(detail.Username) + } + if action.PhotoRequested && detail.Photo != nil { + item.SetPhoto(tgPhoto(*detail.Photo)) + } + out = append(out, item) case domain.PeerTypeChannel: - out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID}) + item := &tg.RequestedPeerChannel{ChannelID: peer.ID} + if action.NameRequested { + item.SetTitle(detail.Title) + } + if action.UsernameRequested { + item.SetUsername(detail.Username) + } + if action.PhotoRequested && detail.Photo != nil { + item.SetPhoto(tgPhoto(*detail.Photo)) + } + out = append(out, item) } } return out diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 1aded8b5..feb34456 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -821,6 +821,7 @@ type Deps struct { Updates UpdatesService BootstrapUpdates store.BootstrapUpdateJobStore BotAPIUpdates store.BotAPIUpdateStore + BotCallbacks store.BotCallbackRegistryStore Contacts ContactsService Dialogs DialogsService Chatlists ChatlistsService diff --git a/internal/rpc/messages_bot_longtail.go b/internal/rpc/messages_bot_longtail.go index 68cf4f81..513122a8 100644 --- a/internal/rpc/messages_bot_longtail.go +++ b/internal/rpc/messages_bot_longtail.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "fmt" "strconv" "strings" "unicode/utf8" @@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages if err != nil { return nil, messageSendErr(err) } + r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res) var users []tg.UserClass var chats []tg.ChatClass if !res.Duplicate { @@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes if !found || !botUser.Bot { return nil, botInvalidErr() } - webAppReqID, ok := req.GetWebappReqID() - if !ok || webAppReqID == "" { - return nil, buttonDataInvalidErr() - } - button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) - if err != nil { - return nil, internalErr() - } - if !found || button.ButtonID != req.ButtonID { - return nil, buttonDataInvalidErr() + webAppReqID, fromWebApp := req.GetWebappReqID() + idempotencyKey := webAppReqID + var button domain.BotRequestedWebViewButton + if fromWebApp { + if webAppReqID == "" { + return nil, buttonDataInvalidErr() + } + var found bool + button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + if err != nil { + return nil, internalErr() + } + if !found || button.ButtonID != req.ButtonID { + return nil, buttonDataInvalidErr() + } + } else { + idempotencyKey = "message:" + strconv.Itoa(req.MsgID) + button, err = r.requestPeerButtonFromMessage(ctx, userID, botUser.ID, req.MsgID, req.ButtonID) + if err != nil { + return nil, err + } } if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity { return nil, buttonDataInvalidErr() @@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes if err != nil { return nil, err } - if !requestedPeerTypeMatches(button.PeerType, resolved) { + if matches, err := r.requestedPeerMatches(ctx, userID, botUser.ID, button, resolved); err != nil { + return nil, internalErr() + } else if !matches { return nil, buttonDataInvalidErr() } peers = append(peers, resolved) } + details, err := r.requestedPeerDetails(ctx, userID, peers, button) + if err != nil { + return nil, internalErr() + } recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID) if err != nil { return nil, err @@ -134,14 +153,18 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ SenderUserID: userID, RecipientUserID: botUser.ID, - RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers), + RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers), Media: &domain.MessageMedia{ Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{ - ButtonID: button.ButtonID, - Peers: peers, + ButtonID: button.ButtonID, + Peers: peers, + Details: details, + NameRequested: button.NameRequested, + UsernameRequested: button.UsernameRequested, + PhotoRequested: button.PhotoRequested, }, }, }, @@ -153,7 +176,10 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes if err != nil { return nil, internalErr() } - _ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res) + if fromWebApp { + _ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + } var users []tg.UserClass var chats []tg.ChatClass if !res.Duplicate { @@ -163,6 +189,128 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil } +type requestedPeerPhotoProvider interface { + GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) +} + +func (r *Router) requestedPeerDetails(ctx context.Context, viewerUserID int64, peers []domain.Peer, button domain.BotRequestedWebViewButton) ([]domain.MessageRequestedPeerDetails, error) { + details := make([]domain.MessageRequestedPeerDetails, len(peers)) + for i, peer := range peers { + details[i].Peer = peer + } + if !button.NameRequested && !button.UsernameRequested && !button.PhotoRequested { + return details, nil + } + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, peer := range peers { + addDomainPeerRef(peer, 0, userIDs, channelIDs) + } + cache := newViewerPeerCache(r) + users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs)) + channels := cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs)) + userByID := make(map[int64]domain.User, len(users)) + channelByID := make(map[int64]domain.Channel, len(channels)) + photoIDs := make([]int64, 0, len(peers)) + for _, user := range users { + userByID[user.ID] = user + if button.PhotoRequested && user.PhotoID != 0 { + photoIDs = append(photoIDs, user.PhotoID) + } + } + for _, channel := range channels { + channelByID[channel.ID] = channel + if button.PhotoRequested && channel.PhotoID != 0 { + photoIDs = append(photoIDs, channel.PhotoID) + } + } + photoByID := make(map[int64]domain.Photo, len(photoIDs)) + if len(photoIDs) > 0 { + provider, ok := r.deps.Files.(requestedPeerPhotoProvider) + if !ok { + return nil, fmt.Errorf("requested peer photo provider unavailable") + } + photos, err := provider.GetPhotos(ctx, photoIDs) + if err != nil { + return nil, err + } + for _, photo := range photos { + photoByID[photo.ID] = photo + } + } + for i, peer := range peers { + detail := &details[i] + switch peer.Type { + case domain.PeerTypeUser: + user, ok := userByID[peer.ID] + if !ok { + return nil, fmt.Errorf("requested user %d not hydrated", peer.ID) + } + if button.NameRequested { + detail.FirstName, detail.LastName = user.FirstName, user.LastName + } + if button.UsernameRequested { + detail.Username = user.Username + } + if button.PhotoRequested && user.PhotoID != 0 { + photo, ok := photoByID[user.PhotoID] + if !ok { + return nil, fmt.Errorf("requested user photo %d missing", user.PhotoID) + } + detail.Photo = &photo + } + case domain.PeerTypeChannel: + channel, ok := channelByID[peer.ID] + if !ok { + return nil, fmt.Errorf("requested channel %d not hydrated", peer.ID) + } + if button.NameRequested { + detail.Title = channel.Title + } + if button.UsernameRequested { + detail.Username = channel.Username + } + if button.PhotoRequested && channel.PhotoID != 0 { + photo, ok := photoByID[channel.PhotoID] + if !ok { + return nil, fmt.Errorf("requested channel photo %d missing", channel.PhotoID) + } + detail.Photo = &photo + } + } + } + return details, nil +} + +func (r *Router) requestPeerButtonFromMessage(ctx context.Context, userID, botUserID int64, messageID, buttonID int) (domain.BotRequestedWebViewButton, error) { + if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID == 0 { + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() + } + message, found, err := r.lookupOwnerMessage(ctx, userID, messageID) + if err != nil { + return domain.BotRequestedWebViewButton{}, internalErr() + } + if !found || message.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || + message.From != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || message.ReplyMarkup == nil || + message.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard { + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() + } + for _, row := range message.ReplyMarkup.Keyboard { + for _, item := range row { + if item.Type != domain.MarkupButtonRequestPeer || item.ButtonID != buttonID { + continue + } + return domain.BotRequestedWebViewButton{ + BotUserID: botUserID, UserID: userID, ButtonID: item.ButtonID, + PeerType: item.RequestPeerType, MaxQuantity: item.MaxQuantity, PeerFilter: item.RequestPeerFilter, + NameRequested: item.NameRequested, UsernameRequested: item.UsernameRequested, + PhotoRequested: item.PhotoRequested, + }, nil + } + } + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() +} + func requestedPeerTypeMatches(kind string, peer domain.Peer) bool { switch kind { case "user", "": @@ -174,6 +322,97 @@ func requestedPeerTypeMatches(kind string, peer domain.Peer) bool { } } +func (r *Router) requestedPeerMatches(ctx context.Context, userID, botUserID int64, button domain.BotRequestedWebViewButton, peer domain.Peer) (bool, error) { + if !requestedPeerTypeMatches(button.PeerType, peer) { + return false, nil + } + filter := button.PeerFilter + if filter == nil { + return true, nil + } + if peer.Type == domain.PeerTypeUser { + if r.deps.Users == nil { + return false, nil + } + user, found, err := r.deps.Users.ByID(ctx, userID, peer.ID) + if err != nil || !found { + return false, err + } + if filter.UserIsBotSet && user.Bot != filter.UserIsBot { + return false, nil + } + if filter.UserIsPremiumSet && user.PremiumActiveAt(r.clock.Now().Unix()) != filter.UserIsPremium { + return false, nil + } + return true, nil + } + if r.deps.Channels == nil { + return false, nil + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID) + if err != nil { + return false, err + } + channel := view.Channel + if button.PeerType == "chat" && (!channel.Megagroup || channel.Broadcast) { + return false, nil + } + if button.PeerType == "broadcast" && !channel.Broadcast { + return false, nil + } + if filter.ChatHasUsernameSet && (channel.Username != "") != filter.ChatHasUsername { + return false, nil + } + if filter.ChatIsForumSet && channel.Forum != filter.ChatIsForum { + return false, nil + } + if filter.ChatIsCreated && view.Self.Role != domain.ChannelRoleCreator { + return false, nil + } + if filter.UserAdminRights != nil && !channelMemberHasRequestRights(view.Self, *filter.UserAdminRights) { + return false, nil + } + if filter.BotIsMember || filter.BotAdminRights != nil { + botMember, err := r.deps.Channels.GetParticipant(ctx, userID, peer.ID, botUserID) + if err != nil { + return false, err + } + if botMember.Status != domain.ChannelMemberActive { + return false, nil + } + if filter.BotAdminRights != nil && !channelMemberHasRequestRights(botMember, *filter.BotAdminRights) { + return false, nil + } + } + return true, nil +} + +func channelMemberHasRequestRights(member domain.ChannelMember, required domain.BotRequestAdminRights) bool { + if member.Role == domain.ChannelRoleCreator { + return true + } + if member.Role != domain.ChannelRoleAdmin { + return false + } + rights := member.AdminRights + return (!required.Anonymous || rights.Anonymous) && + (!required.ManageChat || rights.ManageChat) && + (!required.DeleteMessages || rights.DeleteMessages) && + (!required.ManageVideoChats || rights.ManageCall) && + (!required.RestrictMembers || rights.BanUsers) && + (!required.PromoteMembers || rights.AddAdmins) && + (!required.ChangeInfo || rights.ChangeInfo) && + (!required.InviteUsers || rights.InviteUsers) && + (!required.PostStories || rights.PostStories) && + (!required.EditStories || rights.EditStories) && + (!required.DeleteStories || rights.DeleteStories) && + (!required.PostMessages || rights.PostMessages) && + (!required.EditMessages || rights.EditMessages) && + (!required.PinMessages || rights.PinMessages) && + (!required.ManageTopics || rights.ManageTopics) && + (!required.ManageDirectMessages || rights.ManageDirectMessages) +} + func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 { parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)} for _, peer := range peers { diff --git a/internal/rpc/messages_bot_longtail_rpc_test.go b/internal/rpc/messages_bot_longtail_rpc_test.go index 7f92bd87..4d999d68 100644 --- a/internal/rpc/messages_bot_longtail_rpc_test.go +++ b/internal/rpc/messages_bot_longtail_rpc_test.go @@ -11,11 +11,13 @@ import ( "telesrv/internal/domain" "telesrv/internal/store" + "telesrv/internal/store/memory" ) func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) { ctx := context.Background() f := newInlineBotRPCTestFixture(t) + f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore() ownerCtx := WithUserID(ctx, f.owner.ID) updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ @@ -46,6 +48,13 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) { if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID { t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID) } + botAPIEvents, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0) + if err != nil || len(botAPIEvents) != 1 || botAPIEvents[0].Message.Media == nil || + botAPIEvents[0].Message.Media.ServiceAction == nil || + botAPIEvents[0].Message.Media.ServiceAction.WebViewData == nil || + botAPIEvents[0].Message.Media.ServiceAction.WebViewData.Data != `{"ok":true}` { + t.Fatalf("bot api webview events=%#v err=%v", botAPIEvents, err) + } botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{ HasPeer: true, @@ -140,6 +149,41 @@ func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing } } +func TestMessagesSendBotRequestedPeerQueuesBotAPIResponse(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore() + ownerCtx := WithUserID(ctx, f.owner.ID) + button := domain.MarkupButton{ + Type: domain.MarkupButtonRequestPeer, Text: "Share user", ButtonID: 77, + RequestPeerType: "user", MaxQuantity: 1, NameRequested: true, UsernameRequested: true, + } + requestMessage, err := f.router.deps.Messages.SendPrivateText(ctx, f.bot.ID, domain.SendPrivateTextRequest{ + SenderUserID: f.bot.ID, RecipientUserID: f.owner.ID, RandomID: 7001, Message: "Choose", + ReplyMarkup: &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupKeyboard, Keyboard: [][]domain.MarkupButton{{button}}}, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("send request message: %v", err) + } + if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{ + Peer: inputPeerUser(f.bot), MsgID: requestMessage.RecipientMessage.ID, ButtonID: button.ButtonID, + RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)}, + }); err != nil { + t.Fatalf("send requested peer: %v", err) + } + events, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0) + if err != nil || len(events) != 1 { + t.Fatalf("bot api requested-peer events=%#v err=%v", events, err) + } + action := events[0].Message.Media.ServiceAction.RequestedPeer + if action == nil || action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0].ID != f.peer.ID || + len(action.Details) != 1 || action.Details[0].Peer != action.Peers[0] || action.Details[0].FirstName != f.peer.FirstName || + !action.NameRequested || !action.UsernameRequested { + t.Fatalf("requested-peer action=%#v", action) + } +} + func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) { ctx := context.Background() f := newInlineBotRPCTestFixture(t) diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index a81715a7..5906cff8 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -169,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = err return nil, sendErr } - // reply_markup(bot inline keyboard):仅 bot 账号发送被接受+校验;非 bot 静默丢弃。 + // reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force; + // 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。 // 仅在请求携带 markup 时才查 is_bot,避免普通发送多打一次查询。 var replyMarkup *domain.MessageReplyMarkup if req.ReplyMarkup != nil { - replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) + replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) if err != nil { sendErr = replyMarkupErr(err) return nil, sendErr } + if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil { + sendErr = err + return nil, sendErr + } } // rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。 // Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。 diff --git a/internal/rpc/router.go b/internal/rpc/router.go index b90c31c4..52801173 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -93,7 +93,7 @@ type Config struct { // Router 把解密后的 RPC 请求按 semantic method 路由到 typed handler(tlprofile.Dispatcher)。 // -// handler 输入输出均为 iamxvbaba/td/tg 类型,各业务域的 handler +// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler // 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳: // 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。 type Router struct { @@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { if instanceID == "" { instanceID = fmt.Sprintf("%016x", randomNonZeroInt64()) } - r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID} + r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID} r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer) r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer) r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency) diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index bd69d3a5..2c90df66 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -37,7 +37,7 @@ type outgoingSend struct { sendAs *domain.Peer sendAsReady bool clearDraft bool - // replyMarkup 是 bot inline keyboard(已解析+校验;非 bot 恒 nil)。 + // replyMarkup 是 bot reply/inline keyboard(已解析+校验;非 bot 恒 nil)。 replyMarkup *domain.MessageReplyMarkup viaBotID int64 // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。 @@ -382,13 +382,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if media == nil { return nil, mediaInvalidErr() } - // reply_markup(bot inline keyboard on media):仅 bot 接受+校验,非 bot 静默丢弃。 + // reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force; + // 非 bot 静默丢弃。 var replyMarkup *domain.MessageReplyMarkup if req.ReplyMarkup != nil { - replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) + replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) if err != nil { return nil, replyMarkupErr(err) } + if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil { + return nil, err + } } if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{ diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go index a91be7b0..0cab9519 100644 --- a/internal/rpc/update_peer_refs.go +++ b/internal/rpc/update_peer_refs.go @@ -45,6 +45,9 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser addDomainPeerRef(peer, 0, userIDs, channelIDs) } collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs) + if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 { + userIDs[out[i].BotCallbackQuery.UserID] = struct{}{} + } removeKnownChannelRefs(channelIDs, out[i].Channels) refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs} for id := range userIDs { @@ -220,6 +223,11 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { userIDs[msg.Media.Contact.UserID] = struct{}{} } + if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil { + for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers { + addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs) + } + } collectPollMediaUserRefs(msg.Media, userIDs) collectTodoMediaUserRefs(msg.Media, userIDs) if msg.Reactions != nil { diff --git a/internal/store/bot_callback.go b/internal/store/bot_callback.go new file mode 100644 index 00000000..cfe422bc --- /dev/null +++ b/internal/store/bot_callback.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// BotCallbackPending is the short-lived, protocol-neutral ownership record for +// one messages.getBotCallbackAnswer request. It is deliberately ephemeral: the +// durable Bot API update remains in BotAPIUpdateStore, while this record only +// coordinates the synchronous client answer across server instances. +type BotCallbackPending struct { + QueryID int64 + BotUserID int64 + UserID int64 + CreatedAt time.Time +} + +type BotCallbackAnswerPush struct { + QueryID int64 + BotUserID int64 + Answer domain.BotCallbackAnswer +} + +// BotCallbackRegistryStore coordinates callback waiters across processes. +// Implementations must make Put and Resolve atomic: query ids cannot be +// overwritten and at most one answer may win for the owning bot. +type BotCallbackRegistryStore interface { + PutBotCallbackPending(ctx context.Context, pending BotCallbackPending, ttl time.Duration) (bool, error) + ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error) + GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) + DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error + SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, BotCallbackAnswerPush)) error +} diff --git a/internal/store/botapi_update.go b/internal/store/botapi_update.go index c76a9a33..0aa13cac 100644 --- a/internal/store/botapi_update.go +++ b/internal/store/botapi_update.go @@ -2,14 +2,40 @@ package store import ( "context" + "time" "telesrv/internal/domain" ) +// BotAPIPollLeaseStore serializes getUpdates across all HTTP gateway +// instances. owner is an opaque per-request token; Release must be compare-and- +// delete so a stale request cannot release a successor's lease. +type BotAPIPollLeaseStore interface { + AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error +} + +// BotAPIWebhookStore coordinates durable webhook configuration and delivery +// leases. Delivery itself stays at the HTTP edge; this store only owns state. +type BotAPIWebhookStore interface { + SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error + DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error + BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) + ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) + AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) + ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error + RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error + RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error +} + // BotAPIUpdateStore persists update_id based Bot API delivery queues. type BotAPIUpdateStore interface { EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) + ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) ConfirmBotAPIUpdates(ctx context.Context, botUserID, confirmedUpdateID int64) error ConfirmedBotAPIUpdateID(ctx context.Context, botUserID int64) (int64, bool, error) + SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error + DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error + PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) } diff --git a/internal/store/memory/botapi_update.go b/internal/store/memory/botapi_update.go index 1703fda4..3230a7f3 100644 --- a/internal/store/memory/botapi_update.go +++ b/internal/store/memory/botapi_update.go @@ -3,29 +3,228 @@ package memory import ( "context" "fmt" + "slices" "sync" + "time" "telesrv/internal/domain" ) // BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore. type BotAPIUpdateStore struct { - mu sync.RWMutex - nextID int64 - rows []domain.BotAPIUpdate - state map[int64]int64 - byKey map[string]int64 + mu sync.RWMutex + nextID int64 + rows []domain.BotAPIUpdate + state map[int64]int64 + cursorInitialized map[int64]bool + allowed map[int64]map[domain.BotAPIUpdateKind]struct{} + byKey map[string]int64 + pollLeases map[int64]botAPIPollLease + webhooks map[int64]domain.BotAPIWebhook + webhookLeases map[int64]botAPIPollLease +} + +type botAPIPollLease struct { + owner string + expiresAt time.Time } // NewBotAPIUpdateStore creates an in-memory Bot API update queue. func NewBotAPIUpdateStore() *BotAPIUpdateStore { return &BotAPIUpdateStore{ - nextID: 1, - state: make(map[int64]int64), - byKey: make(map[string]int64), + nextID: 1, + state: make(map[int64]int64), + cursorInitialized: make(map[int64]bool), + allowed: make(map[int64]map[domain.BotAPIUpdateKind]struct{}), + byKey: make(map[string]int64), + pollLeases: make(map[int64]botAPIPollLease), + webhooks: make(map[int64]domain.BotAPIWebhook), + webhookLeases: make(map[int64]botAPIPollLease), } } +func (s *BotAPIUpdateStore) SetBotAPIWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error { + if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 { + return fmt.Errorf("invalid bot api webhook") + } + s.mu.Lock() + defer s.mu.Unlock() + if !config.AllowedUpdatesSet { + config.AllowedUpdates = allowedUpdateKinds(s.allowed[config.BotUserID]) + } + config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...) + config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, "" + config.NextAttemptAt = time.Now() + s.webhooks[config.BotUserID] = config + if config.AllowedUpdates == nil { + delete(s.allowed, config.BotUserID) + } else { + allowed := make(map[domain.BotAPIUpdateKind]struct{}, len(config.AllowedUpdates)) + for _, kind := range config.AllowedUpdates { + allowed[kind] = struct{}{} + } + s.allowed[config.BotUserID] = allowed + } + if dropPending { + s.dropPendingLocked(config.BotUserID) + } + return nil +} + +func allowedUpdateKinds(items map[domain.BotAPIUpdateKind]struct{}) []domain.BotAPIUpdateKind { + if len(items) == 0 { + return nil + } + out := make([]domain.BotAPIUpdateKind, 0, len(items)) + for kind := range items { + out = append(out, kind) + } + slices.Sort(out) + return out +} + +func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(_ context.Context, botUserID int64, dropPending bool) error { + s.mu.Lock() + delete(s.webhooks, botUserID) + delete(s.webhookLeases, botUserID) + if dropPending { + s.dropPendingLocked(botUserID) + } + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) BotAPIWebhook(_ context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) { + s.mu.RLock() + config, found := s.webhooks[botUserID] + s.mu.RUnlock() + config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...) + return config, found, nil +} + +func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(_ context.Context, limit int) ([]domain.BotAPIWebhook, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + now := time.Now() + s.mu.RLock() + out := make([]domain.BotAPIWebhook, 0, min(limit, len(s.webhooks))) + for botID, config := range s.webhooks { + lease := s.webhookLeases[botID] + if config.NextAttemptAt.After(now) || (lease.owner != "" && lease.expiresAt.After(now)) { + continue + } + config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...) + out = append(out, config) + if len(out) == limit { + break + } + } + s.mu.RUnlock() + return out, nil +} + +func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api webhook lease") + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + if _, found := s.webhooks[botUserID]; !found { + return false, nil + } + current := s.webhookLeases[botUserID] + if current.owner != "" && current.owner != owner && current.expiresAt.After(now) { + return false, nil + } + s.webhookLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)} + return true, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(_ context.Context, botUserID int64, owner string) error { + s.mu.Lock() + if current := s.webhookLeases[botUserID]; current.owner == owner { + delete(s.webhookLeases, botUserID) + } + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(_ context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error { + s.mu.Lock() + defer s.mu.Unlock() + if current := s.webhookLeases[botUserID]; current.owner != owner { + return nil + } + config, found := s.webhooks[botUserID] + if !found { + delete(s.webhookLeases, botUserID) + return nil + } + config.FailureCount++ + config.LastErrorDate = int(time.Now().Unix()) + if len(message) > 512 { + message = message[:512] + } + config.LastErrorMessage = message + config.NextAttemptAt = nextAttempt + s.webhooks[botUserID] = config + delete(s.webhookLeases, botUserID) + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(_ context.Context, botUserID int64, owner string, nextAttempt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if current := s.webhookLeases[botUserID]; current.owner != owner { + return nil + } + if config, found := s.webhooks[botUserID]; found { + config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, "" + config.NextAttemptAt = nextAttempt + s.webhooks[botUserID] = config + } + delete(s.webhookLeases, botUserID) + return nil +} + +func (s *BotAPIUpdateStore) dropPendingLocked(botUserID int64) { + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > s.state[botUserID] { + s.state[botUserID] = row.ID + } + } + s.cursorInitialized[botUserID] = true +} + +func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api poll lease") + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + current, found := s.pollLeases[botUserID] + if found && current.owner != owner && current.expiresAt.After(now) { + return false, nil + } + s.pollLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)} + return true, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(_ context.Context, botUserID int64, owner string) error { + if botUserID <= 0 || owner == "" { + return nil + } + s.mu.Lock() + if current, found := s.pollLeases[botUserID]; found && current.owner == owner { + delete(s.pollLeases, botUserID) + } + s.mu.Unlock() + return nil +} + func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) { if err := validateBotAPIUpdateRequest(req); err != nil { return domain.BotAPIUpdate{}, false, err @@ -33,6 +232,11 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En key := botAPIUpdateKey(req) s.mu.Lock() defer s.mu.Unlock() + if allowed, configured := s.allowed[req.BotUserID]; configured { + if _, ok := allowed[req.Kind]; !ok { + return domain.BotAPIUpdate{}, false, nil + } + } if existingID, ok := s.byKey[key]; ok { for _, row := range s.rows { if row.ID == existingID { @@ -48,13 +252,56 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En MessageID: req.MessageID, SourcePts: req.SourcePts, Date: req.Date, + Callback: cloneBotAPICallback(req.Callback), } s.nextID++ s.rows = append(s.rows, row) s.byKey[key] = row.ID + if config, found := s.webhooks[req.BotUserID]; found { + config.NextAttemptAt = time.Now() + s.webhooks[req.BotUserID] = config + } return cloneBotAPIUpdate(row), true, nil } +func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(_ context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) { + if botUserID == 0 || tail <= 0 { + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 100 + } + s.mu.RLock() + defer s.mu.RUnlock() + confirmed := s.state[botUserID] + matching := make([]domain.BotAPIUpdate, 0, min(tail, limit)) + start := 0 + count := 0 + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > confirmed { + count++ + } + } + if count > tail { + start = count - tail + } + seen := 0 + for _, row := range s.rows { + if row.BotUserID != botUserID || row.ID <= confirmed { + continue + } + if seen < start { + seen++ + continue + } + matching = append(matching, cloneBotAPIUpdate(row)) + if len(matching) >= limit { + break + } + } + return matching, nil +} + func (s *BotAPIUpdateStore) ListBotAPIUpdates(_ context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) { if botUserID == 0 { return nil, nil @@ -85,13 +332,78 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, c return nil } s.mu.Lock() + maxExisting := int64(0) + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > maxExisting { + maxExisting = row.ID + } + } + if confirmedUpdateID > maxExisting && s.cursorInitialized[botUserID] { + s.mu.Unlock() + return nil + } + if confirmedUpdateID > maxExisting { + confirmedUpdateID = maxExisting + } if confirmedUpdateID > s.state[botUserID] { s.state[botUserID] = confirmedUpdateID } + s.cursorInitialized[botUserID] = true s.mu.Unlock() return nil } +func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(_ context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error { + if botUserID == 0 { + return nil + } + s.mu.Lock() + if len(allowed) == 0 { + delete(s.allowed, botUserID) + } else { + set := make(map[domain.BotAPIUpdateKind]struct{}, len(allowed)) + for _, kind := range allowed { + if kind != "" { + set[kind] = struct{}{} + } + } + s.allowed[botUserID] = set + } + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error { + if botUserID == 0 { + return nil + } + s.mu.Lock() + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > s.state[botUserID] { + s.state[botUserID] = row.ID + } + } + s.cursorInitialized[botUserID] = true + s.mu.Unlock() + return nil +} + +func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(_ context.Context, botUserID int64) (int, error) { + if botUserID == 0 { + return 0, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + confirmed := s.state[botUserID] + count := 0 + for _, row := range s.rows { + if row.BotUserID == botUserID && row.ID > confirmed { + count++ + } + } + return count, nil +} + func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID int64) (int64, bool, error) { if botUserID == 0 { return 0, false, nil @@ -103,27 +415,65 @@ func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID } func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { - if req.BotUserID == 0 || req.MessageID <= 0 { + if req.BotUserID == 0 { return fmt.Errorf("invalid bot api update") } - if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage { + if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery { return fmt.Errorf("invalid bot api update kind %q", req.Kind) } switch req.Peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: - if req.Peer.ID <= 0 { + if req.Peer.ID <= 0 || req.MessageID <= 0 { + return fmt.Errorf("invalid bot api update peer") + } + case "": + if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 { return fmt.Errorf("invalid bot api update peer") } default: return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) } + if req.Kind == domain.BotAPIUpdateCallbackQuery { + cb := req.Callback + if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || + cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 || + len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 { + return fmt.Errorf("invalid bot api callback query") + } + inline := cb.InlineMessage + if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) { + return fmt.Errorf("invalid bot api inline callback query") + } + if req.MessageID > 0 && inline != nil { + return fmt.Errorf("ambiguous bot api callback query") + } + } else if req.Callback != nil { + return fmt.Errorf("unexpected bot api callback query") + } return nil } func botAPIUpdateKey(req domain.EnqueueBotAPIUpdateRequest) string { + if req.Kind == domain.BotAPIUpdateCallbackQuery && req.Callback != nil { + return fmt.Sprintf("%d:%s:%d", req.BotUserID, req.Kind, req.Callback.ID) + } return fmt.Sprintf("%d:%s:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.SourcePts) } func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate { + row.Callback = cloneBotAPICallback(row.Callback) return row } + +func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery { + if in == nil { + return nil + } + out := *in + out.Data = append([]byte(nil), in.Data...) + if in.InlineMessage != nil { + inline := *in.InlineMessage + out.InlineMessage = &inline + } + return &out +} diff --git a/internal/store/memory/botapi_update_test.go b/internal/store/memory/botapi_update_test.go new file mode 100644 index 00000000..ddc70edd --- /dev/null +++ b/internal/store/memory/botapi_update_test.go @@ -0,0 +1,191 @@ +package memory + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +func botAPIMessageRequest(botID int64, kind domain.BotAPIUpdateKind, messageID int) domain.EnqueueBotAPIUpdateRequest { + return domain.EnqueueBotAPIUpdateRequest{ + BotUserID: botID, + Kind: kind, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, + MessageID: messageID, + SourcePts: messageID, + Date: 1700000000 + messageID, + } +} + +func TestBotAPIPollLeaseCompareOwnerAndExpiry(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "one", 20*time.Millisecond); err != nil || !acquired { + t.Fatalf("first acquire=%v err=%v", acquired, err) + } + if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || acquired { + t.Fatalf("competing acquire=%v err=%v", acquired, err) + } + if err := store.ReleaseBotAPIPollLease(ctx, 1001, "stale"); err != nil { + t.Fatal(err) + } + if acquired, _ := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); acquired { + t.Fatal("stale release removed active owner") + } + time.Sleep(25 * time.Millisecond) + if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || !acquired { + t.Fatalf("expired acquire=%v err=%v", acquired, err) + } +} + +func TestBotAPIWebhookLeaseWakeAndAtomicDrop(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || !created { + t.Fatalf("enqueue initial created=%v err=%v", created, err) + } + config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/hook", MaxConnections: 8} + if err := store.SetBotAPIWebhook(ctx, config, true); err != nil { + t.Fatal(err) + } + if count, _ := store.PendingBotAPIUpdateCount(ctx, 1001); count != 0 { + t.Fatalf("pending after atomic drop=%d", count) + } + if acquired, err := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-1", time.Second); err != nil || !acquired { + t.Fatalf("lease acquire=%v err=%v", acquired, err) + } + if acquired, _ := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-2", time.Second); acquired { + t.Fatal("second webhook worker acquired active lease") + } + if err := store.RecordBotAPIWebhookSuccess(ctx, 1001, "worker-1", time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 { + t.Fatalf("idle due=%#v err=%v", due, err) + } + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created { + t.Fatalf("enqueue wake created=%v err=%v", created, err) + } + if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != 1001 { + t.Fatalf("woken due=%#v err=%v", due, err) + } +} + +func TestBotAPIWebhookAllowedUpdatesOmissionPreservesPolicy(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}); err != nil { + t.Fatal(err) + } + config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/one", MaxConnections: 8} + if err := store.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err := store.BotAPIWebhook(ctx, 1001) + if err != nil || !found || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery { + t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err) + } + if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || created || row.ID != 0 { + t.Fatalf("message bypassed preserved policy: row=%#v created=%v err=%v", row, created, err) + } + config.URL = "https://example.test/two" + config.AllowedUpdatesSet = true // Explicit empty resets to the default/all policy. + if err := store.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err = store.BotAPIWebhook(ctx, 1001) + if err != nil || !found || stored.AllowedUpdates != nil { + t.Fatalf("explicit empty webhook=%#v found=%v err=%v", stored, found, err) + } + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created { + t.Fatalf("message after explicit reset created=%v err=%v", created, err) + } +} + +func TestBotAPIUpdateCursorClampDropAndTail(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + for id := 1; id <= 5; id++ { + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, id)); err != nil || !created { + t.Fatalf("enqueue %d: created=%v err=%v", id, created, err) + } + } + tail, err := store.ListTailBotAPIUpdates(ctx, 1001, 2, 100) + if err != nil || len(tail) != 2 || tail[0].MessageID != 4 || tail[1].MessageID != 5 { + t.Fatalf("tail = %#v err=%v", tail, err) + } + if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil { + t.Fatalf("confirm huge offset: %v", err) + } + confirmed, found, err := store.ConfirmedBotAPIUpdateID(ctx, 1001) + if err != nil || !found || confirmed != 5 { + t.Fatalf("confirmed = %d found=%v err=%v, want 5", confirmed, found, err) + } + row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 6)) + if err != nil || !created { + t.Fatalf("enqueue after huge offset: row=%#v created=%v err=%v", row, created, err) + } + if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil { + t.Fatalf("repeat foreign offset: %v", err) + } + if confirmed, _, _ := store.ConfirmedBotAPIUpdateID(ctx, 1001); confirmed != 5 { + t.Fatalf("repeat foreign offset advanced cursor to %d, want 5", confirmed) + } + pending, err := store.ListBotAPIUpdates(ctx, 1001, confirmed+1, 100) + if err != nil || len(pending) != 1 || pending[0].MessageID != 6 { + t.Fatalf("pending after huge offset = %#v err=%v", pending, err) + } + if err := store.DropPendingBotAPIUpdates(ctx, 1001); err != nil { + t.Fatalf("drop pending: %v", err) + } + count, err := store.PendingBotAPIUpdateCount(ctx, 1001) + if err != nil || count != 0 { + t.Fatalf("pending count = %d err=%v", count, err) + } +} + +func TestBotAPIAllowedUpdatesOnlyAffectsFutureEnqueue(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + first, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)) + if err != nil || !created { + t.Fatalf("enqueue pre-policy: %#v created=%v err=%v", first, created, err) + } + if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil { + t.Fatalf("set policy: %v", err) + } + if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || created || row.ID != 0 { + t.Fatalf("filtered message = %#v created=%v err=%v", row, created, err) + } + if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateEditedMessage, 3)); err != nil || !created { + t.Fatalf("allowed edit created=%v err=%v", created, err) + } + rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100) + if err != nil || len(rows) != 2 || rows[0].ID != first.ID || rows[1].Kind != domain.BotAPIUpdateEditedMessage { + t.Fatalf("rows = %#v err=%v", rows, err) + } +} + +func TestBotAPIInlineCallbackRoundTrip(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + callback := &domain.BotCallbackQuery{ + ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99, Data: []byte("tap"), + InlineMessage: &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 15, AccessHash: 1234}, + } + row, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback, + }) + if err != nil || !created || row.MessageID != 0 || row.Peer != (domain.Peer{}) || row.Callback == nil || + row.Callback.InlineMessage == nil || *row.Callback.InlineMessage != *callback.InlineMessage { + t.Fatalf("inline callback row=%#v created=%v err=%v", row, created, err) + } + callback.Data[0] = 'X' + callback.InlineMessage.ID = 99 + rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100) + if err != nil || len(rows) != 1 || string(rows[0].Callback.Data) != "tap" || rows[0].Callback.InlineMessage.ID != 15 { + t.Fatalf("inline callback rows=%#v err=%v", rows, err) + } +} diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index a8d0b08d..1a5996ee 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -91,6 +91,7 @@ func normalizeMemoryMessageIDs(ids []int) []int { func cloneMessage(msg domain.Message) domain.Message { msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + msg.Media = cloneRequestedPeerMedia(msg.Media) msg.ReplyTo = cloneMessageReply(msg.ReplyTo) msg.Forward = cloneMessageForward(msg.Forward) msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions) @@ -99,13 +100,36 @@ func cloneMessage(msg domain.Message) domain.Message { return msg } -// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐 +// cloneRequestedPeerMedia isolates the immutable disclosure snapshot carried by +// messageActionRequestedPeer. Other media payloads retain their established +// copy behavior; this helper only deep-copies the newly mutable peer/photo slices. +func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia { + if media == nil { + return nil + } + clone := *media + if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil { + return &clone + } + action := *media.ServiceAction + requested := *media.ServiceAction.RequestedPeer + requested.Peers = append([]domain.Peer(nil), requested.Peers...) + requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...) + for i := range requested.Details { + requested.Details[i].Photo = domain.ClonePhotoPtr(requested.Details[i].Photo) + } + action.RequestedPeer = &requested + clone.ServiceAction = &action + return &clone +} + +// cloneReplyMarkup 深拷 reply markup 快照:与 postgres 每盒独立 decode 对齐 // (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。 func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { if m == nil { return nil } - clone := domain.MessageReplyMarkup{} + clone := *m if m.Inline != nil { clone.Inline = make([][]domain.MarkupButton, len(m.Inline)) for i, row := range m.Inline { @@ -117,6 +141,12 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { clone.Inline[i] = cloneRow } } + if m.Keyboard != nil { + clone.Keyboard = make([][]domain.MarkupButton, len(m.Keyboard)) + for i, row := range m.Keyboard { + clone.Keyboard[i] = append([]domain.MarkupButton(nil), row...) + } + } return &clone } diff --git a/internal/store/memory/message_history.go b/internal/store/memory/message_history.go index 14692cc4..e9cf58b2 100644 --- a/internal/store/memory/message_history.go +++ b/internal/store/memory/message_history.go @@ -35,6 +35,33 @@ func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (dom return out, nil } +// GetByUID resolves one owner's box row by the shared private message id. Callback delivery +// uses it to translate the clicker's box id to the bot's box id without scanning history. +func (s *MessageStore) GetByUID(_ context.Context, userID, uid int64) (domain.Message, bool, error) { + if userID == 0 || uid == 0 { + return domain.Message{}, false, nil + } + s.mu.RLock() + var found domain.Message + for _, msg := range s.m[userID] { + if msg.UID == uid { + found = cloneMessage(msg) + reactions := s.privateMessageReactionsForMessageLocked(found) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + found.Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + break + } + } + s.mu.RUnlock() + if found.ID == 0 { + return domain.Message{}, false, nil + } + items := []domain.Message{found} + s.enrichPrivateMessagePolls(items, int(time.Now().Unix())) + return items[0], true, nil +} + func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { s.mu.RLock() messages := cloneMessages(s.m[userID]) diff --git a/internal/store/memory/message_markup_test.go b/internal/store/memory/message_markup_test.go new file mode 100644 index 00000000..09798bff --- /dev/null +++ b/internal/store/memory/message_markup_test.go @@ -0,0 +1,43 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestPrivateReplyKeyboardSurvivesBothBoxesAndHistory(t *testing.T) { + store := NewMessageStore(NewDialogStore()) + markup := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}}, + Resize: true, + SingleUse: true, + Persistent: true, + Placeholder: "Choose", + } + res, err := store.SendPrivateText(context.Background(), domain.SendPrivateTextRequest{ + SenderUserID: 10, RecipientUserID: 20, RandomID: 30, + Message: "pick", Date: 40, ReplyMarkup: markup, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertReplyKeyboard := func(name string, got *domain.MessageReplyMarkup) { + t.Helper() + if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 || + len(got.Keyboard[0]) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || + !got.SingleUse || !got.Persistent || got.Placeholder != "Choose" { + t.Fatalf("%s = %#v", name, got) + } + } + assertReplyKeyboard("sender", res.SenderMessage.ReplyMarkup) + assertReplyKeyboard("recipient", res.RecipientMessage.ReplyMarkup) + markup.Keyboard[0][0].Text = "mutated" + list, err := store.GetByIDs(context.Background(), 20, []int{res.RecipientMessage.ID}) + if err != nil || len(list.Messages) != 1 { + t.Fatalf("GetByIDs = %+v, %v", list, err) + } + assertReplyKeyboard("recipient history", list.Messages[0].ReplyMarkup) +} diff --git a/internal/store/memory/message_send.go b/internal/store/memory/message_send.go index 471ed9bd..4db7baac 100644 --- a/internal/store/memory/message_send.go +++ b/internal/store/memory/message_send.go @@ -84,7 +84,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate NoForwards: req.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), - Media: req.Media, + Media: cloneRequestedPeerMedia(req.Media), ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, @@ -110,6 +110,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} recipient.Out = false recipient.ReplyTo = cloneMessageReply(recipientReply) + recipient.Media = cloneRequestedPeerMedia(sender.Media) // recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷 // 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。 recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup) diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index fc365457..7c2ce4af 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -222,6 +222,65 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { assertWebViewData("recipient history", recipientHistory.Messages[0]) } +func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + photo := domain.Photo{ID: 8101, Sizes: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096, + }}} + requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003} + req := domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, RecipientUserID: 1000000002, RandomID: 200, Date: 1700000121, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 77, Peers: []domain.Peer{requestedPeer}, + Details: []domain.MessageRequestedPeerDetails{{ + Peer: requestedPeer, FirstName: "Shared", Username: "shared_user", Photo: &photo, + }}, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + }, + }}, + } + + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertSnapshot := func(name string, msg domain.Message) { + t.Helper() + if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil { + t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media) + } + action := msg.Media.ServiceAction.RequestedPeer + if action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer || + len(action.Details) != 1 || action.Details[0].FirstName != "Shared" || + action.Details[0].Username != "shared_user" || action.Details[0].Photo == nil || + len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 || + !action.NameRequested || !action.UsernameRequested || !action.PhotoRequested { + t.Fatalf("%s requested-peer=%+v", name, action) + } + } + assertSnapshot("sender", got.SenderMessage) + assertSnapshot("recipient", got.RecipientMessage) + + // Mutating either the request or one returned box must not alter the other + // box or the immutable store snapshot. + req.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-request" + req.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 1 + got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-result" + got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 2 + assertSnapshot("isolated recipient result", got.RecipientMessage) + + for _, owner := range []int64{req.SenderUserID, req.RecipientUserID} { + history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{Limit: 10}) + if err != nil || len(history.Messages) != 1 { + t.Fatalf("owner %d history=%+v err=%v", owner, history, err) + } + assertSnapshot("stored history", history.Messages[0]) + } +} + func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) { ctx := context.Background() messages := NewMessageStore() diff --git a/internal/store/postgres/bot_integration_test.go b/internal/store/postgres/bot_integration_test.go index cd3c5c4a..12bdf74f 100644 --- a/internal/store/postgres/bot_integration_test.go +++ b/internal/store/postgres/bot_integration_test.go @@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) { if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory { t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory) } + requestedButton := domain.BotRequestedWebViewButton{ + WebAppReqID: fmt.Sprintf("pg-requested-%d", suffix), BotUserID: bot1.ID, UserID: owner.ID, + ButtonID: 45, Text: "Share", PeerType: "user", MaxQuantity: 2, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour), + } + t.Cleanup(func() { + _ = bots.DeleteRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID) + }) + if err := bots.SaveRequestedWebViewButton(ctx, requestedButton); err != nil { + t.Fatalf("save requested button: %v", err) + } + storedButton, found, err := bots.GetRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID) + if err != nil || !found || !storedButton.NameRequested || !storedButton.UsernameRequested || !storedButton.PhotoRequested { + t.Fatalf("requested button=%#v found=%v err=%v", storedButton, found, err) + } if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can { t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err) } diff --git a/internal/store/postgres/bot_miniapp.go b/internal/store/postgres/bot_miniapp.go index 6e1b99f3..0f9d2c9b 100644 --- a/internal/store/postgres/bot_miniapp.go +++ b/internal/store/postgres/bot_miniapp.go @@ -416,16 +416,29 @@ func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() { return domain.ErrBotRequestedButtonInvalid } - _, err := s.db.Exec(ctx, ` -INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + peerFilter, err := json.Marshal(button.PeerFilter) + if err != nil { + return domain.ErrBotRequestedButtonInvalid + } + _, err = s.db.Exec(ctx, ` +INSERT INTO webview_requested_buttons ( + webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, + peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (webapp_req_id) DO UPDATE SET button_id=EXCLUDED.button_id, text=EXCLUDED.text, peer_type=EXCLUDED.peer_type, max_quantity=EXCLUDED.max_quantity, + peer_filter=EXCLUDED.peer_filter, + name_requested=EXCLUDED.name_requested, + username_requested=EXCLUDED.username_requested, + photo_requested=EXCLUDED.photo_requested, expires_at=EXCLUDED.expires_at`, - button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt) + button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, + button.PeerType, button.MaxQuantity, peerFilter, button.NameRequested, + button.UsernameRequested, button.PhotoRequested, button.CreatedAt, button.ExpiresAt) if err != nil { return fmt.Errorf("save requested webview button: %w", err) } @@ -435,18 +448,28 @@ ON CONFLICT (webapp_req_id) DO UPDATE SET func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) { _, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`) var button domain.BotRequestedWebViewButton + var peerFilter []byte err := s.db.QueryRow(ctx, ` -SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at +SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, + peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at FROM webview_requested_buttons WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`, botUserID, userID, webAppReqID). - Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt) + Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, + &button.Text, &button.PeerType, &button.MaxQuantity, &peerFilter, + &button.NameRequested, &button.UsernameRequested, &button.PhotoRequested, + &button.CreatedAt, &button.ExpiresAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.BotRequestedWebViewButton{}, false, nil } return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err) } + if string(peerFilter) != "{}" && string(peerFilter) != "null" { + if err := json.Unmarshal(peerFilter, &button.PeerFilter); err != nil { + return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("decode requested webview button filter: %w", err) + } + } return button, true, nil } diff --git a/internal/store/postgres/botapi_update.go b/internal/store/postgres/botapi_update.go index c69b96de..a562d571 100644 --- a/internal/store/postgres/botapi_update.go +++ b/internal/store/postgres/botapi_update.go @@ -20,17 +20,320 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore { return &BotAPIUpdateStore{db: db} } +func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error { + if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 { + return fmt.Errorf("invalid bot api webhook") + } + var allowed []string + if len(config.AllowedUpdates) > 0 { + allowed = make([]string, 0, len(config.AllowedUpdates)) + for _, kind := range config.AllowedUpdates { + if kind != "" { + allowed = append(allowed, string(kind)) + } + } + } + if _, err := s.db.Exec(ctx, ` +WITH policy AS ( + SELECT CASE WHEN $6::boolean THEN $5::text[] + ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1) + END AS allowed_updates +), configured AS ( + INSERT INTO bot_api_webhooks ( + bot_user_id, url, secret_token, max_connections, allowed_updates, + failure_count, last_error_date, last_error_message, next_attempt_at, + delivery_owner, delivery_expires_at, updated_at + ) + SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now() + FROM policy + ON CONFLICT (bot_user_id) DO UPDATE + SET url = EXCLUDED.url, + secret_token = EXCLUDED.secret_token, + max_connections = EXCLUDED.max_connections, + allowed_updates = EXCLUDED.allowed_updates, + failure_count = 0, + last_error_date = 0, + last_error_message = '', + next_attempt_at = now(), + delivery_owner = '', + delivery_expires_at = NULL, + updated_at = now() + RETURNING bot_user_id +), boundary AS ( + SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id + FROM bot_api_updates + WHERE bot_user_id = $1 +) +INSERT INTO bot_api_update_states ( + bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized +) +SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean +FROM boundary, configured, policy +ON CONFLICT (bot_user_id) DO UPDATE +SET confirmed_update_id = CASE WHEN $7::boolean + THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id) + ELSE bot_api_update_states.confirmed_update_id + END, + allowed_updates = EXCLUDED.allowed_updates, + cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized, + updated_at = now() +`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed, + config.AllowedUpdatesSet, dropPending); err != nil { + return fmt.Errorf("set bot api webhook: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error { + if botUserID <= 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id +), boundary AS ( + SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id + FROM bot_api_updates + WHERE bot_user_id = $1 +) +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized) +SELECT $1, confirmed_update_id, $2::boolean +FROM boundary +ON CONFLICT (bot_user_id) DO UPDATE +SET confirmed_update_id = CASE WHEN $2::boolean + THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id) + ELSE bot_api_update_states.confirmed_update_id + END, + cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized, + updated_at = now() +`, botUserID, dropPending); err != nil { + return fmt.Errorf("delete bot api webhook: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) { + config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, ` +SELECT bot_user_id, url, secret_token, max_connections, allowed_updates, + failure_count, last_error_date, last_error_message, next_attempt_at +FROM bot_api_webhooks +WHERE bot_user_id = $1 +`, botUserID)) + if err == pgx.ErrNoRows { + return domain.BotAPIWebhook{}, false, nil + } + if err != nil { + return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err) + } + return config, true, nil +} + +func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + rows, err := s.db.Query(ctx, ` +SELECT bot_user_id, url, secret_token, max_connections, allowed_updates, + failure_count, last_error_date, last_error_message, next_attempt_at +FROM bot_api_webhooks +WHERE next_attempt_at <= now() + AND (delivery_owner = '' OR delivery_expires_at <= now()) +ORDER BY next_attempt_at, bot_user_id +LIMIT $1 +`, limit) + if err != nil { + return nil, fmt.Errorf("list due bot api webhooks: %w", err) + } + defer rows.Close() + out := make([]domain.BotAPIWebhook, 0, limit) + for rows.Next() { + config, err := scanBotAPIWebhook(rows) + if err != nil { + return nil, err + } + out = append(out, config) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list due bot api webhook rows: %w", err) + } + return out, nil +} + +func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api webhook lease") + } + var acquiredOwner string + err := s.db.QueryRow(ctx, ` +UPDATE bot_api_webhooks +SET delivery_owner = $2, + delivery_expires_at = now() + make_interval(secs => $3), + updated_at = now() +WHERE bot_user_id = $1 + AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now()) +RETURNING delivery_owner +`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("acquire bot api webhook lease: %w", err) + } + return acquiredOwner == owner, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error { + if botUserID <= 0 || owner == "" { + return nil + } + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_webhooks +SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND delivery_owner = $2 +`, botUserID, owner); err != nil { + return fmt.Errorf("release bot api webhook lease: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error { + if len(message) > 512 { + message = message[:512] + } + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_webhooks +SET failure_count = failure_count + 1, + last_error_date = EXTRACT(EPOCH FROM now())::integer, + last_error_message = $3, + next_attempt_at = $4, + delivery_owner = '', delivery_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND delivery_owner = $2 +`, botUserID, owner, message, nextAttempt); err != nil { + return fmt.Errorf("record bot api webhook failure: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error { + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_webhooks +SET failure_count = 0, last_error_date = 0, last_error_message = '', + next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND delivery_owner = $2 +`, botUserID, owner, nextAttempt); err != nil { + return fmt.Errorf("record bot api webhook success: %w", err) + } + return nil +} + +func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) { + var config domain.BotAPIWebhook + var allowed []string + if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed, + &config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil { + return domain.BotAPIWebhook{}, err + } + if allowed != nil { + config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed)) + for _, kind := range allowed { + config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind)) + } + } + return config, nil +} + +func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) { + if botUserID <= 0 || owner == "" || ttl <= 0 { + return false, fmt.Errorf("invalid bot api poll lease") + } + var acquiredOwner string + err := s.db.QueryRow(ctx, ` +INSERT INTO bot_api_update_states ( + bot_user_id, confirmed_update_id, poll_owner, poll_expires_at +) VALUES ($1, 0, $2, now() + make_interval(secs => $3)) +ON CONFLICT (bot_user_id) DO UPDATE +SET poll_owner = EXCLUDED.poll_owner, + poll_expires_at = EXCLUDED.poll_expires_at, + updated_at = now() +WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner + OR bot_api_update_states.poll_expires_at IS NULL + OR bot_api_update_states.poll_expires_at <= now() +RETURNING poll_owner +`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("acquire bot api poll lease: %w", err) + } + return acquiredOwner == owner, nil +} + +func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error { + if botUserID <= 0 || owner == "" { + return nil + } + if _, err := s.db.Exec(ctx, ` +UPDATE bot_api_update_states +SET poll_owner = '', poll_expires_at = NULL, updated_at = now() +WHERE bot_user_id = $1 AND poll_owner = $2 +`, botUserID, owner); err != nil { + return fmt.Errorf("release bot api poll lease: %w", err) + } + return nil +} + func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) { if err := validateBotAPIUpdateRequest(req); err != nil { return domain.BotAPIUpdate{}, false, err } + var callbackQueryID, callbackUserID, callbackChatInstance int64 + var callbackInlineDCID, callbackInlineMessageID int + var callbackInlineOwnerID, callbackInlineAccessHash int64 + var callbackData []byte + if req.Callback != nil { + callbackQueryID = req.Callback.ID + callbackUserID = req.Callback.UserID + callbackChatInstance = req.Callback.ChatInstance + callbackData = req.Callback.Data + if req.Callback.InlineMessage != nil { + callbackInlineDCID = req.Callback.InlineMessage.DCID + callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID + callbackInlineMessageID = req.Callback.InlineMessage.ID + callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash + } + } row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` -INSERT INTO bot_api_updates ( - bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date -) VALUES ($1, $2, $3, $4, $5, $6, $7) -ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING -RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date -`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date)) +WITH inserted AS ( + INSERT INTO bot_api_updates ( + bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash +) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 +WHERE NOT EXISTS ( + SELECT 1 + FROM bot_api_update_states + WHERE bot_user_id = $1 + AND allowed_updates IS NOT NULL + AND NOT ($2::text = ANY(allowed_updates)) +) + ON CONFLICT DO NOTHING + RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash +), wake_webhook AS ( + UPDATE bot_api_webhooks + SET next_attempt_at = now(), updated_at = now() + WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted) + RETURNING bot_user_id +) +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash +FROM inserted +`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date, + callbackQueryID, callbackUserID, callbackChatInstance, callbackData, + callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash)) if err == nil { return row, true, nil } @@ -38,21 +341,69 @@ RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_p return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err) } row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` -SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash FROM bot_api_updates WHERE bot_user_id = $1 AND update_kind = $2 - AND peer_type = $3 - AND peer_id = $4 - AND message_id = $5 - AND source_pts = $6 -`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts)) + AND ( + (update_kind = 'callback_query' AND callback_query_id = $7) + OR + (update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND source_pts = $6) + ) +`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID)) if err != nil { + if err == pgx.ErrNoRows { + return domain.BotAPIUpdate{}, false, nil + } return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err) } return row, false, nil } +func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) { + if botUserID == 0 || tail <= 0 { + return nil, nil + } + if limit <= 0 || limit > 100 { + limit = 100 + } + rows, err := s.db.Query(ctx, ` +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash +FROM ( + SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash + FROM bot_api_updates + WHERE bot_user_id = $1 + AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0) + ORDER BY id DESC + LIMIT $2 +) AS tail_updates +ORDER BY id +LIMIT $3 +`, botUserID, tail, limit) + if err != nil { + return nil, fmt.Errorf("list bot api tail updates: %w", err) + } + defer rows.Close() + out := make([]domain.BotAPIUpdate, 0, limit) + for rows.Next() { + item, err := scanBotAPIUpdateRows(rows) + if err != nil { + return nil, err + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list bot api tail update rows: %w", err) + } + return out, nil +} + func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) { if botUserID == 0 { return nil, nil @@ -64,7 +415,9 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr limit = 100 } rows, err := s.db.Query(ctx, ` -SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date +SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, + callback_query_id, callback_user_id, callback_chat_instance, callback_data, + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash FROM bot_api_updates WHERE bot_user_id = $1 AND id >= $2 ORDER BY id @@ -93,23 +446,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID, return nil } if _, err := s.db.Exec(ctx, ` -INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id) -VALUES ($1, $2) +WITH bounded AS ( + SELECT COALESCE(MAX(id), 0) AS max_update_id, + LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id + FROM bot_api_updates + WHERE bot_user_id = $1 +) +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized) +SELECT $1, confirmed_update_id, true +FROM bounded ON CONFLICT (bot_user_id) DO UPDATE -SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id), +SET confirmed_update_id = GREATEST( + bot_api_update_states.confirmed_update_id, + CASE + WHEN $2::bigint > (SELECT max_update_id FROM bounded) + AND bot_api_update_states.cursor_initialized + THEN bot_api_update_states.confirmed_update_id + ELSE EXCLUDED.confirmed_update_id + END + ), + cursor_initialized = true, updated_at = now() -WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id `, botUserID, confirmedUpdateID); err != nil { return fmt.Errorf("confirm bot api updates: %w", err) } return nil } +func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error { + if botUserID == 0 { + return nil + } + var values []string + if len(allowed) > 0 { + values = make([]string, 0, len(allowed)) + for _, kind := range allowed { + if kind != "" { + values = append(values, string(kind)) + } + } + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates) +VALUES ($1, 0, $2::text[]) +ON CONFLICT (bot_user_id) DO UPDATE +SET allowed_updates = EXCLUDED.allowed_updates, + updated_at = now() +`, botUserID, values); err != nil { + return fmt.Errorf("set bot api allowed updates: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error { + if botUserID == 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized) +SELECT $1, COALESCE(MAX(id), 0), true +FROM bot_api_updates +WHERE bot_user_id = $1 +ON CONFLICT (bot_user_id) DO UPDATE +SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id), + cursor_initialized = true, + updated_at = now() +`, botUserID); err != nil { + return fmt.Errorf("drop pending bot api updates: %w", err) + } + return nil +} + +func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) { + if botUserID == 0 { + return 0, nil + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*) +FROM bot_api_updates +WHERE bot_user_id = $1 + AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0) +`, botUserID).Scan(&count); err != nil { + return 0, fmt.Errorf("count pending bot api updates: %w", err) + } + return count, nil +} + // DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1): // 1. 已确认(id <= bot_api_update_states.confirmed_update_id)且入队超过 confirmedGrace 的行—— // 官方 Bot API 语义下确认即弃,getUpdates 的 fromID 恒 > confirmed,删除不影响任何读路径; // 宽限仅防御 offset 回拨调试场景。 -// 2. 按消息 date 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」 +// 2. 按队列 created_at 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」 // 语义,同时封顶 MTProto-only bot(从不调 getUpdates、无 state 行)成员身份带来的无界增长。 // // 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫, @@ -139,15 +567,15 @@ WHERE id IN ( total += int(tag.RowsAffected()) } if maxAge > 0 { - cutoff := time.Now().Add(-maxAge).Unix() - // 走 bot_api_updates_retention_idx(date, id)。 + cutoff := time.Now().Add(-maxAge) + // 走 bot_api_updates_created_retention_idx(created_at, id)。 tag, err := s.db.Exec(ctx, ` DELETE FROM bot_api_updates WHERE id IN ( SELECT id FROM bot_api_updates - WHERE date < $1 - ORDER BY date, id + WHERE created_at < $1 + ORDER BY created_at, id LIMIT $2 )`, cutoff, limit) if err != nil { @@ -187,28 +615,69 @@ type botAPIUpdateScanner interface { func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) { var item domain.BotAPIUpdate var kind, peerType string - if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date); err != nil { + var callbackQueryID, callbackUserID, callbackChatInstance int64 + var callbackInlineDCID, callbackInlineMessageID int + var callbackInlineOwnerID, callbackInlineAccessHash int64 + var callbackData []byte + if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date, + &callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData, + &callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash); err != nil { return domain.BotAPIUpdate{}, err } item.Kind = domain.BotAPIUpdateKind(kind) item.Peer.Type = domain.PeerType(peerType) + if item.Kind == domain.BotAPIUpdateCallbackQuery { + item.Callback = &domain.BotCallbackQuery{ + ID: callbackQueryID, + BotUserID: item.BotUserID, + UserID: callbackUserID, + Peer: item.Peer, + MessageID: item.MessageID, + ChatInstance: callbackChatInstance, + Data: append([]byte(nil), callbackData...), + } + if callbackInlineMessageID > 0 { + item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash} + } + } return item, nil } func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { - if req.BotUserID == 0 || req.MessageID <= 0 { + if req.BotUserID == 0 { return fmt.Errorf("invalid bot api update") } - if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage { + if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery { return fmt.Errorf("invalid bot api update kind %q", req.Kind) } switch req.Peer.Type { case domain.PeerTypeUser, domain.PeerTypeChannel: - if req.Peer.ID <= 0 { + if req.Peer.ID <= 0 || req.MessageID <= 0 { + return fmt.Errorf("invalid bot api update peer") + } + case "": + if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 { return fmt.Errorf("invalid bot api update peer") } default: return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) } + if req.Kind == domain.BotAPIUpdateCallbackQuery { + cb := req.Callback + if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || + cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 || + len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 { + return fmt.Errorf("invalid bot api callback query") + } + inline := cb.InlineMessage + if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) { + return fmt.Errorf("invalid bot api inline callback query") + } + if req.MessageID > 0 && inline != nil { + return fmt.Errorf("ambiguous bot api callback query") + } + } else if req.Callback != nil { + return fmt.Errorf("unexpected bot api callback query") + } return nil } diff --git a/internal/store/postgres/botapi_update_integration_test.go b/internal/store/postgres/botapi_update_integration_test.go index 67b6dd5d..99d092af 100644 --- a/internal/store/postgres/botapi_update_integration_test.go +++ b/internal/store/postgres/botapi_update_integration_test.go @@ -1,6 +1,7 @@ package postgres import ( + "bytes" "context" "testing" "time" @@ -8,10 +9,292 @@ import ( "telesrv/internal/domain" ) +func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{ + AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot", + }) + if err != nil { + t.Fatalf("create bot user: %v", err) + } + clicker, err := users.Create(ctx, domain.User{ + AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker", + }) + if err != nil { + t.Fatalf("create callback user: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO bots (bot_user_id, owner_user_id, token_secret) +VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil { + t.Fatalf("seed bot: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + callback := &domain.BotCallbackQuery{ + ID: 880011, BotUserID: bot.ID, UserID: clicker.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17, + ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'}, + } + req := domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, + Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback, + } + store := NewBotAPIUpdateStore(pool) + first, created, err := store.EnqueueBotAPIUpdate(ctx, req) + if err != nil || !created { + t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err) + } + again, created, err := store.EnqueueBotAPIUpdate(ctx, req) + if err != nil || created || again.ID != first.ID { + t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err) + } + items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100) + if err != nil || len(items) != 1 { + t.Fatalf("list callback = %+v, %v", items, err) + } + got := items[0].Callback + if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID || + got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance || + !bytes.Equal(got.Data, callback.Data) { + t.Fatalf("callback round trip = %+v, want %+v", got, callback) + } +} + +func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"}) + if err != nil { + t.Fatal(err) + } + clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"}) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + s := NewBotAPIUpdateStore(pool) + inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566} + callback := &domain.BotCallbackQuery{ + ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811, + Data: []byte{0, 1, 0xff}, InlineMessage: inline, + } + row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback, + }) + if err != nil || !created { + t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err) + } + items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100) + if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 || + items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline || + !bytes.Equal(items[0].Callback.Data, callback.Data) { + t.Fatalf("inline callback items=%#v err=%v", items, err) + } + + config := domain.BotAPIWebhook{ + BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret", + MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true, + } + if err := s.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err := s.BotAPIWebhook(ctx, bot.ID) + if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken || + stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 { + t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err) + } + config.URL = "https://example.test/reconfigured" + config.AllowedUpdates = nil + config.AllowedUpdatesSet = false + if err := s.SetBotAPIWebhook(ctx, config, false); err != nil { + t.Fatal(err) + } + stored, found, err = s.BotAPIWebhook(ctx, bot.ID) + if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery { + t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err) + } + if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired { + t.Fatalf("first lease=%v err=%v", acquired, err) + } + if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired { + t.Fatalf("second lease=%v err=%v", acquired, err) + } + if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil { + t.Fatal(err) + } + if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired { + t.Fatal("stale webhook release removed active lease") + } + next := time.Now().Add(time.Hour) + if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil { + t.Fatal(err) + } + if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 { + t.Fatalf("idle due=%#v err=%v", due, err) + } + // A newly inserted allowed callback wakes the idle webhook in the same SQL statement. + callback2 := *callback + callback2.ID++ + callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677} + if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2, + }); err != nil || !created { + t.Fatalf("enqueue wake created=%v err=%v", created, err) + } + if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID { + t.Fatalf("woken due=%#v err=%v", due, err) + } + if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil { + t.Fatal(err) + } + if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found { + t.Fatalf("webhook after delete found=%v err=%v", found, err) + } + if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 { + t.Fatalf("pending after delete/drop=%d err=%v", pending, err) + } +} + +func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"}) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool) + if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired { + t.Fatalf("first acquire=%v err=%v", acquired, err) + } + if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired { + t.Fatalf("cross-instance acquire=%v err=%v", acquired, err) + } + if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil { + t.Fatal(err) + } + if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired { + t.Fatal("stale release removed active poll lease") + } + if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil { + t.Fatal(err) + } + if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired { + t.Fatalf("successor acquire=%v err=%v", acquired, err) + } +} + +func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := randomSuffix(t) + bot, err := users.Create(ctx, domain.User{ + AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot", + }) + if err != nil { + t.Fatalf("create bot user: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil { + t.Fatalf("seed bot: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) + }) + + s := NewBotAPIUpdateStore(pool) + enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) { + t.Helper() + row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: kind, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1}, + MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("enqueue %s/%d: %v", kind, messageID, err) + } + return row, created + } + for id := 1; id <= 3; id++ { + if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created { + t.Fatalf("initial message %d was not created", id) + } + } + if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil { + t.Fatalf("set allowed updates: %v", err) + } + if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 { + t.Fatalf("filtered row=%+v created=%v", row, created) + } + lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5) + if !created { + t.Fatal("allowed edit was filtered") + } + if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil { + t.Fatalf("initialize external cursor: %v", err) + } + confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID) + if err != nil || !found || confirmed != lastBeforeBaseline.ID { + t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID) + } + pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6) + if !created { + t.Fatal("post-baseline edit was filtered") + } + if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil { + t.Fatalf("repeat external cursor: %v", err) + } + confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID) + if confirmed != lastBeforeBaseline.ID { + t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID) + } + tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100) + if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID { + t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID) + } + if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 { + t.Fatalf("pending count=%d err=%v", count, err) + } + if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil { + t.Fatalf("drop pending: %v", err) + } + if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 { + t.Fatalf("pending after drop=%d err=%v", count, err) + } +} + // TestBotAPIUpdateRetention 锁定 H1 场景矩阵: // - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留; -// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot); -// - 未确认 + date 在保留期内 → 留; +// - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot); +// - 未确认 + created_at 在保留期内 → 留; // - 删除后 getUpdates 读路径(fromID > confirmed)不受影响。 func TestBotAPIUpdateRetention(t *testing.T) { pool := testPool(t) @@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { s := NewBotAPIUpdateStore(pool) now := time.Now().Unix() - stale := now - int64((48 * time.Hour).Seconds()) enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate { t.Helper() row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ @@ -67,7 +349,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删 confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留 unconfirmedFresh := enqueue(confirmedBot, 3, now) - expiredNoState := enqueue(mtprotoOnlyBot, 4, stale) // 无 state 行 + date 超保留期 → 删 + expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删 freshNoState := enqueue(mtprotoOnlyBot, 5, now) if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil { @@ -77,6 +359,10 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { "UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil { t.Fatalf("backdate confirmed row: %v", err) } + if _, err := pool.Exec(ctx, + "UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil { + t.Fatalf("backdate expired row: %v", err) + } deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000) if err != nil { @@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { // 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行; // 精确归属由下方 remaining 断言保证。 if deleted < 2 { - t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, date expired)", deleted) + t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted) } remaining := map[int64]bool{} diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go index 52308aa5..22b6789b 100644 --- a/internal/store/postgres/media.go +++ b/internal/store/postgres/media.go @@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool return photo, true, nil } +// GetPhotos resolves a bounded set of immutable photo metadata with one indexed +// ANY query. Missing ids are omitted and the result follows first-seen caller order. +func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) { + if len(ids) == 0 { + return nil, nil + } + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + if len(unique) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text +FROM photos +WHERE id = ANY($1::bigint[]) +`, unique) + if err != nil { + return nil, err + } + defer rows.Close() + byID := make(map[int64]domain.Photo, len(unique)) + for rows.Next() { + photo, err := scanPhotoRow(rows) + if err != nil { + return nil, err + } + byID[photo.ID] = photo + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]domain.Photo, 0, len(byID)) + for _, id := range unique { + if photo, ok := byID[id]; ok { + out = append(out, photo) + } + } + return out, nil +} + type photoScanner interface { Scan(dest ...any) error } diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go index 1ffc06f0..98aa688b 100644 --- a/internal/store/postgres/media_codec.go +++ b/internal/store/postgres/media_codec.go @@ -2,6 +2,7 @@ package postgres import ( "encoding/json" + "fmt" "reflect" "telesrv/internal/domain" @@ -44,9 +45,12 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) { return &m, nil } -// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。 +// encodeReplyMarkup 把 reply/inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。 // callback data 是 []byte,json.Marshal 自动 base64(保证经 JSONB 字节级 round-trip)。 func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) { + if err := domain.ValidateReplyMarkup(m); err != nil { + return nil, fmt.Errorf("encode reply markup: %w", err) + } if m.IsZero() { return []byte("{}"), nil } @@ -63,6 +67,9 @@ func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) { if err := json.Unmarshal([]byte(s), &m); err != nil { return nil, err } + if err := domain.ValidateReplyMarkup(&m); err != nil { + return nil, fmt.Errorf("decode reply markup: %w", err) + } if m.IsZero() { return nil, nil } diff --git a/internal/store/postgres/media_integration_test.go b/internal/store/postgres/media_integration_test.go index 39e0b650..5d89cb80 100644 --- a/internal/store/postgres/media_integration_test.go +++ b/internal/store/postgres/media_integration_test.go @@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) { if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" { t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto) } + photos, err := s.GetPhotos(ctx, []int64{photoID, 0, photoID, photoID + 99}) + if err != nil || len(photos) != 1 || photos[0].ID != photoID || len(photos[0].Sizes) != 1 { + t.Fatalf("get photos mismatch: photos=%+v err=%v", photos, err) + } // ---- sticker set ---- set := domain.StickerSet{ diff --git a/internal/store/postgres/message_history.go b/internal/store/postgres/message_history.go index b9497606..c1c4de04 100644 --- a/internal/store/postgres/message_history.go +++ b/internal/store/postgres/message_history.go @@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d return out, nil } +// GetByUID resolves one owner's box row by the indexed shared private_message_id. +func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) { + if userID == 0 || uid == 0 { + return domain.Message{}, false, nil + } + row, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: userID, + PrivateMessageID: uid, + }) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Message{}, false, nil + } + if err != nil { + return domain.Message{}, false, fmt.Errorf("get message by uid: %w", err) + } + if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil { + return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err) + } + return messageFromGetBoxRow(row), true, nil +} + func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { limit := filter.Limit if limit <= 0 { diff --git a/internal/store/postgres/message_markup_codec_test.go b/internal/store/postgres/message_markup_codec_test.go new file mode 100644 index 00000000..9bf83865 --- /dev/null +++ b/internal/store/postgres/message_markup_codec_test.go @@ -0,0 +1,42 @@ +package postgres + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestReplyMarkupCodecValidatesTaggedUnion(t *testing.T) { + keyboard := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupKeyboard, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}}, + Resize: true, + Placeholder: "Choose", + } + raw, err := encodeReplyMarkup(keyboard) + if err != nil { + t.Fatalf("encode reply keyboard: %v", err) + } + got, err := decodeReplyMarkup(string(raw)) + if err != nil || got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || + len(got.Keyboard) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || got.Placeholder != "Choose" { + t.Fatalf("decoded reply keyboard = %#v, err=%v", got, err) + } + + // Pre-union inline snapshots intentionally remain readable. + legacy, err := decodeReplyMarkup(`{"inline":[[{"type":"callback","text":"OK","data":"b2s="}]]}`) + if err != nil || legacy == nil || legacy.Kind() != domain.MessageReplyMarkupInline { + t.Fatalf("legacy inline markup = %#v, err=%v", legacy, err) + } + + malformed := &domain.MessageReplyMarkup{ + Type: domain.MessageReplyMarkupInline, + Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "wrong"}}}, + } + if _, err := encodeReplyMarkup(malformed); err == nil { + t.Fatal("malformed union must fail at the write boundary") + } + if _, err := decodeReplyMarkup(`{"type":"inline","keyboard":[[{"type":"text","text":"wrong"}]]}`); err == nil { + t.Fatal("malformed stored union must fail at the read boundary") + } +} diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go index d7e2199d..b6fc41e2 100644 --- a/internal/store/postgres/message_send.go +++ b/internal/store/postgres/message_send.go @@ -117,7 +117,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP if err != nil { return domain.SendPrivateTextResult{}, err } - // reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。 + // reply_markup(bot reply/inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。 replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) if err != nil { return domain.SendPrivateTextResult{}, err diff --git a/internal/store/postgres/message_send_integration_test.go b/internal/store/postgres/message_send_integration_test.go index 6462edac..f995fb7f 100644 --- a/internal/store/postgres/message_send_integration_test.go +++ b/internal/store/postgres/message_send_integration_test.go @@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { assertWebViewData("recipient event", events[0].Message) } +func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1666"+suffix+"33", "RequestedSender", "") + recipient := createTestUser(t, ctx, users, "+1666"+suffix+"34", "RequestedRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + requestedPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 5501} + photo := domain.Photo{ID: 8201, Sizes: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096, + }}} + messages := NewMessageStore(pool) + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 9002, Date: 1700000212, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: 88, Peers: []domain.Peer{requestedPeer}, + Details: []domain.MessageRequestedPeerDetails{{ + Peer: requestedPeer, Title: "Shared Chat", Username: "shared_chat", Photo: &photo, + }}, + NameRequested: true, UsernameRequested: true, PhotoRequested: true, + }, + }}, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertSnapshot := func(name string, msg domain.Message) { + t.Helper() + if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil { + t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media) + } + action := msg.Media.ServiceAction.RequestedPeer + if action.ButtonID != 88 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer || + len(action.Details) != 1 || action.Details[0].Title != "Shared Chat" || + action.Details[0].Username != "shared_chat" || action.Details[0].Photo == nil || + len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 || + !action.NameRequested || !action.UsernameRequested || !action.PhotoRequested { + t.Fatalf("%s requested-peer=%+v", name, action) + } + } + assertSnapshot("sender", got.SenderMessage) + assertSnapshot("recipient", got.RecipientMessage) + + history, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10, + }) + if err != nil || len(history.Messages) != 1 { + t.Fatalf("recipient history=%+v err=%v", history, err) + } + assertSnapshot("recipient history", history.Messages[0]) + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil || len(events) != 1 { + t.Fatalf("recipient events=%+v err=%v", events, err) + } + assertSnapshot("recipient event", events[0].Message) +} + func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) { pool := testPool(t) ctx := context.Background() diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index ed83afce..24a3dbf6 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -294,21 +294,33 @@ type Bot struct { } type BotApiUpdate struct { - ID int64 - BotUserID int64 - UpdateKind string - PeerType string - PeerID int64 - MessageID int32 - SourcePts int32 - Date int32 - CreatedAt pgtype.Timestamptz + ID int64 + BotUserID int64 + UpdateKind string + PeerType string + PeerID int64 + MessageID int32 + SourcePts int32 + Date int32 + CreatedAt pgtype.Timestamptz + CallbackQueryID int64 + CallbackUserID int64 + CallbackChatInstance int64 + CallbackData []byte + CallbackInlineDcID int32 + CallbackInlineOwnerID int64 + CallbackInlineMessageID int32 + CallbackInlineAccessHash int64 } type BotApiUpdateState struct { BotUserID int64 ConfirmedUpdateID int64 UpdatedAt pgtype.Timestamptz + AllowedUpdates []string + CursorInitialized bool + PollOwner string + PollExpiresAt pgtype.Timestamptz } type BotApp struct { diff --git a/internal/store/redisstore/bot_callback.go b/internal/store/redisstore/bot_callback.go new file mode 100644 index 00000000..a3b51e58 --- /dev/null +++ b/internal/store/redisstore/bot_callback.go @@ -0,0 +1,150 @@ +package redisstore + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const botCallbackAnswerChannel = "telesrv:bot_callback:answers" + +type BotCallbackRegistryStore struct { + c redis.UniversalClient +} + +func NewBotCallbackRegistryStore(c redis.UniversalClient) *BotCallbackRegistryStore { + return &BotCallbackRegistryStore{c: c} +} + +func botCallbackKey(queryID int64) string { + return fmt.Sprintf("telesrv:bot_callback:%d", queryID) +} + +var putBotCallbackScript = redis.NewScript(` +if redis.call('EXISTS', KEYS[1]) ~= 0 then + return 0 +end +redis.call('HSET', KEYS[1], + 'bot_user_id', ARGV[1], + 'user_id', ARGV[2], + 'created_at_unix_nano', ARGV[3]) +redis.call('PEXPIRE', KEYS[1], ARGV[4]) +return 1 +`) + +func (s *BotCallbackRegistryStore) PutBotCallbackPending(ctx context.Context, pending store.BotCallbackPending, ttl time.Duration) (bool, error) { + if s == nil || s.c == nil || pending.QueryID == 0 || pending.BotUserID <= 0 || pending.UserID <= 0 || ttl <= 0 { + return false, fmt.Errorf("invalid bot callback pending") + } + createdAt := pending.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + result, err := putBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(pending.QueryID)}, + pending.BotUserID, pending.UserID, createdAt.UnixNano(), ttl.Milliseconds()).Int64() + if err != nil { + return false, fmt.Errorf("put bot callback pending: %w", err) + } + return result == 1, nil +} + +var resolveBotCallbackScript = redis.NewScript(` +if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then + return 0 +end +if redis.call('HEXISTS', KEYS[1], 'answer') ~= 0 then + return 0 +end +redis.call('HSET', KEYS[1], 'answer', ARGV[2]) +redis.call('PUBLISH', ARGV[3], ARGV[4]) +return 1 +`) + +func (s *BotCallbackRegistryStore) ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error) { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return false, nil + } + answerJSON, err := json.Marshal(answer) + if err != nil { + return false, fmt.Errorf("marshal bot callback answer: %w", err) + } + pushJSON, err := json.Marshal(store.BotCallbackAnswerPush{QueryID: queryID, BotUserID: botUserID, Answer: answer}) + if err != nil { + return false, fmt.Errorf("marshal bot callback answer push: %w", err) + } + result, err := resolveBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)}, + strconv.FormatInt(botUserID, 10), answerJSON, botCallbackAnswerChannel, pushJSON).Int64() + if err != nil { + return false, fmt.Errorf("resolve bot callback: %w", err) + } + return result == 1, nil +} + +func (s *BotCallbackRegistryStore) GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return domain.BotCallbackAnswer{}, false, nil + } + values, err := s.c.HMGet(ctx, botCallbackKey(queryID), "bot_user_id", "answer").Result() + if err != nil { + return domain.BotCallbackAnswer{}, false, fmt.Errorf("get bot callback answer: %w", err) + } + if len(values) != 2 || values[0] == nil || values[1] == nil || fmt.Sprint(values[0]) != strconv.FormatInt(botUserID, 10) { + return domain.BotCallbackAnswer{}, false, nil + } + var answer domain.BotCallbackAnswer + if err := json.Unmarshal([]byte(fmt.Sprint(values[1])), &answer); err != nil { + return domain.BotCallbackAnswer{}, false, fmt.Errorf("decode bot callback answer: %w", err) + } + return answer, true, nil +} + +var deleteBotCallbackScript = redis.NewScript(` +if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then + return 0 +end +return redis.call('DEL', KEYS[1]) +`) + +func (s *BotCallbackRegistryStore) DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return nil + } + if _, err := deleteBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)}, strconv.FormatInt(botUserID, 10)).Result(); err != nil && err != redis.Nil { + return fmt.Errorf("delete bot callback pending: %w", err) + } + return nil +} + +func (s *BotCallbackRegistryStore) SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, store.BotCallbackAnswerPush)) error { + if s == nil || s.c == nil || handle == nil { + return nil + } + pubsub := s.c.Subscribe(ctx, botCallbackAnswerChannel) + defer pubsub.Close() + if _, err := pubsub.Receive(ctx); err != nil { + return fmt.Errorf("subscribe bot callback answers: %w", err) + } + channel := pubsub.Channel(redis.WithChannelSize(256)) + for { + select { + case <-ctx.Done(): + return nil + case message, ok := <-channel: + if !ok { + return nil + } + var push store.BotCallbackAnswerPush + if err := json.Unmarshal([]byte(message.Payload), &push); err != nil || push.QueryID == 0 || push.BotUserID <= 0 { + continue + } + handle(ctx, push) + } + } +} diff --git a/internal/store/redisstore/bot_callback_integration_test.go b/internal/store/redisstore/bot_callback_integration_test.go new file mode 100644 index 00000000..bf89f2ab --- /dev/null +++ b/internal/store/redisstore/bot_callback_integration_test.go @@ -0,0 +1,76 @@ +package redisstore + +import ( + "context" + "os" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestRedisBotCallbackRegistryCrossInstanceCASAndPubSub(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + clientA, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatal(err) + } + defer clientA.Close() + clientB, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatal(err) + } + defer clientB.Close() + a, b := NewBotCallbackRegistryStore(clientA), NewBotCallbackRegistryStore(clientB) + queryID := time.Now().UnixNano() + defer a.DeleteBotCallbackPending(context.Background(), 1001, queryID) + pushes := make(chan store.BotCallbackAnswerPush, 1) + subscribed := make(chan struct{}) + go func() { + _ = b.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) { + select { + case pushes <- push: + default: + } + }) + }() + // Subscribe uses Redis' acknowledgement before consuming Channel. Give that + // acknowledgement one bounded scheduling turn before publishing. + time.AfterFunc(50*time.Millisecond, func() { close(subscribed) }) + <-subscribed + created, err := a.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2001}, time.Second) + if err != nil || !created { + t.Fatalf("put created=%v err=%v", created, err) + } + if duplicate, err := b.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2002}, time.Second); err != nil || duplicate { + t.Fatalf("duplicate=%v err=%v", duplicate, err) + } + answer := domain.BotCallbackAnswer{Message: "done", CacheTime: 3} + if resolved, err := b.ResolveBotCallback(ctx, 9999, queryID, answer); err != nil || resolved { + t.Fatalf("foreign resolve=%v err=%v", resolved, err) + } + if resolved, err := b.ResolveBotCallback(ctx, 1001, queryID, answer); err != nil || !resolved { + t.Fatalf("owner resolve=%v err=%v", resolved, err) + } + if second, err := a.ResolveBotCallback(ctx, 1001, queryID, domain.BotCallbackAnswer{Message: "second"}); err != nil || second { + t.Fatalf("second resolve=%v err=%v", second, err) + } + stored, found, err := a.GetBotCallbackAnswer(ctx, 1001, queryID) + if err != nil || !found || stored.Message != "done" { + t.Fatalf("stored=%#v found=%v err=%v", stored, found, err) + } + select { + case push := <-pushes: + if push.QueryID != queryID || push.BotUserID != 1001 || push.Answer.Message != "done" { + t.Fatalf("push=%#v", push) + } + case <-ctx.Done(): + t.Fatal("missing cross-instance callback pubsub") + } +}