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"
```
发送 reply keyboard 与 inline callback 两条验证消息并保持 polling
```powershell
python .\cmd\bots\ptbecho\echo.py `
--buttons-chat-id 1780243200
```
reply keyboard 与 inline keyboard 都会各显示蓝/绿/红三种语义色;点击 reply
button 会按普通文本消息进入 echo 链,点击 inline button 会由 `callback_query` handler 调用
`answerCallbackQuery` 并显示 `telesrv inline callback OK`。也可以在私聊中发送
`/buttons` 生成同样的两条消息。
可选参数:
- `--send-count N`:连续主动发送 N 条。
- `--send-interval SEC`:连续发送之间的间隔。
- `TELESRV_BOT_DEMO_CHAT_ID` / `TELESRV_BOT_DEMO_SEND_TEXT`:主动发送参数的环境变量形式。
- `--buttons-chat-id` / `TELESRV_BOT_DEMO_BUTTONS_CHAT_ID`:发送两类键盘验证消息并监听 callback。
本地超级群 chat id 使用 Bot API 形式 `-100<channel_id>`;例如 channel id 为
`2` 时是 `-1000000000002`

View file

@ -32,11 +32,18 @@ import os
import signal
from typing import Iterable
from telegram import Bot, Update
from telegram.constants import ChatAction
from telegram import (
Bot,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
Update,
)
from telegram.ext import (
Application,
ApplicationBuilder,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
@ -78,6 +85,12 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--send-count", type=int, default=1, help="Number of proactive messages to send")
parser.add_argument("--send-interval", type=float, default=1.0, help="Seconds between proactive sends")
parser.add_argument(
"--buttons-chat-id",
type=int,
default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"),
help="Send reply/inline keyboard validation messages to this chat on startup",
)
parser.add_argument("--send-only", action="store_true", help="Send proactive messages and exit without polling")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
@ -87,11 +100,10 @@ def parse_args() -> argparse.Namespace:
parser.error("--send-count must be >= 1")
if args.send_interval < 0:
parser.error("--send-interval must be >= 0")
wants_send = args.send_only or bool(args.send_text)
if wants_send and args.send_chat_id is None:
if args.send_text and args.send_chat_id is None:
parser.error("--send-chat-id is required when --send-text or --send-only is used")
if args.send_only and not args.send_text:
parser.error("--send-only requires --send-text")
if args.send_only and not args.send_text and args.buttons_chat_id is None:
parser.error("--send-only requires --send-text or --buttons-chat-id")
return args
@ -112,7 +124,6 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not text:
return
prefix = context.application.bot_data.get("prefix", "echo: ")
await context.bot.send_chat_action(chat_id=update.effective_chat.id, action=ChatAction.TYPING)
sent = await update.effective_message.reply_text(prefix + text)
LOG.info(
"echoed update_id=%s chat_id=%s message_id=%s sent_message_id=%s text=%r",
@ -124,6 +135,26 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
)
async def buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_chat is None:
return
await send_button_messages(context.bot, update.effective_chat.id)
async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if query is None:
return
await query.answer("telesrv inline callback OK")
LOG.info(
"answered callback query_id=%s chat_id=%s message_id=%s data=%r",
query.id,
query.message.chat_id if query.message else None,
query.message.message_id if query.message else None,
query.data,
)
async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None:
for index in range(count):
sent = await bot.send_message(chat_id=chat_id, text=text)
@ -139,25 +170,63 @@ async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, in
await asyncio.sleep(interval)
async def send_button_messages(bot: Bot, chat_id: int) -> None:
reply = await bot.send_message(
chat_id=chat_id,
text="TELESRV_REPLY_KEYBOARD_20260719",
reply_markup=ReplyKeyboardMarkup(
[[
KeyboardButton("Primary", api_kwargs={"style": "primary"}),
KeyboardButton("Success", api_kwargs={"style": "success"}),
KeyboardButton("Danger", api_kwargs={"style": "danger"}),
]],
resize_keyboard=True,
one_time_keyboard=True,
input_field_placeholder="Tap the reply button",
),
)
inline = await bot.send_message(
chat_id=chat_id,
text="TELESRV_INLINE_CALLBACK_20260719",
reply_markup=InlineKeyboardMarkup(
[[
InlineKeyboardButton("Primary", callback_data="telesrv-primary", api_kwargs={"style": "primary"}),
InlineKeyboardButton("Success", callback_data="telesrv-success", api_kwargs={"style": "success"}),
InlineKeyboardButton("Danger", callback_data="telesrv-danger", api_kwargs={"style": "danger"}),
]],
),
)
LOG.info(
"sent keyboard validation chat_id=%s reply_message_id=%s inline_message_id=%s",
chat_id,
reply.message_id,
inline.message_id,
)
async def send_on_startup(app: Application) -> None:
chat_id = app.bot_data.get("send_chat_id")
text = app.bot_data.get("send_text")
if chat_id is None or not text:
return
await send_active_messages(
app.bot,
chat_id=chat_id,
text=text,
count=int(app.bot_data.get("send_count", 1)),
interval=float(app.bot_data.get("send_interval", 1.0)),
)
if chat_id is not None and text:
await send_active_messages(
app.bot,
chat_id=chat_id,
text=text,
count=int(app.bot_data.get("send_count", 1)),
interval=float(app.bot_data.get("send_interval", 1.0)),
)
buttons_chat_id = app.bot_data.get("buttons_chat_id")
if buttons_chat_id is not None:
await send_button_messages(app.bot, int(buttons_chat_id))
async def post_init(app: Application) -> None:
me = await app.bot.get_me()
LOG.info("listening as @%s (%s), bot_api=%s", me.username or me.id, me.id, app.bot_data["base_url"])
if app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text"):
app.create_task(send_on_startup(app), name="ptbecho-proactive-send")
if (app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text")) or app.bot_data.get(
"buttons_chat_id"
) is not None:
await send_on_startup(app)
def build_app(args: argparse.Namespace) -> Application:
@ -175,8 +244,11 @@ def build_app(args: argparse.Namespace) -> Application:
app.bot_data["send_text"] = args.send_text
app.bot_data["send_count"] = args.send_count
app.bot_data["send_interval"] = args.send_interval
app.bot_data["buttons_chat_id"] = args.buttons_chat_id
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("ping", ping))
app.add_handler(CommandHandler("buttons", buttons))
app.add_handler(CallbackQueryHandler(callback))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
return app
@ -185,18 +257,21 @@ async def run_send_only(args: argparse.Namespace) -> None:
bot = Bot(token=args.token, base_url=args.base_url, base_file_url=args.base_file_url)
me = await bot.get_me()
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
await send_active_messages(
bot,
chat_id=args.send_chat_id,
text=args.send_text,
count=args.send_count,
interval=args.send_interval,
)
if args.send_chat_id is not None and args.send_text:
await send_active_messages(
bot,
chat_id=args.send_chat_id,
text=args.send_text,
count=args.send_count,
interval=args.send_interval,
)
if args.buttons_chat_id is not None:
await send_button_messages(bot, args.buttons_chat_id)
def stop_signals() -> Iterable[int]:
def stop_signals() -> Iterable[int] | None:
if os.name == "nt":
return (signal.SIGINT, signal.SIGTERM)
return None
return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
@ -214,7 +289,7 @@ def main() -> int:
app = build_app(args)
app.run_polling(
allowed_updates=["message", "edited_message"],
allowed_updates=["message", "edited_message", "callback_query"],
drop_pending_updates=args.drop_pending,
poll_interval=0.0,
timeout=args.timeout,

View file

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

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

View file

@ -461,7 +461,14 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
if in == nil {
return nil
}
out := &domain.MessageReplyMarkup{}
out := &domain.MessageReplyMarkup{
Type: in.Type,
Resize: in.Resize,
SingleUse: in.SingleUse,
Selective: in.Selective,
Persistent: in.Persistent,
Placeholder: in.Placeholder,
}
if len(in.Inline) > 0 {
out.Inline = make([][]domain.MarkupButton, len(in.Inline))
for i, row := range in.Inline {
@ -472,6 +479,12 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
}
}
}
if len(in.Keyboard) > 0 {
out.Keyboard = make([][]domain.MarkupButton, len(in.Keyboard))
for i, row := range in.Keyboard {
out.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
}
}
return out
}

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)
}
type photoBatchStore interface {
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
}
// GetPhotos loads immutable photo metadata in caller order without requiring
// one storage round-trip per requested-peer response. PostgreSQL implements the
// optional batch primitive; lightweight stores retain a bounded fallback.
func (s *Service) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
if s == nil || s.media == nil || len(ids) == 0 {
return nil, nil
}
if batch, ok := s.media.(photoBatchStore); ok {
return batch.GetPhotos(ctx, ids)
}
seen := make(map[int64]struct{}, len(ids))
out := make([]domain.Photo, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
photo, found, err := s.media.GetPhoto(ctx, id)
if err != nil {
return nil, err
}
if found {
out = append(out, photo)
}
}
return out, nil
}
// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
return s.media.GetDocument(ctx, id)

View file

@ -100,6 +100,9 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
if req.SenderUserID != userID {
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
}
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.SendPrivateTextResult{}, err
}
if req.RandomID != 0 && !req.IdempotencyPreflighted {
fingerprint, err := store.PrivateSendFingerprint(req)
if err != nil {
@ -263,6 +266,32 @@ func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (dom
return s.projectMessageUsers(ctx, userID, list)
}
type messageByUIDStore interface {
GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
}
// GetMessageByUID translates a shared private message id into one owner's exact box row.
// It is intentionally an optional capability so lightweight MessageStore test doubles that
// never exercise callback translation do not need a meaningless implementation.
func (s *Service) GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
if s == nil || userID == 0 || uid == 0 {
return domain.Message{}, false, nil
}
provider, ok := s.messages.(messageByUIDStore)
if !ok {
return domain.Message{}, false, nil
}
msg, found, err := provider.GetByUID(ctx, userID, uid)
if err != nil || !found {
return domain.Message{}, found, err
}
list, err := s.projectMessageUsers(ctx, userID, domain.MessageList{Messages: []domain.Message{msg}})
if err != nil || len(list.Messages) != 1 {
return domain.Message{}, false, err
}
return list.Messages[0], true, nil
}
// GetHistory 返回当前账号某个 peer 的历史消息。
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
return s.list(ctx, userID, filter)
@ -406,6 +435,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
if req.OwnerUserID == 0 {
req.OwnerUserID = userID
}
if req.SetReplyMarkup {
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
return domain.EditMessageResult{OwnerUserID: userID}, err
}
}
return s.messages.EditMessage(ctx, req)
}

View file

