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.
This commit is contained in:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 deletions

View file

@ -0,0 +1,63 @@
# aiogram 3 echo demo
该示例使用标准 aiogram 3 API仅把 API server 指向 telesrv。aiogram 的
`TelegramAPIServer.from_base()` 会自动拼出 telesrv 已支持的
`/bot<TOKEN>/<method>``/file/bot<TOKEN>/<path>`
```powershell
python -m pip install -r .\cmd\bots\aiogramecho\requirements.txt
$env:TELESRV_BOT_TOKEN = "<bot_id>:<secret>"
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 <custom_emoji_document_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`

View file

@ -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<TOKEN> and /file/bot<TOKEN>",
)
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 <text>, /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())

View file

@ -0,0 +1 @@
aiogram==3.30.0

View file

@ -34,11 +34,24 @@ python .\cmd\bots\ptbecho\echo.py `
--send-text "ptbecho is online" --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-count N`:连续主动发送 N 条。
- `--send-interval SEC`:连续发送之间的间隔。 - `--send-interval SEC`:连续发送之间的间隔。
- `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。 - `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>`;例如 channel id 为 本地超级群 chat id 使用 Bot API 形式 `-100<channel_id>`;例如 channel id 为
`2` 时是 `-1000000000002` `2` 时是 `-1000000000002`

View file

@ -32,11 +32,18 @@ import os
import signal import signal
from typing import Iterable from typing import Iterable
from telegram import Bot, Update from telegram import (
from telegram.constants import ChatAction Bot,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
Update,
)
from telegram.ext import ( from telegram.ext import (
Application, Application,
ApplicationBuilder, ApplicationBuilder,
CallbackQueryHandler,
CommandHandler, CommandHandler,
ContextTypes, ContextTypes,
MessageHandler, 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-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("--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("--send-only", action="store_true", help="Send proactive messages and exit without polling")
parser.add_argument("--log-level", default="INFO") parser.add_argument("--log-level", default="INFO")
args = parser.parse_args() args = parser.parse_args()
@ -87,11 +100,10 @@ def parse_args() -> argparse.Namespace:
parser.error("--send-count must be >= 1") parser.error("--send-count must be >= 1")
if args.send_interval < 0: if args.send_interval < 0:
parser.error("--send-interval must be >= 0") parser.error("--send-interval must be >= 0")
wants_send = args.send_only or bool(args.send_text) if args.send_text and args.send_chat_id is None:
if wants_send and args.send_chat_id is None:
parser.error("--send-chat-id is required when --send-text or --send-only is used") parser.error("--send-chat-id is required when --send-text or --send-only is used")
if args.send_only and not args.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") parser.error("--send-only requires --send-text or --buttons-chat-id")
return args return args
@ -112,7 +124,6 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not text: if not text:
return return
prefix = context.application.bot_data.get("prefix", "echo: ") 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) sent = await update.effective_message.reply_text(prefix + text)
LOG.info( LOG.info(
"echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r", "echoed update_id=%s chat_id=%s message_id=%s 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: async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None:
for index in range(count): for index in range(count):
sent = await bot.send_message(chat_id=chat_id, text=text) 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) 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: async def send_on_startup(app: Application) -> None:
chat_id = app.bot_data.get("send_chat_id") chat_id = app.bot_data.get("send_chat_id")
text = app.bot_data.get("send_text") text = app.bot_data.get("send_text")
if chat_id is None or not text: if chat_id is not None and text:
return await send_active_messages(
await send_active_messages( app.bot,
app.bot, chat_id=chat_id,
chat_id=chat_id, text=text,
text=text, count=int(app.bot_data.get("send_count", 1)),
count=int(app.bot_data.get("send_count", 1)), interval=float(app.bot_data.get("send_interval", 1.0)),
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: async def post_init(app: Application) -> None:
me = await app.bot.get_me() 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"]) 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"): if (app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text")) or app.bot_data.get(
app.create_task(send_on_startup(app), name="ptbecho-proactive-send") "buttons_chat_id"
) is not None:
await send_on_startup(app)
def build_app(args: argparse.Namespace) -> Application: 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_text"] = args.send_text
app.bot_data["send_count"] = args.send_count app.bot_data["send_count"] = args.send_count
app.bot_data["send_interval"] = args.send_interval 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("start", start))
app.add_handler(CommandHandler("ping", ping)) 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)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
return app 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) bot = Bot(token=args.token, base_url=args.base_url, base_file_url=args.base_file_url)
me = await bot.get_me() me = await bot.get_me()
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url) LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
await send_active_messages( if args.send_chat_id is not None and args.send_text:
bot, await send_active_messages(
chat_id=args.send_chat_id, bot,
text=args.send_text, chat_id=args.send_chat_id,
count=args.send_count, text=args.send_text,
interval=args.send_interval, 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": if os.name == "nt":
return (signal.SIGINT, signal.SIGTERM) return None
return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
@ -214,7 +289,7 @@ def main() -> int:
app = build_app(args) app = build_app(args)
app.run_polling( app.run_polling(
allowed_updates=["message", "edited_message"], allowed_updates=["message", "edited_message", "callback_query"],
drop_pending_updates=args.drop_pending, drop_pending_updates=args.drop_pending,
poll_interval=0.0, poll_interval=0.0,
timeout=args.timeout, timeout=args.timeout,

View file

@ -362,6 +362,7 @@ func run(logger *zap.Logger) error {
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout)) dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool) bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool) botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(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) return fmt.Errorf("seed appearance: %w", err)
} else if !stats.Skipped { } else if !stats.Skipped {
logger.Info("外观种子导入完成", logger.Info("外观种子导入完成",
zap.String("source", "orange-live"), zap.String("source", "default-seed"),
zap.Int("wallpapers", stats.Wallpapers), zap.Int("wallpapers", stats.Wallpapers),
zap.Int("documents", stats.Documents), zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs), zap.Int("blobs", stats.Blobs),
@ -792,6 +793,7 @@ func run(logger *zap.Logger) error {
Updates: updatesService, Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore, BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore, BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService, Contacts: contactsService,
Dialogs: dialogsService, Dialogs: dialogsService,
Chatlists: chatlistsService, Chatlists: chatlistsService,
@ -900,6 +902,7 @@ func run(logger *zap.Logger) error {
} }
}() }()
go router.RunInlineBotPushSubscriber(ctx) go router.RunInlineBotPushSubscriber(ctx)
go router.RunBotCallbackAnswerSubscriber(ctx)
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil { if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err) return fmt.Errorf("start bot api: %w", err)
} }

View file

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

View file

@ -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';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.bot_api_webhooks;

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE public.webview_requested_buttons
DROP COLUMN IF EXISTS peer_filter;

View file

@ -0,0 +1,2 @@
ALTER TABLE public.webview_requested_buttons
ADD COLUMN peer_filter jsonb NOT NULL DEFAULT '{}'::jsonb;

View file

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

View file

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

View file

@ -1276,6 +1276,9 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send
if req.UserID != userID { if req.UserID != userID {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
} }
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.SendChannelMessageResult{}, err
}
if req.RandomID != 0 && !req.IdempotencyPreflighted { if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.ChannelSendFingerprint(req) fingerprint, err := store.ChannelSendFingerprint(req)
if err != nil { 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 { if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid 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) 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 { if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 {
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
} }
if req.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.EditChannelMessageResult{}, err
}
}
req.ViaBotEditBotID = botID req.ViaBotEditBotID = botID
return s.channels.EditChannelMessage(ctx, req) return s.channels.EditChannelMessage(ctx, req)
} }

View file

@ -461,7 +461,14 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
if in == nil { if in == nil {
return 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 { if len(in.Inline) > 0 {
out.Inline = make([][]domain.MarkupButton, len(in.Inline)) out.Inline = make([][]domain.MarkupButton, len(in.Inline))
for i, row := range 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 return out
} }

View file

@ -98,6 +98,41 @@ func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, e
return s.media.GetPhoto(ctx, id) 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 返回已存储文档(贴纸 / 文件)。 // GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
return s.media.GetDocument(ctx, id) return s.media.GetDocument(ctx, id)

View file

@ -100,6 +100,9 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
if req.SenderUserID != userID { if req.SenderUserID != userID {
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
} }
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.SendPrivateTextResult{}, err
}
if req.RandomID != 0 && !req.IdempotencyPreflighted { if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.PrivateSendFingerprint(req) fingerprint, err := store.PrivateSendFingerprint(req)
if err != nil { 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) 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 的历史消息。 // GetHistory 返回当前账号某个 peer 的历史消息。
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
return s.list(ctx, userID, filter) 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 { if req.OwnerUserID == 0 {
req.OwnerUserID = userID 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) return s.messages.EditMessage(ctx, req)
} }

View file

@ -36,7 +36,7 @@ func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) {
if err != nil { if err != nil {
return domain.BotInlineResult{}, err return domain.BotInlineResult{}, err
} }
markup, err := replyMarkupFromAPI(payload.ReplyMarkup) markup, err := inlineReplyMarkupFromAPI(payload.ReplyMarkup)
if err != nil { if err != nil {
return domain.BotInlineResult{}, err return domain.BotInlineResult{}, err
} }
@ -165,6 +165,64 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
if len(raw) == 0 || string(raw) == "null" { if len(raw) == 0 || string(raw) == "null" {
return nil, nil 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 var payload apiInlineKeyboardMarkup
if err := json.Unmarshal(raw, &payload); err != nil { if err := json.Unmarshal(raw, &payload); err != nil {
return nil, errors.New("BUTTON_INVALID") return nil, errors.New("BUTTON_INVALID")
@ -172,7 +230,7 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
if len(payload.InlineKeyboard) == 0 { if len(payload.InlineKeyboard) == 0 {
return nil, nil 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 { for _, row := range payload.InlineKeyboard {
domainRow := make([]domain.MarkupButton, 0, len(row)) domainRow := make([]domain.MarkupButton, 0, len(row))
for _, button := range row { for _, button := range row {
@ -193,19 +251,132 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
return out, nil return out, nil
} }
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) { func replyKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
if button.URL != "" { var payload apiReplyKeyboardMarkup
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil if err := json.Unmarshal(raw, &payload); err != nil || len(payload.Keyboard) == 0 {
return nil, errors.New("BUTTON_INVALID")
} }
if button.CallbackData != nil { out := &domain.MessageReplyMarkup{
if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen { 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{}, 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") 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 { func replyMarkupErrFromDomain(err error) error {
switch { switch {
case errors.Is(err, domain.ErrButtonURLInvalid): case errors.Is(err, domain.ErrButtonURLInvalid):
@ -287,8 +458,342 @@ type apiInlineKeyboardMarkup struct {
InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"` InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"`
} }
type apiInlineKeyboardButton struct { type apiReplyKeyboardMarkup struct {
Text string `json:"text"` Keyboard [][]apiKeyboardButton `json:"keyboard"`
URL string `json:"url"` IsPersistent bool `json:"is_persistent"`
CallbackData *string `json:"callback_data"` 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, &copy) != 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
} }

