Files
AskaEth 353293e807
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
2026-10-07 10:59:22 +08:00

91 lines
4.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package adapter
import "errors"
// 本文件给**出站投递失败**一个稳定的原因码。此前一次失败只有自由文本(真机日志:
// `weixin_ilink: proactive push outside 24h window`),调用方只能把整串原文塞进日志与
// 记录 —— 于是「她的话没送到微信」在面板上只表现为一句人读的英文,既没法稳定地告诉
// 主人「哪个渠道、因为哪条平台规则」,也没法按原因聚合。
//
// 口径与 internal/runtime/voice 的 `Category(err)` 同族(那处给语音转码失败分类):
// **产生错误的那一层**声明码值,读取侧只调一个访问函数;错误**没有自报**更具体的
// 原因码时一律落到兜底码 CategoryFailed —— 绝不替它编一个更精确的码。
//
// 为什么放在 adapter 契约层:能见到真实错误的层(plugin 适配器)与要记录它的层
// (channelbridge / backthink)之间只共享本包。契约层是唯一不需要任何一方反向依赖
// 具体插件的落点。范式与 SystemNoticeNormalizer 逐字同族:**可选接口 + 调用方探测**。
// DeliveryCategory 是出站投递失败的**稳定原因码**(snake_case,可直接进日志字段、
// 记录字段与审计文案)。机器读它,人读文案 —— 所以文案会变,码不变。
type DeliveryCategory string
// 契约层自带的四个码。平台特有的码(如 weixin-ilink 的 proactive_window_closed)
// 由各插件在自己的包内声明并自报,本包不枚举它们。
const (
// CategoryNotConnected 表示适配器当前没有可用会话(ErrNotConnected)。
CategoryNotConnected DeliveryCategory = "not_connected"
// CategoryUnsupported 表示该能力/操作不被平台支持(ErrUnsupported)。
CategoryUnsupported DeliveryCategory = "unsupported"
// CategoryClosed 表示适配器已关闭(ErrClosed)。
CategoryClosed DeliveryCategory = "closed"
// CategoryFailed 是**兜底**码:错误没有自报更具体的原因码时使用。它如实表示
// 「只知道失败了,不知道平台规则层面的原因」,既不是成功,也不是猜测。
CategoryFailed DeliveryCategory = "failed"
)
// DeliveryFailure 是投递错误**可选**的自我描述(适配器按需实现,调用方用
// errors.As 探测)。实现必须无副作用:不做 I/O、不改状态。
type DeliveryFailure interface {
// DeliveryCategory 返回该错误的稳定原因码。返回空串表示「不自报」,读取侧
// 退到共享哨兵判定与兜底码。
DeliveryCategory() DeliveryCategory
// DeliveryChannel 返回出错渠道的平台名(例如 "weixin-ilink")。空串表示
// **不知道**——绝不填一个猜出来的平台名。
DeliveryChannel() string
}
// DeliveryCategoryOf 返回 err 的稳定原因码:err 自报则用自报值,否则按契约层
// 哨兵判定,最后落到 CategoryFailed。err 为 nil 时返回空串 —— 「没有失败」不是
// 一种失败码。
//
// errors.As 沿 %w 链走,因此中间层的包装(channelbridge 的
// `send bubble #1 of 8: …`、`send proactive via "weixin-ilink": …`)都不会把
// 自报的码藏起来。
func DeliveryCategoryOf(err error) DeliveryCategory {
if err == nil {
return ""
}
if f := deliveryFailureOf(err); f != nil {
if c := f.DeliveryCategory(); c != "" {
return c
}
}
switch {
case errors.Is(err, ErrNotConnected):
return CategoryNotConnected
case errors.Is(err, ErrUnsupported):
return CategoryUnsupported
case errors.Is(err, ErrClosed):
return CategoryClosed
default:
return CategoryFailed
}
}
// DeliveryChannelOf 返回 err 自报的出错渠道;未自报时返回空串(= 未知)。
func DeliveryChannelOf(err error) string {
if f := deliveryFailureOf(err); f != nil {
return f.DeliveryChannel()
}
return ""
}
// deliveryFailureOf 在错误链上找出第一个自描述失败;没有则返回 nil。
func deliveryFailureOf(err error) DeliveryFailure {
var f DeliveryFailure
if errors.As(err, &f) {
return f
}
return nil
}