@ -36,7 +36,7 @@ func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) {
if err != nil {
return domain.BotInlineResult{}, err
}
markup, err := replyMarkupFromAPI(payload.ReplyMarkup)
markup, err := inlineReplyMarkupFromAPI(payload.ReplyMarkup)
if err != nil {
return domain.BotInlineResult{}, err
}
@ -165,6 +165,64 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var shape map[string]json.RawMessage
if err := json.Unmarshal(raw, &shape); err != nil {
return nil, errors.New("BUTTON_INVALID")
}
constructors := 0
for _, key := range []string{"inline_keyboard", "keyboard", "remove_keyboard", "force_reply"} {
if _, ok := shape[key]; ok {
constructors++
}
}
if constructors != 1 {
return nil, errors.New("BUTTON_INVALID")
}
if _, ok := shape["inline_keyboard"]; ok {
return inlineKeyboardMarkupFromAPI(raw)
}
if _, ok := shape["keyboard"]; ok {
return replyKeyboardMarkupFromAPI(raw)
}
if _, ok := shape["remove_keyboard"]; ok {
var payload apiReplyKeyboardRemove
if err := json.Unmarshal(raw, &payload); err != nil || !payload.RemoveKeyboard {
return nil, errors.New("BUTTON_INVALID")
}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: payload.Selective}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, replyMarkupErrFromDomain(err)
}
return out, nil
}
var payload apiForceReply
if err := json.Unmarshal(raw, &payload); err != nil || !payload.ForceReply {
return nil, errors.New("BUTTON_INVALID")
}
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupForceReply,
SingleUse: true,
Selective: payload.Selective,
Placeholder: payload.InputFieldPlaceholder,
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, replyMarkupErrFromDomain(err)
}
return out, nil
}
func inlineReplyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
markup, err := replyMarkupFromAPI(raw)
if err != nil || markup == nil {
return markup, err
}
if markup.Kind() != domain.MessageReplyMarkupInline {
return nil, errors.New("BUTTON_INVALID")
}
return markup, nil
}
func inlineKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
var payload apiInlineKeyboardMarkup
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, errors.New("BUTTON_INVALID")
@ -172,7 +230,7 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
if len(payload.InlineKeyboard) == 0 {
return nil, nil
}
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
for _, row := range payload.InlineKeyboard {
domainRow := make([]domain.MarkupButton, 0, len(row))
for _, button := range row {
@ -193,19 +251,132 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
return out, nil
}
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
if button.URL != "" {
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil
func replyKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
var payload apiReplyKeyboardMarkup
if err := json.Unmarshal(raw, &payload); err != nil || len(payload.Keyboard) == 0 {
return nil, errors.New("BUTTON_INVALID")
}
if button.CallbackData != nil {
if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen {
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: make([][]domain.MarkupButton, 0, len(payload.Keyboard)),
Resize: payload.ResizeKeyboard,
SingleUse: payload.OneTimeKeyboard,
Selective: payload.Selective,
Persistent: payload.IsPersistent,
Placeholder: payload.InputFieldPlaceholder,
}
for _, row := range payload.Keyboard {
domainRow := make([]domain.MarkupButton, 0, len(row))
for _, button := range row {
if button.Text == "" {
return nil, errors.New("BUTTON_INVALID")
}
if button.Unsupported {
return nil, errors.New("BUTTON_TYPE_INVALID")
}
style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet)
if err != nil {
return nil, err
}
item := domain.MarkupButton{Type: domain.MarkupButtonText, Text: button.Text, Style: style, IconCustomEmojiID: icon}
switch button.Kind {
case "request_contact":
item.Type = domain.MarkupButtonRequestPhone
case "request_location":
item.Type = domain.MarkupButtonRequestLocation
case "request_poll":
item.Type, item.PollType = domain.MarkupButtonRequestPoll, button.PollType
case "request_users":
item.Type, item.ButtonID, item.RequestPeerType = domain.MarkupButtonRequestPeer, button.RequestID, "user"
item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = button.MaxQuantity, button.RequestName, button.RequestUsername, button.RequestPhoto
item.RequestPeerFilter = button.RequestPeerFilter
case "request_chat":
item.Type, item.ButtonID = domain.MarkupButtonRequestPeer, button.RequestID
if button.ChatIsChannel {
item.RequestPeerType = "broadcast"
} else {
item.RequestPeerType = "chat"
}
item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = 1, button.RequestTitle, button.RequestUsername, button.RequestPhoto
item.RequestPeerFilter = button.RequestPeerFilter
case "web_app":
item.Type, item.URL = domain.MarkupButtonSimpleWebView, button.WebAppURL
}
domainRow = append(domainRow, item)
}
out.Keyboard = append(out.Keyboard, domainRow)
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, replyMarkupErrFromDomain(err)
}
return out, nil
}
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
if button.Unsupported {
return domain.MarkupButton{}, errors.New("BUTTON_TYPE_INVALID")
}
constructors := 0
if button.URLSet {
constructors++
}
if button.CallbackDataSet {
constructors++
}
if button.WebAppSet {
constructors++
}
if button.SwitchInlineSet {
constructors++
}
if button.CopyTextSet {
constructors++
}
if constructors != 1 {
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
}
style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet)
if err != nil {
return domain.MarkupButton{}, err
}
if button.URLSet {
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil
}
if button.CallbackDataSet {
if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen {
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
}
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(button.CallbackData), Style: style, IconCustomEmojiID: icon}, nil
}
if button.WebAppSet {
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: button.Text, URL: button.WebAppURL, Style: style, IconCustomEmojiID: icon}, nil
}
if button.SwitchInlineSet {
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: button.Text, Query: button.SwitchInlineQuery, SamePeer: button.SwitchInlineSamePeer, PeerTypes: append([]string(nil), button.SwitchInlinePeerTypes...), Style: style, IconCustomEmojiID: icon}, nil
}
if button.CopyTextSet {
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: button.Text, CopyText: button.CopyText, Style: style, IconCustomEmojiID: icon}, nil
}
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
}
func markupButtonDecorationFromAPI(rawStyle, rawIcon string, iconSet bool) (domain.MarkupButtonStyle, int64, error) {
style := domain.MarkupButtonStyle(strings.TrimSpace(rawStyle))
switch style {
case "", domain.MarkupButtonStylePrimary, domain.MarkupButtonStyleDanger, domain.MarkupButtonStyleSuccess:
default:
return "", 0, errors.New("BUTTON_INVALID")
}
if !iconSet {
return style, 0, nil
}
icon, err := strconv.ParseInt(strings.TrimSpace(rawIcon), 10, 64)
if err != nil || icon <= 0 {
return "", 0, errors.New("BUTTON_INVALID")
}
return style, icon, nil
}
func replyMarkupErrFromDomain(err error) error {
switch {
case errors.Is(err, domain.ErrButtonURLInvalid):
@ -287,8 +458,342 @@ type apiInlineKeyboardMarkup struct {
InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"`
}
type apiInlineKeyboardButton struct {
Text string `json:"text"`
URL string `json:"url"`
CallbackData *string `json:"callback_data"`
type apiReplyKeyboardMarkup struct {
Keyboard [][]apiKeyboardButton `json:"keyboard"`
IsPersistent bool `json:"is_persistent"`
ResizeKeyboard bool `json:"resize_keyboard"`
OneTimeKeyboard bool `json:"one_time_keyboard"`
InputFieldPlaceholder string `json:"input_field_placeholder"`
Selective bool `json:"selective"`
}
type apiKeyboardButton struct {
Text string
Style string
IconCustomEmojiID string
IconCustomEmojiIDSet bool
Unsupported bool
Kind string
PollType string
RequestID int
MaxQuantity int
RequestName bool
RequestUsername bool
RequestPhoto bool
RequestTitle bool
ChatIsChannel bool
WebAppURL string
RequestPeerFilter *domain.BotRequestPeerFilter
}
type apiChatAdministratorRights struct {
IsAnonymous bool `json:"is_anonymous"`
CanManageChat bool `json:"can_manage_chat"`
CanDeleteMessages bool `json:"can_delete_messages"`
CanManageVideoChats bool `json:"can_manage_video_chats"`
CanRestrictMembers bool `json:"can_restrict_members"`
CanPromoteMembers bool `json:"can_promote_members"`
CanChangeInfo bool `json:"can_change_info"`
CanInviteUsers bool `json:"can_invite_users"`
CanPostStories bool `json:"can_post_stories"`
CanEditStories bool `json:"can_edit_stories"`
CanDeleteStories bool `json:"can_delete_stories"`
CanPostMessages bool `json:"can_post_messages"`
CanEditMessages bool `json:"can_edit_messages"`
CanPinMessages bool `json:"can_pin_messages"`
CanManageTopics bool `json:"can_manage_topics"`
CanManageDirectMessages bool `json:"can_manage_direct_messages"`
}
func domainRequestAdminRights(in *apiChatAdministratorRights) *domain.BotRequestAdminRights {
if in == nil {
return nil
}
return &domain.BotRequestAdminRights{
Anonymous: in.IsAnonymous, ManageChat: in.CanManageChat, DeleteMessages: in.CanDeleteMessages,
ManageVideoChats: in.CanManageVideoChats, RestrictMembers: in.CanRestrictMembers,
PromoteMembers: in.CanPromoteMembers, ChangeInfo: in.CanChangeInfo, InviteUsers: in.CanInviteUsers,
PostStories: in.CanPostStories, EditStories: in.CanEditStories, DeleteStories: in.CanDeleteStories,
PostMessages: in.CanPostMessages, EditMessages: in.CanEditMessages, PinMessages: in.CanPinMessages,
ManageTopics: in.CanManageTopics, ManageDirectMessages: in.CanManageDirectMessages,
}
}
func (b *apiKeyboardButton) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if strings.HasPrefix(trimmed, "\"") {
b.Kind = "text"
return json.Unmarshal([]byte(trimmed), &b.Text)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
text, ok := fields["text"]
if !ok || json.Unmarshal(text, &b.Text) != nil {
return errors.New("invalid keyboard button text")
}
if raw, ok := fields["style"]; ok {
if err := json.Unmarshal(raw, &b.Style); err != nil {
return err
}
}
if raw, ok := fields["icon_custom_emoji_id"]; ok {
b.IconCustomEmojiIDSet = true
if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil {
return err
}
}
actions := 0
if raw, ok := fields["request_contact"]; ok {
var enabled bool
if json.Unmarshal(raw, &enabled) != nil || !enabled {
b.Unsupported = true
} else {
b.Kind = "request_contact"
actions++
}
}
if raw, ok := fields["request_location"]; ok {
var enabled bool
if json.Unmarshal(raw, &enabled) != nil || !enabled {
b.Unsupported = true
} else {
b.Kind = "request_location"
actions++
}
}
if raw, ok := fields["request_poll"]; ok {
var poll struct {
Type string `json:"type"`
}
if json.Unmarshal(raw, &poll) != nil {
b.Unsupported = true
} else {
b.Kind, b.PollType = "request_poll", poll.Type
actions++
}
}
if raw, ok := fields["request_users"]; ok {
var request struct {
RequestID int `json:"request_id"`
UserIsBot *bool `json:"user_is_bot"`
UserIsPremium *bool `json:"user_is_premium"`
MaxQuantity int `json:"max_quantity"`
RequestName bool `json:"request_name"`
RequestUsername bool `json:"request_username"`
RequestPhoto bool `json:"request_photo"`
}
if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 {
b.Unsupported = true
} else {
b.Kind, b.RequestID, b.MaxQuantity, b.RequestName, b.RequestUsername, b.RequestPhoto = "request_users", request.RequestID, request.MaxQuantity, request.RequestName, request.RequestUsername, request.RequestPhoto
b.RequestPeerFilter = &domain.BotRequestPeerFilter{}
if request.UserIsBot != nil {
b.RequestPeerFilter.UserIsBotSet, b.RequestPeerFilter.UserIsBot = true, *request.UserIsBot
}
if request.UserIsPremium != nil {
b.RequestPeerFilter.UserIsPremiumSet, b.RequestPeerFilter.UserIsPremium = true, *request.UserIsPremium
}
if b.MaxQuantity == 0 {
b.MaxQuantity = 1
}
actions++
}
}
if raw, ok := fields["request_chat"]; ok {
var request struct {
RequestID int `json:"request_id"`
ChatIsChannel bool `json:"chat_is_channel"`
ChatIsForum *bool `json:"chat_is_forum"`
ChatHasUsername *bool `json:"chat_has_username"`
ChatIsCreated bool `json:"chat_is_created"`
UserAdministratorRights *apiChatAdministratorRights `json:"user_administrator_rights"`
BotAdministratorRights *apiChatAdministratorRights `json:"bot_administrator_rights"`
BotIsMember bool `json:"bot_is_member"`
RequestTitle bool `json:"request_title"`
RequestUsername bool `json:"request_username"`
RequestPhoto bool `json:"request_photo"`
}
if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 || (request.ChatIsChannel && (request.ChatIsForum != nil || request.BotIsMember)) {
b.Unsupported = true
} else {
b.Kind, b.RequestID, b.ChatIsChannel, b.RequestTitle, b.RequestUsername, b.RequestPhoto = "request_chat", request.RequestID, request.ChatIsChannel, request.RequestTitle, request.RequestUsername, request.RequestPhoto
b.RequestPeerFilter = &domain.BotRequestPeerFilter{
ChatIsCreated: request.ChatIsCreated, BotIsMember: request.BotIsMember,
UserAdminRights: domainRequestAdminRights(request.UserAdministratorRights),
BotAdminRights: domainRequestAdminRights(request.BotAdministratorRights),
}
if request.ChatIsForum != nil {
b.RequestPeerFilter.ChatIsForumSet, b.RequestPeerFilter.ChatIsForum = true, *request.ChatIsForum
}
if request.ChatHasUsername != nil {
b.RequestPeerFilter.ChatHasUsernameSet, b.RequestPeerFilter.ChatHasUsername = true, *request.ChatHasUsername
}
actions++
}
}
if raw, ok := fields["web_app"]; ok {
var app struct {
URL string `json:"url"`
}
if json.Unmarshal(raw, &app) != nil {
b.Unsupported = true
} else {
b.Kind, b.WebAppURL = "web_app", app.URL
actions++
}
}
if actions == 0 {
b.Kind = "text"
}
if actions > 1 {
b.Unsupported = true
}
for key := range fields {
switch key {
case "text", "style", "icon_custom_emoji_id", "request_contact", "request_location", "request_poll", "request_users", "request_chat", "web_app":
default:
b.Unsupported = true
}
}
return nil
}
type apiReplyKeyboardRemove struct {
RemoveKeyboard bool `json:"remove_keyboard"`
Selective bool `json:"selective"`
}
type apiForceReply struct {
ForceReply bool `json:"force_reply"`
InputFieldPlaceholder string `json:"input_field_placeholder"`
Selective bool `json:"selective"`
}
type apiInlineKeyboardButton struct {
Text string
URL string
URLSet bool
CallbackData string
CallbackDataSet bool
Style string
IconCustomEmojiID string
IconCustomEmojiIDSet bool
Unsupported bool
WebAppURL string
WebAppSet bool
SwitchInlineQuery string
SwitchInlineSet bool
SwitchInlineSamePeer bool
SwitchInlinePeerTypes []string
CopyText string
CopyTextSet bool
}
func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
text, ok := fields["text"]
if !ok || json.Unmarshal(text, &b.Text) != nil {
return errors.New("invalid inline keyboard button text")
}
if raw, ok := fields["url"]; ok {
b.URLSet = true
if err := json.Unmarshal(raw, &b.URL); err != nil {
return err
}
}
if raw, ok := fields["callback_data"]; ok {
b.CallbackDataSet = true
if err := json.Unmarshal(raw, &b.CallbackData); err != nil {
return err
}
}
if raw, ok := fields["web_app"]; ok {
b.WebAppSet = true
var app struct {
URL string `json:"url"`
}
if json.Unmarshal(raw, &app) != nil {
return errors.New("invalid web app")
}
b.WebAppURL = app.URL
}
switchActions := 0
if raw, ok := fields["switch_inline_query"]; ok {
switchActions++
b.SwitchInlineSet = true
if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil {
return errors.New("invalid switch inline query")
}
}
if raw, ok := fields["switch_inline_query_current_chat"]; ok {
switchActions++
b.SwitchInlineSet, b.SwitchInlineSamePeer = true, true
if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil {
return errors.New("invalid switch inline query")
}
}
if raw, ok := fields["switch_inline_query_chosen_chat"]; ok {
switchActions++
b.SwitchInlineSet = true
var chosen struct {
Query string `json:"query"`
AllowUserChats bool `json:"allow_user_chats"`
AllowBotChats bool `json:"allow_bot_chats"`
AllowGroupChats bool `json:"allow_group_chats"`
AllowChannelChats bool `json:"allow_channel_chats"`
}
if json.Unmarshal(raw, &chosen) != nil {
return errors.New("invalid switch inline query")
}
b.SwitchInlineQuery = chosen.Query
if chosen.AllowUserChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypePM)
}
if chosen.AllowBotChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBotPM)
}
if chosen.AllowGroupChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup)
}
if chosen.AllowChannelChats {
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBroadcast)
}
}
if switchActions > 1 {
b.Unsupported = true
}
if raw, ok := fields["copy_text"]; ok {
b.CopyTextSet = true
var copy struct {
Text string `json:"text"`
}
if json.Unmarshal(raw, &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 (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"strconv"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func apiInt(raw string, fallback int) int {
@ -33,33 +35,42 @@ func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) {
return messageEntitiesFromAPI(payload)
}
func allowedUpdates(raw string) map[string]struct{} {
func parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
out := make(map[string]struct{}, len(items))
if len(items) > 100 {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
seen := make(map[domain.BotAPIUpdateKind]struct{}, len(items))
out := make([]domain.BotAPIUpdateKind, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
out[item] = struct{}{}
if item == "" || len(item) > 64 {
return nil, errors.New("ALLOWED_UPDATES_INVALID")
}
kind := domain.BotAPIUpdateKind(item)
if _, ok := seen[kind]; !ok {
seen[kind] = struct{}{}
out = append(out, kind)
}
}
return out
return out, nil
}
func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any {
func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any {
if limit <= 0 || limit > 100 {
limit = 100
}
out := make([]map[string]any, 0, min(len(events), limit))
for _, event := range events {
item, kind, ok := apiUpdate(event)
if !ok || !updateAllowed(kind, allowed) {
item, _, ok := apiUpdate(event)
if !ok {
continue
}
out = append(out, item)
@ -73,14 +84,6 @@ func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit
return out
}
func updateAllowed(kind string, allowed map[string]struct{}) bool {
if len(allowed) == 0 {
return true
}
_, ok := allowed[kind]
return ok
}
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
if event.Pts <= 0 {
return nil, "", false
@ -92,7 +95,7 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
}
return map[string]any{
"update_id": event.Pts,
"message": apiMessage(event.Message, event.Users),
"message": apiMessage(event.Message, event.Users, event.Channels),
}, "message", true
case domain.UpdateEventEditMessage:
if !apiMessageProjectable(event.Message) {
@ -100,18 +103,90 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
}
return map[string]any{
"update_id": event.Pts,
"edited_message": apiMessage(event.Message, event.Users),
"edited_message": apiMessage(event.Message, event.Users, event.Channels),
}, "edited_message", true
case domain.UpdateEventBotCallbackQuery:
callback := event.BotCallbackQuery
if callback == nil || callback.ID == 0 || callback.UserID == 0 {
return nil, "", false
}
var from domain.User
for _, user := range event.Users {
if user.ID == callback.UserID {
from = user
break
}
}
if from.ID == 0 {
from = domain.User{ID: callback.UserID}
}
query := map[string]any{
"id": strconv.FormatInt(callback.ID, 10),
"from": apiUser(from),
"chat_instance": strconv.FormatInt(callback.ChatInstance, 10),
"data": string(callback.Data),
}
if callback.InlineMessage != nil {
inlineMessageID, ok := encodeBotAPIInlineMessageID(*callback.InlineMessage)
if !ok || callback.MessageID != 0 || callback.Peer != (domain.Peer{}) {
return nil, "", false
}
query["inline_message_id"] = inlineMessageID
} else {
if callback.MessageID <= 0 || event.Message.ID != callback.MessageID {
return nil, "", false
}
query["message"] = apiMessage(event.Message, event.Users, event.Channels)
}
return map[string]any{
"update_id": event.Pts,
"callback_query": query,
}, "callback_query", true
default:
return nil, "", false
}
}
const botAPIInlineMessageIDVersion byte = 1
// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an
// opaque, fixed-size Bot API token. AccessHash remains the authorization boundary; the
// version byte lets us reject rather than reinterpret future shapes.
func encodeBotAPIInlineMessageID(id domain.BotInlineMessageID) (string, bool) {
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
return "", false
}
buf := make([]byte, 1+4+8+4+8)
buf[0] = botAPIInlineMessageIDVersion
binary.LittleEndian.PutUint32(buf[1:5], uint32(id.DCID))
binary.LittleEndian.PutUint64(buf[5:13], uint64(id.OwnerID))
binary.LittleEndian.PutUint32(buf[13:17], uint32(id.ID))
binary.LittleEndian.PutUint64(buf[17:25], uint64(id.AccessHash))
return base64.RawURLEncoding.EncodeToString(buf), true
}
func decodeBotAPIInlineMessageID(raw string) (domain.BotInlineMessageID, error) {
buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(buf) != 25 || buf[0] != botAPIInlineMessageIDVersion {
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
}
id := domain.BotInlineMessageID{
DCID: int(binary.LittleEndian.Uint32(buf[1:5])),
OwnerID: int64(binary.LittleEndian.Uint64(buf[5:13])),
ID: int(binary.LittleEndian.Uint32(buf[13:17])),
AccessHash: int64(binary.LittleEndian.Uint64(buf[17:25])),
}
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
}
return id, nil
}
func apiMessageProjectable(msg domain.Message) bool {
if msg.Out || msg.ID <= 0 {
return false
}
return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0
return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0
}
func apiUser(u domain.User) map[string]any {
@ -133,11 +208,17 @@ func apiUser(u domain.User) map[string]any {
return out
}
func apiMessage(msg domain.Message, users []domain.User) map[string]any {
func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domain.Channel) map[string]any {
userByID := map[int64]domain.User{}
for _, u := range users {
userByID[u.ID] = u
}
channelByID := map[int64]domain.Channel{}
if len(channelLists) > 0 {
for _, channel := range channelLists[0] {
channelByID[channel.ID] = channel
}
}
out := map[string]any{
"message_id": msg.ID,
"date": msg.Date,
@ -153,17 +234,25 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any {
}
out["from"] = apiUser(from)
}
media := apiMessageMedia(msg.Media)
media := apiMessageMedia(msg.Media, userByID, channelByID)
if msg.Body != "" {
if len(media) > 0 {
if _, photo := media["photo"]; photo {
out["caption"] = msg.Body
} else if _, document := media["document"]; document {
out["caption"] = msg.Body
} else if poll, ok := media["poll"].(map[string]any); ok {
poll["description"] = msg.Body
} else {
out["text"] = msg.Body
}
}
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
if len(media) > 0 {
if _, photo := media["photo"]; photo {
out["caption_entities"] = entities
} else if _, document := media["document"]; document {
out["caption_entities"] = entities
} else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" {
poll["description_entities"] = entities
} else {
out["entities"] = entities
}
@ -309,6 +398,11 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
if markup.IsZero() {
return nil
}
// Bot API Message.reply_markup is InlineKeyboardMarkup only. ReplyKeyboardMarkup,
// ReplyKeyboardRemove and ForceReply are send parameters, not message response fields.
if markup.Kind() != domain.MessageReplyMarkupInline {
return nil
}
rows := make([][]map[string]any, 0, len(markup.Inline))
for _, row := range markup.Inline {
if len(row) == 0 {
@ -317,11 +411,43 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
apiRow := make([]map[string]any, 0, len(row))
for _, button := range row {
item := map[string]any{"text": button.Text}
if button.Style != "" {
item["style"] = string(button.Style)
}
if button.IconCustomEmojiID > 0 {
item["icon_custom_emoji_id"] = strconv.FormatInt(button.IconCustomEmojiID, 10)
}
switch button.Type {
case domain.MarkupButtonURL:
item["url"] = button.URL
case domain.MarkupButtonCallback:
item["callback_data"] = string(button.Data)
case domain.MarkupButtonWebView:
item["web_app"] = map[string]any{"url": button.URL}
case domain.MarkupButtonSwitchInline:
switch {
case button.SamePeer:
item["switch_inline_query_current_chat"] = button.Query
case len(button.PeerTypes) > 0:
chosen := map[string]any{"query": button.Query}
for _, peerType := range button.PeerTypes {
switch peerType {
case store.InlineQueryPeerTypePM:
chosen["allow_user_chats"] = true
case store.InlineQueryPeerTypeBotPM:
chosen["allow_bot_chats"] = true
case store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup:
chosen["allow_group_chats"] = true
case store.InlineQueryPeerTypeBroadcast:
chosen["allow_channel_chats"] = true
}
}
item["switch_inline_query_chosen_chat"] = chosen
default:
item["switch_inline_query"] = button.Query
}
case domain.MarkupButtonCopy:
item["copy_text"] = map[string]any{"text": button.CopyText}
default:
continue
}
@ -337,7 +463,7 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
return map[string]any{"inline_keyboard": rows}
}
func apiMessageMedia(media *domain.MessageMedia) map[string]any {
func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, channels map[int64]domain.Channel) map[string]any {
if media.IsZero() {
return nil
}
@ -356,11 +482,264 @@ func apiMessageMedia(media *domain.MessageMedia) map[string]any {
return nil
}
return map[string]any{"document": apiDocument(*media.Document)}
case domain.MessageMediaKindContact:
if media.Contact == nil {
return nil
}
contact := map[string]any{
"phone_number": media.Contact.PhoneNumber,
"first_name": media.Contact.FirstName,
}
if media.Contact.LastName != "" {
contact["last_name"] = media.Contact.LastName
}
if media.Contact.Vcard != "" {
contact["vcard"] = media.Contact.Vcard
}
if media.Contact.UserID != 0 {
contact["user_id"] = media.Contact.UserID
}
return map[string]any{"contact": contact}
case domain.MessageMediaKindGeo:
if media.Geo == nil {
return nil
}
return map[string]any{"location": apiLocation(*media.Geo, nil)}
case domain.MessageMediaKindVenue:
if media.Venue == nil {
return nil
}
return map[string]any{"venue": apiVenue(*media.Venue)}
case domain.MessageMediaKindGeoLive:
if media.GeoLive == nil {
return nil
}
return map[string]any{"location": apiLocation(media.GeoLive.Geo, media.GeoLive)}
case domain.MessageMediaKindPoll:
if media.Poll == nil {
return nil
}
return map[string]any{"poll": apiPoll(*media.Poll, users)}
case domain.MessageMediaKindService:
if media.ServiceAction == nil {
return nil
}
switch media.ServiceAction.Kind {
case domain.MessageServiceActionWebViewDataSent:
if media.ServiceAction.WebViewData == nil {
return nil
}
return map[string]any{"web_app_data": map[string]any{
"data": media.ServiceAction.WebViewData.Data, "button_text": media.ServiceAction.WebViewData.ButtonText,
}}
case domain.MessageServiceActionRequestedPeer:
return apiRequestedPeer(media.ServiceAction.RequestedPeer, users, channels)
default:
return nil
}
default:
return nil
}
}
func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any {
out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long}
if geo.AccuracyRadius > 0 {
out["horizontal_accuracy"] = float64(geo.AccuracyRadius)
}
if live != nil {
if live.Period > 0 {
out["live_period"] = live.Period
}
if live.Heading > 0 {
out["heading"] = live.Heading
}
if live.ProximityNotificationRadius > 0 {
out["proximity_alert_radius"] = live.ProximityNotificationRadius
}
}
return out
}
func apiVenue(venue domain.MessageVenue) map[string]any {
out := map[string]any{
"location": apiLocation(venue.Geo, nil), "title": venue.Title, "address": venue.Address,
}
switch strings.ToLower(venue.Provider) {
case "foursquare":
if venue.VenueID != "" {
out["foursquare_id"] = venue.VenueID
}
if venue.VenueType != "" {
out["foursquare_type"] = venue.VenueType
}
case "gplaces", "google":
if venue.VenueID != "" {
out["google_place_id"] = venue.VenueID
}
if venue.VenueType != "" {
out["google_place_type"] = venue.VenueType
}
}
return out
}
func apiPoll(poll domain.MessagePoll, users map[int64]domain.User) map[string]any {
resultByOption := make(map[string]domain.MessagePollAnswerVoters)
totalVoters := 0
if poll.Results != nil {
totalVoters = poll.Results.TotalVoters
for _, result := range poll.Results.Voters {
resultByOption[string(result.Option)] = result
}
}
options := make([]map[string]any, 0, len(poll.Answers))
correct := make([]int, 0, len(poll.Answers))
for index, answer := range poll.Answers {
persistentID := base64.RawURLEncoding.EncodeToString(answer.Option)
if persistentID == "" {
persistentID = strconv.Itoa(index)
}
result := resultByOption[string(answer.Option)]
option := map[string]any{
"persistent_id": persistentID, "text": answer.Text, "voter_count": result.Voters,
}
if entities := apiMessageEntities(answer.Entities, users); len(entities) > 0 {
option["text_entities"] = entities
}
if answer.Media != nil {
if projected := apiPollMedia(answer.Media); len(projected) > 0 {
option["media"] = projected
}
}
if result.Correct {
correct = append(correct, index)
}
options = append(options, option)
}
pollType := "regular"
if poll.Quiz {
pollType = "quiz"
}
out := map[string]any{
"id": strconv.FormatInt(poll.ID, 10), "question": poll.Question,
"options": options, "total_voter_count": totalVoters, "is_closed": poll.Closed,
"is_anonymous": !poll.PublicVoters, "type": pollType,
"allows_multiple_answers": poll.MultipleChoice, "allows_revoting": !poll.RevotingDisabled,
}
if entities := apiMessageEntities(poll.QuestionEntities, users); len(entities) > 0 {
out["question_entities"] = entities
}
if len(correct) > 0 {
out["correct_option_ids"] = correct
}
if poll.Results != nil && poll.Results.Solution != "" {
out["explanation"] = poll.Results.Solution
if entities := apiMessageEntities(poll.Results.SolutionEntities, users); len(entities) > 0 {
out["explanation_entities"] = entities
}
}
if poll.ClosePeriod > 0 {
out["open_period"] = poll.ClosePeriod
}
if poll.CloseDate > 0 {
out["close_date"] = poll.CloseDate
}
if poll.AttachedMedia != nil {
if projected := apiPollMedia(poll.AttachedMedia); len(projected) > 0 {
out["media"] = projected
}
}
return out
}
func apiPollMedia(media *domain.MessageMedia) map[string]any {
if media.IsZero() {
return nil
}
switch media.Kind {
case domain.MessageMediaKindPhoto:
if media.Photo != nil {
if sizes := apiPhotoSizes(*media.Photo); len(sizes) > 0 {
return map[string]any{"photo": sizes}
}
}
case domain.MessageMediaKindDocument:
if media.Document != nil {
return map[string]any{"document": apiDocument(*media.Document)}
}
case domain.MessageMediaKindGeo:
if media.Geo != nil {
return map[string]any{"location": apiLocation(*media.Geo, nil)}
}
case domain.MessageMediaKindVenue:
if media.Venue != nil {
return map[string]any{"venue": apiVenue(*media.Venue)}
}
}
return nil
}
func apiRequestedPeer(action *domain.MessageRequestedPeerAction, _ map[int64]domain.User, _ map[int64]domain.Channel) map[string]any {
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 {
return nil
}
allUsers := true
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
for _, detail := range action.Details {
details[detail.Peer] = detail
}
for _, peer := range action.Peers {
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return nil
}
allUsers = allUsers && peer.Type == domain.PeerTypeUser
}
if allUsers {
shared := make([]map[string]any, 0, len(action.Peers))
for _, peer := range action.Peers {
item := map[string]any{"user_id": peer.ID}
detail := details[peer]
if action.NameRequested {
if detail.FirstName != "" {
item["first_name"] = detail.FirstName
}
if detail.LastName != "" {
item["last_name"] = detail.LastName
}
}
if action.UsernameRequested && detail.Username != "" {
item["username"] = detail.Username
}
if action.PhotoRequested && detail.Photo != nil {
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
item["photo"] = photo
}
}
shared = append(shared, item)
}
return map[string]any{"users_shared": map[string]any{"request_id": action.ButtonID, "users": shared}}
}
if len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeChannel {
return nil
}
peer := action.Peers[0]
shared := map[string]any{"request_id": action.ButtonID, "chat_id": -1000000000000 - peer.ID}
detail := details[peer]
if action.NameRequested && detail.Title != "" {
shared["title"] = detail.Title
}
if action.UsernameRequested && detail.Username != "" {
shared["username"] = detail.Username
}
if action.PhotoRequested && detail.Photo != nil {
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
shared["photo"] = photo
}
}
return map[string]any{"chat_shared": shared}
}
func apiPhotoSizes(photo domain.Photo) []map[string]any {
return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":")
}

View file

@ -10,8 +10,10 @@ import (
"io"
"net"
"net/http"
neturl "net/url"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@ -41,6 +43,7 @@ type GatewayService interface {
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error)
BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error)
BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error)
BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error)
BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error)
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
@ -51,6 +54,29 @@ type GatewayUpdateWaiter interface {
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool
}
type GatewayUpdateControl interface {
BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error
BotAPIDropPendingUpdates(ctx context.Context, botID int64) error
BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error)
}
type GatewayPollLease interface {
AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error)
ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error
}
type GatewayWebhookControl interface {
BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error
BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error
BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error)
ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error)
AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error)
ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error
RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error
RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error
ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error
}
func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, gateway GatewayService, logger *zap.Logger) (*http.Server, error) {
if strings.TrimSpace(addr) == "" {
return nil, nil
@ -58,7 +84,7 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
if logger == nil {
logger = zap.NewNop()
}
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger}
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger, webhookClient: newWebhookHTTPClient()}
srv := &http.Server{
Addr: addr,
Handler: handler.routes(),
@ -76,6 +102,9 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
logger.Warn("Bot API 网关退出", zap.Error(err))
}
}()
if webhooks, ok := gateway.(GatewayWebhookControl); ok {
go runWebhookDispatcher(ctx, webhooks, gateway, handler.webhookClient, logger.Named("webhook"))
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -86,11 +115,37 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
}
type handler struct {
bots BotsService
users UsersService
webapps WebAppService
gateway GatewayService
logger *zap.Logger
bots BotsService
users UsersService
webapps WebAppService
gateway GatewayService
logger *zap.Logger
polls botAPIPollRegistry
webhookClient *http.Client
}
type botAPIPollRegistry struct {
mu sync.Mutex
active map[int64]struct{}
}
func (p *botAPIPollRegistry) acquire(botID int64) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.active == nil {
p.active = make(map[int64]struct{})
}
if _, exists := p.active[botID]; exists {
return false
}
p.active[botID] = struct{}{}
return true
}
func (p *botAPIPollRegistry) release(botID int64) {
p.mu.Lock()
delete(p.active, botID)
p.mu.Unlock()
}
const (
@ -148,11 +203,11 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
case "getfile":
h.getFile(w, r, botID)
case "deletewebhook":
writeAPIOK(w, true)
h.deleteWebhook(w, r, botID)
case "getwebhookinfo":
writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0})
h.getWebhookInfo(w, r, botID)
case "setwebhook":
h.setWebhook(w, r)
h.setWebhook(w, r, botID)
case "setchatmenubutton":
h.setChatMenuButton(w, r, botID)
case "getchatmenubutton":
@ -216,7 +271,14 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
offset, _ := strconv.ParseInt(strings.TrimSpace(values["offset"]), 10, 64)
var offset int64
if raw := strings.TrimSpace(values["offset"]); raw != "" {
offset, err = strconv.ParseInt(raw, 10, 64)
if err != nil || offset < -10000 {
writeAPIError(w, http.StatusBadRequest, "OFFSET_INVALID")
return
}
}
limit := apiInt(values["limit"], 100)
if limit <= 0 {
limit = 100
@ -231,7 +293,56 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
if timeoutSeconds > 50 {
timeoutSeconds = 50
}
allowed := allowedUpdates(values["allowed_updates"])
if !h.polls.acquire(botID) {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer h.polls.release(botID)
if leases, ok := h.gateway.(GatewayPollLease); ok {
owner := randomBotAPIOwner()
leaseTTL := time.Duration(timeoutSeconds)*time.Second + 30*time.Second
acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, leaseTTL)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner); err != nil {
h.logger.Warn("release bot api poll lease", zap.Int64("bot_user_id", botID), zap.Error(err))
}
}()
}
if webhooks, ok := h.gateway.(GatewayWebhookControl); ok {
if _, configured, err := webhooks.BotAPIWebhook(r.Context(), botID); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
} else if configured {
writeAPIError(w, http.StatusConflict, "CONFLICT: can't use getUpdates method while webhook is active")
return
}
}
if raw, present := values["allowed_updates"]; present {
allowed, err := parseAllowedUpdates(raw)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
control, ok := h.gateway.(GatewayUpdateControl)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "ALLOWED_UPDATES_UNSUPPORTED")
return
}
if err := control.BotAPISetAllowedUpdates(r.Context(), botID, allowed); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
}
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
for {
version := botAPIUpdateWaitVersion(h.gateway, botID)
@ -240,7 +351,7 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
updates := apiUpdates(events, allowed, limit)
updates := apiUpdates(events, limit)
if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) {
writeAPIOK(w, updates)
return
@ -249,6 +360,84 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
}
}
func randomBotAPIOwner() string {
var raw [16]byte
if _, err := rand.Read(raw[:]); err == nil {
return fmt.Sprintf("%x", raw[:])
}
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
func (h *handler) deleteWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
values, err := requestValues(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
control, ok := h.gateway.(GatewayWebhookControl)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED")
return
}
leaseOwner := randomBotAPIOwner()
if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
} else if found {
acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, leaseOwner, 30*time.Second)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active")
return
}
defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, leaseOwner) }()
}
if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, true)
}
func (h *handler) getWebhookInfo(w http.ResponseWriter, r *http.Request, botID int64) {
pending := 0
if control, ok := h.gateway.(GatewayUpdateControl); ok {
var err error
pending, err = control.BotAPIPendingUpdateCount(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
}
result := map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": pending}
if control, ok := h.gateway.(GatewayWebhookControl); ok {
config, found, err := control.BotAPIWebhook(r.Context(), botID)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if found {
result["url"] = config.URL
result["max_connections"] = config.MaxConnections
if config.AllowedUpdates != nil {
allowed := make([]string, 0, len(config.AllowedUpdates))
for _, kind := range config.AllowedUpdates {
allowed = append(allowed, string(kind))
}
result["allowed_updates"] = allowed
}
if config.LastErrorDate > 0 {
result["last_error_date"] = config.LastErrorDate
result["last_error_message"] = config.LastErrorMessage
}
}
}
writeAPIOK(w, result)
}
func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 {
waiter, ok := gateway.(GatewayUpdateWaiter)
if !ok {
@ -381,14 +570,22 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
messageID := apiInt(values["message_id"], 0)
if messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
rawInlineID := strings.TrimSpace(values["inline_message_id"])
var chatID int64
messageID := 0
if rawInlineID == "" {
chatID, err = strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
if err != nil || chatID == 0 {
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return
}
messageID = apiInt(values["message_id"], 0)
if messageID <= 0 {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
return
}
} else if strings.TrimSpace(values["chat_id"]) != "" || strings.TrimSpace(values["message_id"]) != "" {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
return
}
if strings.TrimSpace(values["parse_mode"]) != "" {
@ -403,12 +600,26 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
var markup *domain.MessageReplyMarkup
_, setReplyMarkup := values["reply_markup"]
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw))
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
if rawInlineID != "" {
inlineID, err := decodeBotAPIInlineMessageID(rawInlineID)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return
}
writeAPIOK(w, ok)
return
}
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
@ -557,17 +768,136 @@ func (h *handler) downloadFile(w http.ResponseWriter, r *http.Request) {
}
}
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request) {
values, err := requestValues(r)
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
values, files, err := requestValuesWithFiles(r)
if err != nil {
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
return
}
if strings.TrimSpace(values["url"]) == "" {
control, ok := h.gateway.(GatewayWebhookControl)
if !ok {
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED")
return
}
rawURL := strings.TrimSpace(values["url"])
if rawURL == "" {
if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, true)
return
}
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_NOT_IMPLEMENTED")
if err := validateWebhookURL(rawURL); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(values["certificate"]) != "" || len(files) != 0 {
writeAPIError(w, http.StatusBadRequest, "CERTIFICATE_PINNING_UNSUPPORTED")
return
}
if strings.TrimSpace(values["ip_address"]) != "" {
writeAPIError(w, http.StatusBadRequest, "IP_ADDRESS_UNSUPPORTED")
return
}
secret := strings.TrimSpace(values["secret_token"])
if !validWebhookSecret(secret) {
writeAPIError(w, http.StatusBadRequest, "SECRET_TOKEN_INVALID")
return
}
maxConnections := apiInt(values["max_connections"], 40)
if maxConnections < 1 || maxConnections > 100 {
writeAPIError(w, http.StatusBadRequest, "MAX_CONNECTIONS_INVALID")
return
}
var allowed []domain.BotAPIUpdateKind
_, allowedUpdatesSet := values["allowed_updates"]
if raw, present := values["allowed_updates"]; present {
allowed, err = parseAllowedUpdates(raw)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if len(allowed) == 0 {
allowed = nil
}
}
if !h.polls.acquire(botID) {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer h.polls.release(botID)
if leases, ok := h.gateway.(GatewayPollLease); ok {
owner := randomBotAPIOwner()
acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, 30*time.Second)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
return
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner)
}()
}
webhookOwner := randomBotAPIOwner()
if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
} else if found {
acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, webhookOwner, 30*time.Second)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
if !acquired {
writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active")
return
}
defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, webhookOwner) }()
}
if err := control.BotAPISetWebhook(r.Context(), domain.BotAPIWebhook{
BotUserID: botID, URL: rawURL, SecretToken: secret,
MaxConnections: maxConnections, AllowedUpdates: allowed, AllowedUpdatesSet: allowedUpdatesSet,
}, apiBool(values["drop_pending_updates"])); err != nil {
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
return
}
writeAPIOK(w, true)
}
func validateWebhookURL(raw string) error {
if len(raw) > 2048 {
return errors.New("WEBHOOK_URL_INVALID")
}
u, err := neturl.ParseRequestURI(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
return errors.New("WEBHOOK_URL_INVALID")
}
if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" {
return errors.New("WEBHOOK_PORT_NOT_ALLOWED")
}
return nil
}
func validWebhookSecret(secret string) bool {
if secret == "" {
return true
}
if len(secret) > 256 {
return false
}
for _, r := range secret {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) {

View file

@ -10,6 +10,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -328,6 +329,312 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
}
}
func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
markup := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}, {Type: domain.MarkupButtonText, Text: "Status"}}},
Resize: true,
SingleUse: true,
Persistent: true,
Placeholder: "Choose an action",
}
gateway := &fakeBotAPIGateway{
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
sendMessage: domain.Message{
ID: 10, OwnerUserID: 1001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
Date: 1700000003, Body: "pick", Out: true, ReplyMarkup: markup,
},
}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{
"chat_id":2001,
"text":"pick",
"reply_markup":{
"keyboard":[["Help",{"text":"Status"}]],
"resize_keyboard":true,
"one_time_keyboard":true,
"is_persistent":true,
"input_field_placeholder":"Choose an action"
}
}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
if gateway.sendMarkup == nil || gateway.sendMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
len(gateway.sendMarkup.Keyboard) != 1 || len(gateway.sendMarkup.Keyboard[0]) != 2 ||
gateway.sendMarkup.Keyboard[0][0].Text != "Help" || !gateway.sendMarkup.Resize ||
!gateway.sendMarkup.SingleUse || !gateway.sendMarkup.Persistent || gateway.sendMarkup.Placeholder != "Choose an action" {
t.Fatalf("gateway reply keyboard = %#v", gateway.sendMarkup)
}
var resp struct {
OK bool `json:"ok"`
Result struct {
ReplyMarkup json.RawMessage `json:"reply_markup"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// Bot API Message.reply_markup only contains InlineKeyboardMarkup; reply keyboards are
// accepted send parameters but are deliberately absent from the returned Message object.
if !resp.OK || len(resp.Result.ReplyMarkup) != 0 {
t.Fatalf("reply keyboard response = %s", rec.Body.String())
}
}
func TestGetUpdatesProjectsCallbackQuery(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
callback := &domain.BotCallbackQuery{
ID: 123456, BotUserID: 1001, UserID: 2001,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, MessageID: 9,
ChatInstance: 9988, Data: []byte("confirm"),
}
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 77, Date: 1700000004,
Peer: callback.Peer, BotCallbackQuery: callback,
Message: domain.Message{
ID: 9, OwnerUserID: 1001, Peer: callback.Peer,
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Date: 1700000003,
Body: "tap", Out: true,
},
Users: []domain.User{{ID: 1001, FirstName: "Echo", Bot: true}, {ID: 2001, FirstName: "Alice"}},
}}}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{"allowed_updates":["callback_query"]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Result []struct {
UpdateID int `json:"update_id"`
CallbackQuery struct {
ID string `json:"id"`
Data string `json:"data"`
ChatInstance string `json:"chat_instance"`
From struct {
ID int64 `json:"id"`
} `json:"from"`
Message struct {
MessageID int `json:"message_id"`
} `json:"message"`
} `json:"callback_query"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || len(resp.Result) != 1 || resp.Result[0].UpdateID != 77 ||
resp.Result[0].CallbackQuery.ID != "123456" || resp.Result[0].CallbackQuery.Data != "confirm" ||
resp.Result[0].CallbackQuery.ChatInstance != "9988" || resp.Result[0].CallbackQuery.From.ID != 2001 ||
resp.Result[0].CallbackQuery.Message.MessageID != 9 {
t.Fatalf("callback update response = %s", rec.Body.String())
}
}
func TestInlineCallbackProjectsOpaqueIDAndEditMessageTextUsesIt(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 998877}
callback := &domain.BotCallbackQuery{
ID: 123456, BotUserID: 1001, UserID: 2001,
ChatInstance: 9988, Data: []byte("inline"), InlineMessage: inline,
}
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 78, Date: 1700000004,
BotCallbackQuery: callback, Users: []domain.User{{ID: 2001, FirstName: "Alice"}},
}}}
h := (&handler{bots: bots, gateway: gateway}).routes()
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("getUpdates status=%d body=%s", rec.Code, rec.Body.String())
}
var response struct {
Result []struct {
CallbackQuery struct {
InlineMessageID string `json:"inline_message_id"`
Message json.RawMessage `json:"message"`
} `json:"callback_query"`
} `json:"result"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || len(response.Result) != 1 {
t.Fatalf("callback response=%s err=%v", rec.Body.String(), err)
}
inlineToken := response.Result[0].CallbackQuery.InlineMessageID
decoded, err := decodeBotAPIInlineMessageID(inlineToken)
if err != nil || decoded != *inline || len(response.Result[0].CallbackQuery.Message) != 0 {
t.Fatalf("inline token=%q decoded=%#v message=%s err=%v", inlineToken, decoded, response.Result[0].CallbackQuery.Message, err)
}
edit := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"inline_message_id":"`+inlineToken+`","text":"updated"}`)
if edit.Code != http.StatusOK || !gateway.editInlineCalled || gateway.editInlineID != *inline {
t.Fatalf("edit status=%d body=%s called=%v id=%#v", edit.Code, edit.Body.String(), gateway.editInlineCalled, gateway.editInlineID)
}
}
func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) {
tests := []struct {
name string
raw string
kind domain.MessageReplyMarkupType
err string
}{
{name: "remove", raw: `{"remove_keyboard":true,"selective":true}`, kind: domain.MessageReplyMarkupHide},
{name: "force", raw: `{"force_reply":true,"input_field_placeholder":"Answer"}`, kind: domain.MessageReplyMarkupForceReply},
{name: "contact", raw: `{"keyboard":[[{"text":"Phone","request_contact":true}]]}`, kind: domain.MessageReplyMarkupKeyboard},
{name: "filtered users", raw: `{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2,"request_name":true}}]]}`, kind: domain.MessageReplyMarkupKeyboard},
{name: "filtered chat", raw: `{"keyboard":[[{"text":"Forum","request_chat":{"request_id":8,"chat_is_channel":false,"chat_is_forum":true,"chat_has_username":false,"chat_is_created":true,"bot_is_member":true,"user_administrator_rights":{"can_manage_chat":true,"can_delete_messages":true},"bot_administrator_rights":{"can_manage_chat":true}}}]]}`, kind: domain.MessageReplyMarkupKeyboard},
{name: "unsupported legacy user request", raw: `{"keyboard":[[{"text":"User","request_user":{"request_id":1}}]]}`, err: "BUTTON_TYPE_INVALID"},
{name: "multiple constructors", raw: `{"keyboard":[["A"]],"inline_keyboard":[[{"text":"B","callback_data":"b"}]]}`, err: "BUTTON_INVALID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
markup, err := replyMarkupFromAPI(json.RawMessage(tt.raw))
if tt.err != "" {
if err == nil || err.Error() != tt.err {
t.Fatalf("error = %v, want %s", err, tt.err)
}
return
}
if err != nil || markup == nil || markup.Kind() != tt.kind {
t.Fatalf("markup = %#v err=%v, want kind %s", markup, err, tt.kind)
}
})
}
if _, err := inlineReplyMarkupFromAPI(json.RawMessage(`{"keyboard":[["A"]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
t.Fatalf("inline-only parser error = %v, want BUTTON_INVALID", err)
}
if _, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Bad","url":"https://example.com","callback_data":"x"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
t.Fatalf("multi-constructor inline button error = %v, want BUTTON_INVALID", err)
}
filtered, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2}}]]}`))
if err != nil || filtered == nil {
t.Fatalf("filtered users markup=%#v err=%v", filtered, err)
}
filter := filtered.Keyboard[0][0].RequestPeerFilter
if filter == nil || !filter.UserIsBotSet || filter.UserIsBot || !filter.UserIsPremiumSet || !filter.UserIsPremium {
t.Fatalf("filtered users = %#v", filter)
}
webApp, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"App","web_app":{"url":"https://example.com"}}]]}`))
if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView {
t.Fatalf("web_app inline button = %#v err=%v", webApp, err)
}
}
func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) {
reply, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Run","style":"primary","icon_custom_emoji_id":"123"}]]}`))
if err != nil {
t.Fatalf("reply markup: %v", err)
}
button := reply.Keyboard[0][0]
if button.Style != domain.MarkupButtonStylePrimary || button.IconCustomEmojiID != 123 {
t.Fatalf("reply button = %#v", button)
}
inline, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Delete","callback_data":"delete","style":"danger","icon_custom_emoji_id":"456"}]]}`))
if err != nil {
t.Fatalf("inline markup: %v", err)
}
button = inline.Inline[0][0]
if button.Style != domain.MarkupButtonStyleDanger || button.IconCustomEmojiID != 456 {
t.Fatalf("inline button = %#v", button)
}
projected := apiReplyMarkup(inline)
rows := projected["inline_keyboard"].([][]map[string]any)
if rows[0][0]["style"] != "danger" || rows[0][0]["icon_custom_emoji_id"] != "456" {
t.Fatalf("projected inline button = %#v", rows[0][0])
}
if _, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Bad","style":"rainbow"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
t.Fatalf("invalid style error = %v", err)
}
}
func TestDeleteWebhookDropsPendingAndWebhookInfoReportsCount(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{pendingCount: 7}
h := (&handler{bots: bots, gateway: gateway}).routes()
info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`)
if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"pending_update_count":7`) {
t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String())
}
drop := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{"drop_pending_updates":true}`)
if drop.Code != http.StatusOK || !gateway.dropPending {
t.Fatalf("deleteWebhook status=%d body=%s drop=%v", drop.Code, drop.Body.String(), gateway.dropPending)
}
}
func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{pendingCount: 3}
h := (&handler{bots: bots, gateway: gateway}).routes()
set := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{
"url":"https://bot.example.test/hook",
"secret_token":"safe_secret-1",
"max_connections":12,
"allowed_updates":["message","callback_query"],
"drop_pending_updates":true
}`)
if set.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != "https://bot.example.test/hook" ||
gateway.webhook.SecretToken != "safe_secret-1" || gateway.webhook.MaxConnections != 12 ||
len(gateway.webhook.AllowedUpdates) != 2 || !gateway.webhook.AllowedUpdatesSet || !gateway.webhookDrop {
t.Fatalf("setWebhook status=%d body=%s config=%#v", set.Code, set.Body.String(), gateway.webhook)
}
info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`)
if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"url":"https://bot.example.test/hook"`) ||
!strings.Contains(info.Body.String(), `"max_connections":12`) || !strings.Contains(info.Body.String(), `"pending_update_count":3`) {
t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String())
}
reconfigure := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{"url":"https://bot.example.test/new"}`)
if reconfigure.Code != http.StatusOK || gateway.webhook.AllowedUpdatesSet {
t.Fatalf("omitted allowed_updates status=%d body=%s config=%#v", reconfigure.Code, reconfigure.Body.String(), gateway.webhook)
}
poll := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
if poll.Code != http.StatusConflict || !strings.Contains(poll.Body.String(), "webhook is active") {
t.Fatalf("getUpdates status=%d body=%s", poll.Code, poll.Body.String())
}
del := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{}`)
if del.Code != http.StatusOK || !gateway.webhookDeleted || gateway.webhookFound {
t.Fatalf("deleteWebhook status=%d body=%s deleted=%v", del.Code, del.Body.String(), gateway.webhookDeleted)
}
}
func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
tests := []struct {
body string
want string
}{
{`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
{`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"},
{`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"},
{`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"},
}
for _, tt := range tests {
rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", tt.body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tt.want) {
t.Fatalf("setWebhook body=%s status=%d response=%s want=%s", tt.body, rec.Code, rec.Body.String(), tt.want)
}
}
}
func TestBotAPIPollRegistryRejectsConcurrentPoller(t *testing.T) {
var polls botAPIPollRegistry
if !polls.acquire(1001) {
t.Fatal("first poller was rejected")
}
if polls.acquire(1001) {
t.Fatal("second poller for same bot was accepted")
}
if !polls.acquire(1002) {
t.Fatal("different bot poller was rejected")
}
polls.release(1001)
if !polls.acquire(1001) {
t.Fatal("poller remained locked after release")
}
}
func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{
@ -554,6 +861,108 @@ func TestAPIUpdateProjectsCaptionlessMediaMessage(t *testing.T) {
}
}
func TestAPIMessageProjectsReplyKeyboardResponses(t *testing.T) {
base := domain.Message{
ID: 10, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000010,
}
t.Run("contact", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{
PhoneNumber: "+12025550123", FirstName: "Alice", LastName: "Example", Vcard: "VCARD", UserID: 2001,
}}
contact := apiMessage(msg, nil)["contact"].(map[string]any)
if contact["phone_number"] != "+12025550123" || contact["user_id"] != int64(2001) {
t.Fatalf("contact=%#v", contact)
}
})
t.Run("locations", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{Lat: 1.5, Long: 2.5, AccuracyRadius: 7}}
location := apiMessage(msg, nil)["location"].(map[string]any)
if location["latitude"] != 1.5 || location["horizontal_accuracy"] != float64(7) {
t.Fatalf("location=%#v", location)
}
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{
Geo: domain.MessageGeoPoint{Lat: 3.5, Long: 4.5}, Period: 60, Heading: 90, ProximityNotificationRadius: 25,
}}
location = apiMessage(msg, nil)["location"].(map[string]any)
if location["live_period"] != 60 || location["heading"] != 90 || location["proximity_alert_radius"] != 25 {
t.Fatalf("live location=%#v", location)
}
})
t.Run("venue", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
Geo: domain.MessageGeoPoint{Lat: 1, Long: 2}, Title: "Cafe", Address: "Main St",
Provider: "foursquare", VenueID: "place-1", VenueType: "food/cafe",
}}
venue := apiMessage(msg, nil)["venue"].(map[string]any)
if venue["title"] != "Cafe" || venue["foursquare_id"] != "place-1" {
t.Fatalf("venue=%#v", venue)
}
})
t.Run("poll", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{
ID: 77, Question: "Pick", Quiz: true, RevotingDisabled: true,
Answers: []domain.MessagePollAnswer{{Text: "A", Option: []byte{1}}, {Text: "B", Option: []byte{2}}},
Results: &domain.MessagePollResults{TotalVoters: 3, Voters: []domain.MessagePollAnswerVoters{
{Option: []byte{1}, Voters: 1}, {Option: []byte{2}, Voters: 2, Correct: true},
}, Solution: "Because B"},
}}
poll := apiMessage(msg, nil)["poll"].(map[string]any)
options := poll["options"].([]map[string]any)
correct := poll["correct_option_ids"].([]int)
if poll["id"] != "77" || poll["type"] != "quiz" || poll["allows_revoting"] != false ||
len(options) != 2 || options[1]["voter_count"] != 2 || len(correct) != 1 || correct[0] != 1 {
t.Fatalf("poll=%#v", poll)
}
})
t.Run("web_app_data", func(t *testing.T) {
msg := base
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionWebViewDataSent,
WebViewData: &domain.MessageWebViewDataAction{ButtonText: "Open", Data: `{"ok":true}`},
}}
data := apiMessage(msg, nil)["web_app_data"].(map[string]any)
if data["button_text"] != "Open" || data["data"] != `{"ok":true}` {
t.Fatalf("web_app_data=%#v", data)
}
})
t.Run("shared_peers", func(t *testing.T) {
msg := base
sharedPhoto := domain.Photo{ID: 9001, Sizes: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
}}}
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 42, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 3001}},
Details: []domain.MessageRequestedPeerDetails{{
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3001}, FirstName: "Shared", Username: "shared_user", Photo: &sharedPhoto,
}},
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
},
}}
projected := apiMessage(msg, nil)
usersShared := projected["users_shared"].(map[string]any)
sharedUsers := usersShared["users"].([]map[string]any)
if usersShared["request_id"] != 42 || sharedUsers[0]["user_id"] != int64(3001) || sharedUsers[0]["username"] != "shared_user" || len(sharedUsers[0]["photo"].([]map[string]any)) != 1 {
t.Fatalf("users_shared=%#v", usersShared)
}
msg.Media.ServiceAction.RequestedPeer.Peers = []domain.Peer{{Type: domain.PeerTypeChannel, ID: 55}}
msg.Media.ServiceAction.RequestedPeer.Details = []domain.MessageRequestedPeerDetails{{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 55}, Title: "Shared Chat", Username: "shared_chat",
}}
projected = apiMessage(msg, nil)
chatShared := projected["chat_shared"].(map[string]any)
if chatShared["request_id"] != 42 || chatShared["chat_id"] != int64(-1000000000055) || chatShared["title"] != "Shared Chat" {
t.Fatalf("chat_shared=%#v", chatShared)
}
})
}
func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder {
t.Helper()
token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret)
@ -649,11 +1058,21 @@ type fakeBotAPIGateway struct {
editCalled bool
editSetMarkup bool
editMessage domain.Message
editInlineCalled bool
editInlineID domain.BotInlineMessageID
deleteCalled bool
callbackCalled bool
callbackID string
fileLocationKey string
fileChunks map[string]domain.FileChunk
allowedUpdates []domain.BotAPIUpdateKind
dropPending bool
pendingCount int
webhook domain.BotAPIWebhook
webhookFound bool
webhookDeleted bool
webhookDrop bool
webhookConfirmed int64
}
func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) {
@ -666,6 +1085,65 @@ func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset
return append([]domain.UpdateEvent(nil), f.updates...), nil
}
func (f *fakeBotAPIGateway) BotAPISetAllowedUpdates(_ context.Context, _ int64, allowed []domain.BotAPIUpdateKind) error {
f.allowedUpdates = append([]domain.BotAPIUpdateKind(nil), allowed...)
return nil
}
func (f *fakeBotAPIGateway) BotAPIDropPendingUpdates(context.Context, int64) error {
f.dropPending = true
return nil
}
func (f *fakeBotAPIGateway) BotAPIPendingUpdateCount(context.Context, int64) (int, error) {
return f.pendingCount, nil
}
func (f *fakeBotAPIGateway) BotAPISetWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error {
f.webhook, f.webhookFound, f.webhookDrop = config, true, dropPending
return nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteWebhook(_ context.Context, _ int64, dropPending bool) error {
f.webhook, f.webhookFound, f.webhookDeleted, f.webhookDrop = domain.BotAPIWebhook{}, false, true, dropPending
if dropPending {
f.dropPending = true
}
return nil
}
func (f *fakeBotAPIGateway) BotAPIWebhook(context.Context, int64) (domain.BotAPIWebhook, bool, error) {
return f.webhook, f.webhookFound, nil
}
func (f *fakeBotAPIGateway) ListDueBotAPIWebhooks(context.Context, int) ([]domain.BotAPIWebhook, error) {
if !f.webhookFound {
return nil, nil
}
return []domain.BotAPIWebhook{f.webhook}, nil
}
func (f *fakeBotAPIGateway) AcquireBotAPIWebhookLease(context.Context, int64, string, time.Duration) (bool, error) {
return true, nil
}
func (f *fakeBotAPIGateway) ReleaseBotAPIWebhookLease(context.Context, int64, string) error {
return nil
}
func (f *fakeBotAPIGateway) RecordBotAPIWebhookFailure(context.Context, int64, string, time.Time, string) error {
return nil
}
func (f *fakeBotAPIGateway) RecordBotAPIWebhookSuccess(context.Context, int64, string, time.Time) error {
return nil
}
func (f *fakeBotAPIGateway) ConfirmBotAPIWebhookDelivery(_ context.Context, _ int64, updateID int64) error {
f.webhookConfirmed = updateID
return nil
}
func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) {
f.sendCalled = true
f.sendBotID = botID
@ -695,6 +1173,11 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat
return f.editMessage, nil
}
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, _ string, _ []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
f.editInlineCalled, f.editInlineID = true, inlineMessageID
return true, nil
}
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
f.deleteCalled = true
return true, nil

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 上下文。
type BotRequestedWebViewButton struct {
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
CreatedAt time.Time
ExpiresAt time.Time
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
PeerFilter *BotRequestPeerFilter
NameRequested bool
UsernameRequested bool
PhotoRequested bool
CreatedAt time.Time
ExpiresAt time.Time
}
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答

