diff --git a/backend/docs/im-clawbot-design.md b/backend/docs/im-clawbot-design.md new file mode 100644 index 0000000..ac7dd5f --- /dev/null +++ b/backend/docs/im-clawbot-design.md @@ -0,0 +1,121 @@ +# IM Provider 与微信 ClawBot 接入设计 + +## 目标 + +ChatAPI 将 IM 作为可替换的操作员通道。首个 Provider 是腾讯微信 ClawBot(iLink HTTP/JSON API): + +1. ChatAPI 收到 `turn.waiting`。 +2. 已绑定用户的微信收到请求摘要。 +3. 扫码者本人发送文本,ChatAPI 以该文本完成选中的 pending turn。 + +该通道不是新的模型请求入口;微信联系人不会因此创建 ChatAPI turn。 + +协议依据: + +- [微信开放文档:ClawBot 相关接口](https://developers.weixin.qq.com/doc/aispeech/knowledge/openapi/Clawbotrelated.html) +- [Tencent/openclaw-weixin](https://github.com/Tencent/openclaw-weixin) + +## 首版范围 + +| 微信输入 | 行为 | +| --- | --- | +| 普通文本 | `stream_complete` 当前请求 | +| `/list` | 从 `PendingRegistry.ListByOwnerID` 读取实时列表 | +| `/use <编号>` | 按 conversation/request ID 前缀选择请求 | +| `/abort [原因]` | 中止当前请求 | +| `/bind` | 刷新 context token 并返回绑定说明 | +| `/help` | 返回帮助 | + +首版不支持流式 delta、思考、工具调用、媒体或群聊。原因是 iLink cursor checkpoint 与非幂等 delta 无法在现有存储边界内实现原子提交;普通完成和中止会再次由 pending request identity 校验,重放不会重复完成 turn。 + +## Provider 契约 + +`internal/service/im.Provider` 负责: + +- 登录挑战:`StartLogin` / `PollLogin`(仅向 iLink 提交当前 owner 已保存的 local token,不跨用户汇总) +- 账号长轮询:`Run` +- 出站文本:`Send` +- Provider 状态判断:`Ready`;`ReadinessVersion` 只在获得新回复上下文时改变,使 Coordinator 区分 cursor checkpoint 与真实 readiness 恢复 + +Coordinator 不解析 Provider 私有 credentials/state。Provider 通过 checkpoint 回调提交 opaque state;Coordinator 负责加密、账号 generation、worker 生命周期、owner 鉴权、pending 选择和 turn control。 + +后续 Provider 可以实现相同契约,而无需进入 `chat/turn` 或复制 workspace handler。 + +## 账号与秘密 + +每个 ChatAPI 用户最多保存一个 `im.account.clawbot` user config。公开 envelope 只包含:Provider、外部 bot/user ID、受信 endpoint、连接时间。以下 JSON 合并后由 `secretbox` 使用 `CHATAPI_MASTER_KEY` 加密: + +- `bot_token` +- `context_token` +- `get_updates_buf` +- processed message ID window + +HTTP 状态、日志和前端响应均不返回 ciphertext 或明文秘密。删除连接会先 invalidate generation、取消 worker、等待 in-flight callback barrier,再删除 config;旧 checkpoint 不能把账号写回。 + +## 微信身份边界 + +Provider 只接受同时满足下列条件的消息: + +- `message_type == USER` +- `message_state == FINISH` +- `group_id` 为空 +- `from_user_id ==` 扫码确认返回的 `ilink_user_id` +- `to_user_id` 为空(部分响应省略该字段)或等于 `ilink_bot_id` +- 恰好一个完整文本 item +- message ID 未在去重窗口中 + +Coordinator 在每个控制命令前重新读取 ChatAPI user,停用或删除的 owner 不能控制 turn。账号服务的停用/删除成功路径还会同步调用 `RevokeOwner`,取消 login/runtime、等待 callback barrier 并删除 IM config。`chat/control.Execute` 仍校验 owner、conversation、response 与 request identity。 + +## Cursor 与重复消息 + +Provider 先处理 batch,再 checkpoint `get_updates_buf`、最新 context token 和最近 128 个 message ID。崩溃可能重放已经执行但尚未 checkpoint 的消息,因此首版只开放终态操作和只读/幂等命令: + +- 已完成/中止的 request 会从 PendingRegistry 消失,重放无法再次控制。 +- `/list`、`/use`、`/bind`、`/help` 重放最多产生重复说明。 +- 出站 waiting notification 的 `client_id` 由 request ID 稳定派生,有限重试不会产生不同消息身份。 + +## 通知与并发 + +`HandleChatEvent` 不执行网络请求。它只把每个 owner 的最新 waiting snapshot 写入 dirty map,并向容量为 1 的 wake channel 发信号。两个 worker 从 dirty map 取不同 owner;同 owner 在途时继续合并新 snapshot,完成后重新唤醒。 + +发送前再次确认 PendingRegistry 中存在同一个 conversation/request。waiting event 入队时不会改变当前选择;只有通知成功送达且 request 仍 pending,才在同一 runtime barrier 内把该 conversation 设为普通回复目标,避免用户回复上一条可见通知却误结束尚未送达的新请求。账号尚未收到 `/bind` 时保留最新 waiting snapshot;首次 context checkpoint 后重新排队。 + +每个 account runtime 带单调 generation 和 callback barrier: + +1. disconnect/replace 先增加 generation 并从 active map 移除 runtime; +2. cancel 长轮询; +3. 等待正在执行的 inbound/checkpoint/send;checkpoint 持有 barrier 完成存储写入,旧写入只能发生在删除之前; +4. 删除或替换持久化账号; +5. 旧 callback 因 generation 不匹配返回,不会发送、控制或持久化。 + +## 网络安全 + +- QR 入口固定为 `https://ilinkai.weixin.qq.com`;生产 client 使用 `urlsafety.SafeDialer` 在连接时重新解析并拒绝私网、回环、链路本地和混合 DNS 结果,且不使用代理。 +- 动态 `baseurl`/`redirect_host` 必须为 HTTPS、默认端口、无 userinfo/query/fragment/path,且 hostname 等于 `weixin.qq.com` 或以 `.weixin.qq.com` 结尾。 +- `evilweixin.qq.com` 不满足点边界。 +- HTTP 响应上限 1 MiB;二维码 token/URL、context/cursor、入站/出站文本均有限制。 +- 长轮询上限 40 秒,普通请求 15 秒,start/stop notify 5 秒。 +- `ret/errcode == -14` 标记 `reauth_required`,不无限重试旧 token;`sendmessage -2` 在仍持有该 runtime barrier 时记录本次发送使用的 context generation 为失效,避免随后到达的新 context 被旧失败覆盖。Provider 为每个有效本人入站 context 递增持久化 generation;cursor-only checkpoint 不改变 generation,只有新的 context generation 才恢复 Ready 并重排一次最新 waiting 通知。 +- 敏感请求不跟随 HTTP 3xx;iLink 的合法 endpoint 切换只接受 JSON `redirect_host` 并重新执行白名单校验。 + +## 用户 API + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| GET | `/api/user/im/clawbot` | 安全状态 | +| POST | `/api/user/im/clawbot/login` | 创建二维码 | +| POST | `/api/user/im/clawbot/login/{session_id}/poll` | owner-scoped 状态查询/验证码 | +| DELETE | `/api/user/im/clawbot` | 断开并删除连接 | + +路由沿用 session authentication、principal access 和现有 mutation CSRF 约束。login session 仅保存在内存,绑定 owner,5 分钟过期,同一 session 只允许一个在途 poll。 + +## 恢复与限制 + +服务启动时枚举 active users 并恢复可解密的 IM account。ChatAPI 的 pending turn 不跨进程恢复为 waiting;旧微信回复因此只会得到“当前没有等待中的请求”。账号连接恢复不意味着旧 turn 恢复。 + +当前限制: + +- 每用户一个 ClawBot; +- 仅扫码者本人私聊文本; +- 无跨节点 worker lease;多副本部署必须保证同一账号只由一个 ChatAPI 实例运行; +- 不保证 iLink 服务端对 client ID 的去重行为,ChatAPI 自身仍以 pending identity 防止重复终态控制。 diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index f6940c7..0c0e28b 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -129,6 +129,13 @@ func New(ctx context.Context, options Options) (_ *App, err error) { func(ctx context.Context) { expirePendingLoop(ctx, app.services.Turn, app.services.ChatSettings, app.logger) }, + func(ctx context.Context) { + if app.services.IM != nil { + if err := app.services.IM.Run(ctx); err != nil && ctx.Err() == nil { + app.logger.Warn("IM service stopped", zap.Error(err)) + } + } + }, func(ctx context.Context) { storageVacuumLoop(ctx, app.Config, app.store, app.services.Audit, app.logger) }, @@ -237,6 +244,7 @@ func (a *App) Close() { }) } +// pi-lens-ignore: go-bare-error func ModeFromArgs(args []string) (config.Mode, error) { if len(args) == 0 || strings.TrimSpace(args[0]) == "serve" { return config.ModeServe, nil @@ -247,10 +255,11 @@ func ModeFromArgs(args []string) (config.Mode, error) { return "", fmt.Errorf("unknown mode %q (expected serve or lab)", args[0]) } +// pi-lens-ignore: go-bare-error func DetectBackendRoot() (string, error) { wd, err := os.Getwd() if err != nil { - return "", err + return "", fmt.Errorf("get working directory: %w", err) } return DetectBackendRootFrom(wd), nil } diff --git a/backend/internal/bootstrap/http.go b/backend/internal/bootstrap/http.go index 14d5ac4..933120a 100644 --- a/backend/internal/bootstrap/http.go +++ b/backend/internal/bootstrap/http.go @@ -57,6 +57,8 @@ import ( turnquerysvc "github.com/zyf2007/ChatAPI/internal/service/chat/turnquery" workspacesvc "github.com/zyf2007/ChatAPI/internal/service/chat/workspace" workspacesettings "github.com/zyf2007/ChatAPI/internal/service/chat/workspace/settings" + imsvc "github.com/zyf2007/ChatAPI/internal/service/im" + "github.com/zyf2007/ChatAPI/internal/service/im/clawbot" ntfynotify "github.com/zyf2007/ChatAPI/internal/service/notification/ntfy" "github.com/zyf2007/ChatAPI/internal/service/usercontrol" "github.com/zyf2007/ChatAPI/internal/service/usercontrol/conversationretention" @@ -66,6 +68,7 @@ type Services struct { Turn *turnsvc.Service ChatSettings *chatsettings.Service Audit *auditsvc.Service + IM *imsvc.Service } type applicationInput struct { @@ -116,6 +119,7 @@ type chatModule struct { workspaceHub *workspacesvc.Hub events *chatevents.Dispatcher automation *automationsvc.Service + im *imsvc.Service notifications *ntfynotify.Service outputUploader httphandler.OutputImageUploader } @@ -132,6 +136,7 @@ func assembleApplication(ctx context.Context, input applicationInput) (applicati return applicationResult{}, err } chat := buildChatModule(input, auth) + auth.accounts.SetOwnerRevoker(chat.im.RevokeOwner) admin, err := buildAdminModule(input, auth, chat) if err != nil { _ = chat.notifications.Close() @@ -139,7 +144,7 @@ func assembleApplication(ctx context.Context, input applicationInput) (applicati } return applicationResult{ router: buildRouter(input, auth, chat, admin), - services: Services{Turn: chat.turn, ChatSettings: chat.settings, Audit: auth.audit}, + services: Services{Turn: chat.turn, ChatSettings: chat.settings, Audit: auth.audit, IM: chat.im}, notifications: chat.notifications, }, nil } @@ -199,9 +204,11 @@ func buildChatModule(input applicationInput, auth authModule) chatModule { automationSettings := automationsettings.New(store) automationEvents := automationsvc.NewDispatcher(workspacesvc.NewAutomationRealtimePublisher(hub)) automation := automationsvc.New(automationsvc.Deps{Rules: store, ModelKeys: store, Control: control, Pending: pending, Events: automationEvents, Logger: logger(logging.LayerTurn), Settings: automationSettings}) + imService := imsvc.NewService(store, pending, control, cfg.MasterKey, logger(logging.LayerIM), clawbot.NewProvider(nil)) workspace.SetAutomation(automation) control.Subscribe(automation) events.Subscribe(automation) + events.Subscribe(imService) mediaSettings := preprocesssettings.New(store, cfg) mediaStore := localstore.Store{RootDir: cfg.MediaDerivedDir} outputImages := outputassetsvc.New(cfg, store, mediaStore, input.mediaProcessor) @@ -217,7 +224,7 @@ func buildChatModule(input applicationInput, auth authModule) chatModule { turn: turn, query: query, control: control, timeline: timeline, ingress: ingresssvc.New(turn), streaming: streamingsvc.New(), egress: egress, catalog: catalogsvc.New(auth.modelKeys), settings: settings, mediaSettings: mediaSettings, realtimeSettings: realtimeSettings, automationSettings: automationSettings, workspaceHub: hub, - events: events, automation: automation, notifications: notifications, outputUploader: turn, + events: events, automation: automation, im: imService, notifications: notifications, outputUploader: turn, } } @@ -265,6 +272,7 @@ func buildRouter(input applicationInput, auth authModule, chat chatModule, admin App: httphandler.AppAPIHandler{Turn: chat.turn, Query: chat.query, Timeline: chat.timeline, Logger: logger(logging.LayerTurnQuery)}, Auth: httphandler.AuthHandler{Config: cfg, LocalAuth: auth.local, Verification: auth.verification, Policy: auth.policy, Settings: auth.settings, GeeTest: auth.geetest, TOTP: auth.totp, OIDC: auth.oidc, Audit: auth.audit, LoginLimiter: auth.loginLimiter, Sessions: auth.sessions, Logger: logger(logging.LayerAuth)}, User: httphandler.UserHandler{Config: cfg, UserControl: admin.user, Timeline: chat.timeline, Logger: logger(logging.LayerAuth)}, + IM: httphandler.IMHandler{Service: chat.im}, Admin: httphandler.AdminHandler{Control: admin.admin, Timeline: chat.timeline, Audit: auth.audit, Logger: logger(logging.LayerAudit), Monitoring: admin.monitoring}, Lab: httphandler.LabHandler{Config: cfg, Query: chat.query, Turn: chat.turn, Control: chat.control, Logger: logger(logging.LayerHTTP)}, Workspace: httphandler.WorkspaceHandler{Hub: chat.workspaceHub, Logger: logger(logging.LayerHTTP)}, diff --git a/backend/internal/http/handler/im.go b/backend/internal/http/handler/im.go new file mode 100644 index 0000000..c9c4cd9 --- /dev/null +++ b/backend/internal/http/handler/im.go @@ -0,0 +1,116 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/zyf2007/ChatAPI/internal/http/httpx" + "github.com/zyf2007/ChatAPI/internal/service/auth/authz/session" + imsvc "github.com/zyf2007/ChatAPI/internal/service/im" +) + +type IMHandler struct { + Service *imsvc.Service +} + +func (h IMHandler) Status(w http.ResponseWriter, r *http.Request) { + ownerID, ok := imOwnerID(r) + if !ok || h.Service == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + status, err := h.Service.GetStatus(r.Context(), ownerID) + if err != nil { + writeIMError(w, err, http.StatusInternalServerError) + return + } + httpx.WriteJSON(w, http.StatusOK, status) +} + +func (h IMHandler) StartLogin(w http.ResponseWriter, r *http.Request) { + ownerID, ok := imOwnerID(r) + if !ok || h.Service == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + view, err := h.Service.BeginLogin(r.Context(), ownerID, imsvc.ProviderClawBot) + if err != nil { + writeIMError(w, err, http.StatusBadGateway) + return + } + httpx.WriteJSON(w, http.StatusOK, view) +} + +func (h IMHandler) PollLogin(w http.ResponseWriter, r *http.Request) { + ownerID, ok := imOwnerID(r) + if !ok || h.Service == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var input struct { + VerifyCode string `json:"verify_code"` + } + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + sessionID := strings.TrimSpace(chi.URLParam(r, "session_id")) + if sessionID == "" { + http.Error(w, "login session is required", http.StatusBadRequest) + return + } + view, err := h.Service.PollLogin(r.Context(), ownerID, sessionID, input.VerifyCode) + if err != nil { + writeIMError(w, err, http.StatusBadGateway) + return + } + httpx.WriteJSON(w, http.StatusOK, view) +} + +func (h IMHandler) Disconnect(w http.ResponseWriter, r *http.Request) { + ownerID, ok := imOwnerID(r) + if !ok || h.Service == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if err := h.Service.Disconnect(r.Context(), ownerID); err != nil { + writeIMError(w, err, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func imOwnerID(r *http.Request) (string, bool) { + principal, ok := session.PrincipalFromContext(r.Context()) + ownerID := strings.TrimSpace(principal.UserID) + return ownerID, ok && ownerID != "" +} + +func writeIMError(w http.ResponseWriter, err error, fallback int) { + status := fallback + message := "微信 ClawBot 请求失败,请稍后重试" + switch { + case errors.Is(err, imsvc.ErrOwnerInactive): + status = http.StatusForbidden + message = "当前用户已停用" + case errors.Is(err, imsvc.ErrLoginNotFound), errors.Is(err, imsvc.ErrConnectionNotFound): + status = http.StatusNotFound + message = "微信登录会话或连接不存在,请重新开始" + case errors.Is(err, imsvc.ErrLoginBusy): + status = http.StatusConflict + message = "正在查询二维码状态,请稍后重试" + case errors.Is(err, context.DeadlineExceeded): + status = http.StatusGatewayTimeout + message = "微信服务响应超时,请稍后重试" + } + httpx.WriteJSON(w, status, map[string]any{"error": message}) +} diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index e315eec..b110009 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -32,6 +32,7 @@ type Deps struct { App httphandler.AppAPIHandler Auth httphandler.AuthHandler User httphandler.UserHandler + IM httphandler.IMHandler Admin httphandler.AdminHandler Lab httphandler.LabHandler Workspace httphandler.WorkspaceHandler @@ -157,6 +158,10 @@ func New(deps Deps) http.Handler { router.With(userAuth, userPrincipalAccess).Get("/api/user/identities", userHandler.ListIdentities) router.With(userAuth, userPrincipalAccess).Get("/api/user/config", userHandler.GetConfig) router.With(userAuth, userPrincipalAccess).Post("/api/user/config", userHandler.SetConfig) + router.With(userAuth, userPrincipalAccess).Get("/api/user/im/clawbot", deps.IM.Status) + router.With(userAuth, userPrincipalAccess).Post("/api/user/im/clawbot/login", deps.IM.StartLogin) + router.With(userAuth, userPrincipalAccess).Post("/api/user/im/clawbot/login/{session_id}/poll", deps.IM.PollLogin) + router.With(userAuth, userPrincipalAccess).Delete("/api/user/im/clawbot", deps.IM.Disconnect) router.With(userAuth, userPrincipalAccess).Post("/api/user/password", userHandler.ChangePassword) router.With(userAuth, userPrincipalAccess).Get("/api/automation/rules", userHandler.ListAutomationRules) router.With(userAuth, userPrincipalAccess).Post("/api/automation/rules", userHandler.SaveAutomationRule) diff --git a/backend/internal/ops/observability/logging/logger.go b/backend/internal/ops/observability/logging/logger.go index a2f23f0..459b6f2 100644 --- a/backend/internal/ops/observability/logging/logger.go +++ b/backend/internal/ops/observability/logging/logger.go @@ -24,6 +24,7 @@ const ( LayerMigrate = "migrate" LayerPlatform = "platform" LayerAudit = "audit" + LayerIM = "im" ) type Config struct { diff --git a/backend/internal/service/account/service.go b/backend/internal/service/account/service.go index 79b704d..d5a0957 100644 --- a/backend/internal/service/account/service.go +++ b/backend/internal/service/account/service.go @@ -3,6 +3,7 @@ package account import ( "context" "errors" + "fmt" "strings" "time" @@ -18,8 +19,9 @@ var ( ) type Service struct { - store auth.Store - now func() time.Time + store auth.Store + now func() time.Time + revokeOwner func(context.Context, string) error } type CreateUserInput struct { @@ -60,6 +62,12 @@ func NewService(dataStore auth.Store) *Service { } } +func (s *Service) SetOwnerRevoker(revoke func(context.Context, string) error) { + if s != nil { + s.revokeOwner = revoke + } +} + func (s *Service) GetUser(ctx context.Context, userID string) (common.User, error) { return s.store.GetUser(ctx, strings.TrimSpace(userID)) } @@ -142,12 +150,18 @@ func (s *Service) CreateUser(ctx context.Context, input CreateUserInput) (common } func (s *Service) UpdateUser(ctx context.Context, input UpdateUserInput) (common.User, error) { + userID := strings.TrimSpace(input.ID) passwordHash, err := resolvePasswordHash(strings.TrimSpace(input.Password), strings.TrimSpace(input.PasswordHash)) if err != nil { return common.User{}, err } + if !input.IsActive && s.revokeOwner != nil { + if err := s.revokeOwner(ctx, userID); err != nil { + return common.User{}, err + } + } return s.store.UpdateUser(ctx, common.UpdateUserInput{ - ID: strings.TrimSpace(input.ID), + ID: userID, Username: strings.TrimSpace(input.Username), Email: normalizeEmail(input.Email), PasswordHash: passwordHash, @@ -197,7 +211,13 @@ func (s *Service) PreviewDeletion(ctx context.Context, userID string) (common.Us } func (s *Service) DeleteUser(ctx context.Context, userID string) error { - return s.store.DeleteUserAccount(ctx, strings.TrimSpace(userID)) + userID = strings.TrimSpace(userID) + if s.revokeOwner != nil { + if err := s.revokeOwner(ctx, userID); err != nil { + return err + } + } + return s.store.DeleteUserAccount(ctx, userID) } func (s *Service) TransferOwnership(ctx context.Context, sourceUserID string, targetUserID string) (common.UserOwnershipTransferResult, error) { @@ -248,5 +268,9 @@ func resolvePasswordHash(passwordText string, existingHash string) (string, erro if passwordText == "" { return existingHash, nil } - return password.Hash(passwordText) + hash, err := password.Hash(passwordText) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return hash, nil } diff --git a/backend/internal/service/account/service_test.go b/backend/internal/service/account/service_test.go new file mode 100644 index 0000000..5365c80 --- /dev/null +++ b/backend/internal/service/account/service_test.go @@ -0,0 +1,74 @@ +package account + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/zyf2007/ChatAPI/internal/repository/auth" + "github.com/zyf2007/ChatAPI/internal/repository/common" +) + +func TestInactiveUpdateAndDeleteRevokeIMOwnerFirst(t *testing.T) { + t.Parallel() + t.Run("inactive update", func(t *testing.T) { + store := &revocationStore{} + service := NewService(store) + service.SetOwnerRevoker(func(_ context.Context, ownerID string) error { + store.events = append(store.events, "revoke:"+ownerID) + return nil + }) + _, err := service.UpdateUser(context.Background(), UpdateUserInput{ID: "owner-1", PasswordHash: "hash", IsActive: false}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(store.events, []string{"revoke:owner-1", "update:owner-1"}) { + t.Fatalf("events = %#v", store.events) + } + }) + t.Run("delete", func(t *testing.T) { + store := &revocationStore{} + service := NewService(store) + service.SetOwnerRevoker(func(_ context.Context, ownerID string) error { + store.events = append(store.events, "revoke:"+ownerID) + return nil + }) + if err := service.DeleteUser(context.Background(), "owner-1"); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(store.events, []string{"revoke:owner-1", "delete:owner-1"}) { + t.Fatalf("events = %#v", store.events) + } + }) +} + +func TestRevocationFailureBlocksInactiveUpdate(t *testing.T) { + t.Parallel() + store := &revocationStore{} + service := NewService(store) + want := errors.New("revoke failed") + service.SetOwnerRevoker(func(context.Context, string) error { return want }) + _, err := service.UpdateUser(context.Background(), UpdateUserInput{ID: "owner-1", PasswordHash: "hash", IsActive: false}) + if !errors.Is(err, want) { + t.Fatalf("err = %v", err) + } + if len(store.events) != 0 { + t.Fatalf("store mutated after failed revoke: %#v", store.events) + } +} + +type revocationStore struct { + auth.Store + events []string +} + +func (s *revocationStore) UpdateUser(_ context.Context, input common.UpdateUserInput) (common.User, error) { + s.events = append(s.events, "update:"+input.ID) + return common.User{ID: input.ID, IsActive: input.IsActive}, nil +} + +func (s *revocationStore) DeleteUserAccount(_ context.Context, userID string) error { + s.events = append(s.events, "delete:"+userID) + return nil +} diff --git a/backend/internal/service/automation/recorder.go b/backend/internal/service/automation/recorder.go index 31d1797..e55a2ff 100644 --- a/backend/internal/service/automation/recorder.go +++ b/backend/internal/service/automation/recorder.go @@ -190,7 +190,7 @@ func (s *Service) ControlApplied(ctx context.Context, applied controlsvc.Applied if !ActionFromTurn(command.Action).Terminal() && !autoCompleted { s.markManualTakeover(command.ConversationID, command.RequestID) } - recordSource := command.Source == "" || command.Source == controlsvc.SourceAPI || command.Source == controlsvc.SourceWorkspace + recordSource := command.Source == "" || command.Source == controlsvc.SourceAPI || command.Source == controlsvc.SourceWorkspace || command.Source == controlsvc.SourceIM requestID := strings.TrimSpace(command.RequestID) now := time.Now().UTC() s.mu.Lock() diff --git a/backend/internal/service/automation/service_test.go b/backend/internal/service/automation/service_test.go index 2ed4cb9..6bbe01d 100644 --- a/backend/internal/service/automation/service_test.go +++ b/backend/internal/service/automation/service_test.go @@ -292,6 +292,10 @@ func TestRecordingCapturesManualActionsAndPersistsDraft(t *testing.T) { OwnerID: "owner", ConversationID: "conv", RequestID: "req", Source: controlsvc.SourceWorkspace, Action: turnsvc.OutputAction{Kind: turnsvc.TurnControlStreamDelta, OutputText: "hello", Mode: "answer"}, }}) + service.ControlApplied(context.Background(), controlsvc.AppliedCommand{Command: controlsvc.Command{ + OwnerID: "owner", ConversationID: "conv", RequestID: "req", Source: controlsvc.SourceIM, + Action: turnsvc.OutputAction{Kind: turnsvc.TurnControlStreamDelta, OutputText: "wechat", Mode: "answer"}, + }}) service.ControlApplied(context.Background(), controlsvc.AppliedCommand{Command: controlsvc.Command{ OwnerID: "owner", ConversationID: "conv", RequestID: "req", Source: controlsvc.SourceAutomation, Action: turnsvc.OutputAction{Kind: turnsvc.TurnControlStreamDelta, OutputText: "ignored", Mode: "answer"}, @@ -300,10 +304,10 @@ func TestRecordingCapturesManualActionsAndPersistsDraft(t *testing.T) { if err != nil { t.Fatal(err) } - if len(state.Steps) != 1 || state.Steps[0].Action.Text != "hello" { + if len(state.Steps) != 2 || state.Steps[0].Action.Text != "hello" || state.Steps[1].Action.Text != "wechat" { t.Fatalf("unexpected recorded steps: %#v", state.Steps) } - if state.DraftRule == nil || state.DraftRule.Enabled || len(state.DraftRule.Steps) != 1 { + if state.DraftRule == nil || state.DraftRule.Enabled || len(state.DraftRule.Steps) != 2 { t.Fatalf("unexpected draft rule: %#v", state.DraftRule) } snapshot := service.StateSnapshot("owner") diff --git a/backend/internal/service/chat/control/service.go b/backend/internal/service/chat/control/service.go index 0d3b801..7e0df3c 100644 --- a/backend/internal/service/chat/control/service.go +++ b/backend/internal/service/chat/control/service.go @@ -40,6 +40,7 @@ const ( SourceAutomation CommandSource = "automation" SourceAdmin CommandSource = "admin" SourceLab CommandSource = "lab" + SourceIM CommandSource = "im" ) type AppliedCommand struct { diff --git a/backend/internal/service/im/clawbot/client.go b/backend/internal/service/im/clawbot/client.go new file mode 100644 index 0000000..991609a --- /dev/null +++ b/backend/internal/service/im/clawbot/client.go @@ -0,0 +1,425 @@ +package clawbot + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/zyf2007/ChatAPI/internal/platform/urlsafety" +) + +const ( + defaultBaseURL = "https://ilinkai.weixin.qq.com" + defaultBotType = "3" + maxResponseBytes = 1 << 20 + maxQRCodeTokenBytes = 4096 + maxQRCodeURLBytes = 4096 + maxOutboundText = 8000 + regularTimeout = 15 * time.Second + loginPollTimeout = 38 * time.Second + ilinkAppID = "bot" + ilinkChannelVersion = "2.4.6" + ilinkClientVersion = "132102" // 2.4.6 encoded as 0x00MMNNPP. +) + +var ( + ErrStaleToken = errors.New("clawbot token is stale") + ErrContextExpired = errors.New("clawbot reply context is stale") +) + +type APIError struct { + Operation string + Status int + Ret int + ErrCode int + Message string +} + +func (e *APIError) Error() string { + if e == nil { + return "clawbot API error" + } + return fmt.Sprintf("clawbot %s failed (status=%d ret=%d errcode=%d)", e.Operation, e.Status, e.Ret, e.ErrCode) +} + +type Client struct { + httpClient *http.Client + loginBaseURL string + allowTestHTTP bool + allowTestEndpointHost string +} + +func NewClient(client *http.Client) *Client { + if client == nil { + client = urlsafety.NewSafeHTTPClient(45*time.Second, nil) + } + clone := *client + // iLink advertises endpoint changes in signed JSON fields. Never follow HTTP + // redirects with bot credentials; the caller receives the 3xx as an error. + clone.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &Client{httpClient: &clone, loginBaseURL: defaultBaseURL} +} + +type loginChallenge struct { + QRCode string `json:"qrcode"` + Base string `json:"base"` +} + +type qrCodeResponse struct { + QRCode string `json:"qrcode"` + QRCodeURL string `json:"qrcode_img_content"` +} + +type qrStatusResponse struct { + Status string `json:"status"` + BotToken string `json:"bot_token"` + BotID string `json:"ilink_bot_id"` + BaseURL string `json:"baseurl"` + OwnerID string `json:"ilink_user_id"` + RedirectHost string `json:"redirect_host"` +} + +type baseInfo struct { + ChannelVersion string `json:"channel_version"` + BotAgent string `json:"bot_agent"` +} + +type textItem struct { + Text string `json:"text,omitempty"` +} + +type messageItem struct { + Type int `json:"type,omitempty"` + IsCompleted bool `json:"is_completed,omitempty"` + MessageID string `json:"msg_id,omitempty"` + Text *textItem `json:"text_item,omitempty"` +} + +type message struct { + Sequence int64 `json:"seq,omitempty"` + MessageID int64 `json:"message_id,omitempty"` + From string `json:"from_user_id"` + To string `json:"to_user_id,omitempty"` + ClientID string `json:"client_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + GroupID string `json:"group_id,omitempty"` + MessageType int `json:"message_type,omitempty"` + MessageState int `json:"message_state,omitempty"` + Items []messageItem `json:"item_list,omitempty"` + ContextToken string `json:"context_token,omitempty"` +} + +type updatesResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrorMessage string `json:"errmsg"` + Messages []message `json:"msgs"` + Cursor string `json:"get_updates_buf"` + LongPollingTimeoutMsec int `json:"longpolling_timeout_ms"` +} + +type sendResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrorMessage string `json:"errmsg"` +} + +func (c *Client) StartLogin(ctx context.Context, localTokens []string) (loginChallenge, string, error) { + if len(localTokens) > 1 { + return loginChallenge{}, "", errors.New("clawbot login accepts at most one local token") + } + normalizedTokens := make([]string, len(localTokens)) + for index, token := range localTokens { + normalizedTokens[index] = strings.TrimSpace(token) + if normalizedTokens[index] == "" || len(normalizedTokens[index]) > 64*1024 { + return loginChallenge{}, "", errors.New("invalid clawbot local token") + } + } + var response qrCodeResponse + err := c.doJSON(ctx, http.MethodPost, c.loginBaseURL, "/ilink/bot/get_bot_qrcode", url.Values{"bot_type": {defaultBotType}}, "", map[string]any{ + "local_token_list": normalizedTokens, + }, regularTimeout, &response) + if err != nil { + return loginChallenge{}, "", err + } + response.QRCode = strings.TrimSpace(response.QRCode) + response.QRCodeURL = strings.TrimSpace(response.QRCodeURL) + if response.QRCode == "" || len(response.QRCode) > maxQRCodeTokenBytes { + return loginChallenge{}, "", errors.New("clawbot returned an invalid QR token") + } + if err := validatePublicQRCodeURL(response.QRCodeURL); err != nil { + return loginChallenge{}, "", err + } + return loginChallenge{QRCode: response.QRCode, Base: c.loginBaseURL}, response.QRCodeURL, nil +} + +func (c *Client) PollLogin(ctx context.Context, challenge loginChallenge, verifyCode string) (qrStatusResponse, loginChallenge, error) { + if len(challenge.QRCode) == 0 || len(challenge.QRCode) > maxQRCodeTokenBytes { + return qrStatusResponse{}, challenge, errors.New("invalid clawbot login challenge") + } + query := url.Values{"qrcode": {challenge.QRCode}} + verifyCode = strings.TrimSpace(verifyCode) + if verifyCode != "" { + if len(verifyCode) > 12 { + return qrStatusResponse{}, challenge, errors.New("verification code is too long") + } + for _, character := range verifyCode { + if character < '0' || character > '9' { + return qrStatusResponse{}, challenge, errors.New("verification code must contain only digits") + } + } + query.Set("verify_code", verifyCode) + } + var response qrStatusResponse + if err := c.doJSON(ctx, http.MethodGet, challenge.Base, "/ilink/bot/get_qrcode_status", query, "", nil, loginPollTimeout, &response); err != nil { + return qrStatusResponse{}, challenge, err + } + if response.Status == "scaned_but_redirect" { + redirectURL, err := endpointFromRedirectHost(response.RedirectHost, c.allowTestHTTP, c.allowTestEndpointHost) + if err != nil { + return qrStatusResponse{}, challenge, err + } + challenge.Base = redirectURL + } + return response, challenge, nil +} + +func (c *Client) GetUpdates(ctx context.Context, endpoint, token, cursor string, timeout time.Duration) (updatesResponse, error) { + var response updatesResponse + if timeout < 5*time.Second { + timeout = 5 * time.Second + } + if timeout > 40*time.Second { + timeout = 40 * time.Second + } + err := c.doJSON(ctx, http.MethodPost, endpoint, "/ilink/bot/getupdates", nil, token, map[string]any{ + "get_updates_buf": cursor, + "base_info": requestBaseInfo(), + }, timeout, &response) + if err != nil { + return updatesResponse{}, err + } + if response.Ret != 0 || response.ErrCode != 0 { + apiErr := &APIError{Operation: "getupdates", Ret: response.Ret, ErrCode: response.ErrCode, Message: response.ErrorMessage} + if response.ErrCode == -14 || response.Ret == -14 { + return updatesResponse{}, fmt.Errorf("%w: %v", ErrStaleToken, apiErr) + } + return updatesResponse{}, apiErr + } + return response, nil +} + +func (c *Client) SendText(ctx context.Context, endpoint, token string, outgoing outboundText) error { + if !utf8.ValidString(outgoing.Text) || len([]rune(outgoing.Text)) > maxOutboundText { + return errors.New("clawbot outbound text is invalid or too long") + } + if strings.TrimSpace(outgoing.To) == "" || strings.TrimSpace(outgoing.ClientID) == "" { + return errors.New("clawbot outbound recipient and client id are required") + } + request := map[string]any{ + "msg": message{ + To: strings.TrimSpace(outgoing.To), + ClientID: strings.TrimSpace(outgoing.ClientID), + MessageType: 2, + MessageState: 2, + Items: []messageItem{{Type: 1, IsCompleted: true, MessageID: strings.TrimSpace(outgoing.ClientID), Text: &textItem{Text: outgoing.Text}}}, + ContextToken: strings.TrimSpace(outgoing.ContextToken), + }, + "base_info": requestBaseInfo(), + } + var response sendResponse + if err := c.doJSON(ctx, http.MethodPost, endpoint, "/ilink/bot/sendmessage", nil, token, request, regularTimeout, &response); err != nil { + return err + } + if response.Ret != 0 || response.ErrCode != 0 { + apiErr := &APIError{Operation: "sendmessage", Ret: response.Ret, ErrCode: response.ErrCode, Message: response.ErrorMessage} + if response.ErrCode == -14 || response.Ret == -14 { + return fmt.Errorf("%w: %v", ErrStaleToken, apiErr) + } + if response.ErrCode == -2 || response.Ret == -2 { + return fmt.Errorf("%w: %v", ErrContextExpired, apiErr) + } + return apiErr + } + return nil +} + +func (c *Client) NotifyStart(ctx context.Context, endpoint, token string) error { + return c.notify(ctx, endpoint, token, "/ilink/bot/msg/notifystart") +} + +func (c *Client) NotifyStop(ctx context.Context, endpoint, token string) error { + return c.notify(ctx, endpoint, token, "/ilink/bot/msg/notifystop") +} + +func (c *Client) notify(ctx context.Context, endpoint, token, path string) error { + var response sendResponse + if err := c.doJSON(ctx, http.MethodPost, endpoint, path, nil, token, map[string]any{"base_info": requestBaseInfo()}, 5*time.Second, &response); err != nil { + return err + } + if response.Ret != 0 || response.ErrCode != 0 { + apiErr := &APIError{Operation: strings.TrimPrefix(path, "/ilink/bot/"), Ret: response.Ret, ErrCode: response.ErrCode, Message: response.ErrorMessage} + if response.ErrCode == -14 || response.Ret == -14 { + return fmt.Errorf("%w: %v", ErrStaleToken, apiErr) + } + return apiErr + } + return nil +} + +type outboundText struct { + To string + ContextToken string + Text string + ClientID string +} + +func requestBaseInfo() baseInfo { + return baseInfo{ChannelVersion: ilinkChannelVersion, BotAgent: "ChatAPI/1.0.0"} +} + +func (c *Client) doJSON(ctx context.Context, method, rawBase, path string, query url.Values, token string, body any, timeout time.Duration, output any) error { + base, err := parseEndpointURL(rawBase, c.allowTestHTTP, c.allowTestEndpointHost) + if err != nil { + return err + } + requestURL := *base + requestURL.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(path, "/") + requestURL.RawQuery = query.Encode() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode clawbot request: %w", err) + } + if len(encoded) > maxResponseBytes { + return errors.New("clawbot request is too large") + } + reader = bytes.NewReader(encoded) + } + requestCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req, err := http.NewRequestWithContext(requestCtx, method, requestURL.String(), reader) + if err != nil { + return fmt.Errorf("create clawbot request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("AuthorizationType", "ilink_bot_token") + req.Header.Set("X-WECHAT-UIN", randomWechatUIN()) + req.Header.Set("iLink-App-Id", ilinkAppID) + req.Header.Set("iLink-App-ClientVersion", ilinkClientVersion) + if token = strings.TrimSpace(token); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("clawbot request failed: %w", err) + } + defer resp.Body.Close() + limited := io.LimitReader(resp.Body, maxResponseBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return fmt.Errorf("read clawbot response: %w", err) + } + if len(data) > maxResponseBytes { + return errors.New("clawbot response is too large") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{Operation: path, Status: resp.StatusCode} + } + if output == nil || len(bytes.TrimSpace(data)) == 0 { + return nil + } + if err := json.Unmarshal(data, output); err != nil { + return fmt.Errorf("decode clawbot response: %w", err) + } + return nil +} + +func randomWechatUIN() string { + var data [4]byte + if _, err := rand.Read(data[:]); err != nil { + return base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(time.Now().UnixNano(), 10))) + } + value := binary.BigEndian.Uint32(data[:]) + return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(value), 10))) +} + +func parseEndpointURL(raw string, allowTestHTTP bool, testHost string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, errors.New("invalid clawbot endpoint") + } + if err := validateEndpointURL(u, allowTestHTTP, testHost); err != nil { + return nil, err + } + return u, nil +} + +func validateEndpointURL(u *url.URL, allowTestHTTP bool, testHost string) error { + if u == nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return errors.New("invalid clawbot endpoint") + } + hostname := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + if allowTestHTTP && u.Scheme == "http" && hostname == strings.ToLower(strings.TrimSpace(testHost)) { + return nil + } + if u.Scheme != "https" || (u.Port() != "" && u.Port() != "443") { + return errors.New("clawbot endpoint must use HTTPS on the default port") + } + if hostname != "weixin.qq.com" && !strings.HasSuffix(hostname, ".weixin.qq.com") { + return errors.New("clawbot endpoint host is not trusted") + } + return nil +} + +// pi-lens-ignore: go-bare-error +func endpointFromRedirectHost(host string, allowTestHTTP bool, testHost string) (string, error) { + host = strings.TrimSpace(host) + if host == "" || strings.ContainsAny(host, "/?#@") { + return "", errors.New("invalid clawbot redirect host") + } + scheme := "https" + probe := &url.URL{Host: host} + if allowTestHTTP && strings.EqualFold(strings.TrimSuffix(probe.Hostname(), "."), testHost) { + scheme = "http" + } + u := &url.URL{Scheme: scheme, Host: host} + if err := validateEndpointURL(u, allowTestHTTP, testHost); err != nil { + return "", fmt.Errorf("validate clawbot redirect host: %w", err) + } + return strings.TrimRight(u.String(), "/"), nil +} + +func validatePublicQRCodeURL(raw string) error { + if len(raw) == 0 || len(raw) > maxQRCodeURLBytes || strings.ContainsAny(raw, "\r\n\x00") { + return errors.New("clawbot returned an invalid QR URL") + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.User != nil || u.Fragment != "" || (u.Port() != "" && u.Port() != "443") { + return errors.New("clawbot returned an invalid QR URL") + } + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + trusted := host == "weixin.qq.com" || strings.HasSuffix(host, ".weixin.qq.com") || host == "wechat.com" || strings.HasSuffix(host, ".wechat.com") + if !trusted { + return errors.New("clawbot returned an untrusted QR URL") + } + return nil +} diff --git a/backend/internal/service/im/clawbot/client_test.go b/backend/internal/service/im/clawbot/client_test.go new file mode 100644 index 0000000..20d1f44 --- /dev/null +++ b/backend/internal/service/im/clawbot/client_test.go @@ -0,0 +1,265 @@ +package clawbot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + imsvc "github.com/zyf2007/ChatAPI/internal/service/im" +) + +func TestClientLoginUpdatesAndSend(t *testing.T) { + t.Parallel() + var mu sync.Mutex + calls := make(map[string]int) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls[r.URL.Path]++ + mu.Unlock() + if got := r.Header.Get("AuthorizationType"); got != "ilink_bot_token" { + t.Errorf("AuthorizationType = %q", got) + } + if r.Header.Get("X-WECHAT-UIN") == "" { + t.Error("missing X-WECHAT-UIN") + } + if r.Header.Get("iLink-App-Id") != ilinkAppID || r.Header.Get("iLink-App-ClientVersion") != ilinkClientVersion { + t.Errorf("unexpected iLink client headers: %q %q", r.Header.Get("iLink-App-Id"), r.Header.Get("iLink-App-ClientVersion")) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/ilink/bot/get_bot_qrcode": + if r.URL.Query().Get("bot_type") != defaultBotType { + t.Errorf("bot_type = %q", r.URL.Query().Get("bot_type")) + } + var body struct { + LocalTokens []string `json:"local_token_list"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.LocalTokens) != 1 || body.LocalTokens[0] != "local-token" { + t.Errorf("local tokens = %#v, err=%v", body.LocalTokens, err) + } + fmt.Fprint(w, `{"qrcode":"qr-secret","qrcode_img_content":"https://weixin.qq.com/x/test"}`) + case "/ilink/bot/get_qrcode_status": + if r.URL.Query().Get("qrcode") != "qr-secret" || r.URL.Query().Get("verify_code") != "123456" { + t.Errorf("unexpected login query: %s", r.URL.RawQuery) + } + fmt.Fprintf(w, `{"status":"confirmed","bot_token":"token-secret","ilink_bot_id":"bot-1","ilink_user_id":"owner-1","baseurl":%q}`, server.URL) + case "/ilink/bot/getupdates": + if r.Header.Get("Authorization") != "Bearer token-secret" { + t.Errorf("authorization = %q", r.Header.Get("Authorization")) + } + fmt.Fprint(w, `{"ret":0,"errcode":0,"get_updates_buf":"cursor-2","longpolling_timeout_ms":12000,"msgs":[]}`) + case "/ilink/bot/sendmessage": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + msg, _ := body["msg"].(map[string]any) + info, _ := body["base_info"].(map[string]any) + if info["channel_version"] != ilinkChannelVersion || info["bot_agent"] != "ChatAPI/1.0.0" { + t.Errorf("unexpected base_info: %#v", info) + } + _, hasFrom := msg["from_user_id"] + if !hasFrom || msg["from_user_id"] != "" || msg["to_user_id"] != "owner-1" || msg["context_token"] != "context-1" { + t.Errorf("unexpected message: %#v", msg) + } + fmt.Fprint(w, `{"ret":0,"errcode":0}`) + case "/ilink/bot/msg/notifystart", "/ilink/bot/msg/notifystop": + fmt.Fprint(w, `{"ret":0,"errcode":0}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := newTestClient(t, server) + challenge, qrURL, err := client.StartLogin(context.Background(), []string{"local-token"}) + if err != nil { + t.Fatal(err) + } + if challenge.QRCode != "qr-secret" || qrURL != "https://weixin.qq.com/x/test" { + t.Fatalf("unexpected challenge: %#v %q", challenge, qrURL) + } + status, _, err := client.PollLogin(context.Background(), challenge, "123456") + if err != nil { + t.Fatal(err) + } + provider := NewProvider(client) + account, err := provider.accountFromLogin(status) + if err != nil { + t.Fatal(err) + } + if account.Endpoint != server.URL { + t.Fatalf("endpoint = %q", account.Endpoint) + } + updates, err := client.GetUpdates(context.Background(), account.Endpoint, "token-secret", "cursor-1", 12*time.Second) + if err != nil || updates.Cursor != "cursor-2" { + t.Fatalf("updates = %#v, err=%v", updates, err) + } + if err := client.SendText(context.Background(), account.Endpoint, "token-secret", outboundText{ + To: "owner-1", ContextToken: "context-1", Text: "hello", ClientID: "client-1", + }); err != nil { + t.Fatal(err) + } + if err := client.NotifyStart(context.Background(), account.Endpoint, "token-secret"); err != nil { + t.Fatal(err) + } + if err := client.NotifyStop(context.Background(), account.Endpoint, "token-secret"); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + for _, path := range []string{"/ilink/bot/get_bot_qrcode", "/ilink/bot/get_qrcode_status", "/ilink/bot/getupdates", "/ilink/bot/sendmessage", "/ilink/bot/msg/notifystart", "/ilink/bot/msg/notifystop"} { + if calls[path] != 1 { + t.Errorf("calls[%s] = %d", path, calls[path]) + } + } +} + +func TestProviderMapsQRCodeStates(t *testing.T) { + t.Parallel() + var response string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, response) + })) + defer server.Close() + client := newTestClient(t, server) + provider := NewProvider(client) + internal, _ := json.Marshal(loginChallenge{QRCode: "qr", Base: server.URL}) + challenge := imsvc.LoginChallenge{ + Provider: imsvc.ProviderClawBot, Opaque: internal, QRCodeURL: "https://weixin.qq.com/x/test", ExpiresAt: time.Now().Add(time.Minute), + } + for _, item := range []struct { + response string + want imsvc.LoginState + }{ + {`{"status":"wait"}`, imsvc.LoginWaiting}, + {`{"status":"scaned"}`, imsvc.LoginScanned}, + {`{"status":"need_verifycode"}`, imsvc.LoginVerifyNeeded}, + {`{"status":"verify_code_blocked"}`, imsvc.LoginVerifyBlocked}, + {`{"status":"expired"}`, imsvc.LoginExpired}, + {`{"status":"binded_redirect"}`, imsvc.LoginAlreadyBound}, + } { + response = item.response + result, err := provider.PollLogin(context.Background(), challenge, "") + if err != nil { + t.Fatalf("response %s: %v", item.response, err) + } + if result.State != item.want { + t.Errorf("response %s: state=%s want=%s", item.response, result.State, item.want) + } + } + parsed, _ := url.Parse(server.URL) + response = fmt.Sprintf(`{"status":"scaned_but_redirect","redirect_host":%q}`, parsed.Host) + redirected, err := provider.PollLogin(context.Background(), challenge, "") + if err != nil || redirected.State != imsvc.LoginWaiting { + t.Fatalf("redirect state = %#v, err=%v", redirected, err) + } + var redirectedChallenge loginChallenge + if json.Unmarshal(redirected.Challenge.Opaque, &redirectedChallenge) != nil || redirectedChallenge.Base != server.URL { + t.Fatalf("redirected challenge = %#v", redirectedChallenge) + } +} + +func TestClientRejectsUntrustedEndpointsAndQRCode(t *testing.T) { + t.Parallel() + badEndpoints := []string{ + "http://ilinkai.weixin.qq.com", + "https://evilweixin.qq.com", + "https://user@ilinkai.weixin.qq.com", + "https://ilinkai.weixin.qq.com:8443", + "https://ilinkai.weixin.qq.com/path", + "https://ilinkai.weixin.qq.com?token=x", + } + for _, raw := range badEndpoints { + if _, err := parseEndpointURL(raw, false, ""); err == nil { + t.Errorf("expected endpoint rejection: %s", raw) + } + } + for _, raw := range []string{"javascript:alert(1)", "https://example.com/qr", "https://weixin.qq.com/#fragment", strings.Repeat("x", maxQRCodeURLBytes+1)} { + if err := validatePublicQRCodeURL(raw); err == nil { + t.Errorf("expected QR URL rejection: %q", raw) + } + } + if _, err := parseEndpointURL("https://ilinkai.weixin.qq.com", false, ""); err != nil { + t.Fatalf("trusted endpoint rejected: %v", err) + } +} + +func TestClientBoundsResponsesAndClassifiesStaleToken(t *testing.T) { + t.Parallel() + t.Run("oversized response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("x", maxResponseBytes+1))) + })) + defer server.Close() + client := newTestClient(t, server) + _, err := client.GetUpdates(context.Background(), server.URL, "token", "", 5*time.Second) + if err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("err = %v", err) + } + }) + t.Run("redirect is not followed", func(t *testing.T) { + followed := make(chan struct{}, 1) + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { followed <- struct{}{} })) + defer target.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirect.Close() + client := newTestClient(t, redirect) + _, err := client.GetUpdates(context.Background(), redirect.URL, "token", "", 5*time.Second) + if err == nil { + t.Fatal("redirect response should fail") + } + select { + case <-followed: + t.Fatal("credentialed redirect was followed") + default: + } + }) + t.Run("stale context", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"ret":-2,"errcode":0}`) + })) + defer server.Close() + client := newTestClient(t, server) + err := client.SendText(context.Background(), server.URL, "token", outboundText{To: "owner", ContextToken: "stale", Text: "hello", ClientID: "client"}) + if !errors.Is(err, ErrContextExpired) { + t.Fatalf("err = %v", err) + } + }) + t.Run("stale ret", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"ret":-14,"errcode":0}`) + })) + defer server.Close() + client := newTestClient(t, server) + _, err := client.GetUpdates(context.Background(), server.URL, "token", "", 5*time.Second) + if !errors.Is(err, ErrStaleToken) { + t.Fatalf("err = %v", err) + } + }) +} + +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := NewClient(server.Client()) + client.loginBaseURL = server.URL + client.allowTestHTTP = true + client.allowTestEndpointHost = parsed.Hostname() + return client +} diff --git a/backend/internal/service/im/clawbot/provider.go b/backend/internal/service/im/clawbot/provider.go new file mode 100644 index 0000000..09b0324 --- /dev/null +++ b/backend/internal/service/im/clawbot/provider.go @@ -0,0 +1,368 @@ +package clawbot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + imsvc "github.com/zyf2007/ChatAPI/internal/service/im" +) + +var ErrNotReady = fmt.Errorf("%w: send /bind from WeChat first", imsvc.ErrProviderNotReady) + +type Provider struct { + Client *Client + Now func() time.Time +} + +type credentials struct { + Token string `json:"token"` +} + +type providerState struct { + Cursor string `json:"cursor,omitempty"` + ContextToken string `json:"context_token,omitempty"` + ContextGeneration uint64 `json:"context_generation,omitempty"` + ProcessedMessageID []string `json:"processed_message_ids,omitempty"` +} + +func NewProvider(client *Client) *Provider { + if client == nil { + client = NewClient(nil) + } + return &Provider{Client: client, Now: time.Now} +} + +func (p *Provider) ID() string { return imsvc.ProviderClawBot } + +func (p *Provider) Ready(account imsvc.Account) bool { + _, state, err := decodeAccount(account) + return err == nil && strings.TrimSpace(state.ContextToken) != "" +} + +func (p *Provider) ReadinessVersion(account imsvc.Account) string { + _, state, err := decodeAccount(account) + if err != nil || state.ContextGeneration == 0 { + return "" + } + return strconv.FormatUint(state.ContextGeneration, 10) +} + +func (p *Provider) StartLogin(ctx context.Context, existing *imsvc.Account) (imsvc.LoginChallenge, error) { + var localTokens []string + if existing != nil && existing.Provider == p.ID() { + if creds, _, err := decodeAccount(*existing); err == nil { + localTokens = []string{creds.Token} + } + } + challenge, qrCodeURL, err := p.Client.StartLogin(ctx, localTokens) + if err != nil { + return imsvc.LoginChallenge{}, err + } + opaque, err := json.Marshal(challenge) + if err != nil { + return imsvc.LoginChallenge{}, fmt.Errorf("encode clawbot login challenge: %w", err) + } + return imsvc.LoginChallenge{ + Provider: p.ID(), + Opaque: opaque, + QRCodeURL: qrCodeURL, + ExpiresAt: p.now().Add(5 * time.Minute), + }, nil +} + +func (p *Provider) PollLogin(ctx context.Context, challenge imsvc.LoginChallenge, verifyCode string) (imsvc.LoginPollResult, error) { + var internal loginChallenge + if challenge.Provider != p.ID() || json.Unmarshal(challenge.Opaque, &internal) != nil { + return imsvc.LoginPollResult{}, errors.New("invalid clawbot login challenge") + } + response, updated, err := p.Client.PollLogin(ctx, internal, verifyCode) + if err != nil { + return imsvc.LoginPollResult{}, err + } + updatedOpaque, err := json.Marshal(updated) + if err != nil { + return imsvc.LoginPollResult{}, fmt.Errorf("encode clawbot login challenge: %w", err) + } + challenge.Opaque = updatedOpaque + result := imsvc.LoginPollResult{Challenge: challenge} + switch response.Status { + case "wait", "scaned_but_redirect": + result.State = imsvc.LoginWaiting + result.Message = "等待微信扫码确认" + case "scaned": + result.State = imsvc.LoginScanned + result.Message = "已扫码,正在确认" + case "need_verifycode": + result.State = imsvc.LoginVerifyNeeded + result.Message = "请输入手机微信显示的数字" + case "verify_code_blocked": + result.State = imsvc.LoginVerifyBlocked + result.Message = "验证码尝试过多,请重新生成二维码" + case "expired": + result.State = imsvc.LoginExpired + result.Message = "二维码已过期,请重新生成" + case "binded_redirect": + result.State = imsvc.LoginAlreadyBound + result.Message = "该微信 ClawBot 已绑定其他客户端" + case "confirmed": + account, err := p.accountFromLogin(response) + if err != nil { + return imsvc.LoginPollResult{}, err + } + result.State = imsvc.LoginConnected + result.Message = "微信 ClawBot 已连接" + result.Account = &account + default: + return imsvc.LoginPollResult{}, fmt.Errorf("unknown clawbot QR status %q", response.Status) + } + return result, nil +} + +func (p *Provider) Run(ctx context.Context, account imsvc.Account, callbacks imsvc.ProviderCallbacks) error { + creds, state, err := decodeAccount(account) + if err != nil { + return err + } + if err := p.Client.NotifyStart(ctx, account.Endpoint, creds.Token); err != nil { + if errors.Is(err, ErrStaleToken) { + return fmt.Errorf("%w: %v", imsvc.ErrReauthRequired, err) + } + report(callbacks, err) + } + defer func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := p.Client.NotifyStop(stopCtx, account.Endpoint, creds.Token); err != nil && !errors.Is(err, context.Canceled) { + report(callbacks, err) + } + }() + + longPollTimeout := 35 * time.Second + backoff := time.Second + for ctx.Err() == nil { + response, err := p.Client.GetUpdates(ctx, account.Endpoint, creds.Token, state.Cursor, longPollTimeout) + if err != nil { + if ctx.Err() != nil { + return nil + } + if errors.Is(err, ErrStaleToken) { + return fmt.Errorf("%w: %v", imsvc.ErrReauthRequired, err) + } + report(callbacks, err) + if !waitContext(ctx, backoff) { + return nil + } + if backoff < 30*time.Second { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + continue + } + backoff = time.Second + if response.LongPollingTimeoutMsec > 0 { + longPollTimeout = time.Duration(response.LongPollingTimeoutMsec) * time.Millisecond + } + + stateChanged := response.Cursor != "" && response.Cursor != state.Cursor + for _, raw := range response.Messages { + inbound, ok := inboundFromMessage(account, raw) + if !ok || hasProcessed(state.ProcessedMessageID, inbound.ID) { + continue + } + if token := strings.TrimSpace(inbound.ContextToken); token != "" { + state.ContextToken = token + state.ContextGeneration++ + inbound.ReadinessVersion = strconv.FormatUint(state.ContextGeneration, 10) + } + if callbacks.HandleInbound != nil { + if err := callbacks.HandleInbound(ctx, inbound); err != nil { + report(callbacks, err) + return err + } + } + state.ProcessedMessageID = appendProcessed(state.ProcessedMessageID, inbound.ID) + stateChanged = true + } + if response.Cursor != "" { + state.Cursor = response.Cursor + } + if stateChanged && callbacks.Checkpoint != nil { + encoded, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("encode clawbot checkpoint: %w", err) + } + if err := callbacks.Checkpoint(ctx, encoded); err != nil { + return err + } + } + } + return nil +} + +func (p *Provider) Send(ctx context.Context, account imsvc.Account, outgoing imsvc.OutboundMessage) error { + creds, state, err := decodeAccount(account) + if err != nil { + return err + } + to := strings.TrimSpace(outgoing.To) + if to == "" { + to = strings.TrimSpace(account.ExternalOwnerID) + } + contextToken := strings.TrimSpace(outgoing.ContextToken) + if contextToken == "" { + contextToken = strings.TrimSpace(state.ContextToken) + } + if contextToken == "" { + return ErrNotReady + } + clientID := strings.TrimSpace(outgoing.ClientID) + if clientID == "" { + clientID = uuid.NewString() + } + err = p.Client.SendText(ctx, account.Endpoint, creds.Token, outboundText{ + To: to, ContextToken: contextToken, Text: outgoing.Text, ClientID: clientID, + }) + if errors.Is(err, ErrStaleToken) { + return fmt.Errorf("%w: %v", imsvc.ErrReauthRequired, err) + } + if errors.Is(err, ErrContextExpired) { + return fmt.Errorf("%w: %v", imsvc.ErrProviderNotReady, err) + } + return err +} + +func (p *Provider) accountFromLogin(response qrStatusResponse) (imsvc.Account, error) { + response.BotToken = strings.TrimSpace(response.BotToken) + response.BotID = strings.TrimSpace(response.BotID) + response.OwnerID = strings.TrimSpace(response.OwnerID) + if response.BotToken == "" || response.BotID == "" || response.OwnerID == "" { + return imsvc.Account{}, errors.New("clawbot login confirmation omitted required credentials") + } + endpoint := strings.TrimSpace(response.BaseURL) + if endpoint == "" { + endpoint = defaultBaseURL + } + validated, err := parseEndpointURL(endpoint, p.Client.allowTestHTTP, p.Client.allowTestEndpointHost) + if err != nil { + return imsvc.Account{}, err + } + credentialJSON, err := json.Marshal(credentials{Token: response.BotToken}) + if err != nil { + return imsvc.Account{}, err + } + stateJSON, err := json.Marshal(providerState{}) + if err != nil { + return imsvc.Account{}, err + } + return imsvc.Account{ + Provider: p.ID(), ExternalBotID: response.BotID, ExternalOwnerID: response.OwnerID, + Endpoint: strings.TrimRight(validated.String(), "/"), Credentials: credentialJSON, + State: stateJSON, ConnectedAt: p.now(), + }, nil +} + +func decodeAccount(account imsvc.Account) (credentials, providerState, error) { + var creds credentials + var state providerState + if account.Provider != imsvc.ProviderClawBot || json.Unmarshal(account.Credentials, &creds) != nil || strings.TrimSpace(creds.Token) == "" || len(creds.Token) > 64*1024 { + return credentials{}, providerState{}, errors.New("invalid clawbot credentials") + } + if len(account.State) > 0 { + if err := json.Unmarshal(account.State, &state); err != nil { + return credentials{}, providerState{}, errors.New("invalid clawbot state") + } + } + if len(state.Cursor) > 512*1024 || len(state.ContextToken) > 64*1024 || len(state.ProcessedMessageID) > 256 { + return credentials{}, providerState{}, errors.New("clawbot state exceeds safety limits") + } + return creds, state, nil +} + +func inboundFromMessage(account imsvc.Account, raw message) (imsvc.InboundMessage, bool) { + if raw.MessageType != 1 || raw.MessageState != 2 || strings.TrimSpace(raw.GroupID) != "" { + return imsvc.InboundMessage{}, false + } + if strings.TrimSpace(raw.From) != strings.TrimSpace(account.ExternalOwnerID) { + return imsvc.InboundMessage{}, false + } + if target := strings.TrimSpace(raw.To); target != "" && target != strings.TrimSpace(account.ExternalBotID) { + return imsvc.InboundMessage{}, false + } + id := "" + if raw.MessageID != 0 { + id = strconv.FormatInt(raw.MessageID, 10) + } else if strings.TrimSpace(raw.ClientID) != "" { + id = strings.TrimSpace(raw.ClientID) + } + if id == "" { + return imsvc.InboundMessage{}, false + } + text := "" + textItems := 0 + unsupported := false + for _, item := range raw.Items { + if item.Type == 1 && item.Text != nil { + textItems++ + text = item.Text.Text + } else if item.Type != 0 { + unsupported = true + } + } + if textItems != 1 || unsupported { + text = "" + } + return imsvc.InboundMessage{ + ID: id, Sequence: raw.Sequence, From: strings.TrimSpace(raw.From), To: strings.TrimSpace(raw.To), + ContextToken: strings.TrimSpace(raw.ContextToken), Text: text, Direct: true, Complete: true, + }, true +} + +func hasProcessed(items []string, id string) bool { + for _, item := range items { + if item == id { + return true + } + } + return false +} + +func appendProcessed(items []string, id string) []string { + items = append(items, id) + if len(items) > 128 { + items = append([]string(nil), items[len(items)-128:]...) + } + return items +} + +func report(callbacks imsvc.ProviderCallbacks, err error) { + if err != nil && callbacks.ReportError != nil { + callbacks.ReportError(err) + } +} + +func waitContext(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func (p *Provider) now() time.Time { + if p.Now != nil { + return p.Now().UTC() + } + return time.Now().UTC() +} diff --git a/backend/internal/service/im/clawbot/provider_test.go b/backend/internal/service/im/clawbot/provider_test.go new file mode 100644 index 0000000..c33a7dc --- /dev/null +++ b/backend/internal/service/im/clawbot/provider_test.go @@ -0,0 +1,192 @@ +package clawbot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + imsvc "github.com/zyf2007/ChatAPI/internal/service/im" +) + +func TestInboundFromMessageEnforcesOwnerDirectFinishedText(t *testing.T) { + t.Parallel() + account := imsvc.Account{Provider: imsvc.ProviderClawBot, ExternalBotID: "bot-1", ExternalOwnerID: "owner-1"} + valid := message{ + MessageID: 42, From: "owner-1", To: "bot-1", MessageType: 1, MessageState: 2, + ContextToken: "context-1", Items: []messageItem{{Type: 1, IsCompleted: true, Text: &textItem{Text: "answer"}}}, + } + inbound, ok := inboundFromMessage(account, valid) + if !ok || inbound.ID != "42" || inbound.Text != "answer" || inbound.ContextToken != "context-1" { + t.Fatalf("inbound = %#v, ok=%v", inbound, ok) + } + withoutTarget := valid + withoutTarget.To = "" + if _, ok := inboundFromMessage(account, withoutTarget); !ok { + t.Fatal("server response without to_user_id should remain valid") + } + + invalid := []message{ + func() message { value := valid; value.From = "other"; return value }(), + func() message { value := valid; value.To = "other-bot"; return value }(), + func() message { value := valid; value.GroupID = "group-1"; return value }(), + func() message { value := valid; value.MessageType = 2; return value }(), + func() message { value := valid; value.MessageState = 1; return value }(), + func() message { value := valid; value.MessageID = 0; return value }(), + } + for index, candidate := range invalid { + if _, ok := inboundFromMessage(account, candidate); ok { + t.Errorf("invalid[%d] was accepted: %#v", index, candidate) + } + } + + media := valid + media.Items = append(media.Items, messageItem{Type: 2, IsCompleted: true}) + inbound, ok = inboundFromMessage(account, media) + if !ok || inbound.Text != "" { + t.Fatalf("unsupported media should reach coordinator without text: %#v, ok=%v", inbound, ok) + } +} + +func TestProviderCheckpointAdvancesReadinessVersionOnlyForInboundContext(t *testing.T) { + t.Parallel() + var polls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/ilink/bot/msg/notifystart", "/ilink/bot/msg/notifystop": + fmt.Fprint(w, `{"ret":0,"errcode":0}`) + case "/ilink/bot/getupdates": + if polls.Add(1) == 1 { + fmt.Fprint(w, `{"ret":0,"errcode":0,"get_updates_buf":"cursor-only","msgs":[]}`) + return + } + fmt.Fprint(w, `{"ret":0,"errcode":0,"get_updates_buf":"cursor-with-context","msgs":[`+ + `{"message_id":1,"from_user_id":"owner-1","to_user_id":"bot-1","message_type":1,"message_state":2,"context_token":"fresh","item_list":[{"type":1,"text_item":{"text":"hello"}}]}`+ + `]}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + provider := NewProvider(newTestClient(t, server)) + credentialsJSON, _ := json.Marshal(credentials{Token: "token"}) + stateJSON, _ := json.Marshal(providerState{Cursor: "cursor-old", ContextToken: "stale", ContextGeneration: 4}) + account := imsvc.Account{ + Provider: imsvc.ProviderClawBot, ExternalBotID: "bot-1", ExternalOwnerID: "owner-1", + Endpoint: server.URL, Credentials: credentialsJSON, State: stateJSON, + } + ctx, cancel := context.WithCancel(context.Background()) + var checkpoints []json.RawMessage + var inboundVersion string + err := provider.Run(ctx, account, imsvc.ProviderCallbacks{ + HandleInbound: func(_ context.Context, inbound imsvc.InboundMessage) error { + inboundVersion = inbound.ReadinessVersion + return nil + }, + Checkpoint: func(_ context.Context, state json.RawMessage) error { + checkpoints = append(checkpoints, append(json.RawMessage(nil), state...)) + if len(checkpoints) == 2 { + cancel() + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if len(checkpoints) != 2 { + t.Fatalf("checkpoint count = %d", len(checkpoints)) + } + if inboundVersion != "5" { + t.Fatalf("inbound readiness version = %q", inboundVersion) + } + account.State = checkpoints[0] + if version := provider.ReadinessVersion(account); version != "4" { + t.Fatalf("cursor checkpoint version = %q, state=%s", version, checkpoints[0]) + } + account.State = checkpoints[1] + if version := provider.ReadinessVersion(account); version != "5" { + t.Fatalf("inbound checkpoint version = %q, state=%s", version, checkpoints[1]) + } +} + +func TestProviderDoesNotCheckpointBatchCursorOnMessageFailure(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/ilink/bot/msg/notifystart", "/ilink/bot/msg/notifystop": + fmt.Fprint(w, `{"ret":0,"errcode":0}`) + case "/ilink/bot/getupdates": + fmt.Fprint(w, `{"ret":0,"errcode":0,"get_updates_buf":"cursor-new","msgs":[`+ + `{"message_id":1,"from_user_id":"owner-1","to_user_id":"bot-1","message_type":1,"message_state":2,"context_token":"ctx","item_list":[{"type":1,"text_item":{"text":"first"}}]},`+ + `{"message_id":2,"from_user_id":"owner-1","to_user_id":"bot-1","message_type":1,"message_state":2,"context_token":"ctx","item_list":[{"type":1,"text_item":{"text":"second"}}]}`+ + `]}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := newTestClient(t, server) + provider := NewProvider(client) + credentialsJSON, _ := json.Marshal(credentials{Token: "token"}) + stateJSON, _ := json.Marshal(providerState{Cursor: "cursor-old"}) + account := imsvc.Account{ + Provider: imsvc.ProviderClawBot, ExternalBotID: "bot-1", ExternalOwnerID: "owner-1", + Endpoint: server.URL, Credentials: credentialsJSON, State: stateJSON, + } + boom := errors.New("second message failed") + var handled atomic.Int32 + var checkpoints atomic.Int32 + err := provider.Run(context.Background(), account, imsvc.ProviderCallbacks{ + HandleInbound: func(context.Context, imsvc.InboundMessage) error { + if handled.Add(1) == 2 { + return boom + } + return nil + }, + Checkpoint: func(context.Context, json.RawMessage) error { + checkpoints.Add(1) + return nil + }, + }) + if !errors.Is(err, boom) { + t.Fatalf("err = %v", err) + } + if handled.Load() != 2 || checkpoints.Load() != 0 { + t.Fatalf("handled=%d checkpoints=%d", handled.Load(), checkpoints.Load()) + } +} + +func TestProviderReadyAndProcessedWindow(t *testing.T) { + t.Parallel() + provider := NewProvider(nil) + credentials, _ := json.Marshal(struct { + Token string `json:"token"` + }{Token: "token"}) + state, _ := json.Marshal(providerState{ContextToken: "context"}) + account := imsvc.Account{Provider: imsvc.ProviderClawBot, Credentials: credentials, State: state} + if !provider.Ready(account) { + t.Fatal("account with context token should be ready") + } + account.State = json.RawMessage(`{}`) + if provider.Ready(account) { + t.Fatal("account without context token should not be ready") + } + + items := make([]string, 0, 140) + for index := range 140 { + items = appendProcessed(items, string(rune(index+1))) + } + if len(items) != 128 { + t.Fatalf("processed window length = %d", len(items)) + } + if !hasProcessed(items, string(rune(140))) || hasProcessed(items, string(rune(1))) { + t.Fatalf("processed window contents are incorrect") + } +} diff --git a/backend/internal/service/im/commands.go b/backend/internal/service/im/commands.go new file mode 100644 index 0000000..642986a --- /dev/null +++ b/backend/internal/service/im/commands.go @@ -0,0 +1,296 @@ +package im + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/zyf2007/ChatAPI/internal/actor" + controlsvc "github.com/zyf2007/ChatAPI/internal/service/chat/control" + turnsvc "github.com/zyf2007/ChatAPI/internal/service/chat/turn" +) + +const maxInboundTextRunes = 8000 + +func (s *Service) handleInbound(ctx context.Context, runtime *accountRuntime, inbound InboundMessage) error { + ownerID := runtime.ownerID + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err := s.requireActiveOwner(checkCtx, ownerID) + cancel() + if err != nil { + return err + } + now := time.Now().UTC() + s.mu.Lock() + if status := s.statuses[ownerID]; status != nil { + status.lastInboundAt = &now + } + s.mu.Unlock() + + text := strings.TrimSpace(inbound.Text) + if text == "" { + s.replyBestEffort(ctx, runtime, inbound, "首版微信接入仅支持单条文本消息。") + return nil + } + if !utf8.ValidString(text) || utf8.RuneCountInString(text) > maxInboundTextRunes { + s.replyBestEffort(ctx, runtime, inbound, "消息过长,请缩短后重试。") + return nil + } + + command, argument := splitCommand(text) + switch command { + case "/bind": + status := s.connectionSummary(ownerID) + s.replyBestEffort(ctx, runtime, inbound, "绑定已刷新。"+status+"\n\n"+commandHelp()) + case "/help": + s.replyBestEffort(ctx, runtime, inbound, commandHelp()) + case "/list": + s.replyBestEffort(ctx, runtime, inbound, s.pendingList(ownerID)) + case "/use": + pending, err := s.selectPending(ownerID, argument) + if err != nil { + s.replyBestEffort(ctx, runtime, inbound, err.Error()) + return nil + } + s.replyBestEffort(ctx, runtime, inbound, fmt.Sprintf("已选择请求 %s(%s)。直接回复即可结束该请求。", shortRef(pending.ConversationID), pending.Model)) + case "/abort": + s.handleAbort(ctx, runtime, inbound, argument) + case "": + s.handleComplete(ctx, runtime, inbound, text) + default: + s.replyBestEffort(ctx, runtime, inbound, "未知命令。\n\n"+commandHelp()) + } + return nil +} + +func (s *Service) handleComplete(ctx context.Context, runtime *accountRuntime, inbound InboundMessage, text string) { + pending, err := s.currentPending(runtime.ownerID) + if err != nil { + s.replyBestEffort(ctx, runtime, inbound, err.Error()) + return + } + if s.Control == nil { + s.replyBestEffort(ctx, runtime, inbound, "回复服务暂不可用,请打开 Web 工作区处理。") + return + } + controlCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + controlCtx = actor.WithActor(controlCtx, actor.Actor{ + UserID: runtime.ownerID, Source: "im", EntryPoint: ProviderClawBot, PrincipalID: runtime.ownerID, + }) + _, err = s.Control.Execute(controlCtx, controlsvc.Command{ + Source: controlsvc.SourceIM, OwnerID: runtime.ownerID, + ConversationID: pending.ConversationID, ResponseID: pending.ResponseID, RequestID: pending.RequestID, + Action: turnsvc.OutputAction{Kind: turnsvc.TurnControlStreamComplete, OutputText: text, Mode: "assistant_message"}, + }) + if err != nil { + s.clearSelectionIf(runtime.ownerID, pending.ConversationID) + s.replyBestEffort(ctx, runtime, inbound, "该请求已经结束、失效或不可由当前账号处理。请发送 /list 刷新。") + return + } + s.clearSelectionIf(runtime.ownerID, pending.ConversationID) + s.replyBestEffort(ctx, runtime, inbound, fmt.Sprintf("已结束请求 %s。", shortRef(pending.ConversationID))) +} + +func (s *Service) handleAbort(ctx context.Context, runtime *accountRuntime, inbound InboundMessage, reason string) { + pending, err := s.currentPending(runtime.ownerID) + if err != nil { + s.replyBestEffort(ctx, runtime, inbound, err.Error()) + return + } + if s.Control == nil { + s.replyBestEffort(ctx, runtime, inbound, "回复服务暂不可用,请打开 Web 工作区处理。") + return + } + if strings.TrimSpace(reason) == "" { + reason = "operator aborted the request from WeChat ClawBot" + } + controlCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + controlCtx = actor.WithActor(controlCtx, actor.Actor{ + UserID: runtime.ownerID, Source: "im", EntryPoint: ProviderClawBot, PrincipalID: runtime.ownerID, + }) + _, err = s.Control.Execute(controlCtx, controlsvc.Command{ + Source: controlsvc.SourceIM, OwnerID: runtime.ownerID, + ConversationID: pending.ConversationID, ResponseID: pending.ResponseID, RequestID: pending.RequestID, + Action: turnsvc.OutputAction{Kind: turnsvc.TurnControlAbort, AbortReason: reason}, + }) + if err != nil { + s.clearSelectionIf(runtime.ownerID, pending.ConversationID) + s.replyBestEffort(ctx, runtime, inbound, "该请求已经结束、失效或不可由当前账号处理。请发送 /list 刷新。") + return + } + s.clearSelectionIf(runtime.ownerID, pending.ConversationID) + s.replyBestEffort(ctx, runtime, inbound, fmt.Sprintf("已中止请求 %s。", shortRef(pending.ConversationID))) +} + +func (s *Service) replyBestEffort(ctx context.Context, runtime *accountRuntime, inbound InboundMessage, text string) { + s.mu.Lock() + if s.runtimes[runtime.ownerID] != runtime || s.accountGen[runtime.ownerID] != runtime.generation { + s.mu.Unlock() + return + } + account := s.accounts[runtime.ownerID] + provider := s.providers[account.Provider] + s.mu.Unlock() + if provider == nil { + return + } + sendCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + err := provider.Send(sendCtx, account, OutboundMessage{ + To: inbound.From, ContextToken: inbound.ContextToken, Text: text, + ClientID: inboundReplyClientID(inbound.ID, text), + }) + cancel() + if err == nil { + now := time.Now().UTC() + s.mu.Lock() + if status := s.statuses[runtime.ownerID]; status != nil { + status.lastOutboundAt = &now + status.lastError = "" + status.lastErrorAt = nil + } + s.mu.Unlock() + return + } + if errors.Is(err, ErrReauthRequired) { + s.markReauthRequired(runtime.ownerID) + } else if errors.Is(err, ErrProviderNotReady) { + invalidVersion := inbound.ReadinessVersion + if invalidVersion == "" { + invalidVersion = provider.ReadinessVersion(account) + } + s.markProviderNotReady(runtime.ownerID, invalidVersion) + } else { + s.recordOwnerError(runtime.ownerID, "微信确认消息发送失败") + } +} + +func (s *Service) currentPending(ownerID string) (*turnsvc.PendingTurn, error) { + if s.Pending == nil { + return nil, errors.New("回复服务暂不可用,请打开 Web 工作区处理。") + } + items := sortedPending(s.Pending.ListByOwnerID(ownerID)) + if len(items) == 0 { + return nil, errors.New("当前没有等待中的请求。") + } + s.mu.Lock() + selectedID := s.selected[ownerID] + s.mu.Unlock() + if selectedID != "" { + for _, item := range items { + if item.ConversationID == selectedID { + copy := *item + return ©, nil + } + } + } + latest := items[len(items)-1] + s.mu.Lock() + s.selected[ownerID] = latest.ConversationID + s.mu.Unlock() + copy := *latest + return ©, nil +} + +func (s *Service) selectPending(ownerID, reference string) (*turnsvc.PendingTurn, error) { + if s.Pending == nil { + return nil, errors.New("回复服务暂不可用,请打开 Web 工作区处理。") + } + reference = strings.ToLower(strings.TrimSpace(reference)) + if len(reference) < 4 { + return nil, errors.New("用法:/use <至少 4 位请求编号>") + } + var matches []*turnsvc.PendingTurn + for _, item := range sortedPending(s.Pending.ListByOwnerID(ownerID)) { + conversationID := strings.ToLower(item.ConversationID) + requestID := strings.ToLower(item.RequestID) + if strings.HasPrefix(conversationID, reference) || strings.HasPrefix(requestID, reference) { + matches = append(matches, item) + } + } + if len(matches) == 0 { + return nil, errors.New("没有找到该请求,请发送 /list 查看最新编号。") + } + if len(matches) > 1 { + return nil, errors.New("编号不唯一,请输入更多字符。") + } + selected := *matches[0] + s.mu.Lock() + s.selected[ownerID] = selected.ConversationID + s.mu.Unlock() + return &selected, nil +} + +func (s *Service) pendingList(ownerID string) string { + if s.Pending == nil { + return "回复服务暂不可用,请打开 Web 工作区处理。" + } + items := sortedPending(s.Pending.ListByOwnerID(ownerID)) + if len(items) == 0 { + return "当前没有等待中的请求。" + } + s.mu.Lock() + selectedID := s.selected[ownerID] + s.mu.Unlock() + var builder strings.Builder + builder.WriteString("等待中的请求:\n") + start := 0 + if len(items) > 10 { + start = len(items) - 10 + } + for _, item := range items[start:] { + marker := " " + if item.ConversationID == selectedID { + marker = "→ " + } + fmt.Fprintf(&builder, "%s%s · %s\n", marker, shortRef(item.ConversationID), truncateText(item.Model, 60)) + } + builder.WriteString("发送 /use <编号> 切换;直接回复会结束当前选中的请求。") + return strings.TrimSpace(builder.String()) +} + +func (s *Service) clearSelectionIf(ownerID, conversationID string) { + s.mu.Lock() + if s.selected[ownerID] == conversationID { + delete(s.selected, ownerID) + } + s.mu.Unlock() +} + +func (s *Service) connectionSummary(ownerID string) string { + s.mu.Lock() + status := s.statusLocked(ownerID) + s.mu.Unlock() + if status.Ready { + return "当前连接已可接收 ChatAPI 通知。" + } + return "已收到你的消息,连接将在状态保存后开始接收通知。" +} + +func splitCommand(text string) (string, string) { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, "/") { + return "", text + } + fields := strings.Fields(text) + if len(fields) == 0 { + return "", "" + } + command := fields[0] + return strings.ToLower(command), strings.TrimSpace(text[len(command):]) +} + +func commandHelp() string { + return "微信 ClawBot 命令:\n直接回复:结束当前请求\n/list:查看等待请求\n/use <编号>:切换请求\n/abort [原因]:中止请求\n/bind:刷新绑定\n/help:显示帮助\n\n首版不支持流式片段、思考、工具调用、媒体或群聊。" +} + +func inboundReplyClientID(messageID, text string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(messageID) + "\x00" + text)) + return "chatapi-reply-" + hex.EncodeToString(sum[:12]) +} diff --git a/backend/internal/service/im/notification.go b/backend/internal/service/im/notification.go new file mode 100644 index 0000000..cf95fbd --- /dev/null +++ b/backend/internal/service/im/notification.go @@ -0,0 +1,259 @@ +package im + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "go.uber.org/zap" + + chatevents "github.com/zyf2007/ChatAPI/internal/service/chat/events" +) + +func (s *Service) notificationWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-s.notifyWake: + ownerID, job, ok := s.takeNotification() + if !ok { + continue + } + s.sendWaitingNotification(ctx, ownerID, job.waiting) + s.finishNotification(ownerID) + } + } +} + +func (s *Service) takeNotification() (string, notificationJob, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for ownerID, job := range s.notification { + if s.notifyInFlight[ownerID] { + continue + } + delete(s.notification, ownerID) + s.notifyInFlight[ownerID] = true + for candidate := range s.notification { + if !s.notifyInFlight[candidate] { + s.signalNotificationLocked() + break + } + } + return ownerID, job, true + } + return "", notificationJob{}, false +} + +func (s *Service) finishNotification(ownerID string) { + s.mu.Lock() + delete(s.notifyInFlight, ownerID) + if _, ok := s.notification[ownerID]; ok { + s.signalNotificationLocked() + } + s.mu.Unlock() +} + +func (s *Service) sendWaitingNotification(ctx context.Context, ownerID string, waiting chatevents.WaitingTurn) { + if !s.waitingStillPending(ownerID, waiting.ConversationID, waiting.RequestID) { + s.clearLatestWaiting(ownerID, waiting.ConversationID, waiting.RequestID) + return + } + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err := s.requireActiveOwner(checkCtx, ownerID) + cancel() + if err != nil { + return + } + + text := waitingNotificationText(waiting) + clientID := notificationClientID(waiting.RequestID) + for attempt := range 3 { + if !s.waitingStillPending(ownerID, waiting.ConversationID, waiting.RequestID) { + s.clearLatestWaiting(ownerID, waiting.ConversationID, waiting.RequestID) + return + } + err = s.withRuntime(ownerID, func(provider Provider, account Account) error { + if !provider.Ready(account) { + return ErrProviderNotReady + } + sendCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := provider.Send(sendCtx, account, OutboundMessage{Text: text, ClientID: clientID}); err != nil { + switch { + case errors.Is(err, ErrProviderNotReady): + s.markProviderNotReady(ownerID, provider.ReadinessVersion(account)) + case errors.Is(err, ErrReauthRequired): + s.markReauthRequired(ownerID) + case attempt == 2: + s.recordOwnerError(ownerID, "微信通知发送失败;新请求到达时会再次尝试") + } + return err + } + if s.waitingStillPending(ownerID, waiting.ConversationID, waiting.RequestID) { + s.mu.Lock() + s.selected[ownerID] = waiting.ConversationID + s.mu.Unlock() + } + s.recordOutbound(ownerID) + return nil + }) + if err == nil || errors.Is(err, ErrConnectionNotFound) { + return + } + if errors.Is(err, ErrProviderNotReady) || errors.Is(err, ErrReauthRequired) { + return + } + if attempt < 2 && waitForContext(ctx, time.Duration(attempt+1)*time.Second) { + continue + } + } + if err != nil { + s.Logger.Warn("send IM waiting notification failed", zap.String("owner_id", ownerID), zap.Error(err)) + } +} + +func (s *Service) withRuntime(ownerID string, fn func(Provider, Account) error) error { + s.mu.Lock() + runtime := s.runtimes[ownerID] + generation := s.accountGen[ownerID] + s.mu.Unlock() + if runtime == nil { + return ErrConnectionNotFound + } + runtime.barrier.Lock() + defer runtime.barrier.Unlock() + s.mu.Lock() + if s.runtimes[ownerID] != runtime || s.accountGen[ownerID] != generation { + s.mu.Unlock() + return ErrConnectionNotFound + } + account := s.accounts[ownerID] + provider := s.providers[account.Provider] + s.mu.Unlock() + if provider == nil { + return errors.New("IM provider is unavailable") + } + return fn(provider, account) +} + +func (s *Service) waitingStillPending(ownerID, conversationID, requestID string) bool { + if s.Pending == nil { + return false + } + for _, pending := range s.Pending.ListByOwnerID(ownerID) { + if pending != nil && pending.ConversationID == conversationID && pending.RequestID == requestID { + return true + } + } + return false +} + +func (s *Service) clearLatestWaiting(ownerID, conversationID, requestID string) { + s.mu.Lock() + defer s.mu.Unlock() + current, ok := s.latestWaiting[ownerID] + if ok && current.ConversationID == conversationID && current.RequestID == requestID { + delete(s.latestWaiting, ownerID) + } +} + +func (s *Service) markProviderNotReady(ownerID, invalidReadinessVersion string) { + s.mu.Lock() + defer s.mu.Unlock() + if status := s.statuses[ownerID]; status != nil { + status.contextInvalid = true + status.invalidReadinessVersion = invalidReadinessVersion + status.lastError = "微信回复上下文已过期,请从扫码微信发送 /bind" + now := time.Now().UTC() + status.lastErrorAt = &now + } +} + +func (s *Service) markReauthRequired(ownerID string) { + s.mu.Lock() + defer s.mu.Unlock() + if status := s.statuses[ownerID]; status != nil { + status.reauthRequired = true + status.workerState = "reauth_required" + status.lastError = "微信登录已失效,请重新扫码连接" + now := time.Now().UTC() + status.lastErrorAt = &now + } +} + +func (s *Service) recordOutbound(ownerID string) { + s.mu.Lock() + defer s.mu.Unlock() + if status := s.statuses[ownerID]; status != nil { + now := time.Now().UTC() + status.lastOutboundAt = &now + status.lastError = "" + status.lastErrorAt = nil + } +} + +func (s *Service) recordOwnerError(ownerID, message string) { + s.mu.Lock() + defer s.mu.Unlock() + if status := s.statuses[ownerID]; status != nil { + status.lastError = message + now := time.Now().UTC() + status.lastErrorAt = &now + } +} + +func waitingNotificationText(waiting chatevents.WaitingTurn) string { + conversationRef := shortRef(waiting.ConversationID) + model := truncateText(strings.TrimSpace(waiting.Model), 80) + if model == "" { + model = "unknown" + } + userText := truncateText(strings.TrimSpace(waiting.LastUserText), 1200) + if userText == "" { + userText = "(没有可展示的用户文本)" + } + return fmt.Sprintf( + "ChatAPI 新请求\n编号:%s\n模型:%s\n\n%s\n\n直接回复将结束该请求。\n/list 查看等待请求 · /use <编号> 切换 · /abort 中止 · /help 帮助", + conversationRef, model, userText, + ) +} + +func notificationClientID(requestID string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(requestID))) + return "chatapi-wait-" + hex.EncodeToString(sum[:12]) +} + +func shortRef(value string) string { + value = strings.TrimSpace(value) + if len(value) <= 8 { + return value + } + return value[:8] +} + +func waitForContext(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func truncateText(value string, maxRunes int) string { + value = strings.TrimSpace(value) + if maxRunes <= 0 || utf8.RuneCountInString(value) <= maxRunes { + return value + } + runes := []rune(value) + return string(runes[:maxRunes]) + "…" +} diff --git a/backend/internal/service/im/service.go b/backend/internal/service/im/service.go new file mode 100644 index 0000000..8598210 --- /dev/null +++ b/backend/internal/service/im/service.go @@ -0,0 +1,712 @@ +package im + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/zyf2007/ChatAPI/internal/repository/common" + controlsvc "github.com/zyf2007/ChatAPI/internal/service/chat/control" + chatevents "github.com/zyf2007/ChatAPI/internal/service/chat/events" + turnsvc "github.com/zyf2007/ChatAPI/internal/service/chat/turn" +) + +var ( + ErrLoginNotFound = errors.New("IM login session not found") + ErrLoginBusy = errors.New("IM login poll already in progress") + ErrOwnerInactive = errors.New("IM owner is inactive") +) + +type PendingLookup interface { + ListByOwnerID(string) []*turnsvc.PendingTurn +} + +type Controller interface { + Execute(context.Context, controlsvc.Command) (controlsvc.Result, error) +} + +type ConnectionStatus struct { + Provider string `json:"provider"` + Connected bool `json:"connected"` + Ready bool `json:"ready"` + WorkerState string `json:"worker_state"` + ReauthRequired bool `json:"reauth_required"` + ExternalBotID string `json:"external_bot_id,omitempty"` + ConnectedAt *time.Time `json:"connected_at,omitempty"` + LastInboundAt *time.Time `json:"last_inbound_at,omitempty"` + LastOutboundAt *time.Time `json:"last_outbound_at,omitempty"` + LastError string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` +} + +type LoginView struct { + SessionID string `json:"session_id"` + State LoginState `json:"state"` + Message string `json:"message"` + QRCodeURL string `json:"qr_code_url,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + Status *ConnectionStatus `json:"connection,omitempty"` +} + +type runtimeStatus struct { + workerState string + reauthRequired bool + contextInvalid bool + invalidReadinessVersion string + lastInboundAt *time.Time + lastOutboundAt *time.Time + lastError string + lastErrorAt *time.Time +} + +type accountRuntime struct { + ownerID string + generation uint64 + cancel context.CancelFunc + done chan struct{} + barrier sync.Mutex +} + +type loginSession struct { + id string + ownerID string + generation uint64 + challenge LoginChallenge + state LoginState + message string + polling bool +} + +type notificationJob struct { + waiting chatevents.WaitingTurn +} + +type Service struct { + Store AccountStore + Pending PendingLookup + Control Controller + MasterKey string + Logger *zap.Logger + + providers map[string]Provider + + mu sync.Mutex + lifeCtx context.Context + running bool + accounts map[string]Account + runtimes map[string]*accountRuntime + statuses map[string]*runtimeStatus + accountGen map[string]uint64 + loginGen map[string]uint64 + logins map[string]*loginSession + loginByOwner map[string]string + ownerOps sync.Map + selected map[string]string + latestWaiting map[string]chatevents.WaitingTurn + notification map[string]notificationJob + notifyInFlight map[string]bool + notifyWake chan struct{} +} + +func NewService(store AccountStore, pending PendingLookup, control Controller, masterKey string, logger *zap.Logger, providers ...Provider) *Service { + if logger == nil { + logger = zap.NewNop() + } + s := &Service{ + Store: store, Pending: pending, Control: control, MasterKey: strings.TrimSpace(masterKey), Logger: logger, + providers: make(map[string]Provider), accounts: make(map[string]Account), runtimes: make(map[string]*accountRuntime), + statuses: make(map[string]*runtimeStatus), accountGen: make(map[string]uint64), loginGen: make(map[string]uint64), + logins: make(map[string]*loginSession), loginByOwner: make(map[string]string), selected: make(map[string]string), + latestWaiting: make(map[string]chatevents.WaitingTurn), notification: make(map[string]notificationJob), + notifyInFlight: make(map[string]bool), notifyWake: make(chan struct{}, 1), + } + for _, provider := range providers { + if provider != nil && strings.TrimSpace(provider.ID()) != "" { + s.providers[provider.ID()] = provider + } + } + return s +} + +func (s *Service) Run(ctx context.Context) error { + if s == nil { + return nil + } + s.mu.Lock() + if s.running { + s.mu.Unlock() + return errors.New("IM service is already running") + } + runCtx, cancel := context.WithCancel(ctx) + s.running = true + s.lifeCtx = runCtx + s.mu.Unlock() + defer cancel() + + if err := s.restoreAccounts(runCtx); err != nil { + s.Logger.Warn("restore IM accounts failed", zap.Error(err)) + } + var workers sync.WaitGroup + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + s.notificationWorker(runCtx) + }() + } + <-runCtx.Done() + + s.mu.Lock() + runtimes := make([]*accountRuntime, 0, len(s.runtimes)) + for ownerID, runtime := range s.runtimes { + s.accountGen[ownerID]++ + delete(s.runtimes, ownerID) + runtimes = append(runtimes, runtime) + } + s.running = false + s.lifeCtx = nil + s.mu.Unlock() + for _, runtime := range runtimes { + s.stopRuntime(runtime) + } + workers.Wait() + return nil +} + +func (s *Service) GetStatus(ctx context.Context, ownerID string) (ConnectionStatus, error) { + if err := s.requireActiveOwner(ctx, ownerID); err != nil { + return ConnectionStatus{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.statusLocked(strings.TrimSpace(ownerID)), nil +} + +func (s *Service) BeginLogin(ctx context.Context, ownerID, providerID string) (LoginView, error) { + ownerID = strings.TrimSpace(ownerID) + if err := s.requireActiveOwner(ctx, ownerID); err != nil { + return LoginView{}, err + } + provider := s.providers[strings.TrimSpace(providerID)] + if provider == nil { + return LoginView{}, errors.New("unsupported IM provider") + } + op := s.ownerOperation(ownerID) + op.Lock() + defer op.Unlock() + s.mu.Lock() + if existingID := s.loginByOwner[ownerID]; existingID != "" { + if existing := s.logins[existingID]; existing != nil && time.Now().Before(existing.challenge.ExpiresAt) && (existing.state == LoginWaiting || existing.state == LoginScanned || existing.state == LoginVerifyNeeded) { + view := s.loginViewLocked(existing) + s.mu.Unlock() + return view, nil + } + } + var existingAccount *Account + if account, ok := s.accounts[ownerID]; ok && account.Provider == provider.ID() { + copy := account + existingAccount = © + } + s.mu.Unlock() + challenge, err := provider.StartLogin(ctx, existingAccount) + if err != nil { + return LoginView{}, err + } + + s.mu.Lock() + s.loginGen[ownerID]++ + generation := s.loginGen[ownerID] + if oldID := s.loginByOwner[ownerID]; oldID != "" { + delete(s.logins, oldID) + } + session := &loginSession{ + id: uuid.NewString(), ownerID: ownerID, generation: generation, challenge: challenge, + state: LoginWaiting, message: "等待微信扫码确认", + } + s.logins[session.id] = session + s.loginByOwner[ownerID] = session.id + view := s.loginViewLocked(session) + s.mu.Unlock() + return view, nil +} + +func (s *Service) PollLogin(ctx context.Context, ownerID, sessionID, verifyCode string) (LoginView, error) { + ownerID = strings.TrimSpace(ownerID) + sessionID = strings.TrimSpace(sessionID) + if err := s.requireActiveOwner(ctx, ownerID); err != nil { + return LoginView{}, err + } + s.mu.Lock() + session := s.logins[sessionID] + if session == nil || session.ownerID != ownerID || time.Now().After(session.challenge.ExpiresAt) { + s.mu.Unlock() + return LoginView{}, ErrLoginNotFound + } + if session.polling { + s.mu.Unlock() + return LoginView{}, ErrLoginBusy + } + session.polling = true + generation := session.generation + challenge := session.challenge + provider := s.providers[challenge.Provider] + s.mu.Unlock() + if provider == nil { + s.clearLoginPolling(sessionID, generation) + return LoginView{}, errors.New("IM provider is unavailable") + } + + result, err := provider.PollLogin(ctx, challenge, verifyCode) + if err != nil { + s.clearLoginPolling(sessionID, generation) + return LoginView{}, err + } + if result.State == LoginAlreadyBound { + op := s.ownerOperation(ownerID) + op.Lock() + s.mu.Lock() + current := s.logins[sessionID] + if current == nil || current.ownerID != ownerID || current.generation != generation || s.loginGen[ownerID] != generation { + s.mu.Unlock() + op.Unlock() + return LoginView{}, ErrLoginNotFound + } + status := s.statusLocked(ownerID) + if status.Connected { + delete(s.logins, sessionID) + delete(s.loginByOwner, ownerID) + s.mu.Unlock() + op.Unlock() + return LoginView{State: LoginConnected, Message: result.Message, ExpiresAt: result.Challenge.ExpiresAt, Status: &status}, nil + } + s.mu.Unlock() + op.Unlock() + } + if result.State != LoginConnected || result.Account == nil { + s.mu.Lock() + current := s.logins[sessionID] + if current == nil || current.ownerID != ownerID || current.generation != generation || s.loginGen[ownerID] != generation { + s.mu.Unlock() + return LoginView{}, ErrLoginNotFound + } + current.polling = false + current.challenge = result.Challenge + current.state = result.State + current.message = result.Message + view := s.loginViewLocked(current) + s.mu.Unlock() + return view, nil + } + + op := s.ownerOperation(ownerID) + op.Lock() + defer op.Unlock() + s.mu.Lock() + current := s.logins[sessionID] + if current == nil || current.ownerID != ownerID || current.generation != generation || s.loginGen[ownerID] != generation { + s.mu.Unlock() + return LoginView{}, ErrLoginNotFound + } + delete(s.logins, sessionID) + delete(s.loginByOwner, ownerID) + account := *result.Account + account.OwnerID = ownerID + s.mu.Unlock() + + if err := s.replaceAccount(ctx, account); err != nil { + return LoginView{}, err + } + status, err := s.GetStatus(ctx, ownerID) + if err != nil { + return LoginView{}, err + } + return LoginView{State: LoginConnected, Message: result.Message, ExpiresAt: result.Challenge.ExpiresAt, Status: &status}, nil +} + +func (s *Service) RevokeOwner(ctx context.Context, ownerID string) error { + return s.Disconnect(ctx, ownerID) +} + +func (s *Service) Disconnect(ctx context.Context, ownerID string) error { + ownerID = strings.TrimSpace(ownerID) + if ownerID == "" { + return ErrConnectionNotFound + } + op := s.ownerOperation(ownerID) + op.Lock() + defer op.Unlock() + + s.mu.Lock() + s.loginGen[ownerID]++ + if sessionID := s.loginByOwner[ownerID]; sessionID != "" { + delete(s.logins, sessionID) + delete(s.loginByOwner, ownerID) + } + oldAccount, hadAccount, runtime := s.detachOwnerLocked(ownerID) + s.mu.Unlock() + s.stopRuntime(runtime) + + err := s.Store.DeleteUserConfig(ctx, ownerID, accountConfigKey) + if err == nil || errors.Is(err, common.ErrNotFound) { + return nil + } + if hadAccount { + s.mu.Lock() + s.accounts[ownerID] = oldAccount + s.statuses[ownerID] = &runtimeStatus{workerState: "starting", lastError: "disconnect failed"} + s.startRuntimeLocked(oldAccount) + s.mu.Unlock() + } + return fmt.Errorf("delete IM connection: %w", err) +} + +func (s *Service) HandleChatEvent(_ context.Context, event chatevents.Event) { + if s == nil || event.Type != chatevents.TypeTurnWaiting || event.WaitingTurn == nil { + return + } + waiting := *event.WaitingTurn + waiting.OwnerID = strings.TrimSpace(waiting.OwnerID) + if waiting.OwnerID == "" || strings.TrimSpace(waiting.ConversationID) == "" || strings.TrimSpace(waiting.RequestID) == "" { + return + } + s.mu.Lock() + s.latestWaiting[waiting.OwnerID] = waiting + s.notification[waiting.OwnerID] = notificationJob{waiting: waiting} + s.signalNotificationLocked() + s.mu.Unlock() +} + +func (s *Service) replaceAccount(ctx context.Context, account Account) error { + ownerID := strings.TrimSpace(account.OwnerID) + if ownerID == "" { + return errors.New("IM account owner is required") + } + s.mu.Lock() + oldAccount, hadOld, runtime := s.detachOwnerLocked(ownerID) + s.mu.Unlock() + s.stopRuntime(runtime) + + if err := saveAccount(ctx, s.Store, s.MasterKey, account); err != nil { + if hadOld { + s.mu.Lock() + s.accounts[ownerID] = oldAccount + s.statuses[ownerID] = &runtimeStatus{workerState: "starting"} + s.startRuntimeLocked(oldAccount) + s.mu.Unlock() + } + return err + } + s.mu.Lock() + s.accounts[ownerID] = account + s.statuses[ownerID] = &runtimeStatus{workerState: "starting"} + s.startRuntimeLocked(account) + s.mu.Unlock() + return nil +} + +func (s *Service) restoreAccounts(ctx context.Context) error { + users, err := s.Store.ListUsers(ctx) + if err != nil { + return err + } + for _, user := range users { + ownerID := strings.TrimSpace(user.ID) + if !user.IsActive || ownerID == "" { + continue + } + op := s.ownerOperation(ownerID) + op.Lock() + currentUser, userErr := s.Store.GetUser(ctx, ownerID) + if userErr != nil || !currentUser.IsActive { + op.Unlock() + continue + } + account, loadErr := loadAccount(ctx, s.Store, s.MasterKey, ownerID) + if errors.Is(loadErr, common.ErrNotFound) { + op.Unlock() + continue + } + if loadErr != nil { + s.Logger.Warn("restore IM account failed", zap.String("owner_id", ownerID), zap.Error(loadErr)) + op.Unlock() + continue + } + if s.providers[account.Provider] == nil { + s.Logger.Warn("restore IM account skipped: provider unavailable", zap.String("owner_id", ownerID), zap.String("provider", account.Provider)) + op.Unlock() + continue + } + s.mu.Lock() + if !s.running || s.lifeCtx == nil || s.accounts[ownerID].OwnerID != "" || s.runtimes[ownerID] != nil { + s.mu.Unlock() + op.Unlock() + continue + } + s.accounts[ownerID] = account + s.statuses[ownerID] = &runtimeStatus{workerState: "starting"} + s.startRuntimeLocked(account) + s.mu.Unlock() + op.Unlock() + } + return nil +} + +func (s *Service) startRuntimeLocked(account Account) { + if !s.running || s.lifeCtx == nil || s.providers[account.Provider] == nil { + return + } + ownerID := account.OwnerID + if s.runtimes[ownerID] != nil { + s.Logger.Warn("refusing to overwrite an active IM runtime", zap.String("owner_id", ownerID), zap.String("provider", account.Provider)) + return + } + s.accountGen[ownerID]++ + generation := s.accountGen[ownerID] + runCtx, cancel := context.WithCancel(s.lifeCtx) + runtime := &accountRuntime{ownerID: ownerID, generation: generation, cancel: cancel, done: make(chan struct{})} + s.runtimes[ownerID] = runtime + provider := s.providers[account.Provider] + status := s.statuses[ownerID] + if status == nil { + status = &runtimeStatus{} + s.statuses[ownerID] = status + } + status.workerState = "running" + go func() { + err := provider.Run(runCtx, account, ProviderCallbacks{ + HandleInbound: func(ctx context.Context, inbound InboundMessage) error { + runtime.barrier.Lock() + defer runtime.barrier.Unlock() + if !s.runtimeCurrent(ownerID, generation, runtime) { + return context.Canceled + } + return s.handleInbound(ctx, runtime, inbound) + }, + Checkpoint: func(ctx context.Context, state json.RawMessage) error { + runtime.barrier.Lock() + defer runtime.barrier.Unlock() + return s.checkpoint(ctx, ownerID, generation, runtime, state) + }, + ReportError: func(err error) { s.recordRuntimeError(ownerID, generation, runtime, err) }, + }) + s.runtimeExited(ownerID, generation, runtime, err) + close(runtime.done) + }() +} + +// checkpoint is called while runtime.barrier is held. Disconnect/replace first +// invalidate the generation, then wait on that barrier before deleting or +// replacing the stored account. Therefore an old save may finish before the +// delete, but can never commit after a successful disconnect returns. +func (s *Service) checkpoint(ctx context.Context, ownerID string, generation uint64, runtime *accountRuntime, state []byte) error { + s.mu.Lock() + if s.runtimes[ownerID] != runtime || s.accountGen[ownerID] != generation { + s.mu.Unlock() + return context.Canceled + } + account := s.accounts[ownerID] + provider := s.providers[account.Provider] + status := s.statuses[ownerID] + wasReady := provider != nil && provider.Ready(account) && (status == nil || !status.contextInvalid) + account.State = append(json.RawMessage(nil), state...) + s.mu.Unlock() + if err := saveAccount(ctx, s.Store, s.MasterKey, account); err != nil { + return err + } + s.mu.Lock() + if s.runtimes[ownerID] != runtime || s.accountGen[ownerID] != generation { + s.mu.Unlock() + return context.Canceled + } + s.accounts[ownerID] = account + if status != nil && status.contextInvalid { + candidateVersion := "" + if provider != nil { + candidateVersion = provider.ReadinessVersion(account) + } + if candidateVersion != "" && candidateVersion != status.invalidReadinessVersion { + status.contextInvalid = false + status.invalidReadinessVersion = "" + } + } + isReady := provider != nil && provider.Ready(account) && (status == nil || !status.contextInvalid) + if latest, ok := s.latestWaiting[ownerID]; ok && !wasReady && isReady { + s.notification[ownerID] = notificationJob{waiting: latest} + s.signalNotificationLocked() + } + s.mu.Unlock() + return nil +} + +func (s *Service) runtimeCurrent(ownerID string, generation uint64, runtime *accountRuntime) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.runtimes[ownerID] == runtime && s.accountGen[ownerID] == generation +} + +func (s *Service) runtimeExited(ownerID string, generation uint64, runtime *accountRuntime, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.runtimes[ownerID] != runtime || s.accountGen[ownerID] != generation { + return + } + status := s.statuses[ownerID] + if status == nil { + status = &runtimeStatus{} + s.statuses[ownerID] = status + } + if err == nil || errors.Is(err, context.Canceled) { + status.workerState = "stopped" + return + } + status.workerState = "error" + status.lastError = "微信连接已停止,请重新连接" + now := time.Now().UTC() + status.lastErrorAt = &now + if errors.Is(err, ErrReauthRequired) { + status.reauthRequired = true + } + s.Logger.Warn("IM provider stopped", zap.String("owner_id", ownerID), zap.String("provider", s.accounts[ownerID].Provider), zap.Error(err)) +} + +func (s *Service) recordRuntimeError(ownerID string, generation uint64, runtime *accountRuntime, err error) { + if err == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.runtimes[ownerID] != runtime || s.accountGen[ownerID] != generation { + return + } + status := s.statuses[ownerID] + if status == nil { + status = &runtimeStatus{} + s.statuses[ownerID] = status + } + status.lastError = "微信网络请求失败,服务会自动重试" + now := time.Now().UTC() + status.lastErrorAt = &now +} + +func (s *Service) detachOwnerLocked(ownerID string) (Account, bool, *accountRuntime) { + s.accountGen[ownerID]++ + account, ok := s.accounts[ownerID] + runtime := s.runtimes[ownerID] + delete(s.accounts, ownerID) + delete(s.runtimes, ownerID) + delete(s.statuses, ownerID) + delete(s.selected, ownerID) + delete(s.notification, ownerID) + delete(s.latestWaiting, ownerID) + return account, ok, runtime +} + +func (s *Service) stopRuntime(runtime *accountRuntime) { + if runtime == nil { + return + } + runtime.cancel() + runtime.barrier.Lock() + runtime.barrier.Unlock() + waitRuntime(runtime, s.Logger) +} + +func waitRuntime(runtime *accountRuntime, logger *zap.Logger) { + if runtime == nil { + return + } + select { + case <-runtime.done: + case <-time.After(8 * time.Second): + logger.Warn("IM provider stop timed out", zap.String("owner_id", runtime.ownerID)) + } +} + +func (s *Service) statusLocked(ownerID string) ConnectionStatus { + account, connected := s.accounts[ownerID] + status := ConnectionStatus{Provider: ProviderClawBot, Connected: connected, WorkerState: "disconnected"} + if !connected { + return status + } + status.Provider = account.Provider + status.ExternalBotID = account.ExternalBotID + connectedAt := account.ConnectedAt.UTC() + status.ConnectedAt = &connectedAt + if provider := s.providers[account.Provider]; provider != nil { + status.Ready = provider.Ready(account) + } + if runtimeStatus := s.statuses[ownerID]; runtimeStatus != nil { + status.Ready = status.Ready && !runtimeStatus.contextInvalid + status.WorkerState = runtimeStatus.workerState + status.ReauthRequired = runtimeStatus.reauthRequired + status.LastInboundAt = runtimeStatus.lastInboundAt + status.LastOutboundAt = runtimeStatus.lastOutboundAt + status.LastError = runtimeStatus.lastError + status.LastErrorAt = runtimeStatus.lastErrorAt + } + return status +} + +func (s *Service) loginViewLocked(session *loginSession) LoginView { + view := LoginView{ + SessionID: session.id, State: session.state, Message: session.message, + QRCodeURL: session.challenge.QRCodeURL, ExpiresAt: session.challenge.ExpiresAt, + } + status := s.statusLocked(session.ownerID) + view.Status = &status + return view +} + +func (s *Service) clearLoginPolling(sessionID string, generation uint64) { + s.mu.Lock() + defer s.mu.Unlock() + if session := s.logins[sessionID]; session != nil && session.generation == generation { + session.polling = false + } +} + +func (s *Service) ownerOperation(ownerID string) *sync.Mutex { + value, _ := s.ownerOps.LoadOrStore(ownerID, &sync.Mutex{}) + return value.(*sync.Mutex) +} + +func (s *Service) requireActiveOwner(ctx context.Context, ownerID string) error { + ownerID = strings.TrimSpace(ownerID) + if ownerID == "" || s.Store == nil { + return ErrOwnerInactive + } + user, err := s.Store.GetUser(ctx, ownerID) + if err != nil { + return ErrOwnerInactive + } + if !user.IsActive { + return ErrOwnerInactive + } + return nil +} + +func (s *Service) signalNotificationLocked() { + select { + case s.notifyWake <- struct{}{}: + default: + } +} + +func sortedPending(items []*turnsvc.PendingTurn) []*turnsvc.PendingTurn { + filtered := make([]*turnsvc.PendingTurn, 0, len(items)) + for _, item := range items { + if item != nil && strings.TrimSpace(item.ConversationID) != "" && strings.TrimSpace(item.RequestID) != "" { + filtered = append(filtered, item) + } + } + sort.SliceStable(filtered, func(i, j int) bool { return filtered[i].CreatedAt.Before(filtered[j].CreatedAt) }) + return filtered +} diff --git a/backend/internal/service/im/service_test.go b/backend/internal/service/im/service_test.go new file mode 100644 index 0000000..fd1e66f --- /dev/null +++ b/backend/internal/service/im/service_test.go @@ -0,0 +1,745 @@ +package im + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/zyf2007/ChatAPI/internal/actor" + "github.com/zyf2007/ChatAPI/internal/repository/common" + controlsvc "github.com/zyf2007/ChatAPI/internal/service/chat/control" + chatevents "github.com/zyf2007/ChatAPI/internal/service/chat/events" + turnsvc "github.com/zyf2007/ChatAPI/internal/service/chat/turn" +) + +const testMasterKey = "test-master-key-for-im-account-encryption" + +func TestSplitCommandAndPendingSelection(t *testing.T) { + command, argument := splitCommand("/abort\noperator requested") + if command != "/abort" || argument != "operator requested" { + t.Fatalf("command=%q argument=%q", command, argument) + } + pending := &fakePending{} + pending.set( + &turnsvc.PendingTurn{OwnerID: "owner-1", ConversationID: "aaaa-1111", RequestID: "req-a", Model: "a", CreatedAt: time.Unix(1, 0)}, + &turnsvc.PendingTurn{OwnerID: "owner-1", ConversationID: "bbbb-2222", RequestID: "req-b", Model: "b", CreatedAt: time.Unix(2, 0)}, + ) + service := NewService(newFakeStore(), pending, nil, testMasterKey, zap.NewNop()) + current, err := service.currentPending("owner-1") + if err != nil || current.ConversationID != "bbbb-2222" { + t.Fatalf("current=%#v err=%v", current, err) + } + selected, err := service.selectPending("owner-1", "aaaa") + if err != nil || selected.ConversationID != "aaaa-1111" { + t.Fatalf("selected=%#v err=%v", selected, err) + } +} + +func TestDisconnectWaitsForOldCheckpointBeforeDeletingConfig(t *testing.T) { + store := newFakeStore() + store.setConfigStarted = make(chan struct{}) + store.setConfigBlock = make(chan struct{}) + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + provider := newFakeProvider() + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot-1", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":false}`), ConnectedAt: time.Now().UTC(), + } + done := make(chan struct{}) + close(done) + runtime := &accountRuntime{ownerID: "owner-1", generation: 1, cancel: func() {}, done: done} + service.accounts["owner-1"] = account + service.runtimes["owner-1"] = runtime + service.accountGen["owner-1"] = 1 + checkpointDone := make(chan error, 1) + go func() { + runtime.barrier.Lock() + defer runtime.barrier.Unlock() + checkpointDone <- service.checkpoint(context.Background(), "owner-1", 1, runtime, json.RawMessage(`{"ready":true}`)) + }() + <-store.setConfigStarted + disconnectDone := make(chan error, 1) + go func() { disconnectDone <- service.Disconnect(context.Background(), "owner-1") }() + waitFor(t, time.Second, func() bool { + service.mu.Lock() + defer service.mu.Unlock() + return service.runtimes["owner-1"] == nil + }) + select { + case err := <-disconnectDone: + t.Fatalf("disconnect returned before checkpoint save completed: %v", err) + case <-time.After(30 * time.Millisecond): + } + if store.wasDeleteCalled() { + t.Fatal("disconnect deleted config while old checkpoint could still write") + } + close(store.setConfigBlock) + if err := <-checkpointDone; !errors.Is(err, context.Canceled) { + t.Fatalf("checkpoint error = %v", err) + } + if err := <-disconnectDone; err != nil { + t.Fatal(err) + } + if _, err := store.GetUserConfig(context.Background(), "owner-1", accountConfigKey); !errors.Is(err, common.ErrNotFound) { + t.Fatalf("checkpoint resurrected deleted config: %v", err) + } +} + +func TestCheckpointFailureKeepsPreviousInMemoryState(t *testing.T) { + store := newFakeStore() + store.setConfigErr = errors.New("write failed") + provider := newFakeProvider() + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot-1", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":false}`), ConnectedAt: time.Now().UTC(), + } + runtime := &accountRuntime{ownerID: "owner-1", generation: 1, done: make(chan struct{})} + service.accounts["owner-1"] = account + service.runtimes["owner-1"] = runtime + service.accountGen["owner-1"] = 1 + if err := service.checkpoint(context.Background(), "owner-1", 1, runtime, json.RawMessage(`{"ready":true}`)); err == nil { + t.Fatal("checkpoint should fail") + } + if string(service.accounts["owner-1"].State) != `{"ready":false}` { + t.Fatalf("state advanced after persistence failure: %s", service.accounts["owner-1"].State) + } +} + +func TestCheckpointRequeuesWaitingOnlyOnFreshContextTransition(t *testing.T) { + store := newFakeStore() + provider := newFakeProvider() + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot-1", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":true,"context":"stale","context_generation":1}`), ConnectedAt: time.Now().UTC(), + } + runtime := &accountRuntime{ownerID: "owner-1", generation: 1, done: make(chan struct{})} + service.accounts["owner-1"] = account + service.runtimes["owner-1"] = runtime + service.statuses["owner-1"] = &runtimeStatus{workerState: "running", contextInvalid: true, invalidReadinessVersion: "1"} + service.accountGen["owner-1"] = 1 + service.latestWaiting["owner-1"] = chatevents.WaitingTurn{OwnerID: "owner-1", ConversationID: "conv", RequestID: "req"} + if service.statusLocked("owner-1").Ready { + t.Fatal("invalidated context should not report ready") + } + if err := service.checkpoint(context.Background(), "owner-1", 1, runtime, json.RawMessage(`{"ready":true,"context":"stale","context_generation":1,"cursor":"next"}`)); err != nil { + t.Fatal(err) + } + if service.statusLocked("owner-1").Ready { + t.Fatal("cursor-only checkpoint restored an invalid context") + } + if _, ok := service.notification["owner-1"]; ok { + t.Fatal("cursor-only checkpoint requeued waiting notification") + } + if err := service.checkpoint(context.Background(), "owner-1", 1, runtime, json.RawMessage(`{"ready":true,"context":"fresh","context_generation":2,"cursor":"next"}`)); err != nil { + t.Fatal(err) + } + if _, ok := service.notification["owner-1"]; !ok { + t.Fatal("fresh context transition did not requeue waiting notification") + } + if !service.statusLocked("owner-1").Ready { + t.Fatal("fresh context checkpoint did not restore ready status") + } + delete(service.notification, "owner-1") + if err := service.checkpoint(context.Background(), "owner-1", 1, runtime, json.RawMessage(`{"ready":true,"context":"fresh","context_generation":2,"cursor":"later"}`)); err != nil { + t.Fatal(err) + } + if _, ok := service.notification["owner-1"]; ok { + t.Fatal("ready-to-ready checkpoint requeued waiting notification") + } +} + +func TestQueuedNotificationDoesNotChangeVisibleSelection(t *testing.T) { + service := NewService(newFakeStore(), &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop()) + service.selected["owner-1"] = "visible-conversation" + service.HandleChatEvent(context.Background(), chatevents.Event{Type: chatevents.TypeTurnWaiting, WaitingTurn: &chatevents.WaitingTurn{ + OwnerID: "owner-1", ConversationID: "queued-conversation", RequestID: "queued-request", + }}) + if service.selected["owner-1"] != "visible-conversation" { + t.Fatalf("queued notification changed selected request: %q", service.selected["owner-1"]) + } +} + +func TestFailedNotificationMarksItsContextBeforeFreshCheckpoint(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + pending := &fakePending{} + pending.set(&turnsvc.PendingTurn{OwnerID: "owner-1", ConversationID: "conv", RequestID: "req", CreatedAt: time.Now()}) + provider := newFakeProvider() + provider.sendStarted = make(chan struct{}) + provider.sendBlock = make(chan struct{}) + provider.sendErr = ErrProviderNotReady + service := NewService(store, pending, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":true,"context_generation":4}`), ConnectedAt: time.Now().UTC(), + } + runtime := &accountRuntime{ownerID: "owner-1", generation: 1, done: make(chan struct{})} + service.accounts["owner-1"] = account + service.runtimes["owner-1"] = runtime + service.statuses["owner-1"] = &runtimeStatus{workerState: "running"} + service.accountGen["owner-1"] = 1 + notificationDone := make(chan struct{}) + go func() { + service.sendWaitingNotification(context.Background(), "owner-1", chatevents.WaitingTurn{ + OwnerID: "owner-1", ConversationID: "conv", RequestID: "req", Model: "gpt-test", LastUserText: "question", + }) + close(notificationDone) + }() + <-provider.sendStarted + checkpointReady := make(chan struct{}) + checkpointDone := make(chan error, 1) + go func() { + close(checkpointReady) + runtime.barrier.Lock() + defer runtime.barrier.Unlock() + checkpointDone <- service.checkpoint(context.Background(), "owner-1", 1, runtime, json.RawMessage(`{"ready":true,"context_generation":5}`)) + }() + <-checkpointReady + time.Sleep(10 * time.Millisecond) + close(provider.sendBlock) + <-notificationDone + if err := <-checkpointDone; err != nil { + t.Fatal(err) + } + if !service.statusLocked("owner-1").Ready { + t.Fatalf("fresh checkpoint was invalidated by older send failure: %+v", service.statusLocked("owner-1")) + } +} + +func TestSuccessfulNotificationSelectsItsPendingRequest(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + pending := &fakePending{} + pending.set( + &turnsvc.PendingTurn{OwnerID: "owner-1", ConversationID: "old-conversation", RequestID: "old-request", CreatedAt: time.Unix(1, 0)}, + &turnsvc.PendingTurn{OwnerID: "owner-1", ConversationID: "new-conversation", RequestID: "new-request", CreatedAt: time.Unix(2, 0)}, + ) + provider := newFakeProvider() + service := NewService(store, pending, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":true}`), ConnectedAt: time.Now().UTC(), + } + runtime := &accountRuntime{ownerID: "owner-1", generation: 1, done: make(chan struct{})} + service.accounts["owner-1"] = account + service.runtimes["owner-1"] = runtime + service.statuses["owner-1"] = &runtimeStatus{workerState: "running"} + service.accountGen["owner-1"] = 1 + service.selected["owner-1"] = "old-conversation" + service.sendWaitingNotification(context.Background(), "owner-1", chatevents.WaitingTurn{ + OwnerID: "owner-1", ConversationID: "new-conversation", RequestID: "new-request", Model: "gpt-test", LastUserText: "new question", + }) + if service.selected["owner-1"] != "new-conversation" { + t.Fatalf("selected = %q", service.selected["owner-1"]) + } +} + +func TestServiceConnectNotifyReplyAndDisconnect(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + pending := &fakePending{} + controller := &fakeController{pending: pending, commands: make(chan controlsvc.Command, 8)} + provider := newFakeProvider() + service := NewService(store, pending, controller, testMasterKey, zap.NewNop(), provider) + + runCtx, cancelRun := context.WithCancel(context.Background()) + runDone := make(chan error, 1) + go func() { runDone <- service.Run(runCtx) }() + waitFor(t, time.Second, func() bool { + service.mu.Lock() + defer service.mu.Unlock() + return service.running + }) + + login, err := service.BeginLogin(context.Background(), "owner-1", ProviderClawBot) + if err != nil { + t.Fatal(err) + } + connected, err := service.PollLogin(context.Background(), "owner-1", login.SessionID, "") + if err != nil { + t.Fatal(err) + } + if connected.State != LoginConnected || connected.Status == nil || !connected.Status.Ready { + t.Fatalf("connected = %#v", connected) + } + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("provider did not start") + } + + stored := store.configValue("owner-1", accountConfigKey) + encoded, _ := json.Marshal(stored) + if strings.Contains(string(encoded), "plain-token") || strings.Contains(string(encoded), "plain-context") { + t.Fatalf("plaintext secret persisted: %s", encoded) + } + + turn := &turnsvc.PendingTurn{ + OwnerID: "owner-1", ConversationID: "conversation-1234", RequestID: "request-1234", + ResponseID: "response-1234", Model: "gpt-test", CreatedAt: time.Now().UTC(), + } + pending.set(turn) + service.HandleChatEvent(context.Background(), chatevents.Event{Type: chatevents.TypeTurnWaiting, WaitingTurn: &chatevents.WaitingTurn{ + OwnerID: turn.OwnerID, ConversationID: turn.ConversationID, RequestID: turn.RequestID, + ResponseID: turn.ResponseID, Model: turn.Model, LastUserText: "please answer", + }}) + notification := provider.nextSent(t) + if !strings.Contains(notification.Text, "ChatAPI 新请求") || notification.ClientID == "" { + t.Fatalf("notification = %#v", notification) + } + + provider.inbound <- InboundMessage{ID: "message-1", From: "wechat-owner", ContextToken: "context-new", Text: "final answer", Direct: true, Complete: true} + command := controller.nextCommand(t) + if command.OwnerID != "owner-1" || command.RequestID != turn.RequestID || command.Action.Kind != turnsvc.TurnControlStreamComplete || command.Action.OutputText != "final answer" { + t.Fatalf("command = %#v", command) + } + if got, ok := actor.FromContext(controller.lastContext()); !ok || got.UserID != "owner-1" || got.Source != "im" { + t.Fatalf("actor = %#v, ok=%v", got, ok) + } + ack := provider.nextSent(t) + if !strings.Contains(ack.Text, "已结束请求") || ack.ContextToken != "context-new" { + t.Fatalf("ack = %#v", ack) + } + + if err := service.Disconnect(context.Background(), "owner-1"); err != nil { + t.Fatal(err) + } + if _, err := store.GetUserConfig(context.Background(), "owner-1", accountConfigKey); !errors.Is(err, common.ErrNotFound) { + t.Fatalf("config survived disconnect: %v", err) + } + status, err := service.GetStatus(context.Background(), "owner-1") + if err != nil || status.Connected { + t.Fatalf("status = %#v, err=%v", status, err) + } + + cancelRun() + select { + case err := <-runDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("service did not stop") + } +} + +func TestServiceRestoresEncryptedAccount(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot-1", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":true,"context":"plain-context"}`), ConnectedAt: time.Now().UTC(), + } + if err := saveAccount(context.Background(), store, testMasterKey, account); err != nil { + t.Fatal(err) + } + provider := newFakeProvider() + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- service.Run(ctx) }() + select { + case <-provider.started: + case <-time.After(time.Second): + t.Fatal("restored provider did not start") + } + status, err := service.GetStatus(context.Background(), "owner-1") + if err != nil || !status.Connected || !status.Ready { + t.Fatalf("status = %#v, err=%v", status, err) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestRestoreCannotResurrectAfterConcurrentDisconnect(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + account := Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot-1", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":true}`), ConnectedAt: time.Now().UTC(), + } + if err := saveAccount(context.Background(), store, testMasterKey, account); err != nil { + t.Fatal(err) + } + store.getConfigStarted = make(chan struct{}) + store.getConfigBlock = make(chan struct{}) + provider := newFakeProvider() + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- service.Run(ctx) }() + <-store.getConfigStarted + disconnected := make(chan error, 1) + go func() { disconnected <- service.Disconnect(context.Background(), "owner-1") }() + close(store.getConfigBlock) + if err := <-disconnected; err != nil { + t.Fatal(err) + } + status, err := service.GetStatus(context.Background(), "owner-1") + if err != nil || status.Connected { + t.Fatalf("status = %#v, err=%v", status, err) + } + if _, err := store.GetUserConfig(context.Background(), "owner-1", accountConfigKey); !errors.Is(err, common.ErrNotFound) { + t.Fatalf("config survived restore/disconnect race: %v", err) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestAlreadyBoundUsesExistingOwnerConnection(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + provider := newFakeProvider() + provider.pollResult = &LoginPollResult{State: LoginAlreadyBound, Message: "already connected"} + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + service.accounts["owner-1"] = Account{ + Provider: ProviderClawBot, OwnerID: "owner-1", ExternalBotID: "bot", ExternalOwnerID: "wechat-owner", + Endpoint: "https://ilinkai.weixin.qq.com", Credentials: json.RawMessage(`{"token":"plain-token"}`), + State: json.RawMessage(`{"ready":true}`), ConnectedAt: time.Now().UTC(), + } + service.statuses["owner-1"] = &runtimeStatus{workerState: "running"} + login, err := service.BeginLogin(context.Background(), "owner-1", ProviderClawBot) + if err != nil { + t.Fatal(err) + } + result, err := service.PollLogin(context.Background(), "owner-1", login.SessionID, "") + if err != nil { + t.Fatal(err) + } + if result.State != LoginConnected || result.Status == nil || !result.Status.Connected || !provider.startExisting { + t.Fatalf("result = %#v, startExisting=%v", result, provider.startExisting) + } +} + +func TestServiceRejectsConcurrentOrInvalidatedLoginPoll(t *testing.T) { + store := newFakeStore() + store.users["owner-1"] = common.User{ID: "owner-1", IsActive: true} + provider := newFakeProvider() + provider.pollBlock = make(chan struct{}) + provider.pollStarted = make(chan struct{}) + service := NewService(store, &fakePending{}, &fakeController{commands: make(chan controlsvc.Command, 8)}, testMasterKey, zap.NewNop(), provider) + + login, err := service.BeginLogin(context.Background(), "owner-1", ProviderClawBot) + if err != nil { + t.Fatal(err) + } + firstDone := make(chan error, 1) + go func() { + _, err := service.PollLogin(context.Background(), "owner-1", login.SessionID, "") + firstDone <- err + }() + <-provider.pollStarted + if _, err := service.PollLogin(context.Background(), "owner-1", login.SessionID, ""); !errors.Is(err, ErrLoginBusy) { + t.Fatalf("second poll error = %v", err) + } + if err := service.Disconnect(context.Background(), "owner-1"); err != nil { + t.Fatal(err) + } + close(provider.pollBlock) + if err := <-firstDone; !errors.Is(err, ErrLoginNotFound) { + t.Fatalf("invalidated poll error = %v", err) + } + if _, err := store.GetUserConfig(context.Background(), "owner-1", accountConfigKey); !errors.Is(err, common.ErrNotFound) { + t.Fatalf("invalidated login persisted an account: %v", err) + } +} + +type fakeStore struct { + mu sync.Mutex + users map[string]common.User + configs map[string]common.UserConfig + getConfigStarted chan struct{} + getConfigBlock chan struct{} + getConfigOnce sync.Once + setConfigErr error + setConfigStarted chan struct{} + setConfigBlock chan struct{} + setConfigOnce sync.Once + deleteCalled bool +} + +func newFakeStore() *fakeStore { + return &fakeStore{users: make(map[string]common.User), configs: make(map[string]common.UserConfig)} +} + +func (s *fakeStore) ListUsers(context.Context) ([]common.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + items := make([]common.User, 0, len(s.users)) + for _, user := range s.users { + items = append(items, user) + } + return items, nil +} + +func (s *fakeStore) GetUser(_ context.Context, id string) (common.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + user, ok := s.users[id] + if !ok { + return common.User{}, common.ErrNotFound + } + return user, nil +} + +func (s *fakeStore) GetUserConfig(_ context.Context, userID, key string) (common.UserConfig, error) { + if s.getConfigStarted != nil { + s.getConfigOnce.Do(func() { close(s.getConfigStarted) }) + } + if s.getConfigBlock != nil { + <-s.getConfigBlock + } + s.mu.Lock() + defer s.mu.Unlock() + value, ok := s.configs[userID+"\x00"+key] + if !ok { + return common.UserConfig{}, common.ErrNotFound + } + return value, nil +} + +func (s *fakeStore) SetUserConfig(_ context.Context, input common.SetUserConfigInput) (common.UserConfig, error) { + if s.setConfigStarted != nil { + s.setConfigOnce.Do(func() { close(s.setConfigStarted) }) + } + if s.setConfigBlock != nil { + <-s.setConfigBlock + } + s.mu.Lock() + defer s.mu.Unlock() + if s.setConfigErr != nil { + return common.UserConfig{}, s.setConfigErr + } + value := common.UserConfig{UserID: input.UserID, Key: input.Key, Value: input.Value, UpdatedAt: time.Now().UTC()} + s.configs[input.UserID+"\x00"+input.Key] = value + return value, nil +} + +func (s *fakeStore) DeleteUserConfig(_ context.Context, userID, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.deleteCalled = true + mapKey := userID + "\x00" + key + if _, ok := s.configs[mapKey]; !ok { + return common.ErrNotFound + } + delete(s.configs, mapKey) + return nil +} + +func (s *fakeStore) wasDeleteCalled() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.deleteCalled +} + +func (s *fakeStore) configValue(userID, key string) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + return s.configs[userID+"\x00"+key].Value +} + +type fakePending struct { + mu sync.Mutex + items []*turnsvc.PendingTurn +} + +func (p *fakePending) ListByOwnerID(ownerID string) []*turnsvc.PendingTurn { + p.mu.Lock() + defer p.mu.Unlock() + var out []*turnsvc.PendingTurn + for _, item := range p.items { + if item.OwnerID == ownerID { + copy := *item + out = append(out, ©) + } + } + return out +} + +func (p *fakePending) set(items ...*turnsvc.PendingTurn) { + p.mu.Lock() + p.items = items + p.mu.Unlock() +} + +func (p *fakePending) remove(requestID string) { + p.mu.Lock() + defer p.mu.Unlock() + for i, item := range p.items { + if item.RequestID == requestID { + p.items = append(p.items[:i], p.items[i+1:]...) + return + } + } +} + +type fakeController struct { + pending *fakePending + commands chan controlsvc.Command + mu sync.Mutex + ctx context.Context +} + +func (c *fakeController) Execute(ctx context.Context, command controlsvc.Command) (controlsvc.Result, error) { + c.mu.Lock() + c.ctx = ctx + c.mu.Unlock() + c.commands <- command + if c.pending != nil { + c.pending.remove(command.RequestID) + } + return controlsvc.Result{}, nil +} + +func (c *fakeController) nextCommand(t *testing.T) controlsvc.Command { + t.Helper() + select { + case command := <-c.commands: + return command + case <-time.After(time.Second): + t.Fatal("control command not received") + return controlsvc.Command{} + } +} + +func (c *fakeController) lastContext() context.Context { + c.mu.Lock() + defer c.mu.Unlock() + return c.ctx +} + +type fakeProvider struct { + started chan struct{} + inbound chan InboundMessage + sent chan OutboundMessage + pollBlock chan struct{} + pollStarted chan struct{} + startOnce sync.Once + pollOnce sync.Once + pollResult *LoginPollResult + startExisting bool + sendBlock chan struct{} + sendStarted chan struct{} + sendOnce sync.Once + sendErr error +} + +func newFakeProvider() *fakeProvider { + return &fakeProvider{started: make(chan struct{}), inbound: make(chan InboundMessage, 8), sent: make(chan OutboundMessage, 16)} +} + +func (p *fakeProvider) ID() string { return ProviderClawBot } + +func (p *fakeProvider) StartLogin(_ context.Context, existing *Account) (LoginChallenge, error) { + p.startExisting = existing != nil + return LoginChallenge{Provider: p.ID(), Opaque: json.RawMessage(`{}`), QRCodeURL: "https://weixin.qq.com/x/test", ExpiresAt: time.Now().Add(time.Minute)}, nil +} + +func (p *fakeProvider) PollLogin(context.Context, LoginChallenge, string) (LoginPollResult, error) { + if p.pollStarted != nil { + p.pollOnce.Do(func() { close(p.pollStarted) }) + } + if p.pollBlock != nil { + <-p.pollBlock + } + if p.pollResult != nil { + return *p.pollResult, nil + } + return LoginPollResult{State: LoginConnected, Message: "connected", Account: &Account{ + Provider: p.ID(), ExternalBotID: "bot-1", ExternalOwnerID: "wechat-owner", Endpoint: "https://ilinkai.weixin.qq.com", + Credentials: json.RawMessage(`{"token":"plain-token"}`), State: json.RawMessage(`{"ready":true,"context":"plain-context"}`), ConnectedAt: time.Now().UTC(), + }}, nil +} + +func (p *fakeProvider) Run(ctx context.Context, _ Account, callbacks ProviderCallbacks) error { + p.startOnce.Do(func() { close(p.started) }) + for { + select { + case <-ctx.Done(): + if callbacks.Checkpoint != nil { + _ = callbacks.Checkpoint(context.Background(), json.RawMessage(`{"late":"plain-context"}`)) + } + return nil + case inbound := <-p.inbound: + if callbacks.HandleInbound != nil { + if err := callbacks.HandleInbound(ctx, inbound); err != nil { + return err + } + } + if callbacks.Checkpoint != nil { + if err := callbacks.Checkpoint(ctx, json.RawMessage(`{"ready":true,"context":"plain-context"}`)); err != nil { + return err + } + } + } + } +} + +func (p *fakeProvider) Send(_ context.Context, _ Account, outgoing OutboundMessage) error { + p.sent <- outgoing + if p.sendStarted != nil { + p.sendOnce.Do(func() { close(p.sendStarted) }) + } + if p.sendBlock != nil { + <-p.sendBlock + } + return p.sendErr +} + +func (p *fakeProvider) Ready(account Account) bool { + return strings.Contains(string(account.State), `"ready":true`) +} + +func (p *fakeProvider) ReadinessVersion(account Account) string { + var state struct { + ContextGeneration json.Number `json:"context_generation"` + } + if json.Unmarshal(account.State, &state) != nil { + return "" + } + return state.ContextGeneration.String() +} + +func (p *fakeProvider) nextSent(t *testing.T) OutboundMessage { + t.Helper() + select { + case sent := <-p.sent: + return sent + case <-time.After(time.Second): + t.Fatal("outbound message not sent") + return OutboundMessage{} + } +} + +func waitFor(t *testing.T, timeout time.Duration, predicate func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if predicate() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("condition not met before timeout") +} diff --git a/backend/internal/service/im/store.go b/backend/internal/service/im/store.go new file mode 100644 index 0000000..70c0b2f --- /dev/null +++ b/backend/internal/service/im/store.go @@ -0,0 +1,118 @@ +package im + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/zyf2007/ChatAPI/internal/platform/secretbox" + "github.com/zyf2007/ChatAPI/internal/repository/common" +) + +const accountConfigKey = "im.account.clawbot" + +var ErrConnectionNotFound = errors.New("IM connection not found") + +type AccountStore interface { + ListUsers(context.Context) ([]common.User, error) + GetUser(context.Context, string) (common.User, error) + GetUserConfig(context.Context, string, string) (common.UserConfig, error) + SetUserConfig(context.Context, common.SetUserConfigInput) (common.UserConfig, error) + DeleteUserConfig(context.Context, string, string) error +} + +type storedAccount struct { + Version int `json:"version"` + Provider string `json:"provider"` + ExternalBotID string `json:"external_bot_id"` + ExternalOwnerID string `json:"external_owner_id"` + Endpoint string `json:"endpoint"` + Secret string `json:"secret_ciphertext"` + ConnectedAt time.Time `json:"connected_at"` +} + +type accountSecret struct { + Credentials json.RawMessage `json:"credentials"` + State json.RawMessage `json:"state"` +} + +func saveAccount(ctx context.Context, store AccountStore, masterKey string, account Account) error { + if err := validateAccount(account); err != nil { + return err + } + secretJSON, err := json.Marshal(accountSecret{Credentials: account.Credentials, State: account.State}) + if err != nil { + return fmt.Errorf("encode IM account secret: %w", err) + } + if len(secretJSON) > 1<<20 { + return errors.New("IM account secret exceeds safety limit") + } + sealed, err := secretbox.Seal(string(secretJSON), masterKey) + if err != nil { + return fmt.Errorf("seal IM account secret: %w", err) + } + record := storedAccount{ + Version: 1, Provider: account.Provider, ExternalBotID: account.ExternalBotID, + ExternalOwnerID: account.ExternalOwnerID, Endpoint: account.Endpoint, + Secret: sealed, ConnectedAt: account.ConnectedAt.UTC(), + } + encoded, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("encode IM account: %w", err) + } + value := make(map[string]any) + if err := json.Unmarshal(encoded, &value); err != nil { + return fmt.Errorf("encode IM account value: %w", err) + } + _, err = store.SetUserConfig(ctx, common.SetUserConfigInput{UserID: account.OwnerID, Key: accountConfigKey, Value: value}) + if err != nil { + return fmt.Errorf("save IM account: %w", err) + } + return nil +} + +func loadAccount(ctx context.Context, store AccountStore, masterKey, ownerID string) (Account, error) { + record, err := store.GetUserConfig(ctx, strings.TrimSpace(ownerID), accountConfigKey) + if err != nil { + return Account{}, err + } + encoded, err := json.Marshal(record.Value) + if err != nil { + return Account{}, fmt.Errorf("decode IM account value: %w", err) + } + var stored storedAccount + if err := json.Unmarshal(encoded, &stored); err != nil || stored.Version != 1 || strings.TrimSpace(stored.Secret) == "" || len(stored.Secret) > 2<<20 { + return Account{}, errors.New("invalid stored IM account") + } + plaintext, err := secretbox.Open(stored.Secret, masterKey) + if err != nil { + return Account{}, fmt.Errorf("open IM account secret: %w", err) + } + var secret accountSecret + if err := json.Unmarshal([]byte(plaintext), &secret); err != nil { + return Account{}, errors.New("invalid stored IM account secret") + } + account := Account{ + Provider: strings.TrimSpace(stored.Provider), OwnerID: strings.TrimSpace(ownerID), + ExternalBotID: strings.TrimSpace(stored.ExternalBotID), ExternalOwnerID: strings.TrimSpace(stored.ExternalOwnerID), + Endpoint: strings.TrimSpace(stored.Endpoint), Credentials: secret.Credentials, State: secret.State, + ConnectedAt: stored.ConnectedAt.UTC(), + } + if err := validateAccount(account); err != nil { + return Account{}, errors.New("stored IM account is incomplete or exceeds safety limits") + } + return account, nil +} + +func validateAccount(account Account) error { + if strings.TrimSpace(account.Provider) == "" || strings.TrimSpace(account.OwnerID) == "" || strings.TrimSpace(account.ExternalBotID) == "" || strings.TrimSpace(account.ExternalOwnerID) == "" || strings.TrimSpace(account.Endpoint) == "" || len(account.Credentials) == 0 { + return errors.New("IM account is incomplete") + } + if len(account.Provider) > 64 || len(account.OwnerID) > 256 || len(account.ExternalBotID) > 512 || len(account.ExternalOwnerID) > 512 || len(account.Endpoint) > 2048 || len(account.Credentials)+len(account.State) > 1<<20 { + return errors.New("IM account exceeds safety limits") + } + return nil +} diff --git a/backend/internal/service/im/types.go b/backend/internal/service/im/types.go new file mode 100644 index 0000000..10c579c --- /dev/null +++ b/backend/internal/service/im/types.go @@ -0,0 +1,89 @@ +package im + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +var ( + ErrProviderNotReady = errors.New("IM provider is not ready") + ErrReauthRequired = errors.New("IM provider requires reauthentication") +) + +const ProviderClawBot = "clawbot" + +type LoginState string + +const ( + LoginWaiting LoginState = "waiting" + LoginScanned LoginState = "scanned" + LoginVerifyNeeded LoginState = "verify_required" + LoginVerifyBlocked LoginState = "verify_blocked" + LoginExpired LoginState = "expired" + LoginAlreadyBound LoginState = "already_bound" + LoginConnected LoginState = "connected" +) + +type LoginChallenge struct { + Provider string + Opaque json.RawMessage + QRCodeURL string + ExpiresAt time.Time +} + +type LoginPollResult struct { + State LoginState + Message string + Challenge LoginChallenge + Account *Account +} + +type Account struct { + Provider string + OwnerID string + ExternalBotID string + ExternalOwnerID string + Endpoint string + Credentials json.RawMessage + State json.RawMessage + ConnectedAt time.Time +} + +type InboundMessage struct { + ID string + Sequence int64 + From string + To string + ContextToken string + ReadinessVersion string + Text string + Direct bool + Complete bool +} + +type OutboundMessage struct { + To string + ContextToken string + Text string + ClientID string +} + +type ProviderCallbacks struct { + HandleInbound func(context.Context, InboundMessage) error + Checkpoint func(context.Context, json.RawMessage) error + ReportError func(error) +} + +type Provider interface { + ID() string + StartLogin(context.Context, *Account) (LoginChallenge, error) + PollLogin(context.Context, LoginChallenge, string) (LoginPollResult, error) + Run(context.Context, Account, ProviderCallbacks) error + Send(context.Context, Account, OutboundMessage) error + Ready(Account) bool + // ReadinessVersion changes only when the provider obtains a fresh reply + // context; cursor-only checkpoints must retain the same opaque value. + ReadinessVersion(Account) string +} diff --git a/frontend/src/App.css b/frontend/src/App.css index ae1fdfc..834bc17 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1205,6 +1205,54 @@ gap: 16px; } +.clawbot-settings { + display: grid; + gap: 14px; + max-width: 760px; +} + +.clawbot-settings h4.ant-typography { + margin: 0 0 6px; +} + +.clawbot-settings h4 .anticon { + margin-right: 7px; + color: #07c160; +} + +.clawbot-settings-alert { + margin-top: 0; +} + +.clawbot-connection-card, +.clawbot-login-card { + border-color: var(--app-border); + background: var(--app-bg-panel-strong); +} + +.clawbot-connection-card .ant-card-body { + display: grid; + gap: 14px; +} + +.clawbot-connection-meta, +.clawbot-command-help { + display: grid; + gap: 4px; +} + +.clawbot-command-help code { + white-space: nowrap; +} + +.clawbot-login-card .ant-card-body { + padding: 20px; +} + +.clawbot-verify-code { + width: min(100%, 360px); +} + .system-settings-rows { display: grid; gap: 10px; diff --git a/frontend/src/components/settings/ClawBotSettingsCard.tsx b/frontend/src/components/settings/ClawBotSettingsCard.tsx new file mode 100644 index 0000000..76abc46 --- /dev/null +++ b/frontend/src/components/settings/ClawBotSettingsCard.tsx @@ -0,0 +1,382 @@ +import { useEffect, useRef, useState } from 'react' +import { + Alert, + Button, + Card, + Flex, + Input, + Popconfirm, + QRCode, + Space, + Spin, + Tag, + Typography, +} from 'antd' +import { + DisconnectOutlined, + LinkOutlined, + ReloadOutlined, + WechatOutlined, +} from '@ant-design/icons' + +import { requestJson } from '../../lib/api' + +const CLAWBOT_DOCS = 'https://developers.weixin.qq.com/doc/aispeech/knowledge/openapi/Clawbotrelated.html' + +type ConnectionStatus = { + provider: string + connected: boolean + ready: boolean + worker_state: string + reauth_required: boolean + external_bot_id?: string + connected_at?: string + last_inbound_at?: string + last_outbound_at?: string + last_error?: string + last_error_at?: string +} + +type LoginState = + | 'waiting' + | 'scanned' + | 'verify_required' + | 'verify_blocked' + | 'expired' + | 'already_bound' + | 'connected' + +type LoginView = { + session_id: string + state: LoginState + message: string + qr_code_url?: string + expires_at: string + connection?: ConnectionStatus +} + +const DISCONNECTED_STATUS: ConnectionStatus = { + provider: 'clawbot', + connected: false, + ready: false, + worker_state: 'disconnected', + reauth_required: false, +} + +function shouldAutoPoll(login: LoginView | null): boolean { + return login?.state === 'waiting' || login?.state === 'scanned' +} + +function formatDate(value?: string): string { + if (!value) return '暂无' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '暂无' : date.toLocaleString() +} + +export function ClawBotSettingsCard({ open }: { open: boolean }) { + const [status, setStatus] = useState(null) + const [login, setLogin] = useState(null) + const [verifyCode, setVerifyCode] = useState('') + const [loading, setLoading] = useState(false) + const [connecting, setConnecting] = useState(false) + const [disconnecting, setDisconnecting] = useState(false) + const [error, setError] = useState('') + const pollGeneration = useRef(0) + const statusRequestVersion = useRef(0) + + useEffect(() => { + if (!open) return + const controller = new AbortController() + let timer: number | undefined + let initial = true + async function loadStatus() { + const requestVersion = ++statusRequestVersion.current + if (initial) setLoading(true) + try { + const next = await requestJson('/api/user/im/clawbot', { + signal: controller.signal, + }) + if (!controller.signal.aborted && requestVersion === statusRequestVersion.current) { + setStatus(next) + setError('') + } + } catch (loadError) { + if (!controller.signal.aborted && requestVersion === statusRequestVersion.current) { + setError(loadError instanceof Error ? loadError.message : '微信连接状态加载失败') + } + } finally { + if (!controller.signal.aborted) { + if (initial) setLoading(false) + initial = false + timer = window.setTimeout(() => void loadStatus(), 15_000) + } + } + } + void loadStatus() + return () => { + controller.abort() + if (timer !== undefined) window.clearTimeout(timer) + } + }, [open]) + + const loginSessionID = login?.session_id + const loginState = login?.state + const loginExpiresAt = login?.expires_at + + useEffect(() => { + if (!open || !loginSessionID || (loginState !== 'waiting' && loginState !== 'scanned')) return + const sessionID = loginSessionID + const generation = ++pollGeneration.current + const controller = new AbortController() + let timer: number | undefined + + async function poll() { + if (loginExpiresAt && Date.parse(loginExpiresAt) <= Date.now()) { + setLogin((current) => current ? { ...current, state: 'expired', message: '二维码已过期,请重新生成' } : current) + return + } + try { + const next = await requestJson( + `/api/user/im/clawbot/login/${encodeURIComponent(sessionID)}/poll`, + { method: 'POST', body: '{}', signal: controller.signal }, + ) + if (controller.signal.aborted || pollGeneration.current !== generation) return + setLogin(next) + setError('') + if ((next.state === 'connected' || next.state === 'already_bound') && next.connection?.connected) { + statusRequestVersion.current += 1 + setStatus(next.connection) + setLogin(null) + return + } + if (shouldAutoPoll(next)) timer = window.setTimeout(() => void poll(), 800) + } catch (pollError) { + if (controller.signal.aborted || pollGeneration.current !== generation) return + setError(pollError instanceof Error ? pollError.message : '二维码状态查询失败') + timer = window.setTimeout(() => void poll(), 2500) + } + } + + timer = window.setTimeout(() => void poll(), 300) + return () => { + controller.abort() + if (timer !== undefined) window.clearTimeout(timer) + } + }, [open, loginExpiresAt, loginSessionID, loginState]) + + async function startLogin() { + setConnecting(true) + setError('') + setVerifyCode('') + pollGeneration.current += 1 + try { + const next = await requestJson('/api/user/im/clawbot/login', { + method: 'POST', + body: '{}', + }) + setLogin(next) + } catch (connectError) { + setError(connectError instanceof Error ? connectError.message : '微信二维码创建失败') + } finally { + setConnecting(false) + } + } + + async function submitVerifyCode() { + if (!login?.session_id || !verifyCode.trim()) return + setConnecting(true) + setError('') + try { + const next = await requestJson( + `/api/user/im/clawbot/login/${encodeURIComponent(login.session_id)}/poll`, + { method: 'POST', body: JSON.stringify({ verify_code: verifyCode.trim() }) }, + ) + setLogin(next) + if ((next.state === 'connected' || next.state === 'already_bound') && next.connection?.connected) { + statusRequestVersion.current += 1 + setStatus(next.connection) + setLogin(null) + } + } catch (verifyError) { + setError(verifyError instanceof Error ? verifyError.message : '验证码提交失败') + } finally { + setConnecting(false) + } + } + + async function disconnect() { + setDisconnecting(true) + setError('') + pollGeneration.current += 1 + statusRequestVersion.current += 1 + try { + await requestJson('/api/user/im/clawbot', { method: 'DELETE' }) + setLogin(null) + setStatus(DISCONNECTED_STATUS) + setVerifyCode('') + } catch (disconnectError) { + setError(disconnectError instanceof Error ? disconnectError.message : '微信连接断开失败') + } finally { + setDisconnecting(false) + } + } + + const connected = Boolean(status?.connected) + const needsReconnect = Boolean( + status?.reauth_required + || status?.worker_state === 'reauth_required' + || status?.worker_state === 'error' + || status?.worker_state === 'stopped', + ) + const available = Boolean(connected && status?.ready && status.worker_state === 'running' && !needsReconnect) + const terminalLogin = login && ['verify_blocked', 'expired', 'already_bound'].includes(login.state) + const loginActive = Boolean(login && !terminalLogin && login.state !== 'connected') + const disconnectControl = ( + void disconnect()} + > + + + ) + + return ( +
+ +
+ + 微信 ClawBot + + + 把新的待回复请求发送到扫码者微信。扫码者的普通文本回复会直接结束当前请求。 + +
+ +
+ + {error ? : null} + {status?.last_error ? ( + + ) : null} + + + + + + 连接状态 + {!connected ? 未连接 : null} + {connected && !status?.ready && !needsReconnect ? 等待微信消息 : null} + {connected && status?.ready && !available && !needsReconnect ? 正在启动 : null} + {available ? 可用 : null} + {needsReconnect ? 需要重新扫码 : null} + + {!connected ? ( + + ) : needsReconnect ? ( + + + {disconnectControl} + + ) : disconnectControl} + + + {connected ? ( +
+ 连接时间:{formatDate(status?.connected_at)} + 最近收到微信消息:{formatDate(status?.last_inbound_at)} + 最近发送微信消息:{formatDate(status?.last_outbound_at)} +
+ ) : null} + + {connected && !status?.ready ? ( + + ) : null} +
+
+ + {login ? ( + + + {login.qr_code_url && !terminalLogin ? ( + + ) : null} + {login.message} + + 二维码有效期至 {formatDate(login.expires_at)}。请只使用准备接收 ChatAPI 请求的微信扫码。 + + {login.state === 'verify_required' ? ( + + setVerifyCode(event.target.value.replace(/\D/g, ''))} + onPressEnter={() => void submitVerifyCode()} + /> + + + ) : null} + {terminalLogin ? ( + + ) : null} + + + ) : null} + + + 直接回复:结束当前请求 + /list 查看等待请求;/use <编号> 切换请求 + /abort [原因] 中止请求;/help 查看帮助 + 暂不支持流式片段、思考、工具调用、媒体或群聊。 + + )} + /> +
+ ) +} diff --git a/frontend/src/components/settings/UserSettingsPanel.tsx b/frontend/src/components/settings/UserSettingsPanel.tsx index 02260ab..e7107cf 100644 --- a/frontend/src/components/settings/UserSettingsPanel.tsx +++ b/frontend/src/components/settings/UserSettingsPanel.tsx @@ -4,6 +4,7 @@ import { Button, Divider, Form, Input, InputNumber, Switch, Typography } from 'a import { appMessage } from '../../lib/antdMessage' import { requestJson } from '../../lib/api' import type { UserConfig } from '../../types/chat' +import { ClawBotSettingsCard } from './ClawBotSettingsCard' import { TotpSetupPanel } from './TotpSetupPanel' type UserSettingsPanelProps = { @@ -247,6 +248,10 @@ export function UserSettingsPanel({ open, onClose, totpEnabled, onTotpRefresh }: + + + + )