View file

@ -2,12 +2,14 @@ package botapi
import ( import (
"encoding/base64" "encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store"
) )
func apiInt(raw string, fallback int) int { func apiInt(raw string, fallback int) int {
@ -33,33 +35,42 @@ func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) {
return messageEntitiesFromAPI(payload) return messageEntitiesFromAPI(payload)
} }
func allowedUpdates(raw string) map[string]struct{} { func parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return nil return nil, errors.New("ALLOWED_UPDATES_INVALID")
} }
var items []string var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil { 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 { for _, item := range items {
item = strings.TrimSpace(item) item = strings.TrimSpace(item)
if item != "" { if item == "" || len(item) > 64 {
out[item] = struct{}{} 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 { if limit <= 0 || limit > 100 {
limit = 100 limit = 100
} }
out := make([]map[string]any, 0, min(len(events), limit)) out := make([]map[string]any, 0, min(len(events), limit))
for _, event := range events { for _, event := range events {
item, kind, ok := apiUpdate(event) item, _, ok := apiUpdate(event)
if !ok || !updateAllowed(kind, allowed) { if !ok {
continue continue
} }
out = append(out, item) out = append(out, item)
@ -73,14 +84,6 @@ func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit
return out 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) { func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
if event.Pts <= 0 { if event.Pts <= 0 {
return nil, "", false return nil, "", false
@ -92,7 +95,7 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
} }
return map[string]any{ return map[string]any{
"update_id": event.Pts, "update_id": event.Pts,
"message": apiMessage(event.Message, event.Users), "message": apiMessage(event.Message, event.Users, event.Channels),
}, "message", true }, "message", true
case domain.UpdateEventEditMessage: case domain.UpdateEventEditMessage:
if !apiMessageProjectable(event.Message) { if !apiMessageProjectable(event.Message) {
@ -100,18 +103,90 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
} }
return map[string]any{ return map[string]any{
"update_id": event.Pts, "update_id": event.Pts,
"edited_message": apiMessage(event.Message, event.Users), "edited_message": apiMessage(event.Message, event.Users, event.Channels),
}, "edited_message", true }, "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: default:
return nil, "", false 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 { func apiMessageProjectable(msg domain.Message) bool {
if msg.Out || msg.ID <= 0 { if msg.Out || msg.ID <= 0 {
return false 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 { func apiUser(u domain.User) map[string]any {
@ -133,11 +208,17 @@ func apiUser(u domain.User) map[string]any {
return out 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{} userByID := map[int64]domain.User{}
for _, u := range users { for _, u := range users {
userByID[u.ID] = u 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{ out := map[string]any{
"message_id": msg.ID, "message_id": msg.ID,
"date": msg.Date, "date": msg.Date,
@ -153,17 +234,25 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any {
} }
out["from"] = apiUser(from) out["from"] = apiUser(from)
} }
media := apiMessageMedia(msg.Media) media := apiMessageMedia(msg.Media, userByID, channelByID)
if msg.Body != "" { if msg.Body != "" {
if len(media) > 0 { if _, photo := media["photo"]; photo {
out["caption"] = msg.Body 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 { } else {
out["text"] = msg.Body out["text"] = msg.Body
} }
} }
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 { if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
if len(media) > 0 { if _, photo := media["photo"]; photo {
out["caption_entities"] = entities 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 { } else {
out["entities"] = entities out["entities"] = entities
} }
@ -309,6 +398,11 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
if markup.IsZero() { if markup.IsZero() {
return nil 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)) rows := make([][]map[string]any, 0, len(markup.Inline))
for _, row := range markup.Inline { for _, row := range markup.Inline {
if len(row) == 0 { if len(row) == 0 {
@ -317,11 +411,43 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
apiRow := make([]map[string]any, 0, len(row)) apiRow := make([]map[string]any, 0, len(row))
for _, button := range row { for _, button := range row {
item := map[string]any{"text": button.Text} 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 { switch button.Type {
case domain.MarkupButtonURL: case domain.MarkupButtonURL:
item["url"] = button.URL item["url"] = button.URL
case domain.MarkupButtonCallback: case domain.MarkupButtonCallback:
item["callback_data"] = string(button.Data) 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: default:
continue continue
} }
@ -337,7 +463,7 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
return map[string]any{"inline_keyboard": rows} 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() { if media.IsZero() {
return nil return nil
} }
@ -356,11 +482,264 @@ func apiMessageMedia(media *domain.MessageMedia) map[string]any {
return nil return nil
} }
return map[string]any{"document": apiDocument(*media.Document)} 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: default:
return nil 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 { func apiPhotoSizes(photo domain.Photo) []map[string]any {
return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":") return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":")
} }

View file

@ -10,8 +10,10 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
neturl "net/url"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"go.uber.org/zap" "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) 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) 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) 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) 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) 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) 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 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) { 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) == "" { if strings.TrimSpace(addr) == "" {
return nil, nil return nil, nil
@ -58,7 +84,7 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
if logger == nil { if logger == nil {
logger = zap.NewNop() 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{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: handler.routes(), 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)) 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() { go func() {
<-ctx.Done() <-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 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 { type handler struct {
bots BotsService bots BotsService
users UsersService users UsersService
webapps WebAppService webapps WebAppService
gateway GatewayService gateway GatewayService
logger *zap.Logger 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 ( const (
@ -148,11 +203,11 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
case "getfile": case "getfile":
h.getFile(w, r, botID) h.getFile(w, r, botID)
case "deletewebhook": case "deletewebhook":
writeAPIOK(w, true) h.deleteWebhook(w, r, botID)
case "getwebhookinfo": case "getwebhookinfo":
writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0}) h.getWebhookInfo(w, r, botID)
case "setwebhook": case "setwebhook":
h.setWebhook(w, r) h.setWebhook(w, r, botID)
case "setchatmenubutton": case "setchatmenubutton":
h.setChatMenuButton(w, r, botID) h.setChatMenuButton(w, r, botID)
case "getchatmenubutton": case "getchatmenubutton":
@ -216,7 +271,14 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return 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) limit := apiInt(values["limit"], 100)
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100
@ -231,7 +293,56 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
if timeoutSeconds > 50 { if timeoutSeconds > 50 {
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) deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
for { for {
version := botAPIUpdateWaitVersion(h.gateway, botID) 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") writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return return
} }
updates := apiUpdates(events, allowed, limit) updates := apiUpdates(events, limit)
if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) { if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) {
writeAPIOK(w, updates) writeAPIOK(w, updates)
return 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 { func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 {
waiter, ok := gateway.(GatewayUpdateWaiter) waiter, ok := gateway.(GatewayUpdateWaiter)
if !ok { if !ok {
@ -381,14 +570,22 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return return
} }
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) rawInlineID := strings.TrimSpace(values["inline_message_id"])
if err != nil || chatID == 0 { var chatID int64
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") messageID := 0
return if rawInlineID == "" {
} chatID, err = strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
messageID := apiInt(values["message_id"], 0) if err != nil || chatID == 0 {
if messageID <= 0 { writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
writeAPIError(w, http.StatusBadRequest, "MESSAGE_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 return
} }
if strings.TrimSpace(values["parse_mode"]) != "" { 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 var markup *domain.MessageReplyMarkup
_, setReplyMarkup := values["reply_markup"] _, setReplyMarkup := values["reply_markup"]
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw)) markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw))
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return 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"])) msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) 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) { func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r) values, files, err := requestValuesWithFiles(r)
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return 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) writeAPIOK(w, true)
return 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) { func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) {

View file

@ -10,6 +10,7 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "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) { func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{ 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 { func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret) token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret)
@ -649,11 +1058,21 @@ type fakeBotAPIGateway struct {
editCalled bool editCalled bool
editSetMarkup bool editSetMarkup bool
editMessage domain.Message editMessage domain.Message
editInlineCalled bool
editInlineID domain.BotInlineMessageID
deleteCalled bool deleteCalled bool
callbackCalled bool callbackCalled bool
callbackID string callbackID string
fileLocationKey string fileLocationKey string
fileChunks map[string]domain.FileChunk 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) { 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 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) { 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.sendCalled = true
f.sendBotID = botID f.sendBotID = botID
@ -695,6 +1173,11 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat
return f.editMessage, nil 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) { func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
f.deleteCalled = true f.deleteCalled = true
return true, nil return true, nil

259
internal/botapi/webhook.go Normal file
View file

@ -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<<exponent)
if delay > 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))
}

View file

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

View file

@ -204,15 +204,19 @@ type BotAttachMenuState struct {
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。 // BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
type BotRequestedWebViewButton struct { type BotRequestedWebViewButton struct {
WebAppReqID string WebAppReqID string
BotUserID int64 BotUserID int64
UserID int64 UserID int64
ButtonID int ButtonID int
Text string Text string
PeerType string PeerType string
MaxQuantity int MaxQuantity int
CreatedAt time.Time PeerFilter *BotRequestPeerFilter
ExpiresAt time.Time NameRequested bool
UsernameRequested bool
PhotoRequested bool
CreatedAt time.Time
ExpiresAt time.Time
} }
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答 // BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答

View file

@ -6,8 +6,32 @@ type BotAPIUpdateKind string
const ( const (
BotAPIUpdateMessage BotAPIUpdateKind = "message" BotAPIUpdateMessage BotAPIUpdateKind = "message"
BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_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 // 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 // and is global across all bots, matching Telegram Bot API's monotonic offset
// contract without reusing MTProto pts from user/channel logs. // contract without reusing MTProto pts from user/channel logs.
@ -19,6 +43,7 @@ type BotAPIUpdate struct {
MessageID int MessageID int
SourcePts int SourcePts int
Date int Date int
Callback *BotCallbackQuery
} }
// EnqueueBotAPIUpdateRequest describes a message-like update that should be // EnqueueBotAPIUpdateRequest describes a message-like update that should be
@ -30,4 +55,5 @@ type EnqueueBotAPIUpdateRequest struct {
MessageID int MessageID int
SourcePts int SourcePts int
Date int Date int
Callback *BotCallbackQuery
} }

View file

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

View file

@ -188,20 +188,23 @@ const (
// ChannelAdminRights is a domain-only representation of Telegram admin rights. // ChannelAdminRights is a domain-only representation of Telegram admin rights.
type ChannelAdminRights struct { type ChannelAdminRights struct {
ChangeInfo bool ChangeInfo bool
PostMessages bool PostMessages bool
EditMessages bool EditMessages bool
DeleteMessages bool DeleteMessages bool
PostStories bool PostStories bool
EditStories bool EditStories bool
DeleteStories bool DeleteStories bool
BanUsers bool BanUsers bool
InviteUsers bool InviteUsers bool
PinMessages bool PinMessages bool
AddAdmins bool AddAdmins bool
ManageCall bool ManageCall bool
Anonymous bool ManageChat bool
ManageRanks bool ManageTopics bool
Anonymous bool
ManageRanks bool
ManageLinkedPeers bool
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的 // ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。 // 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
ManageDirectMessages bool ManageDirectMessages bool
@ -210,19 +213,22 @@ type ChannelAdminRights struct {
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections. // CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
func CreatorChannelAdminRights() ChannelAdminRights { func CreatorChannelAdminRights() ChannelAdminRights {
return ChannelAdminRights{ return ChannelAdminRights{
ChangeInfo: true, ChangeInfo: true,
PostMessages: true, PostMessages: true,
EditMessages: true, EditMessages: true,
DeleteMessages: true, DeleteMessages: true,
PostStories: true, PostStories: true,
EditStories: true, EditStories: true,
DeleteStories: true, DeleteStories: true,
BanUsers: true, BanUsers: true,
InviteUsers: true, InviteUsers: true,
PinMessages: true, PinMessages: true,
AddAdmins: true, AddAdmins: true,
ManageCall: true, ManageCall: true,
ManageRanks: true, ManageChat: true,
ManageTopics: true,
ManageRanks: true,
ManageLinkedPeers: true,
} }
} }

View file

@ -610,6 +610,23 @@ type MessageWebViewDataAction struct {
type MessageRequestedPeerAction struct { type MessageRequestedPeerAction struct {
ButtonID int `json:"button_id"` ButtonID int `json:"button_id"`
Peers []Peer `json:"peers"` 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 是私聊服务消息动作的协议中立表示。 // MessageServiceAction 是私聊服务消息动作的协议中立表示。

View file

@ -151,7 +151,7 @@ type Message struct {
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。 // (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
// 转发不携带特效(新消息恒 0。仅私聊群/频道不渲染。 // 转发不携带特效(新消息恒 0。仅私聊群/频道不渲染。
Effect int64 Effect int64
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照P3。仅 bot 出站消息可 // ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
// 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。 // 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。 // RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
@ -283,7 +283,7 @@ type SendPrivateTextRequest struct {
// BusinessAutomationKind is internal app-layer metadata used to suppress // BusinessAutomationKind is internal app-layer metadata used to suppress
// recursive greeting/away automation for server-generated replies. // recursive greeting/away automation for server-generated replies.
BusinessAutomationKind BusinessAutomationKind BusinessAutomationKind BusinessAutomationKind
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照P3;普通用户发送恒 nil。 // ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
ReplyMarkup *MessageReplyMarkup ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。 // RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage RichMessage *MessageRichMessage

View file

@ -18,6 +18,10 @@ const (
MaxCallbackDataLen = 64 MaxCallbackDataLen = 64
// MaxMarkupButtonTextLen 是按钮文本长度上限rune 计数)。 // MaxMarkupButtonTextLen 是按钮文本长度上限rune 计数)。
MaxMarkupButtonTextLen = 256 MaxMarkupButtonTextLen = 256
// MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。
MaxReplyKeyboardButtonTextLen = 64
// MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。
MaxReplyKeyboardPlaceholderLen = 64
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。 // MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
MaxBotCallbackAnswerLen = 200 MaxBotCallbackAnswerLen = 200
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64 // MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64
@ -38,20 +42,81 @@ var (
ErrStartParamInvalid = errors.New("start param invalid") ErrStartParamInvalid = errors.New("start param invalid")
) )
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。 // MarkupButtonType 标识消息键盘按钮类型。
type MarkupButtonType string type MarkupButtonType string
const ( const (
// MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。
MarkupButtonText MarkupButtonType = "text"
// MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer // MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer
MarkupButtonCallback MarkupButtonType = "callback" MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl点击打开链接 // 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 MarkupButton struct {
Type MarkupButtonType `json:"type"` Type MarkupButtonType `json:"type"`
Text string `json:"text"` 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 // Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位。json 自动 base64
// 编解码,保证经 JSONB 列字节级 round-tripupdateBotCallbackQuery.data 须原样)。 // 编解码,保证经 JSONB 列字节级 round-tripupdateBotCallbackQuery.data 须原样)。
Data []byte `json:"data,omitempty"` Data []byte `json:"data,omitempty"`
@ -60,11 +125,68 @@ type MarkupButton struct {
// RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password // RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password
// 2FA SRP 校验 P3 stub // 2FA SRP 校验 P3 stub
RequiresPassword bool `json:"requires_password,omitempty"` 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 keyboardKind 会将其解释为 inline。
type MessageReplyMarkup struct { 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、不入库。 // IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool {
if m == nil { if m == nil {
return true return true
} }
for _, row := range m.Inline { switch m.Kind() {
if len(row) > 0 { case MessageReplyMarkupInline:
return false 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 // ValidateReplyMarkup 校验 markup constructor、结构与按钮校验须先于落库I9
// 空 markup 合法(视为清空/无键盘)。 // 空 inline markup 合法(视为清空/无键盘)。
func ValidateReplyMarkup(m *MessageReplyMarkup) error { func ValidateReplyMarkup(m *MessageReplyMarkup) error {
if m == nil { if m == nil {
return 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 return ErrButtonInvalid
} }
total := 0 total := 0
for _, row := range m.Inline { for _, row := range rows {
if len(row) > MaxMarkupButtonsPerRow { if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
return ErrButtonInvalid return ErrButtonInvalid
} }
total += len(row) total += len(row)
@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return ErrButtonInvalid return ErrButtonInvalid
} }
for i := range row { for i := range row {
if err := validateMarkupButton(row[i]); err != nil { if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
return err return err
} }
} }
@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return nil return nil
} }
func validateMarkupButton(b MarkupButton) error { func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
text := strings.TrimSpace(b.Text) text := strings.TrimSpace(b.Text)
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen { if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
return ErrButtonInvalid 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 { switch b.Type {
case MarkupButtonCallback: case MarkupButtonCallback:
if len(b.Data) > MaxCallbackDataLen { if len(b.Data) > MaxCallbackDataLen {
@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error {
if err := validateButtonURL(b.URL); err != nil { if err := validateButtonURL(b.URL); err != nil {
return err 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: default:
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。 // webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
return ErrButtonTypeInvalid return ErrButtonTypeInvalid

View file

@ -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 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 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}, {"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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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() { if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
t.Fatal("markup with a button must not be zero") 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")
}
} }

View file

@ -12,6 +12,9 @@ const (
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox" UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
UpdateEventReadMessageContents UpdateEventType = "read_message_contents" UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
UpdateEventEditMessage UpdateEventType = "edit_message" UpdateEventEditMessage UpdateEventType = "edit_message"
// UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号
// pts/difference/outbox。
UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query"
// UpdateEventWebPage 映射 updateWebPage异步解析完成后把消息里的 pending 链接预览 // UpdateEventWebPage 映射 updateWebPage异步解析完成后把消息里的 pending 链接预览
// 占位就地替换为已解析卡片。携带账号 pts非 LacksWirePts消息快照经 box JOIN 重建, // 占位就地替换为已解析卡片。携带账号 pts非 LacksWirePts消息快照经 box JOIN 重建,
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。 // 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
@ -110,6 +113,7 @@ type UpdateEvent struct {
QuickReplies []QuickReply QuickReplies []QuickReply
QuickReply QuickReply QuickReply QuickReply
QuickReplyMessage QuickReplyMessage QuickReplyMessage QuickReplyMessage
BotCallbackQuery *BotCallbackQuery
} }
// LacksWirePts 表示该事件占用了账号 pts但它对应的 TL update 构造器没有 // LacksWirePts 表示该事件占用了账号 pts但它对应的 TL update 构造器没有

View file

@ -8,7 +8,10 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store"
) )
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1} 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 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 // 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; // or channel message state machine. Positive chat_id is a user private chat;
// -1000000000000-channel_id is a supergroup/channel 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 { if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID") 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 == "" { if text == "" {
return domain.Message{}, errors.New("MESSAGE_EMPTY") return domain.Message{}, errors.New("MESSAGE_EMPTY")
} }
@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
if !ok { if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID") 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 { if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
return domain.Message{}, errors.New("MESSAGE_TOO_LONG") 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 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 // BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
// the target user's MTProto clients observe the normal delete update. // 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) { 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 { if cacheTime < 0 {
cacheTime = 0 cacheTime = 0
} }
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{ resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{
Alert: showAlert, Alert: showAlert,
Message: text, Message: text,
URL: url, URL: url,
CacheTime: cacheTime, CacheTime: cacheTime,
}) })
if resolveErr != nil {
return false, resolveErr
}
if !resolved {
return false, errors.New("QUERY_ID_INVALID")
}
return true, nil return true, nil
} }

View file

@ -2,11 +2,14 @@ package rpc
import ( import (
"context" "context"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/iamxvbaba/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
appbots "telesrv/internal/app/bots" appbots "telesrv/internal/app/bots"
@ -17,6 +20,170 @@ import (
"telesrv/internal/store/memory" "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) { func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
ctx := context.Background() ctx := context.Background()
userStore := memory.NewUserStore() userStore := memory.NewUserStore()
@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
}, zaptest.NewLogger(t), clock.System) }, zaptest.NewLogger(t), clock.System)
chatID := -botAPIChannelChatIDBase - created.Channel.ID 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 { if err != nil {
t.Fatalf("BotAPISendMessage: %v", err) t.Fatalf("BotAPISendMessage: %v", err)
} }
@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetHistory: %v", err) 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) t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
} }
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) { if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {

View file

@ -2,11 +2,16 @@ package rpc
import ( import (
"context" "context"
"errors"
"time"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
const botAPIGetUpdatesLimit = 100 const (
botAPIGetUpdatesLimit = 100
botAPIMaxNegativeOffset = 10000
)
type botAPIChannelBotMemberProvider interface { type botAPIChannelBotMemberProvider interface {
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) 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 return nil, nil
} }
fromID := int64(1) 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 { if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil {
return nil, err 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 offset >= 0 {
if err != nil { confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID)
return nil, err 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 { if len(items) == 0 {
return nil, nil return nil, nil
} }
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items) events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now())
if leadingSkipped > 0 { if leadingSkipped > 0 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil { if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
return nil, err 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 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) privateIDs := make([]int, 0)
privateSeen := make(map[int]struct{}) privateSeen := make(map[int]struct{})
channelIDs := make(map[int64][]int) channelIDs := make(map[int64][]int)
channelSeen := make(map[int64]map[int]struct{}) channelSeen := make(map[int64]map[int]struct{})
for _, item := range items { 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 continue
} }
switch item.Peer.Type { 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)) events := make([]domain.UpdateEvent, 0, len(items))
leadingSkipped := int64(0) leadingSkipped := int64(0)
for _, item := range items { for _, item := range items {
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages) event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now)
if !ok { if !ok {
if len(events) == 0 { if len(events) == 0 {
leadingSkipped = item.ID 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)) out := make(map[int]domain.Message, len(list.Messages))
for _, msg := range list.Messages { for _, msg := range list.Messages {
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) { if msg.ID <= 0 || msg.OwnerUserID != botID {
continue continue
} }
out[msg.ID] = msg 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 { if msg.ID <= 0 || msg.Deleted || msg.Action != nil {
continue continue
} }
projected := botAPIMessageFromChannel(botID, msg)
if projected.Out || !botAPIMessageProjectable(projected) {
continue
}
byID[msg.ID] = msg byID[msg.ID] = msg
} }
if len(byID) > 0 { if len(byID) > 0 {
@ -140,14 +163,37 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
return out return out
} }
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) { func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) {
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 { if item.ID <= 0 || item.BotUserID != botID {
return "", false return "", false
} }
eventType, ok := botAPIUpdateEventType(item.Kind) eventType, ok := botAPIUpdateEventType(item.Kind)
if !ok { if !ok {
return "", false 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 { switch item.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel: case domain.PeerTypeUser, domain.PeerTypeChannel:
if item.Peer.ID <= 0 { if item.Peer.ID <= 0 {
@ -159,17 +205,48 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat
return eventType, true 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) { 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) eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
if !ok { if !ok {
return domain.UpdateEvent{}, false 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 { switch item.Peer.Type {
case domain.PeerTypeUser: case domain.PeerTypeUser:
msg, found := privateMessages[item.MessageID] msg, found := privateMessages[item.MessageID]
if !found { if !found {
return domain.UpdateEvent{}, false 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) msg.Pts = int(item.ID)
return domain.UpdateEvent{ return domain.UpdateEvent{
UserID: botID, UserID: botID,
@ -186,6 +263,23 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
return domain.UpdateEvent{}, false return domain.UpdateEvent{}, false
} }
projected := botAPIMessageFromChannel(botID, msg) 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) projected.Pts = int(item.ID)
return domain.UpdateEvent{ return domain.UpdateEvent{
UserID: botID, UserID: botID,
@ -207,6 +301,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType
return domain.UpdateEventNewMessage, true return domain.UpdateEventNewMessage, true
case domain.BotAPIUpdateEditedMessage: case domain.BotAPIUpdateEditedMessage:
return domain.UpdateEventEditMessage, true return domain.UpdateEventEditMessage, true
case domain.BotAPIUpdateCallbackQuery:
return domain.UpdateEventBotCallbackQuery, true
default: default:
return "", false return "", false
} }
@ -410,7 +506,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
return media.Photo != nil return media.Photo != nil
case domain.MessageMediaKindDocument: case domain.MessageMediaKindDocument:
return media.Document != nil 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: default:
return false 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)
}

View file

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

View file

@ -1,12 +1,14 @@
package rpc package rpc
import ( import (
"bytes"
"context" "context"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr" "github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") } func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") } func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery type privateMessageByUIDService interface {
// 推给 bot挂起等待 bot 的 setBotCallbackAnswer或超时回 BOT_RESPONSE_TIMEOUT。 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) { func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
userID, _, err := r.currentUserID(ctx) userID, _, err := r.currentUserID(ctx)
if err != nil { if err != nil {
@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if err != nil { if err != nil {
return nil, err 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.gameP3 不支持:返回空答案(客户端不弹任何东西), // game 按钮getBotCallbackAnswer.gameP3 不支持:返回空答案(客户端不弹任何东西),
// 不挂起、不推送(避免给 bot 投递无法处理的 game query // 不挂起、不推送(避免给 bot 投递无法处理的 game query
if req.Game { if req.Game {
@ -46,32 +48,64 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if !hasData { if !hasData {
return nil, dataInvalidErr() return nil, dataInvalidErr()
} }
if len(data) > domain.MaxCallbackDataLen {
return nil, dataInvalidErr()
}
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。 // 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr() 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 { 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() return nil, internalErr()
} }
if !ok || msg.Peer != peer { defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID)
return nil, messageIDInvalidErr() 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) // updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference私聊 MessageID
defer r.callbacks.deregister(queryID) // 已翻译为 bot 视角 box idchannel 使用共享 message id。
var update tg.UpdateClass
// updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference仅在线推给 if callback.InlineMessage != nil {
// botbot 离线则投递 0但仍走超时窗口I5给 bot 上线追答机会)。 inline := &tg.UpdateInlineBotCallbackQuery{
// MsgID 透传请求者侧的 box idP3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo QueryID: queryID, UserID: userID,
update := &tg.UpdateBotCallbackQuery{ MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance,
QueryID: queryID, }
UserID: userID, inline.SetData(data)
Peer: &tg.PeerUser{UserID: userID}, update = inline
MsgID: req.MsgID, } else {
ChatInstance: chatInstanceFor(botUserID, userID), 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{ r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
Updates: []tg.UpdateClass{update}, Updates: []tg.UpdateClass{update},
Date: int(r.clock.Now().Unix()), 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) waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
defer cancel() defer cancel()
select { ticker := time.NewTicker(250 * time.Millisecond)
case ans := <-pending.ch: defer ticker.Stop()
return tgBotCallbackAnswer(ans), nil for {
case <-waitCtx.Done(): select {
return nil, botResponseTimeoutErr() 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 的应答:解挂等待中的 // onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
// getBotCallbackAnswer。仅属主 bot 可解挂callerBotID==pending.botUserIDI6 // getBotCallbackAnswer。仅属主 bot 可解挂callerBotID==pending.botUserIDI6
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) { 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 而言答案是否 // resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
// 被等待者接收无关紧要(官方恒返回 true但非属主必须拒绝投递防钓鱼弹窗 // 被等待者接收无关紧要(官方恒返回 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 return true, nil
} }

View file

@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg
case *tg.InputKeyboardButtonRequestPeer: case *tg.InputKeyboardButtonRequestPeer:
out.ButtonID = b.ButtonID out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text) out.Text = strings.TrimSpace(b.Text)
out.PeerType = requestPeerTypeName(b.PeerType) out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
out.MaxQuantity = b.MaxQuantity out.MaxQuantity = b.MaxQuantity
out.NameRequested = b.NameRequested
out.UsernameRequested = b.UsernameRequested
out.PhotoRequested = b.PhotoRequested
case *tg.KeyboardButtonRequestPeer: case *tg.KeyboardButtonRequestPeer:
out.ButtonID = b.ButtonID out.ButtonID = b.ButtonID
out.Text = strings.TrimSpace(b.Text) out.Text = strings.TrimSpace(b.Text)
out.PeerType = requestPeerTypeName(b.PeerType) out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
out.MaxQuantity = b.MaxQuantity out.MaxQuantity = b.MaxQuantity
default: default:
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key
return &tg.KeyboardButtonRequestPeer{ return &tg.KeyboardButtonRequestPeer{
Text: button.Text, Text: button.Text,
ButtonID: button.ButtonID, ButtonID: button.ButtonID,
PeerType: tgRequestPeerType(button.PeerType), PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
MaxQuantity: button.MaxQuantity, MaxQuantity: button.MaxQuantity,
} }
} }

View file

@ -1,22 +1,29 @@
package rpc package rpc
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"fmt"
"hash/fnv" "hash/fnv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"go.uber.org/zap"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store"
) )
// callbackRegistry 是 bot callback query 的进程内挂起表messages.getBotCallbackAnswer // callbackRegistry keeps local waiter channels and mirrors ownership/answers to
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待bot 经 // a short-lived shared store. The shared CAS is the source of truth when wired:
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道 // it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
// getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。 // without accepting two answers or trusting a process-local owner map.
type callbackRegistry struct { type callbackRegistry struct {
mu sync.Mutex mu sync.Mutex
pending map[int64]*pendingCallback pending map[int64]*pendingCallback
shared store.BotCallbackRegistryStore
} }
type pendingCallback struct { type pendingCallback struct {
@ -26,35 +33,77 @@ type pendingCallback struct {
userID int64 userID int64
} }
func newCallbackRegistry() *callbackRegistry { func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
return &callbackRegistry{pending: make(map[int64]*pendingCallback)} var sharedStore store.BotCallbackRegistryStore
if len(shared) > 0 {
sharedStore = shared[0]
}
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
} }
// register 登记一次挂起的 callback返回全局唯一 query_id 与接收通道。调用方必须 // register 登记一次挂起的 callback返回全局唯一 query_id 与接收通道。调用方必须
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。 // defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) { func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
p := &pendingCallback{ queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
ch: make(chan domain.BotCallbackAnswer, 1), return queryID, pending
done: make(chan struct{}), }
botUserID: botUserID,
userID: userID, 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++ {
c.mu.Lock() p := &pendingCallback{
defer c.mu.Unlock() ch: make(chan domain.BotCallbackAnswer, 1),
var queryID int64 done: make(chan struct{}),
for { botUserID: botUserID,
queryID = randomNonZeroInt64() userID: userID,
if _, exists := c.pending[queryID]; !exists {
break
} }
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 0, nil, fmt.Errorf("allocate bot callback query id")
return queryID, p
} }
// deregister 移除挂起条目并关闭 done超时/解挂后必调,幂等)。关闭 done 让仍在 // deregister 移除挂起条目并关闭 done超时/解挂后必调,幂等)。关闭 done 让仍在
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 chTOCTOU // select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 chTOCTOU
func (c *callbackRegistry) deregister(queryID int64) { 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() c.mu.Lock()
if p, ok := c.pending[queryID]; ok { if p, ok := c.pending[queryID]; ok {
delete(c.pending, queryID) delete(c.pending, queryID)
@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int {
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂callerBotID // resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂callerBotID
// 必须等于注册时的 botUserIDI6。返回是否成功投递query 未注册/已超时/非属主 → false // 必须等于注册时的 botUserIDI6。返回是否成功投递query 未注册/已超时/非属主 → false
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool { 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() c.mu.Lock()
p, ok := c.pending[queryID] p, ok := c.pending[queryID]
if !ok || p.botUserID != callerBotID { if !ok || p.botUserID != callerBotID {
@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal
return true 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 在持锁下调用,故此处禁止 // randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
// 无限重试——熵源异常时退化为单调序列兜底query_id 只需进程内唯一register 的 // 无限重试——熵源异常时退化为单调序列兜底query_id 只需进程内唯一register 的
// 撞键复核会再保证唯一性),绝不卡住整个 registry。 // 撞键复核会再保证唯一性),绝不卡住整个 registry。
@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 {
} }
return v 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
}

View file

@ -800,21 +800,23 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage
func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights { func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{ return tg.ChatAdminRights{
ChangeInfo: rights.ChangeInfo, ChangeInfo: rights.ChangeInfo,
PostMessages: rights.PostMessages, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, EditMessages: rights.EditMessages,
DeleteMessages: rights.DeleteMessages, DeleteMessages: rights.DeleteMessages,
PostStories: rights.PostStories, PostStories: rights.PostStories,
EditStories: rights.EditStories, EditStories: rights.EditStories,
DeleteStories: rights.DeleteStories, DeleteStories: rights.DeleteStories,
BanUsers: rights.BanUsers, BanUsers: rights.BanUsers,
InviteUsers: rights.InviteUsers, InviteUsers: rights.InviteUsers,
PinMessages: rights.PinMessages, PinMessages: rights.PinMessages,
AddAdmins: rights.AddAdmins, AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous, Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall, ManageCall: rights.ManageCall,
Other: true, Other: true,
ManageRanks: rights.ManageRanks, ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
// manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum, // manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum,
// 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。 // 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。
ManageDirectMessages: rights.ManageDirectMessages, ManageDirectMessages: rights.ManageDirectMessages,
@ -840,7 +842,10 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh
AddAdmins: rights.AddAdmins, AddAdmins: rights.AddAdmins,
Anonymous: rights.Anonymous, Anonymous: rights.Anonymous,
ManageCall: rights.ManageCall, ManageCall: rights.ManageCall,
ManageChat: rights.Other,
ManageTopics: rights.ManageTopics,
ManageRanks: rights.ManageRanks, ManageRanks: rights.ManageRanks,
ManageLinkedPeers: rights.ManageLinkedPeers,
ManageDirectMessages: rights.ManageDirectMessages, ManageDirectMessages: rights.ManageDirectMessages,
} }
} }

View file

@ -1,6 +1,7 @@
package rpc package rpc
import ( import (
"context"
"errors" "errors"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
@ -9,9 +10,30 @@ import (
"telesrv/internal/domain" "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 错误码(对齐官方)。 // P3 reply_markup 错误码(对齐官方)。
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") } func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_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") } func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") }
// replyMarkupErr 把 domain 校验错误映射为客户端错误码。 // replyMarkupErr 把 domain 校验错误映射为客户端错误码。
@ -21,18 +43,21 @@ func replyMarkupErr(err error) error {
return buttonDataInvalidErr() return buttonDataInvalidErr()
case errors.Is(err, domain.ErrButtonURLInvalid): case errors.Is(err, domain.ErrButtonURLInvalid):
return buttonURLInvalidErr() 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() return buttonInvalidErr()
default: default:
return replyMarkupInvalidErr() return replyMarkupInvalidErr()
} }
} }
// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义: // domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markupinline
// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。
// 语义:
// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil不报错——对齐 // - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil不报错——对齐
// 官方「普通用户 markup 无效」I1 // 官方「普通用户 markup 无效」I1
// - 仅 ReplyInlineMarkup 被处理reply keyboard 家族(自定义键盘/隐藏/强制回复) // - 仅 ReplyInlineMarkup 被处理bot 的 reply keyboard 家族在这些上下文中显式拒绝。
// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4
// - inline 行内按钮仅 callback / url其它按钮类型webview/game/url_auth/ // - inline 行内按钮仅 callback / url其它按钮类型webview/game/url_auth/
// request_* 等)→ ErrButtonTypeInvalid拒绝整条发送绝不半实现下发 // request_* 等)→ ErrButtonTypeInvalid拒绝整条发送绝不半实现下发
// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。 // - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。
@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
} }
inline, ok := markup.(*tg.ReplyInlineMarkup) inline, ok := markup.(*tg.ReplyInlineMarkup)
if !ok { if !ok {
// reply keyboard / hide / force-replyP3 不支持,丢弃。 return nil, domain.ErrButtonTypeInvalid
return nil, nil
} }
parsed, err := domainInlineMarkup(inline) parsed, err := domainInlineMarkup(inline)
if err != nil { if err != nil {
@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
return parsed, nil 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) { 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 { for _, row := range inline.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, btn := range 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) { func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(btn)
if err != nil {
return domain.MarkupButton{}, err
}
switch b := btn.(type) { switch b := btn.(type) {
case *tg.KeyboardButtonCallback: case *tg.KeyboardButtonCallback:
return domain.MarkupButton{ return domain.MarkupButton{
Type: domain.MarkupButtonCallback, Type: domain.MarkupButtonCallback,
Text: b.Text, Text: b.Text,
Data: append([]byte(nil), b.Data...), Style: style,
RequiresPassword: b.RequiresPassword, IconCustomEmojiID: icon,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
}, nil }, nil
case *tg.KeyboardButtonURL: case *tg.KeyboardButtonURL:
return domain.MarkupButton{ return domain.MarkupButton{
Type: domain.MarkupButtonURL, Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
Text: b.Text, Style: style, IconCustomEmojiID: icon,
URL: b.URL,
}, nil }, 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: default:
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。 // webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid 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 { func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
if m.IsZero() { if m.IsZero() {
return nil 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)) rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
for _, row := range m.Inline { for _, row := range m.Inline {
buttons := make([]tg.KeyboardButtonClass, 0, len(row)) 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 { func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
switch btn.Type { switch btn.Type {
case domain.MarkupButtonURL: 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 default: // callback
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data} out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
if btn.RequiresPassword { if btn.RequiresPassword {
out.SetRequiresPassword(true) out.SetRequiresPassword(true)
} }
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out 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,
}
}

View file

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

View file

@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
msg.SetInvertMedia(true) msg.SetInvertMedia(true)
} }
} }
// reply_markupbot inline keyboard仅普通 tg.Message 携带service 消息不带)。 // reply_markupbot reply/inline keyboard仅普通 tg.Message 携带service 消息不带)。
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup) msg.SetReplyMarkup(markup)
} }
@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
if msg.Out { if msg.Out {
return &tg.MessageActionRequestedPeerSentMe{ return &tg.MessageActionRequestedPeerSentMe{
ButtonID: shared.ButtonID, ButtonID: shared.ButtonID,
Peers: tgRequestedPeers(shared.Peers), Peers: tgRequestedPeers(shared),
} }
} }
return &tg.MessageActionRequestedPeer{ return &tg.MessageActionRequestedPeer{
@ -294,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass {
return out return out
} }
func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass { func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass {
out := make([]tg.RequestedPeerClass, 0, len(peers)) if action == nil {
for _, peer := range peers { 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 { switch peer.Type {
case domain.PeerTypeUser: 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: 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 return out

View file

@ -821,6 +821,7 @@ type Deps struct {
Updates UpdatesService Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore BotAPIUpdates store.BotAPIUpdateStore
BotCallbacks store.BotCallbackRegistryStore
Contacts ContactsService Contacts ContactsService
Dialogs DialogsService Dialogs DialogsService
Chatlists ChatlistsService Chatlists ChatlistsService

View file

@ -2,6 +2,7 @@ package rpc
import ( import (
"context" "context"
"fmt"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
if err != nil { if err != nil {
return nil, messageSendErr(err) return nil, messageSendErr(err)
} }
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
var users []tg.UserClass var users []tg.UserClass
var chats []tg.ChatClass var chats []tg.ChatClass
if !res.Duplicate { if !res.Duplicate {
@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if !found || !botUser.Bot { if !found || !botUser.Bot {
return nil, botInvalidErr() return nil, botInvalidErr()
} }
webAppReqID, ok := req.GetWebappReqID() webAppReqID, fromWebApp := req.GetWebappReqID()
if !ok || webAppReqID == "" { idempotencyKey := webAppReqID
return nil, buttonDataInvalidErr() var button domain.BotRequestedWebViewButton
} if fromWebApp {
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) if webAppReqID == "" {
if err != nil { return nil, buttonDataInvalidErr()
return nil, internalErr() }
} var found bool
if !found || button.ButtonID != req.ButtonID { button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
return nil, buttonDataInvalidErr() 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 { if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity {
return nil, buttonDataInvalidErr() return nil, buttonDataInvalidErr()
@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if err != nil { if err != nil {
return nil, err 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() return nil, buttonDataInvalidErr()
} }
peers = append(peers, resolved) 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) recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID)
if err != nil { if err != nil {
return nil, err 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{ res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID, SenderUserID: userID,
RecipientUserID: botUser.ID, RecipientUserID: botUser.ID,
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers), RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers),
Media: &domain.MessageMedia{ Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService, Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{ ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer, Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{ RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: button.ButtonID, ButtonID: button.ButtonID,
Peers: peers, 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 { if err != nil {
return nil, internalErr() 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 users []tg.UserClass
var chats []tg.ChatClass var chats []tg.ChatClass
if !res.Duplicate { 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 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 { func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
switch kind { switch kind {
case "user", "": 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 { 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)} parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)}
for _, peer := range peers { for _, peer := range peers {

View file

@ -11,11 +11,13 @@ import (
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
"telesrv/internal/store/memory"
) )
func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) { func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
f := newInlineBotRPCTestFixture(t) f := newInlineBotRPCTestFixture(t)
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
ownerCtx := WithUserID(ctx, f.owner.ID) ownerCtx := WithUserID(ctx, f.owner.ID)
updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ 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 { 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) 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{ botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{
HasPeer: true, 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) { func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) {
ctx := context.Background() ctx := context.Background()
f := newInlineBotRPCTestFixture(t) f := newInlineBotRPCTestFixture(t)

View file

@ -169,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendErr = err sendErr = err
return nil, sendErr return nil, sendErr
} }
// reply_markupbot inline keyboard仅 bot 账号发送被接受+校验;非 bot 静默丢弃。 // reply_markupbot 可发送 inline keyboard 与普通 reply keyboard/hide/force
// 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。
// 仅在请求携带 markup 时才查 is_bot避免普通发送多打一次查询。 // 仅在请求携带 markup 时才查 is_bot避免普通发送多打一次查询。
var replyMarkup *domain.MessageReplyMarkup var replyMarkup *domain.MessageReplyMarkup
if req.ReplyMarkup != nil { 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 { if err != nil {
sendErr = replyMarkupErr(err) sendErr = replyMarkupErr(err)
return nil, sendErr return nil, sendErr
} }
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
sendErr = err
return nil, sendErr
}
} }
// rich_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。 // rich_messageLayer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
// Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误。 // Phase 1 仅认 inputRichMessageblocks 形态HTML/Markdown 变体返回错误。

View file

@ -93,7 +93,7 @@ type Config struct {
// Router 把解密后的 RPC 请求按 semantic method 路由到 typed handlertlprofile.Dispatcher // Router 把解密后的 RPC 请求按 semantic method 路由到 typed handlertlprofile.Dispatcher
// //
// handler 输入输出均为 iamxvbaba/td/tg 类型,各业务域的 handler // handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler
// 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳: // 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳:
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。 // 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。
type Router struct { type Router struct {
@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
if instanceID == "" { if instanceID == "" {
instanceID = fmt.Sprintf("%016x", randomNonZeroInt64()) 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.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer) r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency) r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)

View file

@ -37,7 +37,7 @@ type outgoingSend struct {
sendAs *domain.Peer sendAs *domain.Peer
sendAsReady bool sendAsReady bool
clearDraft bool clearDraft bool
// replyMarkup 是 bot inline keyboard已解析+校验;非 bot 恒 nil // replyMarkup 是 bot reply/inline keyboard已解析+校验;非 bot 恒 nil
replyMarkup *domain.MessageReplyMarkup replyMarkup *domain.MessageReplyMarkup
viaBotID int64 viaBotID int64
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil
@ -382,13 +382,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if media == nil { if media == nil {
return nil, mediaInvalidErr() return nil, mediaInvalidErr()
} }
// reply_markupbot inline keyboard on media仅 bot 接受+校验,非 bot 静默丢弃。 // reply_markupbot 可发送 inline keyboard 与普通 reply keyboard/hide/force
// 非 bot 静默丢弃。
var replyMarkup *domain.MessageReplyMarkup var replyMarkup *domain.MessageReplyMarkup
if req.ReplyMarkup != nil { 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 { if err != nil {
return nil, replyMarkupErr(err) 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())) { if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{ return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{

View file

@ -45,6 +45,9 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
addDomainPeerRef(peer, 0, userIDs, channelIDs) addDomainPeerRef(peer, 0, userIDs, channelIDs)
} }
collectMessagePeerRefs(out[i].Message, 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) removeKnownChannelRefs(channelIDs, out[i].Channels)
refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs} refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs}
for id := range userIDs { 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 { if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
userIDs[msg.Media.Contact.UserID] = struct{}{} 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) collectPollMediaUserRefs(msg.Media, userIDs)
collectTodoMediaUserRefs(msg.Media, userIDs) collectTodoMediaUserRefs(msg.Media, userIDs)
if msg.Reactions != nil { if msg.Reactions != nil {

View file

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

View file

@ -2,14 +2,40 @@ package store
import ( import (
"context" "context"
"time"
"telesrv/internal/domain" "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. // BotAPIUpdateStore persists update_id based Bot API delivery queues.
type BotAPIUpdateStore interface { type BotAPIUpdateStore interface {
EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error)
ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, 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 ConfirmBotAPIUpdates(ctx context.Context, botUserID, confirmedUpdateID int64) error
ConfirmedBotAPIUpdateID(ctx context.Context, botUserID int64) (int64, bool, 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)
} }

View file

@ -3,29 +3,228 @@ package memory
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"sync" "sync"
"time"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
// BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore. // BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore.
type BotAPIUpdateStore struct { type BotAPIUpdateStore struct {
mu sync.RWMutex mu sync.RWMutex
nextID int64 nextID int64
rows []domain.BotAPIUpdate rows []domain.BotAPIUpdate
state map[int64]int64 state map[int64]int64
byKey map[string]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. // NewBotAPIUpdateStore creates an in-memory Bot API update queue.
func NewBotAPIUpdateStore() *BotAPIUpdateStore { func NewBotAPIUpdateStore() *BotAPIUpdateStore {
return &BotAPIUpdateStore{ return &BotAPIUpdateStore{
nextID: 1, nextID: 1,
state: make(map[int64]int64), state: make(map[int64]int64),
byKey: make(map[string]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) { func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
if err := validateBotAPIUpdateRequest(req); err != nil { if err := validateBotAPIUpdateRequest(req); err != nil {
return domain.BotAPIUpdate{}, false, err return domain.BotAPIUpdate{}, false, err
@ -33,6 +232,11 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
key := botAPIUpdateKey(req) key := botAPIUpdateKey(req)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { if existingID, ok := s.byKey[key]; ok {
for _, row := range s.rows { for _, row := range s.rows {
if row.ID == existingID { if row.ID == existingID {
@ -48,13 +252,56 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
MessageID: req.MessageID, MessageID: req.MessageID,
SourcePts: req.SourcePts, SourcePts: req.SourcePts,
Date: req.Date, Date: req.Date,
Callback: cloneBotAPICallback(req.Callback),
} }
s.nextID++ s.nextID++
s.rows = append(s.rows, row) s.rows = append(s.rows, row)
s.byKey[key] = row.ID 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 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) { func (s *BotAPIUpdateStore) ListBotAPIUpdates(_ context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 { if botUserID == 0 {
return nil, nil return nil, nil
@ -85,13 +332,78 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, c
return nil return nil
} }
s.mu.Lock() 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] { if confirmedUpdateID > s.state[botUserID] {
s.state[botUserID] = confirmedUpdateID s.state[botUserID] = confirmedUpdateID
} }
s.cursorInitialized[botUserID] = true
s.mu.Unlock() s.mu.Unlock()
return nil 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) { func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID int64) (int64, bool, error) {
if botUserID == 0 { if botUserID == 0 {
return 0, false, nil return 0, false, nil
@ -103,27 +415,65 @@ func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID
} }
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
if req.BotUserID == 0 || req.MessageID <= 0 { if req.BotUserID == 0 {
return fmt.Errorf("invalid bot api update") 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) return fmt.Errorf("invalid bot api update kind %q", req.Kind)
} }
switch req.Peer.Type { switch req.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel: 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") return fmt.Errorf("invalid bot api update peer")
} }
default: default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) 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 return nil
} }
func botAPIUpdateKey(req domain.EnqueueBotAPIUpdateRequest) string { 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) 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 { func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate {
row.Callback = cloneBotAPICallback(row.Callback)
return row 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
}

View file

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

View file

@ -91,6 +91,7 @@ func normalizeMemoryMessageIDs(ids []int) []int {
func cloneMessage(msg domain.Message) domain.Message { func cloneMessage(msg domain.Message) domain.Message {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.Media = cloneRequestedPeerMedia(msg.Media)
msg.ReplyTo = cloneMessageReply(msg.ReplyTo) msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
msg.Forward = cloneMessageForward(msg.Forward) msg.Forward = cloneMessageForward(msg.Forward)
msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions) msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions)
@ -99,13 +100,36 @@ func cloneMessage(msg domain.Message) domain.Message {
return msg 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 切片。 // (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。
func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
if m == nil { if m == nil {
return nil return nil
} }
clone := domain.MessageReplyMarkup{} clone := *m
if m.Inline != nil { if m.Inline != nil {
clone.Inline = make([][]domain.MarkupButton, len(m.Inline)) clone.Inline = make([][]domain.MarkupButton, len(m.Inline))
for i, row := range m.Inline { for i, row := range m.Inline {
@ -117,6 +141,12 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
clone.Inline[i] = cloneRow 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 return &clone
} }

View file

@ -35,6 +35,33 @@ func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (dom
return out, nil 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) { func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
s.mu.RLock() s.mu.RLock()
messages := cloneMessages(s.m[userID]) messages := cloneMessages(s.m[userID])

View file

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

View file

@ -84,7 +84,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
NoForwards: req.NoForwards, NoForwards: req.NoForwards,
Body: req.Message, Body: req.Message,
Entities: append([]domain.MessageEntity(nil), req.Entities...), Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media, Media: cloneRequestedPeerMedia(req.Media),
ViaBotID: req.ViaBotID, ViaBotID: req.ViaBotID,
GroupedID: req.GroupedID, GroupedID: req.GroupedID,
Effect: req.Effect, 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.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
recipient.Out = false recipient.Out = false
recipient.ReplyTo = cloneMessageReply(recipientReply) recipient.ReplyTo = cloneMessageReply(recipientReply)
recipient.Media = cloneRequestedPeerMedia(sender.Media)
// recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷 // recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷
// 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐I3/I2 // 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐I3/I2
recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup) recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup)

View file

@ -222,6 +222,65 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("recipient history", recipientHistory.Messages[0]) 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) { func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
ctx := context.Background() ctx := context.Background()
messages := NewMessageStore() messages := NewMessageStore()

View file

@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory { 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) 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 { 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) t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
} }

View file

@ -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() { if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() {
return domain.ErrBotRequestedButtonInvalid return domain.ErrBotRequestedButtonInvalid
} }
_, err := s.db.Exec(ctx, ` peerFilter, err := json.Marshal(button.PeerFilter)
INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at) if err != nil {
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) 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 ON CONFLICT (webapp_req_id) DO UPDATE SET
button_id=EXCLUDED.button_id, button_id=EXCLUDED.button_id,
text=EXCLUDED.text, text=EXCLUDED.text,
peer_type=EXCLUDED.peer_type, peer_type=EXCLUDED.peer_type,
max_quantity=EXCLUDED.max_quantity, 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`, 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 { if err != nil {
return fmt.Errorf("save requested webview button: %w", err) 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) { 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()`) _, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`)
var button domain.BotRequestedWebViewButton var button domain.BotRequestedWebViewButton
var peerFilter []byte
err := s.db.QueryRow(ctx, ` 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 FROM webview_requested_buttons
WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`, WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`,
botUserID, userID, webAppReqID). 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.BotRequestedWebViewButton{}, false, nil return domain.BotRequestedWebViewButton{}, false, nil
} }
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err) 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 return button, true, nil
} }

View file

@ -20,17 +20,320 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore {
return &BotAPIUpdateStore{db: db} 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) { func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
if err := validateBotAPIUpdateRequest(req); err != nil { if err := validateBotAPIUpdateRequest(req); err != nil {
return domain.BotAPIUpdate{}, false, err 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, ` row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
INSERT INTO bot_api_updates ( WITH inserted AS (
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date INSERT INTO bot_api_updates (
) VALUES ($1, $2, $3, $4, $5, $6, $7) bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING callback_query_id, callback_user_id, callback_chat_instance, callback_data,
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date)) ) 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 { if err == nil {
return row, true, 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) return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err)
} }
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` 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 FROM bot_api_updates
WHERE bot_user_id = $1 WHERE bot_user_id = $1
AND update_kind = $2 AND update_kind = $2
AND peer_type = $3 AND (
AND peer_id = $4 (update_kind = 'callback_query' AND callback_query_id = $7)
AND message_id = $5 OR
AND source_pts = $6 (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)) )
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID))
if err != nil { 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 domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err)
} }
return row, false, nil 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) { func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 { if botUserID == 0 {
return nil, nil return nil, nil
@ -64,7 +415,9 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr
limit = 100 limit = 100
} }
rows, err := s.db.Query(ctx, ` 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 FROM bot_api_updates
WHERE bot_user_id = $1 AND id >= $2 WHERE bot_user_id = $1 AND id >= $2
ORDER BY id ORDER BY id
@ -93,23 +446,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID,
return nil return nil
} }
if _, err := s.db.Exec(ctx, ` if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id) WITH bounded AS (
VALUES ($1, $2) 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 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() updated_at = now()
WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id
`, botUserID, confirmedUpdateID); err != nil { `, botUserID, confirmedUpdateID); err != nil {
return fmt.Errorf("confirm bot api updates: %w", err) return fmt.Errorf("confirm bot api updates: %w", err)
} }
return nil 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 // DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1
// 1. 已确认id <= bot_api_update_states.confirmed_update_id且入队超过 confirmedGrace 的行—— // 1. 已确认id <= bot_api_update_states.confirmed_update_id且入队超过 confirmedGrace 的行——
// 官方 Bot API 语义下确认即弃getUpdates 的 fromID 恒 > confirmed删除不影响任何读路径 // 官方 Bot API 语义下确认即弃getUpdates 的 fromID 恒 > confirmed删除不影响任何读路径
// 宽限仅防御 offset 回拨调试场景。 // 宽限仅防御 offset 回拨调试场景。
// 2. 按消息 date 超过 maxAge 的行无论确认与否——对齐官方「updates 服务器最多保留 24 小时」 // 2. 按队列 created_at 超过 maxAge 的行无论确认与否——对齐官方「updates 服务器最多保留 24 小时」
// 语义,同时封顶 MTProto-only bot从不调 getUpdates、无 state 行)成员身份带来的无界增长。 // 语义,同时封顶 MTProto-only bot从不调 getUpdates、无 state 行)成员身份带来的无界增长。
// //
// 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫, // 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫,
@ -139,15 +567,15 @@ WHERE id IN (
total += int(tag.RowsAffected()) total += int(tag.RowsAffected())
} }
if maxAge > 0 { if maxAge > 0 {
cutoff := time.Now().Add(-maxAge).Unix() cutoff := time.Now().Add(-maxAge)
// 走 bot_api_updates_retention_idx(date, id)。 // 走 bot_api_updates_created_retention_idx(created_at, id)。
tag, err := s.db.Exec(ctx, ` tag, err := s.db.Exec(ctx, `
DELETE FROM bot_api_updates DELETE FROM bot_api_updates
WHERE id IN ( WHERE id IN (
SELECT id SELECT id
FROM bot_api_updates FROM bot_api_updates
WHERE date < $1 WHERE created_at < $1
ORDER BY date, id ORDER BY created_at, id
LIMIT $2 LIMIT $2
)`, cutoff, limit) )`, cutoff, limit)
if err != nil { if err != nil {
@ -187,28 +615,69 @@ type botAPIUpdateScanner interface {
func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) { func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) {
var item domain.BotAPIUpdate var item domain.BotAPIUpdate
var kind, peerType string 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 return domain.BotAPIUpdate{}, err
} }
item.Kind = domain.BotAPIUpdateKind(kind) item.Kind = domain.BotAPIUpdateKind(kind)
item.Peer.Type = domain.PeerType(peerType) 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 return item, nil
} }
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
if req.BotUserID == 0 || req.MessageID <= 0 { if req.BotUserID == 0 {
return fmt.Errorf("invalid bot api update") 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) return fmt.Errorf("invalid bot api update kind %q", req.Kind)
} }
switch req.Peer.Type { switch req.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel: 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") return fmt.Errorf("invalid bot api update peer")
} }
default: default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) 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 return nil
} }

View file

@ -1,6 +1,7 @@
package postgres package postgres
import ( import (
"bytes"
"context" "context"
"testing" "testing"
"time" "time"
@ -8,10 +9,292 @@ import (
"telesrv/internal/domain" "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 场景矩阵: // TestBotAPIUpdateRetention 锁定 H1 场景矩阵:
// - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留; // - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留;
// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot // - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot
// - 未确认 + date 在保留期内 → 留; // - 未确认 + created_at 在保留期内 → 留;
// - 删除后 getUpdates 读路径fromID > confirmed不受影响。 // - 删除后 getUpdates 读路径fromID > confirmed不受影响。
func TestBotAPIUpdateRetention(t *testing.T) { func TestBotAPIUpdateRetention(t *testing.T) {
pool := testPool(t) pool := testPool(t)
@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
s := NewBotAPIUpdateStore(pool) s := NewBotAPIUpdateStore(pool)
now := time.Now().Unix() now := time.Now().Unix()
stale := now - int64((48 * time.Hour).Seconds())
enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate { enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate {
t.Helper() t.Helper()
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ 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 回拨超宽限 → 删 confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删
confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留 confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留
unconfirmedFresh := enqueue(confirmedBot, 3, 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) freshNoState := enqueue(mtprotoOnlyBot, 5, now)
if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil { 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 { "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) 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) deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000)
if err != nil { if err != nil {
@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
// 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行; // 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行;
// 精确归属由下方 remaining 断言保证。 // 精确归属由下方 remaining 断言保证。
if deleted < 2 { 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{} remaining := map[int64]bool{}

View file

@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool
return photo, true, nil 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 { type photoScanner interface {
Scan(dest ...any) error Scan(dest ...any) error
} }

View file

@ -2,6 +2,7 @@ package postgres
import ( import (
"encoding/json" "encoding/json"
"fmt"
"reflect" "reflect"
"telesrv/internal/domain" "telesrv/internal/domain"
@ -44,9 +45,12 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) {
return &m, nil return &m, nil
} }
// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB空 markup 序列化为 "{}"。 // encodeReplyMarkup 把 reply/inline keyboard 快照序列化为 JSONB空 markup 序列化为 "{}"。
// callback data 是 []bytejson.Marshal 自动 base64保证经 JSONB 字节级 round-trip // callback data 是 []bytejson.Marshal 自动 base64保证经 JSONB 字节级 round-trip
func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) { 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() { if m.IsZero() {
return []byte("{}"), nil return []byte("{}"), nil
} }
@ -63,6 +67,9 @@ func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) {
if err := json.Unmarshal([]byte(s), &m); err != nil { if err := json.Unmarshal([]byte(s), &m); err != nil {
return nil, err return nil, err
} }
if err := domain.ValidateReplyMarkup(&m); err != nil {
return nil, fmt.Errorf("decode reply markup: %w", err)
}
if m.IsZero() { if m.IsZero() {
return nil, nil return nil, nil
} }

View file

@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) {
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" { 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) 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 ---- // ---- sticker set ----
set := domain.StickerSet{ set := domain.StickerSet{

View file

@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d
return out, nil 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) { func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
limit := filter.Limit limit := filter.Limit
if limit <= 0 { if limit <= 0 {

View file

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

View file

@ -117,7 +117,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
if err != nil { if err != nil {
return domain.SendPrivateTextResult{}, err return domain.SendPrivateTextResult{}, err
} }
// reply_markupbot inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。 // reply_markupbot reply/inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
if err != nil { if err != nil {
return domain.SendPrivateTextResult{}, err return domain.SendPrivateTextResult{}, err

View file

@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("recipient event", events[0].Message) 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) { func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
pool := testPool(t) pool := testPool(t)
ctx := context.Background() ctx := context.Background()

View file

@ -294,21 +294,33 @@ type Bot struct {
} }
type BotApiUpdate struct { type BotApiUpdate struct {
ID int64 ID int64
BotUserID int64 BotUserID int64
UpdateKind string UpdateKind string
PeerType string PeerType string
PeerID int64 PeerID int64
MessageID int32 MessageID int32
SourcePts int32 SourcePts int32
Date int32 Date int32
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
CallbackQueryID int64
CallbackUserID int64
CallbackChatInstance int64
CallbackData []byte
CallbackInlineDcID int32
CallbackInlineOwnerID int64
CallbackInlineMessageID int32
CallbackInlineAccessHash int64
} }
type BotApiUpdateState struct { type BotApiUpdateState struct {
BotUserID int64 BotUserID int64
ConfirmedUpdateID int64 ConfirmedUpdateID int64
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
AllowedUpdates []string
CursorInitialized bool
PollOwner string
PollExpiresAt pgtype.Timestamptz
} }
type BotApp struct { type BotApp struct {

View file

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

View file

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