Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions backend/docs/im-clawbot-design.md
Original file line number Diff line number Diff line change
@@ -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 防止重复终态控制。
11 changes: 10 additions & 1 deletion backend/internal/bootstrap/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
12 changes: 10 additions & 2 deletions backend/internal/bootstrap/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -66,6 +68,7 @@ type Services struct {
Turn *turnsvc.Service
ChatSettings *chatsettings.Service
Audit *auditsvc.Service
IM *imsvc.Service
}

type applicationInput struct {
Expand Down Expand Up @@ -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
}
Expand All @@ -132,14 +136,15 @@ 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()
return applicationResult{}, err
}
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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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)},
Expand Down
116 changes: 116 additions & 0 deletions backend/internal/http/handler/im.go
Original file line number Diff line number Diff line change
@@ -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})
}
5 changes: 5 additions & 0 deletions backend/internal/http/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/ops/observability/logging/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
LayerMigrate = "migrate"
LayerPlatform = "platform"
LayerAudit = "audit"
LayerIM = "im"
)

type Config struct {
Expand Down
Loading