View file

@ -6,8 +6,32 @@ type BotAPIUpdateKind string
const (
BotAPIUpdateMessage BotAPIUpdateKind = "message"
BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message"
BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query"
)
// BotCallbackQuery is the protocol-neutral payload shared by MTProto
// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection.
type BotCallbackQuery struct {
ID int64
BotUserID int64
UserID int64
Peer Peer
MessageID int
ChatInstance int64
Data []byte
InlineMessage *BotInlineMessageID
}
// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
// It can be projected both to MTProto and to Bot API's opaque
// inline_message_id without leaking tg types into the store boundary.
type BotInlineMessageID struct {
DCID int
OwnerID int64
ID int
AccessHash int64
}
// BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id
// and is global across all bots, matching Telegram Bot API's monotonic offset
// contract without reusing MTProto pts from user/channel logs.
@ -19,6 +43,7 @@ type BotAPIUpdate struct {
MessageID int
SourcePts int
Date int
Callback *BotCallbackQuery
}
// EnqueueBotAPIUpdateRequest describes a message-like update that should be
@ -30,4 +55,5 @@ type EnqueueBotAPIUpdateRequest struct {
MessageID int
SourcePts int
Date int
Callback *BotCallbackQuery
}

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

View file

@ -610,6 +610,23 @@ type MessageWebViewDataAction struct {
type MessageRequestedPeerAction struct {
ButtonID int `json:"button_id"`
Peers []Peer `json:"peers"`
// Details is the immutable, permission-gated snapshot delivered to the bot.
// It is kept separate from Peers because the sender-side MTProto action only
// exposes peer identities, while the bot-side/Bot API view may additionally
// expose the requested name, username, and profile photo.
Details []MessageRequestedPeerDetails `json:"details,omitempty"`
NameRequested bool `json:"name_requested,omitempty"`
UsernameRequested bool `json:"username_requested,omitempty"`
PhotoRequested bool `json:"photo_requested,omitempty"`
}
type MessageRequestedPeerDetails struct {
Peer Peer `json:"peer"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
Photo *Photo `json:"photo,omitempty"`
}
// MessageServiceAction 是私聊服务消息动作的协议中立表示。

View file

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

View file

@ -18,6 +18,10 @@ const (
MaxCallbackDataLen = 64
// MaxMarkupButtonTextLen 是按钮文本长度上限rune 计数)。
MaxMarkupButtonTextLen = 256
// MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。
MaxReplyKeyboardButtonTextLen = 64
// MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。
MaxReplyKeyboardPlaceholderLen = 64
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
MaxBotCallbackAnswerLen = 200
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64
@ -38,20 +42,81 @@ var (
ErrStartParamInvalid = errors.New("start param invalid")
)
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
// MarkupButtonType 标识消息键盘按钮类型。
type MarkupButtonType string
const (
// MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。
MarkupButtonText MarkupButtonType = "text"
// MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer
MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl点击打开链接
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
MarkupButtonRequestLocation MarkupButtonType = "request_location"
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
MarkupButtonRequestPeer MarkupButtonType = "request_peer"
MarkupButtonWebView MarkupButtonType = "webview"
MarkupButtonSimpleWebView MarkupButtonType = "simple_webview"
MarkupButtonSwitchInline MarkupButtonType = "switch_inline"
MarkupButtonCopy MarkupButtonType = "copy"
)
// MarkupButton 是一颗 inline keyboard 按钮P3 仅 callback/url
// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram
// intentionally exposes semantic colors instead of arbitrary RGB values.
type MarkupButtonStyle string
const (
MarkupButtonStylePrimary MarkupButtonStyle = "primary"
MarkupButtonStyleDanger MarkupButtonStyle = "danger"
MarkupButtonStyleSuccess MarkupButtonStyle = "success"
)
// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without
// importing protocol types into persisted message state.
type BotRequestAdminRights struct {
Anonymous bool `json:"anonymous,omitempty"`
ManageChat bool `json:"manage_chat,omitempty"`
DeleteMessages bool `json:"delete_messages,omitempty"`
ManageVideoChats bool `json:"manage_video_chats,omitempty"`
RestrictMembers bool `json:"restrict_members,omitempty"`
PromoteMembers bool `json:"promote_members,omitempty"`
ChangeInfo bool `json:"change_info,omitempty"`
InviteUsers bool `json:"invite_users,omitempty"`
PostStories bool `json:"post_stories,omitempty"`
EditStories bool `json:"edit_stories,omitempty"`
DeleteStories bool `json:"delete_stories,omitempty"`
PostMessages bool `json:"post_messages,omitempty"`
EditMessages bool `json:"edit_messages,omitempty"`
PinMessages bool `json:"pin_messages,omitempty"`
ManageTopics bool `json:"manage_topics,omitempty"`
ManageDirectMessages bool `json:"manage_direct_messages,omitempty"`
}
type BotRequestPeerFilter struct {
UserIsBotSet bool `json:"user_is_bot_set,omitempty"`
UserIsBot bool `json:"user_is_bot,omitempty"`
UserIsPremiumSet bool `json:"user_is_premium_set,omitempty"`
UserIsPremium bool `json:"user_is_premium,omitempty"`
ChatHasUsernameSet bool `json:"chat_has_username_set,omitempty"`
ChatHasUsername bool `json:"chat_has_username,omitempty"`
ChatIsForumSet bool `json:"chat_is_forum_set,omitempty"`
ChatIsForum bool `json:"chat_is_forum,omitempty"`
ChatIsCreated bool `json:"chat_is_created,omitempty"`
BotIsMember bool `json:"bot_is_member,omitempty"`
UserAdminRights *BotRequestAdminRights `json:"user_admin_rights,omitempty"`
BotAdminRights *BotRequestAdminRights `json:"bot_admin_rights,omitempty"`
}
// MarkupButton 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮;
// inline keyboard 当前接受 callback/url。
type MarkupButton struct {
Type MarkupButtonType `json:"type"`
Text string `json:"text"`
// Style is one of primary/danger/success. Empty means the client default.
Style MarkupButtonStyle `json:"style,omitempty"`
// IconCustomEmojiID is the optional custom emoji rendered before Text.
IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"`
// Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位。json 自动 base64
// 编解码,保证经 JSONB 列字节级 round-tripupdateBotCallbackQuery.data 须原样)。
Data []byte `json:"data,omitempty"`
@ -60,11 +125,68 @@ type MarkupButton struct {
// RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password
// 2FA SRP 校验 P3 stub
RequiresPassword bool `json:"requires_password,omitempty"`
// PollType is empty, "regular", or "quiz" for request_poll.
PollType string `json:"poll_type,omitempty"`
// ButtonID and request-peer fields preserve Bot API request_id and the
// client-side chooser shape. RequestPeerType is user/chat/broadcast.
ButtonID int `json:"button_id,omitempty"`
RequestPeerType string `json:"request_peer_type,omitempty"`
MaxQuantity int `json:"max_quantity,omitempty"`
NameRequested bool `json:"name_requested,omitempty"`
UsernameRequested bool `json:"username_requested,omitempty"`
PhotoRequested bool `json:"photo_requested,omitempty"`
RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"`
Query string `json:"query,omitempty"`
SamePeer bool `json:"same_peer,omitempty"`
PeerTypes []string `json:"peer_types,omitempty"`
CopyText string `json:"copy_text,omitempty"`
}
// MessageReplyMarkup 是消息携带的 inline keyboard 快照P3 仅 ReplyInlineMarkup
// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。
type MessageReplyMarkupType string
const (
MessageReplyMarkupInline MessageReplyMarkupType = "inline"
MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard"
MessageReplyMarkupHide MessageReplyMarkupType = "hide"
MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply"
)
// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline
// 非空表示 0110 之前已经持久化的合法 inline keyboardKind 会将其解释为 inline。
type MessageReplyMarkup struct {
Inline [][]MarkupButton `json:"inline,omitempty"`
Type MessageReplyMarkupType `json:"type,omitempty"`
Inline [][]MarkupButton `json:"inline,omitempty"`
Keyboard [][]MarkupButton `json:"keyboard,omitempty"`
Resize bool `json:"resize,omitempty"`
SingleUse bool `json:"single_use,omitempty"`
Selective bool `json:"selective,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
}
// Kind 返回 markup constructor兼容已落库的无 Type inline 快照。
func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType {
if m == nil {
return ""
}
if m.Type != "" {
return m.Type
}
if len(m.Inline) > 0 {
return MessageReplyMarkupInline
}
return ""
}
// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。
func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool {
switch m.Kind() {
case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply:
return true
default:
return false
}
}
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool {
if m == nil {
return true
}
for _, row := range m.Inline {
if len(row) > 0 {
return false
switch m.Kind() {
case MessageReplyMarkupInline:
for _, row := range m.Inline {
if len(row) > 0 {
return false
}
}
return true
case MessageReplyMarkupKeyboard:
for _, row := range m.Keyboard {
if len(row) > 0 {
return false
}
}
return true
case MessageReplyMarkupHide, MessageReplyMarkupForceReply:
return false
default:
return true
}
return true
}
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮校验须先于落库I9
// 空 markup 合法(视为清空/无键盘)。
// ValidateReplyMarkup 校验 markup constructor、结构与按钮校验须先于落库I9
// 空 inline markup 合法(视为清空/无键盘)。
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
if m == nil {
return nil
}
if len(m.Inline) > MaxMarkupRows {
kind := m.Kind()
if kind == "" {
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return nil
}
switch kind {
case MessageReplyMarkupInline:
if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return validateMarkupRows(m.Inline, false)
case MessageReplyMarkupKeyboard:
if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
return ErrButtonInvalid
}
if len(m.Keyboard) == 0 {
return ErrButtonInvalid
}
return validateMarkupRows(m.Keyboard, true)
case MessageReplyMarkupHide:
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return nil
case MessageReplyMarkupForceReply:
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
return ErrButtonInvalid
}
return nil
default:
return ErrButtonInvalid
}
}
func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error {
if len(rows) > MaxMarkupRows {
return ErrButtonInvalid
}
total := 0
for _, row := range m.Inline {
if len(row) > MaxMarkupButtonsPerRow {
for _, row := range rows {
if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
return ErrButtonInvalid
}
total += len(row)
@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return ErrButtonInvalid
}
for i := range row {
if err := validateMarkupButton(row[i]); err != nil {
if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
return err
}
}
@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return nil
}
func validateMarkupButton(b MarkupButton) error {
func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
text := strings.TrimSpace(b.Text)
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
return ErrButtonInvalid
}
switch b.Style {
case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess:
default:
return ErrButtonInvalid
}
if b.IconCustomEmojiID < 0 {
return ErrButtonInvalid
}
if replyKeyboard {
if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen {
return ErrButtonInvalid
}
switch b.Type {
case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation:
case MarkupButtonRequestPoll:
if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" {
return ErrButtonInvalid
}
case MarkupButtonRequestPeer:
if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 ||
(b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") {
return ErrButtonInvalid
}
if b.RequestPeerFilter != nil {
filter := b.RequestPeerFilter
if b.RequestPeerType == "user" {
if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember ||
filter.UserAdminRights != nil || filter.BotAdminRights != nil {
return ErrButtonInvalid
}
} else {
if filter.UserIsBotSet || filter.UserIsPremiumSet ||
(b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) {
return ErrButtonInvalid
}
}
}
case MarkupButtonSimpleWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
}
default:
return ErrButtonTypeInvalid
}
if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" {
return ErrButtonInvalid
}
return nil
}
switch b.Type {
case MarkupButtonCallback:
if len(b.Data) > MaxCallbackDataLen {
@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error {
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonSwitchInline:
if utf8.RuneCountInString(b.Query) > 256 {
return ErrButtonInvalid
}
case MarkupButtonCopy:
if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 {
return ErrButtonInvalid
}
default:
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
return ErrButtonTypeInvalid

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 javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
{"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil},
{"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid},
{"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid},
{"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid},
{"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid},
{"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid},
{"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid},
{"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil},
{"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil},
{"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
{"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) {
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
t.Fatal("markup with a button must not be zero")
}
if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() {
t.Fatal("reply keyboard with a button must not be zero")
}
if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() {
t.Fatal("hide keyboard constructor must not be zero")
}
}

View file

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

View file

@ -8,7 +8,10 @@ import (
"time"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1}
@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) (
return r.enrichUpdateEvents(ctx, botID, diff.Events), nil
}
func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error {
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
return nil
}
return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed)
}
func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error {
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
return nil
}
return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID)
}
func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) {
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
return 0, nil
}
return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID)
}
func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
if !ok || botID <= 0 {
return true, nil
}
return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl)
}
func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error {
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
if !ok || botID <= 0 {
return nil
}
return leases.ReleaseBotAPIPollLease(ctx, botID, owner)
}
func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return errors.New("WEBHOOK_UNSUPPORTED")
}
return webhooks.SetBotAPIWebhook(ctx, config, dropPending)
}
func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return errors.New("WEBHOOK_UNSUPPORTED")
}
return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending)
}
func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return domain.BotAPIWebhook{}, false, nil
}
return webhooks.BotAPIWebhook(ctx, botID)
}
func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil, nil
}
return webhooks.ListDueBotAPIWebhooks(ctx, limit)
}
func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return false, nil
}
return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl)
}
func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil
}
return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner)
}
func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil
}
return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message)
}
func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error {
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
if !ok {
return nil
}
return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt)
}
func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error {
if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 {
return nil
}
return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID)
}
// BotAPISendMessage sends a text message as a bot through the normal private
// or channel message state machine. Positive chat_id is a user private chat;
// -1000000000000-channel_id is a supergroup/channel chat.
@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
if text == "" {
return domain.Message{}, errors.New("MESSAGE_EMPTY")
}
@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
if !ok {
return domain.Message{}, errors.New("CHAT_ID_INVALID")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
}
@ -400,6 +523,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
return self.Message, nil
}
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
return false, errors.New("BOT_INVALID")
}
if text == "" {
return false, errors.New("MESSAGE_EMPTY")
}
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
return false, errors.New("MESSAGE_TOO_LONG")
}
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
req := &tg.MessagesEditInlineBotMessageRequest{
ID: tgInputBotInlineMessageID(inlineMessageID),
NoWebpage: disableWebPagePreview,
}
req.SetMessage(text)
if len(entities) > 0 {
req.SetEntities(tgMessageEntities(entities))
}
if setReplyMarkup {
wire := tgReplyMarkup(replyMarkup)
if wire == nil {
wire = &tg.ReplyInlineMarkup{}
}
req.SetReplyMarkup(wire)
}
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
}
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
// the target user's MTProto clients observe the normal delete update.
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {
@ -440,12 +594,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal
if cacheTime < 0 {
cacheTime = 0
}
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{
resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{
Alert: showAlert,
Message: text,
URL: url,
CacheTime: cacheTime,
})
if resolveErr != nil {
return false, resolveErr
}
if !resolved {
return false, errors.New("QUERY_ID_INVALID")
}
return true, nil
}

View file

@ -2,11 +2,14 @@ package rpc
import (
"context"
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appbots "telesrv/internal/app/bots"
@ -17,6 +20,170 @@ import (
"telesrv/internal/store/memory"
)
func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
data := []byte("private-confirm")
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
}}}}
sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{
SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID,
RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup,
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
if _, err := fixture.router.resolveBotCallbackQuery(
fixture.ctx,
fixture.owner.ID,
domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID},
sent.RecipientMessage.ID,
[]byte("forged-callback-data"),
); !tgerr.Is(err, "DATA_INVALID") {
t.Fatalf("forged callback data err = %v, want DATA_INVALID", err)
}
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
defer cancel()
answerCh := make(chan struct {
answer *tg.MessagesBotCallbackAnswer
err error
}, 1)
go func() {
req := &tg.MessagesGetBotCallbackAnswerRequest{
Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash},
MsgID: sent.RecipientMessage.ID,
}
req.SetData(data)
answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
answerCh <- struct {
answer *tg.MessagesBotCallbackAnswer
err error
}{answer: answer, err: err}
}()
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out {
t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID)
}
callback := event.BotCallbackQuery
if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) ||
callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) {
t.Fatalf("callback = %+v", callback)
}
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok {
t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err)
}
select {
case result := <-answerCh:
if result.err != nil || result.answer == nil || result.answer.Message != "accepted" {
t.Fatalf("callback answer = %+v err=%v", result.answer, result.err)
}
case <-ctx.Done():
t.Fatal("callback answer did not unblock requester")
}
}
func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") {
t.Fatalf("unknown answer = ok=%v err=%v", ok, err)
}
item := domain.BotAPIUpdate{
ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1,
Date: 100,
Callback: &domain.BotCallbackQuery{
ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3,
},
}
if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok {
t.Fatal("callback at answer deadline remained deliverable")
}
}
func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) {
now := time.Unix(200, 0)
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988}
item := domain.BotAPIUpdate{
ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()),
Callback: &domain.BotCallbackQuery{
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99,
Data: []byte("inline"), InlineMessage: inline,
},
}
event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now)
if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) ||
event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline {
t.Fatalf("inline callback event=%#v ok=%v", event, ok)
}
}
func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) {
fixture := newBotAPIReceiveFixture(t, false)
data := []byte("group-confirm")
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
}}}}
sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{
UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002,
Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true,
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
defer cancel()
answerCh := make(chan error, 1)
go func() {
req := &tg.MessagesGetBotCallbackAnswerRequest{
Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash},
MsgID: sent.Message.ID,
}
req.SetData(data)
_, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
answerCh <- err
}()
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
callback := event.BotCallbackQuery
if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) ||
callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out {
t.Fatalf("group callback event = %+v", event)
}
if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil {
t.Fatalf("BotAPIAnswerCallbackQuery: %v", err)
}
select {
case err := <-answerCh:
if err != nil {
t.Fatalf("group callback answer: %v", err)
}
case <-ctx.Done():
t.Fatal("group callback answer did not unblock requester")
}
}
func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent {
t.Helper()
for {
events, err := router.BotAPIUpdates(ctx, botID, 0)
if err != nil {
t.Fatalf("BotAPIUpdates: %v", err)
}
for _, event := range events {
if event.Type == domain.UpdateEventBotCallbackQuery {
return event
}
}
select {
case <-ctx.Done():
t.Fatal("callback query did not reach Bot API queue")
case <-time.After(10 * time.Millisecond):
}
}
}
func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
}, zaptest.NewLogger(t), clock.System)
chatID := -botAPIChannelChatIDBase - created.Channel.ID
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0)
replyKeyboard := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
Resize: true,
}
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0)
if err != nil {
t.Fatalf("BotAPISendMessage: %v", err)
}
@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body {
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body ||
history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" {
t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
}
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {

View file

@ -2,11 +2,16 @@ package rpc
import (
"context"
"errors"
"time"
"telesrv/internal/domain"
)
const botAPIGetUpdatesLimit = 100
const (
botAPIGetUpdatesLimit = 100
botAPIMaxNegativeOffset = 10000
)
type botAPIChannelBotMemberProvider interface {
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
return nil, nil
}
fromID := int64(1)
if offset > 0 {
var items []domain.BotAPIUpdate
if offset < 0 {
if offset < -botAPIMaxNegativeOffset {
return nil, errors.New("OFFSET_INVALID")
}
var err error
items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit)
if err != nil {
return nil, err
}
if len(items) > 0 && items[0].ID > 1 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil {
return nil, err
}
}
} else if offset > 0 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil {
return nil, err
}
fromID = offset
} else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil {
return nil, err
} else if found {
fromID = confirmed + 1
}
items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
if err != nil {
return nil, err
if offset >= 0 {
confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID)
if err != nil {
return nil, err
}
if found {
fromID = confirmed + 1
}
items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
if err != nil {
return nil, err
}
}
if len(items) == 0 {
return nil, nil
}
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items)
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now())
if leadingSkipped > 0 {
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
return nil, err
@ -46,13 +70,16 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
return r.enrichUpdateEvents(ctx, botID, events), nil
}
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) {
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) {
privateIDs := make([]int, 0)
privateSeen := make(map[int]struct{})
channelIDs := make(map[int64][]int)
channelSeen := make(map[int64]map[int]struct{})
for _, item := range items {
if _, ok := botAPIQueuedUpdateKind(botID, item); !ok {
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
continue
}
if item.Callback != nil && item.Callback.InlineMessage != nil {
continue
}
switch item.Peer.Type {
@ -79,7 +106,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
events := make([]domain.UpdateEvent, 0, len(items))
leadingSkipped := int64(0)
for _, item := range items {
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages)
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now)
if !ok {
if len(events) == 0 {
leadingSkipped = item.ID
@ -101,7 +128,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i
}
out := make(map[int]domain.Message, len(list.Messages))
for _, msg := range list.Messages {
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) {
if msg.ID <= 0 || msg.OwnerUserID != botID {
continue
}
out[msg.ID] = msg
@ -127,10 +154,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
if msg.ID <= 0 || msg.Deleted || msg.Action != nil {
continue
}
projected := botAPIMessageFromChannel(botID, msg)
if projected.Out || !botAPIMessageProjectable(projected) {
continue
}
byID[msg.ID] = msg
}
if len(byID) > 0 {
@ -140,14 +163,37 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
return out
}
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) {
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 {
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) {
if item.ID <= 0 || item.BotUserID != botID {
return "", false
}
eventType, ok := botAPIUpdateEventType(item.Kind)
if !ok {
return "", false
}
if item.Kind == domain.BotAPIUpdateCallbackQuery {
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
return "", false
}
cb := item.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 ||
cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen {
return "", false
}
if cb.InlineMessage != nil {
inline := cb.InlineMessage
if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) ||
inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 {
return "", false
}
return eventType, true
}
if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID {
return "", false
}
} else if item.MessageID <= 0 {
return "", false
}
switch item.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
if item.Peer.ID <= 0 {
@ -159,17 +205,48 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat
return eventType, true
}
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) {
eventType, ok := botAPIQueuedUpdateKind(botID, item)
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) {
eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
if !ok {
return domain.UpdateEvent{}, false
}
if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
inline := *item.Callback.InlineMessage
callback.InlineMessage = &inline
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
BotCallbackQuery: &callback,
}, true
}
switch item.Peer.Type {
case domain.PeerTypeUser:
msg, found := privateMessages[item.MessageID]
if !found {
return domain.UpdateEvent{}, false
}
if eventType == domain.UpdateEventBotCallbackQuery {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
Peer: item.Peer,
Message: msg,
BotCallbackQuery: &callback,
}, true
}
if msg.Out || !botAPIMessageProjectable(msg) {
return domain.UpdateEvent{}, false
}
msg.Pts = int(item.ID)
return domain.UpdateEvent{
UserID: botID,
@ -186,6 +263,23 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
return domain.UpdateEvent{}, false
}
projected := botAPIMessageFromChannel(botID, msg)
if eventType == domain.UpdateEventBotCallbackQuery {
callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...)
return domain.UpdateEvent{
UserID: botID,
Type: eventType,
Pts: int(item.ID),
PtsCount: 1,
Date: item.Date,
Peer: item.Peer,
Message: projected,
BotCallbackQuery: &callback,
}, true
}
if projected.Out || !botAPIMessageProjectable(projected) {
return domain.UpdateEvent{}, false
}
projected.Pts = int(item.ID)
return domain.UpdateEvent{
UserID: botID,
@ -207,6 +301,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType
return domain.UpdateEventNewMessage, true
case domain.BotAPIUpdateEditedMessage:
return domain.UpdateEventEditMessage, true
case domain.BotAPIUpdateCallbackQuery:
return domain.UpdateEventBotCallbackQuery, true
default:
return "", false
}
@ -410,7 +506,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
return media.Photo != nil
case domain.MessageMediaKindDocument:
return media.Document != nil
case domain.MessageMediaKindContact:
return media.Contact != nil
case domain.MessageMediaKindGeo:
return media.Geo != nil
case domain.MessageMediaKindVenue:
return media.Venue != nil
case domain.MessageMediaKindPoll:
return media.Poll != nil
case domain.MessageMediaKindGeoLive:
return media.GeoLive != nil
case domain.MessageMediaKindService:
if media.ServiceAction == nil {
return false
}
switch media.ServiceAction.Kind {
case domain.MessageServiceActionWebViewDataSent:
return media.ServiceAction.WebViewData != nil
case domain.MessageServiceActionRequestedPeer:
return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer)
default:
return false
}
default:
return false
}
}
func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool {
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity {
return false
}
details := make(map[domain.Peer]struct{}, len(action.Details))
for _, detail := range action.Details {
if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) {
return false
}
details[detail.Peer] = struct{}{}
}
requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested
allUsers := true
for _, peer := range action.Peers {
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
return false
}
if requiresDetails {
if _, ok := details[peer]; !ok {
return false
}
}
allUsers = allUsers && peer.Type == domain.PeerTypeUser
}
return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel)
}

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
import (
"bytes"
"context"
"time"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap"
"telesrv/internal/domain"
)
@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery
// 推给 bot挂起等待 bot 的 setBotCallbackAnswer或超时回 BOT_RESPONSE_TIMEOUT。
type privateMessageByUIDService interface {
GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
}
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query
// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的
// setBotCallbackAnswer/answerCallbackQuery或超时回 BOT_RESPONSE_TIMEOUT。
func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
// callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。
if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) {
return nil, dataInvalidErr()
}
botUserID := peer.ID
// game 按钮getBotCallbackAnswer.gameP3 不支持:返回空答案(客户端不弹任何东西),
// 不挂起、不推送(避免给 bot 投递无法处理的 game query
if req.Game {
@ -46,32 +48,64 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
if !hasData {
return nil, dataInvalidErr()
}
if len(data) > domain.MaxCallbackDataLen {
return nil, dataInvalidErr()
}
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID)
callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data)
if err != nil {
return nil, err
}
botUserID := callback.BotUserID
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
if err != nil {
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))
return nil, internalErr()
}
if !ok || msg.Peer != peer {
return nil, messageIDInvalidErr()
defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID)
callback.ID = queryID
// Bot API callback_query shares the dedicated durable update_id queue with message and
// edited_message. The callback answer waiter itself remains ephemeral/process-local.
if r.deps.BotAPIUpdates != nil {
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: botUserID,
Kind: domain.BotAPIUpdateCallbackQuery,
Peer: callback.Peer,
MessageID: callback.MessageID,
Date: int(r.clock.Now().Unix()),
Callback: &callback,
}); err != nil {
r.log.Warn("enqueue bot api callback query",
zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
return nil, internalErr()
} else if created {
r.notifyBotAPIUpdate(botUserID)
}
}
queryID, pending := r.callbacks.register(botUserID, userID)
defer r.callbacks.deregister(queryID)
// updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference仅在线推给
// botbot 离线则投递 0但仍走超时窗口I5给 bot 上线追答机会)。
// MsgID 透传请求者侧的 box idP3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo
update := &tg.UpdateBotCallbackQuery{
QueryID: queryID,
UserID: userID,
Peer: &tg.PeerUser{UserID: userID},
MsgID: req.MsgID,
ChatInstance: chatInstanceFor(botUserID, userID),
// updateBotCallbackQuery 是 ephemeral无 pts/qts不进 getDifference私聊 MessageID
// 已翻译为 bot 视角 box idchannel 使用共享 message id。
var update tg.UpdateClass
if callback.InlineMessage != nil {
inline := &tg.UpdateInlineBotCallbackQuery{
QueryID: queryID, UserID: userID,
MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance,
}
inline.SetData(data)
update = inline
} else {
direct := &tg.UpdateBotCallbackQuery{
QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer),
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
}
direct.SetData(data)
update = direct
}
update.SetData(data)
r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
Updates: []tg.UpdateClass{update},
Date: int(r.clock.Now().Unix()),
@ -79,14 +113,149 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
defer cancel()
select {
case ans := <-pending.ch:
return tgBotCallbackAnswer(ans), nil
case <-waitCtx.Done():
return nil, botResponseTimeoutErr()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case ans := <-pending.ch:
return tgBotCallbackAnswer(ans), nil
case <-ticker.C:
ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID)
if err != nil {
r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
continue
}
if found {
return tgBotCallbackAnswer(ans), nil
}
case <-waitCtx.Done():
return nil, botResponseTimeoutErr()
}
}
}
// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message
// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline
// ids and therefore remain an explicit blocked path instead of being misrouted here.
func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) {
if peer.Type == domain.PeerTypeUser {
msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID)
if err != nil {
return domain.BotCallbackQuery{}, internalErr()
}
if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
if msg.ViaBotID != 0 {
if !r.userIsBot(ctx, msg.ViaBotID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
if !ok {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.ViaBotID, UserID: userID,
ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...),
InlineMessage: domainInlineMessageID(inlineID),
}, nil
}
if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
provider, ok := r.deps.Messages.(privateMessageByUIDService)
if !ok || msg.UID == 0 {
return domain.BotCallbackQuery{}, internalErr()
}
botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID)
if err != nil {
return domain.BotCallbackQuery{}, internalErr()
}
if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID ||
botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.From.ID,
UserID: userID,
Peer: botMessage.Peer,
MessageID: botMessage.ID,
ChatInstance: chatInstanceFor(msg.From.ID, userID),
Data: append([]byte(nil), data...),
}, nil
}
if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
return domain.BotCallbackQuery{}, peerIDInvalidErr()
}
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID})
if err != nil {
return domain.BotCallbackQuery{}, channelInvalidErr(err)
}
if len(history.Messages) != 1 {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
msg := history.Messages[0]
if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
if msg.ViaBotID != 0 {
if !r.userIsBot(ctx, msg.ViaBotID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
if !ok {
return domain.BotCallbackQuery{}, messageIDInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.ViaBotID, UserID: userID,
ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...),
InlineMessage: domainInlineMessageID(inlineID),
}, nil
}
if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) {
return domain.BotCallbackQuery{}, dataInvalidErr()
}
return domain.BotCallbackQuery{
BotUserID: msg.SenderUserID,
UserID: userID,
Peer: peer,
MessageID: msg.ID,
ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer),
Data: append([]byte(nil), data...),
}, nil
}
func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID {
if id == nil {
return nil
}
return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
}
func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass {
return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
}
func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool {
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
return false
}
for _, row := range markup.Inline {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
return true
}
}
}
return false
}
// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
// getBotCallbackAnswer。仅属主 bot 可解挂callerBotID==pending.botUserIDI6
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) {
@ -106,7 +275,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes
}
// resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
// 被等待者接收无关紧要(官方恒返回 true但非属主必须拒绝投递防钓鱼弹窗
r.callbacks.resolve(botID, req.QueryID, ans)
if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil {
return false, internalErr()
}
return true, nil
}

View file

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

View file

@ -1,22 +1,29 @@
package rpc
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"hash/fnv"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// callbackRegistry 是 bot callback query 的进程内挂起表messages.getBotCallbackAnswer
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待bot 经
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道
// getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。
// callbackRegistry keeps local waiter channels and mirrors ownership/answers to
// a short-lived shared store. The shared CAS is the source of truth when wired:
// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
// without accepting two answers or trusting a process-local owner map.
type callbackRegistry struct {
mu sync.Mutex
pending map[int64]*pendingCallback
shared store.BotCallbackRegistryStore
}
type pendingCallback struct {
@ -26,35 +33,77 @@ type pendingCallback struct {
userID int64
}
func newCallbackRegistry() *callbackRegistry {
return &callbackRegistry{pending: make(map[int64]*pendingCallback)}
func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
var sharedStore store.BotCallbackRegistryStore
if len(shared) > 0 {
sharedStore = shared[0]
}
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
}
// register 登记一次挂起的 callback返回全局唯一 query_id 与接收通道。调用方必须
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
p := &pendingCallback{
ch: make(chan domain.BotCallbackAnswer, 1),
done: make(chan struct{}),
botUserID: botUserID,
userID: userID,
}
c.mu.Lock()
defer c.mu.Unlock()
var queryID int64
for {
queryID = randomNonZeroInt64()
if _, exists := c.pending[queryID]; !exists {
break
queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
return queryID, pending
}
func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) {
for attempts := 0; attempts < 32; attempts++ {
p := &pendingCallback{
ch: make(chan domain.BotCallbackAnswer, 1),
done: make(chan struct{}),
botUserID: botUserID,
userID: userID,
}
c.mu.Lock()
queryID := randomNonZeroInt64()
if _, exists := c.pending[queryID]; exists {
c.mu.Unlock()
continue
}
c.pending[queryID] = p
c.mu.Unlock()
if c.shared == nil {
return queryID, p, nil
}
created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{
QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now,
}, ttl)
if err != nil {
c.removeLocal(queryID)
return 0, nil, err
}
if created {
return queryID, p, nil
}
c.removeLocal(queryID)
}
c.pending[queryID] = p
return queryID, p
return 0, nil, fmt.Errorf("allocate bot callback query id")
}
// deregister 移除挂起条目并关闭 done超时/解挂后必调,幂等)。关闭 done 让仍在
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 chTOCTOU
func (c *callbackRegistry) deregister(queryID int64) {
c.deregisterContext(context.Background(), 0, queryID)
}
func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
if botUserID == 0 {
botUserID = p.botUserID
}
delete(c.pending, queryID)
close(p.done)
}
c.mu.Unlock()
if c.shared != nil && botUserID > 0 {
_ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID)
}
}
func (c *callbackRegistry) removeLocal(queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
delete(c.pending, queryID)
@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int {
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂callerBotID
// 必须等于注册时的 botUserIDI6。返回是否成功投递query 未注册/已超时/非属主 → false
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans)
return resolved
}
func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) {
if c.shared != nil {
resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans)
if err != nil || !resolved {
return resolved, err
}
c.deliver(callerBotID, queryID, ans)
return true, nil
}
return c.deliver(callerBotID, queryID, ans), nil
}
func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
c.mu.Lock()
p, ok := c.pending[queryID]
if !ok || p.botUserID != callerBotID {
@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal
return true
}
func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
if c.shared == nil {
return domain.BotCallbackAnswer{}, false, nil
}
return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID)
}
func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) {
if r == nil || r.callbacks == nil || r.callbacks.shared == nil {
return
}
for ctx.Err() == nil {
err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer)
})
if ctx.Err() != nil {
return
}
if err != nil && r.log != nil {
r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err))
}
timer := time.NewTimer(time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
// 无限重试——熵源异常时退化为单调序列兜底query_id 只需进程内唯一register 的
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 {
}
return v
}
// chatInstanceForPeer extends the stable hash to non-private chats without allowing a
// channel id to collide with a numerically equal private user id.
func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 {
h := fnv.New64a()
var buf [17]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID))
switch peer.Type {
case domain.PeerTypeChannel:
buf[16] = 2
default:
buf[16] = 1
}
_, _ = h.Write(buf[:])
v := int64(h.Sum64())
if v == 0 {
return 1
}
return v
}

View file

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

View file

@ -1,6 +1,7 @@
package rpc
import (
"context"
"errors"
"github.com/iamxvbaba/td/tg"
@ -9,9 +10,30 @@ import (
"telesrv/internal/domain"
)
// validateReplyMarkupForPeer enforces the Bot API/TL boundary that reply keyboards control
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
// valid in both megagroups and broadcasts.
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
return nil
}
if r == nil || r.deps.Channels == nil {
return channelInvalidErr(domain.ErrChannelInvalid)
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return channelInvalidErr(err)
}
if view.Channel.Broadcast && !view.Channel.Megagroup {
return replyMarkupInvalidErr()
}
return nil
}
// P3 reply_markup 错误码(对齐官方)。
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
func buttonTypeInvalidErr() error { return tgerr.New(400, "BUTTON_TYPE_INVALID") }
func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") }
// replyMarkupErr 把 domain 校验错误映射为客户端错误码。
@ -21,18 +43,21 @@ func replyMarkupErr(err error) error {
return buttonDataInvalidErr()
case errors.Is(err, domain.ErrButtonURLInvalid):
return buttonURLInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid):
case errors.Is(err, domain.ErrButtonTypeInvalid):
return buttonTypeInvalidErr()
case errors.Is(err, domain.ErrButtonInvalid):
return buttonInvalidErr()
default:
return replyMarkupInvalidErr()
}
}
// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义:
// domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markupinline
// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。
// 语义:
// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil不报错——对齐
// 官方「普通用户 markup 无效」I1
// - 仅 ReplyInlineMarkup 被处理reply keyboard 家族(自定义键盘/隐藏/强制回复)
// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4
// - 仅 ReplyInlineMarkup 被处理bot 的 reply keyboard 家族在这些上下文中显式拒绝。
// - inline 行内按钮仅 callback / url其它按钮类型webview/game/url_auth/
// request_* 等)→ ErrButtonTypeInvalid拒绝整条发送绝不半实现下发
// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。
@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
}
inline, ok := markup.(*tg.ReplyInlineMarkup)
if !ok {
// reply keyboard / hide / force-replyP3 不支持,丢弃。
return nil, nil
return nil, domain.ErrButtonTypeInvalid
}
parsed, err := domainInlineMarkup(inline)
if err != nil {
@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
return parsed, nil
}
// domainOutgoingReplyMarkupForSender 解析普通 sendMessage/sendMedia 的完整 reply markup。
// 非 bot 携带 markup 仍按官方权限边界静默丢弃bot 的未知/未实现按钮则拒绝整条消息,
// 避免客户端看到一个被服务端悄悄改形的键盘。
func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) {
if markup == nil || !senderIsBot {
return nil, nil
}
switch v := markup.(type) {
case *tg.ReplyInlineMarkup:
return domainReplyMarkupForSender(v, true)
case *tg.ReplyKeyboardMarkup:
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupKeyboard,
Keyboard: make([][]domain.MarkupButton, 0, len(v.Rows)),
Resize: v.Resize,
SingleUse: v.SingleUse,
Selective: v.Selective,
Persistent: v.Persistent,
Placeholder: v.Placeholder,
}
for _, row := range v.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, button := range row.Buttons {
parsed, err := domainReplyKeyboardButton(button)
if err != nil {
return nil, err
}
domainRow = append(domainRow, parsed)
}
out.Keyboard = append(out.Keyboard, domainRow)
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
case *tg.ReplyKeyboardHide:
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: v.Selective}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
case *tg.ReplyKeyboardForceReply:
out := &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupForceReply,
SingleUse: v.SingleUse,
Selective: v.Selective,
Placeholder: v.Placeholder,
}
if err := domain.ValidateReplyMarkup(out); err != nil {
return nil, err
}
return out, nil
default:
return nil, domain.ErrButtonTypeInvalid
}
}
func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(button)
if err != nil {
return domain.MarkupButton{}, err
}
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon}
switch b := button.(type) {
case *tg.KeyboardButton:
base.Type, base.Text = domain.MarkupButtonText, b.Text
case *tg.KeyboardButtonRequestPhone:
base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text
case *tg.KeyboardButtonRequestGeoLocation:
base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text
case *tg.KeyboardButtonRequestPoll:
base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text
if quiz, ok := b.GetQuiz(); ok {
if quiz {
base.PollType = "quiz"
} else {
base.PollType = "regular"
}
}
case *tg.KeyboardButtonRequestPeer:
base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text
base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType)
case *tg.KeyboardButtonSimpleWebView:
base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL
default:
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
return base, nil
}
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
for _, row := range inline.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, btn := range row.Buttons {
@ -75,31 +190,119 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku
}
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(btn)
if err != nil {
return domain.MarkupButton{}, err
}
switch b := btn.(type) {
case *tg.KeyboardButtonCallback:
return domain.MarkupButton{
Type: domain.MarkupButtonCallback,
Text: b.Text,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
Type: domain.MarkupButtonCallback,
Text: b.Text,
Style: style,
IconCustomEmojiID: icon,
Data: append([]byte(nil), b.Data...),
RequiresPassword: b.RequiresPassword,
}, nil
case *tg.KeyboardButtonURL:
return domain.MarkupButton{
Type: domain.MarkupButtonURL,
Text: b.Text,
URL: b.URL,
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonWebView:
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonSwitchInline:
peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes)
if err != nil {
return domain.MarkupButton{}, domain.ErrButtonInvalid
}
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonCopy:
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
default:
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
}
}
// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。
func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) {
style, ok := btn.GetStyle()
if !ok {
return "", 0, nil
}
colors := 0
var out domain.MarkupButtonStyle
if style.GetBgPrimary() {
colors++
out = domain.MarkupButtonStylePrimary
}
if style.GetBgDanger() {
colors++
out = domain.MarkupButtonStyleDanger
}
if style.GetBgSuccess() {
colors++
out = domain.MarkupButtonStyleSuccess
}
icon, hasIcon := style.GetIcon()
if colors > 1 || (hasIcon && icon <= 0) || (colors == 0 && !hasIcon) {
return "", 0, domain.ErrButtonInvalid
}
return out, icon, nil
}
func tgMarkupButtonStyle(btn domain.MarkupButton) (tg.KeyboardButtonStyle, bool) {
var out tg.KeyboardButtonStyle
switch btn.Style {
case domain.MarkupButtonStylePrimary:
out.SetBgPrimary(true)
case domain.MarkupButtonStyleDanger:
out.SetBgDanger(true)
case domain.MarkupButtonStyleSuccess:
out.SetBgSuccess(true)
}
if btn.IconCustomEmojiID > 0 {
out.SetIcon(btn.IconCustomEmojiID)
}
return out, btn.Style != "" || btn.IconCustomEmojiID > 0
}
// tgReplyMarkup 把存储的协议中立快照还原为对应 ReplyMarkup constructor。
func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
if m.IsZero() {
return nil
}
switch m.Kind() {
case domain.MessageReplyMarkupKeyboard:
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
for _, row := range m.Keyboard {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
for _, btn := range row {
buttons = append(buttons, tgReplyKeyboardButton(btn))
}
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
}
return &tg.ReplyKeyboardMarkup{
Resize: m.Resize,
SingleUse: m.SingleUse,
Selective: m.Selective,
Persistent: m.Persistent,
Rows: rows,
Placeholder: m.Placeholder,
}
case domain.MessageReplyMarkupHide:
return &tg.ReplyKeyboardHide{Selective: m.Selective}
case domain.MessageReplyMarkupForceReply:
return &tg.ReplyKeyboardForceReply{
SingleUse: m.SingleUse,
Selective: m.Selective,
Placeholder: m.Placeholder,
}
case domain.MessageReplyMarkupInline:
// Continue below.
default:
return nil
}
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
for _, row := range m.Inline {
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
@ -114,12 +317,202 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
switch btn.Type {
case domain.MarkupButtonURL:
return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonWebView:
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonSwitchInline:
out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer}
if len(btn.PeerTypes) > 0 {
out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonCopy:
out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
default: // callback
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
if btn.RequiresPassword {
out.SetRequiresPassword(true)
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
}
}
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
var out tg.KeyboardButtonClass
switch btn.Type {
case domain.MarkupButtonRequestPhone:
out = &tg.KeyboardButtonRequestPhone{Text: btn.Text}
case domain.MarkupButtonRequestLocation:
out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text}
case domain.MarkupButtonRequestPoll:
button := &tg.KeyboardButtonRequestPoll{Text: btn.Text}
if btn.PollType == "quiz" {
button.SetQuiz(true)
} else if btn.PollType == "regular" {
button.SetQuiz(false)
}
out = button
case domain.MarkupButtonRequestPeer:
out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
case domain.MarkupButtonSimpleWebView:
out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL}
default:
out = &tg.KeyboardButton{Text: btn.Text}
}
if style, ok := tgMarkupButtonStyle(btn); ok {
if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok {
setter.SetStyle(style)
}
}
return out
}
func domainRequestPeerFilter(peerType tg.RequestPeerTypeClass) (string, *domain.BotRequestPeerFilter) {
filter := &domain.BotRequestPeerFilter{}
switch v := peerType.(type) {
case *tg.RequestPeerTypeUser:
if value, ok := v.GetBot(); ok {
filter.UserIsBotSet, filter.UserIsBot = true, value
}
if value, ok := v.GetPremium(); ok {
filter.UserIsPremiumSet, filter.UserIsPremium = true, value
}
if !filter.UserIsBotSet && !filter.UserIsPremiumSet {
return "user", nil
}
return "user", filter
case *tg.RequestPeerTypeChat:
filter.ChatIsCreated, filter.BotIsMember = v.Creator, v.BotParticipant
if value, ok := v.GetHasUsername(); ok {
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
}
if value, ok := v.GetForum(); ok {
filter.ChatIsForumSet, filter.ChatIsForum = true, value
}
if rights, ok := v.GetUserAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.UserAdminRights = &mapped
}
if rights, ok := v.GetBotAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.BotAdminRights = &mapped
}
if botRequestPeerFilterZero(filter) {
return "chat", nil
}
return "chat", filter
case *tg.RequestPeerTypeBroadcast:
filter.ChatIsCreated = v.Creator
if value, ok := v.GetHasUsername(); ok {
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
}
if rights, ok := v.GetUserAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.UserAdminRights = &mapped
}
if rights, ok := v.GetBotAdminRights(); ok {
mapped := domainBotRequestAdminRights(rights)
filter.BotAdminRights = &mapped
}
if botRequestPeerFilterZero(filter) {
return "broadcast", nil
}
return "broadcast", filter
default:
return "", nil
}
}
func botRequestPeerFilterZero(filter *domain.BotRequestPeerFilter) bool {
return filter == nil || (!filter.UserIsBotSet && !filter.UserIsPremiumSet && !filter.ChatHasUsernameSet &&
!filter.ChatIsForumSet && !filter.ChatIsCreated && !filter.BotIsMember && filter.UserAdminRights == nil && filter.BotAdminRights == nil)
}
func tgRequestPeerTypeWithFilter(kind string, filter *domain.BotRequestPeerFilter) tg.RequestPeerTypeClass {
switch kind {
case "chat":
out := &tg.RequestPeerTypeChat{}
if filter != nil {
out.Creator, out.BotParticipant = filter.ChatIsCreated, filter.BotIsMember
if filter.ChatHasUsernameSet {
out.SetHasUsername(filter.ChatHasUsername)
}
if filter.ChatIsForumSet {
out.SetForum(filter.ChatIsForum)
}
if filter.UserAdminRights != nil {
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
}
if filter.BotAdminRights != nil {
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
}
}
return out
case "broadcast":
out := &tg.RequestPeerTypeBroadcast{}
if filter != nil {
out.Creator = filter.ChatIsCreated
if filter.ChatHasUsernameSet {
out.SetHasUsername(filter.ChatHasUsername)
}
if filter.UserAdminRights != nil {
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
}
if filter.BotAdminRights != nil {
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
}
}
return out
default:
out := &tg.RequestPeerTypeUser{}
if filter != nil {
if filter.UserIsBotSet {
out.SetBot(filter.UserIsBot)
}
if filter.UserIsPremiumSet {
out.SetPremium(filter.UserIsPremium)
}
}
return out
}
}
func domainBotRequestAdminRights(rights tg.ChatAdminRights) domain.BotRequestAdminRights {
return domain.BotRequestAdminRights{
Anonymous: rights.Anonymous, ManageChat: rights.Other, DeleteMessages: rights.DeleteMessages,
ManageVideoChats: rights.ManageCall, RestrictMembers: rights.BanUsers, PromoteMembers: rights.AddAdmins,
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
ManageDirectMessages: rights.ManageDirectMessages,
}
}
func tgBotRequestAdminRights(rights domain.BotRequestAdminRights) tg.ChatAdminRights {
return tg.ChatAdminRights{
Anonymous: rights.Anonymous, Other: rights.ManageChat, DeleteMessages: rights.DeleteMessages,
ManageCall: rights.ManageVideoChats, BanUsers: rights.RestrictMembers, AddAdmins: rights.PromoteMembers,
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
ManageDirectMessages: rights.ManageDirectMessages,
}
}

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)
}
}
// reply_markupbot inline keyboard仅普通 tg.Message 携带service 消息不带)。
// reply_markupbot reply/inline keyboard仅普通 tg.Message 携带service 消息不带)。
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
msg.SetReplyMarkup(markup)
}
@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
if msg.Out {
return &tg.MessageActionRequestedPeerSentMe{
ButtonID: shared.ButtonID,
Peers: tgRequestedPeers(shared.Peers),
Peers: tgRequestedPeers(shared),
}
}
return &tg.MessageActionRequestedPeer{
@ -294,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass {
return out
}
func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass {
out := make([]tg.RequestedPeerClass, 0, len(peers))
for _, peer := range peers {
func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass {
if action == nil {
return nil
}
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
for _, detail := range action.Details {
details[detail.Peer] = detail
}
out := make([]tg.RequestedPeerClass, 0, len(action.Peers))
for _, peer := range action.Peers {
detail := details[peer]
switch peer.Type {
case domain.PeerTypeUser:
out = append(out, &tg.RequestedPeerUser{UserID: peer.ID})
item := &tg.RequestedPeerUser{UserID: peer.ID}
if action.NameRequested {
item.SetFirstName(detail.FirstName)
item.SetLastName(detail.LastName)
}
if action.UsernameRequested {
item.SetUsername(detail.Username)
}
if action.PhotoRequested && detail.Photo != nil {
item.SetPhoto(tgPhoto(*detail.Photo))
}
out = append(out, item)
case domain.PeerTypeChannel:
out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID})
item := &tg.RequestedPeerChannel{ChannelID: peer.ID}
if action.NameRequested {
item.SetTitle(detail.Title)
}
if action.UsernameRequested {
item.SetUsername(detail.Username)
}
if action.PhotoRequested && detail.Photo != nil {
item.SetPhoto(tgPhoto(*detail.Photo))
}
out = append(out, item)
}
}
return out

View file

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

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"fmt"
"strconv"
"strings"
"unicode/utf8"
@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
if err != nil {
return nil, messageSendErr(err)
}
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if !found || !botUser.Bot {
return nil, botInvalidErr()
}
webAppReqID, ok := req.GetWebappReqID()
if !ok || webAppReqID == "" {
return nil, buttonDataInvalidErr()
}
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
if err != nil {
return nil, internalErr()
}
if !found || button.ButtonID != req.ButtonID {
return nil, buttonDataInvalidErr()
webAppReqID, fromWebApp := req.GetWebappReqID()
idempotencyKey := webAppReqID
var button domain.BotRequestedWebViewButton
if fromWebApp {
if webAppReqID == "" {
return nil, buttonDataInvalidErr()
}
var found bool
button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
if err != nil {
return nil, internalErr()
}
if !found || button.ButtonID != req.ButtonID {
return nil, buttonDataInvalidErr()
}
} else {
idempotencyKey = "message:" + strconv.Itoa(req.MsgID)
button, err = r.requestPeerButtonFromMessage(ctx, userID, botUser.ID, req.MsgID, req.ButtonID)
if err != nil {
return nil, err
}
}
if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity {
return nil, buttonDataInvalidErr()
@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, err
}
if !requestedPeerTypeMatches(button.PeerType, resolved) {
if matches, err := r.requestedPeerMatches(ctx, userID, botUser.ID, button, resolved); err != nil {
return nil, internalErr()
} else if !matches {
return nil, buttonDataInvalidErr()
}
peers = append(peers, resolved)
}
details, err := r.requestedPeerDetails(ctx, userID, peers, button)
if err != nil {
return nil, internalErr()
}
recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID)
if err != nil {
return nil, err
@ -134,14 +153,18 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
SenderUserID: userID,
RecipientUserID: botUser.ID,
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers),
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers),
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: button.ButtonID,
Peers: peers,
ButtonID: button.ButtonID,
Peers: peers,
Details: details,
NameRequested: button.NameRequested,
UsernameRequested: button.UsernameRequested,
PhotoRequested: button.PhotoRequested,
},
},
},
@ -153,7 +176,10 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
if err != nil {
return nil, internalErr()
}
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
if fromWebApp {
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
}
var users []tg.UserClass
var chats []tg.ChatClass
if !res.Duplicate {
@ -163,6 +189,128 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil
}
type requestedPeerPhotoProvider interface {
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
}
func (r *Router) requestedPeerDetails(ctx context.Context, viewerUserID int64, peers []domain.Peer, button domain.BotRequestedWebViewButton) ([]domain.MessageRequestedPeerDetails, error) {
details := make([]domain.MessageRequestedPeerDetails, len(peers))
for i, peer := range peers {
details[i].Peer = peer
}
if !button.NameRequested && !button.UsernameRequested && !button.PhotoRequested {
return details, nil
}
userIDs := make(map[int64]struct{})
channelIDs := make(map[int64]struct{})
for _, peer := range peers {
addDomainPeerRef(peer, 0, userIDs, channelIDs)
}
cache := newViewerPeerCache(r)
users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))
channels := cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))
userByID := make(map[int64]domain.User, len(users))
channelByID := make(map[int64]domain.Channel, len(channels))
photoIDs := make([]int64, 0, len(peers))
for _, user := range users {
userByID[user.ID] = user
if button.PhotoRequested && user.PhotoID != 0 {
photoIDs = append(photoIDs, user.PhotoID)
}
}
for _, channel := range channels {
channelByID[channel.ID] = channel
if button.PhotoRequested && channel.PhotoID != 0 {
photoIDs = append(photoIDs, channel.PhotoID)
}
}
photoByID := make(map[int64]domain.Photo, len(photoIDs))
if len(photoIDs) > 0 {
provider, ok := r.deps.Files.(requestedPeerPhotoProvider)
if !ok {
return nil, fmt.Errorf("requested peer photo provider unavailable")
}
photos, err := provider.GetPhotos(ctx, photoIDs)
if err != nil {
return nil, err
}
for _, photo := range photos {
photoByID[photo.ID] = photo
}
}
for i, peer := range peers {
detail := &details[i]
switch peer.Type {
case domain.PeerTypeUser:
user, ok := userByID[peer.ID]
if !ok {
return nil, fmt.Errorf("requested user %d not hydrated", peer.ID)
}
if button.NameRequested {
detail.FirstName, detail.LastName = user.FirstName, user.LastName
}
if button.UsernameRequested {
detail.Username = user.Username
}
if button.PhotoRequested && user.PhotoID != 0 {
photo, ok := photoByID[user.PhotoID]
if !ok {
return nil, fmt.Errorf("requested user photo %d missing", user.PhotoID)
}
detail.Photo = &photo
}
case domain.PeerTypeChannel:
channel, ok := channelByID[peer.ID]
if !ok {
return nil, fmt.Errorf("requested channel %d not hydrated", peer.ID)
}
if button.NameRequested {
detail.Title = channel.Title
}
if button.UsernameRequested {
detail.Username = channel.Username
}
if button.PhotoRequested && channel.PhotoID != 0 {
photo, ok := photoByID[channel.PhotoID]
if !ok {
return nil, fmt.Errorf("requested channel photo %d missing", channel.PhotoID)
}
detail.Photo = &photo
}
}
}
return details, nil
}
func (r *Router) requestPeerButtonFromMessage(ctx context.Context, userID, botUserID int64, messageID, buttonID int) (domain.BotRequestedWebViewButton, error) {
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID == 0 {
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
if err != nil {
return domain.BotRequestedWebViewButton{}, internalErr()
}
if !found || message.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) ||
message.From != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || message.ReplyMarkup == nil ||
message.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard {
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
for _, row := range message.ReplyMarkup.Keyboard {
for _, item := range row {
if item.Type != domain.MarkupButtonRequestPeer || item.ButtonID != buttonID {
continue
}
return domain.BotRequestedWebViewButton{
BotUserID: botUserID, UserID: userID, ButtonID: item.ButtonID,
PeerType: item.RequestPeerType, MaxQuantity: item.MaxQuantity, PeerFilter: item.RequestPeerFilter,
NameRequested: item.NameRequested, UsernameRequested: item.UsernameRequested,
PhotoRequested: item.PhotoRequested,
}, nil
}
}
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
}
func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
switch kind {
case "user", "":
@ -174,6 +322,97 @@ func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
}
}
func (r *Router) requestedPeerMatches(ctx context.Context, userID, botUserID int64, button domain.BotRequestedWebViewButton, peer domain.Peer) (bool, error) {
if !requestedPeerTypeMatches(button.PeerType, peer) {
return false, nil
}
filter := button.PeerFilter
if filter == nil {
return true, nil
}
if peer.Type == domain.PeerTypeUser {
if r.deps.Users == nil {
return false, nil
}
user, found, err := r.deps.Users.ByID(ctx, userID, peer.ID)
if err != nil || !found {
return false, err
}
if filter.UserIsBotSet && user.Bot != filter.UserIsBot {
return false, nil
}
if filter.UserIsPremiumSet && user.PremiumActiveAt(r.clock.Now().Unix()) != filter.UserIsPremium {
return false, nil
}
return true, nil
}
if r.deps.Channels == nil {
return false, nil
}
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
if err != nil {
return false, err
}
channel := view.Channel
if button.PeerType == "chat" && (!channel.Megagroup || channel.Broadcast) {
return false, nil
}
if button.PeerType == "broadcast" && !channel.Broadcast {
return false, nil
}
if filter.ChatHasUsernameSet && (channel.Username != "") != filter.ChatHasUsername {
return false, nil
}
if filter.ChatIsForumSet && channel.Forum != filter.ChatIsForum {
return false, nil
}
if filter.ChatIsCreated && view.Self.Role != domain.ChannelRoleCreator {
return false, nil
}
if filter.UserAdminRights != nil && !channelMemberHasRequestRights(view.Self, *filter.UserAdminRights) {
return false, nil
}
if filter.BotIsMember || filter.BotAdminRights != nil {
botMember, err := r.deps.Channels.GetParticipant(ctx, userID, peer.ID, botUserID)
if err != nil {
return false, err
}
if botMember.Status != domain.ChannelMemberActive {
return false, nil
}
if filter.BotAdminRights != nil && !channelMemberHasRequestRights(botMember, *filter.BotAdminRights) {
return false, nil
}
}
return true, nil
}
func channelMemberHasRequestRights(member domain.ChannelMember, required domain.BotRequestAdminRights) bool {
if member.Role == domain.ChannelRoleCreator {
return true
}
if member.Role != domain.ChannelRoleAdmin {
return false
}
rights := member.AdminRights
return (!required.Anonymous || rights.Anonymous) &&
(!required.ManageChat || rights.ManageChat) &&
(!required.DeleteMessages || rights.DeleteMessages) &&
(!required.ManageVideoChats || rights.ManageCall) &&
(!required.RestrictMembers || rights.BanUsers) &&
(!required.PromoteMembers || rights.AddAdmins) &&
(!required.ChangeInfo || rights.ChangeInfo) &&
(!required.InviteUsers || rights.InviteUsers) &&
(!required.PostStories || rights.PostStories) &&
(!required.EditStories || rights.EditStories) &&
(!required.DeleteStories || rights.DeleteStories) &&
(!required.PostMessages || rights.PostMessages) &&
(!required.EditMessages || rights.EditMessages) &&
(!required.PinMessages || rights.PinMessages) &&
(!required.ManageTopics || rights.ManageTopics) &&
(!required.ManageDirectMessages || rights.ManageDirectMessages)
}
func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 {
parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)}
for _, peer := range peers {

View file

@ -11,11 +11,13 @@ import (
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
ownerCtx := WithUserID(ctx, f.owner.ID)
updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
@ -46,6 +48,13 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID {
t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID)
}
botAPIEvents, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
if err != nil || len(botAPIEvents) != 1 || botAPIEvents[0].Message.Media == nil ||
botAPIEvents[0].Message.Media.ServiceAction == nil ||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData == nil ||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData.Data != `{"ok":true}` {
t.Fatalf("bot api webview events=%#v err=%v", botAPIEvents, err)
}
botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{
HasPeer: true,
@ -140,6 +149,41 @@ func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing
}
}
func TestMessagesSendBotRequestedPeerQueuesBotAPIResponse(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
ownerCtx := WithUserID(ctx, f.owner.ID)
button := domain.MarkupButton{
Type: domain.MarkupButtonRequestPeer, Text: "Share user", ButtonID: 77,
RequestPeerType: "user", MaxQuantity: 1, NameRequested: true, UsernameRequested: true,
}
requestMessage, err := f.router.deps.Messages.SendPrivateText(ctx, f.bot.ID, domain.SendPrivateTextRequest{
SenderUserID: f.bot.ID, RecipientUserID: f.owner.ID, RandomID: 7001, Message: "Choose",
ReplyMarkup: &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupKeyboard, Keyboard: [][]domain.MarkupButton{{button}}},
Date: 1700000100,
})
if err != nil {
t.Fatalf("send request message: %v", err)
}
if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{
Peer: inputPeerUser(f.bot), MsgID: requestMessage.RecipientMessage.ID, ButtonID: button.ButtonID,
RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)},
}); err != nil {
t.Fatalf("send requested peer: %v", err)
}
events, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
if err != nil || len(events) != 1 {
t.Fatalf("bot api requested-peer events=%#v err=%v", events, err)
}
action := events[0].Message.Media.ServiceAction.RequestedPeer
if action == nil || action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0].ID != f.peer.ID ||
len(action.Details) != 1 || action.Details[0].Peer != action.Peers[0] || action.Details[0].FirstName != f.peer.FirstName ||
!action.NameRequested || !action.UsernameRequested {
t.Fatalf("requested-peer action=%#v", action)
}
}
func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) {
ctx := context.Background()
f := newInlineBotRPCTestFixture(t)

View file

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

View file

@ -93,7 +93,7 @@ type Config struct {
// 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 本身只负责协议外壳:
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。
type Router struct {
@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
if instanceID == "" {
instanceID = fmt.Sprintf("%016x", randomNonZeroInt64())
}
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)

View file

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

View file

@ -45,6 +45,9 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
addDomainPeerRef(peer, 0, userIDs, channelIDs)
}
collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs)
if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 {
userIDs[out[i].BotCallbackQuery.UserID] = struct{}{}
}
removeKnownChannelRefs(channelIDs, out[i].Channels)
refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs}
for id := range userIDs {
@ -220,6 +223,11 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
userIDs[msg.Media.Contact.UserID] = struct{}{}
}
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil {
for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers {
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
}
}
collectPollMediaUserRefs(msg.Media, userIDs)
collectTodoMediaUserRefs(msg.Media, userIDs)
if msg.Reactions != nil {

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 (
"context"
"time"
"telesrv/internal/domain"
)
// BotAPIPollLeaseStore serializes getUpdates across all HTTP gateway
// instances. owner is an opaque per-request token; Release must be compare-and-
// delete so a stale request cannot release a successor's lease.
type BotAPIPollLeaseStore interface {
AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error)
ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error
}
// BotAPIWebhookStore coordinates durable webhook configuration and delivery
// leases. Delivery itself stays at the HTTP edge; this store only owns state.
type BotAPIWebhookStore interface {
SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error
DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error
BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error)
ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error)
AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error)
ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error
RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error
RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error
}
// BotAPIUpdateStore persists update_id based Bot API delivery queues.
type BotAPIUpdateStore interface {
EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error)
ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error)
ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error)
ConfirmBotAPIUpdates(ctx context.Context, botUserID, confirmedUpdateID int64) error
ConfirmedBotAPIUpdateID(ctx context.Context, botUserID int64) (int64, bool, error)
SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error
DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error
PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error)
}

View file

@ -3,29 +3,228 @@ package memory
import (
"context"
"fmt"
"slices"
"sync"
"time"
"telesrv/internal/domain"
)
// BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore.
type BotAPIUpdateStore struct {
mu sync.RWMutex
nextID int64
rows []domain.BotAPIUpdate
state map[int64]int64
byKey map[string]int64
mu sync.RWMutex
nextID int64
rows []domain.BotAPIUpdate
state map[int64]int64
cursorInitialized map[int64]bool
allowed map[int64]map[domain.BotAPIUpdateKind]struct{}
byKey map[string]int64
pollLeases map[int64]botAPIPollLease
webhooks map[int64]domain.BotAPIWebhook
webhookLeases map[int64]botAPIPollLease
}
type botAPIPollLease struct {
owner string
expiresAt time.Time
}
// NewBotAPIUpdateStore creates an in-memory Bot API update queue.
func NewBotAPIUpdateStore() *BotAPIUpdateStore {
return &BotAPIUpdateStore{
nextID: 1,
state: make(map[int64]int64),
byKey: make(map[string]int64),
nextID: 1,
state: make(map[int64]int64),
cursorInitialized: make(map[int64]bool),
allowed: make(map[int64]map[domain.BotAPIUpdateKind]struct{}),
byKey: make(map[string]int64),
pollLeases: make(map[int64]botAPIPollLease),
webhooks: make(map[int64]domain.BotAPIWebhook),
webhookLeases: make(map[int64]botAPIPollLease),
}
}
func (s *BotAPIUpdateStore) SetBotAPIWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error {
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
return fmt.Errorf("invalid bot api webhook")
}
s.mu.Lock()
defer s.mu.Unlock()
if !config.AllowedUpdatesSet {
config.AllowedUpdates = allowedUpdateKinds(s.allowed[config.BotUserID])
}
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, ""
config.NextAttemptAt = time.Now()
s.webhooks[config.BotUserID] = config
if config.AllowedUpdates == nil {
delete(s.allowed, config.BotUserID)
} else {
allowed := make(map[domain.BotAPIUpdateKind]struct{}, len(config.AllowedUpdates))
for _, kind := range config.AllowedUpdates {
allowed[kind] = struct{}{}
}
s.allowed[config.BotUserID] = allowed
}
if dropPending {
s.dropPendingLocked(config.BotUserID)
}
return nil
}
func allowedUpdateKinds(items map[domain.BotAPIUpdateKind]struct{}) []domain.BotAPIUpdateKind {
if len(items) == 0 {
return nil
}
out := make([]domain.BotAPIUpdateKind, 0, len(items))
for kind := range items {
out = append(out, kind)
}
slices.Sort(out)
return out
}
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(_ context.Context, botUserID int64, dropPending bool) error {
s.mu.Lock()
delete(s.webhooks, botUserID)
delete(s.webhookLeases, botUserID)
if dropPending {
s.dropPendingLocked(botUserID)
}
s.mu.Unlock()
return nil
}
func (s *BotAPIUpdateStore) BotAPIWebhook(_ context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
s.mu.RLock()
config, found := s.webhooks[botUserID]
s.mu.RUnlock()
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
return config, found, nil
}
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(_ context.Context, limit int) ([]domain.BotAPIWebhook, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
now := time.Now()
s.mu.RLock()
out := make([]domain.BotAPIWebhook, 0, min(limit, len(s.webhooks)))
for botID, config := range s.webhooks {
lease := s.webhookLeases[botID]
if config.NextAttemptAt.After(now) || (lease.owner != "" && lease.expiresAt.After(now)) {
continue
}
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
out = append(out, config)
if len(out) == limit {
break
}
}
s.mu.RUnlock()
return out, nil
}
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
if botUserID <= 0 || owner == "" || ttl <= 0 {
return false, fmt.Errorf("invalid bot api webhook lease")
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if _, found := s.webhooks[botUserID]; !found {
return false, nil
}
current := s.webhookLeases[botUserID]
if current.owner != "" && current.owner != owner && current.expiresAt.After(now) {
return false, nil
}
s.webhookLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)}
return true, nil
}
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(_ context.Context, botUserID int64, owner string) error {
s.mu.Lock()
if current := s.webhookLeases[botUserID]; current.owner == owner {
delete(s.webhookLeases, botUserID)
}
s.mu.Unlock()
return nil
}
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(_ context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
s.mu.Lock()
defer s.mu.Unlock()
if current := s.webhookLeases[botUserID]; current.owner != owner {
return nil
}
config, found := s.webhooks[botUserID]
if !found {
delete(s.webhookLeases, botUserID)
return nil
}
config.FailureCount++
config.LastErrorDate = int(time.Now().Unix())
if len(message) > 512 {
message = message[:512]
}
config.LastErrorMessage = message
config.NextAttemptAt = nextAttempt
s.webhooks[botUserID] = config
delete(s.webhookLeases, botUserID)
return nil
}
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(_ context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
if current := s.webhookLeases[botUserID]; current.owner != owner {
return nil
}
if config, found := s.webhooks[botUserID]; found {
config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, ""
config.NextAttemptAt = nextAttempt
s.webhooks[botUserID] = config
}
delete(s.webhookLeases, botUserID)
return nil
}
func (s *BotAPIUpdateStore) dropPendingLocked(botUserID int64) {
for _, row := range s.rows {
if row.BotUserID == botUserID && row.ID > s.state[botUserID] {
s.state[botUserID] = row.ID
}
}
s.cursorInitialized[botUserID] = true
}
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
if botUserID <= 0 || owner == "" || ttl <= 0 {
return false, fmt.Errorf("invalid bot api poll lease")
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
current, found := s.pollLeases[botUserID]
if found && current.owner != owner && current.expiresAt.After(now) {
return false, nil
}
s.pollLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)}
return true, nil
}
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(_ context.Context, botUserID int64, owner string) error {
if botUserID <= 0 || owner == "" {
return nil
}
s.mu.Lock()
if current, found := s.pollLeases[botUserID]; found && current.owner == owner {
delete(s.pollLeases, botUserID)
}
s.mu.Unlock()
return nil
}
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
if err := validateBotAPIUpdateRequest(req); err != nil {
return domain.BotAPIUpdate{}, false, err
@ -33,6 +232,11 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
key := botAPIUpdateKey(req)
s.mu.Lock()
defer s.mu.Unlock()
if allowed, configured := s.allowed[req.BotUserID]; configured {
if _, ok := allowed[req.Kind]; !ok {
return domain.BotAPIUpdate{}, false, nil
}
}
if existingID, ok := s.byKey[key]; ok {
for _, row := range s.rows {
if row.ID == existingID {
@ -48,13 +252,56 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
MessageID: req.MessageID,
SourcePts: req.SourcePts,
Date: req.Date,
Callback: cloneBotAPICallback(req.Callback),
}
s.nextID++
s.rows = append(s.rows, row)
s.byKey[key] = row.ID
if config, found := s.webhooks[req.BotUserID]; found {
config.NextAttemptAt = time.Now()
s.webhooks[req.BotUserID] = config
}
return cloneBotAPIUpdate(row), true, nil
}
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(_ context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 || tail <= 0 {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 100
}
s.mu.RLock()
defer s.mu.RUnlock()
confirmed := s.state[botUserID]
matching := make([]domain.BotAPIUpdate, 0, min(tail, limit))
start := 0
count := 0
for _, row := range s.rows {
if row.BotUserID == botUserID && row.ID > confirmed {
count++
}
}
if count > tail {
start = count - tail
}
seen := 0
for _, row := range s.rows {
if row.BotUserID != botUserID || row.ID <= confirmed {
continue
}
if seen < start {
seen++
continue
}
matching = append(matching, cloneBotAPIUpdate(row))
if len(matching) >= limit {
break
}
}
return matching, nil
}
func (s *BotAPIUpdateStore) ListBotAPIUpdates(_ context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 {
return nil, nil
@ -85,13 +332,78 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, c
return nil
}
s.mu.Lock()
maxExisting := int64(0)
for _, row := range s.rows {
if row.BotUserID == botUserID && row.ID > maxExisting {
maxExisting = row.ID
}
}
if confirmedUpdateID > maxExisting && s.cursorInitialized[botUserID] {
s.mu.Unlock()
return nil
}
if confirmedUpdateID > maxExisting {
confirmedUpdateID = maxExisting
}
if confirmedUpdateID > s.state[botUserID] {
s.state[botUserID] = confirmedUpdateID
}
s.cursorInitialized[botUserID] = true
s.mu.Unlock()
return nil
}
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(_ context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
if botUserID == 0 {
return nil
}
s.mu.Lock()
if len(allowed) == 0 {
delete(s.allowed, botUserID)
} else {
set := make(map[domain.BotAPIUpdateKind]struct{}, len(allowed))
for _, kind := range allowed {
if kind != "" {
set[kind] = struct{}{}
}
}
s.allowed[botUserID] = set
}
s.mu.Unlock()
return nil
}
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
if botUserID == 0 {
return nil
}
s.mu.Lock()
for _, row := range s.rows {
if row.BotUserID == botUserID && row.ID > s.state[botUserID] {
s.state[botUserID] = row.ID
}
}
s.cursorInitialized[botUserID] = true
s.mu.Unlock()
return nil
}
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(_ context.Context, botUserID int64) (int, error) {
if botUserID == 0 {
return 0, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
confirmed := s.state[botUserID]
count := 0
for _, row := range s.rows {
if row.BotUserID == botUserID && row.ID > confirmed {
count++
}
}
return count, nil
}
func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID int64) (int64, bool, error) {
if botUserID == 0 {
return 0, false, nil
@ -103,27 +415,65 @@ func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID
}
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
if req.BotUserID == 0 || req.MessageID <= 0 {
if req.BotUserID == 0 {
return fmt.Errorf("invalid bot api update")
}
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
}
switch req.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
if req.Peer.ID <= 0 {
if req.Peer.ID <= 0 || req.MessageID <= 0 {
return fmt.Errorf("invalid bot api update peer")
}
case "":
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
return fmt.Errorf("invalid bot api update peer")
}
default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
}
if req.Kind == domain.BotAPIUpdateCallbackQuery {
cb := req.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
return fmt.Errorf("invalid bot api callback query")
}
inline := cb.InlineMessage
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
return fmt.Errorf("invalid bot api inline callback query")
}
if req.MessageID > 0 && inline != nil {
return fmt.Errorf("ambiguous bot api callback query")
}
} else if req.Callback != nil {
return fmt.Errorf("unexpected bot api callback query")
}
return nil
}
func botAPIUpdateKey(req domain.EnqueueBotAPIUpdateRequest) string {
if req.Kind == domain.BotAPIUpdateCallbackQuery && req.Callback != nil {
return fmt.Sprintf("%d:%s:%d", req.BotUserID, req.Kind, req.Callback.ID)
}
return fmt.Sprintf("%d:%s:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.SourcePts)
}
func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate {
row.Callback = cloneBotAPICallback(row.Callback)
return row
}
func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery {
if in == nil {
return nil
}
out := *in
out.Data = append([]byte(nil), in.Data...)
if in.InlineMessage != nil {
inline := *in.InlineMessage
out.InlineMessage = &inline
}
return &out
}

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 {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.Media = cloneRequestedPeerMedia(msg.Media)
msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
msg.Forward = cloneMessageForward(msg.Forward)
msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions)
@ -99,13 +100,36 @@ func cloneMessage(msg domain.Message) domain.Message {
return msg
}
// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐
// cloneRequestedPeerMedia isolates the immutable disclosure snapshot carried by
// messageActionRequestedPeer. Other media payloads retain their established
// copy behavior; this helper only deep-copies the newly mutable peer/photo slices.
func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
if media == nil {
return nil
}
clone := *media
if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil {
return &clone
}
action := *media.ServiceAction
requested := *media.ServiceAction.RequestedPeer
requested.Peers = append([]domain.Peer(nil), requested.Peers...)
requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...)
for i := range requested.Details {
requested.Details[i].Photo = domain.ClonePhotoPtr(requested.Details[i].Photo)
}
action.RequestedPeer = &requested
clone.ServiceAction = &action
return &clone
}
// cloneReplyMarkup 深拷 reply markup 快照:与 postgres 每盒独立 decode 对齐
// (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。
func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
if m == nil {
return nil
}
clone := domain.MessageReplyMarkup{}
clone := *m
if m.Inline != nil {
clone.Inline = make([][]domain.MarkupButton, len(m.Inline))
for i, row := range m.Inline {
@ -117,6 +141,12 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
clone.Inline[i] = cloneRow
}
}
if m.Keyboard != nil {
clone.Keyboard = make([][]domain.MarkupButton, len(m.Keyboard))
for i, row := range m.Keyboard {
clone.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
}
}
return &clone
}

View file

@ -35,6 +35,33 @@ func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (dom
return out, nil
}
// GetByUID resolves one owner's box row by the shared private message id. Callback delivery
// uses it to translate the clicker's box id to the bot's box id without scanning history.
func (s *MessageStore) GetByUID(_ context.Context, userID, uid int64) (domain.Message, bool, error) {
if userID == 0 || uid == 0 {
return domain.Message{}, false, nil
}
s.mu.RLock()
var found domain.Message
for _, msg := range s.m[userID] {
if msg.UID == uid {
found = cloneMessage(msg)
reactions := s.privateMessageReactionsForMessageLocked(found)
if len(reactions.Results) > 0 || len(reactions.Recent) > 0 {
found.Reactions = cloneChannelMessageReactionsPtr(&reactions)
}
break
}
}
s.mu.RUnlock()
if found.ID == 0 {
return domain.Message{}, false, nil
}
items := []domain.Message{found}
s.enrichPrivateMessagePolls(items, int(time.Now().Unix()))
return items[0], true, nil
}
func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
s.mu.RLock()
messages := cloneMessages(s.m[userID])

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

View file

@ -222,6 +222,65 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("recipient history", recipientHistory.Messages[0])
}
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
photo := domain.Photo{ID: 8101, Sizes: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
}}}
requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003}
req := domain.SendPrivateTextRequest{
SenderUserID: 1000000001, RecipientUserID: 1000000002, RandomID: 200, Date: 1700000121,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 77, Peers: []domain.Peer{requestedPeer},
Details: []domain.MessageRequestedPeerDetails{{
Peer: requestedPeer, FirstName: "Shared", Username: "shared_user", Photo: &photo,
}},
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
},
}},
}
got, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
assertSnapshot := func(name string, msg domain.Message) {
t.Helper()
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
}
action := msg.Media.ServiceAction.RequestedPeer
if action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
len(action.Details) != 1 || action.Details[0].FirstName != "Shared" ||
action.Details[0].Username != "shared_user" || action.Details[0].Photo == nil ||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
t.Fatalf("%s requested-peer=%+v", name, action)
}
}
assertSnapshot("sender", got.SenderMessage)
assertSnapshot("recipient", got.RecipientMessage)
// Mutating either the request or one returned box must not alter the other
// box or the immutable store snapshot.
req.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-request"
req.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 1
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-result"
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 2
assertSnapshot("isolated recipient result", got.RecipientMessage)
for _, owner := range []int64{req.SenderUserID, req.RecipientUserID} {
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{Limit: 10})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("owner %d history=%+v err=%v", owner, history, err)
}
assertSnapshot("stored history", history.Messages[0])
}
}
func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()

View file

@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory {
t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory)
}
requestedButton := domain.BotRequestedWebViewButton{
WebAppReqID: fmt.Sprintf("pg-requested-%d", suffix), BotUserID: bot1.ID, UserID: owner.ID,
ButtonID: 45, Text: "Share", PeerType: "user", MaxQuantity: 2,
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour),
}
t.Cleanup(func() {
_ = bots.DeleteRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
})
if err := bots.SaveRequestedWebViewButton(ctx, requestedButton); err != nil {
t.Fatalf("save requested button: %v", err)
}
storedButton, found, err := bots.GetRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
if err != nil || !found || !storedButton.NameRequested || !storedButton.UsernameRequested || !storedButton.PhotoRequested {
t.Fatalf("requested button=%#v found=%v err=%v", storedButton, found, err)
}
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can {
t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
}

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() {
return domain.ErrBotRequestedButtonInvalid
}
_, err := s.db.Exec(ctx, `
INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
peerFilter, err := json.Marshal(button.PeerFilter)
if err != nil {
return domain.ErrBotRequestedButtonInvalid
}
_, err = s.db.Exec(ctx, `
INSERT INTO webview_requested_buttons (
webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (webapp_req_id) DO UPDATE SET
button_id=EXCLUDED.button_id,
text=EXCLUDED.text,
peer_type=EXCLUDED.peer_type,
max_quantity=EXCLUDED.max_quantity,
peer_filter=EXCLUDED.peer_filter,
name_requested=EXCLUDED.name_requested,
username_requested=EXCLUDED.username_requested,
photo_requested=EXCLUDED.photo_requested,
expires_at=EXCLUDED.expires_at`,
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt)
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text,
button.PeerType, button.MaxQuantity, peerFilter, button.NameRequested,
button.UsernameRequested, button.PhotoRequested, button.CreatedAt, button.ExpiresAt)
if err != nil {
return fmt.Errorf("save requested webview button: %w", err)
}
@ -435,18 +448,28 @@ ON CONFLICT (webapp_req_id) DO UPDATE SET
func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
_, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`)
var button domain.BotRequestedWebViewButton
var peerFilter []byte
err := s.db.QueryRow(ctx, `
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
FROM webview_requested_buttons
WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`,
botUserID, userID, webAppReqID).
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt)
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID,
&button.Text, &button.PeerType, &button.MaxQuantity, &peerFilter,
&button.NameRequested, &button.UsernameRequested, &button.PhotoRequested,
&button.CreatedAt, &button.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.BotRequestedWebViewButton{}, false, nil
}
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err)
}
if string(peerFilter) != "{}" && string(peerFilter) != "null" {
if err := json.Unmarshal(peerFilter, &button.PeerFilter); err != nil {
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("decode requested webview button filter: %w", err)
}
}
return button, true, nil
}

View file

@ -20,17 +20,320 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore {
return &BotAPIUpdateStore{db: db}
}
func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
return fmt.Errorf("invalid bot api webhook")
}
var allowed []string
if len(config.AllowedUpdates) > 0 {
allowed = make([]string, 0, len(config.AllowedUpdates))
for _, kind := range config.AllowedUpdates {
if kind != "" {
allowed = append(allowed, string(kind))
}
}
}
if _, err := s.db.Exec(ctx, `
WITH policy AS (
SELECT CASE WHEN $6::boolean THEN $5::text[]
ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1)
END AS allowed_updates
), configured AS (
INSERT INTO bot_api_webhooks (
bot_user_id, url, secret_token, max_connections, allowed_updates,
failure_count, last_error_date, last_error_message, next_attempt_at,
delivery_owner, delivery_expires_at, updated_at
)
SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now()
FROM policy
ON CONFLICT (bot_user_id) DO UPDATE
SET url = EXCLUDED.url,
secret_token = EXCLUDED.secret_token,
max_connections = EXCLUDED.max_connections,
allowed_updates = EXCLUDED.allowed_updates,
failure_count = 0,
last_error_date = 0,
last_error_message = '',
next_attempt_at = now(),
delivery_owner = '',
delivery_expires_at = NULL,
updated_at = now()
RETURNING bot_user_id
), boundary AS (
SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
FROM bot_api_updates
WHERE bot_user_id = $1
)
INSERT INTO bot_api_update_states (
bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized
)
SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean
FROM boundary, configured, policy
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = CASE WHEN $7::boolean
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
ELSE bot_api_update_states.confirmed_update_id
END,
allowed_updates = EXCLUDED.allowed_updates,
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
updated_at = now()
`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed,
config.AllowedUpdatesSet, dropPending); err != nil {
return fmt.Errorf("set bot api webhook: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error {
if botUserID <= 0 {
return nil
}
if _, err := s.db.Exec(ctx, `
WITH deleted AS (
DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id
), boundary AS (
SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
FROM bot_api_updates
WHERE bot_user_id = $1
)
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
SELECT $1, confirmed_update_id, $2::boolean
FROM boundary
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = CASE WHEN $2::boolean
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
ELSE bot_api_update_states.confirmed_update_id
END,
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
updated_at = now()
`, botUserID, dropPending); err != nil {
return fmt.Errorf("delete bot api webhook: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, `
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
failure_count, last_error_date, last_error_message, next_attempt_at
FROM bot_api_webhooks
WHERE bot_user_id = $1
`, botUserID))
if err == pgx.ErrNoRows {
return domain.BotAPIWebhook{}, false, nil
}
if err != nil {
return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err)
}
return config, true, nil
}
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
if limit <= 0 || limit > 1000 {
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
failure_count, last_error_date, last_error_message, next_attempt_at
FROM bot_api_webhooks
WHERE next_attempt_at <= now()
AND (delivery_owner = '' OR delivery_expires_at <= now())
ORDER BY next_attempt_at, bot_user_id
LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("list due bot api webhooks: %w", err)
}
defer rows.Close()
out := make([]domain.BotAPIWebhook, 0, limit)
for rows.Next() {
config, err := scanBotAPIWebhook(rows)
if err != nil {
return nil, err
}
out = append(out, config)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list due bot api webhook rows: %w", err)
}
return out, nil
}
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
if botUserID <= 0 || owner == "" || ttl <= 0 {
return false, fmt.Errorf("invalid bot api webhook lease")
}
var acquiredOwner string
err := s.db.QueryRow(ctx, `
UPDATE bot_api_webhooks
SET delivery_owner = $2,
delivery_expires_at = now() + make_interval(secs => $3),
updated_at = now()
WHERE bot_user_id = $1
AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now())
RETURNING delivery_owner
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("acquire bot api webhook lease: %w", err)
}
return acquiredOwner == owner, nil
}
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error {
if botUserID <= 0 || owner == "" {
return nil
}
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_webhooks
SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND delivery_owner = $2
`, botUserID, owner); err != nil {
return fmt.Errorf("release bot api webhook lease: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
if len(message) > 512 {
message = message[:512]
}
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_webhooks
SET failure_count = failure_count + 1,
last_error_date = EXTRACT(EPOCH FROM now())::integer,
last_error_message = $3,
next_attempt_at = $4,
delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND delivery_owner = $2
`, botUserID, owner, message, nextAttempt); err != nil {
return fmt.Errorf("record bot api webhook failure: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_webhooks
SET failure_count = 0, last_error_date = 0, last_error_message = '',
next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND delivery_owner = $2
`, botUserID, owner, nextAttempt); err != nil {
return fmt.Errorf("record bot api webhook success: %w", err)
}
return nil
}
func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) {
var config domain.BotAPIWebhook
var allowed []string
if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed,
&config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil {
return domain.BotAPIWebhook{}, err
}
if allowed != nil {
config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed))
for _, kind := range allowed {
config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind))
}
}
return config, nil
}
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
if botUserID <= 0 || owner == "" || ttl <= 0 {
return false, fmt.Errorf("invalid bot api poll lease")
}
var acquiredOwner string
err := s.db.QueryRow(ctx, `
INSERT INTO bot_api_update_states (
bot_user_id, confirmed_update_id, poll_owner, poll_expires_at
) VALUES ($1, 0, $2, now() + make_interval(secs => $3))
ON CONFLICT (bot_user_id) DO UPDATE
SET poll_owner = EXCLUDED.poll_owner,
poll_expires_at = EXCLUDED.poll_expires_at,
updated_at = now()
WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner
OR bot_api_update_states.poll_expires_at IS NULL
OR bot_api_update_states.poll_expires_at <= now()
RETURNING poll_owner
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("acquire bot api poll lease: %w", err)
}
return acquiredOwner == owner, nil
}
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error {
if botUserID <= 0 || owner == "" {
return nil
}
if _, err := s.db.Exec(ctx, `
UPDATE bot_api_update_states
SET poll_owner = '', poll_expires_at = NULL, updated_at = now()
WHERE bot_user_id = $1 AND poll_owner = $2
`, botUserID, owner); err != nil {
return fmt.Errorf("release bot api poll lease: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
if err := validateBotAPIUpdateRequest(req); err != nil {
return domain.BotAPIUpdate{}, false, err
}
var callbackQueryID, callbackUserID, callbackChatInstance int64
var callbackInlineDCID, callbackInlineMessageID int
var callbackInlineOwnerID, callbackInlineAccessHash int64
var callbackData []byte
if req.Callback != nil {
callbackQueryID = req.Callback.ID
callbackUserID = req.Callback.UserID
callbackChatInstance = req.Callback.ChatInstance
callbackData = req.Callback.Data
if req.Callback.InlineMessage != nil {
callbackInlineDCID = req.Callback.InlineMessage.DCID
callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID
callbackInlineMessageID = req.Callback.InlineMessage.ID
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash
}
}
row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
INSERT INTO bot_api_updates (
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date))
WITH inserted AS (
INSERT INTO bot_api_updates (
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
WHERE NOT EXISTS (
SELECT 1
FROM bot_api_update_states
WHERE bot_user_id = $1
AND allowed_updates IS NOT NULL
AND NOT ($2::text = ANY(allowed_updates))
)
ON CONFLICT DO NOTHING
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
), wake_webhook AS (
UPDATE bot_api_webhooks
SET next_attempt_at = now(), updated_at = now()
WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted)
RETURNING bot_user_id
)
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
FROM inserted
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash))
if err == nil {
return row, true, nil
}
@ -38,21 +341,69 @@ RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_p
return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err)
}
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
FROM bot_api_updates
WHERE bot_user_id = $1
AND update_kind = $2
AND peer_type = $3
AND peer_id = $4
AND message_id = $5
AND source_pts = $6
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts))
AND (
(update_kind = 'callback_query' AND callback_query_id = $7)
OR
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND source_pts = $6)
)
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID))
if err != nil {
if err == pgx.ErrNoRows {
return domain.BotAPIUpdate{}, false, nil
}
return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err)
}
return row, false, nil
}
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 || tail <= 0 {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
FROM (
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
FROM bot_api_updates
WHERE bot_user_id = $1
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
ORDER BY id DESC
LIMIT $2
) AS tail_updates
ORDER BY id
LIMIT $3
`, botUserID, tail, limit)
if err != nil {
return nil, fmt.Errorf("list bot api tail updates: %w", err)
}
defer rows.Close()
out := make([]domain.BotAPIUpdate, 0, limit)
for rows.Next() {
item, err := scanBotAPIUpdateRows(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list bot api tail update rows: %w", err)
}
return out, nil
}
func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
if botUserID == 0 {
return nil, nil
@ -64,7 +415,9 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
FROM bot_api_updates
WHERE bot_user_id = $1 AND id >= $2
ORDER BY id
@ -93,23 +446,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID,
return nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id)
VALUES ($1, $2)
WITH bounded AS (
SELECT COALESCE(MAX(id), 0) AS max_update_id,
LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id
FROM bot_api_updates
WHERE bot_user_id = $1
)
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
SELECT $1, confirmed_update_id, true
FROM bounded
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
SET confirmed_update_id = GREATEST(
bot_api_update_states.confirmed_update_id,
CASE
WHEN $2::bigint > (SELECT max_update_id FROM bounded)
AND bot_api_update_states.cursor_initialized
THEN bot_api_update_states.confirmed_update_id
ELSE EXCLUDED.confirmed_update_id
END
),
cursor_initialized = true,
updated_at = now()
WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id
`, botUserID, confirmedUpdateID); err != nil {
return fmt.Errorf("confirm bot api updates: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
if botUserID == 0 {
return nil
}
var values []string
if len(allowed) > 0 {
values = make([]string, 0, len(allowed))
for _, kind := range allowed {
if kind != "" {
values = append(values, string(kind))
}
}
}
if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates)
VALUES ($1, 0, $2::text[])
ON CONFLICT (bot_user_id) DO UPDATE
SET allowed_updates = EXCLUDED.allowed_updates,
updated_at = now()
`, botUserID, values); err != nil {
return fmt.Errorf("set bot api allowed updates: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
if botUserID == 0 {
return nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
SELECT $1, COALESCE(MAX(id), 0), true
FROM bot_api_updates
WHERE bot_user_id = $1
ON CONFLICT (bot_user_id) DO UPDATE
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
cursor_initialized = true,
updated_at = now()
`, botUserID); err != nil {
return fmt.Errorf("drop pending bot api updates: %w", err)
}
return nil
}
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) {
if botUserID == 0 {
return 0, nil
}
var count int
if err := s.db.QueryRow(ctx, `
SELECT COUNT(*)
FROM bot_api_updates
WHERE bot_user_id = $1
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
`, botUserID).Scan(&count); err != nil {
return 0, fmt.Errorf("count pending bot api updates: %w", err)
}
return count, nil
}
// DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1
// 1. 已确认id <= bot_api_update_states.confirmed_update_id且入队超过 confirmedGrace 的行——
// 官方 Bot API 语义下确认即弃getUpdates 的 fromID 恒 > confirmed删除不影响任何读路径
// 宽限仅防御 offset 回拨调试场景。
// 2. 按消息 date 超过 maxAge 的行无论确认与否——对齐官方「updates 服务器最多保留 24 小时」
// 2. 按队列 created_at 超过 maxAge 的行无论确认与否——对齐官方「updates 服务器最多保留 24 小时」
// 语义,同时封顶 MTProto-only bot从不调 getUpdates、无 state 行)成员身份带来的无界增长。
//
// 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫,
@ -139,15 +567,15 @@ WHERE id IN (
total += int(tag.RowsAffected())
}
if maxAge > 0 {
cutoff := time.Now().Add(-maxAge).Unix()
// 走 bot_api_updates_retention_idx(date, id)。
cutoff := time.Now().Add(-maxAge)
// 走 bot_api_updates_created_retention_idx(created_at, id)。
tag, err := s.db.Exec(ctx, `
DELETE FROM bot_api_updates
WHERE id IN (
SELECT id
FROM bot_api_updates
WHERE date < $1
ORDER BY date, id
WHERE created_at < $1
ORDER BY created_at, id
LIMIT $2
)`, cutoff, limit)
if err != nil {
@ -187,28 +615,69 @@ type botAPIUpdateScanner interface {
func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) {
var item domain.BotAPIUpdate
var kind, peerType string
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date); err != nil {
var callbackQueryID, callbackUserID, callbackChatInstance int64
var callbackInlineDCID, callbackInlineMessageID int
var callbackInlineOwnerID, callbackInlineAccessHash int64
var callbackData []byte
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash); err != nil {
return domain.BotAPIUpdate{}, err
}
item.Kind = domain.BotAPIUpdateKind(kind)
item.Peer.Type = domain.PeerType(peerType)
if item.Kind == domain.BotAPIUpdateCallbackQuery {
item.Callback = &domain.BotCallbackQuery{
ID: callbackQueryID,
BotUserID: item.BotUserID,
UserID: callbackUserID,
Peer: item.Peer,
MessageID: item.MessageID,
ChatInstance: callbackChatInstance,
Data: append([]byte(nil), callbackData...),
}
if callbackInlineMessageID > 0 {
item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash}
}
}
return item, nil
}
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
if req.BotUserID == 0 || req.MessageID <= 0 {
if req.BotUserID == 0 {
return fmt.Errorf("invalid bot api update")
}
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
}
switch req.Peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
if req.Peer.ID <= 0 {
if req.Peer.ID <= 0 || req.MessageID <= 0 {
return fmt.Errorf("invalid bot api update peer")
}
case "":
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
return fmt.Errorf("invalid bot api update peer")
}
default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
}
if req.Kind == domain.BotAPIUpdateCallbackQuery {
cb := req.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
return fmt.Errorf("invalid bot api callback query")
}
inline := cb.InlineMessage
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
return fmt.Errorf("invalid bot api inline callback query")
}
if req.MessageID > 0 && inline != nil {
return fmt.Errorf("ambiguous bot api callback query")
}
} else if req.Callback != nil {
return fmt.Errorf("unexpected bot api callback query")
}
return nil
}

