feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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:
1 parent
bff57bd1ae
commit
353293e807
9 files changed
+772
-31
No files matched your search
@@ -66,21 +66,71 @@ const (
|
||||
errcodeSessionRejected = -14
|
||||
)
|
||||
|
||||
// 本适配器返回的哨兵错误,可用 errors.Is 判定。
|
||||
// 本适配器的投递失败原因码(adapter.DeliveryCategory,见该包 delivery.go 的口径)。
|
||||
// 码值属于**本平台**(平台规则各有各的名字),因此在这里声明、由下面的
|
||||
// selfDescribingError 自报;契约层只提供读取函数,不枚举它们。
|
||||
const (
|
||||
// categoryProactiveWindowClosed 是**平台规则**导致主动推送被拒:iLink 只允许在用户
|
||||
// 最后一条消息之后 24 小时内主动推送。它不是崩溃,也不是可以重试解决的失败。
|
||||
categoryProactiveWindowClosed = adapter.DeliveryCategory("proactive_window_closed")
|
||||
// categoryNoContextToken 是这次发送没有任何可用的会话凭证(对端 context_token 缺失):
|
||||
// 平台无从把消息关联到会话,因此**不能**硬发。
|
||||
categoryNoContextToken = adapter.DeliveryCategory("no_context_token")
|
||||
// categorySessionRejected 是 iLink 已拒绝这份会话凭据(errcode=-14):必须重新扫码,
|
||||
// 与「这次网络不通」是两类失败。
|
||||
categorySessionRejected = adapter.DeliveryCategory("session_rejected")
|
||||
)
|
||||
|
||||
// selfDescribingError 给哨兵错误附上**稳定原因码与渠道名**(实现
|
||||
// adapter.DeliveryFailure),使 channelbridge / backthink 能在日志、思考记录与审计里
|
||||
// 说清「哪个渠道、因为什么」没送到,而不是只能复述一整串自由文本。
|
||||
//
|
||||
// Error()/Unwrap() 逐字委托被包装的哨兵 ⇒ 错误文本与 `errors.Is(err, ErrXxx)` 判定
|
||||
// 与包装之前**完全一致**(这是它能替换 errors.New 而不改变任何既有行为的原因)。
|
||||
type selfDescribingError struct {
|
||||
category adapter.DeliveryCategory
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *selfDescribingError) Error() string { return e.err.Error() }
|
||||
func (e *selfDescribingError) Unwrap() error { return e.err }
|
||||
|
||||
// DeliveryCategory 实现 adapter.DeliveryFailure。
|
||||
func (e *selfDescribingError) DeliveryCategory() adapter.DeliveryCategory { return e.category }
|
||||
|
||||
// DeliveryChannel 实现 adapter.DeliveryFailure:本适配器只说一个平台,因此渠道名恒为
|
||||
// 它自己的平台名(不是猜的,也不是调用方传进来的)。
|
||||
func (e *selfDescribingError) DeliveryChannel() string { return platformName }
|
||||
|
||||
// 本适配器返回的哨兵错误,可用 errors.Is 判定(带原因码的三个由
|
||||
// selfDescribingError 包装,判定与文本不变,另附 stable 码)。
|
||||
var (
|
||||
// ErrInvalidConfig 在必填配置字段为空时返回。
|
||||
ErrInvalidConfig = errors.New("weixin_ilink: invalid config")
|
||||
// ErrLoginTimeout 在二维码未及时确认时返回。
|
||||
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
|
||||
// ErrSessionRejected 在 iLink 上报 errcode=-14 时返回。
|
||||
ErrSessionRejected = errors.New("weixin_ilink: iLink rejected the session (errcode=-14); re-scan required")
|
||||
// ErrSessionRejected 在 iLink 上报 errcode=-14 时返回(原因码 session_rejected:
|
||||
// 主人必须重新扫码)。
|
||||
ErrSessionRejected = &selfDescribingError{
|
||||
category: categorySessionRejected,
|
||||
err: errors.New("weixin_ilink: iLink rejected the session (errcode=-14); re-scan required"),
|
||||
}
|
||||
// ErrNeedsLogin 由 Connect 在尚未配置 bot_token 时返回;扫码绑定必须经面板的
|
||||
// login-qr/login-status 端点驱动。
|
||||
ErrNeedsLogin = errors.New("weixin_ilink: bot not logged in yet, scan the QR")
|
||||
// ErrNoContextToken 在一次发送缺少该对端的 context_token 时返回。
|
||||
ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer")
|
||||
// ErrProactiveWindow 在主动推送落在 24 小时窗口之外时返回。
|
||||
ErrProactiveWindow = errors.New("weixin_ilink: proactive push outside 24h window")
|
||||
// ErrNoContextToken 在一次发送缺少该对端的 context_token 时返回(原因码
|
||||
// no_context_token)。
|
||||
ErrNoContextToken = &selfDescribingError{
|
||||
category: categoryNoContextToken,
|
||||
err: errors.New("weixin_ilink: no context_token for peer"),
|
||||
}
|
||||
// ErrProactiveWindow 在主动推送落在 24 小时窗口之外时返回(原因码
|
||||
// proactive_window_closed)。它是**平台规则**,不是崩溃:调用方必须把它如实记为
|
||||
// 「未送达 + 原因码」,绝不重试刷屏、绝不经别的渠道冒充微信。
|
||||
ErrProactiveWindow = &selfDescribingError{
|
||||
category: categoryProactiveWindowClosed,
|
||||
err: errors.New("weixin_ilink: proactive push outside 24h window"),
|
||||
}
|
||||
// ErrMediaTooLarge 在下载超过 MaxMediaSize 时返回。
|
||||
ErrMediaTooLarge = errors.New("weixin_ilink: media exceeds size limit")
|
||||
// ErrNoMediaKey 在入站附件既没有按条 aes_key、也无法用 bot_token 兜底时返回。
|
||||
|
||||
Reference in new issue
Block a user