主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
594 lines
24 KiB
Go
594 lines
24 KiB
Go
package backthink
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
)
|
||
|
||
// Route 标识一条主动消息实际是如何投递的,供监控面板
|
||
// 与推送结果记录使用。
|
||
type Route string
|
||
|
||
const (
|
||
// RouteNone 表示未尝试推送,或无可用目标。
|
||
RouteNone Route = "none"
|
||
// RouteRateLimited 表示推送被限流器拒绝。
|
||
RouteRateLimited Route = "rate_limited"
|
||
// RouteActiveClient 表示消息发往了活跃 Web/原生客户端。
|
||
RouteActiveClient Route = "active_client"
|
||
// RouteIMBridge 表示消息降级到 IM 桥。
|
||
RouteIMBridge Route = "im_bridge"
|
||
)
|
||
|
||
// 推送路由器识别的渠道提示词。它们来自结论的
|
||
// [渠道:...] 标记(parseProactive),用于选择主动消息
|
||
// 的投递去向。为空/未知的提示按 auto 处理。具体的
|
||
// 平台 id(kook/telegram/...)是这三个之外的值。
|
||
const (
|
||
channelAuto = "auto"
|
||
channelIM = "im"
|
||
channelWeb = "web"
|
||
// defaultProactivePlatform 是结论未指明具体渠道时,
|
||
// 出站主动消息携带的 Platform。
|
||
defaultProactivePlatform = "cyrene-backthink"
|
||
)
|
||
|
||
// String 把路由作为普通标签返回。
|
||
func (r Route) String() string { return string(r) }
|
||
|
||
// PushResult 是一次主动推送尝试的结果,随每轮思考
|
||
// 一起记录(design/12 §4)。
|
||
type PushResult struct {
|
||
// Pushed 报告消息是否已投递。
|
||
Pushed bool
|
||
// Route 是实际采用的路由。
|
||
Route Route
|
||
// Bypassed 报告本次推送是否为事件驱动、跳过了 PushGate
|
||
// (真正的事件,"该发就发")。心跳推送受门控管辖,绝不
|
||
// 被绕过。
|
||
Bypassed bool
|
||
// Detail 是简短可读的备注(如客户端数量、决策理由)。
|
||
Detail string
|
||
// Err 是投递失败时的底层错误文本,或决定不推送的
|
||
// 提供者的错误文本。
|
||
Err string
|
||
}
|
||
|
||
// String 返回用于日志的简洁摘要。
|
||
func (p PushResult) String() string {
|
||
if p.Err != "" {
|
||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
|
||
}
|
||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
|
||
}
|
||
|
||
// ProactiveDelivery 是一次活跃客户端投递的结果(切片 P0「她主动找你」)。
|
||
// 它把**两件本就独立**的事分开报告:帧送到了几条连接,以及这条消息有没有
|
||
// 成为主会话里的一条真实消息。
|
||
//
|
||
// 为什么要分开:修复前活跃客户端路由只广播 WS 帧、不落库,于是面板上的
|
||
// 「delivered to 2 active client(s)」被读成「已推送」,而刷新后什么都没剩
|
||
// —— 那正是主人报的「后台思考的主动推送一个都没有推出来」。落库与投递各自
|
||
// 成败,记录里就必须各自可见。
|
||
type ProactiveDelivery struct {
|
||
// Connections 是接受该帧的在线 owner/admin 连接数。0 意味着此刻没有
|
||
// 在线目标;它**不代表消息丢了**——消息可能已经落库(见 Persisted)。
|
||
Connections int
|
||
// Persisted 报告该消息是否已成为主会话历史(`history.MainSessionID`)里的
|
||
// 一条真实消息,因而刷新/重载后仍在。
|
||
Persisted bool
|
||
}
|
||
|
||
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
|
||
// 客户端,并报告投递与落库两件事。它是首选的推送目标。
|
||
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
|
||
type ActiveClient interface {
|
||
// SendProactive 把 msg 推给每个活跃客户端,并返回
|
||
// ProactiveDelivery。
|
||
//
|
||
// 实现契约(切片 P0):**先落库、再发帧**,且发帧的载荷带上落库行的 id ——
|
||
// 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在)。落库失败时
|
||
// 仍要发出帧(实时气泡不能丢),但必须把错误返回且 Persisted=false,
|
||
// 让调用方如实记录「发出去了、但没留下记录」。
|
||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ProactiveDelivery, error)
|
||
}
|
||
|
||
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
|
||
// 渠道推送。每个 adapter.PlatformAdapter 都满足这个子集,因此本服务
|
||
// 依赖这个窄接口而非具体适配器类型。
|
||
type IMAdapter interface {
|
||
// SendProactive 把 msg 推给主人的 IM 渠道。
|
||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error
|
||
}
|
||
|
||
// PushGate 判断一次主动推送此刻能否进行,套用
|
||
// design/11 §6 的限频规则,外加按风格限制与推送后冷却。它
|
||
// 是对 channel/session 的 ProactivePolicy/RateLimiter 的接缝,因此本服务
|
||
// 可以用 mock 测试。
|
||
type PushGate interface {
|
||
// Allow 报告给定风格的主动推送此刻能否发送。
|
||
Allow(ctx context.Context, kind PushKind) bool
|
||
// RecordPush 记下一次成功投递,使冷却/按风格窗口
|
||
// 从一次真正的成功推送起算,而不是仅仅尝试过。
|
||
RecordPush(kind PushKind)
|
||
}
|
||
|
||
// PushRequest 携带一次主动推送尝试:出站消息加上
|
||
// 自主结论所要求的目标渠道(design/20 §2 P2)。
|
||
// ChannelHint 是已转小写的 [渠道:...] 词:""/auto 选默认(
|
||
// 主人要求:只发一个渠道,由她 / 她最近的平台 /
|
||
// 白名单默认决定);"web" 指向活跃客户端;"im" 指向 IM 桥;
|
||
// 具体平台 id(kook/telegram/weixin-ilink/...)指向 IM 桥
|
||
// 并把消息标记为该平台。
|
||
type PushRequest struct {
|
||
// Message 是归一化后的出站主动消息。
|
||
Message adapter.UnifiedMessage
|
||
// ChannelHint 是请求的目标渠道,她未选择时为 ""。
|
||
ChannelHint string
|
||
// TriggerKind 记录唤醒本轮的原因。事件驱动的推送绕过
|
||
// PushGate(主人要求:"该发就发");心跳保持受门控管辖。
|
||
TriggerKind TriggerKind
|
||
}
|
||
|
||
// Pusher 把主动消息投递到一个目标渠道。它受
|
||
// 频率门约束,并尊重自主结论所选的渠道提示:"web"
|
||
// 去活跃客户端,其余都经 IM 桥走一次,
|
||
// 由桥挑出那一个渠道(她的选择 → 她最近的平台 →
|
||
// 白名单默认,微信含在内)。
|
||
type Pusher interface {
|
||
// Push 按渠道提示把 req 投递到单个目标渠道。它绝不
|
||
// 阻塞在慢目标上。
|
||
Push(ctx context.Context, req PushRequest) PushResult
|
||
}
|
||
|
||
// pusher 是具体的 Pusher:它先咨询频率门,再把消息路由到
|
||
// 恰好一个目标——"web" 提示走活跃客户端,否则走 IM 桥。
|
||
// 本地投递之后,它把消息镜像到可选的推送出口
|
||
// (自托管服务 → 个推降级),且不阻塞该投递。
|
||
type pusher struct {
|
||
active ActiveClient
|
||
im IMAdapter
|
||
gate PushGate
|
||
exporter PushExporter
|
||
log *slog.Logger
|
||
// audit 是可选审计出口(what/result 两字段,与 backthink 的 Auditor 同族)。
|
||
// 它在**推送失败但渠道是主人能感知的那个**时留一行痕,因此主人不必等面板翻记录
|
||
// 才能知道「她要发微信、微信没送到」。nil ⇒ 静默跳过:审计是留痕,不是门,
|
||
// 缺它绝不改变投递结果。
|
||
audit func(what, result string)
|
||
// exportDone 非 nil 时,会在每次异步导出完成后收到一个值。
|
||
// 它存在是为了让测试能确定性地观测该钩子;发送是非阻塞的,
|
||
// 所以通道满也不会卡住导出器。
|
||
exportDone chan struct{}
|
||
}
|
||
|
||
// newPusher 由可选的活跃客户端槽、IM 槽与
|
||
// 频率门构建 Pusher。它们都可以为 nil;目标为 nil 只是让那条路由
|
||
// 不可用。推送出口用 withExporter 单独挂上。
|
||
func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher {
|
||
return &pusher{active: active, im: im, gate: gate, log: log}
|
||
}
|
||
|
||
// withExporter 挂上推送出口钩子并返回 pusher 以便链式
|
||
// 调用。exporter 为 nil 则钩子保持关闭。
|
||
func (p *pusher) withExporter(exporter PushExporter) *pusher {
|
||
p.exporter = exporter
|
||
return p
|
||
}
|
||
|
||
// withAuditor 挂上审计出口并返回 pusher 以便链式调用。传 nil 则关闭该钩子
|
||
// (测试里的裸 pusher 因此不产生审计)。
|
||
func (p *pusher) withAuditor(audit func(what, result string)) *pusher {
|
||
p.audit = audit
|
||
return p
|
||
}
|
||
|
||
// Push 实现 Pusher:它套用频率门,然后把消息路由到
|
||
// 单个目标。"web" 提示到达活跃 Web/原生客户端;其它任何
|
||
// 提示("im"、"auto" 或具体平台)经 IM 桥走一次,由
|
||
// 桥挑出一个主人渠道(她的 [渠道:...] 选择,其次是她
|
||
// 最近用过的平台,再次是白名单默认)。IM 桥是主路径,所以一次
|
||
// 推送绝不扇出到多个渠道。未接线 IM 槽时,活跃
|
||
// 客户端就是诚实的降级,因此主人仍能被触达,而不是
|
||
// 消息被丢弃。
|
||
func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
|
||
if err := ctx.Err(); err != nil {
|
||
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
|
||
}
|
||
kind := classifyPush(req.Message.Content)
|
||
// 真正事件驱动的推送绕过 PushGate:它应原样发出("该发就发"),
|
||
// 不受心跳限流约束(60min≤1 / 冷却 / 按风格窗口)。
|
||
// 只有周期性心跳保持受门控管辖,主人因此绝不会被
|
||
// 空洞的反思刷屏。同一事件去重在 think() 上游套用。
|
||
eventDriven := req.TriggerKind == TriggerEvent
|
||
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
|
||
p.log.Warn("backthink push rate limited", "kind", kind.String())
|
||
return PushResult{Pushed: false, Route: RouteRateLimited, Detail: "proactive push rate limited"}
|
||
}
|
||
|
||
hint := normalizeChannelHint(req.ChannelHint)
|
||
msg := req.Message
|
||
|
||
var res PushResult
|
||
// imFault 非 nil ⇒ IM 路**确实失败过**(带着错因)。它必须活到最终结果:过去
|
||
// 降级到活跃客户端后 res 被整条覆盖,于是「微信没送到」在那次推送里彻底消失
|
||
// —— 正是主人反复收不到、面板却显示「已送达」的成因。
|
||
var imFault *imUndelivered
|
||
if hint == channelWeb {
|
||
res = p.pushActive(ctx, msg)
|
||
} else {
|
||
// "im"/"auto"/具体平台都经 IM 桥走一次。
|
||
// 泛化提示交出哨兵平台,让桥落到它的
|
||
// 最近活跃 / 默认选择上;具体提示在渠道
|
||
// 已加入白名单且已连接时被尊重。
|
||
if hint == channelIM || hint == channelAuto {
|
||
msg.Platform = defaultProactivePlatform
|
||
} else {
|
||
msg.Platform = hint
|
||
}
|
||
im, fault := p.pushIM(ctx, msg)
|
||
res = im
|
||
if !res.Pushed {
|
||
// 降级路照旧兜底(主人仍要被触达),但 IM 那次的失败如实带上:
|
||
// 降级成功 ≠ 她的话送到了她选的渠道。
|
||
res = p.pushActive(ctx, msg)
|
||
if fault != nil {
|
||
res = annotateIMUndelivered(res, *fault, im.Err)
|
||
}
|
||
}
|
||
imFault = fault
|
||
}
|
||
// 只有真正的投递才推进冷却/按风格窗口(且只对
|
||
// 受门控的推送),因此尝试过但失败的推送不会抑制之后的
|
||
// 真实推送,而事件推送也绝不计入心跳限流。
|
||
if res.Pushed && p.gate != nil && !eventDriven {
|
||
p.gate.RecordPush(kind)
|
||
}
|
||
// D1:把主动消息镜像到外部推送出口(自托管
|
||
// 统一推送服务 → 个推降级)。发后不管:慢或失败的
|
||
// 出口绝不延迟或破坏上面的本地投递,而且限流被拒的
|
||
// 推送在此之前就已返回。
|
||
p.exportAsync(msg)
|
||
res = annotateHint(res, req.ChannelHint)
|
||
if eventDriven {
|
||
res.Bypassed = true
|
||
res = annotateBypass(res)
|
||
}
|
||
// IM 路的失败必须**有可观测出口**:一条带原因码的结构化 WARN 已在 pushIM 落过,
|
||
// 这里再落一条主人能读懂的审计(此刻才知道这一轮最终落到哪条路)。
|
||
if imFault != nil {
|
||
p.auditIMUndelivered(*imFault, res)
|
||
}
|
||
return res
|
||
}
|
||
|
||
// exportAsync 在脱离的、有界的 goroutine 上把 msg 上报给推送出口,
|
||
// 因此主动路径绝不在出口上阻塞。exporter 为 nil(钩子关闭)
|
||
// 或出口结果为 nil 都是空操作;投递未命中或出错只记日志。
|
||
func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
|
||
if p.exporter == nil {
|
||
return
|
||
}
|
||
timeout := defaultExportTimeout
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
delivered, err := p.exporter.Export(ctx, msg)
|
||
switch {
|
||
case err != nil:
|
||
p.log.Warn("push outlet export failed", "error", err)
|
||
case !delivered:
|
||
p.log.Info("push outlet did not deliver", "hint", "getui fallback not integrated")
|
||
}
|
||
if p.exportDone != nil {
|
||
select {
|
||
case p.exportDone <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// pushActive 只投递给活跃 Web/原生客户端。它按 ProactiveDelivery 如实报告
|
||
// 「送到了几条连接」与「有没有落进主会话」(切片 P0):投递数为 0 时消息仍可能
|
||
// 已经是一条真实记录,两者绝不互相冒充。
|
||
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||
if p.active == nil {
|
||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||
}
|
||
d, err := p.active.SendProactive(ctx, msg)
|
||
if err != nil {
|
||
p.log.Warn("backthink active client push failed", "error", err)
|
||
}
|
||
if d.Connections > 0 {
|
||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: activeDeliveryDetail(d)}
|
||
}
|
||
if d.Persisted {
|
||
// 此刻没有在线客户端,但这条话已经成为主会话里的一条真实记录:打开/
|
||
// 刷新页面就能看到,只是没有实时帧可发。如实分别报告这两件事,面板才不会
|
||
// 把「没推出去」与「没留下」混为一谈。
|
||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client, recorded in main session"}
|
||
}
|
||
if err != nil {
|
||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client", Err: err.Error()}
|
||
}
|
||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||
}
|
||
|
||
// activeDeliveryDetail 是活跃客户端路由的 Detail 措辞:投递数与「有没有落进
|
||
// 主会话」两件事实都在里面,且**绝不**只说「已推送」(那正是被误读成「刷新还在」
|
||
// 的原话)。
|
||
func activeDeliveryDetail(d ProactiveDelivery) string {
|
||
state := "not persisted to main session"
|
||
if d.Persisted {
|
||
state = "persisted to main session"
|
||
}
|
||
return fmt.Sprintf("delivered to %d active client(s), %s", d.Connections, state)
|
||
}
|
||
|
||
// whatPushIMUndelivered 是「IM 路未送达」审计行的动作标签(点号小写稳定标签,与
|
||
// backthink.self_wake.* 同族;who 由 backthink.auditEvent 统一给 "backthink")。
|
||
const whatPushIMUndelivered = "backthink.push.im_undelivered"
|
||
|
||
// imChannelUnknown 是渠道的显示名兜底:适配器没自报渠道、且这次请求的是泛化提示
|
||
// (auto/im,msg.Platform 只写着一个路由哨兵)时用它。它如实表示「不知道是哪个渠道」,
|
||
// 绝不拿哨兵值冒充平台名。
|
||
const imChannelUnknown = "unknown"
|
||
|
||
// imUndelivered 是一次 IM 路失败的结构化事实:稳定原因码、出错渠道(可能为空 =
|
||
// 未自报)、以及底层错误。它存在的理由是 PushResult.Err 是**字符串** —— 原因码一旦
|
||
// 落成文本就再也读不出来,而日志、记录标签与审计三处都要那个码。
|
||
type imUndelivered struct {
|
||
channel string
|
||
category adapter.DeliveryCategory
|
||
err error
|
||
}
|
||
|
||
// newIMUndelivered 把一次 IM 路的错误解成结构化事实。渠道取适配器**自报**值
|
||
// (沿 %w 链找 adapter.DeliveryFailure);未自报且这次请求的是具体平台时,退到
|
||
// 该平台(此刻 msg.Platform 就是那个渠道,不是猜的);两者都没有 ⇒ 空串 = 未知。
|
||
func newIMUndelivered(err error, platform string) *imUndelivered {
|
||
f := &imUndelivered{
|
||
channel: adapter.DeliveryChannelOf(err),
|
||
category: adapter.DeliveryCategoryOf(err),
|
||
err: err,
|
||
}
|
||
if f.channel == "" && platform != defaultProactivePlatform {
|
||
f.channel = platform
|
||
}
|
||
return f
|
||
}
|
||
|
||
// channelName 是渠道的显示名;未自报时为 imChannelUnknown。
|
||
func (f imUndelivered) channelName() string {
|
||
if f.channel == "" {
|
||
return imChannelUnknown
|
||
}
|
||
return f.channel
|
||
}
|
||
|
||
// categoryName 是原因码的显示值;没有码时如实落到兜底码 failed(与
|
||
// adapter.DeliveryCategoryOf 同口径,绝不编一个更精确的码)。
|
||
func (f imUndelivered) categoryName() adapter.DeliveryCategory {
|
||
if f.category == "" {
|
||
return adapter.CategoryFailed
|
||
}
|
||
return f.category
|
||
}
|
||
|
||
// tag 是写进 PushResult.Detail 的**稳定可解析**标签
|
||
// `im_undelivered=<渠道>:<原因码>`。管理面板的「推送说明」直接读 Detail
|
||
// (frontend/web/src/admin/modules/Backthink.tsx),因此主人不必等一次代码改动
|
||
// 就能看到「微信为什么没送到」。
|
||
func (f imUndelivered) tag() string {
|
||
return fmt.Sprintf("im_undelivered=%s:%s", f.channelName(), f.categoryName())
|
||
}
|
||
|
||
// pushIM 只投递给 IM 桥。返回的第二个值非 nil 表示**这次 IM 投递失败**(带原因码与
|
||
// 渠道);IM 槽未接线不是渠道故障,因此不算失败事实(降级到活跃客户端是诚实的兜底)。
|
||
//
|
||
// 失败至少落一条**带来源与原因码**的 WARN:它同时给出 category(机器可读)、channel
|
||
// (适配器自报的真实渠道)、platform(这次请求的平台)与原文错误,因此「收不到」永远
|
||
// 有一个可观测出口 —— 它绝不静默。
|
||
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) (PushResult, *imUndelivered) {
|
||
if p.im == nil {
|
||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}, nil
|
||
}
|
||
if err := p.im.SendProactive(ctx, msg); err != nil {
|
||
fault := newIMUndelivered(err, msg.Platform)
|
||
p.log.Warn("backthink IM push failed",
|
||
"category", string(fault.categoryName()),
|
||
"channel", fault.channelName(),
|
||
"platform", msg.Platform,
|
||
"route", RouteIMBridge,
|
||
"error", err)
|
||
return PushResult{Pushed: false, Route: RouteIMBridge, Err: err.Error()}, fault
|
||
}
|
||
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}, nil
|
||
}
|
||
|
||
// annotateIMUndelivered 把 IM 路的失败如实拼进**降级后**的结果:Detail 追加稳定标签
|
||
// (渠道 + 原因码),因此记录与面板都明说「这个渠道没送到」。
|
||
//
|
||
// 降级路也没成时,IM 那次的错因是「这一轮为什么没送到」的主因,落进 Err;降级路自己
|
||
// 的错因(若有)另起一段附在后面 —— 两条都不被吞,也不互相冒充。
|
||
func annotateIMUndelivered(res PushResult, fault imUndelivered, imErr string) PushResult {
|
||
res.Detail = appendDetail(res.Detail, fault.tag())
|
||
if res.Pushed || imErr == "" {
|
||
return res
|
||
}
|
||
switch {
|
||
case res.Err == "":
|
||
res.Err = imErr
|
||
case !strings.Contains(res.Err, imErr):
|
||
res.Err = imErr + " | fallback: " + res.Err
|
||
}
|
||
return res
|
||
}
|
||
|
||
// auditIMUndelivered 把一次 IM 路未送达写进审计。审计是留痕不是门:出口未接线
|
||
// (p.audit == nil,例如测试里的裸 pusher)时静默跳过,投递结果不受影响。
|
||
func (p *pusher) auditIMUndelivered(fault imUndelivered, final PushResult) {
|
||
if p.audit == nil {
|
||
return
|
||
}
|
||
p.audit(whatPushIMUndelivered, imUndeliveredAuditText(fault, final))
|
||
}
|
||
|
||
// imUndeliveredAuditText 是给主人看的中文一行:哪个渠道、什么原因码、以及这一轮最终
|
||
// 落到了哪里。它**绝不**把「降级送到活跃客户端」说成「已送达那个渠道」。
|
||
func imUndeliveredAuditText(fault imUndelivered, final PushResult) string {
|
||
outcome := "本轮没有在线 Web 客户端,这条消息没有送达"
|
||
if final.Pushed {
|
||
outcome = "已改由 Web 活跃客户端送达"
|
||
}
|
||
return fmt.Sprintf("主动消息未能送达 %s(原因码 %s):%v;%s",
|
||
fault.channelName(), fault.categoryName(), fault.err, outcome)
|
||
}
|
||
|
||
// appendDetail 把 tag 追加到详情串(逗号分隔),空详情则直接成为详情;空 tag 不改变
|
||
// 详情。它是 Detail 拼接的唯一处(channel_hint / event_bypass / im_undelivered 三个
|
||
// 标签共用同一口径,避免三份各写一遍)。
|
||
func appendDetail(detail, tag string) string {
|
||
switch {
|
||
case tag == "":
|
||
return detail
|
||
case detail == "":
|
||
return tag
|
||
default:
|
||
return detail + ", " + tag
|
||
}
|
||
}
|
||
|
||
// normalizeChannelHint 把渠道提示转小写并去空白,把同义词映射到
|
||
// 规范路由词(auto/im/web);其它一切视为具体平台
|
||
// id(kook/telegram/...)。未知/空映射为 auto。
|
||
func normalizeChannelHint(hint string) string {
|
||
switch strings.ToLower(strings.TrimSpace(hint)) {
|
||
case "", "auto":
|
||
return channelAuto
|
||
case "im", "bridge":
|
||
return channelIM
|
||
case "web", "frontend", "active":
|
||
return channelWeb
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(hint))
|
||
}
|
||
}
|
||
|
||
// isSpecificChannel 报告 hint 指的是具体平台,而不是
|
||
// 路由关键词(auto/im/web)。
|
||
func isSpecificChannel(hint string) bool {
|
||
return hint != channelAuto && hint != channelIM && hint != channelWeb
|
||
}
|
||
|
||
// annotateHint 把请求的渠道提示附加到推送结果 detail 上,
|
||
// 好让记录/面板显示她选了哪个渠道,即便路由发生
|
||
// 了降级。空白/auto 提示不加任何内容。
|
||
func annotateHint(res PushResult, hint string) PushResult {
|
||
h := strings.ToLower(strings.TrimSpace(hint))
|
||
if h == "" || h == channelAuto {
|
||
return res
|
||
}
|
||
res.Detail = appendDetail(res.Detail, "channel_hint="+h)
|
||
return res
|
||
}
|
||
|
||
// annotateBypass 给事件驱动的推送结果打标记,使记录/面板显示它
|
||
// 跳过了 PushGate,与因限流被拒的心跳推送区分开。
|
||
func annotateBypass(res PushResult) PushResult {
|
||
res.Detail = appendDetail(res.Detail, "event_bypass")
|
||
return res
|
||
}
|
||
|
||
// rateGate 是生产用的 PushGate:一个基于
|
||
// session.RateLimiter 的滑动窗口计数器(design/11 §6 反骚扰规则),
|
||
// 结合推送后冷却与按风格(问候/提醒/分享/关心)窗口,
|
||
// 使主人绝不会被同一风格刷屏(design/12 §6 策略二)。
|
||
type rateGate struct {
|
||
log *slog.Logger
|
||
limiter *session.RateLimiter
|
||
cooldown time.Duration
|
||
kindWindow time.Duration
|
||
now func() time.Time
|
||
|
||
mu sync.Mutex
|
||
lastPushTime time.Time
|
||
lastKind map[PushKind]time.Time
|
||
}
|
||
|
||
// newRateGate 构建一个门:在 window 内最多放行 limit 次推送,
|
||
// 成功之后推送之间至少间隔 cooldown,且每种 PushKind 在
|
||
// kindWindow 内只允许一次。非正的 limit 或 window 得到始终放行的
|
||
// 限流器(无限频规则);非正的 cooldown 或 kindWindow 会禁用对应的那道守护。
|
||
func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate {
|
||
return &rateGate{
|
||
log: log,
|
||
limiter: session.NewRateLimiter(limit, window),
|
||
cooldown: cooldown,
|
||
kindWindow: kindWindow,
|
||
now: time.Now,
|
||
lastKind: make(map[PushKind]time.Time),
|
||
}
|
||
}
|
||
|
||
// Allow 实现 PushGate。已取消的 context 一律拒绝(fail closed)。
|
||
// 当最近一次成功推送仍在冷却期内,或同一风格
|
||
// 在 kindWindow 内已投递过,它就在咨询滑动窗口限流器之前拒绝。
|
||
func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
|
||
if err := ctx.Err(); err != nil {
|
||
return false
|
||
}
|
||
now := g.now()
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
if !g.lastPushTime.IsZero() && now.Sub(g.lastPushTime) < g.cooldown {
|
||
return false
|
||
}
|
||
if lt, ok := g.lastKind[kind]; ok && now.Sub(lt) < g.kindWindow {
|
||
return false
|
||
}
|
||
return g.limiter.Allow()
|
||
}
|
||
|
||
// RecordPush 实现 PushGate:它记下最近成功推送时间与
|
||
// 按风格的最近时间,使冷却与风格窗口从真实投递起算。
|
||
func (g *rateGate) RecordPush(kind PushKind) {
|
||
now := g.now()
|
||
g.mu.Lock()
|
||
defer g.mu.Unlock()
|
||
g.lastPushTime = now
|
||
g.lastKind[kind] = now
|
||
}
|
||
|
||
// buildProactiveMessage 拼装承载思考结论文本的、
|
||
// 归一化的出站消息。当结论指明具体平台时,它用请求的渠道
|
||
// 给平台打标签,否则用 backthink 的默认
|
||
// 平台。具体的活跃客户端与 IM 适配器各自对主人施加
|
||
// 自己的路由/寻址(红线:只针对主人)。
|
||
//
|
||
// 正文会再过一遍 stripInternalMarkers 作为最后一道守护,因此
|
||
// 侥幸存活过解析的结构化决策/渠道标记绝不
|
||
// 能到达面向用户的消息(只有我们的日志/记录可以携带它)。
|
||
func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage {
|
||
platform := defaultProactivePlatform
|
||
if h := normalizeChannelHint(channelHint); isSpecificChannel(h) {
|
||
platform = h
|
||
}
|
||
return adapter.UnifiedMessage{
|
||
Platform: platform,
|
||
ChannelType: adapter.ChannelDirect,
|
||
Content: stripInternalMarkers(message),
|
||
ContentType: adapter.ContentText,
|
||
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
|
||
Timestamp: b.now(),
|
||
}
|
||
}
|