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)