主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
470 lines
19 KiB
Go
470 lines
19 KiB
Go
// 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 行的上限之内。
|