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) } }