From 6a004b80ff489771744b4efa41a5525e58508f56 Mon Sep 17 00:00:00 2001 From: qianmokano Date: Mon, 9 Nov 2026 20:14:01 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(notify):=20=E6=96=B0=E5=A2=9E=20QQ=20?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=EF=BC=88=E5=AE=98=E6=96=B9=20API?= =?UTF-8?q?=EF=BC=89=E9=80=9A=E7=9F=A5=E6=B8=A0=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 qqbot 通知渠道,通过 QQ 开放平台官方机器人以单聊消息推送短信、来电和 自动任务通知。本次只做单向通知,双向控制留待后续。 绑定(未绑定时显示,二选一): - 扫码绑定:用手机 QQ 扫码,选择或创建机器人并确认。VoCat 轮询绑定结果, 解密得到 AppID/AppSecret,并把扫码账号设为接收方;二维码过期会自动刷新。 绑定后短暂登录网关约 10 秒再断开,让扫码页完成“连接中”。 - 手动绑定:填写 AppID/AppSecret 后获取一次性绑定码。VoCat 临时连接开放 平台 WebSocket 网关(出站连接,无需公网地址),收到包含绑定码的单聊消息 后保存发送者 openid,并被动回复确认。绑定结束或 5 分钟后断开连接。 - 完成绑定会写入配置并启用该渠道;只读 API 令牌看不到绑定码和二维码链接。 维护(绑定后显示):可直接编辑 AppID、AppSecret、接收方 openid 和 HTTP 代理;“清空配置重新绑定”回到绑定流程。 其他: - access_token 按 AppID 缓存,到期前 60 秒刷新;消息接口返回 401 时丢弃令牌 并重试一次。 - 请求沿用 validateOutboundURL 和受限 HTTP 客户端,只访问公网地址;网关 只接受指向公网的 wss:// 地址。 - 平台拒绝(包括主动消息限制)按 errProviderRejected 处理;错误信息不包含 AppSecret、令牌或 openid。 - app_secret 作为敏感字段保存和掩码,支持清空。 - 二维码由 uqr(MIT,无依赖)在浏览器本地生成。 Co-Authored-By: Claude Opus 5.5 --- .../server/automatic_task_notifications.go | 3 + internal/server/call_notifications.go | 3 + internal/server/meow_notification_test.go | 2 +- internal/server/notification_routing_test.go | 1 + internal/server/qqbot_bind.go | 828 ++++++++++++++++++ internal/server/qqbot_bind_test.go | 576 ++++++++++++ internal/server/qqbot_notification.go | 281 ++++++ internal/server/qqbot_notification_test.go | 371 ++++++++ internal/server/server.go | 81 +- internal/server/settings_api.go | 19 +- internal/server/sms_notifications.go | 8 +- internal/store/settings.go | 2 + web/package-lock.json | 9 +- web/package.json | 3 +- web/src/components/settings/PushTabs.tsx | 257 +++++- web/src/components/settings/controls.tsx | 9 +- web/src/components/settings/model.ts | 87 +- web/src/lib/i18n-en.ts | 33 +- web/src/pages/SettingsPage.tsx | 107 ++- web/src/types.ts | 7 + web/test/notificationSettingsModel.test.mjs | 66 +- 21 files changed, 2694 insertions(+), 59 deletions(-) create mode 100644 internal/server/qqbot_bind.go create mode 100644 internal/server/qqbot_bind_test.go create mode 100644 internal/server/qqbot_notification.go create mode 100644 internal/server/qqbot_notification_test.go diff --git a/internal/server/automatic_task_notifications.go b/internal/server/automatic_task_notifications.go index c094f08c..2e41c66e 100644 --- a/internal/server/automatic_task_notifications.go +++ b/internal/server/automatic_task_notifications.go @@ -91,6 +91,9 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m if channel == "meow" { return sendMeowNotification(ctx, config, &message.Task.DeviceID, message.Title, message.DetailText()) } + if channel == "qqbot" { + return sendQQBotNotification(ctx, config, &message.Task.DeviceID, message.Title, message.DetailText()) + } switch channel { case "telegram": return sendTelegramTextNotification(ctx, config, message.Task.DeviceID, message.Text) diff --git a/internal/server/call_notifications.go b/internal/server/call_notifications.go index 666db1fc..0c84ddf8 100644 --- a/internal/server/call_notifications.go +++ b/internal/server/call_notifications.go @@ -158,6 +158,9 @@ func sendCallNotification(ctx context.Context, channel string, config map[string if channel == "meow" { return sendMeowNotification(ctx, config, &message.DeviceID, message.Title(), message.DetailText()) } + if channel == "qqbot" { + return sendQQBotNotification(ctx, config, &message.DeviceID, message.Title(), message.DetailText()) + } switch channel { case "telegram": return sendTelegramTextNotification(ctx, config, message.DeviceID, message.Text()) diff --git a/internal/server/meow_notification_test.go b/internal/server/meow_notification_test.go index abb50bc6..29a7b892 100644 --- a/internal/server/meow_notification_test.go +++ b/internal/server/meow_notification_test.go @@ -30,7 +30,7 @@ func TestMeowSettingsRoundTrip(t *testing.T) { } response = api.request(t, http.MethodGet, "/api/settings/notifications", "") data := decodeSettingsResponse(t, response)["data"].(map[string]any) - if len(data) != 8 || data["meow"].(map[string]any)["nickname"] != "测试昵称" { + if len(data) != len(notificationChannels) || data["meow"].(map[string]any)["nickname"] != "测试昵称" { t.Fatalf("unexpected channels: %#v", data) } } diff --git a/internal/server/notification_routing_test.go b/internal/server/notification_routing_test.go index 9ab6f3bb..b2319371 100644 --- a/internal/server/notification_routing_test.go +++ b/internal/server/notification_routing_test.go @@ -50,6 +50,7 @@ func TestManualNotificationIgnoresDeviceScope(t *testing.T) { config := map[string]any{"device_ids": []string{}, "nickname": "/", "payload_template": "{"} for name, send := range map[string]func() error{ "meow": func() error { return sendMeowNotification(ctx, config, nil, "test", "test") }, + "qqbot": func() error { return sendQQBotNotification(ctx, config, nil, "test", "test") }, "wecom": func() error { return sendWecomNotificationTest(ctx, config) }, "lark": func() error { return sendLarkNotificationTest(ctx, config) }, } { diff --git a/internal/server/qqbot_bind.go b/internal/server/qqbot_bind.go new file mode 100644 index 00000000..fa5a944f --- /dev/null +++ b/internal/server/qqbot_bind.go @@ -0,0 +1,828 @@ +package server + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + + "github.com/coder/websocket" + + "vocat/internal/store" +) + +// QQ 机器人有两种绑定方式: +// +// - 扫码绑定(qr):在开放平台创建绑定任务,用户用手机 QQ 扫码选择机器人, +// VoCat 轮询结果,得到机器人 AppID、加密的 AppSecret 和扫码用户的 openid。 +// - 绑定码(code):已手动填写 AppID/AppSecret 时,VoCat 临时连接开放平台的 +// WebSocket 网关(出站连接,无需公网地址),等待用户把一次性绑定码私聊发给 +// 机器人,从该消息事件中取得用户 openid。 +// +// 两种方式都只在绑定期间访问开放平台,结束或超时后停止。 +const ( + qqBotBindTTL = 5 * time.Minute + // GROUP_AND_C2C_EVENT 包含单聊消息事件。 + qqBotGatewayIntents = 1 << 25 + qqBotBindReply = "已绑定 VoCat 通知。" + qqBotQRPollInterval = 2 * time.Second + // 扫码页在机器人上线前一直显示“连接中”:绑定后短暂登录网关再断开。 + qqBotOnlineHold = 10 * time.Second + qqBotOnlineTimeout = 30 * time.Second +) + +type qqBotBindStatus string + +const ( + qqBotBindConnecting qqBotBindStatus = "connecting" + qqBotBindWaiting qqBotBindStatus = "waiting" + qqBotBindBound qqBotBindStatus = "bound" + qqBotBindExpired qqBotBindStatus = "expired" + qqBotBindFailed qqBotBindStatus = "failed" + qqBotBindCanceled qqBotBindStatus = "canceled" +) + +type qqBotBindSession struct { + Mode string `json:"mode"` + Code string `json:"code,omitempty"` + QRURL string `json:"qr_url,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + Status qqBotBindStatus `json:"status"` + Error string `json:"error,omitempty"` + cancel context.CancelFunc +} + +func (session *qqBotBindSession) finished() bool { + switch session.Status { + case qqBotBindBound, qqBotBindExpired, qqBotBindFailed, qqBotBindCanceled: + return true + } + return false +} + +// qqBotBindEnv 描述一次绑定使用的网络环境;测试中替换为本地服务。 +type qqBotBindEnv struct { + client *http.Client + endpoints qqBotEndpoints + // checkGateway 校验网关返回的 WebSocket 地址;测试中允许本地 ws://。 + checkGateway func(context.Context, string) (string, error) + retryDelay time.Duration + pollInterval time.Duration + // onlineHold 是扫码绑定后机器人保持在线的时长。 + onlineHold time.Duration +} + +var qqBotBindEnvironment = defaultQQBotBindEnv + +func defaultQQBotBindEnv(ctx context.Context, config map[string]any) (qqBotBindEnv, error) { + endpoints := defaultQQBotEndpoints + for _, endpoint := range []string{endpoints.TokenURL, endpoints.APIBase, endpoints.ConnectBase} { + if _, err := validateOutboundURL(ctx, endpoint, true); err != nil { + return qqBotBindEnv{}, err + } + } + client, err := persistentRestrictedHTTPClient(ctx, 10*time.Second, configString(config, "proxy")) + if err != nil { + return qqBotBindEnv{}, err + } + return qqBotBindEnv{ + client: client, + endpoints: endpoints, + checkGateway: checkQQBotGatewayURL, + retryDelay: 2 * time.Second, + pollInterval: qqBotQRPollInterval, + onlineHold: qqBotOnlineHold, + }, nil +} + +// checkQQBotGatewayURL 只接受指向公网地址的 wss:// 网关。 +func checkQQBotGatewayURL(ctx context.Context, raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "wss" { + return "", errors.New("QQ bot gateway must be a wss:// URL") + } + probe := *parsed + probe.Scheme = "https" + if _, err := validateOutboundURL(ctx, probe.String(), true); err != nil { + return "", err + } + return parsed.String(), nil +} + +func newQQBotBindCode() (string, error) { + number, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) + if err != nil { + return "", err + } + return fmt.Sprintf("VOCAT-%06d", number.Int64()), nil +} + +func (s *Server) handleQQBotBind(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.qqBotBindMu.Lock() + var view *qqBotBindSession + if session := s.qqBotBind; session != nil { + copied := *session + copied.cancel = nil + // 绑定码和二维码可以把接收方改成任何人:只读令牌只能看到状态。 + if principal, ok := bearerPrincipalFromRequest(r); ok && principal.ReadOnly { + copied.Code, copied.QRURL = "", "" + } + view = &copied + } + s.qqBotBindMu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"data": view}) + case http.MethodPost: + s.startQQBotBind(w, r) + case http.MethodDelete: + s.qqBotBindMu.Lock() + if session := s.qqBotBind; session != nil && !session.finished() { + session.cancel() + session.Status = qqBotBindCanceled + } + s.qqBotBindMu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": qqBotBindCanceled}}) + default: + w.Header().Set("Allow", "GET, POST, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +// startQQBotBind 接受 {"mode":"qr"|"code", ...qqbot 配置草稿}。 +func (s *Server) startQQBotBind(w http.ResponseWriter, r *http.Request) { + var document map[string]json.RawMessage + if err := s.decodeJSON(w, r, &document); err != nil || document == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "request body must be a JSON object") + return + } + mode := "code" + if raw, ok := document["mode"]; ok { + if json.Unmarshal(raw, &mode) != nil || (mode != "qr" && mode != "code") { + writeError(w, http.StatusBadRequest, "invalid_request", "mode must be qr or code") + return + } + delete(document, "mode") + } + raw, _ := json.Marshal(document) + _, incoming, err := decodeNotificationConfig("qqbot", raw, false) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + return + } + // 与测试通知相同:表单回传的掩码从已保存配置中恢复。 + resolved, _, err := s.resolveNotificationTestConfig(r.Context(), "qqbot", incoming) + if err != nil && !errors.Is(err, store.ErrNotFound) { + s.writeStoreError(w, err) + return + } + if resolved == nil { + resolved = map[string]any{} + } + if mode == "code" { + // 绑定前还没有 openid,只校验凭据。 + probe := map[string]any{} + for key, value := range resolved { + probe[key] = value + } + probe["target_openid"] = "pending" + if err := validateQQBotNotificationConfig(probe); err != nil { + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + return + } + } + env, err := qqBotBindEnvironment(s.notificationDestinationContext(r.Context()), resolved) + if err != nil { + if errors.Is(err, errUnsafeDestination) { + writeError(w, http.StatusBadRequest, "unsafe_destination", "notification destination resolved to an unusable or protected system address") + return + } + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), qqBotBindTTL) + session := &qqBotBindSession{ + Mode: mode, ExpiresAt: time.Now().Add(qqBotBindTTL).UTC(), + Status: qqBotBindConnecting, cancel: cancel, + } + if mode == "qr" { + task, err := createQQBotQRTask(ctx, env) + if err != nil { + cancel() + s.logger.Warn("QQ bot QR binding could not start", "error", err) + writeError(w, http.StatusBadGateway, "qqbot_bind_unavailable", store.RedactText(err.Error(), store.NotificationSetting{})) + return + } + session.QRURL = task.URL + session.Status = qqBotBindWaiting + go s.runQQBotQRBind(ctx, session, resolved, env, task) + } else { + code, err := newQQBotBindCode() + if err != nil { + cancel() + s.writeStoreError(w, err) + return + } + session.Code = code + go s.runQQBotCodeBind(ctx, session, resolved, env) + } + + s.qqBotBindMu.Lock() + if previous := s.qqBotBind; previous != nil && !previous.finished() { + previous.cancel() + previous.Status = qqBotBindCanceled + } + s.qqBotBind = session + view := *session + view.cancel = nil + s.qqBotBindMu.Unlock() + writeJSON(w, http.StatusAccepted, map[string]any{"data": view}) +} + +func (s *Server) updateQQBotBind(session *qqBotBindSession, update func(*qqBotBindSession)) { + s.qqBotBindMu.Lock() + defer s.qqBotBindMu.Unlock() + if session.Status == qqBotBindCanceled { + return + } + update(session) +} + +func (s *Server) failQQBotBind(session *qqBotBindSession, message string) { + s.logger.Warn("QQ bot binding failed", "mode", session.Mode, "error", message) + s.updateQQBotBind(session, func(session *qqBotBindSession) { + session.Status = qqBotBindFailed + session.Error = message + }) +} + +// finishQQBotBindOnTimeout 在 ctx 结束时把未完成的绑定标记为过期。 +func (s *Server) finishQQBotBindOnTimeout(session *qqBotBindSession) { + s.updateQQBotBind(session, func(session *qqBotBindSession) { + if !session.finished() { + session.Status = qqBotBindExpired + } + }) +} + +func redactQQBotError(err error, config map[string]any) string { + encoded, _ := json.Marshal(config) + return store.RedactText(err.Error(), store.NotificationSetting{ + Channel: "qqbot", Config: encoded, SensitiveFields: store.DefaultNotificationSensitiveFields("qqbot"), + }) +} + +// ---- 扫码绑定 ---- + +type qqBotQRTask struct { + ID string + Key []byte + URL string +} + +type qqBotConnectReply struct { + Retcode int `json:"retcode"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func postQQBotConnect(ctx context.Context, env qqBotBindEnv, path string, body any, data any) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, + strings.TrimRight(env.endpoints.ConnectBase, "/")+path, strings.NewReader(string(payload))) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "vocat-qqbot-notification/1") + response, err := env.client.Do(request) + if err != nil { + return fmt.Errorf("QQ bot binding request failed: %w", unwrapURLError(err)) + } + defer response.Body.Close() + var reply qqBotConnectReply + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode != http.StatusOK || json.Unmarshal(raw, &reply) != nil || reply.Retcode != 0 { + message := []rune(strings.TrimSpace(reply.Msg)) + if len(message) > 200 { + message = message[:200] + } + return fmt.Errorf("%w: QQ bot binding HTTP %d (retcode %d: %s)", + errProviderRejected, response.StatusCode, reply.Retcode, string(message)) + } + if data != nil && len(reply.Data) > 0 { + if err := json.Unmarshal(reply.Data, data); err != nil { + return fmt.Errorf("invalid QQ bot binding response: %w", err) + } + } + return nil +} + +// createQQBotQRTask 创建扫码绑定任务。key 只保存在内存中,用于解密返回的 AppSecret。 +func createQQBotQRTask(ctx context.Context, env qqBotBindEnv) (qqBotQRTask, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return qqBotQRTask{}, err + } + var data struct { + TaskID string `json:"task_id"` + } + if err := postQQBotConnect(ctx, env, "/lite/create_bind_task", + map[string]string{"key": base64.StdEncoding.EncodeToString(key)}, &data); err != nil { + return qqBotQRTask{}, err + } + if strings.TrimSpace(data.TaskID) == "" { + return qqBotQRTask{}, errors.New("QQ bot binding did not return a task") + } + connect := strings.TrimRight(env.endpoints.ConnectBase, "/") + "/qqbot/openclaw/connect.html?" + + url.Values{"task_id": {data.TaskID}, "source": {""}, "_wv": {"2"}}.Encode() + return qqBotQRTask{ID: data.TaskID, Key: key, URL: connect}, nil +} + +const ( + qqBotQRPending = 1 + qqBotQRCompleted = 2 + qqBotQRExpired = 3 +) + +type qqBotQRResult struct { + Status int `json:"status"` + BotAppID any `json:"bot_appid"` + EncryptSecret string `json:"bot_encrypt_secret"` + UserOpenID string `json:"user_openid"` +} + +// decryptQQBotSecret 解密 AES-256-GCM 密文:12 字节 nonce + 密文 + 16 字节 tag。 +func decryptQQBotSecret(encoded string, key []byte) (string, error) { + sealed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil || len(sealed) < 12+16 { + return "", errors.New("QQ bot binding returned an invalid secret") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, sealed[:12], sealed[12:], nil) + if err != nil { + return "", errors.New("QQ bot binding secret could not be decrypted") + } + return string(plain), nil +} + +func (s *Server) runQQBotQRBind(ctx context.Context, session *qqBotBindSession, config map[string]any, env qqBotBindEnv, task qqBotQRTask) { + defer session.cancel() + for { + select { + case <-ctx.Done(): + s.finishQQBotBindOnTimeout(session) + return + case <-time.After(env.pollInterval): + } + var result qqBotQRResult + if err := postQQBotConnect(ctx, env, "/lite/poll_bind_result", map[string]string{"task_id": task.ID}, &result); err != nil { + if ctx.Err() != nil { + s.finishQQBotBindOnTimeout(session) + return + } + // 轮询失败时继续重试。 + s.logger.Warn("QQ bot QR binding poll failed", "error", redactQQBotError(err, config)) + s.updateQQBotBind(session, func(session *qqBotBindSession) { session.Error = redactQQBotError(err, config) }) + continue + } + switch result.Status { + case qqBotQRExpired: + // 二维码过期后自动换新,直到绑定整体超时。 + next, err := createQQBotQRTask(ctx, env) + if err != nil { + if ctx.Err() == nil { + s.failQQBotBind(session, redactQQBotError(err, config)) + } else { + s.finishQQBotBindOnTimeout(session) + } + return + } + task = next + s.updateQQBotBind(session, func(session *qqBotBindSession) { session.QRURL = next.URL }) + case qqBotQRCompleted: + s.completeQQBotQRBind(ctx, session, config, env, task, result) + return + } + } +} + +func (s *Server) completeQQBotQRBind( + ctx context.Context, + session *qqBotBindSession, + config map[string]any, + env qqBotBindEnv, + task qqBotQRTask, + result qqBotQRResult, +) { + appID := strings.TrimSpace(fmt.Sprint(result.BotAppID)) + if number, ok := result.BotAppID.(float64); ok { + appID = fmt.Sprintf("%.0f", number) + } + secret, err := decryptQQBotSecret(result.EncryptSecret, task.Key) + if err != nil || !qqBotAppIDPattern.MatchString(appID) || strings.TrimSpace(secret) == "" { + s.logger.Warn("QQ bot QR binding returned unusable credentials", + "app_id_valid", qqBotAppIDPattern.MatchString(appID), "decrypt_error", err, "secret_empty", strings.TrimSpace(secret) == "") + s.failQQBotBind(session, "the scanned bot credentials were invalid") + return + } + scanned := map[string]any{} + for key, value := range config { + scanned[key] = value + } + scanned["app_id"] = appID + scanned["app_secret"] = secret + + openID := strings.TrimSpace(result.UserOpenID) + if !qqBotOpenIDPattern.MatchString(openID) { + // 平台未返回扫码用户:先保存凭据,再改用绑定码获取接收方。 + if err := s.saveQQBotBinding(ctx, scanned, ""); err != nil { + s.logger.Error("save QQ bot credentials", "error", err) + s.failQQBotBind(session, "the binding could not be saved") + return + } + code, err := newQQBotBindCode() + if err != nil { + s.failQQBotBind(session, "the binding could not be saved") + return + } + s.updateQQBotBind(session, func(session *qqBotBindSession) { + session.Mode, session.Code, session.QRURL = "code", code, "" + session.Status, session.Error = qqBotBindConnecting, "" + }) + s.runQQBotCodeBind(ctx, session, scanned, env) + return + } + if err := s.saveQQBotBinding(ctx, scanned, openID); err != nil { + s.logger.Error("save QQ bot binding", "error", err) + s.failQQBotBind(session, "the binding could not be saved") + return + } + s.updateQQBotBind(session, func(session *qqBotBindSession) { + session.Status, session.Error, session.QRURL = qqBotBindBound, "", "" + }) + s.logger.Info("QQ bot notification recipient bound", "method", "qr") + // 绑定会话随即结束,上线使用独立的超时。 + go func() { + ctx, cancel := context.WithTimeout(context.Background(), qqBotOnlineTimeout+env.onlineHold) + defer cancel() + if err := bringQQBotOnline(ctx, env, appID, secret); err != nil { + s.logger.Warn("QQ bot could not come online after QR binding", "error", redactQQBotError(err, scanned)) + return + } + s.logger.Info("QQ bot came online after QR binding") + }() +} + +// bringQQBotOnline 登录一次网关:收到 READY 后保持 env.onlineHold 再断开。 +// 只用于让扫码页确认连接完成,不接收或处理任何消息。 +func bringQQBotOnline(ctx context.Context, env qqBotBindEnv, appID, secret string) error { + token, err := qqBotTokens.get(ctx, env.client, env.endpoints.TokenURL, appID, secret) + if err != nil { + return err + } + gateway, err := fetchQQBotGateway(ctx, env, token) + if err != nil { + return err + } + dialClient := *env.client + dialClient.Timeout = 0 + dialCtx, cancelDial := context.WithTimeout(ctx, 15*time.Second) + conn, _, err := websocket.Dial(dialCtx, gateway, &websocket.DialOptions{HTTPClient: &dialClient}) + cancelDial() + if err != nil { + return fmt.Errorf("QQ bot gateway connection failed: %w", unwrapURLError(err)) + } + defer conn.CloseNow() + conn.SetReadLimit(1 << 20) + readyCtx, cancelReady := context.WithTimeout(ctx, qqBotOnlineTimeout) + defer cancelReady() + for { + _, raw, err := conn.Read(readyCtx) + if err != nil { + return fmt.Errorf("QQ bot gateway closed: %w", err) + } + var frame qqBotGatewayFrame + if json.Unmarshal(raw, &frame) != nil { + continue + } + switch frame.Op { + case 10: // Hello + payload, _ := json.Marshal(map[string]any{"op": 2, "d": map[string]any{ + "token": "QQBot " + token, "intents": qqBotGatewayIntents, "shard": []int{0, 1}, + }}) + if err := conn.Write(readyCtx, websocket.MessageText, payload); err != nil { + return err + } + case 7, 9: + return fmt.Errorf("QQ bot gateway ended the session (op %d)", frame.Op) + case 0: + if frame.T != "READY" { + continue + } + // 保持在线一小段时间,让扫码页看到机器人上线;期间网关的心跳间隔远大于该时长。 + select { + case <-ctx.Done(): + case <-time.After(env.onlineHold): + } + _ = conn.Close(websocket.StatusNormalClosure, "") + return nil + } + } +} + +// ---- 绑定码 ---- + +func (s *Server) runQQBotCodeBind(ctx context.Context, session *qqBotBindSession, config map[string]any, env qqBotBindEnv) { + defer session.cancel() + appID := strings.TrimSpace(configString(config, "app_id")) + secret := strings.TrimSpace(configString(config, "app_secret")) + for { + event, err := s.waitQQBotBindEvent(ctx, session, env, appID, secret) + if err == nil { + s.completeQQBotCodeBind(ctx, session, config, env, appID, secret, event) + return + } + if ctx.Err() != nil { + s.finishQQBotBindOnTimeout(session) + return + } + // AppID/AppSecret 被拒绝时重试无意义,直接结束。 + if errors.Is(err, errProviderRejected) { + s.failQQBotBind(session, redactQQBotError(err, config)) + return + } + s.updateQQBotBind(session, func(session *qqBotBindSession) { + session.Status = qqBotBindConnecting + session.Error = redactQQBotError(err, config) + }) + s.logger.Warn("QQ bot gateway connection failed during binding", "error", redactQQBotError(err, config)) + select { + case <-ctx.Done(): + case <-time.After(env.retryDelay): + } + } +} + +// qqBotBindEvent 是包含绑定码的单聊消息事件。 +type qqBotBindEvent struct { + OpenID string + MessageID string +} + +type qqBotGatewayFrame struct { + Op int `json:"op"` + D json.RawMessage `json:"d"` + S *int64 `json:"s,omitempty"` + T string `json:"t,omitempty"` +} + +// waitQQBotBindEvent 建立一次网关会话,直到收到包含绑定码的消息、会话结束或 ctx 取消。 +func (s *Server) waitQQBotBindEvent( + ctx context.Context, + session *qqBotBindSession, + env qqBotBindEnv, + appID, secret string, +) (qqBotBindEvent, error) { + token, err := qqBotTokens.get(ctx, env.client, env.endpoints.TokenURL, appID, secret) + if err != nil { + return qqBotBindEvent{}, err + } + gateway, err := fetchQQBotGateway(ctx, env, token) + if err != nil { + return qqBotBindEvent{}, err + } + // coder/websocket 要求用 ctx 控制超时,不能设置 http.Client.Timeout。 + dialClient := *env.client + dialClient.Timeout = 0 + dialCtx, cancelDial := context.WithTimeout(ctx, 15*time.Second) + conn, _, err := websocket.Dial(dialCtx, gateway, &websocket.DialOptions{HTTPClient: &dialClient}) + cancelDial() + if err != nil { + return qqBotBindEvent{}, fmt.Errorf("QQ bot gateway connection failed: %w", unwrapURLError(err)) + } + defer conn.CloseNow() + conn.SetReadLimit(1 << 20) + + s.qqBotBindMu.Lock() + code := session.Code + s.qqBotBindMu.Unlock() + + var sequence atomic.Int64 + heartbeatDone := make(chan struct{}) + defer close(heartbeatDone) + write := func(value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return conn.Write(writeCtx, websocket.MessageText, payload) + } + for { + _, raw, err := conn.Read(ctx) + if err != nil { + return qqBotBindEvent{}, fmt.Errorf("QQ bot gateway closed: %w", err) + } + var frame qqBotGatewayFrame + if json.Unmarshal(raw, &frame) != nil { + continue + } + if frame.S != nil { + sequence.Store(*frame.S) + } + switch frame.Op { + case 10: // Hello + var hello struct { + HeartbeatInterval int64 `json:"heartbeat_interval"` + } + _ = json.Unmarshal(frame.D, &hello) + if err := write(map[string]any{"op": 2, "d": map[string]any{ + "token": "QQBot " + token, "intents": qqBotGatewayIntents, "shard": []int{0, 1}, + }}); err != nil { + return qqBotBindEvent{}, err + } + if hello.HeartbeatInterval > 0 { + interval := time.Duration(hello.HeartbeatInterval) * time.Millisecond + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-heartbeatDone: + return + case <-ticker.C: + _ = write(map[string]any{"op": 1, "d": sequence.Load()}) + } + } + }() + } + case 7: // Reconnect + return qqBotBindEvent{}, errors.New("QQ bot gateway requested a reconnect") + case 9: // Invalid Session:通常是令牌无效或缺少事件权限。 + qqBotTokens.invalidate(env.endpoints.TokenURL, appID, token) + return qqBotBindEvent{}, errors.New("QQ bot gateway rejected the session; check that the bot can receive direct messages") + case 0: // Dispatch + if frame.T == "READY" { + s.updateQQBotBind(session, func(session *qqBotBindSession) { + session.Status = qqBotBindWaiting + session.Error = "" + }) + continue + } + if event, ok := matchQQBotBindEvent(frame.T, frame.D, code); ok { + return event, nil + } + } + } +} + +func fetchQQBotGateway(ctx context.Context, env qqBotBindEnv, token string) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(env.endpoints.APIBase, "/")+"/gateway", nil) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "QQBot "+token) + request.Header.Set("User-Agent", "vocat-qqbot-notification/1") + response, err := env.client.Do(request) + if err != nil { + return "", fmt.Errorf("QQ bot gateway lookup failed: %w", unwrapURLError(err)) + } + defer response.Body.Close() + var reply struct { + qqBotReply + URL string `json:"url"` + } + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if json.Unmarshal(body, &reply) != nil || response.StatusCode != http.StatusOK || reply.URL == "" { + return "", fmt.Errorf("%w: QQ bot gateway lookup HTTP %d%s", errProviderRejected, response.StatusCode, reply.describe()) + } + return env.checkGateway(ctx, reply.URL) +} + +// matchQQBotBindEvent 识别包含绑定码的单聊消息。 +func matchQQBotBindEvent(kind string, raw json.RawMessage, code string) (qqBotBindEvent, bool) { + if kind != "C2C_MESSAGE_CREATE" || code == "" { + return qqBotBindEvent{}, false + } + var event struct { + ID string `json:"id"` + Content string `json:"content"` + Author struct { + UserOpenID string `json:"user_openid"` + } `json:"author"` + } + if json.Unmarshal(raw, &event) != nil || !strings.Contains(strings.ToUpper(event.Content), code) || + !qqBotOpenIDPattern.MatchString(event.Author.UserOpenID) { + return qqBotBindEvent{}, false + } + return qqBotBindEvent{OpenID: event.Author.UserOpenID, MessageID: event.ID}, true +} + +func (s *Server) completeQQBotCodeBind( + ctx context.Context, + session *qqBotBindSession, + config map[string]any, + env qqBotBindEnv, + appID, secret string, + event qqBotBindEvent, +) { + if err := s.saveQQBotBinding(ctx, config, event.OpenID); err != nil { + s.logger.Error("save QQ bot binding", "error", err) + s.failQQBotBind(session, "the binding could not be saved") + return + } + s.updateQQBotBind(session, func(session *qqBotBindSession) { + session.Status, session.Error = qqBotBindBound, "" + }) + s.logger.Info("QQ bot notification recipient bound", "method", "code") + // 被动回复带 msg_id,不占用主动消息额度;失败不影响绑定结果。 + if err := replyQQBotBind(ctx, env, appID, secret, event); err != nil { + s.logger.Warn("QQ bot binding confirmation reply failed", "error", err) + } +} + +// saveQQBotBinding 把凭据和接收方写入已保存的配置,保留其他字段。 +// 写入接收方时启用通道;openID 为空时只保存凭据,保留原有接收方和启用状态。 +func (s *Server) saveQQBotBinding(ctx context.Context, config map[string]any, openID string) error { + enabled := false + stored := map[string]any{} + current, err := s.store.NotificationSetting(ctx, "qqbot") + switch { + case err == nil: + enabled = current.Enabled + if err := json.Unmarshal(current.Config, &stored); err != nil { + return fmt.Errorf("decode stored QQ bot config: %w", err) + } + case !errors.Is(err, store.ErrNotFound): + return err + } + for _, key := range []string{"app_id", "app_secret", "proxy"} { + if value, ok := config[key]; ok { + stored[key] = value + } + } + if openID != "" { + stored["target_openid"] = openID + // 完成绑定即表示要使用该通道;只保存凭据时保留原有启用状态。 + enabled = true + } + encoded, err := json.Marshal(stored) + if err != nil { + return err + } + return s.store.UpsertNotificationSetting(ctx, store.NotificationSetting{ + Channel: "qqbot", + Enabled: enabled, + Config: encoded, + SensitiveFields: store.DefaultNotificationSensitiveFields("qqbot"), + }) +} + +func replyQQBotBind(ctx context.Context, env qqBotBindEnv, appID, secret string, event qqBotBindEvent) error { + token, err := qqBotTokens.get(ctx, env.client, env.endpoints.TokenURL, appID, secret) + if err != nil { + return err + } + payload, err := json.Marshal(map[string]any{ + "content": qqBotBindReply, "msg_type": 0, "msg_id": event.MessageID, "msg_seq": 1, + }) + if err != nil { + return err + } + endpoint := strings.TrimRight(env.endpoints.APIBase, "/") + "/v2/users/" + url.PathEscape(event.OpenID) + "/messages" + status, reply, err := postQQBotMessage(ctx, env.client, endpoint, token, payload) + if err != nil { + return err + } + if status < 200 || status >= 300 || reply.Code != 0 { + return fmt.Errorf("%w: QQ bot HTTP %d%s", errProviderRejected, status, reply.describe()) + } + return nil +} diff --git a/internal/server/qqbot_bind_test.go b/internal/server/qqbot_bind_test.go new file mode 100644 index 00000000..151a64a2 --- /dev/null +++ b/internal/server/qqbot_bind_test.go @@ -0,0 +1,576 @@ +package server + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + + "vocat/internal/store" +) + +// qqBotFakeGateway 模拟令牌接口、/gateway、WebSocket 网关和消息接口。 +type qqBotFakeGateway struct { + t *testing.T + server *httptest.Server + events chan qqBotGatewayFrame + mu sync.Mutex + identify map[string]any + replies []map[string]any + // invalidSession 为真时网关在 Identify 后返回 op 9。 + invalidSession bool + // 扫码绑定:每个任务的密钥、轮询次数,以及决定轮询结果的函数。 + qrKeys map[string][]byte + qrTasks int + qrPolls map[string]int + qrResult func(taskID string, poll int, key []byte) map[string]any +} + +func newQQBotFakeGateway(t *testing.T) *qqBotFakeGateway { + t.Helper() + gateway := &qqBotFakeGateway{ + t: t, events: make(chan qqBotGatewayFrame, 4), + qrKeys: map[string][]byte{}, qrPolls: map[string]int{}, + } + gateway.server = httptest.NewServer(http.HandlerFunc(gateway.serve)) + t.Cleanup(gateway.server.Close) + + previousTokens := qqBotTokens + qqBotTokens = &qqBotTokenCache{entries: map[string]qqBotToken{}, now: time.Now} + previousEnv := qqBotBindEnvironment + qqBotBindEnvironment = func(context.Context, map[string]any) (qqBotBindEnv, error) { + return qqBotBindEnv{ + client: gateway.server.Client(), + endpoints: qqBotEndpoints{ + TokenURL: gateway.server.URL + "/app/getAppAccessToken", APIBase: gateway.server.URL, + ConnectBase: gateway.server.URL, + }, + checkGateway: func(_ context.Context, raw string) (string, error) { return raw, nil }, + retryDelay: 10 * time.Millisecond, + pollInterval: 10 * time.Millisecond, + }, nil + } + t.Cleanup(func() { + qqBotTokens = previousTokens + qqBotBindEnvironment = previousEnv + }) + return gateway +} + +func (gateway *qqBotFakeGateway) serve(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/app/getAppAccessToken": + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["appId"] != qqBotTestAppID || body["clientSecret"] != qqBotTestSecret { + _, _ = w.Write([]byte(`{"code":100016,"message":"invalid appid or secret"}`)) + return + } + _, _ = w.Write([]byte(`{"access_token":"gateway-token","expires_in":"7200"}`)) + case "/gateway": + if r.Header.Get("Authorization") != "QQBot gateway-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"url": "ws" + strings.TrimPrefix(gateway.server.URL, "http") + "/ws"}) + case "/ws": + gateway.serveWebSocket(w, r) + case "/lite/create_bind_task": + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + key, err := base64.StdEncoding.DecodeString(body["key"]) + if err != nil || len(key) != 32 { + _, _ = w.Write([]byte(`{"retcode":1,"msg":"bad key"}`)) + return + } + gateway.mu.Lock() + gateway.qrTasks++ + id := fmt.Sprintf("task-%d", gateway.qrTasks) + gateway.qrKeys[id] = key + gateway.mu.Unlock() + _, _ = fmt.Fprintf(w, `{"retcode":0,"msg":"ok","data":{"task_id":%q}}`, id) + case "/lite/poll_bind_result": + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + gateway.mu.Lock() + key, ok := gateway.qrKeys[body["task_id"]] + gateway.qrPolls[body["task_id"]]++ + poll := gateway.qrPolls[body["task_id"]] + result := gateway.qrResult + gateway.mu.Unlock() + if !ok { + _, _ = w.Write([]byte(`{"retcode":2,"msg":"unknown task"}`)) + return + } + data := map[string]any{"status": qqBotQRPending} + if result != nil { + data = result(body["task_id"], poll, key) + } + _ = json.NewEncoder(w).Encode(map[string]any{"retcode": 0, "msg": "ok", "data": data}) + default: + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + body["path"] = r.URL.Path + gateway.mu.Lock() + gateway.replies = append(gateway.replies, body) + gateway.mu.Unlock() + _, _ = w.Write([]byte(`{"id":"reply-1"}`)) + } +} + +func (gateway *qqBotFakeGateway) serveWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + gateway.t.Errorf("accept: %v", err) + return + } + defer conn.CloseNow() + ctx := r.Context() + send := func(frame qqBotGatewayFrame) { + payload, _ := json.Marshal(frame) + _ = conn.Write(ctx, websocket.MessageText, payload) + } + send(qqBotGatewayFrame{Op: 10, D: json.RawMessage(`{"heartbeat_interval":20}`)}) + _, raw, err := conn.Read(ctx) + if err != nil { + return + } + var identify struct { + Op int `json:"op"` + D map[string]any `json:"d"` + } + _ = json.Unmarshal(raw, &identify) + gateway.mu.Lock() + gateway.identify = identify.D + gateway.mu.Unlock() + if gateway.invalidSession { + send(qqBotGatewayFrame{Op: 9, D: json.RawMessage(`false`)}) + return + } + sequence := int64(1) + send(qqBotGatewayFrame{Op: 0, S: &sequence, T: "READY", D: json.RawMessage(`{"session_id":"s"}`)}) + // 后台读取心跳,避免阻塞客户端写入。 + go func() { + for { + if _, _, err := conn.Read(ctx); err != nil { + return + } + } + }() + for { + select { + case <-ctx.Done(): + return + case frame := <-gateway.events: + sequence++ + frame.S = &sequence + send(frame) + } + } +} + +func (gateway *qqBotFakeGateway) push(kind, data string) { + gateway.events <- qqBotGatewayFrame{Op: 0, T: kind, D: json.RawMessage(data)} +} + +func waitQQBotBind(t *testing.T, api settingsAPITest, want qqBotBindStatus) map[string]any { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + response := api.request(t, http.MethodGet, "/api/settings/notifications/qqbot/bind", "") + data, _ := decodeSettingsResponse(t, response)["data"].(map[string]any) + if data != nil && data["status"] == string(want) { + return data + } + if time.Now().After(deadline) { + t.Fatalf("bind status = %#v, want %s", data, want) + } + time.Sleep(10 * time.Millisecond) + } +} + +func beginQQBotBind(t *testing.T, api settingsAPITest, body string) map[string]any { + t.Helper() + response := api.request(t, http.MethodPost, "/api/settings/notifications/qqbot/bind", body) + if response.Code != http.StatusAccepted { + t.Fatalf("start bind: %d %s", response.Code, response.Body) + } + t.Cleanup(func() { api.request(t, http.MethodDelete, "/api/settings/notifications/qqbot/bind", "") }) + data, _ := decodeSettingsResponse(t, response)["data"].(map[string]any) + return data +} + +func checkQQBotBindCode(t *testing.T, data map[string]any) string { + t.Helper() + code, _ := data["code"].(string) + if data["mode"] != "code" || !strings.HasPrefix(code, "VOCAT-") || len(code) != len("VOCAT-000000") { + t.Fatalf("bind = %#v", data) + } + return code +} + +func startQQBotBind(t *testing.T, api settingsAPITest, body string) string { + t.Helper() + return checkQQBotBindCode(t, beginQQBotBind(t, api, body)) +} + +func storedQQBotConfig(t *testing.T, api settingsAPITest) (store.NotificationSetting, map[string]any) { + t.Helper() + setting, err := api.database.NotificationSetting(context.Background(), "qqbot") + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(setting.Config, &config); err != nil { + t.Fatal(err) + } + return setting, config +} + +const qqBotBindDraft = `{"enabled":true,"app_id":"102000001","app_secret":"test-secret-value"}` + +func TestQQBotBindsDirectMessageSender(t *testing.T) { + gateway := newQQBotFakeGateway(t) + api := newSettingsAPITest(t) + code := startQQBotBind(t, api, qqBotBindDraft) + waitQQBotBind(t, api, qqBotBindWaiting) + + gateway.mu.Lock() + identify := gateway.identify + gateway.mu.Unlock() + if identify["token"] != "QQBot gateway-token" || identify["intents"] != float64(qqBotGatewayIntents) { + t.Fatalf("identify = %#v", identify) + } + + // 不含绑定码的消息被忽略。 + gateway.push("C2C_MESSAGE_CREATE", `{"id":"m0","content":"hello","author":{"user_openid":"OTHER"}}`) + gateway.push("C2C_MESSAGE_CREATE", `{"id":"m1","content":" `+strings.ToLower(code)+` ","author":{"user_openid":"USER_OPENID_1"}}`) + data := waitQQBotBind(t, api, qqBotBindBound) + if data["mode"] != "code" || strings.Contains(mustString(t, data), "USER_OPENID_1") { + t.Fatalf("status = %#v", data) + } + + setting, config := storedQQBotConfig(t, api) + // 完成绑定后自动启用通道。 + if !setting.Enabled || config["target_openid"] != "USER_OPENID_1" || + config["app_id"] != qqBotTestAppID || config["app_secret"] != qqBotTestSecret { + t.Fatalf("stored enabled=%v config=%#v", setting.Enabled, config) + } + + deadline := time.Now().Add(2 * time.Second) + for { + gateway.mu.Lock() + replies := append([]map[string]any(nil), gateway.replies...) + gateway.mu.Unlock() + if len(replies) == 1 { + reply := replies[0] + if reply["path"] != "/v2/users/USER_OPENID_1/messages" || reply["msg_id"] != "m1" || reply["content"] != qqBotBindReply { + t.Fatalf("reply = %#v", reply) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("replies = %#v", replies) + } + time.Sleep(10 * time.Millisecond) + } +} + +func mustString(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(encoded) +} + +func TestQQBotBindKeepsSavedSettings(t *testing.T) { + gateway := newQQBotFakeGateway(t) + api := newSettingsAPITest(t) + if response := api.request(t, http.MethodPut, "/api/settings/notifications", + `{"qqbot":{"enabled":true,"app_id":"102000001","app_secret":"test-secret-value","target_openid":"OLD","device_ids":["a"]}}`); response.Code != http.StatusOK { + t.Fatalf("save: %d %s", response.Code, response.Body) + } + // 表单回传掩码,使用已保存的 AppSecret。 + code := startQQBotBind(t, api, `{"app_id":"102000001","app_secret":"`+store.SecretMask+`"}`) + waitQQBotBind(t, api, qqBotBindWaiting) + gateway.push("C2C_MESSAGE_CREATE", `{"id":"m2","content":"`+code+`","author":{"user_openid":"USER_OPENID_2"}}`) + waitQQBotBind(t, api, qqBotBindBound) + + setting, config := storedQQBotConfig(t, api) + ids, _ := config["device_ids"].([]any) + if !setting.Enabled || config["target_openid"] != "USER_OPENID_2" || config["app_secret"] != qqBotTestSecret || len(ids) != 1 { + t.Fatalf("stored enabled=%v config=%#v", setting.Enabled, config) + } +} + +func TestQQBotBindRejectsBadCredentials(t *testing.T) { + newQQBotFakeGateway(t) + api := newSettingsAPITest(t) + if response := api.request(t, http.MethodPost, "/api/settings/notifications/qqbot/bind", `{"app_id":"abc"}`); response.Code != http.StatusBadRequest { + t.Fatalf("invalid app id: %d", response.Code) + } + startQQBotBind(t, api, `{"app_id":"102000001","app_secret":"wrong-secret"}`) + data := waitQQBotBind(t, api, qqBotBindFailed) + if text, _ := data["error"].(string); !strings.Contains(text, "100016") || strings.Contains(text, "wrong-secret") { + t.Fatalf("error = %q", text) + } +} + +func TestQQBotBindReportsInvalidSessionAndCancels(t *testing.T) { + gateway := newQQBotFakeGateway(t) + gateway.invalidSession = true + api := newSettingsAPITest(t) + startQQBotBind(t, api, qqBotBindDraft) + deadline := time.Now().Add(5 * time.Second) + for { + data, _ := decodeSettingsResponse(t, api.request(t, http.MethodGet, "/api/settings/notifications/qqbot/bind", ""))["data"].(map[string]any) + if text, _ := data["error"].(string); strings.Contains(text, "rejected the session") { + if data["status"] != string(qqBotBindConnecting) { + t.Fatalf("status = %#v", data) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("status = %#v", data) + } + time.Sleep(10 * time.Millisecond) + } + response := api.request(t, http.MethodDelete, "/api/settings/notifications/qqbot/bind", "") + if response.Code != http.StatusOK { + t.Fatalf("cancel: %d", response.Code) + } + waitQQBotBind(t, api, qqBotBindCanceled) + if _, err := api.database.NotificationSetting(context.Background(), "qqbot"); err == nil { + t.Fatal("canceled binding saved a setting") + } +} + +func TestQQBotBindMatchesOnlyMessagesWithCode(t *testing.T) { + for _, test := range []struct { + kind, data string + ok bool + }{ + {"C2C_MESSAGE_CREATE", `{"content":"VOCAT-123456","author":{"user_openid":"U"}}`, true}, + {"C2C_MESSAGE_CREATE", `{"content":"VOCAT-654321","author":{"user_openid":"U"}}`, false}, + {"C2C_MESSAGE_CREATE", `{"content":"VOCAT-123456","author":{"user_openid":"a/b"}}`, false}, + // 只支持单聊:群消息即使包含绑定码也忽略。 + {"GROUP_AT_MESSAGE_CREATE", `{"content":"VOCAT-123456","group_openid":"G","author":{"user_openid":"U"}}`, false}, + {"AT_MESSAGE_CREATE", `{"content":"VOCAT-123456","author":{"user_openid":"U"}}`, false}, + } { + if _, ok := matchQQBotBindEvent(test.kind, json.RawMessage(test.data), "VOCAT-123456"); ok != test.ok { + t.Errorf("%s %s: ok=%v", test.kind, test.data, ok) + } + } +} + +func TestQQBotGatewayURLMustBeWSS(t *testing.T) { + for _, raw := range []string{"ws://api.sgroup.qq.com/websocket", "https://api.sgroup.qq.com/websocket", "wss://127.0.0.1/websocket"} { + if _, err := checkQQBotGatewayURL(context.Background(), raw); err == nil { + t.Errorf("accepted %s", raw) + } + } +} + +// sealQQBotSecret 按开放平台格式加密:12 字节 nonce + 密文 + 16 字节 tag。 +func sealQQBotSecret(t *testing.T, key []byte, secret string) string { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, 12) + _, _ = rand.Read(nonce) + return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(secret), nil)) +} + +func TestQQBotQRBindSavesScannedBotAndRecipient(t *testing.T) { + gateway := newQQBotFakeGateway(t) + gateway.qrResult = func(taskID string, poll int, key []byte) map[string]any { + switch { + case taskID == "task-1" && poll == 1: + return map[string]any{"status": qqBotQRPending} + case taskID == "task-1": + // 第一个二维码过期,应自动换新。 + return map[string]any{"status": qqBotQRExpired} + default: + return map[string]any{ + "status": qqBotQRCompleted, "bot_appid": 102000001, + "bot_encrypt_secret": sealQQBotSecret(t, key, qqBotTestSecret), + "user_openid": "USER_OPENID_QR", + } + } + } + api := newSettingsAPITest(t) + // 已保存的设备过滤在扫码后保留,凭据和接收方被替换。 + if response := api.request(t, http.MethodPut, "/api/settings/notifications", + `{"qqbot":{"enabled":true,"app_id":"1","app_secret":"old-secret","target_openid":"OLD","device_ids":["a"]}}`); response.Code != http.StatusOK { + t.Fatalf("save: %d %s", response.Code, response.Body) + } + // 扫码不需要预先填写凭据。 + data := beginQQBotBind(t, api, `{"mode":"qr"}`) + qrURL, _ := data["qr_url"].(string) + if data["mode"] != "qr" || data["status"] != string(qqBotBindWaiting) || + !strings.Contains(qrURL, "/qqbot/openclaw/connect.html?") || !strings.Contains(qrURL, "task_id=task-1") { + t.Fatalf("bind = %#v", data) + } + data = waitQQBotBind(t, api, qqBotBindBound) + if text := mustString(t, data); strings.Contains(text, qqBotTestSecret) || strings.Contains(text, "USER_OPENID_QR") { + t.Fatalf("status leaks binding: %s", text) + } + + setting, config := storedQQBotConfig(t, api) + ids, _ := config["device_ids"].([]any) + if !setting.Enabled || config["app_id"] != qqBotTestAppID || config["app_secret"] != qqBotTestSecret || + config["target_openid"] != "USER_OPENID_QR" || len(ids) != 1 { + t.Fatalf("stored enabled=%v config=%#v", setting.Enabled, config) + } + gateway.mu.Lock() + tasks := gateway.qrTasks + gateway.mu.Unlock() + if tasks != 2 { + t.Fatalf("tasks = %d, want 2 (expired QR refreshed)", tasks) + } + // 绑定后用扫到的凭据登录一次网关,让扫码页确认连接完成。 + deadline := time.Now().Add(5 * time.Second) + for { + gateway.mu.Lock() + identify := gateway.identify + gateway.mu.Unlock() + if identify != nil { + if identify["token"] != "QQBot gateway-token" { + t.Fatalf("identify = %#v", identify) + } + break + } + if time.Now().After(deadline) { + t.Fatal("bot did not log in to the gateway after QR binding") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestQQBotQRBindFallsBackToCodeWithoutOpenID(t *testing.T) { + gateway := newQQBotFakeGateway(t) + gateway.qrResult = func(_ string, _ int, key []byte) map[string]any { + return map[string]any{ + "status": qqBotQRCompleted, "bot_appid": qqBotTestAppID, + "bot_encrypt_secret": sealQQBotSecret(t, key, qqBotTestSecret), + } + } + api := newSettingsAPITest(t) + beginQQBotBind(t, api, `{"mode":"qr"}`) + data := waitQQBotBind(t, api, qqBotBindWaiting) + if data["mode"] != "code" { + // 首次轮询前状态也是 waiting,等到切换为绑定码。 + deadline := time.Now().Add(5 * time.Second) + for data["mode"] != "code" || data["status"] != string(qqBotBindWaiting) { + if time.Now().After(deadline) { + t.Fatalf("bind = %#v", data) + } + time.Sleep(10 * time.Millisecond) + data, _ = decodeSettingsResponse(t, api.request(t, http.MethodGet, "/api/settings/notifications/qqbot/bind", ""))["data"].(map[string]any) + } + } + code := checkQQBotBindCode(t, data) + // 凭据已先行保存。 + if setting, config := storedQQBotConfig(t, api); setting.Enabled || config["app_secret"] != qqBotTestSecret || config["target_openid"] != nil { + t.Fatalf("enabled=%v config=%#v", setting.Enabled, config) + } + gateway.push("C2C_MESSAGE_CREATE", `{"id":"m3","content":"`+code+`","author":{"user_openid":"USER_OPENID_3"}}`) + waitQQBotBind(t, api, qqBotBindBound) + if setting, config := storedQQBotConfig(t, api); !setting.Enabled || config["target_openid"] != "USER_OPENID_3" { + t.Fatalf("config = %#v", config) + } +} + +func TestQQBotQRBindRejectsUndecryptableSecret(t *testing.T) { + gateway := newQQBotFakeGateway(t) + gateway.qrResult = func(_ string, _ int, _ []byte) map[string]any { + wrong := make([]byte, 32) + return map[string]any{ + "status": qqBotQRCompleted, "bot_appid": qqBotTestAppID, + "bot_encrypt_secret": sealQQBotSecret(t, wrong, qqBotTestSecret), "user_openid": "U", + } + } + api := newSettingsAPITest(t) + beginQQBotBind(t, api, `{"mode":"qr"}`) + waitQQBotBind(t, api, qqBotBindFailed) + if _, err := api.database.NotificationSetting(context.Background(), "qqbot"); err == nil { + t.Fatal("failed binding saved a setting") + } +} + +func TestQQBotBindRejectsUnknownMode(t *testing.T) { + newQQBotFakeGateway(t) + api := newSettingsAPITest(t) + if response := api.request(t, http.MethodPost, "/api/settings/notifications/qqbot/bind", `{"mode":"group"}`); response.Code != http.StatusBadRequest { + t.Fatalf("mode group: %d", response.Code) + } +} + +func TestQQBotBindHidesCodeAndQRFromReadOnlyTokens(t *testing.T) { + newQQBotFakeGateway(t) + app, authService, _ := newBearerTestApplication(t) + write, err := authService.CreateAPIToken(context.Background(), "writer", false, 0) + if err != nil { + t.Fatal(err) + } + readOnly, err := authService.CreateAPIToken(context.Background(), "reader", true, 0) + if err != nil { + t.Fatal(err) + } + call := func(method, token, body string) (int, map[string]any) { + t.Helper() + request, err := http.NewRequest(method, app.server.URL+"/api/settings/notifications/qqbot/bind", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+token) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var envelope struct { + Data map[string]any `json:"data"` + } + _ = json.NewDecoder(response.Body).Decode(&envelope) + return response.StatusCode, envelope.Data + } + + // 只读令牌不能开始绑定。 + if status, _ := call(http.MethodPost, readOnly.Token, `{"mode":"qr"}`); status != http.StatusForbidden { + t.Fatalf("read-only start: %d", status) + } + status, data := call(http.MethodPost, write.Token, `{"mode":"qr"}`) + if status != http.StatusAccepted || data["qr_url"] == nil { + t.Fatalf("start: %d %#v", status, data) + } + t.Cleanup(func() { call(http.MethodDelete, write.Token, "") }) + + if _, data := call(http.MethodGet, write.Token, ""); data["qr_url"] == nil || data["status"] != string(qqBotBindWaiting) { + t.Fatalf("writer view = %#v", data) + } + _, data = call(http.MethodGet, readOnly.Token, "") + if data["status"] != string(qqBotBindWaiting) || data["qr_url"] != nil || data["code"] != nil { + t.Fatalf("read-only view = %#v", data) + } +} diff --git a/internal/server/qqbot_notification.go b/internal/server/qqbot_notification.go new file mode 100644 index 00000000..4fd21a20 --- /dev/null +++ b/internal/server/qqbot_notification.go @@ -0,0 +1,281 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "vocat/internal/store" +) + +// QQ 官方机器人开放平台:先用 AppID/AppSecret 换取 access_token,再调用 +// 单聊消息接口。主动消息受平台策略限制,被拒绝时按普通发送失败处理。 +const ( + qqBotTokenURL = "https://bots.qq.com/app/getAppAccessToken" + qqBotAPIBase = "https://api.sgroup.qq.com" + qqBotConnectBase = "https://q.qq.com" + // 令牌到期前提前刷新,避免请求途中过期。 + qqBotTokenRefreshMargin = 60 * time.Second + maxQQBotContentRunes = 2000 +) + +var ( + qqBotAppIDPattern = regexp.MustCompile(`^[0-9]{1,20}$`) + qqBotOpenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) +) + +func validateQQBotNotificationConfig(config map[string]any) error { + if !qqBotAppIDPattern.MatchString(strings.TrimSpace(configString(config, "app_id"))) { + return errors.New("qqbot.app_id must be the numeric AppID") + } + if secret := strings.TrimSpace(configString(config, "app_secret")); secret == "" || secret == store.SecretMask { + return errors.New("qqbot.app_secret is required") + } + if !qqBotOpenIDPattern.MatchString(strings.TrimSpace(configString(config, "target_openid"))) { + return errors.New("qqbot.target_openid must be a user openid; bind a recipient first") + } + return nil +} + +// qqBotEndpoints 描述一次发送使用的地址;测试中替换为本地接收端。 +type qqBotEndpoints struct { + TokenURL string + APIBase string + // ConnectBase 是扫码绑定接口所在的开放平台地址。 + ConnectBase string +} + +var defaultQQBotEndpoints = qqBotEndpoints{TokenURL: qqBotTokenURL, APIBase: qqBotAPIBase, ConnectBase: qqBotConnectBase} + +// 默认调用真实开放平台;测试中可替换为本地接收端,不访问真实收件人。 +var qqBotNotificationSender = deliverQQBotNotification + +func sendQQBotNotification(ctx context.Context, config map[string]any, deviceID *string, title, text string) error { + if deviceID != nil && !notificationMatchesDevice(configStrings(config, "device_ids"), *deviceID) { + return nil + } + return qqBotNotificationSender(ctx, config, title, text) +} + +func deliverQQBotNotification(ctx context.Context, config map[string]any, title, text string) error { + if err := validateQQBotNotificationConfig(config); err != nil { + return err + } + endpoints := defaultQQBotEndpoints + for _, endpoint := range []string{endpoints.TokenURL, endpoints.APIBase} { + if _, err := validateOutboundURL(ctx, endpoint, true); err != nil { + return err + } + } + client, err := restrictedHTTPClient(ctx, 10*time.Second, configString(config, "proxy")) + if err != nil { + return err + } + return postQQBotNotification(ctx, client, endpoints, config, title, text) +} + +func postQQBotNotification( + ctx context.Context, + client *http.Client, + endpoints qqBotEndpoints, + config map[string]any, + title, text string, +) error { + if err := validateQQBotNotificationConfig(config); err != nil { + return err + } + appID := strings.TrimSpace(configString(config, "app_id")) + secret := strings.TrimSpace(configString(config, "app_secret")) + endpoint := strings.TrimRight(endpoints.APIBase, "/") + "/v2/users/" + + url.PathEscape(strings.TrimSpace(configString(config, "target_openid"))) + "/messages" + payload, err := json.Marshal(map[string]any{ + "content": qqBotContent(title, text), + "msg_type": 0, + }) + if err != nil { + return err + } + + for attempt := 0; ; attempt++ { + token, err := qqBotTokens.get(ctx, client, endpoints.TokenURL, appID, secret) + if err != nil { + return err + } + status, reply, err := postQQBotMessage(ctx, client, endpoint, token, payload) + if err != nil { + return err + } + // 令牌可能在缓存期内被平台提前作废:丢弃后重试一次。 + if status == http.StatusUnauthorized && attempt == 0 { + qqBotTokens.invalidate(endpoints.TokenURL, appID, token) + continue + } + if status < 200 || status >= 300 || reply.Code != 0 { + return fmt.Errorf("%w: QQ bot HTTP %d%s", errProviderRejected, status, reply.describe()) + } + return nil + } +} + +func qqBotContent(title, text string) string { + content := strings.TrimSpace(text) + if title = strings.TrimSpace(title); title != "" { + content = title + "\n" + content + } + runes := []rune(content) + if len(runes) > maxQQBotContentRunes { + content = string(runes[:maxQQBotContentRunes-1]) + "…" + } + return content +} + +type qqBotReply struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// describe 只返回平台的错误码和说明,不包含收件 openid 或令牌。 +func (reply qqBotReply) describe() string { + if reply.Code == 0 && reply.Message == "" { + return "" + } + message := []rune(strings.TrimSpace(reply.Message)) + if len(message) > 200 { + message = message[:200] + } + return fmt.Sprintf(" (code %d: %s)", reply.Code, string(message)) +} + +func postQQBotMessage( + ctx context.Context, + client *http.Client, + endpoint, token string, + payload []byte, +) (int, qqBotReply, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return 0, qqBotReply{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "QQBot "+token) + request.Header.Set("User-Agent", "vocat-qqbot-notification/1") + response, err := client.Do(request) + if err != nil { + return 0, qqBotReply{}, fmt.Errorf("QQ bot request failed: %w", unwrapURLError(err)) + } + defer response.Body.Close() + var reply qqBotReply + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + _ = json.Unmarshal(body, &reply) + return response.StatusCode, reply, nil +} + +// unwrapURLError 去掉 url.Error 中的请求地址:地址包含收件 openid。 +func unwrapURLError(err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + return urlErr.Err + } + return err +} + +// qqBotTokenCache 按令牌地址和 AppID 缓存 access_token。 +type qqBotTokenCache struct { + mu sync.Mutex + entries map[string]qqBotToken + now func() time.Time +} + +type qqBotToken struct { + value string + expires time.Time + secret string +} + +var qqBotTokens = &qqBotTokenCache{entries: map[string]qqBotToken{}, now: time.Now} + +func (cache *qqBotTokenCache) get( + ctx context.Context, + client *http.Client, + tokenURL, appID, secret string, +) (string, error) { + key := tokenURL + "\x00" + appID + cache.mu.Lock() + defer cache.mu.Unlock() + if entry, ok := cache.entries[key]; ok && entry.secret == secret && cache.now().Before(entry.expires) { + return entry.value, nil + } + token, lifetime, err := fetchQQBotToken(ctx, client, tokenURL, appID, secret) + if err != nil { + delete(cache.entries, key) + return "", err + } + cache.entries[key] = qqBotToken{ + value: token, + expires: cache.now().Add(lifetime - qqBotTokenRefreshMargin), + secret: secret, + } + return token, nil +} + +func (cache *qqBotTokenCache) invalidate(tokenURL, appID, token string) { + key := tokenURL + "\x00" + appID + cache.mu.Lock() + defer cache.mu.Unlock() + if entry, ok := cache.entries[key]; ok && entry.value == token { + delete(cache.entries, key) + } +} + +func fetchQQBotToken( + ctx context.Context, + client *http.Client, + tokenURL, appID, secret string, +) (string, time.Duration, error) { + payload, err := json.Marshal(map[string]string{"appId": appID, "clientSecret": secret}) + if err != nil { + return "", 0, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, bytes.NewReader(payload)) + if err != nil { + return "", 0, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-qqbot-notification/1") + response, err := client.Do(request) + if err != nil { + return "", 0, fmt.Errorf("QQ bot token request failed: %w", unwrapURLError(err)) + } + defer response.Body.Close() + var reply struct { + qqBotReply + AccessToken string `json:"access_token"` + ExpiresIn json.RawMessage `json:"expires_in"` + } + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if json.Unmarshal(body, &reply) != nil || response.StatusCode < 200 || response.StatusCode >= 300 || + reply.Code != 0 || reply.AccessToken == "" { + return "", 0, fmt.Errorf("%w: QQ bot rejected AppID %s credentials (HTTP %d%s)", + errProviderRejected, appID, response.StatusCode, reply.describe()) + } + // 平台以字符串返回 expires_in,兼容数字形式。 + seconds, err := strconv.Atoi(strings.Trim(string(reply.ExpiresIn), `" `)) + if err != nil || seconds <= 0 { + seconds = 7200 + } + lifetime := time.Duration(seconds) * time.Second + if lifetime <= qqBotTokenRefreshMargin { + lifetime = qqBotTokenRefreshMargin + time.Second + } + return reply.AccessToken, lifetime, nil +} diff --git a/internal/server/qqbot_notification_test.go b/internal/server/qqbot_notification_test.go new file mode 100644 index 00000000..a01921a9 --- /dev/null +++ b/internal/server/qqbot_notification_test.go @@ -0,0 +1,371 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "vocat/internal/store" +) + +const ( + qqBotTestAppID = "102000001" + qqBotTestSecret = "test-secret-value" + qqBotTestOpenID = "OPENID_test-0123" +) + +func qqBotTestConfig(extra map[string]any) map[string]any { + config := map[string]any{ + "app_id": qqBotTestAppID, "app_secret": qqBotTestSecret, + "target_openid": qqBotTestOpenID, + } + for key, value := range extra { + config[key] = value + } + return config +} + +// qqBotFakePlatform 模拟开放平台的令牌接口和消息接口。 +type qqBotFakePlatform struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + tokens int + messages []qqBotFakeMessage + rejectToken string // 消息接口对该令牌返回 401 + rejectAll bool // 消息接口对所有令牌返回 401 + messageCode int // 非零时消息接口返回业务错误 + tokenStatus int // 非零时令牌接口返回该状态 +} + +type qqBotFakeMessage struct { + Path string + Authorization string + Body map[string]any +} + +func newQQBotFakePlatform(t *testing.T) *qqBotFakePlatform { + t.Helper() + platform := &qqBotFakePlatform{t: t} + platform.server = httptest.NewServer(http.HandlerFunc(platform.serve)) + t.Cleanup(platform.server.Close) + // 各测试使用独立的令牌缓存,避免互相影响。 + previous := qqBotTokens + qqBotTokens = &qqBotTokenCache{entries: map[string]qqBotToken{}, now: time.Now} + t.Cleanup(func() { qqBotTokens = previous }) + return platform +} + +func (platform *qqBotFakePlatform) endpoints() qqBotEndpoints { + return qqBotEndpoints{TokenURL: platform.server.URL + "/app/getAppAccessToken", APIBase: platform.server.URL} +} + +func (platform *qqBotFakePlatform) serve(w http.ResponseWriter, r *http.Request) { + platform.mu.Lock() + defer platform.mu.Unlock() + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { + platform.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + platform.t.Errorf("decode body: %v", err) + } + if r.URL.Path == "/app/getAppAccessToken" { + if body["appId"] != qqBotTestAppID || body["clientSecret"] != qqBotTestSecret { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":100016,"message":"invalid appid or secret"}`)) + return + } + if platform.tokenStatus != 0 { + w.WriteHeader(platform.tokenStatus) + return + } + platform.tokens++ + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "token-" + string(rune('0'+platform.tokens)), + "expires_in": "7200", + }) + return + } + platform.messages = append(platform.messages, qqBotFakeMessage{ + Path: r.URL.Path, Authorization: r.Header.Get("Authorization"), Body: body, + }) + if platform.rejectAll || (platform.rejectToken != "" && r.Header.Get("Authorization") == "QQBot "+platform.rejectToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":11244,"message":"token not exist or expire"}`)) + return + } + if platform.messageCode != 0 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":22009,"message":"msg limit exceed"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"msg-1","timestamp":"2026-11-09T12:00:00+08:00"}`)) +} + +func (platform *qqBotFakePlatform) post(t *testing.T, config map[string]any, title, text string) error { + t.Helper() + return postQQBotNotification(context.Background(), platform.server.Client(), platform.endpoints(), config, title, text) +} + +func TestQQBotValidatesConfig(t *testing.T) { + if err := validateQQBotNotificationConfig(qqBotTestConfig(nil)); err != nil { + t.Fatal(err) + } + for name, extra := range map[string]map[string]any{ + "missing app id": {"app_id": ""}, + "text app id": {"app_id": "abc"}, + "missing secret": {"app_secret": ""}, + "masked secret": {"app_secret": store.SecretMask}, + "missing openid": {"target_openid": ""}, + "openid with path": {"target_openid": "a/b"}, + } { + if err := validateQQBotNotificationConfig(qqBotTestConfig(extra)); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestQQBotSendsDirectMessage(t *testing.T) { + platform := newQQBotFakePlatform(t) + if err := platform.post(t, qqBotTestConfig(nil), "收到新短信", "内容 你好"); err != nil { + t.Fatal(err) + } + if err := platform.post(t, qqBotTestConfig(nil), "", "第二条"); err != nil { + t.Fatal(err) + } + if platform.tokens != 1 { + t.Fatalf("token fetched %d times, want 1 (cached)", platform.tokens) + } + if len(platform.messages) != 2 { + t.Fatalf("messages = %d", len(platform.messages)) + } + first, second := platform.messages[0], platform.messages[1] + for _, message := range platform.messages { + if message.Path != "/v2/users/"+qqBotTestOpenID+"/messages" { + t.Fatalf("path = %q", message.Path) + } + } + if first.Authorization != "QQBot token-1" { + t.Fatalf("authorization = %q", first.Authorization) + } + if first.Body["content"] != "收到新短信\n内容 你好" || first.Body["msg_type"] != float64(0) { + t.Fatalf("first body = %#v", first.Body) + } + if second.Body["content"] != "第二条" { + t.Fatalf("second body = %#v", second.Body) + } +} + +func TestQQBotRefreshesExpiredToken(t *testing.T) { + platform := newQQBotFakePlatform(t) + now := time.Now() + qqBotTokens.now = func() time.Time { return now } + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); err != nil { + t.Fatal(err) + } + // 进入提前刷新窗口后重新获取令牌。 + now = now.Add(7200*time.Second - qqBotTokenRefreshMargin) + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); err != nil { + t.Fatal(err) + } + if platform.tokens != 2 || platform.messages[1].Authorization != "QQBot token-2" { + t.Fatalf("tokens = %d, last auth = %q", platform.tokens, platform.messages[1].Authorization) + } +} + +func TestQQBotRetriesOnceAfterUnauthorized(t *testing.T) { + platform := newQQBotFakePlatform(t) + platform.rejectToken = "token-1" + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); err != nil { + t.Fatal(err) + } + if platform.tokens != 2 || len(platform.messages) != 2 || platform.messages[1].Authorization != "QQBot token-2" { + t.Fatalf("tokens = %d, messages = %#v", platform.tokens, platform.messages) + } + + // 新令牌仍被拒绝时只重试一次,并按发送失败返回。 + platform = newQQBotFakePlatform(t) + platform.rejectAll = true + err := platform.post(t, qqBotTestConfig(nil), "a", "b") + if !errors.Is(err, errProviderRejected) || platform.tokens != 2 || len(platform.messages) != 2 { + t.Fatalf("err = %v, tokens = %d, messages = %d", err, platform.tokens, len(platform.messages)) + } +} + +func TestQQBotRejectionsAreProviderErrorsWithoutSecrets(t *testing.T) { + platform := newQQBotFakePlatform(t) + platform.messageCode = 22009 + err := platform.post(t, qqBotTestConfig(nil), "a", "b") + if !errors.Is(err, errProviderRejected) || !strings.Contains(err.Error(), "22009") { + t.Fatalf("message rejection: %v", err) + } + platform.messageCode = 0 + + err = platform.post(t, qqBotTestConfig(map[string]any{"app_secret": "wrong-secret"}), "a", "b") + if !errors.Is(err, errProviderRejected) || !strings.Contains(err.Error(), "100016") { + t.Fatalf("credential rejection: %v", err) + } + for _, secret := range []string{"wrong-secret", qqBotTestSecret, qqBotTestOpenID, "token-"} { + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks %q: %v", secret, err) + } + } + + platform.tokenStatus = http.StatusInternalServerError + qqBotTokens.entries = map[string]qqBotToken{} + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); !errors.Is(err, errProviderRejected) { + t.Fatalf("token outage: %v", err) + } +} + +func TestQQBotTransportErrorsOmitOpenID(t *testing.T) { + platform := newQQBotFakePlatform(t) + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); err != nil { + t.Fatal(err) + } + endpoints := platform.endpoints() + platform.server.Close() + err := postQQBotNotification(context.Background(), platform.server.Client(), endpoints, qqBotTestConfig(nil), "a", "b") + if err == nil || strings.Contains(err.Error(), qqBotTestOpenID) { + t.Fatalf("transport error = %v", err) + } +} + +func TestQQBotContentIsBounded(t *testing.T) { + content := qqBotContent("标题", strings.Repeat("长", 3000)) + if runes := []rune(content); len(runes) != maxQQBotContentRunes || !strings.HasPrefix(content, "标题\n") { + t.Fatalf("content length = %d", len(runes)) + } +} + +// 从三个真实调用入口发送到本地接收端,确认使用 DetailText 和对应标题。 +func TestQQBotCallersSendDetailText(t *testing.T) { + now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.Local) + sms := smsNotification{DeviceID: "a", DeviceLabel: "EC20", Number: "+447386", Time: now, Content: "验证码 1234"} + call := IncomingCallNotification{DeviceID: "a", DeviceLabel: "EC20", Caller: "+447386", Time: now, Environment: "vowifi"} + task := automaticTaskNotification{ + Task: store.AutomaticTask{DeviceID: "a"}, + Title: "自动任务执行成功", + Text: "自动任务执行成功\n任务 检查网络", + } + for _, test := range []struct { + name, title string + detail string + send func(context.Context, map[string]any) error + }{ + {"SMS", "收到新短信", sms.DetailText(), func(ctx context.Context, config map[string]any) error { + return sendSMSNotification(ctx, "qqbot", config, sms) + }}, + {"call", call.Title(), call.DetailText(), func(ctx context.Context, config map[string]any) error { + return sendCallNotification(ctx, "qqbot", config, call) + }}, + {"automatic task", "自动任务执行成功", task.DetailText(), func(ctx context.Context, config map[string]any) error { + return sendAutomaticTaskNotification(ctx, "qqbot", config, task) + }}, + } { + t.Run(test.name, func(t *testing.T) { + platform := newQQBotFakePlatform(t) + previous := qqBotNotificationSender + qqBotNotificationSender = func(ctx context.Context, config map[string]any, title, text string) error { + return postQQBotNotification(ctx, platform.server.Client(), platform.endpoints(), config, title, text) + } + t.Cleanup(func() { qqBotNotificationSender = previous }) + + if err := test.send(context.Background(), qqBotTestConfig(nil)); err != nil { + t.Fatal(err) + } + if len(platform.messages) != 1 || platform.messages[0].Body["content"] != test.title+"\n"+test.detail { + t.Fatalf("messages = %#v", platform.messages) + } + // 设备范围不包含该设备时不发送。 + if err := test.send(context.Background(), qqBotTestConfig(map[string]any{"device_ids": []string{"b"}})); err != nil { + t.Fatal(err) + } + if len(platform.messages) != 1 { + t.Fatalf("scoped-out notification was sent: %d", len(platform.messages)) + } + }) + } +} + +func TestQQBotSettingsMaskAndClearSecret(t *testing.T) { + api := newSettingsAPITest(t) + save := func(body string) map[string]any { + t.Helper() + response := api.request(t, http.MethodPut, "/api/settings/notifications", body) + if response.Code != http.StatusOK { + t.Fatalf("save %s: %d %s", body, response.Code, response.Body) + } + return decodeSettingsResponse(t, response)["data"].(map[string]any)["qqbot"].(map[string]any) + } + stored := func() map[string]any { + t.Helper() + setting, err := api.database.NotificationSetting(context.Background(), "qqbot") + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(setting.Config, &config); err != nil { + t.Fatal(err) + } + return config + } + + view := save(`{"qqbot":{"enabled":true,"app_id":"102000001","app_secret":"test-secret-value","target_openid":"OPENID_1"}}`) + if view["app_secret"] != store.SecretMask || view["app_id"] != "102000001" || view["target_openid"] != "OPENID_1" { + t.Fatalf("view = %#v", view) + } + if stored()["app_secret"] != qqBotTestSecret { + t.Fatal("secret was not stored") + } + // 表单回传掩码时保留原值。 + save(`{"qqbot":{"enabled":true,"app_id":"102000001","app_secret":"` + store.SecretMask + `","target_openid":"OPENID_2"}}`) + if config := stored(); config["app_secret"] != qqBotTestSecret || config["target_openid"] != "OPENID_2" { + t.Fatalf("config = %#v", config) + } + save(`{"qqbot":{"enabled":false,"app_secret":"","clear_secrets":true}}`) + if config := stored(); config["app_secret"] != "" { + t.Fatalf("secret not cleared: %#v", config) + } + + // 只使用正式环境:不接受沙箱等未知字段。 + if response := api.request(t, http.MethodPut, "/api/settings/notifications", `{"qqbot":{"enabled":false,"sandbox":true}}`); response.Code != http.StatusBadRequest { + t.Fatalf("accepted sandbox field: %d", response.Code) + } +} + +func TestQQBotTestEndpointUsesSavedSecret(t *testing.T) { + api := newSettingsAPITest(t) + if response := api.request(t, http.MethodPut, "/api/settings/notifications", + `{"qqbot":{"enabled":true,"app_id":"102000001","app_secret":"test-secret-value","target_openid":"OPENID_test-0123"}}`); response.Code != http.StatusOK { + t.Fatalf("save: %d %s", response.Code, response.Body) + } + platform := newQQBotFakePlatform(t) + previous := qqBotNotificationSender + qqBotNotificationSender = func(ctx context.Context, config map[string]any, title, text string) error { + return postQQBotNotification(ctx, platform.server.Client(), platform.endpoints(), config, title, text) + } + t.Cleanup(func() { qqBotNotificationSender = previous }) + + response := api.request(t, http.MethodPost, "/api/settings/notifications/qqbot/test", + `{"app_id":"102000001","app_secret":"`+store.SecretMask+`","target_openid":"OPENID_test-0123"}`) + if response.Code != http.StatusOK { + t.Fatalf("test: %d %s", response.Code, response.Body) + } + if len(platform.messages) != 1 || platform.messages[0].Body["content"] != "VoCat 测试通知\nVoCat 消息推送测试" { + t.Fatalf("messages = %#v", platform.messages) + } + + platform.messageCode = 22009 + response = api.request(t, http.MethodPost, "/api/settings/notifications/qqbot/test", `{}`) + if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "notification_provider_rejected") { + t.Fatalf("rejected test: %d %s", response.Code, response.Body) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index e2201aed..658e06e6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -60,45 +60,48 @@ type Options struct { // Server is the single HTTP handler for the JSON API and embedded SPA. type Server struct { - store *store.Store - auth *auth.Service - sso *sso.Service - ssoLimiter *ssoRateLimiter - devices DeviceController - vowifi VoWiFiController - ussdSessions ussdSessionStore - logs *loghub.Hub - assets fs.FS - indexHTML []byte - fileServer http.Handler - logger *slog.Logger - secureCookies bool - maxRequestBodyBytes int64 - startedAt time.Time - handler http.Handler - e911Sessions *e911SessionManager - accessMu sync.RWMutex - access parsedAccessConfig - loginLimiter *loginRateLimiter - extensions *extensions.Manager - exportProxy *exportproxy.Manager - developerEnabled bool - updateRepository string - updateToken string - updateCheck func(context.Context, string, string, string) (update.CheckResult, error) - updateApply func(context.Context, *slog.Logger, update.Options, bool) (update.CheckResult, error) - updateRestart func(*slog.Logger) error - updateMu sync.Mutex - updateApplying bool - https *httpsmode.Manager - mcp *MCPHandlers - netTraffic *liveNetTracker - hostStats *hostStatsSampler - publicIPMu sync.RWMutex - publicIPs map[string]cachedPublicIP - lookupPublicIP func(context.Context, string) (exportproxy.PublicIPInfo, error) - automaticTasks *automaticTaskScheduler - smsSyncMu sync.Mutex + store *store.Store + auth *auth.Service + sso *sso.Service + ssoLimiter *ssoRateLimiter + devices DeviceController + vowifi VoWiFiController + ussdSessions ussdSessionStore + logs *loghub.Hub + assets fs.FS + indexHTML []byte + fileServer http.Handler + logger *slog.Logger + secureCookies bool + maxRequestBodyBytes int64 + startedAt time.Time + handler http.Handler + e911Sessions *e911SessionManager + accessMu sync.RWMutex + access parsedAccessConfig + loginLimiter *loginRateLimiter + extensions *extensions.Manager + exportProxy *exportproxy.Manager + developerEnabled bool + updateRepository string + updateToken string + updateCheck func(context.Context, string, string, string) (update.CheckResult, error) + updateApply func(context.Context, *slog.Logger, update.Options, bool) (update.CheckResult, error) + updateRestart func(*slog.Logger) error + updateMu sync.Mutex + updateApplying bool + https *httpsmode.Manager + mcp *MCPHandlers + netTraffic *liveNetTracker + hostStats *hostStatsSampler + publicIPMu sync.RWMutex + publicIPs map[string]cachedPublicIP + lookupPublicIP func(context.Context, string) (exportproxy.PublicIPInfo, error) + automaticTasks *automaticTaskScheduler + smsSyncMu sync.Mutex + // qqBotBindMu 保护 qqBotBind:当前或最近一次 QQ 机器人接收方绑定。 + qqBotBindMu sync.Mutex + qqBotBind *qqBotBindSession esimSIMResetAttempted map[string]bool // 由 smsSyncMu 保护;读取成功后允许下一次故障恢复。 smsStorageMu sync.Mutex smsStorage map[string]device.SMSStorageUsage diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 69e4fe17..ee6aa66a 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -45,10 +45,15 @@ var notificationChannels = []string{ "wecom", "lark", "meow", + "qqbot", } var notificationFields = map[string]map[string]string{ "meow": {"nickname": "string", "url": "string", "img_url": "string"}, + "qqbot": { + "app_id": "string", "app_secret": "string", "target_openid": "string", + "proxy": "string", + }, "telegram": { "bot_token": "string", "chat_id": "string", "admin_id": "string", "base_url": "string", "proxy": "string", @@ -108,6 +113,11 @@ func (s *Server) routeSettingsAPI( return true } segments := splitAPIPath(cleanPath) + if len(segments) == 4 && segments[0] == "settings" && segments[1] == "notifications" && + segments[2] == "qqbot" && segments[3] == "bind" { + s.handleQQBotBind(w, r) + return true + } if len(segments) == 4 && segments[0] == "settings" && segments[1] == "notifications" && @@ -157,7 +167,7 @@ func (s *Server) handleNotificationSettings(w http.ResponseWriter, r *http.Reque } clearSecrets := false if flag, present := document["clear_secrets"]; present { - if (channel != "telegram" && channel != "email") || string(flag) == "null" || json.Unmarshal(flag, &clearSecrets) != nil { + if (channel != "telegram" && channel != "email" && channel != "qqbot") || string(flag) == "null" || json.Unmarshal(flag, &clearSecrets) != nil { writeError(w, http.StatusBadRequest, "invalid_notification_config", "clear_secrets must be a boolean for a supported channel") return } @@ -472,7 +482,7 @@ func (s *Server) handleNotificationTest( writeError(w, http.StatusNotFound, "not_found", "notification channel was not found") return } - if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" && channel != "meow" { + if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" && channel != "meow" && channel != "qqbot" { writeError( w, http.StatusNotImplemented, @@ -530,6 +540,8 @@ func (s *Server) handleNotificationTest( err = sendLarkNotificationTest(notificationContext, resolved) case "meow": err = sendMeowNotification(notificationContext, resolved, nil, "VoCat 测试通知", "VoCat 消息推送测试") + case "qqbot": + err = sendQQBotNotification(notificationContext, resolved, nil, "VoCat 测试通知", "VoCat 消息推送测试") } if err != nil { redacted := store.RedactText(err.Error(), provider) @@ -673,6 +685,9 @@ func validateNotificationTestConfig(channel string, config map[string]any) error if channel == "meow" { return validateMeowNotificationConfig(config) } + if channel == "qqbot" { + return validateQQBotNotificationConfig(config) + } switch channel { case "webhook": urls := configStrings(config, "urls") diff --git a/internal/server/sms_notifications.go b/internal/server/sms_notifications.go index 67e09469..f4e503d8 100644 --- a/internal/server/sms_notifications.go +++ b/internal/server/sms_notifications.go @@ -56,7 +56,7 @@ func smsNotificationRetryDelay(consecutiveFailures int) time.Duration { return delay } -var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark", "meow"} +var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark", "meow", "qqbot"} type smsNotification struct { DeviceID string @@ -261,6 +261,9 @@ func validateSMSNotificationConfig(channel string, config map[string]any) error if channel == "meow" { return validateMeowNotificationConfig(config) } + if channel == "qqbot" { + return validateQQBotNotificationConfig(config) + } switch channel { case "bark", "email", "webhook", "wecom", "lark": if err := validateNotificationTestConfig(channel, config); err != nil { @@ -329,6 +332,9 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string] if channel == "meow" { return sendMeowNotification(ctx, config, &message.DeviceID, "收到新短信", message.DetailText()) } + if channel == "qqbot" { + return sendQQBotNotification(ctx, config, &message.DeviceID, "收到新短信", message.DetailText()) + } switch channel { case "bark": return sendBarkSMSNotification(ctx, config, message) diff --git a/internal/store/settings.go b/internal/store/settings.go index 63ce4e63..0ede8d4e 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -26,6 +26,8 @@ func DefaultNotificationSensitiveFields(channel string) []string { return []string{"urls"} case "lark": return []string{"url", "secret"} + case "qqbot": + return []string{"app_secret"} default: return nil } diff --git a/web/package-lock.json b/web/package-lock.json index bdb20971..1d484f42 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,7 +21,8 @@ "lucide-react": "^1.61.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.18.4" + "react-router-dom": "^7.18.4", + "uqr": "^0.1.3" }, "devDependencies": { "@playwright/test": "^1.62.1", @@ -2964,6 +2965,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/web/package.json b/web/package.json index 4662c9e4..dc8e6e78 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,8 @@ "lucide-react": "^1.61.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router-dom": "^7.18.4" + "react-router-dom": "^7.18.4", + "uqr": "^0.1.3" }, "devDependencies": { "@playwright/test": "^1.62.1", diff --git a/web/src/components/settings/PushTabs.tsx b/web/src/components/settings/PushTabs.tsx index 38d2399a..4573297c 100644 --- a/web/src/components/settings/PushTabs.tsx +++ b/web/src/components/settings/PushTabs.tsx @@ -1,3 +1,5 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { renderSVG } from "uqr"; import { AddRegular, DeleteRegular } from "@fluentui/react-icons"; import { useI18n } from "../../lib/i18n"; import { Button } from "../ui/Button"; @@ -5,9 +7,31 @@ import { Input, Textarea } from "../ui/Input"; import { Select } from "../ui/Select"; import { Switch } from "../ui/Switch"; import type { DeviceListItem } from "../../types"; -import { ChannelHeader, EmptyLine, Field, NotificationDeviceScope, UrlListEditor } from "./controls"; -import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model"; -import type { BarkForm, EmailForm, HeaderRow, LarkForm, NotifyForms, WebhookForm, WecomForm } from "./model"; +import { ChannelHeader, EmptyLine, Field, NotificationDeviceScope, SegmentedTabs, UrlListEditor } from "./controls"; +import { + HEADER_NAME_SUGGESTIONS, + nextHeaderRowId, + qqBotBindActive, + qqBotBindRemaining, + qqBotConfigured, + qqBotDefaultMethod, + qqBotNextView, + qqBotVisibleMethod, +} from "./model"; +import type { + BarkForm, + EmailForm, + HeaderRow, + LarkForm, + NotifyForms, + QQBotBindMethod, + QQBotBindMode, + QQBotBindState, + QQBotForm, + QQBotView, + WebhookForm, + WecomForm, +} from "./model"; const HEADER_LIST_ID = "vocat-webhook-header-names"; @@ -462,3 +486,230 @@ export function MeowTab({ value, onChange, testing, onTest }: PushChannelProps ); } + +export function QQBotTab({ + value, + onChange, + testing, + onTest, + onClear, + bind, + binding, + onBind, + onCancelBind, +}: PushChannelProps & { + onClear: () => void; + bind: QQBotBindState | null; + binding: boolean; + onBind: (mode: QQBotBindMode) => void; + onCancelBind: () => void; +}) { + const { t } = useI18n(); + const hasCredentials = !!value.appId.trim() && !!value.appSecret; + const active = qqBotBindActive(bind); + // 绑定完成后显示维护面板;清空配置后回到绑定流程。编辑维护面板中的单个字段不跳转。 + const [view, setView] = useState(() => qqBotNextView("bind", value)); + useEffect(() => { + setView((previous) => qqBotNextView(previous, value)); + }, [value.appId, value.appSecret, value.targetOpenid]); + const maintaining = view === "maintain" && !active; + // 选择的绑定方式只是页面状态,不保存。 + const [selected, setSelected] = useState(() => qqBotDefaultMethod(value)); + const method = qqBotVisibleMethod(selected, bind); + // 每次显示扫码面板(进入未绑定的页面、从手动绑定切换过来、清空配置后)自动生成一次二维码; + // 取消或过期后不自动重试,由“重新生成”按钮处理。 + const scanVisible = !maintaining && method === "qr"; + const autoStarted = useRef(false); + useEffect(() => { + if (!scanVisible) { + autoStarted.current = false; + return; + } + if (autoStarted.current || active || binding) return; + autoStarted.current = true; + onBind("qr"); + }, [scanVisible]); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [active]); + // 扫码回退到绑定码时,结束后仍停留在手动方式。 + const previousMode = useRef(null); + const [fellBack, setFellBack] = useState(false); + useEffect(() => { + if (!active || !bind) return; + setSelected(bind.mode === "qr" ? "qr" : "manual"); + // 同一次绑定从扫码变为绑定码,说明平台没有返回扫码用户。 + setFellBack(previousMode.current === "qr" && bind.mode === "code"); + previousMode.current = bind.mode; + }, [active, bind?.mode]); + useEffect(() => { + if (!active) previousMode.current = null; + }, [active]); + const qrUrl = active && bind?.mode === "qr" ? bind.qrUrl : undefined; + // 二维码只编码开放平台的绑定页地址,在本地生成,不经第三方服务。 + const qrImage = useMemo( + () => (qrUrl ? `data:image/svg+xml;utf8,${encodeURIComponent(renderSVG(qrUrl, { pixelSize: 6 }))}` : ""), + [qrUrl], + ); + const remaining = bind ? `${t("剩余")} ${qqBotBindRemaining(bind.expiresAt, now)}` : ""; + // 结束的绑定只在对应方式的面板中提示。 + const ended = !active && bind && (bind.status === "failed" || bind.status === "expired") ? bind : null; + const endedIn = (mode: QQBotBindMode) => + ended && ended.mode === mode ? ( +
+ {ended.status === "expired" ? t("绑定已过期,请重新绑定。") : t("绑定失败")} + {ended.error ? ` · ${ended.error}` : ""} +
+ ) : null; + const bindError = active && bind?.error ?
{bind.error}
: null; + const panel = "space-y-3 rounded-lg border border-gray-200 px-4 py-4 dark:border-gray-700"; + const step = "flex items-center gap-2 text-sm font-semibold text-gray-800 dark:text-gray-100"; + const stepNumber = "flex h-5 w-5 items-center justify-center rounded-full bg-[#0ea5e9]/10 text-xs text-[#0ea5e9] dark:text-[#7dd3fc]"; + return ( +
+ onChange({ enabled })} + actions={ + + } + /> + +
+ {t("通过 QQ 开放平台官方机器人私聊推送。QQ 限制机器人主动消息,被拒绝时会在测试结果和日志中显示。")} +
+ + {maintaining ? ( +
+
+
+ + onChange({ appId: e.target.value })} inputMode="numeric" /> + + + onChange({ appSecret: e.target.value })} type="password" /> + +
+ + onChange({ targetOpenid: e.target.value })} + className="font-mono" + /> + +
+
+ ) : ( + <> +
{t("绑定 QQ 机器人")}
+ { + // 二维码已生成时可以改用手动绑定:放弃当前扫码任务,切回扫码页时再生成新的二维码。 + if (key === "manual" && active && bind?.mode === "qr") onCancelBind(); + setSelected(key as QQBotBindMethod); + }} + // 等待绑定码期间仍锁定,避免丢失已显示的绑定码。 + disabled={active && bind?.mode === "code"} + /> + + {method === "qr" ? ( +
+ {active && bind?.mode === "qr" ? ( + <> +
+ {t("等待扫码")} · {remaining} + +
+ {qrImage ? {t("QQ : null} +
+ {t("用手机 QQ 扫描二维码,按提示选择或创建机器人并确认。VoCat 会自动保存机器人凭据,并把扫码的 QQ 账号设为接收方。")} +
+ {bindError} + + ) : ( + <> + {endedIn("qr")} + + + )} +
+ ) : ( +
+
+ 1 + {t("填写机器人凭据")} +
+
+ + onChange({ appId: e.target.value })} disabled={active} inputMode="numeric" /> + + + onChange({ appSecret: e.target.value })} disabled={active} type="password" /> + +
+ +
+ 2 + {t("绑定接收方")} +
+ {active && bind?.mode === "code" ? ( +
+ {fellBack ? ( +
+ {t("扫码未返回接收方,机器人凭据已保存,请私聊机器人发送下方绑定码。")} +
+ ) : null} +
{bind.code}
+
{t("在 QQ 中私聊机器人,发送上面的绑定码。")}
+
+ + {bind.status === "connecting" ? t("正在连接 QQ 开放平台…") : t("已连接,等待绑定码")} · {remaining} + + +
+ {bindError} +
+ ) : ( + <> + {hasCredentials ? ( +
+ {t("点击获取绑定码,然后在 QQ 中私聊机器人发送该绑定码,VoCat 会把发送者设为接收方。")} +
+ ) : null} + {endedIn("code")} + + + )} +
+ )} + + + )} + +
+ + onChange({ proxy: e.target.value })} disabled={active} placeholder={t("例如 http://127.0.0.1:7890")} /> + +
+
+ ); +} diff --git a/web/src/components/settings/controls.tsx b/web/src/components/settings/controls.tsx index d42396c3..52e38551 100644 --- a/web/src/components/settings/controls.tsx +++ b/web/src/components/settings/controls.tsx @@ -153,12 +153,14 @@ export function ChannelHeader({ onToggle, actions, onClear, + clearLabel, }: { title: ReactNode; enabled: boolean; onToggle: (enabled: boolean) => void; actions?: ReactNode; onClear?: () => void; + clearLabel?: ReactNode; }) { return (
@@ -166,7 +168,7 @@ export function ChannelHeader({
{title}
- {onClear ? : null} + {onClear ? : null} {actions}
@@ -188,10 +190,12 @@ export function SegmentedTabs({ tabs, value, onChange, + disabled, }: { tabs: { key: string; label: ReactNode }[]; value: string; onChange: (key: string) => void; + disabled?: boolean; }) { return (
@@ -201,9 +205,12 @@ export function SegmentedTabs({
: null} {actions} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index a6c7036b..d7a269f2 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -173,7 +173,9 @@ export const EN_DICT: Record = { "绑定接收方": "Bind the recipient", "点击获取绑定码,然后在 QQ 中私聊机器人发送该绑定码,VoCat 会把发送者设为接收方。": "Get a bind code, then send it to the bot in a QQ direct message. VoCat makes the sender the recipient.", "获取绑定码": "Get bind code", - "扫码未返回接收方,机器人凭据已保存,请私聊机器人发送下方绑定码。": "The scan didn't return a recipient. The bot credentials are saved; send the code below to the bot in a direct message.", + "扫码未返回接收方,请在 QQ 中私聊刚才选择的机器人,发送下方绑定码。完成后会一起保存机器人凭据。": "The scan didn't return a recipient. In QQ, send the code below to the bot you just selected. The bot credentials are saved once that's done.", + "正在同步 QQ 机器人的绑定结果,请稍后再保存": "Syncing the QQ Bot binding. Save again in a moment.", + "取消绑定失败": "Couldn't cancel binding", "绑定 QQ 机器人": "Bind a QQ Bot", "接收方 openid(QQ 单聊用户)": "Recipient openid (QQ direct-message user)", "清空并重新绑定": "Clear and bind again", diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1f42f7a2..86047785 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -83,11 +83,15 @@ export default function SettingsPage() { if (!confirmed) return; setForms(prev => ({ ...prev, [channel]: { ...defaultNotifyForms()[channel], enabled: prev[channel].enabled, deviceIds: prev[channel].deviceIds } })); setClearedChannels(prev => prev.includes(channel) ? prev : [...prev, channel]); + // 清空表示用户不再要刚才的绑定结果:停止同步,避免稍后到达的响应恢复被清空的字段。 + if (channel === "qqbot") setQQSyncPending(false); }; const [testingMeow, setTestingMeow] = useState(false); const [testingQQBot, setTestingQQBot] = useState(false); const [qqBind, setQQBind] = useState(null); const [startingQQBind, setStartingQQBind] = useState(false); + // 绑定完成后要从服务器取回新的凭据和接收方;取回之前不允许保存,避免旧表单覆盖新绑定。 + const [qqSyncPending, setQQSyncPending] = useState(false); const [activeTab, setActiveTab] = useState("telegram"); const [loadingNotif, setLoadingNotif] = useState(false); const [savingNotif, setSavingNotif] = useState(false); @@ -374,6 +378,10 @@ export default function SettingsPage() { const onSaveNotifications = useCallback(async () => { if (savingNotif) return; + if (qqSyncPending) { + message.warning(t("正在同步 QQ 机器人的绑定结果,请稍后再保存")); + return; + } setSavingNotif(true); try { // vocat 后端 PUT 成功即返回完整配置文档(参考实现返回 {applied, warning}) @@ -389,7 +397,7 @@ export default function SettingsPage() { } finally { setSavingNotif(false); } - }, [forms, clearedChannels, savingNotif]); + }, [forms, clearedChannels, savingNotif, qqSyncPending]); const onTestMeow = async () => { setTestingMeow(true); @@ -436,49 +444,84 @@ export default function SettingsPage() { setQQBind(null); try { await api("/settings/notifications/qqbot/bind", { method: "DELETE" }); - } catch { - // 绑定会在有效期结束后自动停止。 + } catch (error) { + // 服务器可能仍在绑定:恢复它的真实状态,继续轮询或同步结果。 + message.error(apiMessage(error) || t("取消绑定失败")); + try { + const state = await api("/settings/notifications/qqbot/bind"); + setQQBind(state ?? null); + if (state?.status === "bound") setQQSyncPending(true); + } catch { + // 绑定会在有效期结束后自动停止。 + } } }; - // 绑定进行中时轮询状态;绑定成功后只刷新 QQ 机器人的凭据和接收方,不覆盖其他未保存的修改。 + // 绑定进行中时轮询状态。上一次请求结束后才发下一次,避免过期的响应覆盖新状态。 const qqBindPolling = qqBotBindActive(qqBind); useEffect(() => { if (!qqBindPolling) return; let stopped = false; - const timer = window.setInterval(async () => { + let timer: number | undefined; + const poll = async () => { try { const state = await api("/settings/notifications/qqbot/bind"); - if (stopped || !state) return; - setQQBind(state); - if (state.status === "bound") { - const data = await api("/settings/notifications"); - const saved = formsFromNotifications(data || {}).qqbot; - setForms(prev => ({ - ...prev, - qqbot: { - ...prev.qqbot, - // 完成绑定后服务器已启用该通道。 - enabled: saved.enabled, - appId: saved.appId, - appSecret: saved.appSecret, - targetOpenid: saved.targetOpenid, - }, - })); - // 绑定已写入新凭据:撤销尚未保存的“清空配置”,否则保存时会清掉刚绑定的 AppSecret。 - setClearedChannels(prev => prev.filter(channel => channel !== "qqbot")); - message.success(t("已绑定 QQ 机器人")); + if (stopped) return; + if (state) { + setQQBind(state); + if (state.status === "bound") { + setQQSyncPending(true); + message.success(t("已绑定 QQ 机器人")); + return; + } } } catch { // 下一次轮询重试。 } - }, 2000); + if (!stopped) timer = window.setTimeout(poll, 2000); + }; + timer = window.setTimeout(poll, 2000); return () => { stopped = true; - window.clearInterval(timer); + window.clearTimeout(timer); }; }, [qqBindPolling]); + // 绑定成功后只刷新 QQ 机器人的凭据和接收方,不覆盖其他未保存的修改;失败时持续重试。 + useEffect(() => { + if (!qqSyncPending) return; + let stopped = false; + let timer: number | undefined; + const sync = async () => { + try { + const data = await api("/settings/notifications"); + if (stopped) return; + const saved = formsFromNotifications(data || {}).qqbot; + setForms(prev => ({ + ...prev, + qqbot: { + ...prev.qqbot, + // 完成绑定后服务器已启用该通道。 + enabled: saved.enabled, + appId: saved.appId, + appSecret: saved.appSecret, + targetOpenid: saved.targetOpenid, + }, + })); + // 绑定已写入新凭据:撤销尚未保存的“清空配置”,否则保存时会清掉刚绑定的 AppSecret。 + setClearedChannels(prev => prev.filter(channel => channel !== "qqbot")); + setQQSyncPending(false); + } catch { + if (!stopped) timer = window.setTimeout(sync, 2000); + } + }; + void sync(); + return () => { + stopped = true; + window.clearTimeout(timer); + }; + }, [qqSyncPending]); + const onTestWebhook = useCallback(async () => { setTestingWebhook(true); try { From 67fd3a27349c6f17bdf1d728480ec54e6d9b2786 Mon Sep 17 00:00:00 2001 From: qianmokano Date: Mon, 9 Nov 2026 21:58:51 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(notify):=20=E5=A4=84=E7=90=86=20QQ=20?= =?UTF-8?q?=E6=9C=BA=E5=99=A8=E4=BA=BA=E7=BB=91=E5=AE=9A=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扫码绑定从扫码结果取得凭据,不再要求重新填写表单中的 AppSecret。 - 绑定协程在绑定锁内登记;StopQQBotBinding 开始后拒绝新的绑定, 不会在等待返回后再启动协程(含服务异常退出路径)。 - 取消绑定返回实际状态:绑定已在取消前完成时返回 bound,前端据此 同步接收方;取消请求失败时持续查询服务器状态,确认前不允许保存。 - 同步绑定结果期间锁定凭据和接收方输入框;绑定结束后清除扫码回退 状态。 - 平台错误说明同时隐去当前接收方 openid(包括较短的 openid)。 - 测试:写入与取消交错、取消晚于写入、停止等待协程退出;测试网关在 连接关闭后退出。为新增函数补充注释。 Co-Authored-By: Claude Opus 5.5 --- internal/server/qqbot_bind.go | 56 +++++++++-- internal/server/qqbot_bind_test.go | 103 ++++++++++++++++++++- internal/server/qqbot_notification.go | 25 ++++- internal/server/qqbot_notification_test.go | 13 +++ internal/server/server.go | 1 + web/src/components/settings/PushTabs.tsx | 19 +++- web/src/components/settings/model.ts | 1 + web/src/pages/SettingsPage.tsx | 45 ++++++--- 8 files changed, 228 insertions(+), 35 deletions(-) diff --git a/internal/server/qqbot_bind.go b/internal/server/qqbot_bind.go index 29fadaf7..c2d27aa9 100644 --- a/internal/server/qqbot_bind.go +++ b/internal/server/qqbot_bind.go @@ -63,6 +63,7 @@ type qqBotBindSession struct { cancel context.CancelFunc } +// finished 报告绑定是否已结束(成功、过期、失败或取消)。 func (session *qqBotBindSession) finished() bool { switch session.Status { case qqBotBindBound, qqBotBindExpired, qqBotBindFailed, qqBotBindCanceled: @@ -85,6 +86,7 @@ type qqBotBindEnv struct { var qqBotBindEnvironment = defaultQQBotBindEnv +// defaultQQBotBindEnv 返回连接真实开放平台的绑定环境,并校验各端点和代理。 func defaultQQBotBindEnv(ctx context.Context, config map[string]any) (qqBotBindEnv, error) { endpoints := defaultQQBotEndpoints for _, endpoint := range []string{endpoints.TokenURL, endpoints.APIBase, endpoints.ConnectBase} { @@ -142,6 +144,7 @@ func newQQBotBindCode() (string, error) { return code.String(), nil } +// handleQQBotBind 处理绑定状态查询(GET)、开始绑定(POST)和取消绑定(DELETE)。 func (s *Server) handleQQBotBind(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -161,13 +164,18 @@ func (s *Server) handleQQBotBind(w http.ResponseWriter, r *http.Request) { case http.MethodPost: s.startQQBotBind(w, r) case http.MethodDelete: + // 返回取消后的实际状态:绑定已在取消前完成时为 bound,前端据此同步已保存的接收方。 + status := qqBotBindCanceled s.qqBotBindMu.Lock() - if session := s.qqBotBind; session != nil && !session.finished() { - session.cancel() - session.Status = qqBotBindCanceled + if session := s.qqBotBind; session != nil { + if !session.finished() { + session.cancel() + session.Status = qqBotBindCanceled + } + status = session.Status } s.qqBotBindMu.Unlock() - writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": qqBotBindCanceled}}) + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": status}}) default: w.Header().Set("Allow", "GET, POST, DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -195,7 +203,8 @@ func (s *Server) startQQBotBind(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) return } - if !s.qqBotSecretReentered(w, r, incoming) { + // 扫码绑定从扫码结果取得凭据,表单中的 AppID/AppSecret 不参与,只有绑定码需要检查。 + if mode == "code" && !s.qqBotSecretReentered(w, r, incoming) { return } // 与测试通知相同:表单回传的掩码从已保存配置中恢复。 @@ -229,6 +238,7 @@ func (s *Server) startQQBotBind(w http.ResponseWriter, r *http.Request) { return } + var run func() ctx, cancel := context.WithTimeout(s.qqBotWorkContext(), qqBotBindTTL) session := &qqBotBindSession{ Mode: mode, ExpiresAt: time.Now().Add(qqBotBindTTL).UTC(), @@ -244,7 +254,7 @@ func (s *Server) startQQBotBind(w http.ResponseWriter, r *http.Request) { } session.QRURL = task.URL session.Status = qqBotBindWaiting - s.qqBotBindWork.Go(func() { s.runQQBotQRBind(ctx, session, resolved, env, task) }) + run = func() { s.runQQBotQRBind(ctx, session, resolved, env, task) } } else { code, err := newQQBotBindCode() if err != nil { @@ -253,15 +263,23 @@ func (s *Server) startQQBotBind(w http.ResponseWriter, r *http.Request) { return } session.Code = code - s.qqBotBindWork.Go(func() { s.runQQBotCodeBind(ctx, session, resolved, env) }) + run = func() { s.runQQBotCodeBind(ctx, session, resolved, env) } } s.qqBotBindMu.Lock() + // 在同一把锁内登记协程:StopQQBotBinding 开始等待之后不再启动新的绑定。 + if s.qqBotStopping { + s.qqBotBindMu.Unlock() + cancel() + writeError(w, http.StatusServiceUnavailable, "qqbot_bind_unavailable", "the server is shutting down") + return + } if previous := s.qqBotBind; previous != nil && !previous.finished() { previous.cancel() previous.Status = qqBotBindCanceled } s.qqBotBind = session + s.qqBotBindWork.Go(run) view := *session view.cancel = nil s.qqBotBindMu.Unlock() @@ -283,6 +301,7 @@ func (s *Server) qqBotWorkContext() context.Context { func (s *Server) StopQQBotBinding() { s.qqBotWorkContext() s.qqBotBindMu.Lock() + s.qqBotStopping = true s.qqBotWorkCancel() if session := s.qqBotBind; session != nil && !session.finished() { session.cancel() @@ -292,6 +311,16 @@ func (s *Server) StopQQBotBinding() { s.qqBotBindWork.Wait() } +// startQQBotWork 在绑定锁内登记并启动后台协程;停止开始后不再启动。 +func (s *Server) startQQBotWork(work func()) { + s.qqBotBindMu.Lock() + defer s.qqBotBindMu.Unlock() + if !s.qqBotStopping { + s.qqBotBindWork.Go(work) + } +} + +// updateQQBotBind 在绑定锁内更新会话;已取消的会话不再变化。 func (s *Server) updateQQBotBind(session *qqBotBindSession, update func(*qqBotBindSession)) { s.qqBotBindMu.Lock() defer s.qqBotBindMu.Unlock() @@ -301,6 +330,7 @@ func (s *Server) updateQQBotBind(session *qqBotBindSession, update func(*qqBotBi update(session) } +// failQQBotBind 把会话标记为失败并记录原因。 func (s *Server) failQQBotBind(session *qqBotBindSession, message string) { s.logger.Warn("QQ bot binding failed", "mode", session.Mode, "error", message) s.updateQQBotBind(session, func(session *qqBotBindSession) { @@ -338,6 +368,7 @@ func (s *Server) finishQQBotBindOnTimeout(session *qqBotBindSession) { }) } +// redactQQBotError 隐去错误信息中出现的 AppSecret 等敏感配置。 func redactQQBotError(err error, config map[string]any) string { encoded, _ := json.Marshal(config) return store.RedactText(err.Error(), store.NotificationSetting{ @@ -359,6 +390,7 @@ type qqBotConnectReply struct { Data json.RawMessage `json:"data"` } +// postQQBotConnect 调用扫码绑定接口,并把返回的 data 解码到 data 中。 func postQQBotConnect(ctx context.Context, env qqBotBindEnv, path string, body any, data any) error { payload, err := json.Marshal(body) if err != nil { @@ -450,6 +482,7 @@ func decryptQQBotSecret(encoded string, key []byte) (string, error) { return string(plain), nil } +// runQQBotQRBind 轮询扫码结果,二维码过期时换新任务,直到完成、失败或会话结束。 func (s *Server) runQQBotQRBind(ctx context.Context, session *qqBotBindSession, config map[string]any, env qqBotBindEnv, task qqBotQRTask) { defer session.cancel() for { @@ -491,6 +524,7 @@ func (s *Server) runQQBotQRBind(ctx context.Context, session *qqBotBindSession, } } +// completeQQBotQRBind 解密扫码得到的凭据并保存绑定;没有返回接收方时改用绑定码。 func (s *Server) completeQQBotQRBind( ctx context.Context, session *qqBotBindSession, @@ -544,7 +578,7 @@ func (s *Server) completeQQBotQRBind( } s.logger.Info("QQ bot notification recipient bound", "method", "qr") // 绑定会话随即结束,上线使用独立的超时。 - s.qqBotBindWork.Go(func() { + s.startQQBotWork(func() { ctx, cancel := context.WithTimeout(s.qqBotWorkContext(), qqBotOnlineTimeout+env.onlineHold) defer cancel() if err := bringQQBotOnline(ctx, env, appID, secret); err != nil { @@ -610,6 +644,7 @@ func bringQQBotOnline(ctx context.Context, env qqBotBindEnv, appID, secret strin // ---- 绑定码 ---- +// runQQBotCodeBind 连接网关等待绑定码消息,连接断开时重试,直到完成、失败或会话结束。 func (s *Server) runQQBotCodeBind(ctx context.Context, session *qqBotBindSession, config map[string]any, env qqBotBindEnv) { defer session.cancel() appID := strings.TrimSpace(configString(config, "app_id")) @@ -773,6 +808,7 @@ func connectQQBotGateway(ctx context.Context, env qqBotBindEnv, appID, secret st } } +// fetchQQBotGateway 查询 WebSocket 网关地址并校验。 func fetchQQBotGateway(ctx context.Context, env qqBotBindEnv, token string) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(env.endpoints.APIBase, "/")+"/gateway", nil) if err != nil { @@ -820,6 +856,7 @@ func matchQQBotBindEvent(kind string, raw json.RawMessage, code string) (qqBotBi return qqBotBindEvent{OpenID: event.Author.UserOpenID, MessageID: event.ID}, true } +// completeQQBotCodeBind 保存发送绑定码的用户为接收方,并被动回复确认消息。 func (s *Server) completeQQBotCodeBind( ctx context.Context, session *qqBotBindSession, @@ -874,6 +911,7 @@ func (s *Server) saveQQBotBinding(ctx context.Context, config map[string]any, op }) } +// replyQQBotBind 以被动回复的方式告知用户绑定已完成。 func replyQQBotBind(ctx context.Context, env qqBotBindEnv, appID, secret string, event qqBotBindEvent) error { token, err := qqBotTokens.get(ctx, env.client, env.endpoints.TokenURL, appID, secret) if err != nil { @@ -891,7 +929,7 @@ func replyQQBotBind(ctx context.Context, env qqBotBindEnv, appID, secret string, return err } if status < 200 || status >= 300 || reply.Code != 0 { - return fmt.Errorf("%w: QQ bot HTTP %d%s", errProviderRejected, status, reply.describe()) + return fmt.Errorf("%w: QQ bot HTTP %d%s", errProviderRejected, status, reply.describe(event.OpenID)) } return nil } diff --git a/internal/server/qqbot_bind_test.go b/internal/server/qqbot_bind_test.go index 688fde3b..1ae4a0fe 100644 --- a/internal/server/qqbot_bind_test.go +++ b/internal/server/qqbot_bind_test.go @@ -161,8 +161,10 @@ func (gateway *qqBotFakeGateway) serveWebSocket(w http.ResponseWriter, r *http.R } sequence := int64(1) send(qqBotGatewayFrame{Op: 0, S: &sequence, T: "READY", D: json.RawMessage(`{"session_id":"s"}`)}) - // 后台读取心跳,避免阻塞客户端写入。 + // 后台读取心跳,避免阻塞客户端写入;连接关闭时通知下面的循环退出。 + closed := make(chan struct{}) go func() { + defer close(closed) for { if _, _, err := conn.Read(ctx); err != nil { return @@ -173,6 +175,8 @@ func (gateway *qqBotFakeGateway) serveWebSocket(w http.ResponseWriter, r *http.R select { case <-ctx.Done(): return + case <-closed: + return case frame := <-gateway.events: sequence++ frame.S = &sequence @@ -610,6 +614,57 @@ func qqBotTokenCacheKeyForTest(t *testing.T) string { return env.endpoints.TokenURL + "\x00" + qqBotTestAppID } +func TestQQBotCancelDuringCommitDoesNotSave(t *testing.T) { + api := newSettingsAPITest(t) + session := &qqBotBindSession{Status: qqBotBindWaiting, cancel: func() {}} + // 持有绑定锁,让写入等待;期间取消会话,模拟 DELETE 与写入交错。 + api.server.qqBotBindMu.Lock() + type result struct { + committed bool + err error + } + done := make(chan result, 1) + go func() { + committed, err := api.server.commitQQBotBind(context.Background(), session, qqBotTestConfig(nil), "USER_OPENID") + done <- result{committed, err} + }() + select { + case <-done: + api.server.qqBotBindMu.Unlock() + t.Fatal("commit did not wait for the binding lock") + case <-time.After(50 * time.Millisecond): + } + session.Status = qqBotBindCanceled + api.server.qqBotBindMu.Unlock() + if got := <-done; got.err != nil || got.committed { + t.Fatalf("committed=%v err=%v", got.committed, got.err) + } + if _, err := api.database.NotificationSetting(context.Background(), "qqbot"); err == nil { + t.Fatal("canceled binding was saved") + } +} + +func TestQQBotCancelAfterCommitReportsBound(t *testing.T) { + api := newSettingsAPITest(t) + session := &qqBotBindSession{Mode: "code", Status: qqBotBindWaiting, cancel: func() {}} + api.server.qqBotBindMu.Lock() + api.server.qqBotBind = session + api.server.qqBotBindMu.Unlock() + committed, err := api.server.commitQQBotBind(context.Background(), session, qqBotTestConfig(nil), "USER_OPENID") + if err != nil || !committed { + t.Fatalf("committed=%v err=%v", committed, err) + } + // 绑定完成后才到达的取消不改变结果,并返回实际状态,前端据此同步接收方。 + response := api.request(t, http.MethodDelete, "/api/settings/notifications/qqbot/bind", "") + data, _ := decodeSettingsResponse(t, response)["data"].(map[string]any) + if response.Code != http.StatusOK || data["status"] != string(qqBotBindBound) { + t.Fatalf("delete: %d %#v", response.Code, data) + } + if setting, config := storedQQBotConfig(t, api); !setting.Enabled || config["target_openid"] != "USER_OPENID" { + t.Fatalf("config = %#v", config) + } +} + func TestQQBotCanceledBindDoesNotSave(t *testing.T) { api := newSettingsAPITest(t) session := &qqBotBindSession{Status: qqBotBindCanceled, cancel: func() {}} @@ -622,6 +677,36 @@ func TestQQBotCanceledBindDoesNotSave(t *testing.T) { } } +func TestQQBotStopBindingWaitsForWorkers(t *testing.T) { + api := newSettingsAPITest(t) + release := make(chan struct{}) + api.server.startQQBotWork(func() { <-release }) + stopped := make(chan struct{}) + go func() { + api.server.StopQQBotBinding() + close(stopped) + }() + select { + case <-stopped: + t.Fatal("StopQQBotBinding returned before the worker exited") + case <-time.After(50 * time.Millisecond): + } + close(release) + select { + case <-stopped: + case <-time.After(5 * time.Second): + t.Fatal("StopQQBotBinding did not return after the worker exited") + } + // 停止开始后不再登记新的协程。 + ran := make(chan struct{}, 1) + api.server.startQQBotWork(func() { ran <- struct{}{} }) + select { + case <-ran: + t.Fatal("worker started after stop") + case <-time.After(50 * time.Millisecond): + } +} + func TestQQBotStopBindingCancelsAndWaits(t *testing.T) { newQQBotFakeGateway(t) api := newSettingsAPITest(t) @@ -629,10 +714,20 @@ func TestQQBotStopBindingCancelsAndWaits(t *testing.T) { waitQQBotBind(t, api, qqBotBindWaiting) api.server.StopQQBotBinding() waitQQBotBind(t, api, qqBotBindCanceled) - // 停止后新的绑定立即结束,不会在关闭后继续访问存储。 + // 停止开始后拒绝新的绑定,不会在关闭后启动协程或访问存储。 response := api.request(t, http.MethodPost, "/api/settings/notifications/qqbot/bind", qqBotBindDraft) - if response.Code != http.StatusAccepted { + if response.Code != http.StatusServiceUnavailable { t.Fatalf("start after stop: %d %s", response.Code, response.Body) } - api.server.StopQQBotBinding() +} + +func TestQQBotQRBindIgnoresDraftCredentials(t *testing.T) { + newQQBotFakeGateway(t) + api := newSettingsAPITest(t) + if response := api.request(t, http.MethodPut, "/api/settings/notifications", + `{"qqbot":{"enabled":true,"app_id":"999","app_secret":"old-secret","target_openid":"OLD"}}`); response.Code != http.StatusOK { + t.Fatalf("save: %d %s", response.Code, response.Body) + } + // 扫码绑定不使用表单中的凭据:改过 AppID 也可以开始扫码。 + beginQQBotBind(t, api, `{"mode":"qr","app_id":"102000002","app_secret":"`+store.SecretMask+`"}`) } diff --git a/internal/server/qqbot_notification.go b/internal/server/qqbot_notification.go index 412b84e4..fb6eb928 100644 --- a/internal/server/qqbot_notification.go +++ b/internal/server/qqbot_notification.go @@ -34,6 +34,7 @@ var ( qqBotOpenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) ) +// validateQQBotNotificationConfig 检查发送所需的 AppID、AppSecret 和接收方 openid。 func validateQQBotNotificationConfig(config map[string]any) error { if !qqBotAppIDPattern.MatchString(strings.TrimSpace(configString(config, "app_id"))) { return errors.New("qqbot.app_id must be the numeric AppID") @@ -108,6 +109,7 @@ func (s *Server) qqBotSecretReentered(w http.ResponseWriter, r *http.Request, dr // 默认调用真实开放平台;测试中可替换为本地接收端,不访问真实收件人。 var qqBotNotificationSender = deliverQQBotNotification +// sendQQBotNotification 按设备范围过滤后发送一条 QQ 机器人通知。 func sendQQBotNotification(ctx context.Context, config map[string]any, deviceID *string, title, text string) error { if deviceID != nil && !notificationMatchesDevice(configStrings(config, "device_ids"), *deviceID) { return nil @@ -115,6 +117,7 @@ func sendQQBotNotification(ctx context.Context, config map[string]any, deviceID return qqBotNotificationSender(ctx, config, title, text) } +// deliverQQBotNotification 校验配置和目标地址,通过受限的 HTTP 客户端发送通知。 func deliverQQBotNotification(ctx context.Context, config map[string]any, title, text string) error { if err := validateQQBotNotificationConfig(config); err != nil { return err @@ -132,6 +135,7 @@ func deliverQQBotNotification(ctx context.Context, config map[string]any, title, return postQQBotNotification(ctx, client, endpoints, config, title, text) } +// postQQBotNotification 用单聊消息接口把通知发给接收方;令牌被拒绝时换新令牌重试一次。 func postQQBotNotification( ctx context.Context, client *http.Client, @@ -169,12 +173,14 @@ func postQQBotNotification( continue } if status < 200 || status >= 300 || reply.Code != 0 { - return fmt.Errorf("%w: QQ bot HTTP %d%s", errProviderRejected, status, reply.describe()) + return fmt.Errorf("%w: QQ bot HTTP %d%s", errProviderRejected, status, + reply.describe(configString(config, "target_openid"))) } return nil } } +// qqBotContent 把标题和正文合并为纯文本消息,并限制长度。 func qqBotContent(title, text string) string { content := strings.TrimSpace(text) if title = strings.TrimSpace(title); title != "" { @@ -195,12 +201,19 @@ type qqBotReply struct { // qqBotOpaqueValue 匹配平台说明中可能出现的 openid、令牌等长标识。 var qqBotOpaqueValue = regexp.MustCompile(`[A-Za-z0-9_-]{20,}`) -// describe 只返回平台的错误码和说明;说明中的长标识(openid、令牌等)会被隐去。 -func (reply qqBotReply) describe() string { +// describe 只返回平台的错误码和说明。hidden 中的值(如收件 openid,可能较短)以及 +// 说明中的长标识(openid、令牌等)会被隐去。 +func (reply qqBotReply) describe(hidden ...string) string { if reply.Code == 0 && reply.Message == "" { return "" } - redacted := qqBotOpaqueValue.ReplaceAllString(strings.TrimSpace(reply.Message), "[redacted]") + redacted := strings.TrimSpace(reply.Message) + for _, value := range hidden { + if value = strings.TrimSpace(value); value != "" { + redacted = strings.ReplaceAll(redacted, value, "[redacted]") + } + } + redacted = qqBotOpaqueValue.ReplaceAllString(redacted, "[redacted]") message := []rune(redacted) if len(message) > 200 { message = message[:200] @@ -208,6 +221,7 @@ func (reply qqBotReply) describe() string { return fmt.Sprintf(" (code %d: %s)", reply.Code, string(message)) } +// postQQBotMessage 发送一次消息请求,返回 HTTP 状态和平台的错误码说明。 func postQQBotMessage( ctx context.Context, client *http.Client, @@ -262,6 +276,7 @@ type qqBotToken struct { var qqBotTokens = &qqBotTokenCache{entries: map[string]qqBotToken{}, now: time.Now} +// get 返回缓存中未过期的令牌,否则向开放平台获取新令牌。 func (cache *qqBotTokenCache) get( ctx context.Context, client *http.Client, @@ -286,6 +301,7 @@ func (cache *qqBotTokenCache) get( return token, nil } +// invalidate 丢弃被平台拒绝的令牌,下次发送时重新获取。 func (cache *qqBotTokenCache) invalidate(tokenURL, appID, token string) { key := tokenURL + "\x00" + appID cache.mu.Lock() @@ -295,6 +311,7 @@ func (cache *qqBotTokenCache) invalidate(tokenURL, appID, token string) { } } +// fetchQQBotToken 用 AppID 和 AppSecret 换取访问令牌及其有效期。 func fetchQQBotToken( ctx context.Context, client *http.Client, diff --git a/internal/server/qqbot_notification_test.go b/internal/server/qqbot_notification_test.go index 719766b6..c7aa620c 100644 --- a/internal/server/qqbot_notification_test.go +++ b/internal/server/qqbot_notification_test.go @@ -425,3 +425,16 @@ func TestQQBotRequiresSecretWhenAppIDChanges(t *testing.T) { } } } + +func TestQQBotReplyDescriptionHidesShortRecipient(t *testing.T) { + description := qqBotReply{Code: 40034, Message: "user OPENID_1 rejected"}.describe("OPENID_1") + if strings.Contains(description, "OPENID_1") || !strings.Contains(description, "rejected") { + t.Fatalf("description = %q", description) + } + platform := newQQBotFakePlatform(t) + platform.messageBody = `{"code":40034,"message":"user OPENID_1 rejected"}` + err := platform.post(t, qqBotTestConfig(map[string]any{"target_openid": "OPENID_1"}), "a", "b") + if !errors.Is(err, errProviderRejected) || strings.Contains(err.Error(), "OPENID_1") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 17045dc2..05a09318 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -107,6 +107,7 @@ type Server struct { // qqBotWorkCtx 是这些协程的父 context,由 qqBotBindMu 保护,StopQQBotBinding 取消它。 qqBotWorkCtx context.Context qqBotWorkCancel context.CancelFunc + qqBotStopping bool // 由 qqBotBindMu 保护:StopQQBotBinding 开始后为真。 esimSIMResetAttempted map[string]bool // 由 smsSyncMu 保护;读取成功后允许下一次故障恢复。 smsStorageMu sync.Mutex smsStorage map[string]device.SMSStorageUsage diff --git a/web/src/components/settings/PushTabs.tsx b/web/src/components/settings/PushTabs.tsx index 3b8ea072..e6f5e374 100644 --- a/web/src/components/settings/PushTabs.tsx +++ b/web/src/components/settings/PushTabs.tsx @@ -487,6 +487,7 @@ export function MeowTab({ value, onChange, testing, onTest }: PushChannelProps & { onClear: () => void; bind: QQBotBindState | null; binding: boolean; + /** 正在从服务器同步绑定结果:期间锁定凭据和接收方,避免编辑被同步结果覆盖。 */ + syncing: boolean; onBind: (mode: QQBotBindMode) => void; onCancelBind: () => void; }) { @@ -546,7 +550,11 @@ export function QQBotTab({ previousMode.current = bind.mode; }, [active, bind?.mode]); useEffect(() => { - if (!active) previousMode.current = null; + // 绑定结束后清除回退状态,下一次绑定不会沿用上一次的提示和隐藏的凭据字段。 + if (!active) { + previousMode.current = null; + setFellBack(false); + } }, [active]); const qrUrl = active && bind?.mode === "qr" ? bind.qrUrl : undefined; // 二维码只编码开放平台的绑定页地址,在本地生成,不经第三方服务。 @@ -593,10 +601,10 @@ export function QQBotTab({
- onChange({ appId: e.target.value })} inputMode="numeric" /> + onChange({ appId: e.target.value })} disabled={syncing} inputMode="numeric" /> - onChange({ appSecret: e.target.value })} type="password" /> + onChange({ appSecret: e.target.value })} disabled={syncing} type="password" />
@@ -604,6 +612,7 @@ export function QQBotTab({ aria-label={t("接收方 openid(QQ 单聊用户)")} value={value.targetOpenid} onChange={(e) => onChange({ targetOpenid: e.target.value })} + disabled={syncing} className="font-mono" /> @@ -661,10 +670,10 @@ export function QQBotTab({
- onChange({ appId: e.target.value })} disabled={active} inputMode="numeric" /> + onChange({ appId: e.target.value })} disabled={active || syncing} inputMode="numeric" /> - onChange({ appSecret: e.target.value })} disabled={active} type="password" /> + onChange({ appSecret: e.target.value })} disabled={active || syncing} type="password" />
diff --git a/web/src/components/settings/model.ts b/web/src/components/settings/model.ts index 1da38a9a..15598483 100644 --- a/web/src/components/settings/model.ts +++ b/web/src/components/settings/model.ts @@ -347,6 +347,7 @@ export function buildMeowPayload(form: NotifyForms["meow"]) { }; } +/** 把 QQ 机器人表单整理为提交给接口的配置(去掉首尾空白)。 */ export function buildQQBotPayload(form: QQBotForm) { return { enabled: form.enabled, diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 86047785..cee88d24 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -92,6 +92,10 @@ export default function SettingsPage() { const [startingQQBind, setStartingQQBind] = useState(false); // 绑定完成后要从服务器取回新的凭据和接收方;取回之前不允许保存,避免旧表单覆盖新绑定。 const [qqSyncPending, setQQSyncPending] = useState(false); + // 取消请求发出后、确认服务器实际状态前同样不允许保存:绑定可能已在取消前完成。 + const [qqCancelPending, setQQCancelPending] = useState(false); + const pageUnmounted = useRef(false); + useEffect(() => () => { pageUnmounted.current = true; }, []); const [activeTab, setActiveTab] = useState("telegram"); const [loadingNotif, setLoadingNotif] = useState(false); const [savingNotif, setSavingNotif] = useState(false); @@ -378,7 +382,7 @@ export default function SettingsPage() { const onSaveNotifications = useCallback(async () => { if (savingNotif) return; - if (qqSyncPending) { + if (qqSyncPending || qqCancelPending) { message.warning(t("正在同步 QQ 机器人的绑定结果,请稍后再保存")); return; } @@ -397,7 +401,7 @@ export default function SettingsPage() { } finally { setSavingNotif(false); } - }, [forms, clearedChannels, savingNotif, qqSyncPending]); + }, [forms, clearedChannels, savingNotif, qqSyncPending, qqCancelPending]); const onTestMeow = async () => { setTestingMeow(true); @@ -440,20 +444,35 @@ export default function SettingsPage() { }; const onCancelQQBind = async () => { - // 先更新界面(例如从扫码切换到手动绑定时立即生效),再通知服务器停止。 + // 先更新界面(例如从扫码切换到手动绑定时立即生效),再通知服务器停止,并确认服务器的实际状态。 setQQBind(null); + setQQCancelPending(true); try { - await api("/settings/notifications/qqbot/bind", { method: "DELETE" }); - } catch (error) { - // 服务器可能仍在绑定:恢复它的真实状态,继续轮询或同步结果。 - message.error(apiMessage(error) || t("取消绑定失败")); + let status: QQBotBindState["status"] | undefined; try { - const state = await api("/settings/notifications/qqbot/bind"); - setQQBind(state ?? null); - if (state?.status === "bound") setQQSyncPending(true); - } catch { - // 绑定会在有效期结束后自动停止。 + status = (await api<{ status: QQBotBindState["status"] }>("/settings/notifications/qqbot/bind", { method: "DELETE" }))?.status; + } catch (error) { + message.error(apiMessage(error) || t("取消绑定失败")); + } + // 绑定已在取消前完成:同步已保存的接收方。 + if (status === "bound") { + setQQSyncPending(true); + return; } + if (status) return; + // 取消请求失败:服务器可能仍在绑定,持续查询直到拿到实际状态,再继续轮询或同步结果。 + while (!pageUnmounted.current) { + try { + const state = await api("/settings/notifications/qqbot/bind"); + setQQBind(state ?? null); + if (state?.status === "bound") setQQSyncPending(true); + return; + } catch { + await new Promise(resolve => window.setTimeout(resolve, 2000)); + } + } + } finally { + setQQCancelPending(false); } }; @@ -753,7 +772,7 @@ export default function SettingsPage() {
({ ...tab, label: t(tab.label) }))} value={activeTab} onChange={setActiveTab} /> {activeTab === "meow" ? updateChannel("meow", p)} testing={testingMeow} onTest={onTestMeow} /> : null} - {activeTab === "qqbot" ? void clearChannel("qqbot")} value={forms.qqbot} onChange={(p) => updateChannel("qqbot", p)} testing={testingQQBot} onTest={onTestQQBot} bind={qqBind} binding={startingQQBind} onBind={(mode) => void onBindQQBot(mode)} onCancelBind={() => void onCancelQQBind()} /> : null} + {activeTab === "qqbot" ? void clearChannel("qqbot")} value={forms.qqbot} onChange={(p) => updateChannel("qqbot", p)} testing={testingQQBot} onTest={onTestQQBot} bind={qqBind} binding={startingQQBind} syncing={qqSyncPending || qqCancelPending} onBind={(mode) => void onBindQQBot(mode)} onCancelBind={() => void onCancelQQBind()} /> : null} {activeTab === "telegram" ? ( void clearChannel("telegram")} value={forms.telegram} onChange={(p) => updateChannel("telegram", p)} /> ) : null} From 8fb19085a43048271da0eceee724c14ed2a42cec Mon Sep 17 00:00:00 2001 From: qianmokano Date: Mon, 9 Nov 2026 23:15:46 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(notify):=20QQ=20=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BA=E7=BB=91=E5=AE=9A=E6=9C=9F=E9=97=B4=E4=B8=8D=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E4=BF=9D=E5=AD=98=E9=80=9A=E7=9F=A5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从开始绑定到同步完绑定结果之前都拦截保存:绑定可能在下一次轮询前 完成,此时保存会用旧表单覆盖新的接收方和启用状态。 - 卸载标志在副作用运行时复位,开发模式 StrictMode 重新运行副作用后 取消失败的状态查询仍会继续。 Co-Authored-By: Claude Opus 5.5 --- web/src/lib/i18n-en.ts | 1 + web/src/pages/SettingsPage.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index d7a269f2..257d8c56 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -176,6 +176,7 @@ export const EN_DICT: Record = { "扫码未返回接收方,请在 QQ 中私聊刚才选择的机器人,发送下方绑定码。完成后会一起保存机器人凭据。": "The scan didn't return a recipient. In QQ, send the code below to the bot you just selected. The bot credentials are saved once that's done.", "正在同步 QQ 机器人的绑定结果,请稍后再保存": "Syncing the QQ Bot binding. Save again in a moment.", "取消绑定失败": "Couldn't cancel binding", + "QQ 机器人正在绑定,请完成或取消绑定后再保存": "QQ Bot binding is in progress. Finish or cancel it before saving.", "绑定 QQ 机器人": "Bind a QQ Bot", "接收方 openid(QQ 单聊用户)": "Recipient openid (QQ direct-message user)", "清空并重新绑定": "Clear and bind again", diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index cee88d24..da00073d 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -95,7 +95,11 @@ export default function SettingsPage() { // 取消请求发出后、确认服务器实际状态前同样不允许保存:绑定可能已在取消前完成。 const [qqCancelPending, setQQCancelPending] = useState(false); const pageUnmounted = useRef(false); - useEffect(() => () => { pageUnmounted.current = true; }, []); + useEffect(() => { + // 开发模式的 StrictMode 会先清理再重新运行副作用:每次运行时复位,保证标志只在真正卸载后为真。 + pageUnmounted.current = false; + return () => { pageUnmounted.current = true; }; + }, []); const [activeTab, setActiveTab] = useState("telegram"); const [loadingNotif, setLoadingNotif] = useState(false); const [savingNotif, setSavingNotif] = useState(false); @@ -382,6 +386,11 @@ export default function SettingsPage() { const onSaveNotifications = useCallback(async () => { if (savingNotif) return; + // 绑定可能在下一次轮询前就已完成:从开始绑定到同步完结果之前都不允许保存,避免旧表单覆盖新绑定。 + if (startingQQBind || qqBotBindActive(qqBind)) { + message.warning(t("QQ 机器人正在绑定,请完成或取消绑定后再保存")); + return; + } if (qqSyncPending || qqCancelPending) { message.warning(t("正在同步 QQ 机器人的绑定结果,请稍后再保存")); return; @@ -401,7 +410,7 @@ export default function SettingsPage() { } finally { setSavingNotif(false); } - }, [forms, clearedChannels, savingNotif, qqSyncPending, qqCancelPending]); + }, [forms, clearedChannels, savingNotif, qqSyncPending, qqCancelPending, startingQQBind, qqBind]); const onTestMeow = async () => { setTestingMeow(true); From 0a9140a3912949e80d101a77f5bdec8f9cf4e387 Mon Sep 17 00:00:00 2001 From: qianmokano Date: Mon, 9 Nov 2026 23:34:06 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(notify):=20=E5=BC=80=E5=A7=8B=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E7=9A=84=E8=AF=B7=E6=B1=82=E5=A4=B1=E8=B4=A5=E5=90=8E?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=B8=8A=E7=9A=84?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开始绑定的响应可能丢失而服务器已经开始绑定:请求失败后查询一次绑定 状态,若仍在进行则继续轮询并同步结果,期间照常拦截保存。 Co-Authored-By: Claude Opus 5.5 --- web/src/pages/SettingsPage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index da00073d..6a81e089 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -447,6 +447,14 @@ export default function SettingsPage() { setQQBind(state); } catch (error) { message.error(apiMessage(error) || t("无法开始绑定")); + // 响应可能丢失而服务器已开始绑定:查询一次,若仍在进行则继续轮询并同步结果。 + // 只接管进行中的会话,较早结束的会话不影响当前表单。 + try { + const state = await api("/settings/notifications/qqbot/bind"); + if (qqBotBindActive(state)) setQQBind(state); + } catch { + // 查询也失败时保持现状,绑定会在有效期结束后自动停止。 + } } finally { setStartingQQBind(false); } From d06202e9299568b4d1dd71e648a85a390128e4cc Mon Sep 17 00:00:00 2001 From: qianmokano Date: Mon, 9 Nov 2026 23:49:24 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(notify):=20=E5=8F=AA=E5=9C=A8=E6=9C=AA?= =?UTF-8?q?=E6=94=B6=E5=88=B0=E5=93=8D=E5=BA=94=E6=97=B6=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E5=B7=B2=E5=BC=80=E5=A7=8B=E7=9A=84=20QQ=20=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BA=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务器明确拒绝开始绑定时没有创建会话,直接提示错误,不再接管其他页面 进行中的会话。只有网络中断或代理超时等未收到服务器响应的情况才查询 绑定状态;找到进行中的绑定时接管并不再提示错误。 Co-Authored-By: Claude Opus 5.5 --- web/src/pages/SettingsPage.tsx | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 6a81e089..dc8a93b1 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AlertRegular, CheckmarkRegular } from "@fluentui/react-icons"; -import { api, apiMessage, getApiTokens, getSecuritySettings, revokeApiToken, updateSecuritySettings } from "../api"; +import { ApiError, api, apiMessage, getApiTokens, getSecuritySettings, revokeApiToken, updateSecuritySettings } from "../api"; import type { ApiTokenListItem, DeveloperSettings, DeviceListItem, DevicesResponse, HTTPSSettings, NotificationSettings, SecuritySettings, SMSSettings, SystemInfo } from "../types"; import { Button, PageHeader, confirmDialog, message } from "../components/ui"; import { CardDecor, CardIcon, CardTitle, SecurityCard, SystemInfoCard } from "../components/settings/Cards"; @@ -446,15 +446,22 @@ export default function SettingsPage() { }); setQQBind(state); } catch (error) { - message.error(apiMessage(error) || t("无法开始绑定")); - // 响应可能丢失而服务器已开始绑定:查询一次,若仍在进行则继续轮询并同步结果。 - // 只接管进行中的会话,较早结束的会话不影响当前表单。 - try { - const state = await api("/settings/notifications/qqbot/bind"); - if (qqBotBindActive(state)) setQQBind(state); - } catch { - // 查询也失败时保持现状,绑定会在有效期结束后自动停止。 + // 服务器明确拒绝(返回了带错误码的响应)时没有开始绑定,直接提示。 + // 只有没收到服务器响应(网络中断,或代理超时等不带错误码的 5xx)时,绑定才可能已在服务器上开始: + // 查询一次,若仍在进行则接管并继续轮询,不再提示错误。 + const unanswered = !(error instanceof ApiError) || (error.status >= 500 && !error.code); + if (unanswered) { + try { + const state = await api("/settings/notifications/qqbot/bind"); + if (qqBotBindActive(state)) { + setQQBind(state); + return; + } + } catch { + // 查询也失败时按开始失败处理,绑定会在有效期结束后自动停止。 + } } + message.error(apiMessage(error) || t("无法开始绑定")); } finally { setStartingQQBind(false); }