主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
81 lines
3.4 KiB
Go
81 lines
3.4 KiB
Go
package weixin_ilink
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
)
|
||
|
||
// 本文件锁定「微信这次发不出去」的**稳定原因码**:24 小时窗口关闭是**平台规则**,
|
||
// 不是崩溃,因此它必须能以机器可读的码(proactive_window_closed)+ 渠道名交给上层,
|
||
// 而不是只留一整串自由文本(真机日志 2026-10-07:「主动消息没能送到微信」在面板上
|
||
// 只表现为一句英文)。
|
||
//
|
||
// 🔴 口径:错误文本与 errors.Is 判定**逐字不变**(自描述包装只加码,不改语义),
|
||
// 因此本文件同时是「加码没改行为」的回归探针。
|
||
|
||
// TestSendProactive_ExpiredWindowCarriesCategory 走**真实适配器路径**(httptest 假
|
||
// 服务端 + 已确认连接 + 窗口外的时钟)验证原因码与渠道名,而不是只测哨兵值本身。
|
||
func TestSendProactive_ExpiredWindowCarriesCategory(t *testing.T) {
|
||
t.Parallel()
|
||
rec := &reqRecorder{}
|
||
srv := sendOKServer(t, rec)
|
||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
|
||
|
||
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||
ad.clock = func() time.Time { return now }
|
||
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
|
||
now = now.Add(25 * time.Hour)
|
||
|
||
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
|
||
SenderID: "u-1", Content: "too late",
|
||
})
|
||
if !errors.Is(err, ErrProactiveWindow) {
|
||
t.Fatalf("err = %v, want ErrProactiveWindow", 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 != platformName {
|
||
t.Fatalf("channel = %q, want %q", got, platformName)
|
||
}
|
||
// 文本与包装之前逐字一致:加码**没有**改任何既有语义。
|
||
if want := "weixin_ilink: proactive push outside 24h window"; err.Error() != want {
|
||
t.Fatalf("err text = %q, want %q", err.Error(), want)
|
||
}
|
||
}
|
||
|
||
// TestSentinelErrorsCarryCategories 是三个可操作失败的原因码表:窗口关闭
|
||
// (平台规则)、缺少会话凭证(无从关联会话)、会话被拒(必须重新扫码)。其余哨兵
|
||
// 不自报码 —— 它们落到契约层兜底码 failed,而不是被安一个更精确的假原因。
|
||
func TestSentinelErrorsCarryCategories(t *testing.T) {
|
||
t.Parallel()
|
||
tests := []struct {
|
||
name string
|
||
err error
|
||
want adapter.DeliveryCategory
|
||
wantChannel string
|
||
}{
|
||
{name: "主动推送窗口关闭", err: ErrProactiveWindow, want: "proactive_window_closed", wantChannel: platformName},
|
||
{name: "缺少会话凭证", err: ErrNoContextToken, want: "no_context_token", wantChannel: platformName},
|
||
{name: "会话被拒(需重新扫码)", err: ErrSessionRejected, want: "session_rejected", wantChannel: platformName},
|
||
// 未自报的哨兵连渠道也不报:读取侧得到空串(= 未知),不替它猜平台名。
|
||
{name: "未自报的哨兵落兜底", err: ErrMediaTooLarge, want: adapter.CategoryFailed, wantChannel: ""},
|
||
}
|
||
for _, tc := range tests {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if got := adapter.DeliveryCategoryOf(tc.err); got != tc.want {
|
||
t.Fatalf("category = %q, want %q", got, tc.want)
|
||
}
|
||
if got := adapter.DeliveryChannelOf(tc.err); got != tc.wantChannel {
|
||
t.Fatalf("channel = %q, want %q", got, tc.wantChannel)
|
||
}
|
||
})
|
||
}
|
||
}
|