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" ) // This file holds the channel bridge's minimal-disturbance inbound classification // for public platforms (DevDocs/design/12 §6 策略一/二/五). Non-owner group/stranger // messages are judged cheaply before they reach the full agent loop, so a busy // public group does not fling every message into an expensive LLM turn. The logic // lives here to keep channelbridge.go under the module size bound; it never reads // message content to decide identity — identity is already resolved structurally by // the session/actor layer (a project red line). // Quiet-hour defaults, mirroring the backthink service so the bridge and the // background thinker share one do-not-disturb window (design/12 §6 策略五). const ( defaultQuietStartHour = 23 defaultQuietEndHour = 7 ) // lightVerdict is the outcome of a rule-based light judgement before the optional // injected LightJudge is consulted (design/12 §6 策略二/五). type lightVerdict int const ( // verdictRespond means the rule signals the message is worth a response. verdictRespond lightVerdict = iota // verdictSilent means the rule signals the message should be registered but not // answered (e.g. during quiet hours with no strong signal). verdictSilent // verdictInconclusive means the rules cannot tell; defer to the LightJudge (or // fail-closed to silence when none is wired). verdictInconclusive ) // LightInput is the set of signals a LightJudge receives to decide response // worthiness. Identity is already resolved structurally by the session/actor // layer, so the judge weighs only 值不值得回 — it never decides who the sender is. type LightInput struct { Msg adapter.UnifiedMessage Actor adapter.Actor SenderName string InQuietHours bool QuietStart int QuietEnd int // Engage reports whether this is a cost-controlled participation judgment // (design/18): it is set only when a trigger (role-name mention / probability) // or an active activity window routed the message here, so the caller feeds the // group context and persona prompt ONCE and never on every chat message. A // legacy (design/12 §6) inconclusive judgment leaves it false. Engage bool // PersonaPrompt is the current persona's identity/system-prompt block, so the // judge can weigh the role's tone without ever deciding identity. It carries no // owner secret (a project red line). PersonaPrompt string // RecentContext is the group conversation's recent N messages rendered with each // sender's display name, plus the triggering message, so the judge can tell // whether the topic is worth participating in. RecentContext string // TriggerWords are the current persona's role-name reminder words (design/18 §2), // passed as context so the judge knows which word was mentioned. TriggerWords []string } // LightDecision is a LightJudge's verdict. type LightDecision struct { // Respond is the legacy design/12 §6 simple verdict (respond or not). It is kept // for backward compatibility; the design/18 fields below carry the structured // participation verdict that the engage engine reads. Respond bool Reason string // Interesting reports whether she finds the topic interesting (design/18) — the // signal to enter/remain ACTIVE. Interesting bool // WorthReply reports whether the message is worth a reply. WorthReply bool // AvoidTopic reports whether the topic should be avoided (sensitive / privet / // red-line), so she does not engage even when otherwise interested. AvoidTopic bool } // LightJudge is the optional cost-controlled light-response judge (design/12 §6 // 策略二/五). The bridge consults it only when the built-in rule judge is // inconclusive, so a chat message is not repeatedly sent to an expensive LLM. A // nil LightJudge (the default) keeps the bridge rule-only and fail-closed toward // silence (不打扰为第一原则). The production wiring does not supply one yet, so the // seam is ready but not token-consuming until it is deliberately wired. type LightJudge interface { Judge(ctx context.Context, in LightInput) (LightDecision, error) } // highValueKeywords is the small term list the rule judge matches to decide a // message is worth a response even when the sender was not directly addressed // (设计/12 §6 策略二: 关键词重要度). It is a cheap substring match — never an LLM call, // and never used to infer identity. var highValueKeywords = []string{ "紧急", "救命", "故障", "坏掉", "坏了", "报错", "出错", "崩溃", "求助", "帮帮我", "help", "urgent", "error", "bug", } // isLowValueEvent reports whether a non-owner message is platform/system noise // that must be registered but never drives the full agent loop (设计/12 §6 策略一). // It is only consulted for non-owner messages: a system/synthetic sender is the // canonical low-value signal. Messages with no meaningful chat text are dropped at // the bridge head anyway, so this primarily gates system-actor notifications. func isLowValueEvent(actor adapter.Actor) bool { return actor.IsSystem() } // hasHighValueKeyword reports whether text carries a term worth responding to even // when the sender was not directly addressed. Kept tiny so the rule path stays O(1) // per message. func hasHighValueKeyword(text string) bool { lower := strings.ToLower(text) for _, k := range highValueKeywords { if strings.Contains(lower, k) { return true } } return false } // ruleLightJudge decides response-worthiness from cheap rule signals only // (设计/12 §6 策略二/五). Priority: a direct mention, a mention of the owner's display // name, a high-value keyword, then a quiet-hours bias toward silence; otherwise the // verdict is inconclusive (defer to the injected LightJudge, else silence). It // reads only already-resolved actor metadata and message content — never infers // identity. func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) 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 } if inQuiet { return verdictSilent } return verdictInconclusive } // shouldRespond implements the minimal-disturbance classification for a non-owner // inbound message (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5). It returns whether // the message should drive the full agent loop and reply, or be registered-and- // silenced. Owner messages never reach here (processFrom gates on !actor.IsOwner), // so owner-private chat and owner group @/high-value replies are untouched. sess is // the isolated group/stranger session binding the message resolved to, so the // participation engine can read the group's recent context when it probes. func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) (bool, string) { // 策略一: platform/system noise → register only, never the full loop. 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" } // 策略二/五: rule-based light judgement, quiet-hours aware. A direct @, a mention // of the owner's display name, or a high-value keyword always responds; quiet // hours bias toward silence. The participation engine is only consulted on the // inconclusive remainder. inQuiet := b.inQuietHours(b.now()) switch b.ruleLightJudge(msg, actor, inQuiet) { case verdictRespond: return true, "rule signal: worth responding" case verdictSilent: b.log.Debug("non-owner message silenced (quiet/no signal), registered only", "platform", msg.Platform, "channel", msg.ChannelID, "quiet", inQuiet, "actor_role", actor.Role) return false, "rule signal: not worth responding" } // The cheap rules are inconclusive. When the participation engine (design/18) is // configured it takes over and only consults the light judge on an actual trigger // (role-name mention / probability) or an active activity window — never on every // chat message, so cost stays controlled (a red line). Without engage // configuration the bridge keeps the pre-engage legacy behaviour: consult the // optional injected light judge on every inconclusive message, else fail-closed. if !b.engageConfigured() { return b.legacyLightJudge(ctx, msg, actor, inQuiet) } return b.engageDecision(ctx, msg, actor, sess, inQuiet) } // legacyLightJudge is the pre-design/18 inconclusive handling: consult the optional // injected light judge, fail-closed toward silence on error or absence // (不打扰为第一原则). It is only reached when the participation engine is not wired. 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 reports whether the message content names the current persona // via one of its trigger words (design/18 §2 触发②). It is a cheap substring match — // never an LLM call, and never used to infer who the sender is. 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 persists a silenced non-owner inbound turn into its isolated // session's platform log so the adapter log page still renders the non-owner // stream (design/16 §4.6 §4), then stops — no loop, no reply. a is the account // adapter that received the message, so the log is tagged with the adapter config. // It is the 登记 side of the minimal-disturbance path. func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) { b.persistIsolated(ctx, history.RoleUser, msg.Content, deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a)) b.log.Debug("non-owner message silenced, registered only", "platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID) } // inQuietHours reports whether at falls inside the configured do-not-disturb // window (shared semantics with backthink: equal non-zero hours disable it, and a // window that wraps midnight is handled). It is a pure method on Bridge so tests // drive it deterministically via the Now source. 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 }