package channelbridge import ( "context" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级 // (DevDocs/design/12 §6 策略一/二)。非主人的**群聊**消息在进入完整 // agent 循环前先经廉价判断,使繁忙的公开群不会把每条消息都扔进 // 昂贵的 LLM 轮次;**陌生人私聊**则一律正常聊天(主人 2026-10-05 裁决①), // 只受强制流控约束(裁决③)。这些逻辑放在这里是为了让 channelbridge.go // 保持在模块体积上限内;它绝不读取消息内容来判定身份——身份早已由 // 会话/actor 层结构化解析完毕(项目红线)。 // 安静时段默认值,与 backthink 服务保持一致,使桥接器与 // 后台思考器共用同一个免打扰窗口(design/12 §6 策略五)。它在本车道里 // **只作为信号**随 inQuiet 传给参与度引擎/轻量判定器,不再让规则层静默 // (主人 2026-10-05 裁决②)。 const ( defaultQuietStartHour = 23 defaultQuietEndHour = 7 ) // lightVerdict 是规则轻量判定给出的结果,早于咨询可选的 // 注入式 LightJudge(design/12 §6 策略二/五)。 type lightVerdict int const ( // verdictRespond 表示规则信号显示该消息值得回复。 verdictRespond lightVerdict = iota // verdictInconclusive 表示规则无法判断;交给参与度引擎(design/18) // 或注入式 LightJudge(未装配时则失败关闭为静默)。安静时段**不再** // 单独构成静默(主人 2026-10-05 裁决②)——它只作为信号随 inQuiet // 传给下游判定器,让轻思考/参与度自己权衡要不要回。 verdictInconclusive ) // LightInput 是 LightJudge 判断是否值得回复时收到的信号集合。 // 身份早已由会话/actor 层结构化解析完毕, // 因此判定器只权衡值不值得回 —— 绝不判断发送者是谁。 type LightInput struct { Msg adapter.UnifiedMessage Actor adapter.Actor SenderName string InQuietHours bool QuietStart int QuietEnd int // Engage 报告这是否是一次受成本控制的参与度判定 // (design/18):只有触发(角色名提及 / 概率) // 或活跃窗口把消息送到这里时才为真,因此调用方只投喂一次 // 群上下文与角色提示词,绝不会按每条聊天消息投喂。旧的 // (design/12 §6)无结论判定会使它为 false。 Engage bool // PersonaPrompt 是当前角色的身份/系统提示词块,使判定器 // 能权衡角色的语气而不涉身份判定。它不含 // 主人敏感信息(项目红线)。 PersonaPrompt string // RecentContext 是群会话最近 N 条消息(带各发送者 // 显示名)加上触发消息的渲染文本,使判定器能判断 // 该话题是否值得参与。 RecentContext string // TriggerWords 是当前角色的角色名提醒词(design/18 §2), // 作为上下文传入,使判定器知道被提及的是哪个词。 TriggerWords []string } // LightDecision 是 LightJudge 的裁决。 type LightDecision struct { // Respond 是旧的 design/12 §6 简单裁决(回不回)。保留它 // 是为了向后兼容;下面的 design/18 字段承载结构化的 // 参与度裁决,供参与度引擎读取。 Respond bool Reason string // Interesting 报告她是否觉得该话题有意思(design/18)—— // 进入/保持 ACTIVE 的信号。 Interesting bool // WorthReply 报告该消息是否值得回复。 WorthReply bool // AvoidTopic 报告该话题是否应回避(敏感 / 私密 / // 红线),使她在其他方面感兴趣时也不参与。 AvoidTopic bool } // LightJudge 是可选的受成本控制的轻量应答判定器(design/12 §6 // 策略二/五)。桥接器只在内置规则判定器得不出结论时咨询它, // 因此聊天消息不会被反复送往昂贵的 LLM。LightJudge 为 // nil(默认)时桥接器只用规则,并失败关闭为 // 静默(不打扰为第一原则)。生产装配尚未提供它,因此这个 // 接缝已就绪,但在被刻意接入前不消耗 token。 type LightJudge interface { Judge(ctx context.Context, in LightInput) (LightDecision, error) } // highValueKeywords 是规则判定器用于匹配的小词表:即使发送者 // 未被直接点名,命中即认为该消息值得回复 // (设计/12 §6 策略二:关键词重要度)。它是廉价子串匹配——不调用 LLM, // 也绝不用于推断身份。 var highValueKeywords = []string{ "紧急", "救命", "故障", "坏掉", "坏了", "报错", "出错", "崩溃", "求助", "帮帮我", "help", "urgent", "error", "bug", } // isLowValueEvent 报告非主人消息是否为平台/系统噪声: // 必须登记,但绝不驱动完整 agent 循环(设计/12 §6 策略一)。 // 它只对非主人消息咨询:系统/合成发送者是最典型的 // 低价值信号。没有实质聊天文本的消息本就会在 // 桥接器入口被丢弃,因此它主要用于拦系统 actor 的通知。 func isLowValueEvent(actor adapter.Actor) bool { return actor.IsSystem() } // hasHighValueKeyword 报告文本是否含有「即使未被直接点名 // 也值得回复」的词。词表保持极小,使规则路径对每条消息 // 都是 O(1)。 func hasHighValueKeyword(text string) bool { lower := strings.ToLower(text) for _, k := range highValueKeywords { if strings.Contains(lower, k) { return true } } return false } // ruleLightJudge 只依据廉价规则信号判断是否值得回复 // (设计/12 §6 策略二)。优先级:直接点名、提及主人的显示 // 名、高价值关键词;除此之外一律无结论(交给参与度引擎 / // 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是 // 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断 // 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给 // 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不 // 推断身份(身份由 actor 元数据在会话层解析,红线)。 func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict { if len(msg.Mentions) > 0 { return verdictRespond } if b.owner != nil && b.owner.DisplayName != "" && strings.Contains(msg.Content, b.owner.DisplayName) { return verdictRespond } if hasHighValueKeyword(msg.Content) { return verdictRespond } return verdictInconclusive } // isStrangerPrivate 报告该会话是否为**陌生人私聊**(非群 + 非主人)。 // 它只读会话层结构化解析出的标志(项目红线:身份不靠正文/提示词)。 // 主人私聊在 onMessage/shouldRespond 之前就已被 actor 门拦下,因此 // 这里成真的只会是陌生人私聊。 func isStrangerPrivate(sess session.Session) bool { return !sess.IsGroup && !sess.Owner } // shouldRespond 实现入站消息的最小打扰分级 // (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息 // 应驱动完整 agent 循环并回复,还是只登记并静默。 // **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」): // 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说 // (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回, // 而主人群里不对她说的消息改为**进上下文不回**。sess 是 // 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 // 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器, // 用于取该账号的流控闸门(陌生人私聊的强制限流)。 func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) { // owner 车道:结构化参与闸(design/18 §11)。身份由 actor 元数据判定, // 这里只决定「说不说 / 多频繁」,绝不动权限面。 if actor.IsOwner() { return b.ownerGroupShouldRespond(msg, sess) } // 策略一:平台/系统噪声 → 只登记,绝不进完整循环。 if isLowValueEvent(actor) { b.log.Debug("non-owner low-value system event, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role) return false, "low-value system event; registered only" } // 裁决①(主人 2026-10-05):**陌生人私聊 = 正常聊天** —— 不设安静期、 // 不走「值不值得回」的概率门,直接进入完整回合。唯一的闸门是流控: // 同一发送者每分钟 ≤ 该账号的 directMsgPerMin(未配 = 保护性默认 6,裁决 13 // 让它可配;AllowDirect 记账)与同时最多 1 个在跑的回合 // (processFrom 用 AcquireTurn 施加)。 // 被限流时**不回**:调用方会把它登记进隔离会话,这里留一条带 // source 的可见告警(绝不静默),并报出**实际生效**的额度而不是硬编码默认值。 if isStrangerPrivate(sess) { if g := b.gateFor(a); g != nil && !g.AllowDirect(msg) { b.log.Warn("stranger private chat rate limited, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID, "limit_per_min", g.DirectPerMin()) return false, "stranger private: over the per-sender flow limit" } return true, "stranger private chat: normal reply" } // 策略二/五:基于规则的轻量判定。直接 @、提及主人显示名或 // 高价值关键词一律直接回;规则得不出结论的部分交给参与度 // 引擎 / 轻量判定器。**安静时段只作为信号**(inQuiet)继续往下传, // 规则层不再因为它静默(裁决②)。 inQuiet := b.inQuietHours(b.now()) if b.ruleLightJudge(msg) == verdictRespond { return true, "rule signal: worth responding" } // 廉价规则得不出结论。参与度引擎(design/18)已配置时 // 由它接管,且只在真正触发(角色名提及 / 概率)或 // 活跃窗口内才咨询轻量判定器——绝不按每条 // 聊天消息咨询,成本因此可控(红线)。未配置参与度 // 引擎时,桥接器保持引入引擎之前的旧行为:对每条无结论 // 消息咨询可选的注入式轻量判定器,否则失败关闭。 if !b.engageConfigured() { return b.legacyLightJudge(ctx, msg, actor, inQuiet) } return b.engageDecision(ctx, msg, actor, sess, inQuiet) } // legacyLightJudge 是 design/18 之前的无结论处理:咨询可选的 // 注入式轻量判定器,出错或缺失时失败关闭为静默 // (不打扰为第一原则)。仅在未装配参与度引擎时才走到。 func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) (bool, string) { if b.light != nil { d, err := b.light.Judge(ctx, LightInput{ Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor), InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour, }) if err == nil { if d.Respond { return true, "light judge: " + d.Reason } return false, "light judge: " + d.Reason } b.log.Warn("light judge failed; silencing", "error", err) } b.log.Debug("non-owner message has no rule signal, silenced", "platform", msg.Platform, "channel", msg.ChannelID, "actor_role", actor.Role) return false, "no rule signal; silenced (fail-closed)" } // mentionsTriggerWord 报告消息内容是否通过当前角色的某个触发词 // 提到该角色(design/18 §2 触发②)。它是廉价子串匹配—— // 不调用 LLM,也绝不用于推断发送者是谁。 func (b *Bridge) mentionsTriggerWord(text string) bool { if text == "" { return false } for _, w := range b.triggerWords { if w != "" && strings.Contains(text, w) { return true } } return false } // registerSilenced 把一次被静默的入站对话持久化进其隔离 // 会话的平台日志,使适配器日志页仍能渲染非主人 // 消息流(design/16 §4.6 §4),随后就此结束——不进循环、不回复。a 是 // 收到该消息的账号适配器,日志因此带上适配器配置标签。 // 它是最小打扰路径(以及 owner 车道「进上下文不回」)中「登记」的那一侧。 func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) { // 被静默的消息不摄取附件(摄取在闸门之后),因此这两列恒为空 —— // 绝不写一条指向不存在文件的引用,也绝不写一张没进过上下文的图片。 // turn_id 同样传 "":这条消息**根本不会被回答**,不存在「它属于哪一路回合」, // 给它编一个标识就是把不属于任何回合的行说成有回合(见 turnid.go 的口径)。 // // 发送者标注与回帖路径**同源**(thirdPartySenderName):非主人恒有标注 // (昵称 → actor uid → 「未署名发送者」),主人恒空。静默行会被读回她的 // 上下文(transcript.go),而受限会话里空标注就是「主人本人」——用 // senderDisplayName 会在无昵称时写出空串,把一条陌生人消息渲染成 // 「来自主人(管理员账户)本人」(review p1:本次修掉的 bug 的镜像)。 b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, thirdPartySenderName(msg, actor), adapterName(a), "") b.log.Debug("inbound message silenced, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID, "actor_role", actor.Role) } // registerThrottled 是一次被强制流控(同一发送者已有回合在跑)拦下的 // 非主人消息的落点:先走最小打扰的登记路径(registerSilenced)把它写进 // 其隔离会话,再留一条**带 source 的可见告警** —— 被限流绝不静默 // (主人 2026-10-05 裁决③)。限流路径与普通静默因此区分得开:前者 // 一定有可见日志,后者只有 debug。 func (b *Bridge) registerThrottled(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter, reason string) { b.registerSilenced(ctx, msg, actor, sess, a) b.log.Warn("inbound message throttled by flow gate, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID, "reason", reason) } // inQuietHours 报告 at 是否落在配置的免打扰窗口内 // (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口, // 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试 // 可通过 Now 源确定性地驱动它。 func (b *Bridge) inQuietHours(at time.Time) bool { h := at.Hour() start, end := b.quietStartHour, b.quietEndHour if start <= end { return h >= start && h < end } return h >= start || h < end }