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 是当前角色的身份/系统提示词块。它由装配方传入, // 但 LLM 判定器**不把它透传进模型输入**:判定器只吃由它折算出的 // 「角色名 + 偏好摘要」(engagejudge.go 的 judgePersonaBrief)。 // 理由见 engageJudgePreferenceDigest 的注释 —— 整份人格指令里的称呼与 // 关系设定会被判定器搬进 reason 去指代第三方(审计 2026-10-30 §2.4)。 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 策略二)。优先级:**指向她本人**(design/18 §2 触发①)、 // 提及主人的显示名、高价值关键词;除此之外一律无结论(交给参与度引擎 / // 注入的 LightJudge,没有则失败关闭为静默)。**安静时段不是 // 这里的静默理由**(主人 2026-10-05 裁决②:安静时段由轻思考判断 // 是否回复,不是硬性全静默)——该信号由调用方随 inQuiet 传给 // 下游判定器。它只读取已解析的消息信号(提及/正文)——绝不 // 推断身份(身份由 actor 元数据在会话层解析,红线)。 // // 触发①的判据与 owner 车道**同一份**(ownerAddressedToMe,见 ownerlane.go, // 口径写死在 design/18 §2 触发① 的 2026-10-13 修订里):`@ 她本人 / @全体` // 才必回,**不是「有任意提及」** —— 群里 @ 别人与 @ 她长得一模一样, // 拿「Mentions 非空」当判据会让群员之间的互 @ 把她拽进每一段对话 // (2026-10-14 真机病灶:`@3889001246`、`@867587435` 两条都被必回)。 // // 判据的后半句(SelfID 为空时退回「有提及」)**不可省**:kook / webhook 等尚未 // 填 SelfID 的平台 AddressedToMe 恒 false,只留前半句会把它们的群 @ 一并打死 // (「没有信号」≠「没在对我说话」)。反过来,判据**全部来自结构化字段**, // 绝不匹配正文里的 "@<数字>" —— @ 她与 @ 别人在正文里长得一模一样, // 那正是本病灶的成因。 func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage) lightVerdict { if ownerAddressedToMe(msg) { 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 循环并回复,还是只登记并静默。判定顺序(M18 后的**权威顺序**): // // ① owner 车道(`actor.IsOwner()`,design/18 §11「乙 = C」):主人私聊恒回; // 主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说(见 ownerlane.go)—— // 冷避不适用于主人,主人的 @/@全体仍必回。 // ② 平台/系统噪声 → 只登记。 // ③ 陌生人私聊 → 正常聊天(唯一闸门是流控)。 // ④ **非主人群聊:冷避闸**(design/18 §5,M18 新增位次)——负面态度 → COOLDOWN、 // 冷却期内静默;**优先于第 ⑤ 步的「@她必回」**,与是否装配参与度引擎无关。 // 冷却期内唯一放行口是**真故障/求救信号穿透**(严格子集,非高价值关键词全表: // 例外给的是穿透权,噪声会直接削弱冷避),且**不解除冷却**; // 同句负面态度优先于该例外(塔 2026-10-15 定稿)。 // ⑤ 廉价规则:指向她本人 / 提及主人显示名 / 高价值关键词 → 必回。 // ⑥ 规则无结论 → 参与度引擎(design/18)或旧轻量判定车道(design/12 §6)。 // // 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()) // design/18 §5 冷避**先于**「@她必回」(M18,主人 2026-10-14 裁决「给」: // 群成员也能让她少说话):非主人**群**消息先过冷避闸 —— 命中负面态度就进 // COOLDOWN,冷却期内一律静默(两者都只登记、不进循环)。顺序上必须排在下一条 // 规则判定**之前**,否则 @她 / 高价值关键词会在规则层直接 return true, // 群成员刚说的「少说点」被随后的任何一次 @ 立刻作废(2026-10-14 报障病灶)。 // // 冷却期内的**唯一**放行口是「真故障/求救信号穿透」(塔 2026-10-15 定稿): // 该条由冷避闸自己返回「不拦」,随后落到下面的 ruleLightJudge 正常回复; // 判据是严格子集 coldShoulderPierceKeywords(**不是** ruleLightJudge 用的 // hasHighValueKeyword 全表 —— 穿透权落在全表上会让卡片里的 `bug` 把冷避按掉)。 // @她 / @全体 **不穿透**(那正是「给」的实质),且穿透**不解除冷却**; // 同句既有负面态度又有真故障信号时,**负面态度优先**(仍静默)。 // 口径细节见 engage.go 的 coldShoulderLocked(design/18 §5/§9 由塔同步)。 // // 闸的适用面在冷避闸自己身上写死:只对群聊(私聊与 owner 车道在更早处已分岔) // 且与是否装配参与度引擎无关 —— 旧轻量判定车道的部署同样得到这个顺序。 if sess.IsGroup { if quiet, reason := b.coldShoulderGate(msg, actor); quiet { return false, reason } } 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: " + judgeReason(d.Reason) } return false, "light judge: " + judgeReason(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 }