diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md
index ef46c504..55432536 100644
--- a/cmd/bots/bedolagaformat/README.md
+++ b/cmd/bots/bedolagaformat/README.md
@@ -78,8 +78,8 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
3. 服务端 Authorization Code + PKCE S256 → `/token` Basic Client Secret →
JWKS 验签和 `issuer/audience/nonce/subject` 复核。
-先在 telesrv 的 @BotFather 中对目标 bot 运行 `/setlogin`。选择 bot 后逐条登记 demo
-的精确 origin 和 callback(本机示例):
+先在 telesrv 的 @BotFather 中对目标 bot 运行 `/setlogin`。选择一次 bot 后,可逐条发送,
+也可把下面三行作为一条多行消息粘贴,无需每次重新运行 `/setlogin` 或重选 bot:
```text
add origin http://127.0.0.1:3000
@@ -87,6 +87,8 @@ add redirect http://127.0.0.1:3000/oauth/callback
enable
```
+发送 `/done` 退出配置会话并查看最终摘要。`/cancel` 只退出,不回滚已经成功应用的命令。
+
`/setlogin` 首次创建 client 时只展示一次 OIDC Client Secret;不要写进仓库。可用
`/logininfo` 查看 Client ID 和登记结果,或用 `/resetloginsecret` 轮换 secret。
使用 HTTP 域名/IP 时,在 telesrv 配置 `TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true`;
diff --git a/deploy/migrations/0130_botfather_done_command.down.sql b/deploy/migrations/0130_botfather_done_command.down.sql
new file mode 100644
index 00000000..0b672f17
--- /dev/null
+++ b/deploy/migrations/0130_botfather_done_command.down.sql
@@ -0,0 +1,13 @@
+UPDATE public.bots
+SET commands = COALESCE((
+ SELECT jsonb_agg(command ORDER BY ordinal)
+ FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
+ WHERE command->>'command' <> 'done'
+ ), '[]'::jsonb),
+ updated_at = now()
+WHERE bot_user_id = 93372553;
+
+UPDATE public.users
+SET bot_info_version = bot_info_version + 1,
+ updated_at = now()
+WHERE id = 93372553;
diff --git a/deploy/migrations/0130_botfather_done_command.up.sql b/deploy/migrations/0130_botfather_done_command.up.sql
new file mode 100644
index 00000000..73d562e8
--- /dev/null
+++ b/deploy/migrations/0130_botfather_done_command.up.sql
@@ -0,0 +1,22 @@
+-- /setlogin remains active across multiple configuration messages. Publish
+-- /done in BotFather's command menu so clients can discover the explicit
+-- finish action without reopening /help.
+UPDATE public.bots
+SET commands = commands || '[
+ {"command":"done","description":"finish Telegram Login configuration"}
+ ]'::jsonb,
+ updated_at = now()
+WHERE bot_user_id = 93372553
+ AND NOT EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements(commands) AS item(command)
+ WHERE item.command->>'command' = 'done'
+ );
+
+-- Bot command menus are cached by bot_info_version. Bump it even when an
+-- operator already added /done manually, making the migration convergent and
+-- forcing connected clients to refresh the authoritative command list.
+UPDATE public.users
+SET bot_info_version = bot_info_version + 1,
+ updated_at = now()
+WHERE id = 93372553;
diff --git a/docs/configuration.en.md b/docs/configuration.en.md
index aaa23531..71de0b4b 100644
--- a/docs/configuration.en.md
+++ b/docs/configuration.en.md
@@ -77,7 +77,124 @@ This document describes every setting loaded by `internal/config`. Defaults and
| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | Retention worker interval; bounded to `10s..1h`. |
| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | Maximum rows per retention pass; bounded to `1..1000`. |
-### 3.1 Complete Telegram Login / OIDC setup
+### 3.1 Bot API webhook troubleshooting
+
+Start by separating the three addresses below. Never use the webhook receiver domain as the Bot
+API endpoint unless an explicit reverse-proxy route maps that domain to telesrv:
+
+| Name | Setting/source | Direction and purpose |
+|---|---|---|
+| Bot API listener | telesrv `TELESRV_BOT_API_ADDR` | The telesrv bind address; empty disables the gateway. `0.0.0.0` is valid only for binding and is not a client request target. |
+| Bot API base URL | the bot application's `TELEGRAM_API_URL` or equivalent | A client-reachable address for telesrv, for example `http://172.17.0.1:8088`. Method URLs are `/bot/` and file URLs are `/file/bot/`. |
+| Webhook receiver URL | the bot application's `WEBHOOK_URL + WEBHOOK_PATH`, registered by `setWebhook` | The target to which telesrv actively POSTs updates, for example `https://bot.example.com/webhook`. It is not the Bot API base URL. |
+
+The network direction is different too: polling is `bot application -> telesrv Bot API`, while
+webhook delivery is `telesrv -> bot application webhook receiver`. Working polling proves only the
+first path. It does not prove webhook DNS, outbound TCP, TLS, reverse proxy, or Docker hairpin
+connectivity.
+
+#### 1. Query the authoritative webhook state from the Bot API
+
+Run this inside the bot application container with its actual Bot API base URL. Do not expand and
+paste the token into chat, tickets, or screenshots:
+
+```sh
+curl -sS -X POST \
+ "${TELEGRAM_API_URL%/}/bot${BOT_TOKEN}/getWebhookInfo" | jq
+```
+
+If the application uses a differently named variable, replace `TELEGRAM_API_URL` with the
+**client-reachable address** corresponding to `TELESRV_BOT_API_ADDR`. For example, if telesrv binds
+`0.0.0.0:8088`, a container on the same host might use `http://172.17.0.1:8088`; it must not request
+`http://0.0.0.0:8088`.
+
+Interpret the result as follows:
+
+| Result | Conclusion and next step |
+|---|---|
+| Empty `url` | No webhook is registered on this telesrv instance. Verify that the application uses this Bot API base URL and that startup `setWebhook` succeeded. |
+| Increasing `pending_update_count` | Updates reached the telesrv durable queue but are not being delivered successfully. Inspect `last_error_message`. |
+| HTTP `401`/`403` in `last_error_message` | The receiver is reachable, but its webhook secret differs or an authentication layer rejected the request. |
+| `dial tcp ... i/o timeout` | telesrv cannot connect to the target IP/port. Check outbound firewall rules, Docker networking, loopback/hairpin NAT, and security groups. |
+| `connection refused` | The address is reachable, but nothing listens on that port or the port mapping/reverse-proxy upstream is wrong. |
+| DNS/`no such host` | The webhook hostname cannot be resolved from the telesrv runtime environment. |
+| TLS/`x509` error | The certificate chain, hostname, SNI, or container CA trust is wrong. HTTPS uses the system trust store. |
+| Target type absent from `allowed_updates` | Newly produced updates of that type are not queued. A normal `/start` requires at least `message`. |
+| Pending reaches zero but the app does not react | telesrv received a 2xx response. Inspect the receiver's internal queue, workers, dispatcher, and handlers. |
+
+`getWebhookInfo` reports telesrv's persisted delivery facts. An application `/health` endpoint only
+proves that its receiver route and workers started; it cannot replace this check.
+
+#### 2. Validate the receiver with the correct header
+
+The Telegram webhook secret is distinct from the Bot token, OIDC Client Secret, and other API
+keys. The receiver validates `X-Telegram-Bot-Api-Secret-Token`, not `Authorization: Bearer`:
+
+```sh
+curl -i -X POST "${WEBHOOK_URL%/}${WEBHOOK_PATH}" \
+ -H 'Content-Type: application/json' \
+ -H "X-Telegram-Bot-Api-Secret-Token: ${WEBHOOK_SECRET_TOKEN}" \
+ -d '{"update_id":2147483000}'
+```
+
+Expect an HTTP 2xx response. `401 invalid_secret_token` proves that the request reached the
+application but the header was absent or did not match. Recreate/restart the application after
+editing `.env`; changing the file alone neither updates the secret already registered in telesrv
+nor the receiver process's startup-time secret.
+
+#### 3. Test from the actual telesrv network namespace
+
+A browser or official Telegram reaching the public webhook proves only public inbound
+connectivity. Repeat the test from the host, container, or network namespace that actually runs
+telesrv:
+
+```sh
+docker exec sh -lc \
+ 'getent hosts bot.example.com; curl -vk --connect-timeout 10 https://bot.example.com/health/unified'
+```
+
+If public clients work but this returns `dial tcp ...:443: i/o timeout`, a same-host public-IP
+hairpin failure is a common cause. Prefer split DNS or a container host mapping so the public
+hostname resolves to the reverse proxy's internal entry point inside the telesrv container while
+preserving the hostname, HTTPS SNI, and certificate validation. If the reverse proxy publishes
+443 on the Docker host, test first with:
+
+```sh
+curl -vk --resolve bot.example.com:443:172.17.0.1 \
+ https://bot.example.com/health/unified
+```
+
+After that succeeds, a deployment may use a network-appropriate Compose entry such as:
+
+```yaml
+extra_hosts:
+ - "bot.example.com:host-gateway"
+```
+
+Other fixes include attaching telesrv to the reverse proxy's Docker network, allowing the Docker
+subnet to reach host port 443, or correcting cloud security-group/NAT hairpin rules. telesrv allows
+an internal HTTP receiver, but use one only on a controlled shared network and only when the
+application's `WEBHOOK_URL` is not also its public OIDC, payment, or media callback base. Do not
+blindly replace a global public URL with an internal address to mask a routing problem.
+
+#### 4. Close the loop after the fix
+
+1. Restart the bot application so it calls `setWebhook` again with the current URL, secret, and
+ `allowed_updates`.
+2. Send a new `/start` or press a callback button.
+3. Call `getWebhookInfo` again. `pending_update_count` should fall to `0`, with no new
+ `last_error_date`.
+4. Inspect telesrv Warning logs for `bot api webhook delivery failed`. The record contains
+ `bot_user_id`, `retry_in`, and the failure reason, but must not contain the webhook URL, Bot
+ token, or secret.
+5. Confirm that the receiver recorded and processed the `update_id`. Delivery is at-least-once, so
+ the application must safely handle duplicate updates caused by retries.
+
+Immediately rotate any Bot token, webhook secret, OIDC Client Secret, API key, or database
+password exposed in shell history, chat, or screenshots. Keep only redacted diagnostics in support
+material.
+
+### 3.2 Complete Telegram Login / OIDC setup
#### 1. Generate `data/telegram-login` once
@@ -154,8 +271,10 @@ and choose that bot. Initial setup returns:
- `Client Secret`: shown once, separate from the Bot API token, and meant to be saved immediately
in a secret manager.
-Send each configuration command separately. This example runs the relying party at
-`http://192.0.2.30:3000`:
+After selecting a bot once, BotFather keeps that configuration session active; there is no need to
+repeat `/setlogin` and the bot username for every change. Send commands one at a time or paste them
+as separate lines in one message (up to 32 lines per message). This example runs the relying party
+at `http://192.0.2.30:3000`:
```text
add origin http://192.0.2.30:3000
@@ -164,6 +283,12 @@ algorithm RS256
enable
```
+Send `/done` after the changes succeed. BotFather closes the session and returns the final
+configuration summary. Every successful change takes effect immediately, so `/cancel` only closes
+the session and does not roll back changes. If a multi-line message fails partway through,
+BotFather identifies the applied lines, the failed line, and the later lines that were skipped,
+then keeps the selected bot active for a corrected command.
+
An `origin` is an exact Web origin without a path, query, or fragment; it authorizes the JS SDK,
popup CORS, and legacy `login_url`. A `redirect` is the exact full URI that receives an
Authorization Code. Wildcards and prefix matching are not supported. Use `/logininfo` to inspect
diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md
index 4c2e6af2..69433930 100644
--- a/docs/configuration.zh-CN.md
+++ b/docs/configuration.zh-CN.md
@@ -77,7 +77,117 @@
| `TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL` | duration / `5m` | retention worker 周期,限定 `10s..1h`。 |
| `TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH` | int / `500` | 每轮最大清理行数,限定 `1..1000`。 |
-### 3.1 Telegram Login / OIDC 完整启用流程
+### 3.1 Bot API webhook 故障排查
+
+先区分三个地址,禁止把 webhook 接收域名当成 Bot API 地址:
+
+| 名称 | 配置/来源 | 方向与用途 |
+|---|---|---|
+| Bot API listener | telesrv 的 `TELESRV_BOT_API_ADDR` | telesrv 的监听地址;空值表示关闭。`0.0.0.0` 只能用于 bind,不能作为客户端请求目标。 |
+| Bot API base URL | bot 应用的 `TELEGRAM_API_URL` 等配置 | bot 应用访问 telesrv 的可达地址,例如 `http://172.17.0.1:8088`。方法地址为 `/bot/`,文件地址为 `/file/bot/`。 |
+| Webhook receiver URL | bot 应用的 `WEBHOOK_URL + WEBHOOK_PATH`,经 `setWebhook` 登记 | telesrv 主动 POST update 的目标,例如 `https://bot.example.com/webhook`。它不是 Bot API base URL。 |
+
+网络方向也不同:polling 是 `bot 应用 -> telesrv Bot API`,webhook 是
+`telesrv -> bot 应用 webhook receiver`。因此 polling 正常只能证明前一条路径可达,
+不能证明 webhook 的 DNS、出站 TCP、TLS、反向代理或 Docker hairpin 路径正常。
+
+#### 1. 从 Bot API 查询真实 webhook 状态
+
+应在 bot 应用容器中使用它实际配置的 Bot API base URL;不要把 token 展开后粘贴到
+聊天、工单或截图:
+
+```sh
+curl -sS -X POST \
+ "${TELEGRAM_API_URL%/}/bot${BOT_TOKEN}/getWebhookInfo" | jq
+```
+
+若没有 `TELEGRAM_API_URL` 这个变量,就把它替换成与
+`TELESRV_BOT_API_ADDR` 对应的**客户端可达地址**。例如 telesrv 监听
+`0.0.0.0:8088`,同宿主 Docker 容器可能使用 `http://172.17.0.1:8088`;不要请求
+`http://0.0.0.0:8088`。
+
+按下表判读响应:
+
+| 结果 | 结论与下一步 |
+|---|---|
+| `url` 为空 | webhook 没有登记到这台 telesrv;检查 bot 应用是否确实使用该 Bot API base URL,以及启动时 `setWebhook` 是否成功。 |
+| `pending_update_count` 增长 | update 已进入 telesrv durable queue,但没有成功交付;继续看 `last_error_message`。 |
+| `last_error_message` 为 HTTP `401`/`403` | 接收端已可达,但 webhook secret 不一致或请求被认证层拒绝。 |
+| `dial tcp ... i/o timeout` | telesrv 到目标 IP/端口的连接超时;检查出站防火墙、Docker 网络、回环 NAT/hairpin 和安全组。 |
+| `connection refused` | 目标地址可达,但相应端口没有监听或端口映射/反代 upstream 错误。 |
+| DNS/`no such host` | telesrv 所在运行环境无法解析 webhook hostname。 |
+| TLS/`x509` 错误 | 证书链、hostname、SNI 或容器 CA trust 有问题。HTTPS 使用系统信任链。 |
+| `allowed_updates` 不含目标类型 | 新产生的该类型 update 不会入队;普通 `/start` 至少需要 `message`。 |
+| pending 归零但应用无响应 | telesrv 已收到 2xx;转查接收应用内部 queue、worker、dispatcher 和 handler 日志。 |
+
+`getWebhookInfo` 查询的是 telesrv 持久化的交付事实;应用自己的 `/health` 只能证明
+接收路由和 worker 已启动,不能代替这一步。
+
+#### 2. 用正确请求头验证接收端
+
+Telegram webhook secret 与 Bot token、OIDC Client Secret、API key 都是不同凭据。
+接收端校验的标准请求头是 `X-Telegram-Bot-Api-Secret-Token`,不是
+`Authorization: Bearer`:
+
+```sh
+curl -i -X POST "${WEBHOOK_URL%/}${WEBHOOK_PATH}" \
+ -H 'Content-Type: application/json' \
+ -H "X-Telegram-Bot-Api-Secret-Token: ${WEBHOOK_SECRET_TOKEN}" \
+ -d '{"update_id":2147483000}'
+```
+
+预期为 HTTP 2xx。`401 invalid_secret_token` 表示请求已经到达应用,但 header 缺失或
+值不匹配。编辑 `.env` 后必须重建/重启读取该配置的应用;只修改磁盘文件不会更新
+已经登记到 telesrv 的 secret,也不会更新接收进程启动时捕获的 secret。
+
+#### 3. 从 telesrv 的实际网络命名空间测试
+
+浏览器或官方 Telegram 能访问公网 webhook,只能证明公网入站正常。必须从实际运行
+telesrv 的宿主机、容器或 network namespace 再测一次:
+
+```sh
+docker exec sh -lc \
+ 'getent hosts bot.example.com; curl -vk --connect-timeout 10 https://bot.example.com/health/unified'
+```
+
+如果公网客户端正常而这里 `dial tcp ...:443: i/o timeout`,常见原因是同机公网 IP
+回环失败。优先使用 split DNS 或容器 host mapping,让公网 hostname 在 telesrv 容器
+内解析到反向代理的内部入口,同时保留原 hostname、HTTPS SNI 和证书校验。例如反代
+的 443 已发布到 Docker 宿主机时,可先验证:
+
+```sh
+curl -vk --resolve bot.example.com:443:172.17.0.1 \
+ https://bot.example.com/health/unified
+```
+
+验证通过后,可在 telesrv Compose 中使用与实际网络匹配的配置:
+
+```yaml
+extra_hosts:
+ - "bot.example.com:host-gateway"
+```
+
+其它可选修复包括:把 telesrv 接入反向代理所在 Docker network、为 Docker subnet
+放行宿主机 443,或修正云安全组/NAT hairpin。telesrv 允许登记内部 HTTP receiver,
+但只有在两端共享受控内网且调用方的 `WEBHOOK_URL` 不同时承担 OIDC、支付或公开媒体
+回调时才应使用;不要为绕过网络问题盲目把应用的全局公开 URL 改成内部地址。
+
+#### 4. 修复后的闭环验证
+
+1. 重新启动 bot 应用,让它用当前 URL、secret 和 `allowed_updates` 再次调用
+ `setWebhook`。
+2. 发送一条新的 `/start` 或点击 callback 按钮。
+3. 再次调用 `getWebhookInfo`;`pending_update_count` 应下降到 `0`,且不再出现新的
+ `last_error_date`。
+4. 检查 telesrv Warning 日志中的 `bot api webhook delivery failed`。日志包含
+ `bot_user_id`、`retry_in` 和失败原因,但不得记录 webhook URL、Bot token 或 secret。
+5. 检查接收应用是否记录并处理该 `update_id`。webhook 是 at-least-once,应用必须能
+ 安全处理失败重试带来的重复 update。
+
+若凭据曾出现在命令历史、聊天或截图中,立即轮换 Bot token、webhook secret、OIDC
+Client Secret 及同屏暴露的其它 API key/数据库密码;排查资料只保留脱敏结果。
+
+### 3.2 Telegram Login / OIDC 完整启用流程
#### 1. 一次性生成 `data/telegram-login`
@@ -151,7 +261,9 @@ discovery 返回的 `issuer` 必须等于配置值,`authorization_endpoint`、
- `Client ID`:bot user ID 的十进制字符串;
- `Client Secret`:只显示一次,与 Bot API token 不同,必须立即保存到密钥管理系统。
-接着逐条发送配置命令。下面假设依赖方页面运行在 `http://192.0.2.30:3000`:
+选择一次 bot 后会持续停留在它的配置会话中,无需为每项修改重复 `/setlogin` 和 bot
+username。可以逐条发送,也可以像下面这样在一条消息中粘贴多行命令(每条消息最多
+32 行)。下面假设依赖方页面运行在 `http://192.0.2.30:3000`:
```text
add origin http://192.0.2.30:3000
@@ -160,6 +272,11 @@ algorithm RS256
enable
```
+全部修改成功后发送 `/done`,BotFather 会退出配置会话并返回最终配置摘要。各条修改会
+立即生效;`/cancel` 只关闭当前会话,不会回滚已经成功的修改。多行消息若中途失败,
+BotFather 会明确列出已应用项、失败行以及未执行的后续行,并保留当前 bot 选择供修正
+后继续操作。
+
`origin` 只能是无 path/query/fragment 的精确 Web origin,用于 JS SDK、popup CORS 和
legacy `login_url`;`redirect` 是 Authorization Code Flow 返回 code 的精确完整 URI。
不支持 wildcard 或 prefix 匹配。用 `/logininfo` 检查状态和登记值;用 `/setlogin`
diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go
index d37d492f..26b1ab5b 100644
--- a/internal/app/bots/botfather.go
+++ b/internal/app/bots/botfather.go
@@ -37,6 +37,7 @@ const (
botFatherCmdSetLogin = "setlogin"
botFatherCmdLoginInfo = "logininfo"
botFatherCmdResetLogin = "resetloginsecret"
+ botFatherCmdDone = "done"
botFatherStepName = "name"
botFatherStepUsername = "username"
@@ -45,6 +46,8 @@ const (
botFatherDraftBotID = "bot_id"
botFatherDraftBotUsername = "bot_username"
+
+ maxTelegramLoginCommandsPerMessage = 32
)
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
@@ -67,6 +70,7 @@ You can control me by sending these commands:
/setlogin - configure Telegram Login allowed URLs and signing
/logininfo - show a bot's Telegram Login configuration
/resetloginsecret - rotate a bot's OIDC Client Secret
+/done - finish the active Telegram Login configuration
/cancel - cancel the current operation
/help - show this message`
@@ -176,7 +180,7 @@ func (s *Service) botReplyRandomID() int64 {
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
var botFatherGlobalCommands = map[string]bool{
- "start": true, "help": true, "cancel": true,
+ "start": true, "help": true, "cancel": true, botFatherCmdDone: true,
botFatherCmdNewBot: true, "mybots": true,
botFatherCmdToken: true, botFatherCmdRevoke: true,
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
@@ -288,7 +292,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: botFatherHelpText}
case "cancel":
- _, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
+ state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
@@ -300,7 +304,12 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
+ if state.Command == botFatherCmdSetLogin && state.Step == botFatherStepValue {
+ return botReply{Text: "Telegram Login configuration closed. Changes that were already applied have been kept."}
+ }
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
+ case botFatherCmdDone:
+ return s.finishTelegramLoginConfiguration(ctx, userID)
case botFatherCmdNewBot:
count, err := s.bots.CountBotsByOwner(ctx, userID)
if err != nil {
@@ -577,7 +586,7 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
case botFatherCmdSetLogin:
- reply, err = s.applyTelegramLoginConfiguration(ctx, botID, username, text)
+ return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
default:
s.clearState(ctx, state.UserID)
return internalReply()
@@ -655,7 +664,7 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
}
func telegramLoginConfigurationPrompt(username string) string {
- return fmt.Sprintf(`Send one configuration command for @%s:
+ return fmt.Sprintf(`Configure Telegram Login for @%s. Send commands one at a time or paste up to %d commands on separate lines:
add origin https://example.com
add redirect https://example.com/auth/callback
@@ -668,7 +677,117 @@ algorithm RS256|ES256|EdDSA|ES256K
enable
disable
-Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Run /logininfo to inspect the result or /cancel to stop.`, username)
+Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Changes apply immediately. Send /done to finish, or /cancel to close this session without undoing changes already applied.`, username, maxTelegramLoginCommandsPerMessage)
+}
+
+func telegramLoginConfigurationContinuePrompt(username string) string {
+ return fmt.Sprintf("Still configuring @%s. Send another command, paste multiple commands on separate lines, or send /done to finish.", username)
+}
+
+func (s *Service) finishTelegramLoginConfiguration(ctx context.Context, userID int64) botReply {
+ state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
+ if err != nil {
+ s.log.Error("botfather: get telegram login state", zap.Int64("user_id", userID), zap.Error(err))
+ return internalReply()
+ }
+ if !found || state.Command != botFatherCmdSetLogin || state.Step != botFatherStepValue {
+ return botReply{Text: "There is no active Telegram Login configuration to finish. Send /setlogin to start one."}
+ }
+ botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
+ username := state.Draft[botFatherDraftBotUsername]
+ if botID == 0 || username == "" {
+ s.clearState(ctx, userID)
+ return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /setlogin to start again."}
+ }
+ owns, err := s.OwnsBot(ctx, userID, botID)
+ if err != nil {
+ s.log.Error("botfather: verify telegram login owner", zap.Int64("user_id", userID), zap.Int64("bot_user_id", botID), zap.Error(err))
+ return internalReply()
+ }
+ if !owns {
+ s.clearState(ctx, userID)
+ return botReply{Text: "That bot is no longer available."}
+ }
+ if s.telegramLogin == nil {
+ s.clearState(ctx, userID)
+ return botReply{Text: "Telegram Login is not enabled on this server."}
+ }
+ configuration, configured, err := s.telegramLogin.ClientConfiguration(ctx, botID)
+ if err != nil {
+ s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", botID), zap.Error(err))
+ return internalReply()
+ }
+ if !configured {
+ s.clearState(ctx, userID)
+ return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Send /setlogin to create it.", username)}
+ }
+ if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
+ s.log.Error("botfather: finish telegram login state", zap.Int64("user_id", userID), zap.Error(err))
+ return internalReply()
+ }
+ return botReply{Text: fmt.Sprintf("Finished configuring Telegram Login for @%s.\n\n%s", username, formatTelegramLoginConfiguration(username, configuration))}
+}
+
+func (s *Service) handleTelegramLoginConfigurationInput(
+ ctx context.Context,
+ state domain.BotChatState,
+ botID int64,
+ username string,
+ text string,
+) botReply {
+ if strings.EqualFold(strings.TrimSpace(text), "done") {
+ return s.finishTelegramLoginConfiguration(ctx, state.UserID)
+ }
+ lines := make([]string, 0, 4)
+ for _, raw := range strings.Split(text, "\n") {
+ if line := strings.TrimSpace(raw); line != "" {
+ lines = append(lines, line)
+ }
+ }
+ if len(lines) == 0 {
+ return botReply{Text: "Send a Telegram Login configuration command.\n\n" + telegramLoginConfigurationContinuePrompt(username)}
+ }
+ if len(lines) > maxTelegramLoginCommandsPerMessage {
+ return botReply{Text: fmt.Sprintf("Too many commands in one message. Send at most %d lines at a time.\n\n%s", maxTelegramLoginCommandsPerMessage, telegramLoginConfigurationContinuePrompt(username))}
+ }
+
+ applied := make([]string, 0, len(lines))
+ for i, line := range lines {
+ reply, err := s.applyTelegramLoginConfiguration(ctx, botID, username, line)
+ if err != nil {
+ if len(lines) == 1 {
+ if reply.Text == "" {
+ return internalReply()
+ }
+ return botReply{Text: reply.Text + "\n\n" + telegramLoginConfigurationContinuePrompt(username)}
+ }
+ failure := reply.Text
+ if failure == "" {
+ failure = "Something went wrong on my side. Please try that line again later."
+ }
+ var out strings.Builder
+ if len(applied) > 0 {
+ fmt.Fprintf(&out, "Applied %d command(s) before the error:\n%s\n\n", len(applied), strings.Join(applied, "\n"))
+ }
+ fmt.Fprintf(&out, "Stopped at line %d:\n%s\n\n", i+1, failure)
+ if i+1 < len(lines) {
+ fmt.Fprintf(&out, "%d later command(s) were not applied.\n\n", len(lines)-i-1)
+ }
+ out.WriteString(telegramLoginConfigurationContinuePrompt(username))
+ return botReply{Text: out.String()}
+ }
+ applied = append(applied, fmt.Sprintf("Line %d: %s", i+1, reply.Text))
+ }
+
+ var out strings.Builder
+ if len(lines) == 1 {
+ out.WriteString(strings.TrimPrefix(applied[0], "Line 1: "))
+ } else {
+ fmt.Fprintf(&out, "Applied all %d commands:\n%s", len(applied), strings.Join(applied, "\n"))
+ }
+ out.WriteString("\n\n")
+ out.WriteString(telegramLoginConfigurationContinuePrompt(username))
+ return botReply{Text: out.String()}
}
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
@@ -733,7 +852,7 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
return botReply{}, err
}
- return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s. Use /setlogin for another change or /logininfo to review it.", username)}, nil
+ return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s.", username)}, nil
case "disable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
return botReply{}, err
@@ -763,7 +882,7 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int
if strings.EqualFold(fields[0], "add") {
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
- return botReply{Text: "That URL is not allowed. Use an exact HTTPS URL without credentials, fragments or reserved OAuth query fields."}, err
+ return botReply{Text: "That URL is not allowed. Use an exact HTTP(S) URL permitted by this server without credentials, fragments or reserved OAuth query fields."}, err
}
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
}
diff --git a/internal/app/bots/botfather_login_test.go b/internal/app/bots/botfather_login_test.go
index f48543fe..72360081 100644
--- a/internal/app/bots/botfather_login_test.go
+++ b/internal/app/bots/botfather_login_test.go
@@ -8,6 +8,7 @@ import (
"time"
telegramloginapp "telesrv/internal/app/telegramlogin"
+ "telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@@ -32,7 +33,7 @@ func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
}
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
- svc, users, _, messages := newTestService(t)
+ svc, users, bots, messages := newTestService(t)
svc.telegramLogin = newBotFatherTelegramLoginService(t)
owner := newOwner(t, users, "+1090")
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
@@ -55,31 +56,30 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
t.Fatalf("add origin reply = %q", reply)
}
-
- sendToBotFather(t, svc, messages, owner, "/setlogin")
- sendToBotFather(t, svc, messages, owner, "login_demo_bot")
+ state, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID)
+ if err != nil || !found || state.Step != botFatherStepValue || state.Draft[botFatherDraftBotID] != strconv.FormatInt(bot.ID, 10) {
+ t.Fatalf("state after first command = %+v, found=%v err=%v", state, found, err)
+ }
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
t.Fatalf("add redirect reply = %q", reply)
}
-
- sendToBotFather(t, svc, messages, owner, "/setlogin")
- sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
t.Fatalf("algorithm reply = %q", reply)
}
-
- sendToBotFather(t, svc, messages, owner, "/setlogin")
- sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
t.Fatalf("add iOS app reply = %q", reply)
}
-
- sendToBotFather(t, svc, messages, owner, "/setlogin")
- sendToBotFather(t, svc, messages, owner, "login_demo_bot")
fingerprint := strings.Repeat("A", 64)
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
t.Fatalf("add Android app reply = %q", reply)
}
+ done := sendToBotFather(t, svc, messages, owner, "/done")
+ if !strings.Contains(done, "Finished configuring") || !strings.Contains(done, "Signing algorithm: ES256") {
+ t.Fatalf("/done reply = %q", done)
+ }
+ if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
+ t.Fatalf("state after /done: found=%v err=%v", found, err)
+ }
sendToBotFather(t, svc, messages, owner, "/logininfo")
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
@@ -98,3 +98,70 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
t.Fatalf("rotate reply = %q", rotated)
}
}
+
+func TestBotFatherTelegramLoginBatchAndCancelFlow(t *testing.T) {
+ svc, users, bots, messages := newTestService(t)
+ svc.telegramLogin = newBotFatherTelegramLoginService(t)
+ owner := newOwner(t, users, "+1091")
+ bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Batch Login Demo", "batch_login_bot")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if reply := sendToBotFather(t, svc, messages, owner, "/done"); !strings.Contains(reply, "no active") {
+ t.Fatalf("inactive /done reply = %q", reply)
+ }
+ sendToBotFather(t, svc, messages, owner, "/setlogin")
+ sendToBotFather(t, svc, messages, owner, "@batch_login_bot")
+ tooMany := strings.TrimSuffix(strings.Repeat("enable\n", maxTelegramLoginCommandsPerMessage+1), "\n")
+ if reply := sendToBotFather(t, svc, messages, owner, tooMany); !strings.Contains(reply, "at most 32 lines") {
+ t.Fatalf("oversized batch reply = %q", reply)
+ }
+ oversizedConfiguration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
+ if err != nil || !found || len(oversizedConfiguration.AllowedURLs) != 0 || oversizedConfiguration.Client.SigningAlgorithm != "RS256" {
+ t.Fatalf("configuration after oversized batch = %+v, found=%v err=%v", oversizedConfiguration, found, err)
+ }
+ batch := strings.Join([]string{
+ "add origin http://batch.example.test:3000",
+ "add redirect http://batch.example.test:3000/auth/telegram/callback",
+ "algorithm ES256",
+ "enable",
+ }, "\n")
+ if reply := sendToBotFather(t, svc, messages, owner, batch); !strings.Contains(reply, "Applied all 4 commands") || !strings.Contains(reply, "/done") {
+ t.Fatalf("batch reply = %q", reply)
+ }
+ configuration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
+ if err != nil || !found || !configuration.Client.Enabled || configuration.Client.SigningAlgorithm != "ES256" || len(configuration.AllowedURLs) != 2 {
+ t.Fatalf("configuration after batch = %+v, found=%v err=%v", configuration, found, err)
+ }
+
+ partial := strings.Join([]string{
+ "add origin http://second.example.test:3001",
+ "add redirect not-a-url",
+ "disable",
+ }, "\n")
+ partialReply := sendToBotFather(t, svc, messages, owner, partial)
+ for _, want := range []string{"Applied 1 command(s) before the error", "Stopped at line 2", "1 later command(s) were not applied", "/done"} {
+ if !strings.Contains(partialReply, want) {
+ t.Fatalf("partial batch reply = %q, missing %q", partialReply, want)
+ }
+ }
+ configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
+ if err != nil || !found || !configuration.Client.Enabled || len(configuration.AllowedURLs) != 3 {
+ t.Fatalf("configuration after partial batch = %+v, found=%v err=%v", configuration, found, err)
+ }
+ if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || !found {
+ t.Fatalf("state after partial batch: found=%v err=%v", found, err)
+ }
+
+ if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "already applied have been kept") {
+ t.Fatalf("/cancel reply = %q", reply)
+ }
+ if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
+ t.Fatalf("state after /cancel: found=%v err=%v", found, err)
+ }
+ configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
+ if err != nil || !found || len(configuration.AllowedURLs) != 3 {
+ t.Fatalf("configuration after /cancel = %+v, found=%v err=%v", configuration, found, err)
+ }
+}
diff --git a/internal/botapi/webhook.go b/internal/botapi/webhook.go
index 8a480258..b2a6b52b 100644
--- a/internal/botapi/webhook.go
+++ b/internal/botapi/webhook.go
@@ -255,5 +255,5 @@ func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhoo
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))
+ d.logger.Warn("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
}
diff --git a/internal/botapi/webhook_test.go b/internal/botapi/webhook_test.go
index f31ad385..b6a8482a 100644
--- a/internal/botapi/webhook_test.go
+++ b/internal/botapi/webhook_test.go
@@ -10,6 +10,7 @@ import (
"time"
"go.uber.org/zap"
+ "go.uber.org/zap/zaptest/observer"
"telesrv/internal/domain"
)
@@ -115,7 +116,8 @@ func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testi
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)}
+ logCore, observedLogs := observer.New(zap.WarnLevel)
+ d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.New(logCore), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
d.deliver(context.Background(), base.webhook)
gateway.mu.Lock()
@@ -124,4 +126,18 @@ func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testi
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)
}
+ entries := observedLogs.FilterMessage("bot api webhook delivery failed").All()
+ if len(entries) != 1 {
+ t.Fatalf("delivery failure warning count = %d, want 1", len(entries))
+ }
+ fields := entries[0].ContextMap()
+ if fields["bot_user_id"] != int64(1001) || fields["reason"] != "webhook returned HTTP 503" {
+ t.Fatalf("delivery failure warning fields = %#v", fields)
+ }
+ if _, ok := fields["url"]; ok {
+ t.Fatalf("delivery failure warning must not include webhook URL: %#v", fields)
+ }
+ if _, ok := fields["secret_token"]; ok {
+ t.Fatalf("delivery failure warning must not include webhook secret: %#v", fields)
+ }
}
diff --git a/internal/store/memory/bot.go b/internal/store/memory/bot.go
index 63f457d1..ef0b31bc 100644
--- a/internal/store/memory/bot.go
+++ b/internal/store/memory/bot.go
@@ -78,6 +78,7 @@ func botFatherSeedProfile() domain.BotProfile {
{Command: "setlogin", Description: "configure Telegram Login"},
{Command: "logininfo", Description: "show Telegram Login configuration"},
{Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"},
+ {Command: "done", Description: "finish Telegram Login configuration"},
{Command: "cancel", Description: "cancel the current operation"},
{Command: "help", Description: "show help"},
},
diff --git a/internal/store/postgres/bot_integration_test.go b/internal/store/postgres/bot_integration_test.go
index 12bdf74f..d9dca476 100644
--- a/internal/store/postgres/bot_integration_test.go
+++ b/internal/store/postgres/bot_integration_test.go
@@ -35,6 +35,16 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 {
t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile)
}
+ hasDone := false
+ for _, command := range bfProfile.Commands {
+ if command.Command == "done" {
+ hasDone = true
+ break
+ }
+ }
+ if !hasDone {
+ t.Fatalf("BotFather commands = %+v, want /done for persistent /setlogin sessions", bfProfile.Commands)
+ }
// 空 phone 查询不得命中任何行。
if _, found, err := users.ByPhone(ctx, ""); err != nil || found {
t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err)