diff --git a/README.md b/README.md index cb35ab7..b6e0667 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ http://服务器地址:8180 7. 收到“绑定成功”后,返回页面点击“发送测试”。 -绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。即使关闭查询菜单,仍可完成绑定并接收提醒;开启菜单后可使用以下按钮或命令。`/account` 会向已绑定会话显示账号的完整邮箱,因此只应绑定受信任的私有会话。 +绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。“启用提醒”只控制自动额度提醒,关闭后仍可发送测试消息;关闭查询菜单后 Bot 不再处理查询命令,并移除 Telegram 客户端键盘。即使关闭查询菜单,仍可完成绑定并接收已启用的提醒;开启菜单后可使用以下按钮或命令。`/account` 会向已绑定会话显示账号的完整邮箱,因此只应绑定受信任的私有会话。 - 当前用量(包含所有已添加连接) - 重置时间 / `/reset` @@ -101,6 +101,8 @@ http://服务器地址:8180 服务器必须能够访问 `https://api.telegram.org`。 +点击“解除绑定”会删除当前 Bot Token、Chat ID、开关和未使用的绑定码。该操作不可恢复,之后需要重新填写 Token 并绑定。 + ## 配置 SMTP 邮件 进入“设置中心” → “SMTP 邮件”,填写: diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 98bb26a..5ea494d 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -89,9 +89,10 @@ ### Telegram - `GET /api/v1/settings/telegram` 返回 `{chatId,enabled,menuEnabled,configured,botName?}`,不返回 Token 明文。 -- 任意非 `GET` 方法都按更新处理,前端使用 `PUT`;接受 `{token,chatId,enabled,menuEnabled}`,Token 留空时保留旧值,保存前调用 Bot API `getMe` 验证。失败返回 400 或 502。 +- `PUT`(以及兼容保留的其他非 `GET`、非 `DELETE` 方法)接受 `{token,chatId,enabled,menuEnabled}`,Token 留空时保留旧值,保存前调用 Bot API `getMe` 验证。失败返回 400 或 502。菜单状态变化时响应可能包含 `warning`,表示设置已保存但 Telegram 客户端键盘同步失败。 - `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`,不恢复已删除的秘密。 ## 6. 维护接口 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index d727d36..6542856 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -131,8 +131,10 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { case p == "settings/telegram/test" && r.Method == "POST": a.telegramTest(w, r) case p == "settings/telegram/bind" && r.Method == "POST": + a.telegramMu.Lock() code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000) _ = a.store.SetJSON("telegram_bind", map[string]any{"code": code, "expires": time.Now().Add(10 * time.Minute).Unix()}) + a.telegramMu.Unlock() jsonOut(w, 200, map[string]string{"code": code}) case p == "maintenance/cleanup" && r.Method == "POST": n, e := a.store.Cleanup(a.general().RetentionDays) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index bc6cfcd..b8d2dc6 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -39,6 +39,7 @@ type App struct { runtimes map[int64]*accountRuntime loginAttempts sync.Map reminderMu sync.Mutex + telegramMu sync.Mutex } type accountRuntime struct { client codexClient diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index c5b823d..8a57392 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -165,8 +165,10 @@ func sendSMTPWithTimeout(s SMTPSettings, subject, textBody, htmlBody string, tim func (a *App) telegramSettings() TelegramSettings { var t TelegramSettings a.store.GetJSON("telegram", &t) - if _, ok := a.store.Get("telegram_token"); ok { - t.Configured = true + if enc, ok := a.store.Get("telegram_token"); ok { + if token, err := a.vault.Decrypt(enc); err == nil { + t.Configured = token != "" + } } return t } @@ -183,6 +185,12 @@ func (a *App) telegramAPI(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, a.telegramSettings()) return } + a.telegramMu.Lock() + defer a.telegramMu.Unlock() + if r.Method == http.MethodDelete { + a.telegramDelete(w) + return + } var in TelegramSettings if decode(r, &in) != nil { jsonOut(w, 400, map[string]string{"error": "配置格式错误"}) @@ -208,13 +216,52 @@ func (a *App) telegramAPI(w http.ResponseWriter, r *http.Request) { return } in.BotName = me.Result.FirstName + " @" + me.Result.Username - enc, _ := a.vault.Encrypt(in.Token) + enc, e := a.vault.Encrypt(in.Token) + if e != nil { + jsonOut(w, 500, map[string]string{"error": e.Error()}) + return + } safe := in safe.Token = "" safe.Configured = true - _ = a.store.Set("telegram_token", enc) - _ = a.store.SetJSON("telegram", safe) - jsonOut(w, 200, safe) + settingsJSON, e := json.Marshal(safe) + if e == nil { + e = a.store.SaveTelegram(string(settingsJSON), enc, old.Token != "" && old.Token != in.Token) + } + if e != nil { + jsonOut(w, 500, map[string]string{"error": e.Error()}) + return + } + response := TelegramSettingsResponse{TelegramSettings: safe} + if safe.ChatID != 0 && old.MenuEnabled != safe.MenuEnabled { + message := "查询菜单已关闭。" + if safe.MenuEnabled { + message = "查询菜单已启用。" + } + if e = tgSend(safeWithToken(safe, in.Token), message); e != nil { + response.Warning = "设置已保存,但 Telegram 菜单同步失败,请稍后发送测试消息重试" + } + } + jsonOut(w, 200, response) +} +func safeWithToken(t TelegramSettings, token string) TelegramSettings { + t.Token = token + return t +} +func (a *App) telegramDelete(w http.ResponseWriter) { + old, _ := a.telegramSecret() + if e := a.store.DeleteTelegram(); e != nil { + jsonOut(w, 500, map[string]string{"error": e.Error()}) + return + } + response := map[string]any{"ok": true} + if old.Token != "" && old.ChatID != 0 { + old.MenuEnabled = false + if e := tgSend(old, "Codex Helper 已解除 Telegram Bot 绑定。"); e != nil { + response["warning"] = "配置已删除,但未能从 Telegram 会话移除旧菜单" + } + } + jsonOut(w, 200, response) } func (a *App) telegramTest(w http.ResponseWriter, r *http.Request) { t, e := a.telegramSecret() @@ -231,7 +278,10 @@ func (a *App) telegramTest(w http.ResponseWriter, r *http.Request) { } jsonOut(w, 200, map[string]bool{"ok": true}) } -func tgCall(token, method string, p any, out any) error { + +var tgCall = telegramCall + +func telegramCall(token, method string, p any, out any) error { b, _ := json.Marshal(p) req, e := http.NewRequestWithContext(context.Background(), "POST", "https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(b)) if e != nil { @@ -254,6 +304,8 @@ func tgSend(t TelegramSettings, text string) error { params := map[string]any{"chat_id": t.ChatID, "text": text, "parse_mode": "HTML"} if t.MenuEnabled { params["reply_markup"] = map[string]any{"keyboard": [][]map[string]string{{{"text": "当前用量"}, {"text": "重置时间"}}, {{"text": "历史概览"}, {"text": "账户信息"}}, {{"text": "立即刷新"}}}, "resize_keyboard": true} + } else { + params["reply_markup"] = map[string]any{"remove_keyboard": true} } return tgCall(t.Token, "sendMessage", params, &out) } @@ -288,14 +340,27 @@ func (a *App) telegramLoop() { for _, u := range out.Result { offset = u.UpdateID + 1 if u.Message != nil { - a.handleTG(t, u.Message.Chat.ID, u.Message.Text) + current, currentErr := a.telegramSecret() + if currentErr == nil && current.Configured && current.Token == t.Token { + a.handleTG(current, u.Message.Chat.ID, u.Message.Text) + } } } - _, _ = a.store.DB.Exec("UPDATE telegram_updates SET offset=? WHERE id=1", offset) + current, currentErr := a.telegramSecret() + if currentErr == nil && current.Configured && current.Token == t.Token { + _, _ = a.store.DB.Exec("UPDATE telegram_updates SET offset=? WHERE id=1", offset) + } } } func (a *App) handleTG(t TelegramSettings, chat int64, text string) { if strings.HasPrefix(text, "/bind ") { + a.telegramMu.Lock() + defer a.telegramMu.Unlock() + current, e := a.telegramSecret() + if e != nil || !current.Configured || current.Token != t.Token { + return + } + t = current var b struct { Code string `json:"code"` Expires int64 `json:"expires"` diff --git a/backend/internal/app/reminder_test.go b/backend/internal/app/reminder_test.go index edc6530..ac29cf1 100644 --- a/backend/internal/app/reminder_test.go +++ b/backend/internal/app/reminder_test.go @@ -1,12 +1,17 @@ package app import ( + "bytes" + "encoding/json" "net" + "net/http" + "net/http/httptest" "strconv" "strings" "testing" "time" + "codex-helper/internal/security" "codex-helper/internal/store" ) @@ -48,12 +53,173 @@ func TestSendSMTPStopsWhenServerDoesNotRespond(t *testing.T) { func newReminderTestApp(t *testing.T) *App { t.Helper() - s, err := store.Open(t.TempDir()) + dir := t.TempDir() + s, err := store.Open(dir) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = s.DB.Close() }) - return &App{store: s, runtimes: map[int64]*accountRuntime{}} + vault, err := security.OpenVault(dir) + if err != nil { + t.Fatal(err) + } + return &App{store: s, vault: vault, runtimes: map[int64]*accountRuntime{}} +} + +func TestTGSendKeepsOrRemovesKeyboard(t *testing.T) { + original := tgCall + t.Cleanup(func() { tgCall = original }) + var calls []map[string]any + tgCall = func(_ string, method string, params any, _ any) error { + if method != "sendMessage" { + t.Fatalf("method = %q", method) + } + body, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err = json.Unmarshal(body, &decoded); err != nil { + t.Fatal(err) + } + calls = append(calls, decoded) + return nil + } + if err := tgSend(TelegramSettings{Token: "token", ChatID: 1, MenuEnabled: true}, "on"); err != nil { + t.Fatal(err) + } + if err := tgSend(TelegramSettings{Token: "token", ChatID: 1}, "off"); err != nil { + t.Fatal(err) + } + keyboard := calls[0]["reply_markup"].(map[string]any) + removed := calls[1]["reply_markup"].(map[string]any) + if keyboard["keyboard"] == nil || removed["remove_keyboard"] != true { + t.Fatalf("reply markup mismatch: %#v %#v", keyboard, removed) + } +} + +func TestTelegramDeleteClearsConfigurationAndOffset(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: 0, Enabled: true, MenuEnabled: true, Configured: true}); err != nil { + t.Fatal(err) + } + if err = a.store.Set("telegram_bind", `{"code":"123456"}`); err != nil { + t.Fatal(err) + } + if _, err = a.store.DB.Exec("UPDATE telegram_updates SET offset=42 WHERE id=1"); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + a.telegramAPI(recorder, httptest.NewRequest(http.MethodDelete, "/api/v1/settings/telegram", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String()) + } + for _, key := range []string{"telegram", "telegram_token", "telegram_bind"} { + if _, ok := a.store.Get(key); ok { + t.Fatalf("setting %q still exists", key) + } + } + var offset int64 + if err = a.store.DB.QueryRow("SELECT offset FROM telegram_updates WHERE id=1").Scan(&offset); err != nil || offset != 0 { + t.Fatalf("offset = %d err = %v", offset, err) + } + if got := a.telegramSettings(); got.Configured || got.ChatID != 0 || got.Enabled || got.MenuEnabled { + t.Fatalf("settings after delete = %#v", got) + } +} + +func TestTelegramDeleteWaitsForInFlightSaveAndRemainsFinal(t *testing.T) { + a := newReminderTestApp(t) + original := tgCall + t.Cleanup(func() { tgCall = original }) + getMeStarted := make(chan struct{}) + releaseGetMe := make(chan struct{}) + tgCall = func(_ string, method string, _ any, out any) error { + if method != "getMe" { + t.Fatalf("method = %q", method) + } + close(getMeStarted) + <-releaseGetMe + body := []byte(`{"ok":true,"result":{"first_name":"Test","username":"test_bot"}}`) + return json.Unmarshal(body, out) + } + + putDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + body := bytes.NewBufferString(`{"token":"new-token","chatId":0,"enabled":true,"menuEnabled":false}`) + recorder := httptest.NewRecorder() + a.telegramAPI(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/settings/telegram", body)) + putDone <- recorder + }() + <-getMeStarted + + deleteDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + recorder := httptest.NewRecorder() + a.telegramAPI(recorder, httptest.NewRequest(http.MethodDelete, "/api/v1/settings/telegram", nil)) + deleteDone <- recorder + }() + select { + case recorder := <-deleteDone: + t.Fatalf("delete completed before save: status=%d body=%s", recorder.Code, recorder.Body.String()) + case <-time.After(50 * time.Millisecond): + } + close(releaseGetMe) + if recorder := <-putDone; recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d body = %s", recorder.Code, recorder.Body.String()) + } + if recorder := <-deleteDone; recorder.Code != http.StatusOK { + t.Fatalf("DELETE status = %d body = %s", recorder.Code, recorder.Body.String()) + } + if _, ok := a.store.Get("telegram_token"); ok { + t.Fatal("Telegram token was recreated after delete") + } + if _, ok := a.store.Get("telegram"); ok { + t.Fatal("Telegram settings were recreated after delete") + } +} + +func TestDisabledTelegramSkipsAutomaticReminderButAllowsManualTest(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) + } + now := time.Now() + if _, err = a.store.DB.Exec(`INSERT INTO notifications + (dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at,body) + VALUES('disabled-test','configured','before','pending',0,'',?,NULL,'message')`, now.Unix()); err != nil { + t.Fatal(err) + } + original := tgCall + t.Cleanup(func() { tgCall = original }) + calls := 0 + tgCall = func(_ string, _ string, _ any, _ any) error { + calls++ + return nil + } + a.sendPendingReminders(now) + if calls != 0 { + t.Fatalf("automatic Telegram calls = %d; want 0", calls) + } + recorder := httptest.NewRecorder() + a.telegramTest(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/settings/telegram/test", nil)) + if recorder.Code != http.StatusOK || calls != 1 { + t.Fatalf("manual test status = %d calls = %d body = %s", recorder.Code, calls, recorder.Body.String()) + } } func reminderDashboard(fetchedAt int64, used float64, resetsAt int64) Dashboard { diff --git a/backend/internal/app/types.go b/backend/internal/app/types.go index 37e11ca..7e484b1 100644 --- a/backend/internal/app/types.go +++ b/backend/internal/app/types.go @@ -29,6 +29,10 @@ type TelegramSettings struct { Configured bool `json:"configured"` BotName string `json:"botName,omitempty"` } +type TelegramSettingsResponse struct { + TelegramSettings + Warning string `json:"warning,omitempty"` +} type AccountView struct { Email *string `json:"email"` AuthMode *string `json:"authMode"` diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 30a2424..5ec10a8 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -305,6 +305,39 @@ func (s *Store) SetJSON(key string, v any) error { } return s.Set(key, string(b)) } +func (s *Store) SaveTelegram(settingsJSON, encryptedToken string, resetOffset bool) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + now := time.Now().Unix() + for key, value := range map[string]string{"telegram": settingsJSON, "telegram_token": encryptedToken} { + if _, err = tx.Exec("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at", key, value, now); err != nil { + return err + } + } + if resetOffset { + if _, err = tx.Exec("UPDATE telegram_updates SET offset=0 WHERE id=1"); err != nil { + return err + } + } + return tx.Commit() +} +func (s *Store) DeleteTelegram() error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec("DELETE FROM settings WHERE key IN ('telegram','telegram_token','telegram_bind')"); err != nil { + return err + } + if _, err = tx.Exec("UPDATE telegram_updates SET offset=0 WHERE id=1"); err != nil { + return err + } + return tx.Commit() +} func (s *Store) GetJSON(key string, v any) bool { raw, ok := s.Get(key) return ok && json.Unmarshal([]byte(raw), v) == nil diff --git a/docs/backend/data-notifications-and-backup.md b/docs/backend/data-notifications-and-backup.md index 96c24b7..281071e 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 且启用菜单时才处理查询命令。 +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 键盘失败不会恢复本地秘密。 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 6d05927..2856a6f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,7 @@ import { Activity, Bell, Check, + Github, LogOut, Moon, RefreshCw, @@ -24,6 +25,7 @@ import { AuthProvider, useAuth } from "./auth"; import { ThemeProvider, useTheme } from "./theme"; import { decodeAccounts, + decodeAction, decodeCode, decodeDashboard, decodeDeviceLogin, @@ -42,6 +44,23 @@ import { import "./styles.css"; const UsageChart = lazy(() => import("./usage-chart")); +const repositoryURL = "https://github.com/zhoujun0601/codex-helper"; + +function GitHubLink({ className = "" }: { className?: string }) { + return ( + + + GitHub + + ); +} function App() { const { status, authenticated } = useAuth(); @@ -158,6 +177,7 @@ function Login({ version }: { version: string }) { } }} > +

欢迎回来