// Package weixin_ilink 实现 Cyrene v2 的微信 ClawBot iLink 渠道适配器 // (DevDocs/design/08-渠道接入.md §4)。它把腾讯 iLink Bot API 适配到归一化的 // internal/channel/adapter 契约上。 // // 该适配器覆盖 weixin-ilink 的能力面:扫码登录、带持久化 get_updates_buf 游标的 // HTTP 长轮询、按对端跟踪 context_token、配合 CDN 下载的 AES-128-ECB 媒体加解密, // 以及 24 小时主动推送窗口。媒体 CDN 引用是惰性捕获的——轮询循环绝不下载附件, // 因此大文件不会拖住消息投递(design 03 §8 第 6 条)。 // // 所有网络 I/O 都经过注入的 *http.Client 并尊重传入的 context.Context,测试因此 // 可以换成 httptest.Server,并在取消读循环时不泄漏 goroutine 或定时器。 package weixin_ilink import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) const ( // source 为本包发出的每条日志记录标注来源,使故障可回溯到所属模块 // (constraint 02 §5)。 source = "plugins/channels/weixin_ilink" // platformName 是适配器的 Name() 取值。 platformName = "weixin-ilink" // defaultBaseURL 与 defaultCDNBaseURL 来自协议摘要。 defaultBaseURL = "https://ilinkai.weixin.qq.com" defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c" // defaultLongPollTimeout 超过服务端约 35s 的保持时长,使 HTTP 客户端不会 // 中断一次正常进行中的长轮询。 defaultLongPollTimeout = 45 * time.Second // defaultLoginTimeout 限定扫码确认的等待时长。 defaultLoginTimeout = 8 * time.Minute // defaultProactiveWindow 即 design/08 §4 所称的 iLink 24 小时窗口。 defaultProactiveWindow = 24 * time.Hour // defaultMaxMediaSize 把单个下载的附件限制在 50 MiB。 defaultMaxMediaSize int64 = 50 << 20 // botType 在官方 iLink 源码中硬编码,原样传给 get_bot_qrcode。 botType = 3 // channelVersion 是 getupdates 上发送的 base_info.channel_version。 channelVersion = "1.0.2" // ilinkAppID 对应官方 package.json 的 ilink_appid 字段,并在每次请求上作为 // iLink-App-Id 头发送。 ilinkAppID = "bot" // ilinkVersion 对应上游 @tencent-weixin/openclaw-weixin 的版本,我们以它的 // 客户端版本作为 iLink-App-ClientVersion 在线路上宣称。 ilinkVersion = "2.4.8" // errcodeSessionRejected 是 iLink 的错误码,含义是 iLink 拒绝或作废了这份会话 // 凭据。它**不是**基于时间的过期:bot token 是长期凭据(一个月前扫码拿到的 token // 依然可用),所以 -14 表示 iLink 不再接受这个会话——已解绑、在别处重新登录、 // 或协议层拒绝。 errcodeSessionRejected = -14 ) // 本适配器返回的哨兵错误,可用 errors.Is 判定。 var ( // ErrInvalidConfig 在必填配置字段为空时返回。 ErrInvalidConfig = errors.New("weixin_ilink: invalid config") // ErrLoginTimeout 在二维码未及时确认时返回。 ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out") // ErrSessionRejected 在 iLink 上报 errcode=-14 时返回。 ErrSessionRejected = errors.New("weixin_ilink: iLink rejected the session (errcode=-14); re-scan required") // ErrNeedsLogin 由 Connect 在尚未配置 bot_token 时返回;扫码绑定必须经面板的 // login-qr/login-status 端点驱动。 ErrNeedsLogin = errors.New("weixin_ilink: bot not logged in yet, scan the QR") // ErrNoContextToken 在一次发送缺少该对端的 context_token 时返回。 ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer") // ErrProactiveWindow 在主动推送落在 24 小时窗口之外时返回。 ErrProactiveWindow = errors.New("weixin_ilink: proactive push outside 24h window") // ErrMediaTooLarge 在下载超过 MaxMediaSize 时返回。 ErrMediaTooLarge = errors.New("weixin_ilink: media exceeds size limit") ) // Config 保存 weixin-ilink 适配器的设置。它由渠道层从环境/配置读取,并可通过 // Config() 与 adapter.DecodeConfig 往返转换。 type Config struct { // BaseURL 是 iLink API 的根地址。 BaseURL string `json:"base_url"` // CDNBaseURL 是用于下载与上传的媒体 CDN 根地址。 CDNBaseURL string `json:"cdn_base_url"` // BotToken 是扫码登录得到的 bearer token。token 非空意味着 Connect 会跳过 // 二维码流程。 BotToken string `json:"bot_token"` // BotID 是登录确认返回的 ilink_bot_id。 BotID string `json:"bot_id"` // OwnerID 是已绑定主人账号的 ilink_user_id。 OwnerID string `json:"owner_id"` // Cursor 是持久化的 get_updates_buf。 Cursor string `json:"cursor"` // LongPollTimeout 是 getupdates 的单请求超时。 LongPollTimeout time.Duration `json:"long_poll_timeout"` // LoginTimeout 限定扫码流程等待确认的时长。 LoginTimeout time.Duration `json:"login_timeout"` // LoginInterval 是登录期间轮询二维码状态的间隔。 LoginInterval time.Duration `json:"login_interval"` // PollInterval 是两次长轮询之间的间隔;为零表示立即轮询。 PollInterval time.Duration `json:"poll_interval"` // ProactiveWindow 是用户最后一条消息之后仍允许主动推送的时长。 ProactiveWindow time.Duration `json:"proactive_window"` // MaxMediaSize 以字节为单位限制单个下载的附件。 MaxMediaSize int64 `json:"max_media_size"` } // withDefaults 用协议默认值填充零值字段。BotID/OwnerID 合法地可以为空,因此原样 // 保留。 func (c Config) withDefaults() Config { if c.BaseURL == "" { c.BaseURL = defaultBaseURL } if c.CDNBaseURL == "" { c.CDNBaseURL = defaultCDNBaseURL } if c.LongPollTimeout <= 0 { c.LongPollTimeout = defaultLongPollTimeout } if c.LoginTimeout <= 0 { c.LoginTimeout = defaultLoginTimeout } if c.LoginInterval <= 0 { c.LoginInterval = loginPollInterval } if c.PollInterval < 0 { c.PollInterval = 0 } if c.ProactiveWindow <= 0 { c.ProactiveWindow = defaultProactiveWindow } if c.MaxMediaSize <= 0 { c.MaxMediaSize = defaultMaxMediaSize } return c } // Adapter 是微信 ClawBot iLink 的 PlatformAdapter 实现。它可安全并发使用:会话 // 标志、游标以及按对端的 token/时间戳映射的读写都由同一把 RWMutex 保护。 type Adapter struct { cfg Config httpClient *http.Client log *slog.Logger clock func() time.Time mu sync.RWMutex connected bool // rejected 标记 iLink 以 errcode=-14 拒绝过的会话:在重新扫码登录把它重新绑定 // 之前,这份凭据一直被拒(session_state.go)。rejectedToken 记录**哪一个** token // 被拒,于是之后配置的新 token 会清除该状态;rescanDetail 是面板可见的原因。 // 这三者都由 mu 保护。 rejected bool rejectedToken string rescanDetail string qrcode string // pollBaseURL 可选地覆盖 cfg.BaseURL,用于 get_qrcode_status 轮询。服务端返回 // scaned_but_redirect 且带 redirect_host 时会设置它,使后续状态轮询跟随该 IDC // 主机。由 mu 保护。 pollBaseURL string contexts map[string]string lastMsgAt map[string]time.Time handler adapter.MessageHandler // loginMu 串行化扫码登录会话的生命周期(StartLogin/Connect),使同一时刻只有 // 一次二维码绑定在跑。loginSession / loginStatus / loginDetail 承载面板可观测的 // 登录状态;loginStatus/loginDetail 由 mu 保护,loginSession 由 loginMu 保护。 loginMu sync.Mutex loginSession *loginSession loginStatus string loginDetail string } // New 构造一个 Adapter。client 可为 nil,此时使用 http.DefaultClient;log 可为 nil, // 此时回退到项目 logger 的 info 级别。默认值填补后基础 URL 仍为空时返回 // ErrInvalidConfig。 func New(cfg Config, client *http.Client, log *slog.Logger) (*Adapter, error) { cfg = cfg.withDefaults() if !strings.HasPrefix(cfg.BaseURL, "http://") && !strings.HasPrefix(cfg.BaseURL, "https://") { return nil, fmt.Errorf("%w: base url must be http(s): %q", ErrInvalidConfig, cfg.BaseURL) } if client == nil { client = http.DefaultClient } if log == nil { log = logger.New(logger.Options{}) } log = logger.WithSource(log, source) return &Adapter{ cfg: cfg, httpClient: client, log: log, clock: time.Now, contexts: map[string]string{}, lastMsgAt: map[string]time.Time{}, loginStatus: LoginStatusIdle, }, nil } // Name 返回平台标识符 "weixin-ilink"。 func (a *Adapter) Name() string { return platformName } // Config 以普通 map 返回当前适配器配置的快照,其中包含持久化的游标与登录凭据。 func (a *Adapter) Config() map[string]any { a.mu.RLock() defer a.mu.RUnlock() return map[string]any{ "base_url": a.cfg.BaseURL, "cdn_base_url": a.cfg.CDNBaseURL, "bot_token": a.cfg.BotToken, "bot_id": a.cfg.BotID, "owner_id": a.cfg.OwnerID, "cursor": a.cfg.Cursor, "long_poll_timeout": a.cfg.LongPollTimeout, "login_timeout": a.cfg.LoginTimeout, "login_interval": a.cfg.LoginInterval, "poll_interval": a.cfg.PollInterval, "proactive_window": a.cfg.ProactiveWindow, "max_media_size": a.cfg.MaxMediaSize, } } // Connect 建立 iLink 会话。它是幂等的。已配置且可用的 bot_token 会被当作存活会话; // 否则它会跑扫码登录流程(见 login.go)。 // // 已被 iLink 以 errcode=-14 拒绝的 token **不可用**:Connect 会以 // ErrSessionRejected(需要重新扫码)拒绝它,而不是上报一个「下一次轮询就会拆掉」的 // 会话;回到正轨的途径是扫码登录流程(见 login_admin.go 的 StartLogin)。 func (a *Adapter) Connect(ctx context.Context) error { if rejected, reason := a.rejectedState(); rejected { a.log.Error("ilink connect refused: session rejected, 需要重新扫码绑定(请用「扫码登录」)", "bot_id", a.botID(), "reason", reason) return fmt.Errorf("%w: %s", ErrSessionRejected, reason) } a.mu.RLock() connected := a.connected token := a.cfg.BotToken a.mu.RUnlock() if connected && token != "" { return nil } if token == "" { // 尚无 token:扫码绑定必须带外驱动(面板的 login-qr / login-status 端点, // design/08 §4)。绝不让一次「连接」HTTP 调用阻塞在等待二维码上——上报 // ErrNeedsLogin,让调用方告知主人去扫码,而不是把请求挂到登录超时。 return ErrNeedsLogin } a.mu.Lock() a.connected = true a.mu.Unlock() a.log.Info("ilink session established", "bot_id", a.botID()) return nil } // botID 返回已配置的机器人标识符;它是唯一可安全记录到日志的账号标识(bot token // 是凭据,绝不进入日志行)。 func (a *Adapter) botID() string { a.mu.RLock() defer a.mu.RUnlock() return a.cfg.BotID } // Disconnect 拆除会话。它是幂等的。 func (a *Adapter) Disconnect(ctx context.Context) error { a.mu.Lock() defer a.mu.Unlock() if !a.connected { return nil } a.connected = false a.log.Info("ilink session closed") return nil } // IsConnected 报告会话当前是否存活。 func (a *Adapter) IsConnected() bool { a.mu.RLock() defer a.mu.RUnlock() return a.connected } // HealthCheck 校验会话可用:它必须处于已连接状态并携带 bot_token。被 iLink 拒绝的 // 会话(errcode=-14)以 ErrSessionRejected 上报——「需要重新扫码」——使调用方能 // 把被拒凭据与单纯的适配器未连接区分开。它不探测网络,调用方因此可以在重连后廉价 // 调用,不产生任何请求。 func (a *Adapter) HealthCheck(ctx context.Context) error { if rejected, reason := a.rejectedState(); rejected { return fmt.Errorf("%w: %s", ErrSessionRejected, reason) } a.mu.RLock() defer a.mu.RUnlock() if !a.connected || a.cfg.BotToken == "" { return adapter.ErrNotConnected } return nil } // Capabilities 声明该适配器支持的能力。 func (a *Adapter) Capabilities() adapter.Capabilities { caps := adapter.DefaultCapabilities() caps.Proactive = true return caps } // ListChannels 把机器人账号作为单个私聊渠道返回。iLink 的群成员关系无法可靠枚举, // 因此只列出已绑定的账号。 func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, error) { a.mu.RLock() botID := a.cfg.BotID a.mu.RUnlock() if botID == "" { botID = "weixin-ilink" } return []adapter.ChannelInfo{{ ChannelID: botID, ChannelType: adapter.ChannelDirect, ChannelName: "wechat", }}, nil } // SetMessageHandler 安装接收推送式入站消息的回调。fn 为 nil 表示清除。处理器一旦 // 设置,ReadMessages 就把转换后的消息交给它,而不再交给 emit 回调。 func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) { a.mu.Lock() defer a.mu.Unlock() a.handler = fn } func (a *Adapter) handlerLocked() adapter.MessageHandler { a.mu.RLock() defer a.mu.RUnlock() return a.handler } // --- 并发辅助 --- func (a *Adapter) cursor() string { a.mu.RLock() defer a.mu.RUnlock() return a.cfg.Cursor } func (a *Adapter) setCursor(c string) { if c == "" { return } a.mu.Lock() a.cfg.Cursor = c a.mu.Unlock() } // recordInbound 刷新按对端的 context_token 与最后消息时间(design/08 §8:每条入站 // 消息都刷新 token,使主动推送在窗口内有可用 token)。 func (a *Adapter) recordInbound(token string, m adapter.UnifiedMessage) { peer := m.SenderID if m.IsGroup() { peer = m.GroupID } if peer == "" { return } a.mu.Lock() a.contexts[peer] = token a.lastMsgAt[peer] = m.Timestamp a.mu.Unlock() } // contextFor 返回某个对端已存的 context_token。 func (a *Adapter) contextFor(peer string) string { a.mu.RLock() defer a.mu.RUnlock() return a.contexts[peer] } // recipient 为一条出站消息解析目标对端与群标志:群聊取群 id,否则取发送者 id, // 否则取渠道 id。 func recipient(m adapter.UnifiedMessage) (peer string, group bool) { if m.IsGroup() { return m.GroupID, true } if m.SenderID != "" { return m.SenderID, false } return m.ChannelID, false } // --- HTTP 管道 --- // // 请求/响应的 HTTP 辅助函数(authorizationHeader、doJSON、apiURL、joinURL、 // pollBase、setPollBaseURL、buildClientVersion、cdnURL)位于 adapter_http.go, // 以便本文件保持在项目 ≤500 行的上限之内。