Files
Cyrene_v2/internal/services/backthink/push.go
T
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

594 lines
24 KiB
Go
Raw 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 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(),
}
}