feat: sync bot setup and webhook diagnostics
This commit is contained in:
parent
f53579416e
commit
d9875b5caa
11 changed files with 521 additions and 29 deletions
|
|
@ -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`;
|
||||
|
|
|
|||
13
deploy/migrations/0130_botfather_done_command.down.sql
Normal file
13
deploy/migrations/0130_botfather_done_command.down.sql
Normal 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;
|
||||
22
deploy/migrations/0130_botfather_done_command.up.sql
Normal file
22
deploy/migrations/0130_botfather_done_command.up.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue