feat: fix Telegram controls and add GitHub links
This commit is contained in:
+2
-1
@@ -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. 维护接口
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user