diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index e03fb4c..70642fa 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -489,11 +489,12 @@ func (a *App) storeLimitSnapshots(d Dashboard) (bool, error) { kind = "after" key = fmt.Sprintf("%d:%s:%s:%d:after", d.AccountID, x.LimitID, x.WindowType, previousResetsAt) } - body := fmt.Sprintf("Codex [%s] %s/%s 额度已重置:已用 %.1f%% → %.1f%%,下次重置时间 %s。", - d.DisplayName, x.LimitID, x.WindowType, previousUsed, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + event := notificationEvent{Version: 1, Kind: kind, Account: d.DisplayName, DurationMins: x.WindowDurationMinutes, + Remaining: 100 - x.UsedPercent, PreviousUsed: previousUsed, Used: x.UsedPercent, ResetsAt: x.ResetsAt} + body, _ := json.Marshal(event) _, err = tx.Exec(`INSERT OR IGNORE INTO notifications (dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at,body) - VALUES(?,?,?,'pending',0,'',?,NULL,?)`, key, "configured", kind, d.FetchedAt, body) + VALUES(?,?,?,'pending',0,'',?,NULL,?)`, key, "configured", kind, d.FetchedAt, string(body)) if err != nil { return false, err } diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index 12d8d0d..2bc423e 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -5,8 +5,10 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "encoding/json" "fmt" + "html" "net" "net/http" "net/smtp" @@ -15,6 +17,17 @@ import ( "time" ) +type notificationEvent struct { + Version int `json:"version"` + Kind string `json:"kind"` + Account string `json:"account"` + DurationMins int `json:"durationMinutes"` + Remaining float64 `json:"remainingPercent"` + PreviousUsed float64 `json:"previousUsedPercent,omitempty"` + Used float64 `json:"usedPercent,omitempty"` + ResetsAt int64 `json:"resetsAt"` +} + func (a *App) smtpSettings() SMTPSettings { var s SMTPSettings a.store.GetJSON("smtp", &s) @@ -75,7 +88,7 @@ func (a *App) smtpAPI(w http.ResponseWriter, r *http.Request) { func (a *App) smtpTest(w http.ResponseWriter, r *http.Request) { s, e := a.smtpSecret() if e == nil { - e = sendSMTP(s, "Codex Helper 测试邮件", "SMTP 配置成功,后续用量重置提醒将发送到此邮箱。") + e = sendSMTP(s, "Codex Helper 测试成功", "SMTP 配置成功,后续额度重置提醒将发送到此邮箱。", testEmailHTML(a.general().Timezone)) } if e != nil { jsonOut(w, 502, map[string]string{"error": e.Error()}) @@ -83,7 +96,7 @@ func (a *App) smtpTest(w http.ResponseWriter, r *http.Request) { } jsonOut(w, 200, map[string]bool{"ok": true}) } -func sendSMTP(s SMTPSettings, subject, body string) error { +func sendSMTP(s SMTPSettings, subject, textBody, htmlBody string) error { addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port)) var c *smtp.Client var e error @@ -124,7 +137,9 @@ func sendSMTP(s SMTPSettings, subject, body string) error { if name == "" { name = "Codex Helper" } - msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", name, s.From, s.To, subject, body) + boundary := "codex-helper-alternative" + encodedSubject := "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(subject)) + "?=" + msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=%q\r\n\r\n--%s\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n--%s\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n--%s--\r\n", name, s.From, s.To, encodedSubject, boundary, boundary, textBody, boundary, htmlBody, boundary) if _, e = w.Write([]byte(msg)); e != nil { return e } @@ -191,7 +206,7 @@ func (a *App) telegramTest(w http.ResponseWriter, r *http.Request) { if t.ChatID == 0 { e = fmt.Errorf("请先绑定 Chat ID") } else { - e = tgSend(t, "Codex Helper Telegram 配置成功。") + e = tgSend(t, "✅ Telegram 测试成功\n\n后续额度重置提醒将发送到此会话。") } } if e != nil { @@ -220,7 +235,7 @@ func tgCall(token, method string, p any, out any) error { } func tgSend(t TelegramSettings, text string) error { var out any - params := map[string]any{"chat_id": t.ChatID, "text": text} + 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} } @@ -276,9 +291,9 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { _ = a.store.SetJSON("telegram", safe) _ = a.store.Set("telegram_bind", "{}") t.ChatID = chat - message := "绑定成功。Codex 用量提醒将发送到此会话。" + message := "✅ 绑定成功\n\nCodex 额度提醒将发送到此会话。" if t.MenuEnabled { - message = "绑定成功。现在可以使用菜单查询 Codex 用量。" + message = "✅ 绑定成功\n\n现在可以使用菜单查询 Codex 额度信息。" } _ = tgSend(t, message) } @@ -293,7 +308,7 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { if text == "立即刷新" || text == "/refresh" { a.syncAll(context.Background()) } - msg := "Codex 用量\n" + msg := "" a.mu.RLock() ds := make([]Dashboard, 0, len(a.runtimes)) for _, rt := range a.runtimes { @@ -304,31 +319,39 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { a.mu.RUnlock() switch text { case "重置时间", "/reset": + msg = "⏰ Codex 重置时间\n" for _, d := range ds { - msg += "[" + d.DisplayName + "]\n" + msg += "\n" + html.EscapeString(d.DisplayName) + "\n" for _, x := range d.Limits { - msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + msg += fmt.Sprintf("• %s\n %s\n %s\n", limitLabel(x.WindowDurationMinutes), formatTime(x.ResetsAt, a.general().Timezone), relativeTime(x.ResetsAt, time.Now())) } } case "账户信息", "/account": + msg = "👤 Codex 账户信息\n" for _, d := range ds { - msg += fmt.Sprintf("[%s] 连接:%v\n", d.DisplayName, d.Account.Connected) + status := "断开" + if d.Account.Connected { + status = "正常" + } + msg += fmt.Sprintf("\n%s\n连接:%s\n", html.EscapeString(d.DisplayName), status) if d.Account.Email != nil { - msg += "账户:" + *d.Account.Email + "\n" + msg += "账户:" + html.EscapeString(*d.Account.Email) + "\n" } if d.Account.PlanType != nil { - msg += "套餐:" + *d.Account.PlanType + "\n" + msg += "套餐:" + html.EscapeString(*d.Account.PlanType) + "\n" } } case "历史概览", "/usage": + msg = "📈 Codex 历史概览\n" for _, d := range ds { - msg += fmt.Sprintf("[%s] Lifetime tokens:%s,历史天数:%d\n", d.DisplayName, num(d.Summary.LifetimeTokens), len(d.Usage)) + msg += fmt.Sprintf("\n%s\n累计 Tokens:%s\n历史天数:%d 天\n", html.EscapeString(d.DisplayName), num(d.Summary.LifetimeTokens), len(d.Usage)) } default: + msg = "📊 Codex 当前用量\n" for _, d := range ds { - msg += "[" + d.DisplayName + "]\n" + msg += "\n" + html.EscapeString(d.DisplayName) + "\n" for _, x := range d.Limits { - msg += fmt.Sprintf("%s/%s:剩余 %.1f%%,重置 %s\n", x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + msg += fmt.Sprintf("• %s:剩余 %.1f%%\n 重置:%s(%s)\n", limitLabel(x.WindowDurationMinutes), 100-x.UsedPercent, formatTime(x.ResetsAt, a.general().Timezone), relativeTime(x.ResetsAt, time.Now())) } } } @@ -370,10 +393,11 @@ func (a *App) processReminders() { continue } key := fmt.Sprintf("%d:%s:%s:%d:%s", d.AccountID, x.LimitID, x.WindowType, x.ResetsAt, kind) - body := fmt.Sprintf("Codex [%s] %s/%s 剩余 %.1f%%,重置时间 %s。", d.DisplayName, x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + event := notificationEvent{Version: 1, Kind: kind, Account: d.DisplayName, DurationMins: x.WindowDurationMinutes, Remaining: 100 - x.UsedPercent, Used: x.UsedPercent, ResetsAt: x.ResetsAt} + body, _ := json.Marshal(event) _, _ = a.store.DB.Exec(`INSERT OR IGNORE INTO notifications (dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at,body) - VALUES(?,?,?,'pending',0,'',?,NULL,?)`, key, "configured", kind, at.Unix(), body) + VALUES(?,?,?,'pending',0,'',?,NULL,?)`, key, "configured", kind, at.Unix(), string(body)) } } } @@ -399,16 +423,21 @@ func (a *App) sendPendingReminders(now time.Time) { } _ = rows.Close() for _, p := range pending { + event, structured := decodeNotification(p.body) + textBody, telegramBody, subject, htmlBody := renderNotification(event, p.body, a.general().Timezone, now) ok := true errs := []string{} if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 { - if e = tgSend(t, p.body); e != nil { + if e = tgSend(t, telegramBody); e != nil { ok = false errs = append(errs, e.Error()) } } if s, e := a.smtpSecret(); e == nil && s.Enabled { - if e = sendSMTP(s, "Codex 用量重置提醒", p.body); e != nil { + if !structured { + subject = "Codex 额度重置提醒" + } + if e = sendSMTP(s, subject, textBody, htmlBody); e != nil { ok = false errs = append(errs, e.Error()) } @@ -424,4 +453,97 @@ func (a *App) sendPendingReminders(now time.Time) { } } +func decodeNotification(body string) (notificationEvent, bool) { + var event notificationEvent + err := json.Unmarshal([]byte(body), &event) + return event, err == nil && event.Version == 1 +} + +func limitLabel(minutes int) string { + switch { + case minutes <= 0: + return "Codex 额度" + case minutes%1440 == 0: + return fmt.Sprintf("%d 天额度", minutes/1440) + case minutes%60 == 0: + return fmt.Sprintf("%d 小时额度", minutes/60) + default: + return fmt.Sprintf("%d 分钟额度", minutes) + } +} + +func location(name string) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + return time.UTC + } + return loc +} + +var weekdays = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +func formatTime(ts int64, zone string) string { + t := time.Unix(ts, 0).In(location(zone)) + date := fmt.Sprintf("%d月%d日 %s %02d:%02d", t.Month(), t.Day(), weekdays[t.Weekday()], t.Hour(), t.Minute()) + if t.Year() != time.Now().In(t.Location()).Year() { + return fmt.Sprintf("%d年%s", t.Year(), date) + } + return date +} + +func relativeTime(ts int64, now time.Time) string { + d := time.Unix(ts, 0).Sub(now) + if d <= 0 { + if d > -time.Minute { + return "刚刚重置" + } + return "已重置" + } + minutes := int(d.Round(time.Minute) / time.Minute) + if minutes < 1 { + return "不到 1 分钟后" + } + days, hours, mins := minutes/1440, minutes%1440/60, minutes%60 + if days > 0 { + return fmt.Sprintf("还有 %d 天 %d 小时", days, hours) + } + if hours > 0 { + return fmt.Sprintf("还有 %d 小时 %d 分", hours, mins) + } + return fmt.Sprintf("还有 %d 分钟", mins) +} + +func renderNotification(event notificationEvent, legacy, zone string, now time.Time) (string, string, string, string) { + if event.Version != 1 { + escaped := html.EscapeString(legacy) + return legacy, escaped, "Codex 额度重置提醒", emailCard("额度重置提醒", escaped, "", zone, "#2563eb") + } + after := event.Kind == "after" || event.Kind == "detected_after" || (event.Kind == "before" && event.ResetsAt <= now.Unix()) + title, icon, color := "Codex 即将重置", "⏰", "#2563eb" + detail := fmt.Sprintf("剩余 %.1f%%", event.Remaining) + if after { + title, icon, color = "Codex 额度已重置", "✅", "#059669" + if event.Kind == "detected_after" { + detail = fmt.Sprintf("已用 %.1f%% → %.1f%%", event.PreviousUsed, event.Used) + } else { + detail = fmt.Sprintf("当前已用 %.1f%%", event.Used) + } + } + when := formatTime(event.ResetsAt, zone) + relative := relativeTime(event.ResetsAt, now) + label := limitLabel(event.DurationMins) + plain := fmt.Sprintf("%s\n\n账号:%s\n额度:%s\n%s\n下次重置:%s\n%s\n时区:%s", title, event.Account, label, detail, when, relative, zone) + tg := fmt.Sprintf("%s %s\n\n%s\n%s:%s\n\n下次重置\n%s\n%s", icon, title, html.EscapeString(event.Account), label, detail, when, relative) + content := fmt.Sprintf("
%s · %s
%s
", html.EscapeString(event.Account), label, detail) + return plain, tg, title, emailCard(title, content, when+" · "+relative, zone, color) +} + +func emailCard(title, content, prominent, zone, color string) string { + return fmt.Sprintf(`
%s
%s
下次重置时间
%s
Codex Helper · 时区 %s
`, color, html.EscapeString(title), content, color, html.EscapeString(prominent), html.EscapeString(zone)) +} + +func testEmailHTML(zone string) string { + return emailCard("✅ SMTP 测试成功", "
邮件通知已配置完成,后续额度重置提醒将发送到此邮箱。
", "配置正常", zone, "#059669") +} + var _ = bufio.ErrInvalidUnreadByte diff --git a/backend/internal/app/reminder_test.go b/backend/internal/app/reminder_test.go index f4766ac..0976e27 100644 --- a/backend/internal/app/reminder_test.go +++ b/backend/internal/app/reminder_test.go @@ -56,11 +56,48 @@ func TestStoreLimitSnapshotsDetectsEarlyReset(t *testing.T) { if err := a.store.DB.QueryRow("SELECT kind,body FROM notifications").Scan(&kind, &body); err != nil { t.Fatal(err) } - if kind != "detected_after" || !strings.Contains(body, "42.0% → 3.0%") || !strings.Contains(body, "测试账号") { + event, ok := decodeNotification(body) + if kind != "detected_after" || !ok || event.PreviousUsed != 42 || event.Used != 3 || event.Account != "测试账号" { t.Fatalf("notification kind=%q body=%q", kind, body) } } +func TestNotificationFormatting(t *testing.T) { + if got := limitLabel(300); got != "5 小时额度" { + t.Fatalf("300 minute label = %q", got) + } + if got := limitLabel(10080); got != "7 天额度" { + t.Fatalf("7 day label = %q", got) + } + reset := time.Date(2026, 8, 20, 3, 51, 0, 0, time.UTC) + now := reset.Add(-2*time.Hour - 15*time.Minute) + event := notificationEvent{Version: 1, Kind: "before", Account: "A < B", DurationMins: 300, Remaining: 27.5, ResetsAt: reset.Unix()} + plain, telegram, subject, email := renderNotification(event, "", "Asia/Shanghai", now) + for _, value := range []string{plain, telegram, email} { + if strings.Contains(value, "codex/primary") || strings.Contains(value, "2026-08-20T03:51:00Z") { + t.Fatalf("internal label or RFC3339 leaked: %q", value) + } + } + if subject != "Codex 即将重置" || !strings.Contains(telegram, "Codex 即将重置") || !strings.Contains(telegram, "8月20日 周四 11:51") || !strings.Contains(telegram, "还有 2 小时 15 分") { + t.Fatalf("telegram = %q subject = %q", telegram, subject) + } + if !strings.Contains(telegram, "A < B") || !strings.Contains(email, "multipart") && !strings.Contains(email, "Codex Helper") { + t.Fatalf("output was not escaped or rendered: telegram=%q email=%q", telegram, email) + } +} + +func TestLegacyNotificationFormatting(t *testing.T) { + legacy := "旧消息 <保留>" + event, ok := decodeNotification(legacy) + if ok { + t.Fatal("legacy notification decoded as structured") + } + plain, telegram, subject, email := renderNotification(event, legacy, "UTC", time.Now()) + if plain != legacy || telegram != "旧消息 <保留>" || subject != "Codex 额度重置提醒" || !strings.Contains(email, "旧消息 <保留>") { + t.Fatalf("legacy render mismatch: %q %q %q", plain, telegram, subject) + } +} + func TestStoreLimitSnapshotsUsesScheduledAfterDedupeKey(t *testing.T) { a := newReminderTestApp(t) now := time.Now().Unix()