Files
AskaEth 353293e807
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`backthink IM push failed … weixin_ilink: proactive push outside 24h window`
—— 她的话**送进了 Web 活跃客户端,但没送进微信**,原因是**平台规则**(微信 iLink
只允许用户最后一条消息后 24h 窗口内主动推送),不是崩溃。

本片只做「如实说清」,不绕规则:
- **原因码**:新增契约层分类件 `internal/channel/adapter/delivery.go`
  (`DeliveryCategory` + 4 个码 + 可选自描述接口 `DeliveryFailure` / 读取函数),
  **沿用**既有 voice `Category(err)` 与 `SystemNoticeNormalizer` 的范式,**不造第二套枚举**;
  平台码由产生错误的层声明:`weixin_ilink` 的 `ErrProactiveWindow` 自报
  `proactive_window_closed`(哨兵文本与 `errors.Is` 判定**逐字未变**)。
- **三处可见**:① 结构化 WARN(补齐 category/channel/platform/route);
  ② 审计 `backthink.push.im_undelivered`(与 `backthink.self_wake.*` 同族);
  ③ 推送结果 `Detail` 追加稳定标签 `im_undelivered=<渠道>:<原因码>` —— 面板**已在
  渲染 Detail**(`Backthink.tsx:185`)⇒ 主人现在就能看到,**无需改前端**。
- **不丢消息**:IM 路失败照旧走活跃客户端路,且**只在 Web 真送达时** `pushed=true`;
  有测试钉住「IM 失败不影响另一条路」(同一条消息没被丢)。
- **不做的**:不重试刷屏、不换渠道冒充微信、不伪造送达、不新增 `.env` 键。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;4 个包 vet/测试全绿;
我用 -overlay 把 `ErrProactiveWindow` 退成裸 `errors.New` ⇒ weixin 包两条用例变红
(`category = "failed", want proactive_window_closed`),channelbridge/backthink 亦红。

⚠️ 未落地的建议(已登记,未硬改):给她的"推送回执"(`backthink_records` 加
`push_category`/`push_channel` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
2026-10-07 10:59:22 +08:00

470 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
)
// 本适配器的投递失败原因码(adapter.DeliveryCategory,见该包 delivery.go 的口径)。
// 码值属于**本平台**(平台规则各有各的名字),因此在这里声明、由下面的
// selfDescribingError 自报;契约层只提供读取函数,不枚举它们。
const (
// categoryProactiveWindowClosed 是**平台规则**导致主动推送被拒:iLink 只允许在用户
// 最后一条消息之后 24 小时内主动推送。它不是崩溃,也不是可以重试解决的失败。
categoryProactiveWindowClosed = adapter.DeliveryCategory("proactive_window_closed")
// categoryNoContextToken 是这次发送没有任何可用的会话凭证(对端 context_token 缺失):
// 平台无从把消息关联到会话,因此**不能**硬发。
categoryNoContextToken = adapter.DeliveryCategory("no_context_token")
// categorySessionRejected 是 iLink 已拒绝这份会话凭据(errcode=-14):必须重新扫码,
// 与「这次网络不通」是两类失败。
categorySessionRejected = adapter.DeliveryCategory("session_rejected")
)
// selfDescribingError 给哨兵错误附上**稳定原因码与渠道名**(实现
// adapter.DeliveryFailure),使 channelbridge / backthink 能在日志、思考记录与审计里
// 说清「哪个渠道、因为什么」没送到,而不是只能复述一整串自由文本。
//
// Error()/Unwrap() 逐字委托被包装的哨兵 ⇒ 错误文本与 `errors.Is(err, ErrXxx)` 判定
// 与包装之前**完全一致**(这是它能替换 errors.New 而不改变任何既有行为的原因)。
type selfDescribingError struct {
category adapter.DeliveryCategory
err error
}
func (e *selfDescribingError) Error() string { return e.err.Error() }
func (e *selfDescribingError) Unwrap() error { return e.err }
// DeliveryCategory 实现 adapter.DeliveryFailure。
func (e *selfDescribingError) DeliveryCategory() adapter.DeliveryCategory { return e.category }
// DeliveryChannel 实现 adapter.DeliveryFailure:本适配器只说一个平台,因此渠道名恒为
// 它自己的平台名(不是猜的,也不是调用方传进来的)。
func (e *selfDescribingError) DeliveryChannel() string { return platformName }
// 本适配器返回的哨兵错误,可用 errors.Is 判定(带原因码的三个由
// selfDescribingError 包装,判定与文本不变,另附 stable 码)。
var (
// ErrInvalidConfig 在必填配置字段为空时返回。
ErrInvalidConfig = errors.New("weixin_ilink: invalid config")
// ErrLoginTimeout 在二维码未及时确认时返回。
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
// ErrSessionRejected 在 iLink 上报 errcode=-14 时返回(原因码 session_rejected:
// 主人必须重新扫码)。
ErrSessionRejected = &selfDescribingError{
category: categorySessionRejected,
err: 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 时返回(原因码
// no_context_token)。
ErrNoContextToken = &selfDescribingError{
category: categoryNoContextToken,
err: errors.New("weixin_ilink: no context_token for peer"),
}
// ErrProactiveWindow 在主动推送落在 24 小时窗口之外时返回(原因码
// proactive_window_closed)。它是**平台规则**,不是崩溃:调用方必须把它如实记为
// 「未送达 + 原因码」,绝不重试刷屏、绝不经别的渠道冒充微信。
ErrProactiveWindow = &selfDescribingError{
category: categoryProactiveWindowClosed,
err: errors.New("weixin_ilink: proactive push outside 24h window"),
}
// ErrMediaTooLarge 在下载超过 MaxMediaSize 时返回。
ErrMediaTooLarge = errors.New("weixin_ilink: media exceeds size limit")
// ErrNoMediaKey 在入站附件既没有按条 aes_key、也无法用 bot_token 兜底时返回。
ErrNoMediaKey = errors.New("weixin_ilink: inbound media carries no aes_key")
// ErrKeyNotAES128 在按条 aes_key 既不是 16 字节原始密钥、也不是 32 位十六进制
// 密钥串时返回。宁可诚实报错,也不拿长度不对的东西去解密。
ErrKeyNotAES128 = errors.New("weixin_ilink: aes key is neither 16 raw bytes nor 32 hex chars")
// ErrNoUploadURL 在 getuploadurl 返回成功响应、却既没有完整上传地址也没有可拼接
// 的上传参数时返回。它与 ErrSessionRejected 是两类失败:前者是协议字段缺失,后者
// 是凭据被 iLink 拒绝(必须重新扫码)。
ErrNoUploadURL = errors.New("weixin_ilink: getuploadurl returned no upload url")
// ErrNoUploadParam 在 CDN 上传返回 200、却没有 `x-encrypted-param` 响应头时返回。
// 没有这个凭据就无法告诉对方去哪里下载,**绝不能**拿上传地址冒充它当成功。
ErrNoUploadParam = errors.New("weixin_ilink: cdn upload response carries no " + uploadParamHeader + " header")
)
// 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
// verified 标记 iLink **接受过**当前凭据(一次成功的 getupdates/sendmessage):
// 它是三态里「有效」的唯一证据(session_status.go)。verifiedToken 记录被证实的是
// 哪一个 token,于是配置换了新 token 之后旧证据不会被继承 —— 与 rejectedToken
// 同一套路,方向相反。二者都由 mu 保护。
verified bool
verifiedToken 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 §4 的
// weixin-ilink 条目:每条入站消息都刷新 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 行的上限之内。