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