feat: fix Telegram controls and add GitHub links
This commit is contained in:
@@ -91,7 +91,7 @@ http://服务器地址:8180
|
|||||||
|
|
||||||
7. 收到“绑定成功”后,返回页面点击“发送测试”。
|
7. 收到“绑定成功”后,返回页面点击“发送测试”。
|
||||||
|
|
||||||
绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。即使关闭查询菜单,仍可完成绑定并接收提醒;开启菜单后可使用以下按钮或命令。`/account` 会向已绑定会话显示账号的完整邮箱,因此只应绑定受信任的私有会话。
|
绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。“启用提醒”只控制自动额度提醒,关闭后仍可发送测试消息;关闭查询菜单后 Bot 不再处理查询命令,并移除 Telegram 客户端键盘。即使关闭查询菜单,仍可完成绑定并接收已启用的提醒;开启菜单后可使用以下按钮或命令。`/account` 会向已绑定会话显示账号的完整邮箱,因此只应绑定受信任的私有会话。
|
||||||
|
|
||||||
- 当前用量(包含所有已添加连接)
|
- 当前用量(包含所有已添加连接)
|
||||||
- 重置时间 / `/reset`
|
- 重置时间 / `/reset`
|
||||||
@@ -101,6 +101,8 @@ http://服务器地址:8180
|
|||||||
|
|
||||||
服务器必须能够访问 `https://api.telegram.org`。
|
服务器必须能够访问 `https://api.telegram.org`。
|
||||||
|
|
||||||
|
点击“解除绑定”会删除当前 Bot Token、Chat ID、开关和未使用的绑定码。该操作不可恢复,之后需要重新填写 Token 并绑定。
|
||||||
|
|
||||||
## 配置 SMTP 邮件
|
## 配置 SMTP 邮件
|
||||||
|
|
||||||
进入“设置中心” → “SMTP 邮件”,填写:
|
进入“设置中心” → “SMTP 邮件”,填写:
|
||||||
|
|||||||
+2
-1
@@ -89,9 +89,10 @@
|
|||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
- `GET /api/v1/settings/telegram` 返回 `{chatId,enabled,menuEnabled,configured,botName?}`,不返回 Token 明文。
|
- `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/bind` 返回六位 `{code}`,绑定码有效十分钟。
|
||||||
- `POST /api/v1/settings/telegram/test` 向已绑定会话发送测试消息;未绑定或发送失败返回 502。
|
- `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. 维护接口
|
## 6. 维护接口
|
||||||
|
|
||||||
|
|||||||
@@ -131,8 +131,10 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
|||||||
case p == "settings/telegram/test" && r.Method == "POST":
|
case p == "settings/telegram/test" && r.Method == "POST":
|
||||||
a.telegramTest(w, r)
|
a.telegramTest(w, r)
|
||||||
case p == "settings/telegram/bind" && r.Method == "POST":
|
case p == "settings/telegram/bind" && r.Method == "POST":
|
||||||
|
a.telegramMu.Lock()
|
||||||
code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000)
|
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.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})
|
jsonOut(w, 200, map[string]string{"code": code})
|
||||||
case p == "maintenance/cleanup" && r.Method == "POST":
|
case p == "maintenance/cleanup" && r.Method == "POST":
|
||||||
n, e := a.store.Cleanup(a.general().RetentionDays)
|
n, e := a.store.Cleanup(a.general().RetentionDays)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type App struct {
|
|||||||
runtimes map[int64]*accountRuntime
|
runtimes map[int64]*accountRuntime
|
||||||
loginAttempts sync.Map
|
loginAttempts sync.Map
|
||||||
reminderMu sync.Mutex
|
reminderMu sync.Mutex
|
||||||
|
telegramMu sync.Mutex
|
||||||
}
|
}
|
||||||
type accountRuntime struct {
|
type accountRuntime struct {
|
||||||
client codexClient
|
client codexClient
|
||||||
|
|||||||
@@ -165,8 +165,10 @@ func sendSMTPWithTimeout(s SMTPSettings, subject, textBody, htmlBody string, tim
|
|||||||
func (a *App) telegramSettings() TelegramSettings {
|
func (a *App) telegramSettings() TelegramSettings {
|
||||||
var t TelegramSettings
|
var t TelegramSettings
|
||||||
a.store.GetJSON("telegram", &t)
|
a.store.GetJSON("telegram", &t)
|
||||||
if _, ok := a.store.Get("telegram_token"); ok {
|
if enc, ok := a.store.Get("telegram_token"); ok {
|
||||||
t.Configured = true
|
if token, err := a.vault.Decrypt(enc); err == nil {
|
||||||
|
t.Configured = token != ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
@@ -183,6 +185,12 @@ func (a *App) telegramAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOut(w, 200, a.telegramSettings())
|
jsonOut(w, 200, a.telegramSettings())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.telegramMu.Lock()
|
||||||
|
defer a.telegramMu.Unlock()
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
a.telegramDelete(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
var in TelegramSettings
|
var in TelegramSettings
|
||||||
if decode(r, &in) != nil {
|
if decode(r, &in) != nil {
|
||||||
jsonOut(w, 400, map[string]string{"error": "配置格式错误"})
|
jsonOut(w, 400, map[string]string{"error": "配置格式错误"})
|
||||||
@@ -208,13 +216,52 @@ func (a *App) telegramAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
in.BotName = me.Result.FirstName + " @" + me.Result.Username
|
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 := in
|
||||||
safe.Token = ""
|
safe.Token = ""
|
||||||
safe.Configured = true
|
safe.Configured = true
|
||||||
_ = a.store.Set("telegram_token", enc)
|
settingsJSON, e := json.Marshal(safe)
|
||||||
_ = a.store.SetJSON("telegram", safe)
|
if e == nil {
|
||||||
jsonOut(w, 200, safe)
|
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) {
|
func (a *App) telegramTest(w http.ResponseWriter, r *http.Request) {
|
||||||
t, e := a.telegramSecret()
|
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})
|
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)
|
b, _ := json.Marshal(p)
|
||||||
req, e := http.NewRequestWithContext(context.Background(), "POST", "https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(b))
|
req, e := http.NewRequestWithContext(context.Background(), "POST", "https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(b))
|
||||||
if e != nil {
|
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"}
|
params := map[string]any{"chat_id": t.ChatID, "text": text, "parse_mode": "HTML"}
|
||||||
if t.MenuEnabled {
|
if t.MenuEnabled {
|
||||||
params["reply_markup"] = map[string]any{"keyboard": [][]map[string]string{{{"text": "当前用量"}, {"text": "重置时间"}}, {{"text": "历史概览"}, {"text": "账户信息"}}, {{"text": "立即刷新"}}}, "resize_keyboard": true}
|
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)
|
return tgCall(t.Token, "sendMessage", params, &out)
|
||||||
}
|
}
|
||||||
@@ -288,14 +340,27 @@ func (a *App) telegramLoop() {
|
|||||||
for _, u := range out.Result {
|
for _, u := range out.Result {
|
||||||
offset = u.UpdateID + 1
|
offset = u.UpdateID + 1
|
||||||
if u.Message != nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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)
|
_, _ = a.store.DB.Exec("UPDATE telegram_updates SET offset=? WHERE id=1", offset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func (a *App) handleTG(t TelegramSettings, chat int64, text string) {
|
func (a *App) handleTG(t TelegramSettings, chat int64, text string) {
|
||||||
if strings.HasPrefix(text, "/bind ") {
|
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 {
|
var b struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Expires int64 `json:"expires"`
|
Expires int64 `json:"expires"`
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codex-helper/internal/security"
|
||||||
"codex-helper/internal/store"
|
"codex-helper/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,12 +53,173 @@ func TestSendSMTPStopsWhenServerDoesNotRespond(t *testing.T) {
|
|||||||
|
|
||||||
func newReminderTestApp(t *testing.T) *App {
|
func newReminderTestApp(t *testing.T) *App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := store.Open(t.TempDir())
|
dir := t.TempDir()
|
||||||
|
s, err := store.Open(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = s.DB.Close() })
|
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 {
|
func reminderDashboard(fetchedAt int64, used float64, resetsAt int64) Dashboard {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ type TelegramSettings struct {
|
|||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
BotName string `json:"botName,omitempty"`
|
BotName string `json:"botName,omitempty"`
|
||||||
}
|
}
|
||||||
|
type TelegramSettingsResponse struct {
|
||||||
|
TelegramSettings
|
||||||
|
Warning string `json:"warning,omitempty"`
|
||||||
|
}
|
||||||
type AccountView struct {
|
type AccountView struct {
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
AuthMode *string `json:"authMode"`
|
AuthMode *string `json:"authMode"`
|
||||||
|
|||||||
@@ -305,6 +305,39 @@ func (s *Store) SetJSON(key string, v any) error {
|
|||||||
}
|
}
|
||||||
return s.Set(key, string(b))
|
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 {
|
func (s *Store) GetJSON(key string, v any) bool {
|
||||||
raw, ok := s.Get(key)
|
raw, ok := s.Get(key)
|
||||||
return ok && json.Unmarshal([]byte(raw), v) == nil
|
return ok && json.Unmarshal([]byte(raw), v) == nil
|
||||||
|
|||||||
@@ -27,6 +27,6 @@
|
|||||||
|
|
||||||
## Telegram 与 SMTP
|
## 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 转义和不记录秘密的错误处理。
|
SMTP 支持 `starttls`、隐式 `tls` 和 `none`,TLS 最低 1.2;支持可选 PLAIN AUTH,发送 multipart text/html。TCP 连接和后续 SMTP/TLS 读写共享 35 秒 deadline。修改外部调用时必须保留这一超时边界、TLS server name、HTML 转义和不记录秘密的错误处理。
|
||||||
|
|||||||
+56
-1
@@ -11,6 +11,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
Check,
|
Check,
|
||||||
|
Github,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -24,6 +25,7 @@ import { AuthProvider, useAuth } from "./auth";
|
|||||||
import { ThemeProvider, useTheme } from "./theme";
|
import { ThemeProvider, useTheme } from "./theme";
|
||||||
import {
|
import {
|
||||||
decodeAccounts,
|
decodeAccounts,
|
||||||
|
decodeAction,
|
||||||
decodeCode,
|
decodeCode,
|
||||||
decodeDashboard,
|
decodeDashboard,
|
||||||
decodeDeviceLogin,
|
decodeDeviceLogin,
|
||||||
@@ -42,6 +44,23 @@ import {
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const UsageChart = lazy(() => import("./usage-chart"));
|
const UsageChart = lazy(() => import("./usage-chart"));
|
||||||
|
const repositoryURL = "https://github.com/zhoujun0601/codex-helper";
|
||||||
|
|
||||||
|
function GitHubLink({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={`github-link ${className}`.trim()}
|
||||||
|
href={repositoryURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="在 GitHub 上查看 Codex Helper"
|
||||||
|
title="GitHub"
|
||||||
|
>
|
||||||
|
<Github />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { status, authenticated } = useAuth();
|
const { status, authenticated } = useAuth();
|
||||||
@@ -158,6 +177,7 @@ function Login({ version }: { version: string }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<GitHubLink className="login-github" />
|
||||||
<Brand version={version} />
|
<Brand version={version} />
|
||||||
<h2>欢迎回来</h2>
|
<h2>欢迎回来</h2>
|
||||||
<label>
|
<label>
|
||||||
@@ -198,6 +218,7 @@ function Shell({ version }: { version: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="aside-bottom">
|
<div className="aside-bottom">
|
||||||
|
<GitHubLink />
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void saveTheme(theme === "dark" ? "light" : "dark").catch(
|
void saveTheme(theme === "dark" ? "light" : "dark").catch(
|
||||||
@@ -987,7 +1008,7 @@ function Telegram() {
|
|||||||
body: JSON.stringify(v),
|
body: JSON.stringify(v),
|
||||||
});
|
});
|
||||||
setV({ ...x, token: "" });
|
setV({ ...x, token: "" });
|
||||||
setMsg("Bot 已验证并保存");
|
setMsg(x.warning || "Bot 已验证并保存");
|
||||||
} catch (x) {
|
} catch (x) {
|
||||||
setMsg(toErrorMessage(x));
|
setMsg(toErrorMessage(x));
|
||||||
}
|
}
|
||||||
@@ -1023,6 +1044,10 @@ function Telegram() {
|
|||||||
启用查询菜单
|
启用查询菜单
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="hint telegram-switch-hint">
|
||||||
|
“启用提醒”只控制自动额度提醒,关闭后仍可发送测试消息;“启用查询菜单”关闭后将停止查询并移除
|
||||||
|
Telegram 键盘。
|
||||||
|
</p>
|
||||||
<button>验证并保存</button>
|
<button>验证并保存</button>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
@@ -1053,6 +1078,36 @@ function Telegram() {
|
|||||||
>
|
>
|
||||||
发送测试
|
发送测试
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
disabled={!v.configured}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"确定要解除 Telegram Bot 绑定吗?这会删除 Bot Token、Chat ID、开关和未使用的绑定码。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const x = await del("settings/telegram", decodeAction);
|
||||||
|
setV({
|
||||||
|
chatId: 0,
|
||||||
|
enabled: false,
|
||||||
|
menuEnabled: false,
|
||||||
|
configured: false,
|
||||||
|
token: "",
|
||||||
|
});
|
||||||
|
setCode("");
|
||||||
|
setMsg(x.warning || "Telegram Bot 配置已删除");
|
||||||
|
} catch (error) {
|
||||||
|
setMsg(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
解除绑定
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{code && (
|
{code && (
|
||||||
<div className="codebox">
|
<div className="codebox">
|
||||||
|
|||||||
@@ -174,6 +174,38 @@ button svg {
|
|||||||
}
|
}
|
||||||
.login {
|
.login {
|
||||||
width: min(420px, 90vw);
|
width: min(420px, 90vw);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.github-link {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.github-link:hover {
|
||||||
|
background: var(--track);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.github-link svg {
|
||||||
|
width: 17px;
|
||||||
|
}
|
||||||
|
.login-github {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
.login-github span {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: var(--danger) !important;
|
color: var(--danger) !important;
|
||||||
@@ -445,6 +477,11 @@ header p {
|
|||||||
.checks input {
|
.checks input {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
.telegram-switch-hint {
|
||||||
|
margin: -8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -616,6 +653,12 @@ a {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
aside .github-link {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
aside .github-link span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
aside button {
|
aside button {
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ApiError, toErrorMessage } from "./api";
|
import { ApiError, toErrorMessage } from "./api";
|
||||||
import { decodeDashboard, decodeDeviceLogin, decodeGeneral } from "./types";
|
import {
|
||||||
|
decodeAction,
|
||||||
|
decodeDashboard,
|
||||||
|
decodeDeviceLogin,
|
||||||
|
decodeGeneral,
|
||||||
|
decodeTelegram,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
describe("API decoders", () => {
|
describe("API decoders", () => {
|
||||||
it("保留 Dashboard 的 null 与 optional 摘要", () => {
|
it("保留 Dashboard 的 null 与 optional 摘要", () => {
|
||||||
@@ -46,6 +52,21 @@ describe("API decoders", () => {
|
|||||||
}).userCode,
|
}).userCode,
|
||||||
).toBe("ABC");
|
).toBe("ABC");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("保留 Telegram 操作警告", () => {
|
||||||
|
expect(
|
||||||
|
decodeTelegram({
|
||||||
|
chatId: 1,
|
||||||
|
enabled: false,
|
||||||
|
menuEnabled: false,
|
||||||
|
configured: true,
|
||||||
|
warning: "菜单同步失败",
|
||||||
|
}).warning,
|
||||||
|
).toBe("菜单同步失败");
|
||||||
|
expect(decodeAction({ ok: true, warning: "键盘清理失败" }).warning).toBe(
|
||||||
|
"键盘清理失败",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("errors", () => {
|
describe("errors", () => {
|
||||||
|
|||||||
@@ -83,8 +83,13 @@ export interface TelegramSettingsResponse {
|
|||||||
menuEnabled: boolean;
|
menuEnabled: boolean;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
botName?: string;
|
botName?: string;
|
||||||
|
warning?: string;
|
||||||
}
|
}
|
||||||
export type TelegramSettingsForm = TelegramSettingsResponse & { token: string };
|
export type TelegramSettingsForm = TelegramSettingsResponse & { token: string };
|
||||||
|
export interface ActionResponse {
|
||||||
|
ok: boolean;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
export interface SMTPSettingsResponse {
|
export interface SMTPSettingsResponse {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -223,6 +228,14 @@ export const decodeTelegram: Decoder<TelegramSettingsResponse> = (value) => {
|
|||||||
menuEnabled: boolean(x.menuEnabled, "menuEnabled"),
|
menuEnabled: boolean(x.menuEnabled, "menuEnabled"),
|
||||||
configured: boolean(x.configured, "configured"),
|
configured: boolean(x.configured, "configured"),
|
||||||
...(typeof x.botName === "string" ? { botName: x.botName } : {}),
|
...(typeof x.botName === "string" ? { botName: x.botName } : {}),
|
||||||
|
...(typeof x.warning === "string" ? { warning: x.warning } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const decodeAction: Decoder<ActionResponse> = (value) => {
|
||||||
|
const x = record(value);
|
||||||
|
return {
|
||||||
|
ok: boolean(x.ok, "ok"),
|
||||||
|
...(typeof x.warning === "string" ? { warning: x.warning } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
|
export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
|
||||||
|
|||||||
@@ -94,6 +94,61 @@ test("shows the build version on login", async ({ page }) => {
|
|||||||
const badge = page.locator(".login .version-badge");
|
const badge = page.locator(".login .version-badge");
|
||||||
await expect(badge).toBeVisible();
|
await expect(badge).toBeVisible();
|
||||||
await expect(badge).toHaveText("vtest");
|
await expect(badge).toHaveText("vtest");
|
||||||
|
const github = page.getByRole("link", {
|
||||||
|
name: "在 GitHub 上查看 Codex Helper",
|
||||||
|
});
|
||||||
|
await expect(github).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://github.com/zhoujun0601/codex-helper",
|
||||||
|
);
|
||||||
|
await expect(github).toHaveAttribute("target", "_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the GitHub link after login", async ({ page }) => {
|
||||||
|
await openSettings(page);
|
||||||
|
const github = page.getByRole("link", {
|
||||||
|
name: "在 GitHub 上查看 Codex Helper",
|
||||||
|
});
|
||||||
|
await expect(github).toBeVisible();
|
||||||
|
await expect(github).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://github.com/zhoujun0601/codex-helper",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saves disabled Telegram switches and removes the configuration", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let savedBody: Record<string, unknown> | undefined;
|
||||||
|
let deleted = false;
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const key = new URL(request.url()).pathname.replace("/api/v1/", "");
|
||||||
|
if (key === "settings/telegram" && request.method() === "PUT") {
|
||||||
|
savedBody = request.postDataJSON() as Record<string, unknown>;
|
||||||
|
return route.fulfill({
|
||||||
|
json: { ...responses["settings/telegram"], ...savedBody },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === "settings/telegram" && request.method() === "DELETE") {
|
||||||
|
deleted = true;
|
||||||
|
return route.fulfill({ json: { ok: true } });
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: responses[key] ?? {} });
|
||||||
|
});
|
||||||
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByRole("tab", { name: "Telegram" }).click();
|
||||||
|
await page.getByLabel("启用提醒").uncheck();
|
||||||
|
await page.getByLabel("启用查询菜单").uncheck();
|
||||||
|
await page.getByRole("button", { name: "验证并保存" }).click();
|
||||||
|
await expect.poll(() => savedBody?.enabled).toBe(false);
|
||||||
|
expect(savedBody?.menuEnabled).toBe(false);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "解除绑定" }).click();
|
||||||
|
await expect.poll(() => deleted).toBe(true);
|
||||||
|
await expect(page.getByRole("button", { name: "解除绑定" })).toBeDisabled();
|
||||||
|
await expect(page.getByText("Telegram Bot 配置已删除")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function layout(page: Page) {
|
async function layout(page: Page) {
|
||||||
|
|||||||
Reference in New Issue
Block a user