View file

@ -1,6 +1,7 @@
package postgres
import (
"bytes"
"context"
"testing"
"time"
@ -8,10 +9,292 @@ import (
"telesrv/internal/domain"
)
func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{
AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot",
})
if err != nil {
t.Fatalf("create bot user: %v", err)
}
clicker, err := users.Create(ctx, domain.User{
AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker",
})
if err != nil {
t.Fatalf("create callback user: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO bots (bot_user_id, owner_user_id, token_secret)
VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil {
t.Fatalf("seed bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
callback := &domain.BotCallbackQuery{
ID: 880011, BotUserID: bot.ID, UserID: clicker.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17,
ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'},
}
req := domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback,
}
store := NewBotAPIUpdateStore(pool)
first, created, err := store.EnqueueBotAPIUpdate(ctx, req)
if err != nil || !created {
t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err)
}
again, created, err := store.EnqueueBotAPIUpdate(ctx, req)
if err != nil || created || again.ID != first.ID {
t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err)
}
items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
if err != nil || len(items) != 1 {
t.Fatalf("list callback = %+v, %v", items, err)
}
got := items[0].Callback
if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID ||
got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance ||
!bytes.Equal(got.Data, callback.Data) {
t.Fatalf("callback round trip = %+v, want %+v", got, callback)
}
}
func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"})
if err != nil {
t.Fatal(err)
}
clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"})
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
s := NewBotAPIUpdateStore(pool)
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566}
callback := &domain.BotCallbackQuery{
ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811,
Data: []byte{0, 1, 0xff}, InlineMessage: inline,
}
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback,
})
if err != nil || !created {
t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err)
}
items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100)
if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 ||
items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline ||
!bytes.Equal(items[0].Callback.Data, callback.Data) {
t.Fatalf("inline callback items=%#v err=%v", items, err)
}
config := domain.BotAPIWebhook{
BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret",
MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true,
}
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
t.Fatal(err)
}
stored, found, err := s.BotAPIWebhook(ctx, bot.ID)
if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken ||
stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 {
t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err)
}
config.URL = "https://example.test/reconfigured"
config.AllowedUpdates = nil
config.AllowedUpdatesSet = false
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
t.Fatal(err)
}
stored, found, err = s.BotAPIWebhook(ctx, bot.ID)
if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery {
t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err)
}
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
t.Fatalf("first lease=%v err=%v", acquired, err)
}
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
t.Fatalf("second lease=%v err=%v", acquired, err)
}
if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil {
t.Fatal(err)
}
if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired {
t.Fatal("stale webhook release removed active lease")
}
next := time.Now().Add(time.Hour)
if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil {
t.Fatal(err)
}
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 {
t.Fatalf("idle due=%#v err=%v", due, err)
}
// A newly inserted allowed callback wakes the idle webhook in the same SQL statement.
callback2 := *callback
callback2.ID++
callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677}
if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2,
}); err != nil || !created {
t.Fatalf("enqueue wake created=%v err=%v", created, err)
}
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID {
t.Fatalf("woken due=%#v err=%v", due, err)
}
if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil {
t.Fatal(err)
}
if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found {
t.Fatalf("webhook after delete found=%v err=%v", found, err)
}
if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 {
t.Fatalf("pending after delete/drop=%d err=%v", pending, err)
}
}
func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"})
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool)
if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
t.Fatalf("first acquire=%v err=%v", acquired, err)
}
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
t.Fatalf("cross-instance acquire=%v err=%v", acquired, err)
}
if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil {
t.Fatal(err)
}
if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired {
t.Fatal("stale release removed active poll lease")
}
if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil {
t.Fatal(err)
}
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired {
t.Fatalf("successor acquire=%v err=%v", acquired, err)
}
}
func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
bot, err := users.Create(ctx, domain.User{
AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot",
})
if err != nil {
t.Fatalf("create bot user: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil {
t.Fatalf("seed bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
})
s := NewBotAPIUpdateStore(pool)
enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) {
t.Helper()
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
BotUserID: bot.ID, Kind: kind,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1},
MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("enqueue %s/%d: %v", kind, messageID, err)
}
return row, created
}
for id := 1; id <= 3; id++ {
if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created {
t.Fatalf("initial message %d was not created", id)
}
}
if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil {
t.Fatalf("set allowed updates: %v", err)
}
if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 {
t.Fatalf("filtered row=%+v created=%v", row, created)
}
lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5)
if !created {
t.Fatal("allowed edit was filtered")
}
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
t.Fatalf("initialize external cursor: %v", err)
}
confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
if err != nil || !found || confirmed != lastBeforeBaseline.ID {
t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID)
}
pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6)
if !created {
t.Fatal("post-baseline edit was filtered")
}
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
t.Fatalf("repeat external cursor: %v", err)
}
confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
if confirmed != lastBeforeBaseline.ID {
t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID)
}
tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100)
if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID {
t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID)
}
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 {
t.Fatalf("pending count=%d err=%v", count, err)
}
if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil {
t.Fatalf("drop pending: %v", err)
}
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 {
t.Fatalf("pending after drop=%d err=%v", count, err)
}
}
// TestBotAPIUpdateRetention 锁定 H1 场景矩阵:
// - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留;
// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot
// - 未确认 + date 在保留期内 → 留;
// - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot
// - 未确认 + created_at 在保留期内 → 留;
// - 删除后 getUpdates 读路径fromID > confirmed不受影响。
func TestBotAPIUpdateRetention(t *testing.T) {
pool := testPool(t)
@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
s := NewBotAPIUpdateStore(pool)
now := time.Now().Unix()
stale := now - int64((48 * time.Hour).Seconds())
enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate {
t.Helper()
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
@ -67,7 +349,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删
confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留
unconfirmedFresh := enqueue(confirmedBot, 3, now)
expiredNoState := enqueue(mtprotoOnlyBot, 4, stale) // 无 state 行 + date 超保留期 → 删
expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删
freshNoState := enqueue(mtprotoOnlyBot, 5, now)
if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil {
@ -77,6 +359,10 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
"UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil {
t.Fatalf("backdate confirmed row: %v", err)
}
if _, err := pool.Exec(ctx,
"UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil {
t.Fatalf("backdate expired row: %v", err)
}
deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000)
if err != nil {
@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
// 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行;
// 精确归属由下方 remaining 断言保证。
if deleted < 2 {
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, date expired)", deleted)
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted)
}
remaining := map[int64]bool{}

