From d923a6ed515f8d65cb655b81be819d41756c5b76 Mon Sep 17 00:00:00 2001 From: zhoujun0601 Date: Thu, 13 Aug 2026 12:42:11 -0400 Subject: [PATCH] feat: enable Telegram features on binding --- README.md | 6 +- backend/CONTRACT.md | 6 +- backend/internal/app/notify.go | 38 ++++- backend/internal/app/reminder_test.go | 138 +++++++++++++++++- docs/backend/data-notifications-and-backup.md | 2 +- frontend/src/main.tsx | 25 +--- frontend/src/styles.css | 5 - frontend/tests/settings-layout.spec.ts | 13 +- 8 files changed, 184 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b6e0667..df50ac7 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ http://服务器地址:8180 1. 在 Telegram 中联系 [@BotFather](https://t.me/BotFather),使用 `/newbot` 创建 Bot,并取得 Bot Token。 2. 打开“设置中心” → “Telegram”。 -3. 填写 Bot Token,按需勾选“启用提醒”和“启用查询菜单”。 +3. 填写 Bot Token。 4. 点击“验证并保存”。 5. 点击“生成绑定码”。 6. 在 Telegram 中打开刚创建的 Bot,发送页面显示的命令,例如: @@ -91,7 +91,7 @@ http://服务器地址:8180 7. 收到“绑定成功”后,返回页面点击“发送测试”。 -绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。“启用提醒”只控制自动额度提醒,关闭后仍可发送测试消息;关闭查询菜单后 Bot 不再处理查询命令,并移除 Telegram 客户端键盘。即使关闭查询菜单,仍可完成绑定并接收已启用的提醒;开启菜单后可使用以下按钮或命令。`/account` 会向已绑定会话显示账号的完整邮箱,因此只应绑定受信任的私有会话。 +绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。绑定成功后会自动启用额度提醒和查询菜单,可使用以下按钮或命令。`/account` 会向已绑定会话显示账号的完整邮箱,因此只应绑定受信任的私有会话。 - 当前用量(包含所有已添加连接) - 重置时间 / `/reset` @@ -101,7 +101,7 @@ http://服务器地址:8180 服务器必须能够访问 `https://api.telegram.org`。 -点击“解除绑定”会删除当前 Bot Token、Chat ID、开关和未使用的绑定码。该操作不可恢复,之后需要重新填写 Token 并绑定。 +点击“解除绑定”会删除当前 Bot Token、Chat ID 和未使用的绑定码。该操作不可恢复,之后需要重新填写 Token 并绑定。 ## 配置 SMTP 邮件 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 5ea494d..0bbd68a 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -88,11 +88,11 @@ ### Telegram -- `GET /api/v1/settings/telegram` 返回 `{chatId,enabled,menuEnabled,configured,botName?}`,不返回 Token 明文。 -- `PUT`(以及兼容保留的其他非 `GET`、非 `DELETE` 方法)接受 `{token,chatId,enabled,menuEnabled}`,Token 留空时保留旧值,保存前调用 Bot API `getMe` 验证。失败返回 400 或 502。菜单状态变化时响应可能包含 `warning`,表示设置已保存但 Telegram 客户端键盘同步失败。 +- `GET /api/v1/settings/telegram` 返回 `{chatId,enabled,menuEnabled,configured,botName?}`,不返回 Token 明文。`enabled` 和 `menuEnabled` 是兼容字段:`chatId != 0` 时两者始终为 `true`,未绑定时为 `false`。 +- `PUT`(以及兼容保留的其他非 `GET`、非 `DELETE` 方法)接受 `{token,chatId,enabled,menuEnabled}`,Token 留空时保留旧值,保存前调用 Bot API `getMe` 验证。失败返回 400 或 502。兼容字段 `enabled` 和 `menuEnabled` 的请求值不能关闭已绑定 Bot 的提醒或查询菜单。 - `POST /api/v1/settings/telegram/bind` 返回六位 `{code}`,绑定码有效十分钟。 - `POST /api/v1/settings/telegram/test` 向已绑定会话发送测试消息;未绑定或发送失败返回 502。 -- `DELETE /api/v1/settings/telegram` 原子删除 Bot Token、Chat ID、Bot 信息、开关和绑定码,并重置 update offset;成功返回 `{ok:true,warning?}`。删除后移除 Telegram 客户端键盘失败只产生 `warning`,不恢复已删除的秘密。 +- `DELETE /api/v1/settings/telegram` 原子删除 Bot Token、Chat ID、Bot 信息、兼容开关值和绑定码,并重置 update offset;成功返回 `{ok:true,warning?}`。删除后移除 Telegram 客户端键盘失败只产生 `warning`,不恢复已删除的秘密。 ## 6. 维护接口 diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index 8a57392..9c3967b 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -170,6 +170,8 @@ func (a *App) telegramSettings() TelegramSettings { t.Configured = token != "" } } + t.Enabled = t.ChatID != 0 + t.MenuEnabled = t.ChatID != 0 return t } func (a *App) telegramSecret() (TelegramSettings, error) { @@ -204,6 +206,8 @@ func (a *App) telegramAPI(w http.ResponseWriter, r *http.Request) { jsonOut(w, 400, map[string]string{"error": "Bot Token 必填"}) return } + in.Enabled = in.ChatID != 0 + in.MenuEnabled = in.ChatID != 0 var me struct { OK bool `json:"ok"` Result struct { @@ -309,7 +313,33 @@ func tgSend(t TelegramSettings, text string) error { } return tgCall(t.Token, "sendMessage", params, &out) } +func (a *App) syncLegacyTelegramMenu() { + a.telegramMu.Lock() + defer a.telegramMu.Unlock() + var t TelegramSettings + if !a.store.GetJSON("telegram", &t) || t.ChatID == 0 || t.MenuEnabled { + return + } + enc, ok := a.store.Get("telegram_token") + if !ok { + return + } + token, err := a.vault.Decrypt(enc) + if err != nil || token == "" { + return + } + t.Token = token + t.Configured = true + t.Enabled = true + t.MenuEnabled = true + if tgSend(t, "✅ Codex 查询菜单已自动启用\n\n现在可以使用菜单查询额度信息。") != nil { + return + } + t.Token = "" + _ = a.store.SetJSON("telegram", t) +} func (a *App) telegramLoop() { + a.syncLegacyTelegramMenu() for { select { case <-a.ctx.Done(): @@ -367,16 +397,14 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { } if a.store.GetJSON("telegram_bind", &b) && b.Expires > time.Now().Unix() && strings.TrimSpace(strings.TrimPrefix(text, "/bind ")) == b.Code { t.ChatID = chat + t.Enabled = true + t.MenuEnabled = true safe := t safe.Token = "" _ = a.store.SetJSON("telegram", safe) _ = a.store.Set("telegram_bind", "{}") t.ChatID = chat - message := "✅ 绑定成功\n\nCodex 额度提醒将发送到此会话。" - if t.MenuEnabled { - message = "✅ 绑定成功\n\n现在可以使用菜单查询 Codex 额度信息。" - } - _ = tgSend(t, message) + _ = tgSend(t, "✅ 绑定成功\n\n额度提醒和 Codex 额度查询菜单已启用。") } return } diff --git a/backend/internal/app/reminder_test.go b/backend/internal/app/reminder_test.go index ac29cf1..76c7138 100644 --- a/backend/internal/app/reminder_test.go +++ b/backend/internal/app/reminder_test.go @@ -186,7 +186,7 @@ func TestTelegramDeleteWaitsForInFlightSaveAndRemainsFinal(t *testing.T) { } } -func TestDisabledTelegramSkipsAutomaticReminderButAllowsManualTest(t *testing.T) { +func TestBoundTelegramIgnoresLegacyDisabledFlags(t *testing.T) { a := newReminderTestApp(t) enc, err := a.vault.Encrypt("secret-token") if err != nil { @@ -212,14 +212,144 @@ func TestDisabledTelegramSkipsAutomaticReminderButAllowsManualTest(t *testing.T) return nil } a.sendPendingReminders(now) - if calls != 0 { - t.Fatalf("automatic Telegram calls = %d; want 0", calls) + if calls != 1 { + t.Fatalf("automatic Telegram calls = %d; want 1", calls) } recorder := httptest.NewRecorder() a.telegramTest(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/settings/telegram/test", nil)) - if recorder.Code != http.StatusOK || calls != 1 { + if recorder.Code != http.StatusOK || calls != 2 { t.Fatalf("manual test status = %d calls = %d body = %s", recorder.Code, calls, recorder.Body.String()) } + settings := a.telegramSettings() + if !settings.Enabled || !settings.MenuEnabled { + t.Fatalf("effective settings = %#v", settings) + } +} + +func TestLegacyBoundTelegramRestoresMenuOnce(t *testing.T) { + a := newReminderTestApp(t) + enc, err := a.vault.Encrypt("secret-token") + if err != nil { + t.Fatal(err) + } + if err = a.store.Set("telegram_token", enc); err != nil { + t.Fatal(err) + } + if err = a.store.SetJSON("telegram", TelegramSettings{ChatID: 123, Enabled: false, MenuEnabled: false, Configured: true}); err != nil { + t.Fatal(err) + } + original := tgCall + t.Cleanup(func() { tgCall = original }) + calls := 0 + var sent map[string]any + tgCall = func(_ string, method string, params any, _ any) error { + if method != "sendMessage" { + t.Fatalf("method = %q", method) + } + calls++ + body, marshalErr := json.Marshal(params) + if marshalErr != nil { + return marshalErr + } + return json.Unmarshal(body, &sent) + } + + a.syncLegacyTelegramMenu() + a.syncLegacyTelegramMenu() + if calls != 1 { + t.Fatalf("menu restore calls = %d; want 1", calls) + } + replyMarkup, ok := sent["reply_markup"].(map[string]any) + if !ok || replyMarkup["keyboard"] == nil { + t.Fatalf("send params = %#v", sent) + } + var stored TelegramSettings + if !a.store.GetJSON("telegram", &stored) || !stored.Enabled || !stored.MenuEnabled { + t.Fatalf("stored settings = %#v", stored) + } +} + +func TestTelegramSaveCannotDisableFeaturesForBoundChat(t *testing.T) { + a := newReminderTestApp(t) + original := tgCall + t.Cleanup(func() { tgCall = original }) + tgCall = func(_ string, method string, _ any, out any) error { + if method == "sendMessage" { + return nil + } + if method == "getMe" { + return json.Unmarshal([]byte(`{"ok":true,"result":{"first_name":"Test","username":"test_bot"}}`), out) + } + t.Fatalf("method = %q", method) + return nil + } + + body := bytes.NewBufferString(`{"token":"token","chatId":123,"enabled":false,"menuEnabled":false}`) + recorder := httptest.NewRecorder() + a.telegramAPI(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/settings/telegram", body)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + var response TelegramSettings + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if !response.Enabled || !response.MenuEnabled { + t.Fatalf("response = %#v", response) + } + var stored TelegramSettings + if !a.store.GetJSON("telegram", &stored) || !stored.Enabled || !stored.MenuEnabled { + t.Fatalf("stored settings = %#v", stored) + } +} + +func TestTelegramBindEnablesFeaturesAndSendsMenu(t *testing.T) { + a := newReminderTestApp(t) + enc, err := a.vault.Encrypt("secret-token") + if err != nil { + t.Fatal(err) + } + if err = a.store.Set("telegram_token", enc); err != nil { + t.Fatal(err) + } + if err = a.store.SetJSON("telegram", TelegramSettings{Configured: true}); err != nil { + t.Fatal(err) + } + if err = a.store.SetJSON("telegram_bind", map[string]any{"code": "123456", "expires": time.Now().Add(time.Minute).Unix()}); err != nil { + t.Fatal(err) + } + original := tgCall + t.Cleanup(func() { tgCall = original }) + var sent map[string]any + tgCall = func(_ string, method string, params any, _ any) error { + if method != "sendMessage" { + t.Fatalf("method = %q", method) + } + body, marshalErr := json.Marshal(params) + if marshalErr != nil { + return marshalErr + } + return json.Unmarshal(body, &sent) + } + + a.handleTG(a.telegramSecretForTest(t), 456, "/bind 123456") + settings := a.telegramSettings() + if settings.ChatID != 456 || !settings.Enabled || !settings.MenuEnabled { + t.Fatalf("settings = %#v", settings) + } + replyMarkup, ok := sent["reply_markup"].(map[string]any) + if !ok || replyMarkup["keyboard"] == nil { + t.Fatalf("send params = %#v", sent) + } +} + +func (a *App) telegramSecretForTest(t *testing.T) TelegramSettings { + t.Helper() + settings, err := a.telegramSecret() + if err != nil { + t.Fatal(err) + } + return settings } func reminderDashboard(fetchedAt int64, used float64, resetsAt int64) Dashboard { diff --git a/docs/backend/data-notifications-and-backup.md b/docs/backend/data-notifications-and-backup.md index 281071e..7f2a17f 100644 --- a/docs/backend/data-notifications-and-backup.md +++ b/docs/backend/data-notifications-and-backup.md @@ -27,6 +27,6 @@ ## Telegram 与 SMTP -Telegram 保存加密 Token、Chat ID、启用开关和菜单开关。保存 Token 前调用 `getMe`;long polling timeout 为 25 秒,HTTP client timeout 为 35 秒。六位绑定码十分钟有效且一次成功后清除。只有绑定 Chat ID 且启用菜单时才处理查询命令;每条 update 在处理前重新核对当前 Token 和开关,关闭菜单会通过 `remove_keyboard` 清理客户端键盘。更换 Token 或删除配置会重置 update offset。解除绑定原子删除 Token、Chat ID、Bot 信息、开关和绑定码;随后清理 Telegram 键盘失败不会恢复本地秘密。 +Telegram 保存加密 Token、Chat ID 和 Bot 信息。保存 Token 前调用 `getMe`;long polling timeout 为 25 秒,HTTP client timeout 为 35 秒。六位绑定码十分钟有效且一次成功后清除。存在绑定 Chat ID 时自动启用额度提醒和查询菜单;启动时如发现旧版本已绑定但关闭了菜单,会主动发送带键盘的启用通知,成功后写回新状态。每条 update 在处理前重新核对当前 Token 和 Chat ID。更换 Token 或删除配置会重置 update offset。解除绑定原子删除 Token、Chat ID、Bot 信息、兼容开关值和绑定码;随后清理 Telegram 键盘失败不会恢复本地秘密。 SMTP 支持 `starttls`、隐式 `tls` 和 `none`,TLS 最低 1.2;支持可选 PLAIN AUTH,发送 multipart text/html。TCP 连接和后续 SMTP/TLS 读写共享 35 秒 deadline。修改外部调用时必须保留这一超时边界、TLS server name、HTML 转义和不记录秘密的错误处理。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0694f4c..fef3d05 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1026,28 +1026,7 @@ function Telegram() { } /> -
- - -
-

- “启用提醒”只控制自动额度提醒,关闭后仍可发送测试消息;“启用查询菜单”关闭后将停止查询并移除 - Telegram 键盘。 -

+

Bot 绑定后会自动启用额度提醒和查询菜单。