diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index ea723204..1df90a89 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -735,6 +735,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { case err := <-serverError: cancelDeviceStartup() _ = protocolMux.Close() + handler.StopQQBotBinding() return err case <-signalContext.Done(): // Stop delayed OpenStick 410 startup work before the deferred VoWiFi @@ -761,9 +762,12 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err := <-shutdownErrors; err != nil { _ = plainServer.Close() _ = tlsServer.Close() + handler.StopQQBotBinding() return fmt.Errorf("graceful HTTP shutdown: %w", err) } } + // QQ 机器人绑定在请求结束后仍在后台运行:在数据库关闭前停止并等待。 + handler.StopQQBotBinding() return nil } 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..c2d27aa9 --- /dev/null +++ b/internal/server/qqbot_bind.go @@ -0,0 +1,935 @@ +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 +} + +// finished 报告绑定是否已结束(成功、过期、失败或取消)。 +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 + +// 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 +} + +// qqBotBindCodeAlphabet 去掉了容易混淆的 0/O、1/I/L。 +const qqBotBindCodeAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + +// newQQBotBindCode 生成 VOCAT-XXXXX-XXXXX:31^10 ≈ 8×10^14 种组合, +// 有效期内无法靠给机器人发消息猜中,把自己绑定成接收方。 +func newQQBotBindCode() (string, error) { + var code strings.Builder + code.WriteString("VOCAT-") + limit := big.NewInt(int64(len(qqBotBindCodeAlphabet))) + for index := range 10 { + if index == 5 { + code.WriteByte('-') + } + number, err := rand.Int(rand.Reader, limit) + if err != nil { + return "", err + } + code.WriteByte(qqBotBindCodeAlphabet[number.Int64()]) + } + return code.String(), nil +} + +// handleQQBotBind 处理绑定状态查询(GET)、开始绑定(POST)和取消绑定(DELETE)。 +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: + // 返回取消后的实际状态:绑定已在取消前完成时为 bound,前端据此同步已保存的接收方。 + status := qqBotBindCanceled + s.qqBotBindMu.Lock() + 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": status}}) + 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 + } + // 扫码绑定从扫码结果取得凭据,表单中的 AppID/AppSecret 不参与,只有绑定码需要检查。 + if mode == "code" && !s.qqBotSecretReentered(w, r, incoming) { + 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 + } + + var run func() + ctx, cancel := context.WithTimeout(s.qqBotWorkContext(), 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 + run = func() { s.runQQBotQRBind(ctx, session, resolved, env, task) } + } else { + code, err := newQQBotBindCode() + if err != nil { + cancel() + s.writeStoreError(w, err) + return + } + session.Code = code + 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() + writeJSON(w, http.StatusAccepted, map[string]any{"data": view}) +} + +// qqBotWorkContext 返回绑定协程的父 context;StopQQBotBinding 之后返回已取消的 context。 +func (s *Server) qqBotWorkContext() context.Context { + s.qqBotBindMu.Lock() + defer s.qqBotBindMu.Unlock() + if s.qqBotWorkCtx == nil { + s.qqBotWorkCtx, s.qqBotWorkCancel = context.WithCancel(context.Background()) + } + return s.qqBotWorkCtx +} + +// StopQQBotBinding 取消进行中的绑定和扫码后的上线登录,并等待它们退出。 +// 在关闭 HTTP 服务之后、关闭数据库之前调用,避免后台协程在关闭后访问存储。 +func (s *Server) StopQQBotBinding() { + s.qqBotWorkContext() + s.qqBotBindMu.Lock() + s.qqBotStopping = true + s.qqBotWorkCancel() + if session := s.qqBotBind; session != nil && !session.finished() { + session.cancel() + session.Status = qqBotBindCanceled + } + s.qqBotBindMu.Unlock() + 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() + if session.Status == qqBotBindCanceled { + return + } + 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) { + session.Status = qqBotBindFailed + session.Error = message + }) +} + +// commitQQBotBind 在绑定锁内保存接收方并把会话标记为已绑定,与取消(DELETE 或新的绑定)串行: +// 已取消的会话不会再写入,已写入的绑定也不会被报告为已取消。返回是否已写入。 +func (s *Server) commitQQBotBind( + ctx context.Context, + session *qqBotBindSession, + config map[string]any, + openID string, +) (bool, error) { + s.qqBotBindMu.Lock() + defer s.qqBotBindMu.Unlock() + if session.Status == qqBotBindCanceled { + return false, nil + } + if err := s.saveQQBotBinding(ctx, config, openID); err != nil { + return false, err + } + session.Status, session.Error, session.QRURL = qqBotBindBound, "", "" + return true, nil +} + +// finishQQBotBindOnTimeout 在 ctx 结束时把未完成的绑定标记为过期。 +func (s *Server) finishQQBotBindOnTimeout(session *qqBotBindSession) { + s.updateQQBotBind(session, func(session *qqBotBindSession) { + if !session.finished() { + session.Status = qqBotBindExpired + } + }) +} + +// redactQQBotError 隐去错误信息中出现的 AppSecret 等敏感配置。 +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"` +} + +// 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 { + 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 +} + +// runQQBotQRBind 轮询扫码结果,二维码过期时换新任务,直到完成、失败或会话结束。 +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 + } + } +} + +// completeQQBotQRBind 解密扫码得到的凭据并保存绑定;没有返回接收方时改用绑定码。 +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) { + // 平台未返回扫码用户:改用绑定码获取接收方。新凭据只保存在本次会话中, + // 拿到接收方后与之一起写入,避免中途出现“新机器人 + 旧接收方”的配置。 + 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 + } + committed, err := s.commitQQBotBind(ctx, session, scanned, openID) + if err != nil { + s.logger.Error("save QQ bot binding", "error", err) + s.failQQBotBind(session, "the binding could not be saved") + return + } + if !committed { + return + } + s.logger.Info("QQ bot notification recipient bound", "method", "qr") + // 绑定会话随即结束,上线使用独立的超时。 + s.startQQBotWork(func() { + ctx, cancel := context.WithTimeout(s.qqBotWorkContext(), 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, gateway, err := connectQQBotGateway(ctx, env, appID, secret) + 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 + } + } +} + +// ---- 绑定码 ---- + +// 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")) + 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, gateway, err := connectQQBotGateway(ctx, env, appID, secret) + 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 + } + } + } +} + +// errQQBotTokenRejected 表示令牌在缓存期内被平台提前作废。 +var errQQBotTokenRejected = errors.New("QQ bot access token was rejected") + +// connectQQBotGateway 取得令牌和网关地址;网关拒绝令牌时丢弃缓存并用新令牌重试一次, +// 与发送消息时的处理一致。 +func connectQQBotGateway(ctx context.Context, env qqBotBindEnv, appID, secret string) (string, string, error) { + for attempt := 0; ; attempt++ { + token, err := qqBotTokens.get(ctx, env.client, env.endpoints.TokenURL, appID, secret) + if err != nil { + return "", "", err + } + gateway, err := fetchQQBotGateway(ctx, env, token) + if errors.Is(err, errQQBotTokenRejected) && attempt == 0 { + qqBotTokens.invalidate(env.endpoints.TokenURL, appID, token) + continue + } + return token, gateway, err + } +} + +// 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 { + 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)) + decodeErr := json.Unmarshal(body, &reply) + if response.StatusCode == http.StatusUnauthorized { + return "", fmt.Errorf("%w: %w: QQ bot gateway lookup HTTP 401%s", + errProviderRejected, errQQBotTokenRejected, reply.describe()) + } + if decodeErr != 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 +} + +// completeQQBotCodeBind 保存发送绑定码的用户为接收方,并被动回复确认消息。 +func (s *Server) completeQQBotCodeBind( + ctx context.Context, + session *qqBotBindSession, + config map[string]any, + env qqBotBindEnv, + appID, secret string, + event qqBotBindEvent, +) { + committed, err := s.commitQQBotBind(ctx, session, config, event.OpenID) + if err != nil { + s.logger.Error("save QQ bot binding", "error", err) + s.failQQBotBind(session, "the binding could not be saved") + return + } + if !committed { + return + } + 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 把凭据和接收方一起写入已保存的配置并启用通道,保留其他字段(如设备范围)。 +func (s *Server) saveQQBotBinding(ctx context.Context, config map[string]any, openID string) error { + stored := map[string]any{} + current, err := s.store.NotificationSetting(ctx, "qqbot") + switch { + case err == nil: + 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 + } + } + stored["target_openid"] = openID + encoded, err := json.Marshal(stored) + if err != nil { + return err + } + return s.store.UpsertNotificationSetting(ctx, store.NotificationSetting{ + Channel: "qqbot", + Enabled: true, + Config: encoded, + SensitiveFields: store.DefaultNotificationSensitiveFields("qqbot"), + }) +} + +// 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 { + 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(event.OpenID)) + } + return nil +} diff --git a/internal/server/qqbot_bind_test.go b/internal/server/qqbot_bind_test.go new file mode 100644 index 00000000..1ae4a0fe --- /dev/null +++ b/internal/server/qqbot_bind_test.go @@ -0,0 +1,733 @@ +package server + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "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"}`)}) + // 后台读取心跳,避免阻塞客户端写入;连接关闭时通知下面的循环退出。 + closed := make(chan struct{}) + go func() { + defer close(closed) + for { + if _, _, err := conn.Read(ctx); err != nil { + return + } + } + }() + for { + select { + case <-ctx.Done(): + return + case <-closed: + 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) + } + // 先停止并等待后台协程退出,再由 newQQBotFakeGateway 的 cleanup 恢复全局变量。 + t.Cleanup(api.server.StopQQBotBinding) + data, _ := decodeSettingsResponse(t, response)["data"].(map[string]any) + return data +} + +var qqBotBindCodePattern = regexp.MustCompile(`^VOCAT-[2-9A-HJKMNP-Z]{5}-[2-9A-HJKMNP-Z]{5}$`) + +func checkQQBotBindCode(t *testing.T, data map[string]any) string { + t.Helper() + code, _ := data["code"].(string) + if data["mode"] != "code" || !qqBotBindCodePattern.MatchString(code) { + 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) + // 已有另一个机器人的绑定:拿到新接收方之前必须原样保留。 + if err := api.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{ + Channel: "qqbot", Enabled: true, + Config: json.RawMessage(`{"app_id":"999","app_secret":"old-secret","target_openid":"OLD_OPENID"}`), + SensitiveFields: store.DefaultNotificationSensitiveFields("qqbot"), + }); err != nil { + t.Fatal(err) + } + 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_id"] != "999" || + config["app_secret"] != "old-secret" || config["target_openid"] != "OLD_OPENID" { + 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["app_id"] != qqBotTestAppID || + config["app_secret"] != qqBotTestSecret || 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) + } +} + +func TestQQBotBindRefreshesTokenRejectedByGateway(t *testing.T) { + newQQBotFakeGateway(t) + // 缓存中的令牌已被平台提前作废:网关查询返回 401 后应换新令牌重试。 + qqBotTokens.entries[qqBotTokenCacheKeyForTest(t)] = qqBotToken{ + value: "stale-token", expires: time.Now().Add(time.Hour), secret: qqBotTestSecret, + } + api := newSettingsAPITest(t) + startQQBotBind(t, api, qqBotBindDraft) + waitQQBotBind(t, api, qqBotBindWaiting) +} + +func qqBotTokenCacheKeyForTest(t *testing.T) string { + t.Helper() + env, err := qqBotBindEnvironment(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + 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() {}} + committed, err := api.server.commitQQBotBind(context.Background(), session, qqBotTestConfig(nil), "USER_OPENID") + if err != nil || committed { + t.Fatalf("committed=%v err=%v", committed, err) + } + if _, err := api.database.NotificationSetting(context.Background(), "qqbot"); err == nil { + t.Fatal("canceled binding was saved") + } +} + +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) + startQQBotBind(t, api, qqBotBindDraft) + 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.StatusServiceUnavailable { + t.Fatalf("start after stop: %d %s", response.Code, response.Body) + } +} + +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 new file mode 100644 index 00000000..fb6eb928 --- /dev/null +++ b/internal/server/qqbot_notification.go @@ -0,0 +1,356 @@ +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}$`) +) + +// 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") + } + 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} + +// errQQBotSecretRequired:AppSecret 回传掩码会沿用已保存的值,它属于原来的 AppID。 +var errQQBotSecretRequired = errors.New("qqbot.app_secret must be re-entered when qqbot.app_id changes") + +// checkQQBotSecretReentry 在草稿修改了 AppID 却没有填写新 AppSecret 时返回 errQQBotSecretRequired, +// 避免把新 AppID 与旧机器人的 AppSecret 组合在一起。 +func (s *Server) checkQQBotSecretReentry(ctx context.Context, draft json.RawMessage) error { + var config map[string]any + if err := json.Unmarshal(draft, &config); err != nil { + return err + } + if secret := strings.TrimSpace(configString(config, "app_secret")); secret != "" && secret != store.SecretMask { + return nil + } + appID := strings.TrimSpace(configString(config, "app_id")) + if appID == "" { + return nil + } + current, err := s.store.NotificationSetting(ctx, "qqbot") + if errors.Is(err, store.ErrNotFound) { + return nil + } + if err != nil { + return err + } + var stored map[string]any + if err := json.Unmarshal(current.Config, &stored); err != nil { + return fmt.Errorf("decode stored QQ bot config: %w", err) + } + if storedID := strings.TrimSpace(configString(stored, "app_id")); storedID != "" && storedID != appID { + return errQQBotSecretRequired + } + return nil +} + +// qqBotSecretReentered 执行 checkQQBotSecretReentry 并写出错误响应;返回是否可以继续。 +func (s *Server) qqBotSecretReentered(w http.ResponseWriter, r *http.Request, draft json.RawMessage) bool { + err := s.checkQQBotSecretReentry(r.Context(), draft) + switch { + case err == nil: + return true + case errors.Is(err, errQQBotSecretRequired): + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + default: + s.writeStoreError(w, err) + } + return false +} + +// 默认调用真实开放平台;测试中可替换为本地接收端,不访问真实收件人。 +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 + } + 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 + } + 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) +} + +// postQQBotNotification 用单聊消息接口把通知发给接收方;令牌被拒绝时换新令牌重试一次。 +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(configString(config, "target_openid"))) + } + return nil + } +} + +// qqBotContent 把标题和正文合并为纯文本消息,并限制长度。 +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"` +} + +// qqBotOpaqueValue 匹配平台说明中可能出现的 openid、令牌等长标识。 +var qqBotOpaqueValue = regexp.MustCompile(`[A-Za-z0-9_-]{20,}`) + +// describe 只返回平台的错误码和说明。hidden 中的值(如收件 openid,可能较短)以及 +// 说明中的长标识(openid、令牌等)会被隐去。 +func (reply qqBotReply) describe(hidden ...string) string { + if reply.Code == 0 && reply.Message == "" { + return "" + } + 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] + } + return fmt.Sprintf(" (code %d: %s)", reply.Code, string(message)) +} + +// postQQBotMessage 发送一次消息请求,返回 HTTP 状态和平台的错误码说明。 +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, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return 0, qqBotReply{}, fmt.Errorf("QQ bot response could not be read: %w", err) + } + // 错误响应不一定是 JSON,只在成功状态下要求响应可解析,避免把异常响应当作已送达。 + if err := json.Unmarshal(body, &reply); err != nil && response.StatusCode >= 200 && response.StatusCode < 300 { + return 0, qqBotReply{}, fmt.Errorf("%w: QQ bot HTTP %d returned an unreadable response", errProviderRejected, response.StatusCode) + } + 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} + +// get 返回缓存中未过期的令牌,否则向开放平台获取新令牌。 +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 +} + +// invalidate 丢弃被平台拒绝的令牌,下次发送时重新获取。 +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) + } +} + +// fetchQQBotToken 用 AppID 和 AppSecret 换取访问令牌及其有效期。 +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..c7aa620c --- /dev/null +++ b/internal/server/qqbot_notification_test.go @@ -0,0 +1,440 @@ +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 // 非零时消息接口返回业务错误 + messageBody string // 非空时消息接口以 HTTP 200 返回该内容 + 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.messageBody != "" { + _, _ = w.Write([]byte(platform.messageBody)) + 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) + } +} + +func TestQQBotTreatsHTTP200BusinessErrorsAsRejections(t *testing.T) { + platform := newQQBotFakePlatform(t) + platform.messageBody = `{"code":22009,"message":"msg limit exceed"}` + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); !errors.Is(err, errProviderRejected) || !strings.Contains(err.Error(), "22009") { + t.Fatalf("HTTP 200 business error: %v", err) + } + // 成功状态下无法解析的响应不能当作已送达。 + platform.messageBody = `gateway` + if err := platform.post(t, qqBotTestConfig(nil), "a", "b"); !errors.Is(err, errProviderRejected) { + t.Fatalf("unreadable success response: %v", err) + } +} + +func TestQQBotReplyDescriptionHidesIdentifiers(t *testing.T) { + description := qqBotReply{Code: 40034, Message: "user OPENID_ABCDEFGHIJKLMNOPQRSTUV0123 rejected token abcdefghijklmnopqrstuvwxyz"}.describe() + if strings.Contains(description, "OPENID_ABCDEFGHIJKLMNOPQRSTUV0123") || strings.Contains(description, "abcdefghijklmnopqrstuvwxyz") || + !strings.Contains(description, "40034") || !strings.Contains(description, "rejected token") { + t.Fatalf("description = %q", description) + } +} + +func TestQQBotRequiresSecretWhenAppIDChanges(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_1"}}`); response.Code != http.StatusOK { + t.Fatalf("save: %d %s", response.Code, response.Body) + } + masked := `"app_secret":"` + store.SecretMask + `"` + // 掩码沿用的是原 AppID 的 AppSecret:更换 AppID 时保存、测试和绑定都要求重新填写。 + for _, request := range []struct{ method, path, body string }{ + {http.MethodPut, "/api/settings/notifications", `{"qqbot":{"enabled":true,"app_id":"102000002",` + masked + `,"target_openid":"OPENID_1"}}`}, + {http.MethodPut, "/api/settings/notifications", `{"qqbot":{"enabled":true,"app_id":"102000002","app_secret":"","target_openid":"OPENID_1"}}`}, + {http.MethodPost, "/api/settings/notifications/qqbot/test", `{"app_id":"102000002",` + masked + `,"target_openid":"OPENID_1"}`}, + {http.MethodPost, "/api/settings/notifications/qqbot/bind", `{"mode":"code","app_id":"102000002",` + masked + `}`}, + } { + response := api.request(t, request.method, request.path, request.body) + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "re-entered") { + t.Errorf("%s %s: %d %s", request.method, request.path, response.Code, response.Body) + } + } + // AppID 不变时掩码照常沿用;填写新 AppSecret 时可以更换 AppID。 + for _, body := range []string{ + `{"qqbot":{"enabled":true,"app_id":"102000001",` + masked + `,"target_openid":"OPENID_2"}}`, + `{"qqbot":{"enabled":true,"app_id":"102000002","app_secret":"other-secret","target_openid":"OPENID_2"}}`, + } { + if response := api.request(t, http.MethodPut, "/api/settings/notifications", body); response.Code != http.StatusOK { + t.Errorf("save %s: %d %s", body, response.Code, response.Body) + } + } +} + +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 e2201aed..05a09318 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -60,45 +60,54 @@ 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 + // qqBotBindWork 跟踪绑定期间启动的后台协程;StopQQBotBinding 据此等待其退出。 + qqBotBindWork sync.WaitGroup + // 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/internal/server/settings_api.go b/internal/server/settings_api.go index 69e4fe17..c19b2628 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 } @@ -173,6 +183,9 @@ func (s *Server) handleNotificationSettings(w http.ResponseWriter, r *http.Reque writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) return } + if channel == "qqbot" && !clearSecrets && !s.qqBotSecretReentered(w, r, config) { + return + } clearFields := []string(nil) if clearSecrets { clearFields = store.DefaultNotificationSensitiveFields(channel) @@ -472,7 +485,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, @@ -491,6 +504,9 @@ func (s *Server) handleNotificationTest( writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) return } + if channel == "qqbot" && !s.qqBotSecretReentered(w, r, incoming) { + return + } resolved, provider, err := s.resolveNotificationTestConfig( r.Context(), channel, @@ -530,6 +546,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 +691,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..e6f5e374 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,244 @@ export function MeowTab({ value, onChange, testing, onTest }: PushChannelProps ); } + +/** QQ 机器人渠道:未绑定时显示扫码和手动两种绑定流程,绑定完成后显示可编辑的维护面板。 */ +export function QQBotTab({ + value, + onChange, + testing, + onTest, + onClear, + bind, + binding, + syncing, + onBind, + onCancelBind, +}: PushChannelProps & { + onClear: () => void; + bind: QQBotBindState | null; + binding: boolean; + /** 正在从服务器同步绑定结果:期间锁定凭据和接收方,避免编辑被同步结果覆盖。 */ + syncing: 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; + setFellBack(false); + } + }, [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 })} disabled={syncing} inputMode="numeric" /> + + + onChange({ appSecret: e.target.value })} disabled={syncing} type="password" /> + +
+ + onChange({ targetOpenid: e.target.value })} + disabled={syncing} + 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")} + + + )} +
+ ) : ( +
+ {/* 扫码回退时凭据来自扫码结果,只保存在本次绑定中,不显示手动填写的步骤。 */} + {fellBack && active ? null : ( + <> +
+ 1 + {t("填写机器人凭据")} +
+
+ + onChange({ appId: e.target.value })} disabled={active || syncing} inputMode="numeric" /> + + + onChange({ appSecret: e.target.value })} disabled={active || syncing} type="password" /> + +
+ +
+ 2 + {t("绑定接收方")} +
+ + )} + {active && bind?.mode === "code" ? ( +
+ {fellBack ? ( +
+ {t("扫码未返回接收方,请在 QQ 中私聊刚才选择的机器人,发送下方绑定码。完成后会一起保存机器人凭据。")} +
+ ) : 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..cb58a29b 100644 --- a/web/src/components/settings/controls.tsx +++ b/web/src/components/settings/controls.tsx @@ -153,20 +153,23 @@ export function ChannelHeader({ onToggle, actions, onClear, + clearLabel, }: { title: ReactNode; enabled: boolean; onToggle: (enabled: boolean) => void; actions?: ReactNode; onClear?: () => void; + clearLabel?: ReactNode; }) { return ( -
-
+ // 窄屏下标题和操作按钮可以换行,避免按钮被挤出卡片。 +
+
{title}
-
- {onClear ? : null} +
+ {onClear ? : null} {actions}
@@ -188,10 +191,12 @@ export function SegmentedTabs({ tabs, value, onChange, + disabled, }: { tabs: { key: string; label: ReactNode }[]; value: string; onChange: (key: string) => void; + disabled?: boolean; }) { return (
@@ -201,9 +206,12 @@ export function SegmentedTabs({