View file

@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool
return photo, true, nil
}
// GetPhotos resolves a bounded set of immutable photo metadata with one indexed
// ANY query. Missing ids are omitted and the result follows first-seen caller order.
func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
if len(ids) == 0 {
return nil, nil
}
unique := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text
FROM photos
WHERE id = ANY($1::bigint[])
`, unique)
if err != nil {
return nil, err
}
defer rows.Close()
byID := make(map[int64]domain.Photo, len(unique))
for rows.Next() {
photo, err := scanPhotoRow(rows)
if err != nil {
return nil, err
}
byID[photo.ID] = photo
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]domain.Photo, 0, len(byID))
for _, id := range unique {
if photo, ok := byID[id]; ok {
out = append(out, photo)
}
}
return out, nil
}
type photoScanner interface {
Scan(dest ...any) error
}

View file

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

View file

@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) {
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" {
t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto)
}
photos, err := s.GetPhotos(ctx, []int64{photoID, 0, photoID, photoID + 99})
if err != nil || len(photos) != 1 || photos[0].ID != photoID || len(photos[0].Sizes) != 1 {
t.Fatalf("get photos mismatch: photos=%+v err=%v", photos, err)
}
// ---- sticker set ----
set := domain.StickerSet{

View file

@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d
return out, nil
}
// GetByUID resolves one owner's box row by the indexed shared private_message_id.
func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
if userID == 0 || uid == 0 {
return domain.Message{}, false, nil
}
row, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
OwnerUserID: userID,
PrivateMessageID: uid,
})
if errors.Is(err, pgx.ErrNoRows) {
return domain.Message{}, false, nil
}
if err != nil {
return domain.Message{}, false, fmt.Errorf("get message by uid: %w", err)
}
if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil {
return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err)
}
return messageFromGetBoxRow(row), true, nil
}
func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
limit := filter.Limit
if limit <= 0 {

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 {
return domain.SendPrivateTextResult{}, err
}
// reply_markupbot inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。
// reply_markupbot reply/inline keyboard随消息一并入双盒普通用户发送恒 nil → "{}"。
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
if err != nil {
return domain.SendPrivateTextResult{}, err

View file

@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("recipient event", events[0].Message)
}
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender := createTestUser(t, ctx, users, "+1666"+suffix+"33", "RequestedSender", "")
recipient := createTestUser(t, ctx, users, "+1666"+suffix+"34", "RequestedRecipient", "")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
})
requestedPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 5501}
photo := domain.Photo{ID: 8201, Sizes: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
}}}
messages := NewMessageStore(pool)
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 9002, Date: 1700000212,
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionRequestedPeer,
RequestedPeer: &domain.MessageRequestedPeerAction{
ButtonID: 88, Peers: []domain.Peer{requestedPeer},
Details: []domain.MessageRequestedPeerDetails{{
Peer: requestedPeer, Title: "Shared Chat", Username: "shared_chat", Photo: &photo,
}},
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
},
}},
})
if err != nil {
t.Fatalf("SendPrivateText: %v", err)
}
assertSnapshot := func(name string, msg domain.Message) {
t.Helper()
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
}
action := msg.Media.ServiceAction.RequestedPeer
if action.ButtonID != 88 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
len(action.Details) != 1 || action.Details[0].Title != "Shared Chat" ||
action.Details[0].Username != "shared_chat" || action.Details[0].Photo == nil ||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
t.Fatalf("%s requested-peer=%+v", name, action)
}
}
assertSnapshot("sender", got.SenderMessage)
assertSnapshot("recipient", got.RecipientMessage)
history, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10,
})
if err != nil || len(history.Messages) != 1 {
t.Fatalf("recipient history=%+v err=%v", history, err)
}
assertSnapshot("recipient history", history.Messages[0])
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
if err != nil || len(events) != 1 {
t.Fatalf("recipient events=%+v err=%v", events, err)
}
assertSnapshot("recipient event", events[0].Message)
}
func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
pool := testPool(t)
ctx := context.Background()

View file

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

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