From a51aadeef44672372915ae6d8dec2a399f8494dc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 18:26:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor(weixin-ilink):=20=E6=8E=AA=E8=BE=9E?= =?UTF-8?q?=E7=BA=A0=E6=AD=A3=20=E2=80=94=E3=80=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E3=80=8D=E2=86=92=E3=80=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=A2=AB=20iLink=20=E6=8B=92=E7=BB=9D=E3=80=8D=EF=BC=88?= =?UTF-8?q?=E9=9B=B6=E9=80=BB=E8=BE=91=E6=94=B9=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。 - 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected - 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留 - 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期) - Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go) --- Docs/api-client.md | 9 +- internal/services/channelbridge/inbound.go | 2 +- .../services/channelbridge/readloop_test.go | 2 +- internal/services/gateway/admin.go | 2 +- .../gateway/admin_channels_rescan_test.go | 30 ++--- .../gateway/admin_write_llm_channels.go | 6 +- plugins/channels/weixin_ilink/adapter.go | 44 +++--- plugins/channels/weixin_ilink/login.go | 18 +-- plugins/channels/weixin_ilink/login_admin.go | 20 +-- plugins/channels/weixin_ilink/poll.go | 16 +-- plugins/channels/weixin_ilink/poll_test.go | 6 +- plugins/channels/weixin_ilink/send.go | 33 +++-- .../channels/weixin_ilink/session_state.go | 83 ++++++------ .../weixin_ilink/session_state_test.go | 126 +++++++++--------- 14 files changed, 205 insertions(+), 192 deletions(-) diff --git a/Docs/api-client.md b/Docs/api-client.md index edb3f97..bd80df1 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -124,7 +124,7 @@ | 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) | | 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) | | 5a | `/api/v1/admin/plugins/installed` | GET | ✅ | **已装运行时插件列表**(owner/admin 读):`{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]}`,`apiKey`/敏感 `env` 值**掩码**(如 `****1234`),`live` = 是否已连接注册,`toolCount` = 该 server 注册工具数 | -| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 过期 token) | +| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 返回 `errcode=-14`、会话被 iLink 侧拒绝这一种) | | 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 | | 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;POST 创建用户/签发 key 随 M10 落地) | | 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议与 `maxTokens` 预算);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启) | @@ -160,9 +160,10 @@ - `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。 - `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;`maxTokens` 为负回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`maxTokens` 缺省/`0` = 未设(回落 `LLM_FALLBACK_MAX_TOKENS` / 适配器默认)。`.env` 为兜底。 - `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 -- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_expired`(微信 iLink bot token 已过期,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。 +- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`409 session_rejected`(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/`409 needs_login`(微信 iLink 尚无 token)/`500`(其它凭据缺失等连接失败)。 - `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 -- **微信 iLink「会话过期」契约**(v0.39,适配器级,非端点):iLink 返回 `errcode=-14`(bot token 过期)时适配器进入**显式过期态**——`IsConnected()` 恒 `false`;`Connect()` **不再返回成功**,而是返回「需要重新扫码」类错误(`POST /admin/channels/open` 对应 `409 session_expired`);`HealthCheck` 同样把「过期」与「未连接」区分开。`GET /admin/channels` 该平台行带 **`needsRescan:true`** + **`lastError`**(人类可读原因,**绝不含 token**)。**清除条件**:扫码登录拿到新 token(`login-status` 报 `confirmed`)或配置里换了新 token(保存后热重载)。过期期间:`login-status` **不**假报 `confirmed` 而是 `failed` + 过期原因(避免面板把死 token 当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in` 挡住重新扫码);`get_bot_qrcode` 不再携带已失效 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:`needsRescan`/`lastError` **不出现**(`omitempty`),客户端按缺省处理。 +- **微信 iLink「会话被拒」契约**(v0.39,适配器级,非端点):iLink 返回 `errcode=-14` 时适配器进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` **不再返回成功**,而是返回「需要重新扫码」类错误(`POST /admin/channels/open` 对应 `409 session_rejected`);`HealthCheck` 同样把「被拒」与「未连接」区分开。`GET /admin/channels` 该平台行带 **`needsRescan:true`** + **`lastError`**(人类可读原因,**绝不含 token**)。**清除条件**:扫码登录拿到新 token(`login-status` 报 `confirmed`)或配置里换了新 token(保存后热重载)。被拒期间:`login-status` **不**假报 `confirmed` 而是 `failed` + 被拒原因(避免面板把已作废的凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in` 挡住重新扫码);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:`needsRescan`/`lastError` **不出现**(`omitempty`),客户端按缺省处理。 +- **事实说明(勿误读为「过期」)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**(解绑、在别处重新登录、协议层拒绝等),**不是时间性过期**;恢复方式为**重新扫码绑定**(或配置换新 token)。当前尚未在真实日志中观测到 `-14`,此处按「被拒」语义处理以防静默变哑。 - **KOOK 适配器面板端点**(design/17 §2,**owner-only + 审计**,仅 `kook` 平台实现;其它平台回 `501 not_implemented`,账号未找到回 `404`):这些端点均带可选 `?accountId=` 定位到具体 channels.json 账号(不传=该平台首个账号)。**token/密钥不打印、不落日志**;子项拉取失败不伪造、记入 `errors`。 - `GET /admin/channels/{platform}/view?accountId=` → `200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}`。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);`errors` 为 `{子项:原因}` 记录失败的子项。 - `GET /admin/channels/{platform}/friends?type=&accountId=` → `200 {data:{friends?,requests?,blocked?}}`。`type` ∈ `friend|request|block`(空=全部),调用 KOOK `GET /friend?type=`。 @@ -328,7 +329,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 -- `v0.39`(**微信 iLink 会话过期可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 的 `errcode=-14` 过期 token 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「已过期」回 **`409 session_expired`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式过期态**——`IsConnected()` 恒 `false`;`Connect()` 返回过期错误(**不再**对已过期 token 报成功);`HealthCheck` 区分「过期」与「未连接」;并以 **`ERROR`** 级日志写明「需要重新扫码登录」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。过期态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:过期期间 `login-status` 报 **`failed` + 过期原因**(**不再**假报 `confirmed`,否则面板会把死 token 当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已失效的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。详见 §2 行 6 与「微信 iLink『会话过期』契约」。 +- `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。 - `v0.38`(**B4 客户端共享枚举权威表 + 未知值回落约定**,对客户端**新增一个只读 REST 端点**):新增 **`GET /api/v1/meta/enums`**(**任意已认证角色可读**,与 `GET /sessions` 同级;无 token `401`)→ `200 {data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`:① **`platforms`**(6)= `web`/`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`——即各内置适配器 `Name()` + Web 主会话来源 `web`;② **`moods`**(5)= `calm`/`happy`/`excited`/`sad`/`angry`,与 persona 情绪状态机内置状态一致(该状态机**可扩展**,故非封闭集合);③ **`modes`**(9)= `chat`/`code`/`home`/`work`/`docs`/`voice`/`vision`/`agents`/`settings`(design/07 §1);④ **`unknown`** 恒 `"display-raw"` = **未知值回落约定机器可读**:客户端遇不在表中的取值**原样显示原始字符串**(**不丢弃、不报错、不占位符**),**服务端新增取值视为非破坏性变更**(forward-compatible)。**单一事实来源在 Go 侧**(`internal/contract/enums`),并有**反漂移测试**断言适配器 `Name()` / 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像 `frontend/web/src/lib/enums.ts`(`labelFor` 未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。**向后兼容**:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。 - `v0.37`(**D4 未读状态 / 已读游标**,对客户端**新增一个语义字段、一个 REST 端点、一个下行事件**):① `chat_sessions` 新增 **`read_at INTEGER NOT NULL DEFAULT 0`**(迁移 **0010**,down 可逆;**毫秒 Unix 时间戳**,与 `chat_messages.created_at` **同单位**)。② **语义**:**会话级单一已读游标 + 单 owner 账号**——同一账号所有端共用一个游标,**读一处 = 全端已读**(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);`createdAt <= readAt` 的消息算已读,未读数 = `createdAt > readAt` 的条数。③ **`GET /api/v1/sessions` 每项新增恒返回的 `unreadCount`**(整数,0 = 无未读):与 `lastMessage` **同一条 SQL 派生**(关联子查询 `COUNT(*) WHERE session_id = s.id AND created_at > s.read_at`,走已有 `idx_chat_msgs_session_created`,**无 N+1、不新增索引**)。④ **新增 `POST /api/v1/sessions/{id}/read`**(owner/admin,与 `PATCH /sessions/{id}` 同鉴权):body `{before?}`(可选毫秒;缺省= 现在)→ `200 {data:{sessionId,readAt}}`;`before` 为 0/负数/非数字/body 非对象 → `400`,未知会话(含隔离 `sess-xxx`)→ `404`,未注入 HistoryStore → `501`。**游标只前进不后退**(`read_at = MAX(read_at, before)`),乱序/并发/重试请求幂等安全。⑤ **新增下行事件 `session_read`**:`{type:"session_read", sessionId, data:{readAt}}`,全连接广播 + 客户端按 `sessionId` 过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ **升级回填**:0010 把既有会话游标回填为「该会话现有最新消息时间」,**升级当天不误报未读**(既有历史全部视为已读)。⑦ `HistoryStore`(网关消费侧契约)新增 `MarkSessionRead(ctx, sessionID, before)`;`SessionMeta` 新增 `UnreadCount`。**向后兼容**:仅新增字段/端点/事件,既有契约形状不变;不消费 `unreadCount`/`session_read` 的客户端行为不变。详见 §1 行 4/8a 与 §3.9。 - `v0.36`(**A1 流式帧附加会话 / 回合标识**,对客户端**新增两个可选字段、无端点变更**):结构化流帧 `message.start/delta/end`、`thinking.start/delta/end`、`tool_call.start/delta/end`、`tool_execution.update` 现在带 `sessionId` + `turnId`(**向后兼容**:只新增帧字段,`data` 结构与既有事件形状不变)。`sessionId` 由 loop 在**回合边界**从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);`turnId` 由网关**每回合**新生成(`newTurnID`)并经 `loop.WithTurnID` 挂进运行上下文,`runTurn` 是编排层与 legacy 直连路径**唯一**的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的 `turnId` 一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):**多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡**,现在客户端按 `sessionId === activeSessionId` 过滤即可路由到激活会话。`stream_chunk` / `stream_end` 的 `sessionId`/`turnId` 行为不变。详见 §3.1。 diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index d775209..8ef56b6 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -24,7 +24,7 @@ import ( // messages alone. // // When the read path returns with readCtx still alive, the adapter stopped -// reading on its own — today that is the weixin-iLink expired session +// reading on its own — today that is the weixin-iLink rejecting the session // (errcode=-14, see plugins/channels/weixin_ilink/session_state.go), which used to // leave the bridge claiming the channel was connected while it could no longer // deliver anything. A cancelled readCtx is a deliberate stop (bridge Stop or a diff --git a/internal/services/channelbridge/readloop_test.go b/internal/services/channelbridge/readloop_test.go index 20f8f62..22e667f 100644 --- a/internal/services/channelbridge/readloop_test.go +++ b/internal/services/channelbridge/readloop_test.go @@ -10,7 +10,7 @@ import ( ) // deadSessionAdapter mimics a channel whose session dies while the bridge is -// running — today the weixin-iLink expired bot token (iLink errcode=-14): Connect +// running — today the iLink session rejection (errcode=-14): Connect // reports success, the read loop then returns on its own (nothing cancelled it) // and IsConnected reports false from that point on. type deadSessionAdapter struct { diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index 81dfeca..1ae3a44 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -178,7 +178,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { continue } sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} - // Optional rescan capability (weixin-iLink expired token, v0.39): reported + // Optional rescan capability (weixin-iLink rejected session, v0.39): reported // only when the adapter really asks for a re-scan, so a healthy adapter // keeps the fields omitted rather than shipping a fabricated value. if ra, ok := asRescanAware(cap.Value); ok { diff --git a/internal/services/gateway/admin_channels_rescan_test.go b/internal/services/gateway/admin_channels_rescan_test.go index b7a13e9..332b405 100644 --- a/internal/services/gateway/admin_channels_rescan_test.go +++ b/internal/services/gateway/admin_channels_rescan_test.go @@ -18,7 +18,7 @@ import ( // rescanStubAdapter is a channel adapter that also exposes the optional rescan // capability, exactly as *weixin_ilink.Adapter does once iLink answers -// errcode=-14 (expired bot token, re-scan required). +// errcode=-14 (session rejected, re-scan required). type rescanStubAdapter struct { name string connected bool @@ -58,12 +58,12 @@ var ( ) // TestAdminChannelsReportsNeedsRescan asserts the panel can tell "needs a fresh -// scan" apart from plain "not connected": the expired adapter's row carries +// scan" apart from plain "not connected": the rejected adapter's row carries // needsRescan + the reason, and a healthy adapter omits both rather than shipping // a fabricated value. func TestAdminChannelsReportsNeedsRescan(t *testing.T) { t.Parallel() - const reason = "iLink getupdates 返回 errcode=-14(bot token 已过期)" + const reason = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)" caps := capreg.New() // Registered through the wrapper, the way the composition root registers it. if err := caps.Register(capreg.Capability{ @@ -96,10 +96,10 @@ func TestAdminChannelsReportsNeedsRescan(t *testing.T) { t.Fatalf("no weixin-ilink row in %+v", items) } if wx.Connected { - t.Error("weixin-ilink connected = true, want false while the token is expired") + t.Error("weixin-ilink connected = true, want false while iLink rejects the session") } if !wx.NeedsRescan { - t.Error("weixin-ilink needsRescan = false, want true (expired token must ask for a re-scan)") + t.Error("weixin-ilink needsRescan = false, want true (a rejected session must ask for a re-scan)") } if wx.LastError != reason { t.Errorf("weixin-ilink lastError = %q, want %q", wx.LastError, reason) @@ -135,10 +135,10 @@ func TestAdminChannelsReportsNeedsRescan(t *testing.T) { } } -// TestAdminChannelOpenExpiredSessionReturns409 asserts the open endpoint answers -// honestly for a session a reconnect cannot fix: 409 session_expired with the +// TestAdminChannelOpenRejectedSessionReturns409 asserts the open endpoint answers +// honestly for a session a reconnect cannot fix: 409 session_rejected with the // re-scan instruction, never a 200 that would make the panel show "connected". -func TestAdminChannelOpenExpiredSessionReturns409(t *testing.T) { +func TestAdminChannelOpenRejectedSessionReturns409(t *testing.T) { t.Parallel() caps := capreg.New() if err := caps.Register(capreg.Capability{ @@ -146,8 +146,8 @@ func TestAdminChannelOpenExpiredSessionReturns409(t *testing.T) { Kind: capreg.KindChannel, Value: &rescanStubAdapter{ name: "weixin-ilink", rescan: true, - reason: "iLink bot token 已过期", - connectErr: weixin_ilink.ErrSessionExpired, + reason: "iLink 返回 errcode=-14(本次会话已被 iLink 拒绝)", + connectErr: weixin_ilink.ErrSessionRejected, }, }); err != nil { t.Fatalf("register weixin-ilink: %v", err) @@ -167,8 +167,8 @@ func TestAdminChannelOpenExpiredSessionReturns409(t *testing.T) { if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil { t.Fatalf("decode error envelope: %v (body=%s)", err, body) } - if eobj.Code != "session_expired" { - t.Fatalf("error code = %q, want session_expired", eobj.Code) + if eobj.Code != "session_rejected" { + t.Fatalf("error code = %q, want session_rejected", eobj.Code) } if !strings.Contains(eobj.Message, "扫码") { t.Fatalf("error message = %q, want the re-scan instruction", eobj.Message) @@ -198,7 +198,7 @@ func TestAdminChannelsRescanFromRealIlinkAdapter(t *testing.T) { t.Fatalf("new weixin adapter: %v", err) } if err := ad.Connect(context.Background()); err != nil { - t.Fatalf("connect before expiry: %v", err) + t.Fatalf("connect before rejection: %v", err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -231,10 +231,10 @@ func TestAdminChannelsRescanFromRealIlinkAdapter(t *testing.T) { t.Fatalf("channels = %+v, want one weixin-ilink row", items) } if items[0].Connected { - t.Error("connected = true although iLink rejected the token") + t.Error("connected = true although iLink rejected the session") } if !items[0].NeedsRescan { - t.Error("needsRescan = false, want true for an expired iLink session") + t.Error("needsRescan = false, want true for a rejected iLink session") } if items[0].LastError == "" { t.Error("lastError is empty, want the re-scan reason") diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index 4c8419e..7a682b8 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -240,9 +240,9 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) { // the weixin-iLink bot token was rejected by iLink (errcode=-14), so the // owner needs a fresh scan-code login. Answer 409 with that instruction // instead of pretending the channel opened (the panel marks it connected). - if errors.Is(err, weixin_ilink.ErrSessionExpired) { - writeError(w, apperr.Wrap(apperr.ErrConflict, "session_expired", - "该渠道会话已过期(需要重新扫码登录):请用「扫码登录」重新绑定后再连接")) + if errors.Is(err, weixin_ilink.ErrSessionRejected) { + writeError(w, apperr.Wrap(apperr.ErrConflict, "session_rejected", + "该渠道会话已被 iLink 拒绝(errcode=-14):请用「扫码登录」重新绑定后再连接")) return } // weixin-iLink with no token yet must not hang nor read as a credential diff --git a/plugins/channels/weixin_ilink/adapter.go b/plugins/channels/weixin_ilink/adapter.go index b817b2d..eaa4927 100644 --- a/plugins/channels/weixin_ilink/adapter.go +++ b/plugins/channels/weixin_ilink/adapter.go @@ -63,8 +63,12 @@ const ( // ilinkVersion mirrors the upstream @tencent-weixin/openclaw-weixin version // whose client version we advertise on the wire as iLink-App-ClientVersion. ilinkVersion = "2.4.8" - // errSessionExpired is the iLink errcode for an expired bot token. - errSessionExpired = -14 + // errcodeSessionRejected is the iLink errcode meaning iLink rejected or + // invalidated this session credential. It is NOT a time-based expiry: the bot + // token is a long-lived credential (a token obtained by scanning a month ago + // still works), so -14 means iLink no longer accepts this session — unbound, + // re-logged-in elsewhere, or refused at the protocol level. + errcodeSessionRejected = -14 ) // Sentinel errors returned by this adapter, usable with errors.Is. @@ -73,8 +77,8 @@ var ( ErrInvalidConfig = errors.New("weixin_ilink: invalid config") // ErrLoginTimeout is returned when the QR code is not confirmed in time. ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out") - // ErrSessionExpired is returned when iLink reports errcode=-14. - ErrSessionExpired = errors.New("weixin_ilink: session expired, re-scan required") + // ErrSessionRejected is returned when iLink reports errcode=-14. + ErrSessionRejected = errors.New("weixin_ilink: iLink rejected the session (errcode=-14); re-scan required") // ErrNeedsLogin is returned by Connect when no bot_token is configured yet; the // scan-code bind must be driven via the panel login-qr/login-status endpoints. ErrNeedsLogin = errors.New("weixin_ilink: bot not logged in yet, scan the QR") @@ -159,15 +163,15 @@ type Adapter struct { mu sync.RWMutex connected bool - // expired marks a session iLink rejected with errcode=-14: the bot token is - // dead and only a fresh scan-code login re-binds it (session_state.go). - // expiredToken records WHICH token was rejected, so a subsequently configured + // rejected marks a session iLink rejected with errcode=-14: the credential is + // refused until a fresh scan-code login re-binds it (session_state.go). + // rejectedToken records WHICH token was rejected, so a subsequently configured // token clears the state; rescanDetail is the panel-visible reason. All three // are guarded by mu. - expired bool - expiredToken string - rescanDetail string - qrcode string + rejected bool + rejectedToken string + rescanDetail string + qrcode string // pollBaseURL optionally overrides cfg.BaseURL for get_qrcode_status polling. // It is set when the server returns scaned_but_redirect with a redirect_host, // so subsequent status polls follow the IDC host. Guarded by mu. @@ -242,14 +246,14 @@ func (a *Adapter) Config() map[string]any { // runs the scan-code login flow (see login.go). // // A token iLink already rejected with errcode=-14 is NOT usable: Connect refuses -// it with ErrSessionExpired (re-scan required) instead of reporting a session +// it with ErrSessionRejected (re-scan required) instead of reporting a session // that the very next poll would tear down, and the scan-code login flow is the // way back (see login_admin.go StartLogin). func (a *Adapter) Connect(ctx context.Context) error { - if expired, reason := a.expiredState(); expired { - a.log.Error("ilink connect refused: session expired, 需要重新扫码登录", + 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", ErrSessionExpired, reason) + return fmt.Errorf("%w: %s", ErrSessionRejected, reason) } a.mu.RLock() connected := a.connected @@ -300,14 +304,14 @@ func (a *Adapter) IsConnected() bool { } // HealthCheck verifies the session is usable: it must be connected and carry a -// bot_token. An expired session (iLink rejected the token with errcode=-14) is -// reported as ErrSessionExpired — "re-scan required" — so a caller can tell a -// dead credential apart from a plain disconnected adapter. It does not probe the +// bot_token. A session iLink rejected (errcode=-14) is reported as +// ErrSessionRejected — "re-scan required" — so a caller can tell a refused +// credential apart from a plain disconnected adapter. It does not probe the // network so callers can use it cheaply after a reconnect without incurring a // request. func (a *Adapter) HealthCheck(ctx context.Context) error { - if expired, reason := a.expiredState(); expired { - return fmt.Errorf("%w: %s", ErrSessionExpired, reason) + if rejected, reason := a.rejectedState(); rejected { + return fmt.Errorf("%w: %s", ErrSessionRejected, reason) } a.mu.RLock() defer a.mu.RUnlock() diff --git a/plugins/channels/weixin_ilink/login.go b/plugins/channels/weixin_ilink/login.go index 087c164..b56bb68 100644 --- a/plugins/channels/weixin_ilink/login.go +++ b/plugins/channels/weixin_ilink/login.go @@ -145,7 +145,7 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) { // completeLogin persists the confirmed credentials and marks the session live. // It writes cfg under a.mu, then clears the session under loginMu (never nesting // the two locks), and finally signals the waiting Connect path. A confirmed scan -// delivers a fresh bot_token, which is the canonical way OUT of the expired state +// delivers a fresh bot_token, which is the canonical way OUT of the rejected state // (session_state.go): the rescan flag is cleared here. func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) { a.mu.Lock() @@ -175,14 +175,14 @@ func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) { // completeBindedRedirect treats binded_redirect as an already-bound outcome: the // scanned bot is already linked to this instance, so the server issues no new // credentials and any existing token remains valid. Because NO new token arrives, -// an expired session stays expired here — binded_redirect carries no proof that a +// a rejected session stays rejected here — binded_redirect carries no proof that a // token iLink already rejected with errcode=-14 works again, so the adapter does // not claim a live session on the strength of it. A confirmed scan (completeLogin) -// is what clears the expired state. +// is what clears the rejected state. func (a *Adapter) completeBindedRedirect(sess *loginSession) { a.log.Info("weixin_ilink: login already bound (binded_redirect)") a.mu.Lock() - a.connected = a.cfg.BotToken != "" && !a.expiredLocked() + a.connected = a.cfg.BotToken != "" && !a.rejectedLocked() a.loginStatus = LoginStatusConfirmed a.loginDetail = loginBindedRedirectDetail a.mu.Unlock() @@ -218,12 +218,12 @@ func (a *Adapter) failLogin(sess *loginSession, err error) { // localTokenList returns the locally-held bot tokens to advertise on // get_bot_qrcode, so an already-bound bot can be detected (binded_redirect). On a // fresh bind it returns an empty (non-nil) slice so the wire carries `[]` rather -// than `null`, matching the official client. An EXPIRED token is deliberately not -// advertised: it is not a usable bind, and offering it lets the server answer -// binded_redirect (no new credentials) — which would leave the owner stuck with a -// dead token and no way back. Reporting `[]` asks for a real rebind. +// than `null`, matching the official client. A token iLink REJECTED is deliberately +// not advertised: it is not a usable bind, and offering it lets the server answer +// binded_redirect (no new credentials) — which would leave the owner stuck with an +// unusable credential and no way back. Reporting `[]` asks for a real rebind. func (a *Adapter) localTokenList() []string { - if expired, _ := a.expiredState(); expired { + if rejected, _ := a.rejectedState(); rejected { return []string{} } a.mu.RLock() diff --git a/plugins/channels/weixin_ilink/login_admin.go b/plugins/channels/weixin_ilink/login_admin.go index 92513ae..4f7885f 100644 --- a/plugins/channels/weixin_ilink/login_admin.go +++ b/plugins/channels/weixin_ilink/login_admin.go @@ -119,11 +119,11 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) { a.loginMu.Lock() defer a.loginMu.Unlock() - // Already bound → scanning is moot — unless the stored token is EXPIRED: iLink - // rejected it with errcode=-14, so it is not a usable bind and the owner must - // be able to re-scan (otherwise the only way out of the expired state would be + // Already bound → scanning is moot — unless iLink REJECTED the stored token + // (errcode=-14), so it is not a usable bind and the owner must be able to + // re-scan (otherwise the only way out of the rejected state would be // unreachable). A live token still short-circuits the QR flow as before. - if expired, _ := a.expiredState(); a.hasToken() && !expired { + if rejected, _ := a.rejectedState(); a.hasToken() && !rejected { return LoginQR{}, ErrAlreadyLoggedIn } if sess := a.loginSession; sess != nil { @@ -181,19 +181,19 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) { // LoginStatus reports the current scan-login state. A non-empty bot token // overrides to "confirmed" even if a stale session is still recorded, so the panel -// always sees the true binding — except when that token is EXPIRED: iLink rejected -// it with errcode=-14, so reporting "confirmed" would make the panel re-persist a -// dead credential and call the bind a success. An expired bind is reported as -// failed with the re-scan reason instead, while a re-scan already in progress +// always sees the true binding — except when iLink REJECTED that token +// (errcode=-14), so reporting "confirmed" would make the panel re-persist a dead +// credential and call the bind a success. A rejected bind is reported as failed +// with the re-scan reason instead, while a re-scan already in progress // keeps its own state. detail provides the bot id on confirmation and the failure // reason on failure. func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) { a.mu.Lock() defer a.mu.Unlock() - if a.expiredLocked() { + if a.rejectedLocked() { if a.loginStatus == LoginStatusWaiting || a.loginStatus == LoginStatusNeedVerifyCode { // A re-scan the owner already started: report its progress, not the - // expired state it is meant to replace. + // rejected state it is meant to replace. return a.loginStatus, a.loginDetail } return LoginStatusFailed, a.rescanDetail diff --git a/plugins/channels/weixin_ilink/poll.go b/plugins/channels/weixin_ilink/poll.go index c37e82b..32884c9 100644 --- a/plugins/channels/weixin_ilink/poll.go +++ b/plugins/channels/weixin_ilink/poll.go @@ -35,7 +35,7 @@ const ( type getupdatesResp struct { // Ret is the top-level business result; non-zero is a transient error. Ret int `json:"ret"` - // Errcode is the platform error code; -14 means the session expired. + // Errcode is the platform error code; -14 means iLink rejected the session. Errcode int `json:"errcode"` // Msgs holds the raw inbound messages delivered in this poll. Msgs []json.RawMessage `json:"msgs"` @@ -94,13 +94,13 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes } failures = 0 - if resp.Errcode == errSessionExpired { - // The bot token is dead: retrying cannot help and the poll loop would go - // mute for good. Record the explicit expired state (which also drops the - // connected flag and logs at ERROR) and stop the loop; the owner has to - // re-scan, and everything downstream sees ErrSessionExpired until a fresh - // token arrives. - a.markExpired(rescanReasonGetUpdates) + if resp.Errcode == errcodeSessionRejected { + // iLink rejected this session credential: retrying cannot help and the poll + // loop would go mute for good. Record the explicit rejected state (which also + // drops the connected flag and logs at ERROR) and stop the loop; the owner + // has to re-scan, and everything downstream sees ErrSessionRejected until a + // fresh token arrives. + a.markRejected(rescanReasonGetUpdates) return } if resp.Ret != 0 { diff --git a/plugins/channels/weixin_ilink/poll_test.go b/plugins/channels/weixin_ilink/poll_test.go index 0af3461..e38118d 100644 --- a/plugins/channels/weixin_ilink/poll_test.go +++ b/plugins/channels/weixin_ilink/poll_test.go @@ -216,7 +216,7 @@ func TestReadMessages_GroupMessage(t *testing.T) { } } -func TestReadMessages_SessionExpired(t *testing.T) { +func TestReadMessages_SessionRejected(t *testing.T) { t.Parallel() srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { if req.path != "/ilink/bot/getupdates" { @@ -237,11 +237,11 @@ func TestReadMessages_SessionExpired(t *testing.T) { case <-done: case <-time.After(2 * time.Second): cancel() - t.Fatal("ReadMessages did not stop on session expired") + t.Fatal("ReadMessages did not stop on a session iLink rejected") } cancel() if ad.IsConnected() { - t.Fatal("IsConnected() = true after session expired") + t.Fatal("IsConnected() = true after iLink rejected the session") } } diff --git a/plugins/channels/weixin_ilink/send.go b/plugins/channels/weixin_ilink/send.go index 636a3df..bbbd56f 100644 --- a/plugins/channels/weixin_ilink/send.go +++ b/plugins/channels/weixin_ilink/send.go @@ -73,13 +73,12 @@ func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) return a.sendToOne(ctx, peer, msg) } -// sendGuard reports the error a send must surface before touching the network: an -// expired session (iLink already refused this token with errcode=-14) reads as -// ErrSessionExpired — the owner must re-scan — while any other down session stays -// ErrNotConnected. +// sendGuard reports the error a send must surface before touching the network: a +// session iLink already rejected (errcode=-14) reads as ErrSessionRejected — the +// owner must re-scan — while any other down session stays ErrNotConnected. func (a *Adapter) sendGuard() error { - if expired, reason := a.expiredState(); expired { - return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionExpired, reason) + if rejected, reason := a.rejectedState(); rejected { + return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionRejected, reason) } if !a.IsConnected() { return adapter.ErrNotConnected @@ -165,12 +164,12 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [ bytes.NewReader(body), &out); err != nil { return fmt.Errorf("weixin_ilink: sendmessage: %w", err) } - if out.Errcode == errSessionExpired { - // The send path sees the same dead token as the poll path: record the - // expired state so the adapter stops claiming a live session and the panel - // can ask for a re-scan. - a.markExpired(rescanReasonSendMessage) - return ErrSessionExpired + if out.Errcode == errcodeSessionRejected { + // The send path sees the same rejected credential as the poll path: record + // the rejected state so the adapter stops claiming a live session and the + // panel can ask for a re-scan. + a.markRejected(rescanReasonSendMessage) + return ErrSessionRejected } if out.Ret != 0 { return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret) @@ -190,11 +189,11 @@ func (a *Adapter) getUploadURL(ctx context.Context) (string, error) { bytes.NewReader(body), &raw); err != nil { return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err) } - if intOf(raw["errcode"]) == errSessionExpired { - // Media upload is part of the send path, so the same dead token is recorded - // here too rather than degrading into "no url returned". - a.markExpired(rescanReasonUploadURL) - return "", ErrSessionExpired + if intOf(raw["errcode"]) == errcodeSessionRejected { + // Media upload is part of the send path, so the same rejected credential is + // recorded here too rather than degrading into "no url returned". + a.markRejected(rescanReasonUploadURL) + return "", ErrSessionRejected } u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url") if u == "" { diff --git a/plugins/channels/weixin_ilink/session_state.go b/plugins/channels/weixin_ilink/session_state.go index bb166bf..9e23e3c 100644 --- a/plugins/channels/weixin_ilink/session_state.go +++ b/plugins/channels/weixin_ilink/session_state.go @@ -1,82 +1,89 @@ package weixin_ilink -// This file holds the adapter's session-expiry state machine (design/08 §4-§5). +// This file holds the adapter's session-rejection state machine (design/08 §4-§5). // -// iLink answers errcode=-14 when the bot token is no longer valid. That is not a -// transient failure the read loop can retry away: the token is dead until the -// owner scans a fresh QR code. The adapter therefore keeps an EXPLICIT expired -// flag instead of silently dropping the connection, so: +// iLink answers errcode=-14 when it rejects or invalidates the session credential. +// The bot token is a LONG-LIVED credential — a token obtained by scanning a month +// ago still works — so -14 is NOT a time-based expiry: it means iLink no longer +// accepts this session (unbound, re-logged-in elsewhere, a protocol-level refusal). +// It is worth recording that no log has ever actually shown -14; the earlier +// reading of it as a time-based expiry was an inference, not an observation. +// +// Either way it is not a transient failure the read loop can retry away: the same +// credential keeps being refused until the owner scans a fresh QR code. The adapter +// therefore keeps an EXPLICIT rejected flag instead of silently dropping the +// connection, so: // // - the read loop cannot exit and leave the adapter quietly mute while still -// reporting "connected" (the "过期后静默变哑" defect); -// - Connect/HealthCheck/send surface ErrSessionExpired — "re-scan required" — +// reporting "connected" (the "credential rejected → silently mute" defect); +// - Connect/HealthCheck/send surface ErrSessionRejected — "re-scan required" — // rather than a fake OK or a generic "not connected"; // - the admin panel can read RescanRequired() and show 需要重新扫码 (see // internal/services/gateway/admin.go). // // Lifecycle (set once, cleared on a usable credential): // -// SET markExpired iLink returned errcode=-14 on getupdates, +// SET markRejected iLink returned errcode=-14 on getupdates, // sendmessage or getuploadurl. // CLEAR clearRescanLocked a scan-code login delivered a new bot_token // (completeLogin), or -// expiredLocked saw a bot_token different from the one +// rejectedLocked saw a bot_token different from the one // iLink rejected — a config change / hot reload that // supplied a fresh token. -// Rescan reasons recorded on the expired state. They are short, human-readable +// Rescan reasons recorded on the rejected state. They are short, human-readable // and carry no credential: the bot token itself never leaves cfg. const ( - rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(bot token 已过期)" - rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(bot token 已过期)" - rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(bot token 已过期)" + rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)" + rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)" + rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)" ) -// markExpired records that iLink rejected the current bot token (errcode=-14). -// It drops the connected flag so IsConnected can never keep claiming a live -// session for a dead token, and logs the transition at ERROR level exactly once -// (a failing poll loop or a repeated send must not flood the log). Only the -// non-secret bot_id and the reason are logged. -func (a *Adapter) markExpired(reason string) { +// markRejected records that iLink rejected the current session credential +// (errcode=-14). It drops the connected flag so IsConnected can never keep claiming +// a live session for a rejected credential, and logs the transition at ERROR level +// exactly once (a failing poll loop or a repeated send must not flood the log). Only +// the non-secret bot_id and the reason are logged. +func (a *Adapter) markRejected(reason string) { a.mu.Lock() - first := !a.expired - a.expired = true - a.expiredToken = a.cfg.BotToken + first := !a.rejected + a.rejected = true + a.rejectedToken = a.cfg.BotToken a.rescanDetail = reason a.connected = false botID := a.cfg.BotID a.mu.Unlock() if first { - a.log.Error("ilink session expired: 需要重新扫码登录(请用「扫码登录」重新绑定)", - "bot_id", botID, "errcode", errSessionExpired, "reason", reason) + a.log.Error("ilink session rejected(errcode=-14):本次会话已被 iLink 拒绝,需要重新扫码绑定(请用「扫码登录」)", + "bot_id", botID, "errcode", errcodeSessionRejected, "reason", reason) } } -// clearRescanLocked drops the expired state. Callers must hold a.mu. +// clearRescanLocked drops the rejected state. Callers must hold a.mu. func (a *Adapter) clearRescanLocked() { - a.expired = false - a.expiredToken = "" + a.rejected = false + a.rejectedToken = "" a.rescanDetail = "" } -// expiredLocked reports whether the session is expired. It first clears the state -// when the configured bot_token is no longer the one iLink rejected, so a fresh -// token (scan login, operator config change, hot reload) restores the session +// rejectedLocked reports whether iLink rejected the session. It first clears the +// state when the configured bot_token is no longer the one iLink rejected, so a +// fresh token (scan login, operator config change, hot reload) restores the session // without extra bookkeeping. Callers must hold a.mu. -func (a *Adapter) expiredLocked() bool { - if a.expired && a.expiredToken != a.cfg.BotToken { +func (a *Adapter) rejectedLocked() bool { + if a.rejected && a.rejectedToken != a.cfg.BotToken { a.clearRescanLocked() } - return a.expired + return a.rejected } -// expiredState reports the expired flag together with the rescan reason in one -// lock acquisition. reason is "" when the session is not expired. -func (a *Adapter) expiredState() (bool, string) { +// rejectedState reports the rejected flag together with the rescan reason in one +// lock acquisition. reason is "" when the session has not been rejected. +func (a *Adapter) rejectedState() (bool, string) { a.mu.Lock() defer a.mu.Unlock() - if !a.expiredLocked() { + if !a.rejectedLocked() { return false, "" } return true, a.rescanDetail @@ -88,5 +95,5 @@ func (a *Adapter) expiredState() (bool, string) { // human-readable reason (never a secret). A healthy adapter reports false with an // empty reason. func (a *Adapter) RescanRequired() (bool, string) { - return a.expiredState() + return a.rejectedState() } diff --git a/plugins/channels/weixin_ilink/session_state_test.go b/plugins/channels/weixin_ilink/session_state_test.go index 7dd023e..0178f08 100644 --- a/plugins/channels/weixin_ilink/session_state_test.go +++ b/plugins/channels/weixin_ilink/session_state_test.go @@ -11,26 +11,27 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// These tests cover the explicit session-expiry state machine (session_state.go): +// These tests cover the explicit session-rejection state machine (session_state.go): // iLink answering errcode=-14 must be visible as "re-scan required" instead of a -// silently mute adapter or a connect that reports success with a dead token. +// silently mute adapter or a connect that reports success with a credential iLink +// already refused. -// expiringServer answers every getupdates long poll with the expired-session -// errcode, the way iLink reports a dead bot token. -func expiringServer(t *testing.T) *httptest.Server { +// rejectingServer answers every getupdates long poll with the rejected-session +// errcode, the way iLink refuses a session credential. +func rejectingServer(t *testing.T) *httptest.Server { t.Helper() return newJSONServer(t, nil, func(req recordedReq) handlerResult { if req.path != "/ilink/bot/getupdates" { return handlerResult{status: 404} } - return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})} + return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})} }) } -// expireByPoll drives the adapter into the expired state through the long poll: +// rejectByPoll drives the adapter into the rejected state through the long poll: // ReadMessages must return on errcode=-14 and the adapter must report the // rescan-required state. -func expireByPoll(t *testing.T, ad *Adapter) { +func rejectByPoll(t *testing.T, ad *Adapter) { t.Helper() done := make(chan struct{}) go func() { @@ -43,22 +44,23 @@ func expireByPoll(t *testing.T, ad *Adapter) { } } -// newExpiredByPollAdapter builds a connected adapter and expires it via the poll -// path. -func newExpiredByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter { +// newRejectedByPollAdapter builds a connected adapter and drives it into the +// rejected state via the poll path. +func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter { t.Helper() ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv) - expireByPoll(t, ad) + rejectByPoll(t, ad) return ad } -// assertExpiredSession asserts the observable contract of an expired session: not -// connected, rescan required with a reason, HealthCheck distinguishing expired from -// not-connected, and Connect refusing with ErrSessionExpired instead of a fake OK. -func assertExpiredSession(t *testing.T, ad *Adapter) { +// assertRejectedSession asserts the observable contract of a session iLink rejected: +// not connected, rescan required with a reason, HealthCheck distinguishing the +// rejected state from not-connected, and Connect refusing with ErrSessionRejected +// instead of a fake OK. +func assertRejectedSession(t *testing.T, ad *Adapter) { t.Helper() if ad.IsConnected() { - t.Fatal("IsConnected() = true while the session is expired") + t.Fatal("IsConnected() = true while iLink rejects the session") } required, reason := ad.RescanRequired() if !required { @@ -69,40 +71,40 @@ func assertExpiredSession(t *testing.T, ad *Adapter) { } herr := ad.HealthCheck(context.Background()) - if !errors.Is(herr, ErrSessionExpired) { - t.Fatalf("HealthCheck() = %v, want ErrSessionExpired", herr) + if !errors.Is(herr, ErrSessionRejected) { + t.Fatalf("HealthCheck() = %v, want ErrSessionRejected", herr) } if errors.Is(herr, adapter.ErrNotConnected) { - t.Fatal("HealthCheck() also matches ErrNotConnected, want expired to be distinguishable") + t.Fatal("HealthCheck() also matches ErrNotConnected, want the rejected state to be distinguishable") } cerr := ad.Connect(context.Background()) if cerr == nil { - t.Fatal("Connect() = nil on an expired session, want ErrSessionExpired (no fake OK)") + t.Fatal("Connect() = nil on a rejected session, want ErrSessionRejected (no fake OK)") } - if !errors.Is(cerr, ErrSessionExpired) { - t.Fatalf("Connect() = %v, want ErrSessionExpired", cerr) + if !errors.Is(cerr, ErrSessionRejected) { + t.Fatalf("Connect() = %v, want ErrSessionRejected", cerr) } if errors.Is(cerr, ErrNeedsLogin) { - t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionExpired (expired is not 'unconfigured')") + t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionRejected (a rejected session is not 'unconfigured')") } if ad.IsConnected() { - t.Fatal("IsConnected() = true after a refused Connect on an expired session") + t.Fatal("IsConnected() = true after a refused Connect on a rejected session") } } -func TestSessionExpired_PollMarksRescanAndRefusesConnect(t *testing.T) { +func TestSessionRejected_PollMarksRescanAndRefusesConnect(t *testing.T) { t.Parallel() - srv := expiringServer(t) - ad := newExpiredByPollAdapter(t, srv) - assertExpiredSession(t, ad) + srv := rejectingServer(t) + ad := newRejectedByPollAdapter(t, srv) + assertRejectedSession(t, ad) } -func TestSessionExpired_SendMarksRescan(t *testing.T) { +func TestSessionRejected_SendMarksRescan(t *testing.T) { t.Parallel() srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" { - return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})} + return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})} } return handlerResult{status: 404} }) @@ -112,33 +114,33 @@ func TestSessionExpired_SendMarksRescan(t *testing.T) { err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}}, }) - if !errors.Is(err, ErrSessionExpired) { - t.Fatalf("SendMessage() = %v, want ErrSessionExpired", err) + if !errors.Is(err, ErrSessionRejected) { + t.Fatalf("SendMessage() = %v, want ErrSessionRejected", err) } if ad.IsConnected() { t.Fatal("IsConnected() = true after a send saw errcode=-14") } - // A later send reports the expired session itself, not a bare "not connected". + // A later send reports the rejected session itself, not a bare "not connected". err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}}, }) - if !errors.Is(err, ErrSessionExpired) { - t.Fatalf("second SendMessage() = %v, want ErrSessionExpired", err) + if !errors.Is(err, ErrSessionRejected) { + t.Fatalf("second SendMessage() = %v, want ErrSessionRejected", err) } perr := ad.SendProactive(context.Background(), adapter.UnifiedMessage{SenderID: "u-1", Content: "push"}) - if !errors.Is(perr, ErrSessionExpired) { - t.Fatalf("SendProactive() = %v, want ErrSessionExpired", perr) + if !errors.Is(perr, ErrSessionRejected) { + t.Fatalf("SendProactive() = %v, want ErrSessionRejected", perr) } - assertExpiredSession(t, ad) + assertRejectedSession(t, ad) } -func TestSessionExpired_ClearedByScanLogin(t *testing.T) { +func TestSessionRejected_ClearedByScanLogin(t *testing.T) { t.Parallel() rec := &reqRecorder{} srv := newJSONServer(t, rec, func(req recordedReq) handlerResult { switch req.path { case "/ilink/bot/getupdates": - return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})} + return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})} case "/ilink/bot/get_bot_qrcode": return handlerResult{body: jsonBody(map[string]any{ "qrcode": "qr-re", "qrcode_img_content": "https://img/qr", @@ -151,20 +153,20 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) { } return handlerResult{status: 404} }) - ad := newExpiredByPollAdapter(t, srv) - assertExpiredSession(t, ad) + ad := newRejectedByPollAdapter(t, srv) + assertRejectedSession(t, ad) - // An expired token must not short-circuit the scan flow: it is not a usable + // A rejected token must not short-circuit the scan flow: it is not a usable // bind, and ErrAlreadyLoggedIn would leave the owner no way back. qr, err := ad.StartLogin(context.Background()) if err != nil { - t.Fatalf("StartLogin() on an expired session = %v, want a fresh QR", err) + t.Fatalf("StartLogin() on a rejected session = %v, want a fresh QR", err) } if qr.Qrcode != "qr-re" { t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode) } - // The confirmed scan delivers a new token: the expired state must clear. + // The confirmed scan delivers a new token: the rejected state must clear. waitFor(t, func() bool { ok, _ := ad.RescanRequired(); return !ok }) if err := ad.Connect(context.Background()); err != nil { t.Fatalf("Connect() after re-scan = %v, want nil", err) @@ -182,7 +184,7 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) { t.Fatalf("LoginStatus() = %q, want confirmed after a successful re-scan", status) } - // The re-scan asked for a real rebind: the dead token is not advertised as a + // The re-scan asked for a real rebind: the rejected token is not advertised as a // local token (that is what invites binded_redirect with no new credentials). reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) if len(reqs) != 1 { @@ -195,17 +197,17 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) { } } -func TestSessionExpired_ClearedByNewToken(t *testing.T) { +func TestSessionRejected_ClearedByNewToken(t *testing.T) { t.Parallel() - srv := expiringServer(t) - ad := newExpiredByPollAdapter(t, srv) + srv := rejectingServer(t) + ad := newRejectedByPollAdapter(t, srv) - // The rejected token is still configured: the session stays expired. - if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionExpired) { - t.Fatalf("Connect() = %v, want ErrSessionExpired while the rejected token is unchanged", err) + // The rejected token is still configured: the session stays rejected. + if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionRejected) { + t.Fatalf("Connect() = %v, want ErrSessionRejected while the rejected token is unchanged", err) } - if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionExpired) { - t.Fatalf("HealthCheck() = %v, want ErrSessionExpired while the rejected token is unchanged", err) + if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionRejected) { + t.Fatalf("HealthCheck() = %v, want ErrSessionRejected while the rejected token is unchanged", err) } setBotToken(ad, "tok-fresh") @@ -223,12 +225,12 @@ func TestSessionExpired_ClearedByNewToken(t *testing.T) { } } -func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) { +func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) { t.Parallel() srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { switch req.path { case "/ilink/bot/getupdates": - return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})} + return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})} case "/ilink/bot/get_bot_qrcode": return handlerResult{body: jsonBody(map[string]any{ "qrcode": "qr-2", "qrcode_img_content": "https://img/qr2", @@ -241,13 +243,13 @@ func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) { LoginInterval: time.Millisecond, LoginTimeout: 30 * time.Millisecond, BotToken: "tok-old", }, srv) - expireByPoll(t, ad) + rejectByPoll(t, ad) - // A dead token must not read as "confirmed": the panel would re-persist it and - // call the bind a success. + // A rejected credential must not read as "confirmed": the panel would re-persist + // it and call the bind a success. status, detail := ad.LoginStatus(context.Background()) if status != LoginStatusFailed { - t.Fatalf("LoginStatus() = %q, want failed while the stored token is expired", status) + t.Fatalf("LoginStatus() = %q, want failed while iLink rejects the stored token", status) } if detail == "" { t.Fatal("LoginStatus() detail is empty, want the re-scan reason") @@ -262,7 +264,7 @@ func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) { if qr.Qrcode != "qr-2" { t.Fatalf("StartLogin() qrcode = %q, want qr-2", qr.Qrcode) } - // While the re-scan is in progress its own state is reported, not the expired one. + // While the re-scan is in progress its own state is reported, not the rejected one. if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting { t.Fatalf("LoginStatus() during the re-scan = %q, want waiting", status) }