Files
Cyrene_v2/internal/services/channelbridge/engage.go
T
AskaEth af8591b78a fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
2026-10-04 12:07:40 +08:00

402 lines
16 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 channelbridge
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// This file holds the participation engine for public-platform group/stranger
// conversations (DevDocs/design/18): a per-conversation state machine
// (IDLE→PROBING→ACTIVE→COOLDOWN), a cost-controlled trigger gate (role-name mention
// / probability), an activity window with follow-up review, and cold-avoid when the
// group voices a negative attitude. It is in-memory per process (a restart resets it)
// and consults the light LLM judge only on an actual trigger, so no chat message is
// sent to an LLM just to decide whether to bother someone (a design/18 red line).
// EngageConfig carries the design/18 per-conversation participation tuning. P2c will
// make it per-group / panel-editable; for now DefaultEngageConfig supplies the
// constants and the EngageFor seam is left nil in the composition root.
type EngageConfig struct {
// Enabled is the per-conversation switch (design/18 §6 engageEnabled). When false
// only the role-name-reminder trigger still probes; the probability path is off.
Enabled bool
Probability float64 // 触发③ probability, default 0.10
ActivityWindow time.Duration // ACTIVE window, default 15m
ActivityMessages int // ACTIVE window by message count, default 20
Cooldown time.Duration // COOLDOWN duration, default 30m
ContextMessages int // how many recent messages the probe feeds the judge
}
// Knob defaults (design/18 §6). Compile-time so no bare magic number appears in the
// run path (project rule §10).
const (
defaultEngageProbability = 0.10
defaultActivityWindow = 15 * time.Minute
defaultActivityMessages = 20
defaultCooldownDuration = 30 * time.Minute
defaultEngageContext = 12
// consecutiveNoInterestThreshold is how many follow-up probes in a row return "no
// interest"/avoid before she exits ACTIVE back to IDLE (design/18 §4). A negative
// attitude instead parks her in COOLDOWN via the rule list below.
consecutiveNoInterestThreshold = 2
)
// errNoEngageJudge is returned when the participation engine is configured but no
// LightJudge was wired, so it fails closed to silence.
var errNoEngageJudge = errors.New("channelbridge: no light judge wired for engage")
// DefaultEngageConfig returns the design/18 knob defaults (P2a base; P2c renders them
// per-group/panel editable).
func DefaultEngageConfig() EngageConfig {
return EngageConfig{
Enabled: true,
Probability: defaultEngageProbability,
ActivityWindow: defaultActivityWindow,
ActivityMessages: defaultActivityMessages,
Cooldown: defaultCooldownDuration,
ContextMessages: defaultEngageContext,
}
}
// engageState is one phase of the per-conversation state machine (design/18 §1).
type engageState int
const (
engageIDLE engageState = iota
engagePROBING // transient: a trigger just routed this message to the judge
engageACTIVE // she is participating; follow-up review is on
engageCOOLDOWN // she was asked to be quiet; auto-resumes when the timer ends
)
// engageGroupState is one participation-engine instance, keyed by a group/stranger
// conversation (design/18 §1). It is in-memory only (a process restart resets it).
type engageGroupState struct {
mu sync.Mutex
state engageState
lastTopic string
lastInteract time.Time
activeUntil time.Time
msgsInWindow int
cooldownUntil time.Time
consecutiveNoInterest int
}
// engageConfigured reports whether the design/18 participation engine is wired for
// this bridge at all. It is true when the persona carries trigger words or the engage
// knobs enable the engine; otherwise the bridge keeps the pre-engage legacy light
// judgement (design/12 §6) untouched.
func (b *Bridge) engageConfigured() bool {
return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0
}
// engageKey 返回该会话在参与度引擎里的状态键(design/18 §1/§6,裁决 06.4.3)。键口径与
// 入站节流**完全一致**:adapter.ConversationKey(platform+channelID+groupID/peerID),
// 所以同一会话在「滑窗计数」和「参与度状态机」里落在同一个桶——两个群并发互不挤占。
// 群聊缺 group id 时用 actor 上报的群 id 补齐(适配器可能只填了 Sender.GroupID)。
func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if msg.GroupID == "" && (msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "") {
msg.GroupID = actor.GroupID
}
return adapter.ConversationKey(msg)
}
// engageState returns the state for a conversation key, creating an IDLE instance on
// first use (in-memory, safe for concurrent use).
func (b *Bridge) engageState(key string) *engageGroupState {
b.engageMu.Lock()
defer b.engageMu.Unlock()
st := b.engageGroups[key]
if st == nil {
st = &engageGroupState{state: engageIDLE}
b.engageGroups[key] = st
}
return st
}
// engageConfigFor resolves the effective engage config for a message: the per-group
// override (EngageFor, nil in P2a) when one is wired, else the base config, with any
// zero field normalized to its default.
func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor) EngageConfig {
cfg := b.engage
if b.engageFor != nil {
g := msg.GroupID
if g == "" {
g = actor.GroupID
}
if ov := b.engageFor(msg.Platform, g); ov != nil {
cfg = *ov
}
}
// Normalize unset fields to the sheltered defaults so a trigger-only config still
// bounds the activity window and cooldown instead of running with zero values.
if cfg.ActivityWindow <= 0 {
cfg.ActivityWindow = defaultActivityWindow
}
if cfg.ActivityMessages <= 0 {
cfg.ActivityMessages = defaultActivityMessages
}
if cfg.Cooldown <= 0 {
cfg.Cooldown = defaultCooldownDuration
}
if cfg.ContextMessages <= 0 {
cfg.ContextMessages = defaultEngageContext
}
if cfg.Probability <= 0 {
cfg.Probability = defaultEngageProbability
}
return cfg
}
// engageDecision is the design/18 participation decision for an inconclusive (to the
// cheap rules) non-owner message. It is only reached when engageConfigured() is true.
// It returns whether the message should drive the full agent loop and reply, plus a
// short reason for the log. The caller (shouldRespond) holds no lock; the per-key
// state mutex is acquired here so concurrent messages for one conversation serialize,
// while different conversations stay independent.
func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, inQuiet bool) (bool, string) {
cfg := b.engageConfigFor(msg, actor)
key := engageKey(msg, actor)
st := b.engageState(key)
st.mu.Lock()
defer st.mu.Unlock()
now := b.now()
// design/18 §5 cold-avoid first: a group member voicing a negative attitude is a
// hard "be quiet" signal → COOLDOWN, even mid-engagement. A direct @ / high-value
// keyword already returned above, so only genuinely "worth a reply by content"
// (or an owner message, which never reaches here) can override it.
if b.negativeAttitude(msg.Content) {
b.enterCooldown(st, cfg, now)
b.log.Debug("engage cooldown (negative attitude)",
"group", key, "until", st.cooldownUntil)
return false, "engage: cooldown (negative attitude)"
}
if st.inCooldown(now) {
b.log.Debug("engage cooldown active, not responding",
"group", key, "until", st.cooldownUntil)
return false, "engage: cooldown active"
}
// ACTIVE follow-up: while the activity window is live she keeps lightly judging
// the group's continuing discussion and tends to stay engaged (design/18 §4).
if st.state == engageACTIVE {
if st.activityExpired(now, cfg) {
b.toIdle(st)
b.log.Debug("engage activity window expired, back to idle", "group", key)
} else {
return b.engageFollowUp(ctx, msg, actor, sess, key, st, cfg, inQuiet)
}
}
// IDLE: only a role-name mention or a probability hit routes this message into a
// cost-controlled PROBING judgment (design/18 §2 触发②/③); otherwise it is
// registered and silenced with no LLM call (a red line).
if !b.mentionsTriggerWord(msg.Content) && !b.engageProbabilityHit(cfg) {
b.log.Debug("engage no trigger, registered only", "group", key)
return false, "engage: no trigger"
}
return b.engageProbe(ctx, msg, actor, sess, key, st, cfg, inQuiet)
}
// engageProbe is one cost-controlled PROBING judgment (design/18 §3): it feeds the
// group context + persona prompt to the light judge and decides ACTIVE (reply) or
// IDLE (registered silent). Parsing/LLM failure fails closed to silence.
func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
now := b.now()
st.state = engagePROBING
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
if err != nil {
b.toIdle(st)
b.log.Warn("engage probe failed; silenced", "group", key, "error", err)
return false, "engage: probe failed; silenced"
}
if d.AvoidTopic {
b.toIdle(st)
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", d.Reason)
return false, "engage: avoid topic"
}
if d.WorthReply || d.Interesting {
b.enterActive(st, cfg, now, msg)
b.log.Debug("engage probing → active (respond)", "group", key, "reason", d.Reason)
return true, "engage: " + d.Reason
}
b.toIdle(st)
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason)
return false, "engage: no interest"
}
// engageFollowUp is the ACTIVE-window follow-up review (design/18 §4): she still
// lightly judges the discussion but leans toward continuing. A run of no-interest or a
// finished window drops her out of ACTIVE; a negative attitude was already caught
// earlier by the cold-avoid rule.
func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, key string, st *engageGroupState, cfg EngageConfig, inQuiet bool) (bool, string) {
st.msgsInWindow++
now := b.now()
d, err := b.judgeEngage(ctx, msg, actor, sess, cfg, inQuiet)
if err != nil {
b.countNoInterest(st)
b.log.Warn("engage follow-up judge failed; counted as no interest", "group", key, "error", err)
return b.engageAfterFollowUp(st, cfg, now, key)
}
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
b.countNoInterest(st)
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", d.Reason)
return b.engageAfterFollowUp(st, cfg, now, key)
}
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
st.consecutiveNoInterest = 0
b.log.Debug("engage follow-up respond", "group", key, "reason", d.Reason)
return true, "engage follow-up: " + d.Reason
}
// engageAfterFollowUp resolves the outcome of a no-interest/avoid follow-up probe.
func (b *Bridge) engageAfterFollowUp(st *engageGroupState, cfg EngageConfig, now time.Time, key string) (bool, string) {
if st.consecutiveNoInterest >= consecutiveNoInterestThreshold || st.activityExpired(now, cfg) {
b.toIdle(st)
b.log.Debug("engage exited active (no interest / window ended)", "group", key)
return false, "engage: left active"
}
return false, "engage: not responding"
}
// countNoInterest bumps the consecutive no-interest counter for a follow-up probe.
func (b *Bridge) countNoInterest(st *engageGroupState) {
st.consecutiveNoInterest++
}
// judgeEngage renders the LightInput for a cost-controlled participation judgment and
// consults the injected LightJudge. It feeds the group's recent context and the
// persona prompt only here — never on an untriggered message.
func (b *Bridge) judgeEngage(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, cfg EngageConfig, inQuiet bool) (LightDecision, error) {
if b.light == nil {
return LightDecision{}, errNoEngageJudge
}
return b.light.Judge(ctx, LightInput{
Msg: msg, Actor: actor, SenderName: senderDisplayName(msg, actor),
InQuietHours: inQuiet, QuietStart: b.quietStartHour, QuietEnd: b.quietEndHour,
Engage: true,
PersonaPrompt: b.personaPrompt,
TriggerWords: b.triggerWords,
RecentContext: b.buildEngageContext(ctx, sess, cfg),
})
}
// buildEngageContext renders the conversation's most recent N messages with sender
// names, oldest-first, for the engagement judge. It is a no-op (empty string) when no
// history store is wired or the store has no rows yet, so the judge degrades to a
// single-trigger-message read.
func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, cfg EngageConfig) string {
if b.history == nil || cfg.ContextMessages <= 0 {
return ""
}
msgs, err := b.history.Recent(ctx, sess.SessionID, cfg.ContextMessages)
if err != nil || len(msgs) == 0 {
return ""
}
var sb strings.Builder
for _, m := range msgs {
name := m.SenderName
if name == "" {
name = "(未标注)"
}
role := m.Role
if role == "" {
role = "msg"
}
sb.WriteString(fmt.Sprintf("%s %s: %s\n", role, name, truncateText(m.Content, 80)))
}
if strings.TrimSpace(sb.String()) == "" {
return ""
}
return sb.String()
}
// engageProbabilityHit reports whether this message independently draws the
// role-name-reminder-free probability trigger (design/18 §2 触发③). It reads the
// injectable EngagRand source so tests are deterministic; nil randomness keeps the
// probability path off (fail-closed).
func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool {
if !cfg.Enabled || cfg.Probability <= 0 || b.engageRand == nil {
return false
}
return b.engageRand() < cfg.Probability
}
// negativeAttitude reports whether the group is telling her to be quiet (design/18 §5
// cold-avoid). It is a cheap substring rule that backs off anything the LLM might miss
// — being quieter is the safe direction (不打扰为第一原则). Never used for identity.
func (b *Bridge) negativeAttitude(text string) bool {
if text == "" {
return false
}
lower := strings.ToLower(text)
for _, k := range negativeAttitudeKeywords {
if strings.Contains(lower, k) {
return true
}
}
return false
}
// negativeAttitudeKeywords is the cold-avoid rule list (design/18 §5 词表兜底). It is a
// fallback; the same signal is read by the follow-up review in the judge via avoidTopic.
var negativeAttitudeKeywords = []string{
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
"安静点", "少说两句", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
"打住", "住嘴", "shut up", "too loud", "stop spamming",
}
// contentSnippet shortens a message to a bounded topic token for the state machine.
func contentSnippet(text string) string {
runes := []rune(strings.TrimSpace(text))
if len(runes) <= 40 {
return string(runes)
}
return string(runes[:40]) + "…"
}
func (st *engageGroupState) inCooldown(now time.Time) bool {
return st.state == engageCOOLDOWN && now.Before(st.cooldownUntil)
}
func (st *engageGroupState) activityExpired(now time.Time, cfg EngageConfig) bool {
return now.After(st.activeUntil) || st.msgsInWindow >= cfg.ActivityMessages
}
// toIdle resets the conversation back to IDLE (design/18: exit ACTIVE → IDLE).
func (b *Bridge) toIdle(st *engageGroupState) {
st.state = engageIDLE
st.msgsInWindow = 0
st.consecutiveNoInterest = 0
st.lastTopic = ""
}
// enterActive opens the activity window (design/18 §4). The caller holds st.mu.
func (b *Bridge) enterActive(st *engageGroupState, cfg EngageConfig, now time.Time, msg adapter.UnifiedMessage) {
st.state = engageACTIVE
st.activeUntil = now.Add(cfg.ActivityWindow)
st.msgsInWindow = 1
st.consecutiveNoInterest = 0
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
}
// enterCooldown parks the conversation in a quiet period (design/18 §5). The caller
// holds st.mu.
func (b *Bridge) enterCooldown(st *engageGroupState, cfg EngageConfig, now time.Time) {
st.state = engageCOOLDOWN
st.cooldownUntil = now.Add(cfg.Cooldown)
st.msgsInWindow = 0
st.consecutiveNoInterest = 0
st.lastTopic = ""
}