feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
This commit is contained in:
AskaEth committed 2026-10-07 10:59:22 +08:00
1 parent bff57bd1ae
commit 353293e807
9 files changed
+772 -31

No files matched your search

@@ -0,0 +1,75 @@
package channelbridge
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// 本文件锁定桥接器**再加一层包装**之后,适配器自报的失败原因码与渠道名依然读得出来。
//
// 为什么值得单独钉:真机事故(2026-10-07 10:38)里主人看到的是
//
// channelbridge: send proactive via "weixin-ilink": channelbridge: send bubble #1 of 8:
// weixin_ilink: proactive push outside 24h window
//
// —— 三层自由文本。若包装(%w 之外的任何改写)把自报信息吃掉,backthink 的日志、
// 思考记录与审计就只能复述这段文本,面板也就永远说不出「微信因为哪条规则没送到」。
// TestSendProactive_WindowClosedKeepsCategory 用真实 24h 窗口哨兵穿过整条桥接器
// 出口(多气泡包装 + 主动推送包装),验证码与渠道都还在。
func TestSendProactive_WindowClosedKeepsCategory(t *testing.T) {
t.Parallel()
ad := &fakeAdapter{name: "weixin-ilink", connected: true, sendErr: weixin_ilink.ErrProactiveWindow}
owner := testOwnerProfile("weixin-ilink", "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat")
b := newProactiveBridge(t, owner, ad)
msg := proactiveMsg("<p>一</p><p>二</p>")
msg.Platform = "weixin-ilink"
err := b.SendProactive(context.Background(), msg)
if err == nil {
t.Fatal("SendProactive = nil, want the window-closed failure (no fake success)")
}
if !errors.Is(err, weixin_ilink.ErrProactiveWindow) {
t.Fatalf("err = %v, want it to still match ErrProactiveWindow", err)
}
// 证明这次确实走了「多气泡 + 主动推送」两层包装(否则本用例没覆盖要防的那条路)。
if !strings.Contains(err.Error(), "send bubble #1 of 2") {
t.Fatalf("err = %v, want the multi-bubble wrapper (send bubble #1 of 2)", err)
}
if !strings.Contains(err.Error(), `send proactive via "weixin-ilink"`) {
t.Fatalf("err = %v, want the proactive wrapper naming the channel", err)
}
if got := adapter.DeliveryCategoryOf(err); got != "proactive_window_closed" {
t.Fatalf("category = %q, want proactive_window_closed", got)
}
if got := adapter.DeliveryChannelOf(err); got != "weixin-ilink" {
t.Fatalf("channel = %q, want weixin-ilink", got)
}
}
// TestSendProactive_UnreportedFailureStaysUnknown 是反面探针:适配器**没有**自报
// 原因码时,读取侧只能得到兜底码与「渠道未知」,绝不替它编一个更精确的码或渠道。
func TestSendProactive_UnreportedFailureStaysUnknown(t *testing.T) {
t.Parallel()
ad := &fakeAdapter{name: "weixin-ilink", connected: true, sendErr: errors.New("boom")}
owner := testOwnerProfile("weixin-ilink", "owner-1")
b := newProactiveBridge(t, owner, ad)
msg := proactiveMsg("只有一段")
msg.Platform = "weixin-ilink"
err := b.SendProactive(context.Background(), msg)
if err == nil {
t.Fatal("SendProactive = nil, want the failure")
}
if got := adapter.DeliveryCategoryOf(err); got != adapter.CategoryFailed {
t.Fatalf("category = %q, want the fallback %q", got, adapter.CategoryFailed)
}
if got := adapter.DeliveryChannelOf(err); got != "" {
t.Fatalf("channel = %q, want empty (unknown) — 不得替适配器猜渠道", got)
}
}