feat: sync bot setup and webhook diagnostics

This commit is contained in:
A 2026-07-22 00:51:52 +08:00
parent f53579416e
commit d9875b5caa
11 changed files with 521 additions and 29 deletions

View file

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

View file

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

View file

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

View file

@ -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 `<base>/bot<TOKEN>/<method>` and file URLs are `<base>/file/bot<TOKEN>/<file_path>`. |
| 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 <telesrv-container> 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

View file

@ -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`。方法地址为 `<base>/bot<TOKEN>/<method>`,文件地址为 `<base>/file/bot<TOKEN>/<file_path>`。 |
| 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 <telesrv-container> 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`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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