主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
215 lines
9.0 KiB
Go
215 lines
9.0 KiB
Go
package backthink
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||
)
|
||
|
||
// 本文件锁定「主动消息没能送到微信」这件事**被如实说清楚**(主人 2026-10-07:
|
||
// 「可以,这有必要,不然我老是收不到」)。真机日志是:
|
||
//
|
||
// level=WARN msg="backthink IM push failed" ... error="channelbridge: send proactive via
|
||
// \"weixin-ilink\": channelbridge: send bubble #1 of 8: weixin_ilink: proactive push outside 24h window"
|
||
// level=INFO msg="backthink proactive push" ... route=active_client pushed=true
|
||
// reason="delivered to 2 active client(s), channel_hint=weixin-ilink"
|
||
//
|
||
// 第二行是对的,但它让第一行彻底消失:记录与面板只说「已送达」,于是「她的话没进微信」
|
||
// 只存在于一条没人看的自由文本 WARN 里 —— 主人反复收不到,面板却是绿的。
|
||
//
|
||
// 三条不可退让的口径(对应主人的三条要求):
|
||
// 1. **分类化 + 不假成功**:窗口关闭必须带稳定原因码,且 IM 那条路绝不进 pushed=true;
|
||
// 2. **可见**:结构化 WARN(category/channel)+ 审计 + 思考记录的 push_detail 都带上它;
|
||
// 3. **不丢消息**:IM 路失败**不得**影响活跃客户端那条路(消息照旧送达并落库)。
|
||
|
||
// captureLog 返回一个把日志写进缓冲区的日志器,供断言结构化字段。
|
||
func captureLog() (*slog.Logger, *bytes.Buffer) {
|
||
buf := &bytes.Buffer{}
|
||
return slog.New(slog.NewTextHandler(buf, nil)), buf
|
||
}
|
||
|
||
// auditRecorder 记录 pusher 审计出口的 (what, result) 对。
|
||
type auditRecorder struct {
|
||
rows []auditRow
|
||
}
|
||
|
||
func (a *auditRecorder) record(what, result string) {
|
||
a.rows = append(a.rows, auditRow{who: "backthink", what: what, result: result})
|
||
}
|
||
|
||
// TestPusher_IMWindowClosed_FallbackKeepsFailureVisible:IM 路被 24 小时窗口拒绝、
|
||
// 活跃客户端路成功时,**两条事实都要在结果里**:消息确实送到了 Web(不假失败),
|
||
// 但微信那条路没送到、原因是 proactive_window_closed(不假成功)。
|
||
func TestPusher_IMWindowClosed_FallbackKeepsFailureVisible(t *testing.T) {
|
||
t.Parallel()
|
||
logger, logs := captureLog()
|
||
audit := &auditRecorder{}
|
||
active := &mockActive{n: 2, persisted: true}
|
||
im := &mockIMAdapter{err: weixin_ilink.ErrProactiveWindow}
|
||
p := newPusher(active, im, &mockGate{allow: true}, logger).withAuditor(audit.record)
|
||
|
||
res := p.Push(context.Background(), PushRequest{
|
||
ChannelHint: "weixin-ilink",
|
||
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
|
||
})
|
||
|
||
// ③ 不丢消息:活跃客户端那条路照旧成功,且拿到的是**同一条**消息。
|
||
if !res.Pushed || res.Route != RouteActiveClient {
|
||
t.Fatalf("res = %+v, want pushed via active_client (IM 失败不得连带丢掉另一条路)", res)
|
||
}
|
||
if active.calls != 1 || active.last.Content != "记得喝水哦" {
|
||
t.Fatalf("active calls = %d last = %q, want 1 call with the message text", active.calls, active.last.Content)
|
||
}
|
||
// ① 微信未送达这件事留在结果里(面板「推送说明」直接读 Detail)。
|
||
if !strings.Contains(res.Detail, "im_undelivered=weixin-ilink:proactive_window_closed") {
|
||
t.Fatalf("Detail = %q, want im_undelivered=weixin-ilink:proactive_window_closed", res.Detail)
|
||
}
|
||
if !strings.Contains(res.Detail, "channel_hint=weixin-ilink") {
|
||
t.Fatalf("Detail = %q, want the requested channel hint too", res.Detail)
|
||
}
|
||
if res.Err != "" {
|
||
t.Fatalf("Err = %q, want empty:消息已由活跃客户端送达,错因在 Detail 里如实标注", res.Err)
|
||
}
|
||
|
||
// ② 结构化日志:来源(调用方注入的 logger)+ 错误类别 + 渠道 + 原文。
|
||
log := logs.String()
|
||
for _, want := range []string{
|
||
`msg="backthink IM push failed"`,
|
||
"category=proactive_window_closed",
|
||
"channel=weixin-ilink",
|
||
"route=im_bridge",
|
||
"outside 24h window",
|
||
} {
|
||
if !strings.Contains(log, want) {
|
||
t.Fatalf("log = %q, want it to contain %q", log, want)
|
||
}
|
||
}
|
||
|
||
// ② 审计:一条可读的行,说清渠道、原因码与这一轮最终落到哪。
|
||
if len(audit.rows) != 1 {
|
||
t.Fatalf("audit rows = %+v, want exactly 1", audit.rows)
|
||
}
|
||
row := audit.rows[0]
|
||
if row.what != whatPushIMUndelivered {
|
||
t.Fatalf("audit what = %q, want %q", row.what, whatPushIMUndelivered)
|
||
}
|
||
for _, want := range []string{"weixin-ilink", "proactive_window_closed", "Web 活跃客户端"} {
|
||
if !strings.Contains(row.result, want) {
|
||
t.Fatalf("audit result = %q, want it to contain %q", row.result, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestPusher_IMWindowClosed_NoFallbackNotPushed:两条路都没成时**绝不**报 pushed=true,
|
||
// 且 IM 那次的错因是主因(落进 Err),失败仍有日志与审计两个出口 —— 不静默。
|
||
func TestPusher_IMWindowClosed_NoFallbackNotPushed(t *testing.T) {
|
||
t.Parallel()
|
||
logger, logs := captureLog()
|
||
audit := &auditRecorder{}
|
||
// 没有在线客户端、也没落库 ⇒ 降级路同样失败。
|
||
active := &mockActive{n: 0, persisted: false}
|
||
im := &mockIMAdapter{err: weixin_ilink.ErrProactiveWindow}
|
||
p := newPusher(active, im, &mockGate{allow: true}, logger).withAuditor(audit.record)
|
||
|
||
res := p.Push(context.Background(), PushRequest{
|
||
ChannelHint: "weixin-ilink",
|
||
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
|
||
})
|
||
|
||
if res.Pushed {
|
||
t.Fatalf("res = %+v, want Pushed=false (窗口关闭 + 无客户端 = 真的没送到)", res)
|
||
}
|
||
if res.Route == RouteIMBridge {
|
||
t.Fatalf("Route = %q, want it NOT to claim the IM route delivered", res.Route)
|
||
}
|
||
if !strings.Contains(res.Detail, "im_undelivered=weixin-ilink:proactive_window_closed") {
|
||
t.Fatalf("Detail = %q, want the undelivered tag", res.Detail)
|
||
}
|
||
if !strings.Contains(res.Err, "outside 24h window") {
|
||
t.Fatalf("Err = %q, want the IM failure as the primary error", res.Err)
|
||
}
|
||
if log := logs.String(); !strings.Contains(log, "category=proactive_window_closed") {
|
||
t.Fatalf("log = %q, want the categorized WARN", log)
|
||
}
|
||
if len(audit.rows) != 1 {
|
||
t.Fatalf("audit rows = %+v, want exactly 1", audit.rows)
|
||
}
|
||
if !strings.Contains(audit.rows[0].result, "没有送达") {
|
||
t.Fatalf("audit result = %q, want it to say the round was not delivered", audit.rows[0].result)
|
||
}
|
||
}
|
||
|
||
// TestPusher_NoIMTargetIsNotChannelFailure:IM 槽未接线**不是**渠道故障(没有渠道
|
||
// 可失败),因此不该被标成「某渠道未送达」,也不该产生审计噪声。
|
||
func TestPusher_NoIMTargetIsNotChannelFailure(t *testing.T) {
|
||
t.Parallel()
|
||
audit := &auditRecorder{}
|
||
active := &mockActive{n: 1, persisted: true}
|
||
p := newPusher(active, nil, &mockGate{allow: true}, discardLog()).withAuditor(audit.record)
|
||
|
||
res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "晚安"}})
|
||
|
||
if !res.Pushed || res.Route != RouteActiveClient {
|
||
t.Fatalf("res = %+v, want pushed via active_client", res)
|
||
}
|
||
if strings.Contains(res.Detail, "im_undelivered") {
|
||
t.Fatalf("Detail = %q, want no im_undelivered tag (nothing failed)", res.Detail)
|
||
}
|
||
if len(audit.rows) != 0 {
|
||
t.Fatalf("audit rows = %+v, want none", audit.rows)
|
||
}
|
||
}
|
||
|
||
// TestThink_IMWindowClosed_RecordAndAuditCarryTheCode 是**端到端**探针:她真的决定
|
||
// 推送到 weixin-ilink、桥那边因窗口关闭拒绝、Web 客户端接手 —— 落库的那条思考记录
|
||
// (面板读侧)必须带着「微信未送达 + 原因码」,审计也必须有一行。
|
||
func TestThink_IMWindowClosed_RecordAndAuditCarryTheCode(t *testing.T) {
|
||
t.Parallel()
|
||
logger, logs := captureLog()
|
||
audit := &fakeAuditor{}
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Thinker: &mockThinker{text: "[我决定:push]\n[渠道:weixin-ilink]\n记得喝水哦"},
|
||
State: &mockState{state: UserState{}},
|
||
Active: &mockActive{n: 2, persisted: true},
|
||
IM: &mockIMAdapter{err: weixin_ilink.ErrProactiveWindow},
|
||
Gate: &mockGate{allow: true},
|
||
Audit: audit,
|
||
Log: logger,
|
||
Now: func() time.Time { return dayAt() },
|
||
})
|
||
|
||
b.think(context.Background(), heartbeatTrigger())
|
||
|
||
recs, err := b.List(context.Background(), 10)
|
||
if err != nil {
|
||
t.Fatalf("List: %v", err)
|
||
}
|
||
if len(recs) == 0 {
|
||
t.Fatal("no record persisted")
|
||
}
|
||
rec := recs[0].PushResult
|
||
if !rec.Pushed || rec.Route != RouteActiveClient {
|
||
t.Fatalf("record push = %+v, want pushed via active_client", rec)
|
||
}
|
||
if !strings.Contains(rec.Detail, "im_undelivered=weixin-ilink:proactive_window_closed") {
|
||
t.Fatalf("record Detail = %q, want the undelivered tag (面板「推送说明」读它)", rec.Detail)
|
||
}
|
||
if got := audit.countOf(whatPushIMUndelivered); got != 1 {
|
||
t.Fatalf("audit %q = %d 条, want 1(whats=%v)", whatPushIMUndelivered, got, audit.whats())
|
||
}
|
||
if who := audit.rows[0].who; who != "backthink" {
|
||
t.Fatalf("audit who = %q, want backthink(沿用后台思考既有口径)", who)
|
||
}
|
||
// 日志带来源(services/backthink),因此这条失败在整机日志里可回溯。
|
||
if log := logs.String(); !strings.Contains(log, "source=services/backthink") ||
|
||
!strings.Contains(log, "category=proactive_window_closed") {
|
||
t.Fatalf("log = %q, want a sourced, categorized WARN", log)
|
||
}
|
||
}
|