From 093f38d98611455b317cfb29bc498305116f1e28 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 13:33:06 +0800 Subject: [PATCH] =?UTF-8?q?docs(comments):=20=E6=B3=A8=E9=87=8A=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=8C=96=20=C2=B7=20internal/services/channelbridge?= =?UTF-8?q?=EF=BC=88=E7=BA=AF=E6=9B=BF=E6=8D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态) - 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避) - 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净 --- internal/services/channelbridge/blacklist.go | 28 +- internal/services/channelbridge/bubbles.go | 40 +-- .../services/channelbridge/bubbles_test.go | 22 +- .../services/channelbridge/channelbridge.go | 216 ++++++++-------- .../channelbridge/channelbridge_test.go | 116 ++++----- internal/services/channelbridge/connect.go | 64 ++--- internal/services/channelbridge/engage.go | 176 ++++++------- .../services/channelbridge/engage_test.go | 74 +++--- .../services/channelbridge/engagejudge.go | 68 ++--- internal/services/channelbridge/flowgate.go | 70 ++--- .../services/channelbridge/flowgate_test.go | 40 +-- internal/services/channelbridge/helpers.go | 34 +-- internal/services/channelbridge/inbound.go | 240 +++++++++--------- internal/services/channelbridge/light.go | 196 +++++++------- internal/services/channelbridge/light_test.go | 44 ++-- internal/services/channelbridge/outbound.go | 16 +- internal/services/channelbridge/persist.go | 90 +++---- internal/services/channelbridge/persona.go | 92 +++---- .../services/channelbridge/persona_test.go | 66 ++--- internal/services/channelbridge/proactive.go | 140 +++++----- .../services/channelbridge/proactive_test.go | 72 +++--- .../services/channelbridge/readloop_test.go | 34 +-- internal/services/channelbridge/reload.go | 96 +++---- .../services/channelbridge/reload_test.go | 10 +- internal/services/channelbridge/sanitize.go | 82 +++--- .../services/channelbridge/sanitize_test.go | 2 +- internal/services/channelbridge/source.go | 40 +-- .../services/channelbridge/source_test.go | 10 +- 28 files changed, 1089 insertions(+), 1089 deletions(-) diff --git a/internal/services/channelbridge/blacklist.go b/internal/services/channelbridge/blacklist.go index eddccf6..f54fcc0 100644 --- a/internal/services/channelbridge/blacklist.go +++ b/internal/services/channelbridge/blacklist.go @@ -4,16 +4,16 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// This file holds the per-account allow/deny black-or-white lists (design/16 §4.6 -// §6) enforced at the head of the inbound pipeline, plus the config readers the -// list matching depends on. +// 本文件承载按账号的允许/拒绝黑白名单(design/16 §4.6 +// §6),在入站流水线的最前端执行,并包含名单匹配所依赖的 +// 配置读取函数。 -// blacklisted applies the per-account allow/deny black-or-white lists (design/16 -// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID -// against allowGroups/denyGroups; direct messages are matched by the sender's user -// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the -// deny list applies); a non-empty allow list becomes a white list. The owner is -// never blacklisted (the caller skips this for owner messages). +// blacklisted 对非主人的入站消息应用按账号的允许/拒绝黑白名单 +// (design/16 §4.6 §6)。群消息按 GroupID +// 匹配 allowGroups/denyGroups;私聊消息按发送者用户 +// id 匹配 allowUsers/denyUsers。空的允许名单表示「全部允许」(仅 +// 拒绝名单生效);非空允许名单即成为白名单。主人 +// 永不被拉黑(调用方对主人消息跳过本函数)。 func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) { if a == nil { return false, "" @@ -38,9 +38,9 @@ func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapt return false, "" } -// channelLists reads the allow/deny string lists off an adapter's Config snapshot. -// The lists are stored as plain []string values (set by the multi-account wiring); -// an absent key yields an empty list. +// channelLists 从适配器的 Config 快照中读取允许/拒绝字符串列表。 +// 列表以普通 []string 值存储(由多账号装配设置); +// 键缺失时得到空列表。 func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) { if cfg == nil { return nil, nil, nil, nil @@ -49,8 +49,8 @@ func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, deny cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers") } -// cfgStrings reads a []string field from an adapter config map, returning nil when -// absent or of a different type. +// cfgStrings 从适配器配置 map 中读取 []string 字段,缺失或 +// 类型不符时返回 nil。 func cfgStrings(m map[string]any, key string) []string { if m == nil { return nil diff --git a/internal/services/channelbridge/bubbles.go b/internal/services/channelbridge/bubbles.go index 97c8f10..9850fd5 100644 --- a/internal/services/channelbridge/bubbles.go +++ b/internal/services/channelbridge/bubbles.go @@ -8,29 +8,29 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// bubbleSender is the per-bubble outbound call the shared IM dispatcher uses. A -// reply exits through the adapter's SendMessage (UnifiedResponse) path, a proactive -// push through its SendProactive path — both collapse to "dispatch one chat bubble to -// the platform", which is what lets the reply and the proactive push share a single -// sanitize/split/gap pipeline instead of each re-implementing it. +// bubbleSender 是共享 IM 派发器所用的单气泡出站调用。 +// 回复经适配器的 SendMessage (UnifiedResponse) 路径出口,主动推送 +// 经其 SendProactive 路径出口——两者都归结为「向平台派发一个聊天气泡」, +// 这正是回复与主动推送能共用同一套 +// 清洗/切分/间隔流水线、而无需各自重写的原因。 type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error -// sendIMBubbles is the single IM outbound exit shared by sendReply (reply path) and -// SendProactive (proactive push). It turns content into the plain chat bubbles an IM -// channel should receive: role-play/action/narration markup (rendered as muted italic -// on the Web) is stripped and each

paragraph is delivered as its own -// bubble/message (design/17 §2), with a multi-bubble burst spacing its sends 0.5-2s -// apart so it does not flood the channel. The Web frontend is never affected — it -// renders the persisted stream directly and does not route through here. +// sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送) +// 共用的唯一 IM 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡: +// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离, +// 每个

段落作为独立 +// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s, +// 以免刷屏。Web 前端完全不受影响—— +// 它直接渲染已持久化的流,不经过这里。 // -// target carries the platform/channel targeting fields and is copied onto every -// bubble (only Content/ContentType/Timestamp differ per bubble); sender is the -// adapter's outbound call. A content that strips to nothing (a pure action line) is -// dropped without a send, and a per-bubble failure is wrapped with its index. +// target 携带平台/渠道路由字段,并被复制到每个 +// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同);sender 是 +// 适配器的出站调用。被剥成空的内容(纯动作行) +// 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。 func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error { - // splitBubbles applies stripNarration per bubble (so / and the - // star-drift role-play forms are removed for IM) and splits on

. It is the - // single sanitize entry point, shared with the old reply path. + // splitBubbles 对每个气泡应用 stripNarration(因此 / 与 + // 星号漂移的角色扮演形式都会为 IM 剥离),并按

切分。它是 + // 唯一的清洗入口,与旧的回复路径共用。 bubbles := splitBubbles(content) if len(bubbles) == 0 { b.log.Debug("im outbound has no plain text, skipped", "platform", target.Platform, "channel", target.ChannelID) @@ -44,7 +44,7 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag out.Content = bubble out.ContentType = adapter.ContentText out.Timestamp = time.Now().UTC() - // Each bubble is its own send so the gap lands between messages. + // 每个气泡各自发送一次,间隔才会落在消息之间。 if err := sender(ctx, out); err != nil { return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err) } diff --git a/internal/services/channelbridge/bubbles_test.go b/internal/services/channelbridge/bubbles_test.go index c3480a1..09bc9ec 100644 --- a/internal/services/channelbridge/bubbles_test.go +++ b/internal/services/channelbridge/bubbles_test.go @@ -9,9 +9,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) -// gapCounter returns a bubbleGapFn that counts how many times the inter-bubble delay -// is consulted and returns zero, so the multi-bubble spacing can be asserted without -// sleeping a real 0.5-2s per gap. +// gapCounter 返回一个 bubbleGapFn:它统计气泡间延时的咨询次数 +// 并返回零,因此无需为每个间隔真的 sleep 0.5-2s +// 就能断言多气泡间隔。 func gapCounter(counter *int) func() time.Duration { return func() time.Duration { *counter++ @@ -51,8 +51,8 @@ func TestSendIMBubbles_SplitsStripsAndGaps(t *testing.T) { if ad.sends[0].Platform != "kook" || ad.sends[0].ChannelID != "329284282" { t.Fatalf("bubble[0] target = %+v, want kook 329284282", ad.sends[0]) } - // Two bubbles → exactly one inter-bubble delay is consulted (and applies to both - // reply and proactive paths, since this drives the shared dispatcher). + // 两个气泡 → 恰好咨询一次气泡间延时(回复与主动推送两条路径 + // 都适用,因为驱动的是共享派发器)。 if gaps != 1 { t.Fatalf("gap calls = %d, want 1 (2 bubbles → one gap)", gaps) } @@ -105,9 +105,9 @@ func TestSendIMBubbles_PureActionDropped(t *testing.T) { } } -// TestSendReply_SplitsBubblesAndStrips exercises the reply exit (sendReply) after the -// refactor: a multi-

reply is delivered as several stripped bubbles through the -// shared dispatcher, targeting the source conversation. +// TestSendReply_SplitsBubblesAndStrips 覆盖重构后的回复出口(sendReply): +// 含多个

的回复经共享派发器以多个已剥离的气泡 +// 投递,目标为来源会话。 func TestSendReply_SplitsBubblesAndStrips(t *testing.T) { t.Parallel() b, _, _, ad := newTestBridge(t) @@ -128,9 +128,9 @@ func TestSendReply_SplitsBubblesAndStrips(t *testing.T) { } } -// TestSendProactive_SplitsWeChatBubbles verifies the proactive push now splits a -// multi-paragraph message into bubbles on WeChat (the reported regression: WeChat -// proactive pushes arrived as one un-split

blob). +// TestSendProactive_SplitsWeChatBubbles 验证主动推送现在会在 WeChat 上 +// 把多段消息拆成气泡(上报的回归问题:WeChat +// 主动推送曾以一整块未拆分的

到达)。 func TestSendProactive_SplitsWeChatBubbles(t *testing.T) { t.Parallel() const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat" diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 7ea98ac..628c615 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -1,14 +1,14 @@ -// Package channelbridge wires the registered IM channel adapters into the Cyrene -// runtime: it connects each configured adapter, installs the inbound message -// handler, and routes every normalized inbound message through the -// channel→session→actor→loop→reply pipeline (DevDocs/design/08 §0-§2). +// channelbridge 包把已注册的 IM 渠道适配器接入 Cyrene 运行时: +// 它连接每个已配置的适配器、装配入站消息处理器,并让每条规范化后的入站消息 +// 依次流经 +// 渠道→会话→actor→循环→回复 流水线(DevDocs/design/08 §0-§2)。 // -// The bridge is deliberately generic over adapter.PlatformAdapter. It owns the -// per-platform Connect lifecycle and the inbound→reply path; the concrete -// adapters themselves (and their configuration, sourced from env) are assembled -// in cmd/cyrene/wire.go and handed in via Add. It never reads message text to -// decide identity — that is ResolveActor's job (actor metadata + owner whitelist, -// a project red line). +// 桥接器刻意对 adapter.PlatformAdapter 保持泛化。它掌管 +// 按平台的 Connect 生命周期与「入站→回复」链路;具体适配器 +// 本身(及其来自环境变量的配置)在 cmd/cyrene/wire.go 中组装, +// 并通过 Add 注入。它绝不读取消息正文来 +// 决定身份——那是 ResolveActor 的职责(actor 元数据 + 主人白名单, +// 即项目红线)。 package channelbridge import ( @@ -29,57 +29,57 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) -// webhookAdapter is the optional capability a channel adapter may expose to -// source an HTTP inbound callback. *webhook.Adapter and *kook.Adapter (webhook -// mode) implement it. The bridge reports these so the gateway can mount them. +// webhookAdapter 是渠道适配器可选择性暴露的能力,用于 +// 提供 HTTP 入站回调。*webhook.Adapter 与 *kook.Adapter(webhook +// 模式)实现了它。桥接器上报这些能力,好让 gateway 挂载它们。 type webhookAdapter interface { - // InboundHandler returns the http.Handler the gateway mounts at the route to - // receive external callbacks. + // InboundHandler 返回 gateway 挂载在路由上、用于 + // 接收外部回调的 http.Handler。 InboundHandler() http.Handler - // WebhookPath returns the route the gateway should mount the handler at. It - // returns "" when the adapter is not currently exposing a webhook, letting the - // bridge skip it (e.g. a websocket-mode KOOK). + // WebhookPath 返回 gateway 应挂载该处理器的路由。适配器当前 + // 未暴露 webhook 时返回 "",让 + // 桥接器跳过它(例如 websocket 模式的 KOOK)。 WebhookPath() string } -// Knob constants. Compile-time so no bare magic number appears in the run path -// (project rule §10). +// 调节参数常量。编译期常量,运行路径上就不会出现裸魔法数字 +// (项目规范 §10)。 const ( - // connectTimeout bounds a single adapter Connect so a hung dial cannot stall - // boot (a failed connect logs a warning and is skipped, never fatal). + // connectTimeout 限定单次适配器 Connect 的时长,卡住的拨号不会拖住 + // 启动(连接失败只记警告并跳过,绝不致命)。 connectTimeout = 10 * time.Second - // maxConcurrent bounds how many inbound messages run the agent loop at once, - // so a burst of channel messages cannot spawn unbounded goroutines. + // maxConcurrent 限定同时运行 agent 循环的入站消息数, + // 渠道消息突发时不会无限制地派生 goroutine。 maxConcurrent = 16 ) -// ErrNilLoop is returned when the bridge is built without a chat runner. +// ErrNilLoop 表示构建桥接器时未提供对话运行器。 var ErrNilLoop = errors.New("channelbridge: nil loop runner") -// ErrNilSessions is returned when the bridge is built without a session store. +// ErrNilSessions 表示构建桥接器时未提供会话存储。 var ErrNilSessions = errors.New("channelbridge: nil session store") -// Chatter runs a single agent turn. *loop.Loop is the production implementation; -// tests inject a stub. +// Chatter 执行单轮 agent。生产实现是 *loop.Loop; +// 测试注入桩实现。 type Chatter interface { Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) } -// SessionStore resolves the channel↔session binding for an inbound message. -// *session.SessionManager is the production implementation. +// SessionStore 解析一条入站消息的「渠道↔会话」绑定。 +// 生产实现是 *session.SessionManager。 type SessionStore interface { GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (session.Session, error) } -// Options carries the bridge's injected dependencies. Loop and Sessions are -// required; Owner may be nil (then no account is recognized as owner, the safe -// default-deny posture from design/11 §0). History and OnPersisted are optional -// (design/16 §4.4): when a history store is wired, owner-private messages are -// routed into the main web session and tagged with the channel platform, and every -// persisted message is handed to OnPersisted so the gateway can broadcast a -// `message_persisted` (web chat) or `channel_persisted` (IM adapter log) event; -// when both are absent the bridge runs exactly as before (isolated sess-xxx, no -// chat_messages writes). +// Options 承载桥接器注入的依赖。Loop 与 Sessions 为 +// 必填;Owner 可为 nil(此时不识别任何主人账号,即 design/11 §0 的 +// 安全默认拒绝姿态)。History 与 OnPersisted 可选 +// (design/16 §4.4):接入历史存储后,主人私聊消息会 +// 被路由进主 Web 会话并打上渠道平台标签,且每条 +// 已持久化消息都会交给 OnPersisted,好让 gateway 广播 +// `message_persisted`(Web 聊天)或 `channel_persisted`(IM 适配器日志)事件; +// 两者都缺省时,桥接器行为与以往完全一致(隔离的 sess-xxx, +// 不写 chat_messages)。 type Options struct { Log *slog.Logger Loop Chatter @@ -87,71 +87,71 @@ type Options struct { Owner *user.OwnerProfile History history.HistoryStore OnPersisted func(history.Message) - // OnRejected, when non-nil, is called after a message is dropped by the - // allow/deny black-or-white lists (design/16 §4.6 §6), so the composition root - // can route it into an audit trail. It is invoked with the rejected message and - // a short reason token ("group_denied", "user_not_allowed", ...). Nil = log only. + // OnRejected 非 nil 时,会在消息被 + // 黑白名单(allow/deny,design/16 §4.6 §6)丢弃后调用,便于组合根 + // 将其写入审计轨迹。调用时传入被拒消息与 + // 简短原因标记("group_denied"、"user_not_allowed" 等)。Nil = 仅记日志。 // - // It is also wired as the flow gate's OnPruned audit hook (design/12 §6 策略三/四), - // so a message dropped by the rate limiter or the review prune reaches the same - // audit trail with reason "rate_limited", "review_pruned" or "capped". + // 它同时被装配为 flow gate 的 OnPruned 审计钩子(design/12 §6 策略三/四), + // 因此被限流器或审查裁剪丢弃的消息也会进入同一 + // 审计轨迹,原因为 "rate_limited"、"review_pruned" 或 "capped"。 OnRejected func(adapter.UnifiedMessage, string) - // Review is the optional review model (design/12 §6 策略四) that prunes low-value - // queued messages. Nil falls back to FIFO eviction at the total cap. Use - // NewLLMReviewer to wrap a model.Adapter into this signature. + // Review 是可选的审查模型(design/12 §6 策略四),用于裁剪低价值 + // 队列消息。Nil 时在总量上限处回退为 FIFO 淘汰。用 + // NewLLMReviewer 把 model.Adapter 包装成该签名。 Review adapter.Reviewer - // Light is the optional light-response judge for non-owner group/stranger - // messages (design/12 §6 策略二/五 + design/18 §3). When nil the bridge uses its - // built-in rule-based judge and fails-closed to silence, so no chat message is sent - // to an LLM just to decide whether to bother the owner. It is consulted when the - // rule judge is inconclusive and the participation engine (design/18) has either - // been triggered (role-name mention / probability) or is in an ACTIVE window. Use - // NewLLMLightJudge to wrap a model.Adapter into a cost-controlled judge. + // Light 是可选的轻量应答判定器,用于非主人的群聊/陌生人消息 + // (design/12 §6 策略二/五 + design/18 §3)。为 nil 时桥接器使用 + // 内置规则判定器并静默失败关闭,不会仅为「是否打扰主人」而把 + // 聊天消息发给 LLM。它在规则判定器得不出结论、且参与度引擎 + // (design/18)已被触发(角色名提及 / 概率)或处于 ACTIVE 窗口时 + // 被调用。用 NewLLMLightJudge 把 model.Adapter 包成 + // 受成本控制的判定器。 Light LightJudge - // TriggerWords are the current persona's role-name reminder words (design/18 §2). - // A group message containing any one routes into participation PROBING even without - // a direct @. They never imply identity (that stays structural). Empty keeps the - // bridge on the legacy design/12 §6 light judgement. + // TriggerWords 是当前角色的角色名提醒词(design/18 §2)。 + // 群消息只要含有其中任一个,即使没有直接 @ 也会进入参与度 PROBING。 + // 它们绝不代表身份(身份仍由结构决定)。为空则保持 + // 桥接器沿用旧的 design/12 §6 轻量判定。 TriggerWords []string - // PersonaPrompt is the current persona's identity/system-prompt block handed to the - // participation judge so it can weigh the role's tone. It carries no owner secret. + // PersonaPrompt 是交给参与度判定器的当前角色身份/系统提示词块, + // 使其能权衡角色的语气。其中不含主人敏感信息。 PersonaPrompt string - // Engage is the participation-engine config (probability, activity window, cooldown, - // context size). When its Enabled/Probability are zero AND TriggerWords is empty the - // bridge keeps the legacy light judgement and never consults the engine. Use - // DefaultEngageConfig for the design/18 defaults. + // Engage 是参与度引擎配置(概率、活跃窗口、冷却、 + // 上下文大小)。当其 Enabled/Probability 为零且 TriggerWords 为空时, + // 桥接器沿用旧的轻量判定,永不咨询该引擎。用 + // DefaultEngageConfig 取 design/18 的默认值。 Engage EngageConfig - // EngageFor is an optional per-conversation override (design/18 §6 per-group knobs). - // Nil keeps the base Engage config for every conversation (P2a). + // EngageFor 是可选的按会话覆盖(design/18 §6 的按群参数)。 + // 为 nil 时每个会话都用基准 Engage 配置(P2a)。 EngageFor func(platform, groupID string) *EngageConfig - // EngageRand is the injectable [0,1) source for the probability trigger. Nil uses a - // default math/rand source; tests inject a fixed source for determinism. + // EngageRand 是概率触发可注入的 [0,1) 随机源。为 nil 时使用 + // 默认的 math/rand 源;测试注入固定源以保证确定性。 EngageRand func() float64 - // QuietStartHour and QuietEndHour bound the do-not-disturb window (local clock - // hours), shared with the backthink service so 策略五 reuses the same definition. - // Equal non-zero hours disable the window; both zero pick the backthink defaults - // (23-07). This is the price of keeping one source of truth across services. + // QuietStartHour 与 QuietEndHour 限定免打扰窗口(本地时钟 + // 小时),与 backthink 服务共享,使策略五沿用同一定义。 + // 两个非零且相等的时刻关闭该窗口;都为零则取 backthink 默认值 + // (23-07)。这是跨服务保持单一事实来源的代价。 QuietStartHour int QuietEndHour int - // Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now, - // which makes time-dependent behaviour injectable in tests. + // Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now, + // 使依赖时间的行为在测试中可注入。 Now func() time.Time - // Factory rebuilds a platform's adapters from its effective account config - // (channels.json → env fallback). When non-nil the bridge supports hot reload; - // nil keeps the bridge boot-only (Reload returns ErrNilFactory). + // Factory 依据某平台生效的账号配置重建其适配器 + // (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载; + // 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。 Factory PlatformFactory - // ChannelPersona, when non-nil, resolves the role bound to one channel account - // (channels.json ChannelAccount.PersonaID, design/22 §2 方案 b): it is called with - // the platform and the local account id, and returns "" when the account binds no - // role. It is consulted once per adapter connect/reload (never per message) and - // only for the owner's private chat, so a group/stranger conversation can never be - // moved into a role's scope (design/22 §7-4). Nil keeps every channel on the global - // default role. + // ChannelPersona 非 nil 时解析某个渠道账号绑定的角色 + // (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入 + // 平台与本地账号 id,账号未绑定角色时返回 ""。 + // 它只在每次适配器连接/重载时咨询一次(绝不按消息调用), + // 且仅针对主人的私聊,因此群聊/陌生人会话绝不会 + // 被移入某个角色的作用域(design/22 §7-4)。为 nil 时所有渠道都使用全局 + // 默认角色。 ChannelPersona func(platform, accountID string) string } -// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It -// is safe for concurrent use. +// Bridge 掌管渠道适配器生命周期与入站消息流水线。它 +// 可安全并发使用。 type Bridge struct { log *slog.Logger run Chatter @@ -167,17 +167,17 @@ type Bridge struct { quietEndHour int now func() time.Time - // Participation-engine state (design/18): per-conversation in-memory state machine - // + the persona reminder words / engage knobs that gate it, and the injectable - // randomness source for the probability trigger. + // 参与度引擎状态(design/18):按会话的内存状态机, + // 加上约束它的角色提醒词 / 参与度参数,以及概率触发 + // 可注入的随机源。 triggerWords []string personaPrompt string engage EngageConfig engageFor func(platform, groupID string) *EngageConfig engageRand func() float64 - // bubbleGapFn is the injectable delay between IM outbound bubbles (0.5-2s), - // defaulting to the package bubbleGap. Tests inject a fixed short gap so the - // multi-bubble delay is asserted deterministically without sleeping a real 0.5-2s. + // bubbleGapFn 是 IM 出站气泡之间可注入的延时(0.5-2s), + // 默认取包内的 bubbleGap。测试注入固定的短间隔,从而 + // 无需真的 sleep 0.5-2s 就能确定性地断言多气泡延时。 bubbleGapFn func() time.Duration engageMu sync.Mutex engageGroups map[string]*engageGroupState @@ -189,20 +189,20 @@ type Bridge struct { started bool closed bool factory PlatformFactory - // channelPersona resolves an account's channel-level role from the effective - // config (design/22 §2 方案 b); adapterPersona caches the resolved role per - // connected adapter so the inbound path never re-reads channels.json. Both are - // guarded by mu. + // channelPersona 从生效配置解析账号的渠道级角色 + // (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果, + // 使入站路径永不重读 channels.json。二者 + // 都由 mu 保护。 channelPersona func(platform, accountID string) string adapterPersona map[string]string - // proactiveCursor is the rotation cursor for the proactive-push no-signal - // fallback (channelbridge/proactive.go), so the default channel is not always - // the first whitelisted owner channel. Guarded by mu. + // proactiveCursor 是主动推送「无信号」兜底时的轮转游标 + // (channelbridge/proactive.go),使默认渠道不会总是 + // 白名单中的第一个主人渠道。由 mu 保护。 proactiveCursor int - // gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are - // built lazily on the first inbound message per account and are independent of - // the adapter/connected lifecycle, so the same gate survives reconnects. + // gateMu 保护按适配器的流控闸门(design/12 §6 策略三/四)。它们 + // 按账号在首条入站消息时惰性构建,且独立于 + // 适配器/connected 生命周期,因此同一个闸门能跨重连存活。 gateMu sync.Mutex gates map[string]*adapter.FlowGate @@ -213,8 +213,8 @@ type Bridge struct { sem chan struct{} } -// New builds a Bridge from opts. It returns an error when a required dependency -// is missing so the caller fails fast rather than silently dropping messages. +// New 依据 opts 构建 Bridge。当必需依赖缺失时返回错误, +// 使调用方快速失败,而不是静默丢消息。 func New(opts Options) (*Bridge, error) { if opts.Loop == nil { return nil, ErrNilLoop @@ -226,8 +226,8 @@ func New(opts Options) (*Bridge, error) { if log == nil { log = logger.New(logger.Options{Out: nil}) } - // Quiet hours default to the same window the backthink service uses, so the two - // services never disagree about the do-not-disturb window (design/12 §6 策略五). + // 安静时段默认取 backthink 服务使用的同一窗口,这样两个 + // 服务之间永远不会对免打扰窗口产生分歧(design/12 §6 策略五)。 qStart, qEnd := opts.QuietStartHour, opts.QuietEndHour if qStart == 0 && qEnd == 0 { qStart, qEnd = defaultQuietStartHour, defaultQuietEndHour diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go index 9341bbf..8287e0f 100644 --- a/internal/services/channelbridge/channelbridge_test.go +++ b/internal/services/channelbridge/channelbridge_test.go @@ -16,8 +16,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" ) -// fakeAdapter is a minimal PlatformAdapter that records outbound sends so the -// bridge's reply path can be asserted without network I/O. +// fakeAdapter 是最小的 PlatformAdapter,记录出站发送, +// 使桥接器的回复路径无需网络 I/O 即可断言。 type fakeAdapter struct { name string connected bool @@ -58,7 +58,7 @@ func (f *fakeAdapter) SetMessageHandler(fn adapter.MessageHandler) func (f *fakeAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() } func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil } -// stubChatter records the session id and messages passed to the agent loop. +// stubChatter 记录传给 agent 循环的会话 id 与消息。 type stubChatter struct { sessionID string messages []model.Message @@ -72,7 +72,7 @@ func (s *stubChatter) Run(_ context.Context, sessionID string, msgs []model.Mess return s.res, s.err } -// stubSessions records the inbound message and returns a fixed session. +// stubSessions 记录入站消息并返回固定会话。 type stubSessions struct { msg adapter.UnifiedMessage owner *user.OwnerProfile @@ -133,13 +133,13 @@ func TestProcessRunsPipelineAndReplies(t *testing.T) { if sess.msg.SenderID != "100" { t.Errorf("resolved sender = %q, want 100", sess.msg.SenderID) } - // An owner private chat routes into the single main web session (design/16 §4.4). + // 主人私聊被路由进唯一的 Web 主会话(design/16 §4.4)。 if chat.sessionID != history.MainSessionID { t.Errorf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID) } - // Owner private on an IM platform is mirrored to the main session. The origin is - // carried as a leading system context line and the user text stays undecorated - // (design/16 §4.6.1): metadata never goes into the user's free-text channel. + // IM 平台上的主人私聊会镜像到主会话。来源由 + // 最前面的一行系统上下文承载,用户文本保持不加装饰 + // (design/16 §4.6.1):元数据绝不进入用户的自由文本通道。 var sys, userText string for _, m := range chat.messages { if m.Role == model.RoleSystem { @@ -204,8 +204,8 @@ func TestProcessGroupMessageKeepsGroupTarget(t *testing.T) { t.Parallel() b, chat, _, ad := newTestBridge(t) - // A non-owner group message that is addressed (mentioned) gets a response under - // design/12 §6 策略二, so it still exercises group reply targeting. + // 被点名(提及)的非主人群消息按 design/12 §6 策略二 + // 会得到回复,因此它仍能覆盖群回复目标选择。 msg := adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", @@ -244,8 +244,8 @@ func TestProcessEmptyReplySendsNothing(t *testing.T) { } } -// captureHistory records every Append for asserting the bridge writes owner-private -// messages into the main web session (design/16 §4.4). +// captureHistory 记录每次 Append,用于断言桥接器把主人私聊 +// 消息写入 Web 主会话(design/16 §4.4)。 type captureHistory struct { mu sync.Mutex appends []history.Message @@ -321,19 +321,19 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } - // Register the account adapter that pretends to receive the message, so the - // persisted stream carries the adapter registration name (e.g. "telegram"). + // 注册假装接收该消息的账号适配器,使持久化的 + // 消息流带上适配器注册名(例如 "telegram")。 b.Add(&fakeAdapter{name: "telegram"}) - msg := directMessage() // Platform = "telegram" + msg := directMessage() // Platform 字段 = "telegram" b.process(context.Background(), msg) recs := hist.records() if len(recs) != 2 { t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs)) } - // Both land on the main web session and carry the channel platform + adapter, - // and IM messages are always persisted in chat mode (design/16 §5.1). + // 两者都落在 Web 主会话并带渠道平台 + 适配器, + // 且 IM 消息总是以 chat 模式持久化(design/16 §5.1)。 if recs[0].SessionID != history.MainSessionID || recs[0].Role != history.RoleUser || recs[0].Content != "hello" || recs[0].Platform != "telegram" || recs[0].AdapterID != "telegram" || recs[0].Mode != "chat" { @@ -344,11 +344,11 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) { recs[1].Mode != "chat" { t.Fatalf("assistant record = %+v, want web/telegram/微信回复/telegram/chat", recs[1]) } - // Every persisted message was handed to the broadcast hook. + // 每条已持久化的消息都交给了广播钩子。 if len(persisted) != 2 { t.Fatalf("persisted notifications = %d, want 2", len(persisted)) } - // And the loop ran against the main session, not the isolated sess-1. + // 且循环针对主会话运行,而不是隔离的 sess-1。 if chat.sessionID != history.MainSessionID { t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID) } @@ -368,16 +368,16 @@ func TestOwnerPrivatePersistsToMainWithDeviceID(t *testing.T) { t.Fatalf("New: %v", err) } - // directMessage() → Platform telegram / ChannelID 100 / SenderID 100, so the - // deviceID is built as :: (design/16 §4.5). + // directMessage() → Platform telegram / ChannelID 100 / SenderID 100,因此 + // deviceID 构建为 ::(design/16 §4.5)。 b.process(context.Background(), directMessage()) recs := hist.records() if len(recs) != 2 { t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs)) } - // Both the user turn and the assistant reply carry the derived deviceID and the - // channel platform, so the Web client can label the source end. + // 用户轮次与助手回复都带上推导出的 deviceID 与 + // 渠道平台,使 Web 客户端能标注来源端。 for i, rec := range recs { if rec.DeviceID != "telegram:100:100" { t.Fatalf("record[%d] DeviceID = %q, want telegram:100:100", i, rec.DeviceID) @@ -409,8 +409,8 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) { t.Fatalf("New: %v", err) } - // Addressed (mentioned) group message → responds (design/12 §6 策略二), still - // isolated to the group session. + // 被点名(提及)的群消息 → 回复(design/12 §6 策略二),仍 + // 隔离在群会话内。 msg := adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20", GroupName: "Gang", SenderID: "9", @@ -419,10 +419,10 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) { } b.process(context.Background(), msg) - // design/16 §4.6: a group/stranger conversation stays isolated — it is NOT - // persisted into the main web session, and the loop runs against the isolated - // session id. Messages land only in the isolated session's own platform log - // (the adapter log page), so nothing reaches the Web main session. + // design/16 §4.6:群聊/陌生人会话保持隔离——它不会 + // 持久化进 Web 主会话,循环也针对隔离的 + // 会话 id 运行。消息只落在隔离会话自有的平台日志 + // (适配器日志页),因此没有任何内容到达 Web 主会话。 recs := hist.records() if len(recs) != 2 { t.Fatalf("group appends = %d, want 2 (isolated user + assistant)", len(recs)) @@ -434,7 +434,7 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) { if rec.Platform != "telegram" { t.Fatalf("record[%d] Platform = %q, want telegram", i, rec.Platform) } - // Isolated IM messages are still chat-mode (design/16 §5.1). + // 隔离的 IM 消息仍为 chat 模式(design/16 §5.1)。 if rec.Mode != "chat" { t.Fatalf("record[%d] Mode = %q, want chat", i, rec.Mode) } @@ -442,10 +442,10 @@ func TestGroupMessageNotPersistedToMainSession(t *testing.T) { if chat.sessionID != "sess-g" { t.Fatalf("group loop session id = %q, want sess-g", chat.sessionID) } - // Each isolated write is ALSO handed to the broadcast hook so the gateway can - // emit `channel_persisted` for the IM adapter log page — but the message itself - // stays strictly in the isolated session and never leaks to the main/`web` - // session (design/16 §4.6 §3). + // 每次隔离写入也会交给广播钩子,使 gateway 能为 IM + // 适配器日志页发出 `channel_persisted`——但消息本身 + // 严格留在隔离会话中,绝不泄露到主/`web` + // 会话(design/16 §4.6 §3)。 if len(persisted) != 2 { t.Fatalf("isolated persisted notifications = %d, want 2", len(persisted)) } @@ -463,7 +463,7 @@ func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "不该回复"}} - // A stranger's group session that would otherwise be processed. + // 一个陌生人的群会话(若不拦截本应被处理)。 sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} ad := &fakeAdapter{name: "telegram", cfg: map[string]any{ "bot_token": "configured", @@ -487,8 +487,8 @@ func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) { } b.process(context.Background(), msg) - // The blacklisted group message never reaches the agent loop and never sends a - // reply; the rejection reason is surfaced for the audit trail. + // 被拉黑的群消息绝不进入 agent 循环,也绝不发送 + // 回复;拒绝原因暴露给审计轨迹。 if chat.sessionID != "" || len(chat.messages) != 0 { t.Fatalf("loop ran for blacklisted message: session=%q messages=%+v", chat.sessionID, chat.messages) } @@ -522,8 +522,8 @@ func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) { } b.process(context.Background(), msg) - // A direct message from a user NOT in the allow whitelist is dropped; the owner - // is never affected (this is a stranger). + // 来自不在允许白名单中的用户的私聊消息被丢弃;主人 + // 绝不受影响(这里是陌生人)。 if chat.sessionID != "" || len(chat.messages) != 0 { t.Fatalf("loop ran for non-whitelisted user: session=%q messages=%+v", chat.sessionID, chat.messages) } @@ -543,8 +543,8 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) { t.Fatalf("New: %v", err) } - // Addressed (mentioned) group message → responds (design/12 §6 策略二), so the - // third-party boundary and the [第三方 · 张三] prefix are injected. + // 被点名(提及)的群消息 → 回复(design/12 §6 策略二),因此 + // 会注入第三方边界与 [第三方 · 张三] 前缀。 msg := adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三", @@ -553,9 +553,9 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) { } b.process(context.Background(), msg) - // The loop sees a leading third-party boundary system message plus the user - // message already prefixed with [第三方 · 张三] (the raw content is preserved in - // history, so no source data is lost). + // 循环看到最前面有一条第三方边界系统消息,加上已带 + // [第三方 · 张三] 前缀的用户消息(历史中保留原始 + // 内容,因此不丢失来源数据)。 if len(chat.messages) != 2 { t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages)) } @@ -563,13 +563,13 @@ func TestGroupMessageThirdPartySenderNameAndBoundary(t *testing.T) { t.Fatalf("expected third-party boundary system message, got %+v", chat.messages[0]) } user := chat.messages[1] - // The third-party speaker is a structured SenderName field and the user text stays - // undecorated (design/16 §4.6.1); the origin/boundary lives in the system message. + // 第三方说话者由结构化的 SenderName 字段承载,用户文本保持 + // 不加装饰(design/16 §4.6.1);来源/边界放在系统消息里。 if user.SenderName != "张三" || user.Content[0].Text != "hello all" { t.Fatalf("loop user message = %+v, want SenderName 张三 with undecorated text", user) } - // History keeps the RAW content and carries the sender name so the adapter log - // page can label the stream without altering the source message. + // 历史保留原始内容并携带发送者名,使适配器日志 + // 页能标注消息流而不改动来源消息。 recs := hist.records() if len(recs) != 2 || recs[0].Content != "hello all" || recs[0].SenderName != "张三" { t.Fatalf("isolated records = %+v, want raw content with SenderName 张三", recs) @@ -580,8 +580,8 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) { t.Parallel() chat := &stubChatter{res: &loop.Result{Text: "收到"}} - // Owner speaking inside a group: the session stays restricted (mem read-only), - // but the owner is never labeled as a third party (design/16 §4.6 §1-2). + // 主人在群里发言:会话仍是受限的(记忆只读), + // 但主人绝不会被标为第三方(design/16 §4.6 §1-2)。 sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}} hist := &captureHistory{} b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "owner_uid"), History: hist}) @@ -597,13 +597,13 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) { } b.process(context.Background(), msg) - // Restricted context → a third-party boundary system message is still injected. + // 受限上下文 → 仍会注入第三方边界系统消息。 if len(chat.messages) != 2 || chat.messages[0].Role != model.RoleSystem { t.Fatalf("loop messages = %d, want 2 (boundary + user)", len(chat.messages)) } - // Owner's message is NOT labeled as third-party and carries no SenderName; the user - // text stays undecorated and the group origin lives in the system message - // (design/16 §4.6.1). + // 主人的消息不会被标为第三方,也不携带 SenderName;用户 + // 文本保持不加装饰,群的来源放在系统消息里 + // (design/16 §4.6.1)。 user := chat.messages[1] if user.SenderName != "" || user.Content[0].Text != "我在群里" { t.Fatalf("owner group message = %+v, want undecorated text, no SenderName, no [第三方] prefix", user) @@ -611,7 +611,7 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) { if !strings.Contains(chat.messages[0].Content[0].Text, "群#-20") { t.Fatalf("origin/boundary system = %q, want group#-20", chat.messages[0].Content[0].Text) } - // Owner group speech still lands in the isolated session (not the main web session). + // 主人在群里的发言仍落在隔离会话(不是 Web 主会话)。 recs := hist.records() if len(recs) != 2 || recs[0].SessionID != "sess-g" { t.Fatalf("owner group records = %+v, want isolated sess-g, not web", recs) @@ -620,7 +620,7 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) { func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) { t.Parallel() - // A bridge built without a history store persists nothing but still runs/replies. + // 未装配历史存储的桥接器不持久化任何内容,但仍运行/回复。 b, chat, _, ad := newTestBridge(t) b.process(context.Background(), directMessage()) if len(ad.sends) != 1 { @@ -631,8 +631,8 @@ func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) { } } -// newWebhookBridge builds a bare bridge (no adapters) wired with the stubs the -// other tests use, so a WebhookHandlers test can add adapters explicitly. +// newWebhookBridge 构建一个裸桥接器(无适配器),接上其他测试 +// 使用的桩,便于 WebhookHandlers 测试自行添加适配器。 func newWebhookBridge(t *testing.T) *Bridge { t.Helper() chat := &stubChatter{res: &loop.Result{Text: "hi"}} diff --git a/internal/services/channelbridge/connect.go b/internal/services/channelbridge/connect.go index e4a3cd6..8f168da 100644 --- a/internal/services/channelbridge/connect.go +++ b/internal/services/channelbridge/connect.go @@ -7,13 +7,13 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// This file holds the adapter registry and the connect lifecycle of the channel -// bridge (design/08 §0): adapters are registered through Add, dialed by Start -// (each within connectTimeout), torn down by Stop, and skipped by the credential -// gate when they carry no usable configuration. +// 本文件承载渠道桥接器的适配器注册表与连接生命周期 +// (design/08 §0):适配器经 Add 注册、由 Start 拨号 +// (各自受 connectTimeout 约束)、由 Stop 拆除,并在不带 +// 可用配置时被凭据闸门跳过。 -// Add registers an adapter by its Name. It is safe to call before Start; adding -// after Start has no effect on already-running adapters. +// Add 按适配器的 Name 注册它。在 Start 之前调用是安全的; +// Start 之后再添加不影响已在运行的适配器。 func (b *Bridge) Add(a adapter.PlatformAdapter) { if a == nil { return @@ -23,11 +23,11 @@ func (b *Bridge) Add(a adapter.PlatformAdapter) { b.adapters[a.Name()] = a } -// WebhookHandlers returns the inbound HTTP handlers of every registered adapter -// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint -// path from the adapter's config. The gateway mounts these to receive external -// webhook callbacks into the runtime. A nil map is returned when no adapter -// provides an inbound handler. +// WebhookHandlers 返回每个暴露 HTTP 回调的已注册适配器 +// (如 *webhook.Adapter)的入站 HTTP 处理器,键为适配器配置中的 +// 端点路径。gateway 挂载它们,把外部 webhook 回调 +// 接入运行时。当没有任何适配器提供入站处理器时 +// 返回 nil map。 func (b *Bridge) WebhookHandlers() map[string]http.Handler { b.mu.RLock() defer b.mu.RUnlock() @@ -39,8 +39,8 @@ func (b *Bridge) WebhookHandlers() map[string]http.Handler { } path := wh.WebhookPath() if path == "" { - // Adapter is not currently exposing a webhook (e.g. a websocket-mode - // KOOK); skipping avoids mounting a handler at a stale or colliding route. + // 适配器当前未暴露 webhook(例如 websocket 模式的 + // KOOK);跳过它可避免把处理器挂在过期或冲突的路由上。 continue } h := wh.InboundHandler() @@ -55,11 +55,11 @@ func (b *Bridge) WebhookHandlers() map[string]http.Handler { return out } -// Start connects every configured adapter, installs the inbound handler, and -// launches the read loops. Adapters without credentials (or whose Connect fails) -// are logged and left disconnected; they never fail startup. Start blocks only -// long enough to connect (bounded by connectTimeout per adapter) and returns. It -// is idempotent: a second call is a no-op. +// Start 连接每个已配置的适配器、装配入站处理器并 +// 启动读取循环。没有凭据(或 Connect 失败)的适配器 +// 只记日志并保持未连接;它们绝不会导致启动失败。Start 只 +// 阻塞到连接完成(每个适配器受 connectTimeout 约束)便返回。它 +// 是幂等的:第二次调用无副作用。 func (b *Bridge) Start() { b.mu.Lock() if b.closed || b.started { @@ -77,8 +77,8 @@ func (b *Bridge) Start() { "registered", len(b.snapshot())) } -// Stop cancels the read loops, disconnects every adapter and waits for in-flight -// message processing to finish. It is idempotent. +// Stop 取消读取循环、断开所有适配器,并等待 +// 处理中的消息完成。它是幂等的。 func (b *Bridge) Stop(ctx context.Context) { b.mu.Lock() if b.closed { @@ -99,14 +99,14 @@ func (b *Bridge) Stop(ctx context.Context) { b.log.Info("channel bridge stopped") } -// IsConnected reports whether the named adapter is currently connected. +// IsConnected 报告指定名称的适配器当前是否已连接。 func (b *Bridge) IsConnected(name string) bool { b.mu.RLock() defer b.mu.RUnlock() return b.connected[name] } -// ConnectedCount reports how many adapters are currently connected. +// ConnectedCount 报告当前已连接的适配器数量。 func (b *Bridge) ConnectedCount() int { b.mu.RLock() defer b.mu.RUnlock() @@ -119,7 +119,7 @@ func (b *Bridge) ConnectedCount() int { return n } -// snapshot returns a copy of the registered adapters for a lock-free iteration. +// snapshot 返回已注册适配器的副本,以便无锁遍历。 func (b *Bridge) snapshot() []adapter.PlatformAdapter { b.mu.RLock() defer b.mu.RUnlock() @@ -130,11 +130,11 @@ func (b *Bridge) snapshot() []adapter.PlatformAdapter { return out } -// configured reports whether the adapter carries enough configuration to attempt -// a Connect. It inspects the adapter's own Config() snapshot for the credential -// each platform needs, so a bare (empty) adapter is skipped rather than dialed. -// This is what keeps weixin-ilink from falling into its blocking QR-login flow -// when no bot token is present. +// configured 报告适配器是否带有足够配置可供尝试 +// Connect。它检查适配器自身的 Config() 快照来看各平台所需的 +// 凭据,因此空壳(无配置)适配器会被跳过而不是拨号。 +// 正是这一点避免了 weixin-ilink 在缺少 bot token 时 +// 落入会阻塞的扫码登录流程。 func (b *Bridge) configured(a adapter.PlatformAdapter) bool { cfg := a.Config() switch basePlatform(a.Name()) { @@ -146,15 +146,15 @@ func (b *Bridge) configured(a adapter.PlatformAdapter) bool { } return cfgStr(cfg, "listenAddr") != "" case "webhook": - // Inbound-only: no external credential is required to hold the session. + // 仅入站:维持会话不需要外部凭据。 return true default: return true } } -// cfgStr reads a string field from an adapter config map, returning "" when -// absent. +// cfgStr 从适配器配置 map 中读取字符串字段,缺失时 +// 返回 ""。 func cfgStr(m map[string]any, key string) string { if m == nil { return "" @@ -165,7 +165,7 @@ func cfgStr(m map[string]any, key string) string { return "" } -// adapter returns the registered adapter for a platform name, or nil. +// adapter 返回平台名对应的已注册适配器,没有则返回 nil。 func (b *Bridge) adapter(platform string) adapter.PlatformAdapter { b.mu.RLock() defer b.mu.RUnlock() diff --git a/internal/services/channelbridge/engage.go b/internal/services/channelbridge/engage.go index b0296b9..4be3f0d 100644 --- a/internal/services/channelbridge/engage.go +++ b/internal/services/channelbridge/engage.go @@ -12,48 +12,48 @@ import ( "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). +// 本文件承载公开平台群聊/陌生人会话的参与度引擎 +// (DevDocs/design/18):按会话的状态机 +// (IDLE→PROBING→ACTIVE→COOLDOWN)、受成本控制的触发闸门(角色名提及 +// / 概率)、带跟进复审的活跃窗口,以及群内出现负面 +// 态度时的冷避。它按进程存于内存(重启即重置), +// 且只在真正触发时才咨询轻量 LLM 判定器,因此不会仅为「要不要 +// 打扰某人」就把聊天消息发给 LLM(design/18 红线)。 -// 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. +// EngageConfig 承载 design/18 的按会话参与度调参。P2c 会让它 +// 可按群配置 / 在面板编辑;目前由 DefaultEngageConfig 提供 +// 常量,组合根中的 EngageFor 接缝保持 nil。 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 是按会话开关(design/18 §6 engageEnabled)。为 false 时 + // 只有角色名提醒词触发仍会探测;概率路径关闭。 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 + Probability float64 // 触发③ 概率,默认 0.10 + ActivityWindow time.Duration // ACTIVE 窗口,默认 15m + ActivityMessages int // ACTIVE 窗口按消息条数计,默认 20 + Cooldown time.Duration // COOLDOWN 时长,默认 30m + ContextMessages int // 探测时喂给判定器的近期消息条数 } -// Knob defaults (design/18 §6). Compile-time so no bare magic number appears in the -// run path (project rule §10). +// 参数默认值(design/18 §6)。编译期常量,运行路径上就不会出现 +// 裸魔法数字(项目规范 §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 是连续多少次跟进探测都返回「无 + // 兴趣」/回避后,她从 ACTIVE 退回 IDLE(design/18 §4)。负面 + // 态度则由下面的规则词表把她按进 COOLDOWN。 consecutiveNoInterestThreshold = 2 ) -// errNoEngageJudge is returned when the participation engine is configured but no -// LightJudge was wired, so it fails closed to silence. +// errNoEngageJudge 表示参与度引擎已配置但未装配 LightJudge, +// 因此它失败关闭为静默。 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). +// DefaultEngageConfig 返回 design/18 的参数默认值(P2a 基准;P2c 会让它们 +// 可按群配置/在面板编辑)。 func DefaultEngageConfig() EngageConfig { return EngageConfig{ Enabled: true, @@ -65,18 +65,18 @@ func DefaultEngageConfig() EngageConfig { } } -// engageState is one phase of the per-conversation state machine (design/18 §1). +// engageState 是按会话状态机的一个阶段(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 + engagePROBING // 瞬态:某次触发刚把该消息送去判定 + engageACTIVE // 她正在参与;跟进复审已开启 + engageCOOLDOWN // 她被要求安静;计时结束后自动恢复 ) -// 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). +// engageGroupState 是一个参与度引擎实例,以群聊/陌生人 +// 会话为键(design/18 §1)。它仅存于内存(进程重启即重置)。 type engageGroupState struct { mu sync.Mutex state engageState @@ -88,10 +88,10 @@ type engageGroupState struct { 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. +// engageConfigured 报告该桥接器是否装配了 design/18 参与度引擎。 +// 角色带触发词,或参与度参数启用引擎时为 true; +// 否则桥接器沿用引入参与度引擎之前的旧轻量判定 +// (design/12 §6),不做改动。 func (b *Bridge) engageConfigured() bool { return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0 } @@ -107,8 +107,8 @@ func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string { 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). +// engageState 返回会话键对应的状态,首次使用时创建 IDLE +// 实例(内存态,可安全并发使用)。 func (b *Bridge) engageState(key string) *engageGroupState { b.engageMu.Lock() defer b.engageMu.Unlock() @@ -120,9 +120,9 @@ func (b *Bridge) engageState(key string) *engageGroupState { 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. +// engageConfigFor 解析一条消息实际生效的参与度配置:装配了按群 +// 覆盖(EngageFor,P2a 中为 nil)时用它,否则用基准配置, +// 并把所有零值字段归一化为默认值。 func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor) EngageConfig { cfg := b.engage if b.engageFor != nil { @@ -134,8 +134,8 @@ func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor 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 } @@ -154,12 +154,12 @@ func (b *Bridge) engageConfigFor(msg adapter.UnifiedMessage, actor adapter.Actor 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. +// engageDecision 是对(廉价规则无法判定)非主人消息的 design/18 +// 参与度决策。仅在 engageConfigured() 为 true 时才会走到。 +// 它返回该消息是否应驱动完整 agent 循环并回复,以及 +// 供日志用的简短原因。调用方(shouldRespond)不持锁; +// 这里获取按键的状态互斥锁,使同一会话的并发消息串行化, +// 不同会话之间保持独立。 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) @@ -168,10 +168,10 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, 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. + // design/18 §5 冷避优先:群成员表达负面态度是 + // 明确的「安静」信号 → COOLDOWN,即使正处在参与中。直接 @ / 高价值 + // 关键词已在上方返回,因此只有真正「内容值得回复」 + // 的消息(或主人消息,它根本不会走到这里)才能覆盖它。 if b.negativeAttitude(msg.Content) { b.enterCooldown(st, cfg, now) b.log.Debug("engage cooldown (negative attitude)", @@ -185,8 +185,8 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, 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). + // ACTIVE 跟进:活跃窗口未过期间,她持续轻量判断 + // 群里延续的讨论,并倾向于保持参与(design/18 §4)。 if st.state == engageACTIVE { if st.activityExpired(now, cfg) { b.toIdle(st) @@ -196,9 +196,9 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, } } - // 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). + // IDLE:只有角色名提及或概率命中才会把该消息送入受成本控制的 + // PROBING 判定(design/18 §2 触发②/③);否则它只被 + // 登记并静默,不调用 LLM(一条红线)。 if !b.mentionsTriggerWord(msg.Content) && !b.engageProbabilityHit(cfg) { b.log.Debug("engage no trigger, registered only", "group", key) return false, "engage: no trigger" @@ -206,9 +206,9 @@ func (b *Bridge) engageDecision(ctx context.Context, msg adapter.UnifiedMessage, 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. +// engageProbe 是一次受成本控制的 PROBING 判定(design/18 §3):把 +// 群上下文 + 角色提示词喂给轻量判定器,并决定 ACTIVE(回复)还是 +// IDLE(仅登记、静默)。解析/LLM 失败时失败关闭为静默。 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 @@ -233,10 +233,10 @@ func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, ac 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. +// engageFollowUp 是 ACTIVE 窗口的跟进复审(design/18 §4):她仍 +// 轻量判断讨论,但倾向于继续参与。连续无兴趣或 +// 窗口结束会使她退出 ACTIVE;负面态度早已 +// 被前面的冷避规则捕获。 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() @@ -258,7 +258,7 @@ func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage, return true, "engage follow-up: " + d.Reason } -// engageAfterFollowUp resolves the outcome of a no-interest/avoid follow-up probe. +// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。 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) @@ -268,14 +268,14 @@ func (b *Bridge) engageAfterFollowUp(st *engageGroupState, cfg EngageConfig, now return false, "engage: not responding" } -// countNoInterest bumps the consecutive no-interest counter for a follow-up probe. +// countNoInterest 为一次跟进探测递增连续无兴趣计数。 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. +// judgeEngage 为一次受成本控制的参与度判定渲染 LightInput,并 +// 咨询注入的 LightJudge。只有在这里才喂入群的近期上下文与 +// 角色提示词——绝不在未触发的消息上喂。 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 @@ -290,10 +290,10 @@ func (b *Bridge) judgeEngage(ctx context.Context, msg adapter.UnifiedMessage, ac }) } -// 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. +// buildEngageContext 为参与度判定器渲染该会话最近的 N 条消息(带发送者 +// 名,最旧在前)。未装配历史存储或存储尚无数据时为空操作 +// (返回空字符串),判定器因此退化为只读 +// 那一条触发消息。 func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, cfg EngageConfig) string { if b.history == nil || cfg.ContextMessages <= 0 { return "" @@ -320,10 +320,10 @@ func (b *Bridge) buildEngageContext(ctx context.Context, sess session.Session, c 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). +// engageProbabilityHit 报告该消息是否独立命中与角色名提醒词无关的 +// 概率触发(design/18 §2 触发③)。它读取可注入的 +// EngagRand 源,使测试具备确定性;随机源为 nil 时概率路径 +// 关闭(失败关闭)。 func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool { if !cfg.Enabled || cfg.Probability <= 0 || b.engageRand == nil { return false @@ -331,9 +331,9 @@ func (b *Bridge) engageProbabilityHit(cfg EngageConfig) bool { 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. +// negativeAttitude 报告群是否在让她安静(design/18 §5 +// 冷避)。这是一条廉价子串规则,兜住 LLM 可能漏掉的信号 +// ——更安静是安全的方向(不打扰为第一原则)。绝不用于身份判定。 func (b *Bridge) negativeAttitude(text string) bool { if text == "" { return false @@ -347,15 +347,15 @@ func (b *Bridge) negativeAttitude(text string) bool { 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. +// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是 +// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。 var negativeAttitudeKeywords = []string{ "太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听", "安静点", "少说两句", "别说了", "别bb", "别逼逼", "烦死了", "好烦", "打住", "住嘴", "shut up", "too loud", "stop spamming", } -// contentSnippet shortens a message to a bounded topic token for the state machine. +// contentSnippet 把消息截短为状态机所用的有界话题标记。 func contentSnippet(text string) string { runes := []rune(strings.TrimSpace(text)) if len(runes) <= 40 { @@ -372,7 +372,7 @@ func (st *engageGroupState) activityExpired(now time.Time, cfg EngageConfig) boo return now.After(st.activeUntil) || st.msgsInWindow >= cfg.ActivityMessages } -// toIdle resets the conversation back to IDLE (design/18: exit ACTIVE → IDLE). +// toIdle 把会话重置回 IDLE(design/18:退出 ACTIVE → IDLE)。 func (b *Bridge) toIdle(st *engageGroupState) { st.state = engageIDLE st.msgsInWindow = 0 @@ -380,7 +380,7 @@ func (b *Bridge) toIdle(st *engageGroupState) { st.lastTopic = "" } -// enterActive opens the activity window (design/18 §4). The caller holds st.mu. +// enterActive 打开活跃窗口(design/18 §4)。调用方须持有 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) @@ -390,8 +390,8 @@ func (b *Bridge) enterActive(st *engageGroupState, cfg EngageConfig, now time.Ti st.lastTopic = contentSnippet(msg.Content) } -// enterCooldown parks the conversation in a quiet period (design/18 §5). The caller -// holds st.mu. +// enterCooldown 把会话按进安静期(design/18 §5)。调用方 +// 须持有 st.mu。 func (b *Bridge) enterCooldown(st *engageGroupState, cfg EngageConfig, now time.Time) { st.state = engageCOOLDOWN st.cooldownUntil = now.Add(cfg.Cooldown) diff --git a/internal/services/channelbridge/engage_test.go b/internal/services/channelbridge/engage_test.go index df8fe39..16c7986 100644 --- a/internal/services/channelbridge/engage_test.go +++ b/internal/services/channelbridge/engage_test.go @@ -11,9 +11,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) -// engageScriptJudge is a configurable LightJudge for the participation-engine tests: -// it replays an ordered list of decisions (and optional per-call errors) and records -// how often it was consulted plus the last input it saw. +// engageScriptJudge 是参与度引擎测试用的可配置 LightJudge: +// 它按顺序回放一组裁决(以及可选的逐次调用错误),并记录 +// 被咨询的次数以及最近一次看到的输入。 type engageScriptJudge struct { responses []LightDecision errs []error @@ -34,15 +34,15 @@ func (s *engageScriptJudge) Judge(_ context.Context, in LightInput) (LightDecisi return LightDecision{Interesting: true, WorthReply: true, Reason: "默认参与"}, nil } -// dayNow returns a daytime (non-quiet) wall-clock source so the engine runs outside -// the quiet window. +// dayNow 返回白天(非安静)的墙上时钟源,使引擎运行在 +// 安静窗口之外。 func dayNow() func() time.Time { return func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) } } -// engageTestBridge builds a Bridge wired for the participation engine with the given -// judge/config/clock/rand source. A zero-valued config is replaced by the defaults so -// callers can override just the knob they care about. +// engageTestBridge 构建一个已接好参与度引擎的 Bridge,使用给定的 +// 判定器/配置/时钟/随机源。零值配置会被替换为默认值, +// 使调用方只需覆盖自己关心的参数。 func engageTestBridge(t *testing.T, judge LightJudge, cfg EngageConfig, clock func() time.Time, rng func() float64) (*Bridge, *stubChatter, *captureHistory) { t.Helper() chat := &stubChatter{res: &loop.Result{Text: "参与"}} @@ -52,7 +52,7 @@ func engageTestBridge(t *testing.T, judge LightJudge, cfg EngageConfig, clock fu cfg = DefaultEngageConfig() } if rng == nil { - rng = func() float64 { return 0.5 } // default: never hit the probability trigger + rng = func() float64 { return 0.5 } // 默认:永不命中概率触发 } b, err := New(Options{ Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist, @@ -65,8 +65,8 @@ func engageTestBridge(t *testing.T, judge LightJudge, cfg EngageConfig, clock fu return b, chat, hist } -// cfgShortWindow is a config with a 1h cooldown/activity and a small 2-message -// activity window, so count-based window expiry is easy to drive deterministically. +// cfgShortWindow 是冷却/活跃时长 1 小时、活跃窗口仅 2 条消息 +// 的配置,便于确定性地驱动按条数计的窗口过期。 func cfgShortWindow() EngageConfig { cfg := DefaultEngageConfig() cfg.ActivityWindow = time.Hour @@ -81,7 +81,7 @@ func TestEngageRoleNameMentionProbesAndResponds(t *testing.T) { judge := &engageScriptJudge{} b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil) - // 触发②: naming 昔涟 without an @ routes the message into a cost-controlled PROBING. + // 触发②:没有 @ 但提到「昔涟」会把该消息送入受成本控制的 PROBING。 b.process(context.Background(), nonOwnerGroupMsg("大家在聊昔涟的事")) if judge.calls != 1 { @@ -109,7 +109,7 @@ func TestEngageRoleNameMentionNotInterestedSilences(t *testing.T) { if chat.sessionID != "" { t.Fatalf("loop ran after no-interest, session = %q", chat.sessionID) } - // Not interested → registered but not answered. + // 不感兴趣 → 只登记、不回复。 if len(hist.records()) != 1 || hist.records()[0].Content != "提一句昔涟而已" { t.Fatalf("records = %+v, want 1 registered user message", hist.records()) } @@ -134,8 +134,8 @@ func TestEngageAvoidTopicStaysIdle(t *testing.T) { func TestEngageNoTriggerNoProbabilitySkipsJudge(t *testing.T) { t.Parallel() - // Cost-control red line: an untriggered, probability-missing message must NOT call - // the light judge at all — no context is fed, no LLM turn, just registered silent. + // 成本控制红线:未触发、也未命中概率的消息绝不调用 + // 轻量判定器——不喂上下文、不走 LLM 轮次,只登记并静默。 judge := &engageScriptJudge{} b, chat, hist := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil) @@ -155,8 +155,8 @@ func TestEngageNoTriggerNoProbabilitySkipsJudge(t *testing.T) { func TestEngageProbabilityTriggerResponds(t *testing.T) { t.Parallel() - // 触发③: an injected rand below the probability (0.05 < 0.10) routes the message - // into PROBING even with no role-name mention. + // 触发③:注入的随机值低于概率阈值(0.05 < 0.10),即使没有 + // 角色名提及也会把消息送入 PROBING。 judge := &engageScriptJudge{} b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), func() float64 { return 0.05 }) @@ -190,25 +190,25 @@ func TestEngageStateMachineActivityWindowExpiry(t *testing.T) { t.Parallel() judge := &engageScriptJudge{responses: []LightDecision{ - {Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE - {WorthReply: true, Reason: "跟随"}, // follow-up 1 → still reply + {Interesting: true, WorthReply: true, Reason: "感兴趣"}, // 探测 → ACTIVE + {WorthReply: true, Reason: "跟随"}, // 跟进 1 → 仍然回复 }} b, chat, _ := engageTestBridge(t, judge, cfgShortWindow(), dayNow(), nil) - // msg1: role-name mention → probe → interested → ACTIVE (respond). + // msg1:角色名提及 → 探测 → 感兴趣 → ACTIVE(回复)。 b.process(context.Background(), nonOwnerGroupMsg("聊昔涟")) if chat.sessionID != "sess-g" || judge.calls != 1 { t.Fatalf("after probe: session=%q calls=%d, want sess-g/1", chat.sessionID, judge.calls) } - // msg2: inside the window → follow-up review, still replies. + // msg2:仍在窗口内 → 跟进复审,仍回复。 b.process(context.Background(), nonOwnerGroupMsg("周末去哪玩")) if chat.sessionID != "sess-g" || judge.calls != 2 { t.Fatalf("after follow-up: session=%q calls=%d, want sess-g/2", chat.sessionID, judge.calls) } - // msg3: the 2-message window is now full → exit ACTIVE to IDLE. No trigger/probability - // (rand 0.5) → silent, and crucially the judge is NOT consulted a third time. + // msg3:2 条消息的窗口已满 → 退出 ACTIVE 回到 IDLE。无触发/无概率 + // (随机值 0.5)→ 静默,且关键是判定器不会第三次被咨询。 b.process(context.Background(), nonOwnerGroupMsg("接着聊")) if judge.calls != 2 { t.Fatalf("judge calls = %d, want 2 (window expired; no follow-up, no re-trigger)", judge.calls) @@ -223,9 +223,9 @@ func TestEngageStateMachineFollowUpNoInterestExits(t *testing.T) { cfg.ActivityMessages = 100 cfg.Cooldown = time.Hour judge := &engageScriptJudge{responses: []LightDecision{ - {Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE - {Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 1 → no interest - {Interesting: false, WorthReply: false, Reason: "无感"}, // follow-up 2 → threshold → exit + {Interesting: true, WorthReply: true, Reason: "感兴趣"}, // 探测 → ACTIVE + {Interesting: false, WorthReply: false, Reason: "无感"}, // 跟进 1 → 无兴趣 + {Interesting: false, WorthReply: false, Reason: "无感"}, // 跟进 2 → 达到阈值 → 退出 }} b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil) @@ -233,8 +233,8 @@ func TestEngageStateMachineFollowUpNoInterestExits(t *testing.T) { if judge.calls != 1 || chat.sessionID != "sess-g" { t.Fatalf("probe: calls=%d session=%q, want 1/sess-g", judge.calls, chat.sessionID) } - // Two consecutive no-interest follow-ups, then a subsequent untriggered message - // must no longer call the judge (she has dropped out of ACTIVE). + // 连续两次无兴趣跟进之后,随后一条未触发的消息 + // 绝不再调用判定器(她已退出 ACTIVE)。 b.process(context.Background(), nonOwnerGroupMsg("说说别的")) b.process(context.Background(), nonOwnerGroupMsg("再说一点")) if judge.calls != 3 { @@ -251,10 +251,10 @@ func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) { now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) clock := func() time.Time { return now } - judge := &engageScriptJudge{} // default: interested (would reply if consulted) + judge := &engageScriptJudge{} // 默认:感兴趣(若被咨询会回复) b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), clock, nil) - // 冷避: "别刷屏" is a negative attitude → COOLDOWN, and she is silent (registered only). + // 冷避:「别刷屏」是负面态度 → COOLDOWN,她保持静默(只登记)。 b.process(context.Background(), nonOwnerGroupMsg("昔涟别刷屏了")) if judge.calls != 0 { t.Fatalf("judge calls = %d, want 0 (negative attitude → cooldown, no LLM)", judge.calls) @@ -263,20 +263,20 @@ func TestEngageColdAvoidCooldownSuppressesThenExpires(t *testing.T) { t.Fatalf("loop ran after negative attitude, session = %q", chat.sessionID) } - // During cooldown, even a role-name mention is NOT answered (no proactive reply). + // 冷却期间,即使是角色名提及也不回复(不做主动回复)。 b.process(context.Background(), nonOwnerGroupMsg("那聊聊昔涟")) if judge.calls != 0 || chat.sessionID != "" { t.Fatalf("during cooldown: calls=%d session=%q, want 0/\"\" (suppressed)", judge.calls, chat.sessionID) } - // A direct @ still breaks cooldown (design/18 §5: 除非 @ 她). + // 直接 @ 仍会打断冷却(design/18 §5:除非 @ 她)。 chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"})) if chat.sessionID != "sess-g" { t.Fatalf("mention during cooldown: session=%q, want sess-g (@ always replies)", chat.sessionID) } - // After the cooldown timer elapses, a role-name mention re-engages (PROBING → ACTIVE). + // 冷却计时结束后,角色名提及会重新触发参与(PROBING → ACTIVE)。 now = now.Add(2 * time.Hour) chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("再提昔涟")) @@ -295,7 +295,7 @@ func TestEngageColdAvoidFollowUpDuringActive(t *testing.T) { cfg.ActivityWindow = time.Hour cfg.ActivityMessages = 100 judge := &engageScriptJudge{responses: []LightDecision{ - {Interesting: true, WorthReply: true, Reason: "感兴趣"}, // probe → ACTIVE + {Interesting: true, WorthReply: true, Reason: "感兴趣"}, // 探测 → ACTIVE }} b, chat, _ := engageTestBridge(t, judge, cfg, dayNow(), nil) @@ -304,7 +304,7 @@ func TestEngageColdAvoidFollowUpDuringActive(t *testing.T) { t.Fatalf("probe: session=%q, want sess-g", chat.sessionID) } - // A negative attitude during ACTIVE parks her in COOLDOWN immediately (design/18 §5). + // ACTIVE 期间出现负面态度会立刻把她按进 COOLDOWN(design/18 §5)。 b.process(context.Background(), nonOwnerGroupMsg("别说了太吵")) if judge.calls != 1 { t.Fatalf("judge calls = %d, want 1 (negative attitude must not consult the judge)", judge.calls) @@ -350,8 +350,8 @@ func TestEngageJudgeReceivesContextAndPersonaPrompt(t *testing.T) { } } -// contextHistory is a captureHistory that returns a fixed recent window, so the -// engagement context renderer can be asserted. +// contextHistory 是返回固定近期窗口的 captureHistory,便于 +// 断言参与度上下文渲染器。 type contextHistory struct { captureHistory recent []history.Message diff --git a/internal/services/channelbridge/engagejudge.go b/internal/services/channelbridge/engagejudge.go index 33f62b7..fe55c20 100644 --- a/internal/services/channelbridge/engagejudge.go +++ b/internal/services/channelbridge/engagejudge.go @@ -11,25 +11,25 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file holds the production LightJudge used by the participation engine -// (design/18 §3). It is deliberately separate from flowgate's Reviewer: the Reviewer -// prunes queued messages from a stack; this judge decides, once, at a cost-controlled -// entrance gate, whether a triggered group/stranger message is worth participating in. -// It only ever decides 值不值得回 — never who the sender is (a project red line). +// 本文件承载参与度引擎使用的生产版 LightJudge +// (design/18 §3)。它刻意与 flowgate 的 Reviewer 分开:Reviewer +// 从堆栈中裁剪队列消息;本判定器则在受成本控制的 +// 入口闸门处一次性判断被触发的群聊/陌生人消息是否值得参与。 +// 它只判断值不值得回 —— 绝不判断发送者是谁(项目红线)。 const ( - // engageMaxTokens bounds the judge output so a noisy group context never produces a - // giant completion (design/18 §3 cost guard). + // engageMaxTokens 限定判定器输出,使冗长的群上下文绝不会产出 + // 超长补全(design/18 §3 成本护栏)。 engageMaxTokens = 200 - // engageSnippetLen is how many bytes of each recent message the context renderer - // shows the judge — enough to weigh the topic without paging the whole group. + // engageSnippetLen 是上下文渲染器展示给判定器的每条近期消息 + // 字节数——足以权衡话题,又不必翻完整个群。 engageSnippetLen = 80 ) -// NewLLMLightJudge wraps a model.Adapter into a LightJudge (design/18 §3). It asks the -// LLM, via a single cheap turn, whether the triggered message is worth participating -// in, and routes the structured answer back to the engage engine. A nil adapter returns -// a judge that always fails closed (so the engine stays silent rather than panicking). +// NewLLMLightJudge 把 model.Adapter 包装成 LightJudge(design/18 §3)。它用一次 +// 廉价单轮问 LLM 被触发的消息是否值得参与, +// 并把结构化答案回送给参与度引擎。适配器为 nil 时返回的 +// 判定器总是失败关闭(引擎保持静默,而不是 panic)。 func NewLLMLightJudge(llm model.Adapter, log *slog.Logger) LightJudge { if log == nil { log = slog.Default() @@ -42,10 +42,10 @@ type llmLightJudge struct { log *slog.Logger } -// Judge performs one cost-controlled light-turn on a triggered participation message -// and returns the structured verdict. Any error (upstream, parse) is returned so the -// engage engine fails closed to silence. It never reads sender identity — name is -// passed only as a label, identity was already resolved structurally. +// Judge 对被触发的参与度消息执行一次受成本控制的轻量单轮, +// 并返回结构化裁决。任何错误(上游、解析)都会返回, +// 使参与度引擎失败关闭为静默。它绝不读取发送者身份——name +// 只作为标签传入,身份早已由结构解析完毕。 func (j *llmLightJudge) Judge(ctx context.Context, in LightInput) (LightDecision, error) { if j.llm == nil { return LightDecision{}, errors.New("channelbridge: light judge: nil model adapter") @@ -66,15 +66,15 @@ func (j *llmLightJudge) Judge(ctx context.Context, in LightInput) (LightDecision j.log.Warn("light judge output unparsable, fail-closed to silence", "error", err) return LightDecision{}, err } - // Map the participation verdict onto the legacy design/12 §6 Respond field so the - // same decision drives both the engage engine (WorthReply/Interesting) and any - // legacy consumer that only reads Respond. + // 把参与度裁决映射到旧的 design/12 §6 Respond 字段,使同一 + // 判定既驱动参与度引擎(WorthReply/Interesting),也驱动任何 + // 只读 Respond 的旧消费者。 d.Respond = d.WorthReply || d.Interesting return d, nil } -// engageJudgeSystemPrompt instructs the judge to weigh only participation value, never -// identity (design/18 §0 red line), and to answer with a tight JSON object. +// engageJudgeSystemPrompt 指示判定器只权衡参与价值,绝不涉及 +// 身份(design/18 §0 红线),并以紧凑的 JSON 对象作答。 const engageJudgeSystemPrompt = "你是群聊参与度轻思考器。只判断「她值不值得参与这条群聊消息」," + "绝不判断消息发送者是谁(谁说的已由系统元数据决定)。" + "只输出一个 JSON 对象,字段为:" + @@ -84,11 +84,11 @@ const engageJudgeSystemPrompt = "你是群聊参与度轻思考器。只判断 "\"reason\": string(一句话理由)}。" + "不要输出其它文字。" -// buildEngageUserContent renders the persona prompt, the recent group context and the -// triggering message for the judge. The persona block carries no owner secret; the -// context is bounded by the engage renderer. When Engage is false (a legacy design/12 -// §6 call) only the message and sender label are present, so the judge degrades to a -// plain value judgement. +// buildEngageUserContent 为判定器渲染角色提示词、近期群上下文与 +// 触发消息。角色块不含主人敏感信息; +// 上下文由参与度渲染器限定长度。Engage 为 false(旧的 design/12 +// §6 调用)时只有消息与发送者标签,判定器因此退化为 +// 朴素的价值判断。 func buildEngageUserContent(in LightInput) string { var b strings.Builder if in.Engage && in.PersonaPrompt != "" { @@ -109,7 +109,7 @@ func buildEngageUserContent(in LightInput) string { return b.String() } -// engageDecisionJSON mirrors the judge's structured verdict for decoding. +// engageDecisionJSON 镜像判定器的结构化裁决,便于解码。 type engageDecisionJSON struct { Interested bool `json:"interested"` WorthReply bool `json:"worthReply"` @@ -117,8 +117,8 @@ type engageDecisionJSON struct { Reason string `json:"reason"` } -// parseEngageDecision decodes the judge's JSON object, tolerating a fenced/code-block -// wrapper. A malformed answer returns an error so the caller fails closed to silence. +// parseEngageDecision 解码判定器的 JSON 对象,容忍被围栏/代码块 +// 包裹。答案畸形时返回错误,使调用方失败关闭为静默。 func parseEngageDecision(s string) (LightDecision, error) { trimmed := strings.TrimSpace(s) if strings.HasPrefix(trimmed, "```") { @@ -128,8 +128,8 @@ func parseEngageDecision(s string) (LightDecision, error) { } trimmed = strings.TrimSuffix(strings.TrimSpace(trimmed), "```") } - // The model may wrap the object with explanatory prose; fall back to the first - // balanced JSON object if the whole payload is not pure JSON. + // 模型可能用解释性文字包裹对象;若整个载荷不是纯 JSON, + // 则回退到第一个配对完整的 JSON 对象。 var raw engageDecisionJSON if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { if obj := firstJSONObject(trimmed); obj != "" { @@ -151,8 +151,8 @@ func toEngageDecision(raw engageDecisionJSON) LightDecision { } } -// firstJSONObject returns the first balanced {...} region of s, or "" when none exists. -// It is a cheap tolerance for a model that prepends/appends prose to the JSON answer. +// firstJSONObject 返回 s 中第一个配对完整的 {...} 区域,没有则返回 ""。 +// 这是对在 JSON 答案前后加文字的模型的廉价容忍。 func firstJSONObject(s string) string { start := strings.IndexByte(s, '{') if start < 0 { diff --git a/internal/services/channelbridge/flowgate.go b/internal/services/channelbridge/flowgate.go index 3e2d0f1..c74f97a 100644 --- a/internal/services/channelbridge/flowgate.go +++ b/internal/services/channelbridge/flowgate.go @@ -12,12 +12,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// gateFor returns the per-account flow gate for an adapter, building it lazily -// from the adapter's Config snapshot on first use. Gates are keyed by the adapter -// name so every account (platform:confId) keeps its own gate that survives adapter -// reconnects; **the throttling counters inside a gate are per conversation** -// (adapter.ConversationKey: platform+channelID+groupID/peerID), so two groups never -// squeeze each other (design/12 §6 策略三/四, 裁决 06.4.3). +// gateFor 返回适配器的按账号流控闸门,首次使用时依据适配器的 +// Config 快照惰性构建。闸门以适配器名称为键,使每个账号 +// (platform:confId)保有自己、可跨适配器重连存活的闸门; +// **闸门内部的节流计数器按会话隔离** +// (adapter.ConversationKey:platform+channelID+groupID/peerID),因此两个群绝不会 +// 相互挤占(design/12 §6 策略三/四,裁决 06.4.3)。 func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate { name := a.Name() b.gateMu.Lock() @@ -30,11 +30,11 @@ func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate { return g } -// gateOptions resolves the per-account flow-limits out of the adapter's Config -// snapshot into a FlowGate. An absent key (single-account env adapters) leaves the -// limit off, so existing channels run unchanged; a present key with value 0 follows -// the protective default (6 group/min, 50 cap) and a negative value disables it — -// the backward-compatible contract for CHANNELS_JSON accounts (design/12 §6 实现要点). +// gateOptions 把适配器 Config 快照中的按账号流控限制解析进 +// FlowGate。键缺失(单账号环境变量适配器)时限制关闭, +// 既有渠道行为不变;键存在且值为 0 时采用保护性默认值 +// (每群每分钟 6 条、上限 50),负值则关闭限制—— +// 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。 func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions { opts := adapter.FlowGateOptions{ Review: b.review, @@ -54,10 +54,10 @@ func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions return opts } -// flowOrDefault maps a configured limit value to an effective limit: a negative -// value disables the limit (0), zero follows the protective default, and a positive -// value pins it. This is the "old account without the field keeps prior behavior or -// gets the safe default" contract. +// flowOrDefault 把配置的限制值映射为实际生效的限制:负值 +// 关闭限制(0),零采用保护性默认值,正值则 +// 固定为该值。这就是「没有该字段的旧账号保持原有行为或 +// 取安全默认值」的约定。 func flowOrDefault(v, def int) int { switch { case v < 0: @@ -69,8 +69,8 @@ func flowOrDefault(v, def int) int { } } -// cfgInt reads an integer field from an adapter config map, returning (0,false) -// when the key is absent or of an unexpected type (so an absent field stays "off"). +// cfgInt 从适配器配置 map 读取整数字段,键缺失或类型不符时 +// 返回 (0,false)(因此缺失的字段保持「关闭」)。 func cfgInt(m map[string]any, key string) (int, bool) { if m == nil { return 0, false @@ -88,24 +88,24 @@ func cfgInt(m map[string]any, key string) (int, bool) { } const ( - // reviewMaxTokens bounds the review model output so a noisy queue listing never - // produces a giant completion (design/12 §6 策略四 cost guard). + // reviewMaxTokens 限定审查模型的输出,使冗长的队列清单绝不会 + // 产出超长补全(design/12 §6 策略四 成本护栏)。 reviewMaxTokens = 256 - // reviewSnippetLen is how many bytes of each message's content are shown to the - // reviewer, enough to judge importance without leaking the whole queue. + // reviewSnippetLen 是展示给审查者的每条消息内容字节数, + // 足以判断重要性而不泄露整个队列。 reviewSnippetLen = 80 - // reviewPrompt instructs the review model to pick which queued messages matter. - // It only ever decides noise/importance — never who the sender is (project red - // line: identity comes from actor metadata, not the LLM). + // reviewPrompt 指示审查模型挑选哪些队列消息重要。 + // 它只判断噪声/重要性——绝不判断发送者是谁(项目 + // 红线:身份来自 actor 元数据,而非 LLM)。 reviewPrompt = "你是群聊消息去噪助手。下面列出若干进入队列的消息(每条带编号)。" + "请判断哪些消息没有保留价值(灌水、无关、低信息量),只返回你认为【有必要保留】的编号 JSON 数组。" + "只判断信息价值,不判断发送者身份;不要编造编号;只输出 JSON 数组。" ) -// NewLLMReviewer wraps a model.Adapter into an adapter.Reviewer (design/12 §6 策略四). -// It asks the LLM, via a light single-turn prompt, which of the queued messages are -// worth keeping and routes the answer back to the gate. The gate only invokes it -// when a pending set nears its cap, so the LLM is never called per-message. +// NewLLMReviewer 把 model.Adapter 包装成 adapter.Reviewer(design/12 §6 策略四)。 +// 它用一次轻量单轮提示问 LLM 哪些队列消息值得保留, +// 并把答案回送给闸门。闸门只在待处理集合接近上限时 +// 调用它,因此绝不会按消息调用 LLM。 func NewLLMReviewer(llm model.Adapter, log *slog.Logger) adapter.Reviewer { if log == nil { log = slog.Default() @@ -132,9 +132,9 @@ func NewLLMReviewer(llm model.Adapter, log *slog.Logger) adapter.Reviewer { } } -// buildReviewContent renders the queued messages as a numbered, truncated listing so -// the reviewer can judge importance cheaply. No identity or LLM judgement is made -// here — only a compact, index-labelled text summary. +// buildReviewContent 把队列消息渲染为带编号的截断清单, +// 便于审查者低成本判断重要性。这里不做身份判定或 LLM 判断 +// ——只生成紧凑、带索引标签的文本摘要。 func buildReviewContent(msgs []adapter.UnifiedMessage) string { var b strings.Builder for i, m := range msgs { @@ -147,7 +147,7 @@ func buildReviewContent(msgs []adapter.UnifiedMessage) string { return b.String() } -// truncateText shortens s to at most n bytes (by rune count) for display. +// truncateText 为展示把 s 截短到至多 n 字节(按 rune 计数)。 func truncateText(s string, n int) string { if n <= 0 { return "" @@ -159,9 +159,9 @@ func truncateText(s string, n int) string { return string(runes[:n]) + "…" } -// parseKeepIndices decodes a JSON array of indices from the review model and keeps -// only in-range, de-duplicated values in ascending order. Out-of-range or duplicate -// indices are dropped so a hallucinated number can never evict a real message. +// parseKeepIndices 解码审查模型返回的 JSON 索引数组,并按升序 +// 只保留范围内、去重后的值。越界或重复的 +// 索引被丢弃,因此模型幻觉出的数字绝不会淘汰真实消息。 func parseKeepIndices(s string, bound int) ([]int, error) { var idx []int if err := json.Unmarshal([]byte(s), &idx); err != nil { diff --git a/internal/services/channelbridge/flowgate_test.go b/internal/services/channelbridge/flowgate_test.go index 819b9b2..b6641c6 100644 --- a/internal/services/channelbridge/flowgate_test.go +++ b/internal/services/channelbridge/flowgate_test.go @@ -43,10 +43,10 @@ func groupOwnerMsg(i int) adapter.UnifiedMessage { return m } -// TestFlowGate_RateLimitDropsNonOwnerAndAudits verifies the gate is wired into the -// ingress (onMessage) and that a rate-limited non-owner message is dropped with the -// reason surfaced through the OnRejected audit hook. The dropped path is synchronous -// (no worker goroutine is spawned), so the assertion is deterministic. +// TestFlowGate_RateLimitDropsNonOwnerAndAudits 验证闸门已接入 +// 入口(onMessage),且被限流的非主人消息会被丢弃并经 +// OnRejected 审计钩子暴露原因。丢弃路径是同步的 +// (不会派生工作 goroutine),因此断言是确定性的。 func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) { t.Parallel() @@ -63,8 +63,8 @@ func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) { ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "msgPerMin": 1}} b.Add(ad) - // First message is admitted (async worker), only auditing the second, which the - // total per-minute cap refuses. + // 第一条消息被放行(异步工作项),只对第二条入审计, + // 它被每分钟总量上限拒绝。 b.onMessage(context.Background(), groupSender(0), ad) b.onMessage(context.Background(), groupSender(1), ad) @@ -73,9 +73,9 @@ func TestFlowGate_RateLimitDropsNonOwnerAndAudits(t *testing.T) { } } -// TestFlowGate_OwnerPrivateBypassesGate verifies owner messages bypass the flow gate -// entirely: even with an aggressive rate limit, an owner message is never dropped as -// rate_limited / review_pruned / capped. +// TestFlowGate_OwnerPrivateBypassesGate 验证主人消息完全绕过流控 +// 闸门:即使限流很激进,主人消息也绝不会以 +// rate_limited / review_pruned / capped 被丢弃。 func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) { t.Parallel() @@ -92,8 +92,8 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) { ad := &fakeAdapter{name: "kook", cfg: map[string]any{"bot_token": "x", "groupMsgPerMin": 1, "maxMessages": 1}} b.Add(ad) - // Three owner messages: if the gate applied, the 2nd and 3rd would be rate - // limited / capped and reach OnRejected. Owner bypass leaves the audit empty. + // 三条主人消息:若闸门生效,第 2、3 条会被限流 + // / 触及上限并到达 OnRejected。主人绕过则审计为空。 for i := 0; i < 3; i++ { b.onMessage(context.Background(), groupOwnerMsg(i), ad) } @@ -102,9 +102,9 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) { } } -// TestFlowGate_GateOptionsDefaults pins the config-to-limit resolution that keeps -// old CHANNELS_JSON accounts backward compatible: absent key → off, 0 → protective -// default, negative → off, positive → pinned. +// TestFlowGate_GateOptionsDefaults 钉住「配置→限制」的解析规则, +// 它保证旧的 CHANNELS_JSON 账号向后兼容:键缺失 → 关闭,0 → 保护性 +// 默认值,负值 → 关闭,正值 → 固定为该值。 func TestFlowGate_GateOptionsDefaults(t *testing.T) { t.Parallel() @@ -139,7 +139,7 @@ func TestFlowGate_GateOptionsDefaults(t *testing.T) { } } -// mockLLM is a model.Adapter stub returning a fixed completion. +// mockLLM 是返回固定补全的 model.Adapter 桩。 type mockLLM struct { content string err error @@ -156,8 +156,8 @@ func (m *mockLLM) ChatStream(_ context.Context, _ model.Request, _ func(model.St return model.Response{}, nil } -// TestNewLLMReviewer verifies the review model is wrapped into a Reviewer that asks -// the LLM which queued messages to keep and filters the returned indices. +// TestNewLLMReviewer 验证审查模型被包装成 Reviewer:向 +// LLM 询问哪些队列消息该保留,并过滤返回的索引。 func TestNewLLMReviewer(t *testing.T) { t.Parallel() @@ -173,7 +173,7 @@ func TestNewLLMReviewer(t *testing.T) { if err != nil { t.Fatalf("review: %v", err) } - // Out-of-range index 99 and the duplicate 0 are dropped; keep = [0,1]. + // 越界索引 99 与重复的 0 被丢弃;保留 = [0,1]。 if len(keep) != 2 || keep[0] != 0 || keep[1] != 1 { t.Fatalf("keep = %v, want [0 1]", keep) } @@ -183,8 +183,8 @@ func TestNewLLMReviewer(t *testing.T) { } } -// TestNewLLMReviewerError verifies a review-model failure is surfaced to the gate, -// which then falls back to FIFO eviction rather than fabricating a success. +// TestNewLLMReviewerError 验证审查模型失败会暴露给闸门, +// 闸门随后回退为 FIFO 淘汰,而不是伪造一个成功。 func TestNewLLMReviewerError(t *testing.T) { t.Parallel() diff --git a/internal/services/channelbridge/helpers.go b/internal/services/channelbridge/helpers.go index b0c7f7d..1fff59a 100644 --- a/internal/services/channelbridge/helpers.go +++ b/internal/services/channelbridge/helpers.go @@ -6,12 +6,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// This file holds the small package-level helpers shared across the bridge files: -// platform/account naming, the sender label, and generic slice readers. +// 本文件承载桥接器各文件共用的小型包级辅助函数: +// 平台/账号命名、发送者标签,以及通用切片读取。 -// basePlatform returns the bare platform name of a (possibly multi-account) -// adapter name like "telegram:conf0" → "telegram", so the credential gate and the -// reply-routing fallback treat every account of a platform identically. +// basePlatform 返回(可能多账号的)适配器名称的裸平台名, +// 如 "telegram:conf0" → "telegram",使凭据闸门与 +// 回复路由回退把同一平台的每个账号一视同仁。 func basePlatform(name string) string { if base, _, ok := strings.Cut(name, ":"); ok { return base @@ -19,11 +19,11 @@ func basePlatform(name string) string { return name } -// adapterName returns the account-adapter registration name (e.g. "kook:3bef...") -// for the adapter that received a message, or "" when a is nil. The single-account -// convenience path (process) and a platform with no registered adapter can pass a nil -// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than -// dereferencing a nil adapter. +// adapterName 返回接收消息的账号适配器注册名(如 "kook:3bef..."), +// a 为 nil 时返回 ""。单账号 +// 便捷路径(process)以及没有已注册适配器的平台都可能传入 nil +// 适配器,因此持久化钩子容忍它,存空 adapter_id,而不是 +// 解引用 nil 适配器。 func adapterName(a adapter.PlatformAdapter) string { if a == nil { return "" @@ -31,11 +31,11 @@ func adapterName(a adapter.PlatformAdapter) string { return a.Name() } -// senderDisplayName returns the human-readable sender name used to label a -// third-party message. It prefers the resolved actor's display name (which the -// channel layer derived from channel metadata + the raw SenderName), falling back -// to the message's own SenderName, so an empty SenderName always means "the -// owner/unlabeled" rather than a nickname that resolved to nothing. +// senderDisplayName 返回用于标注第三方消息的 +// 人类可读发送者名。它优先取已解析 actor 的显示名(由 +// 渠道层依据渠道元数据 + 原始 SenderName 推导),回退到 +// 消息自身的 SenderName,因此空的 SenderName 始终表示 +// 「主人/未标注」,而不是解析不出结果的昵称。 func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string { if actor.DisplayName != "" { return actor.DisplayName @@ -43,7 +43,7 @@ func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string { return msg.SenderName } -// sliceContains reports whether s is present in list. +// sliceContains 报告 s 是否存在于 list 中。 func sliceContains(list []string, s string) bool { for _, v := range list { if v == s { @@ -53,7 +53,7 @@ func sliceContains(list []string, s string) bool { return false } -// firstNonEmptyStr returns a when non-empty, otherwise b. +// firstNonEmptyStr 在 a 非空时返回 a,否则返回 b。 func firstNonEmptyStr(a, b string) string { if a != "" { return a diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index 44468ef..17b4f55 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -12,23 +12,23 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// This file holds the inbound path of the channel bridge (design/08 §1-§2): the -// per-adapter read loop, the bounded inbound callback, and the full -// session→actor→loop→reply pipeline including the structural identity boundary. +// 本文件承载渠道桥接器的入站路径(design/08 §1-§2):按适配器的 +// 读取循环、有界的入站回调,以及完整的 +// 会话→actor→循环→回复 流水线(含结构化的身份边界)。 -// readLoop runs an adapter's blocking read path until the bridge stops or the -// adapter's own context is cancelled (a hot reload). The emit sink is wired to -// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll) -// deliver through the same pipeline as push-style adapters. Message processing -// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight -// messages alone. +// readLoop 运行适配器的阻塞式读取路径,直到桥接器停止或 +// 适配器自身的 context 被取消(热重载)。emit 汇聚点接到 +// onMessage,因此轮询式适配器(Telegram getUpdates、weixin 长轮询) +// 与推送式适配器走同一条流水线投递。消息处理 +// 使用桥接器生命周期 context(而非 readCtx),因此重载不会打扰 +// 在途消息。 // -// When the read path returns with readCtx still alive, the adapter stopped -// reading on its own — today that is the weixin-iLink rejecting the session -// (errcode=-14, see plugins/channels/weixin_ilink/session_state.go), which used to -// leave the bridge claiming the channel was connected while it could no longer -// deliver anything. A cancelled readCtx is a deliberate stop (bridge Stop or a -// hot reload of this name) and is handled by the caller that cancelled it. +// 读取路径返回而 readCtx 仍存活时,说明适配器自己停止了 +// 读取——目前就是 weixin-iLink 拒绝会话 +// (errcode=-14,见 plugins/channels/weixin_ilink/session_state.go),过去会 +// 让桥接器仍声称渠道已连接,实际却再也 +// 投递不出任何东西。readCtx 被取消属于刻意停止(桥接器 Stop 或 +// 该名称的热重载),由取消者处理。 func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) { defer b.wg.Done() a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) { @@ -39,12 +39,12 @@ func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) { } } -// markReadStopped records that an adapter's read loop ended by itself while the -// adapter reports no live session, so the per-name accounting (IsConnected / -// ConnectedCount) stops claiming a channel that cannot deliver. It only ever -// clears the flag — never sets it — so it cannot make a connected channel look -// alive, and it is a no-op when the adapter still reports a live session (a -// reconnect that already happened is never undone by an older loop's exit). +// markReadStopped 记录适配器的读取循环自行结束、且适配器 +// 报告没有存活会话,使按名称的统计(IsConnected / +// ConnectedCount)不再声称一个投不出消息的渠道可用。它 +// 只清除标记——绝不设置——因此不会让已连接的渠道显得 +// 存活;适配器仍报告存活会话时它是空操作(已发生的 +// 重连绝不会被更早的循环退出所撤销)。 func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) { if a.IsConnected() { return @@ -59,11 +59,11 @@ func (b *Bridge) markReadStopped(a adapter.PlatformAdapter) { b.log.Warn("channel read loop ended, adapter is no longer connected", "channel", name) } -// onMessage is the adapter-facing inbound callback. It runs the pipeline in a -// bounded goroutine so the adapter's read loop never blocks on a slow agent turn. -// The adapter is carried explicitly so the pipeline knows which account received -// the message (multi-account reply routing + per-account allow/deny, design/16 -// §4.6 §5). +// onMessage 是面向适配器的入站回调。它在有界 goroutine 中运行 +// 流水线,使适配器的读取循环不会因缓慢的 agent 轮次而阻塞。 +// 适配器被显式携带,使流水线知道是哪个账号收到了 +// 消息(多账号回复路由 + 按账号允许/拒绝,design/16 +// §4.6 §5)。 func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { b.mu.Lock() if b.closed { @@ -77,12 +77,12 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad return } - // design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private - // messages bypass it entirely (the owner is never throttled or pruned); every - // other message passes a sliding-window rate limit (策略三) and the bounded - // pending set whose saturation triggers the review model to prune noise (策略四). - // Dropped messages are audited through the gate's OnPruned hook (wired to - // OnRejected) and never reach the pipeline, so a flood cannot saturate the loop. + // design/12 §6 策略三/四:按适配器的最小打扰闸门。主人私聊 + // 消息完全绕过它(主人永不被限流或裁剪);其余 + // 消息先过滑动窗口限流(策略三),再过有界的 + // 待处理集合——集合饱和会触发审查模型裁剪噪声(策略四)。 + // 被丢弃的消息经闸门的 OnPruned 钩子(接到 + // OnRejected)入审计,绝不进入流水线,因此洪泛无法压垮循环。 actor := session.ResolveActor(msg, b.owner) if !actor.IsOwner() { verdict := b.gateFor(a).Admit(ctx, msg) @@ -100,9 +100,9 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad return } - // Add the worker under the same lock that Stop uses to set closed, so a - // concurrent Stop either sees our Add before it waits, or we observe closed - // and bail without leaking a goroutine. + // 在 Stop 设置 closed 所用的同一把锁下 Add 工作项,使 + // 并发的 Stop 要么在等待前看到我们的 Add,要么我们 + // 观察到 closed 后退出,不泄露 goroutine。 b.mu.Lock() if b.closed { b.mu.Unlock() @@ -119,19 +119,19 @@ func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a ad }() } -// process runs the inbound pipeline using the adapter whose platform matches the -// message (single-account convenience path; the adapter may be a bare -// platform-named registration). +// process 用平台与消息匹配的适配器运行入站流水线 +// (单账号便捷路径;该适配器可以是仅以平台名 +// 注册的裸适配器)。 func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) { b.processFrom(ctx, msg, b.adapter(msg.Platform)) } -// processFrom runs the full inbound pipeline against the named account adapter: -// resolve the session/actor, apply the allow/deny black-or-white lists, drive the -// agent loop with the chat text, and send the loop's reply back through the same -// adapter that received the message. Identity is resolved structurally by the -// session layer; the bridge never reads message content to decide who the sender -// is (a project red line). +// processFrom 针对指定的账号适配器运行完整入站流水线: +// 解析会话/actor、应用允许/拒绝黑白名单、以聊天文本 +// 驱动 agent 循环,并把循环的回复经接收该消息的同一 +// 适配器送回去。身份由会话层结构化解析; +// 桥接器绝不读取消息内容来判定发送者是谁 +// (项目红线)。 func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) { text := strings.TrimSpace(msg.Content) if text == "" { @@ -140,10 +140,10 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a return } - // design/16 §4.6 §6: the black-or-white lists are enforced at the very head of - // the pipeline, before a session is resolved or the loop is driven, so a - // blacklisted/non-whitelisted group or user message never reaches the LLM. The - // owner is always allowed (structural, via the resolved actor). + // design/16 §4.6 §6:黑白名单在流水线的最前端执行, + // 早于会话解析与循环驱动,因此被拉黑或不在白名单的 + // 群/用户消息绝不会到达 LLM。 + // 主人始终放行(由解析出的 actor 结构化决定)。 actor := session.ResolveActor(msg, b.owner) if !actor.IsOwner() && b.blacklistDrop(msg, a) { return @@ -161,11 +161,11 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a "session", sess.SessionID, "actor_role", sess.ActorRole, "env", sess.Env(), "mem_writable", sess.MemWritable) - // design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A - // non-owner group/stranger message that the cheap light judgement deems not - // worth a response is registered to its isolated session log and silenced — it - // never drives the full agent loop. Owner messages are never gated here, so - // owner-private chat and owner group @/high-value replies are unchanged. + // design/12 §6 策略一/二/五:面向非主人消息的最小打扰闸门。 + // 被廉价轻量判定认为不值得回复的非主人群聊/陌生人 + // 消息,只登记到其隔离会话日志并静默——绝不 + // 驱动完整 agent 循环。主人消息绝不在此被拦,因此 + // 主人私聊与主人在群里的 @/高价值回复不受影响。 if !actor.IsOwner() { if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok { b.registerSilenced(ctx, msg, actor, sess, a) @@ -179,9 +179,9 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a b.runInboundTurn(ctx, msg, a, text, actor, sess) } -// blacklistDrop applies the black-or-white list to a non-owner message, reporting -// whether it must be dropped (design/16 §4.6 §6). A dropped message is audited -// through the OnRejected hook so the decision is visible in the panel. +// blacklistDrop 对非主人消息应用黑白名单,返回 +// 它是否必须被丢弃(design/16 §4.6 §6)。被丢弃的消息经 +// OnRejected 钩子入审计,使该决定在面板中可见。 func (b *Bridge) blacklistDrop(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) bool { denied, reason := b.blacklisted(msg, a) if !denied { @@ -195,23 +195,23 @@ func (b *Bridge) blacklistDrop(msg adapter.UnifiedMessage, a adapter.PlatformAda return true } -// inboundTarget is where one admitted inbound message is persisted and mirrored -// (design/16 §4.4 + design/02 §3): an owner private chat is routed into the single -// main web session with the channel platform stamped so the Web client mirrors it, -// while a restricted group/stranger conversation keeps its isolated sess-xxx -// session and its own platform log. +// inboundTarget 是一条被放行的入站消息持久化与镜像的去处 +// (design/16 §4.4 + design/02 §3):主人私聊被路由进唯一的 +// Web 主会话并盖上渠道平台戳,供 Web 客户端镜像, +// 而受限的群聊/陌生人会话保留其隔离的 sess-xxx +// 会话与自有的平台日志。 type inboundTarget struct { - // sessionID is the session the turn runs in. + // sessionID 是该轮运行所在的会话。 sessionID string - // private marks an owner private chat mirrored into the main web session. + // private 标记镜像进 Web 主会话的主人私聊。 private bool - // platform is the mirrored chat_messages platform tag (private only). + // platform 是镜像写入 chat_messages 的平台标签(仅私聊)。 platform string } -// inboundTargetFor resolves the routing target from the structurally resolved -// session and the message's platform. Only a non-group owner conversation is -// mirrored; everything else stays in its isolated session. +// inboundTargetFor 依据结构化解析出的会话与消息的平台 +// 解析路由目标。只有非群聊的主人对话才被 +// 镜像;其余一律留在其隔离会话中。 func inboundTargetFor(sess session.Session, platform string) inboundTarget { if !sess.IsGroup && sess.Owner { return inboundTarget{sessionID: history.MainSessionID, private: true, platform: platform} @@ -219,33 +219,33 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget { return inboundTarget{sessionID: sess.SessionID} } -// runInboundTurn drives the agent turn for an admitted message: it resolves the -// routing target, builds the model-visible message list, persists the inbound user -// message before the turn (so it is durable even if the channel reply fails), runs -// the loop and hands the result to deliverReply. +// runInboundTurn 为被放行的消息驱动 agent 轮次:解析 +// 路由目标、构建模型可见的消息列表、在轮次之前持久化入站 +// 用户消息(因此即使渠道回复失败它也已落地),运行 +// 循环并把结果交给 deliverReply。 func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, text string, actor adapter.Actor, sess session.Session) { target := inboundTargetFor(sess, msg.Platform) - // The third-party boundary only applies in a restricted (group/stranger) - // context: memory is read-only (MemWritable=false), and the sender name is - // carried so the agent can see who is speaking (design/16 §4.6 §1-2). The - // resolved actor decides who is the owner vs a third party structurally — never - // from prompt text. + // 第三方边界只在受限(群聊/陌生人)上下文中生效: + // 记忆只读(MemWritable=false),并携带发送者名, + // 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。 + // 谁算主人、谁算第三方由解析出的 actor 结构化决定——绝不 + // 依据提示词文本。 // - // The role this turn speaks as follows design/22 §2/§7-3/§7-4: the main session's - // binding when the owner's private chat is mirrored into it, otherwise the - // account's channel-level binding, otherwise the global default; a group or - // stranger conversation is forced onto the default role. + // 该轮以哪个角色说话遵循 design/22 §2/§7-3/§7-4:主人私聊 + // 镜像进主会话时取主会话的绑定,否则取账号的 + // 渠道级绑定,再否则取全局默认;群聊或 + // 陌生人会话强制使用默认角色。 runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable} runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a)) senderName := senderDisplayName(msg, actor) loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg) - // Owner-private messages land in the main web session and are broadcast to the - // Web client; restricted messages land in their isolated session (the adapter log - // page) and are broadcast as a `channel_persisted` event (never into the - // main/`web` session or the web chat). + // 主人私聊消息落在 Web 主会话并广播给 Web 客户端; + // 受限消息落在其隔离会话(适配器日志页), + // 以 `channel_persisted` 事件广播(绝不进入 + // 主/`web` 会话或 Web 聊天)。 if target.private { b.persistToMain(ctx, history.RoleUser, text, "", target.platform, deviceIDFor(msg), adapterName(a)) } else { @@ -261,10 +261,10 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, b.deliverReply(ctx, msg, a, sess, target, res) } -// deliverReply persists the assistant reply and sends it back through the adapter -// that received the message. An adapter that does not support outbound text -// (adapter.ErrUnsupported) is a debug-level skip, not a failure: the reply is -// already durable in the history store. +// deliverReply 持久化助手回复,并经接收该消息的适配器 +// 送回去。不支持出站文本的适配器 +// (adapter.ErrUnsupported)只按 debug 级跳过,不算失败:回复 +// 已在历史存储中落地。 func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, sess session.Session, target inboundTarget, res *loop.Result) { reply := strings.TrimSpace(res.Text) @@ -272,9 +272,9 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a b.log.Debug("no reply produced", "platform", msg.Platform, "session", target.sessionID) return } - // Persist the assistant reply (owner-private → main session + message_persisted - // broadcast; restricted → isolated log + channel_persisted broadcast) then - // deliver it back to the channel. + // 持久化助手回复(主人私聊 → 主会话 + message_persisted + // 广播;受限 → 隔离日志 + channel_persisted 广播),随后 + // 送回渠道。 if target.private { b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, target.platform, deviceIDFor(msg), adapterName(a)) } else { @@ -290,16 +290,16 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a } } -// deviceIDFor builds the client deviceID tag for an inbound IM message -// (design/16 §4.5): :<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no -// explicit deviceId field yet, so the local config id and the user platform uid are -// taken from the normalized channel binding: ChannelID stands in for the local -// adapter binding and SenderID is the owner's platform uid. When SenderID is absent -// the tag degrades to : so it stays non-empty and readable. +// deviceIDFor 为一条入站 IM 消息构建客户端 deviceID 标签 +// (design/16 §4.5)::<本地配置唯一id>:<用户平台唯一uid>。UnifiedMessage 尚无 +// 显式的 deviceId 字段,因此本地配置 id 与用户平台 uid +// 取自规范化后的渠道绑定:ChannelID 暂代本地 +// 适配器绑定,SenderID 是主人在该平台的 uid。SenderID 缺失时 +// 标签退化为 :,以保持非空且可读。 // -// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the -// bridge gains a local config id), replace the ChannelID stand-in here and return -// that value directly. +// 扩展点:当适配器/UnifiedMessage 长出具体的 deviceId(或 +// 桥接器获得本地配置 id)时,把这里的 ChannelID 暂代替换掉并 +// 直接返回该值。 func deviceIDFor(msg adapter.UnifiedMessage) string { if msg.SenderID != "" { return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID @@ -307,27 +307,27 @@ func deviceIDFor(msg adapter.UnifiedMessage) string { return msg.Platform + ":" + msg.ChannelID } -// buildLoopMessages builds the model-visible message list for one inbound turn. It -// decorates the user message with a "[来源: · ]" prefix so -// the model can see which platform and conversation the message came from (design/16 -// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In -// a restricted (group/stranger) context it additionally prepends a single system -// message that states the third-party identity boundary — "every IM non-admin -// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1). -// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the -// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left -// untouched (a copy is built with the prefix), so persisted content stays consistent -// with what the source platform delivered. +// buildLoopMessages 为一次入站轮次构建模型可见的消息列表。它 +// 用 "[来源: · ]" 前缀装饰用户消息,使 +// 模型能看到消息来自哪个平台、哪个会话(design/16 +// §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在 +// 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统 +// 消息,声明第三方身份边界——「每条 IM 非管理员 +// 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)。 +// 该边界与 "[第三方 · 昵称]" 标记由解析出的 +// SessionEnv/actor 结构化决定,绝不由 LLM 推断。原始消息文本 +// 保持不动(构建带前缀的副本),因此持久化内容与 +// 来源平台投递的内容一致。 func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message { msg := model.TextMessage(model.RoleUser, text) thirdParty := env.Restricted() && !actor.IsOwner() && senderName != "" if thirdParty { - // The speaker is carried as structured metadata, NOT woven into the user text - // (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface. + // 说话者由结构化元数据承载,不织入用户文本 + // (design/16 §4.6.1):文本标签既含糊,又构成提示词注入面。 msg.SenderName = senderName } - // origin system line: conversation origin + identity-trust rule, one per turn. + // 来源系统行:会话来源 + 身份信任规则,每轮一条。 origin := originSystemText(src, actor) if !env.Restricted() { @@ -336,7 +336,7 @@ func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.Sess } return []model.Message{msg} } - // Restricted (group/stranger): origin (if IM) + the third-party identity boundary. + // 受限(群聊/陌生人):来源(若是 IM)+ 第三方身份边界。 var sys string switch { case origin != "" && thirdPartyBoundaryPrompt != "": @@ -349,9 +349,9 @@ func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.Sess return []model.Message{model.TextMessage(model.RoleSystem, sys), msg} } -// thirdPartyBoundaryPrompt is the system message prepended to a restricted -// (group-chat / stranger) context so the model knows the identity boundary. It is -// only injected when the session is structurally restricted (design/16 §4.6 §1); -// the LLM never decides who is a third party — that was already resolved from the -// actor metadata before this message exists. +// thirdPartyBoundaryPrompt 是加在受限(群聊/陌生人)上下文 +// 最前面的系统消息,让模型知道身份边界。只有当会话 +// 在结构上受限时才注入(design/16 §4.6 §1); +// LLM 绝不判断谁是第三方——在这条消息存在之前,它 +// 早已由 actor 元数据解析完毕。 const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。" diff --git a/internal/services/channelbridge/light.go b/internal/services/channelbridge/light.go index c85fdb4..0e8cabd 100644 --- a/internal/services/channelbridge/light.go +++ b/internal/services/channelbridge/light.go @@ -10,39 +10,39 @@ import ( "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). +// 本文件承载渠道桥接器面向公开平台的入站「最小打扰」分级 +// (DevDocs/design/12 §6 策略一/二/五)。非主人的群聊/陌生人 +// 消息在进入完整 agent 循环前先经廉价判断,使繁忙的 +// 公开群不会把每条消息都扔进昂贵的 LLM 轮次。这些逻辑 +// 放在这里是为了让 channelbridge.go 保持在模块体积上限内;它绝不 +// 读取消息内容来判定身份——身份早已由 +// 会话/actor 层结构化解析完毕(项目红线)。 -// Quiet-hour defaults, mirroring the backthink service so the bridge and the -// background thinker share one do-not-disturb window (design/12 §6 策略五). +// 安静时段默认值,与 backthink 服务保持一致,使桥接器与 +// 后台思考器共用同一个免打扰窗口(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 策略二/五). +// lightVerdict 是规则轻量判定给出的结果,早于咨询可选的 +// 注入式 LightJudge(design/12 §6 策略二/五)。 type lightVerdict int const ( - // verdictRespond means the rule signals the message is worth a response. + // verdictRespond 表示规则信号显示该消息值得回复。 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 表示规则信号显示该消息应登记但不应 + // 回复(例如安静时段内没有强信号)。 verdictSilent - // verdictInconclusive means the rules cannot tell; defer to the LightJudge (or - // fail-closed to silence when none is wired). + // verdictInconclusive 表示规则无法判断;交给 LightJudge(未装配 + // 时则失败关闭为静默)。 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. +// LightInput 是 LightJudge 判断是否值得回复时收到的信号集合。 +// 身份早已由会话/actor 层结构化解析完毕, +// 因此判定器只权衡值不值得回 —— 绝不判断发送者是谁。 type LightInput struct { Msg adapter.UnifiedMessage Actor adapter.Actor @@ -51,74 +51,74 @@ type LightInput struct { 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 报告这是否是一次受成本控制的参与度判定 + // (design/18):只有触发(角色名提及 / 概率) + // 或活跃窗口把消息送到这里时才为真,因此调用方只投喂一次 + // 群上下文与角色提示词,绝不会按每条聊天消息投喂。旧的 + // (design/12 §6)无结论判定会使它为 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 是当前角色的身份/系统提示词块,使判定器 + // 能权衡角色的语气而不涉身份判定。它不含 + // 主人敏感信息(项目红线)。 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 是群会话最近 N 条消息(带各发送者 + // 显示名)加上触发消息的渲染文本,使判定器能判断 + // 该话题是否值得参与。 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 是当前角色的角色名提醒词(design/18 §2), + // 作为上下文传入,使判定器知道被提及的是哪个词。 TriggerWords []string } -// LightDecision is a LightJudge's verdict. +// LightDecision 是 LightJudge 的裁决。 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 是旧的 design/12 §6 简单裁决(回不回)。保留它 + // 是为了向后兼容;下面的 design/18 字段承载结构化的 + // 参与度裁决,供参与度引擎读取。 Respond bool Reason string - // Interesting reports whether she finds the topic interesting (design/18) — the - // signal to enter/remain ACTIVE. + // Interesting 报告她是否觉得该话题有意思(design/18)—— + // 进入/保持 ACTIVE 的信号。 Interesting bool - // WorthReply reports whether the message is worth a reply. + // WorthReply 报告该消息是否值得回复。 WorthReply bool - // AvoidTopic reports whether the topic should be avoided (sensitive / privet / - // red-line), so she does not engage even when otherwise interested. + // AvoidTopic 报告该话题是否应回避(敏感 / 私密 / + // 红线),使她在其他方面感兴趣时也不参与。 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. +// LightJudge 是可选的受成本控制的轻量应答判定器(design/12 §6 +// 策略二/五)。桥接器只在内置规则判定器得不出结论时咨询它, +// 因此聊天消息不会被反复送往昂贵的 LLM。LightJudge 为 +// nil(默认)时桥接器只用规则,并失败关闭为 +// 静默(不打扰为第一原则)。生产装配尚未提供它,因此这个 +// 接缝已就绪,但在被刻意接入前不消耗 token。 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. +// highValueKeywords 是规则判定器用于匹配的小词表:即使发送者 +// 未被直接点名,命中即认为该消息值得回复 +// (设计/12 §6 策略二:关键词重要度)。它是廉价子串匹配——不调用 LLM, +// 也绝不用于推断身份。 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. +// isLowValueEvent 报告非主人消息是否为平台/系统噪声: +// 必须登记,但绝不驱动完整 agent 循环(设计/12 §6 策略一)。 +// 它只对非主人消息咨询:系统/合成发送者是最典型的 +// 低价值信号。没有实质聊天文本的消息本就会在 +// 桥接器入口被丢弃,因此它主要用于拦系统 actor 的通知。 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. +// hasHighValueKeyword 报告文本是否含有「即使未被直接点名 +// 也值得回复」的词。词表保持极小,使规则路径对每条消息 +// 都是 O(1)。 func hasHighValueKeyword(text string) bool { lower := strings.ToLower(text) for _, k := range highValueKeywords { @@ -129,12 +129,12 @@ func hasHighValueKeyword(text string) bool { 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. +// ruleLightJudge 只依据廉价规则信号判断是否值得回复 +// (设计/12 §6 策略二/五)。优先级:直接点名、提及主人的显示 +// 名、高价值关键词,其次是安静时段偏向静默;否则 +// 结论为无结论(交给注入的 LightJudge,没有则静默)。它 +// 只读取已解析的 actor 元数据与消息内容——绝不推断 +// 身份。 func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, inQuiet bool) lightVerdict { if len(msg.Mentions) > 0 { return verdictRespond @@ -152,25 +152,25 @@ func (b *Bridge) ruleLightJudge(msg adapter.UnifiedMessage, actor adapter.Actor, 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. +// shouldRespond 实现非主人入站消息的最小打扰分级 +// (设计/12 §6 策略一/二/五 + design/18 §2/§4/§5)。它返回该消息 +// 应驱动完整 agent 循环并回复,还是只登记并静默。 +// 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门), +// 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是 +// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度 +// 引擎探测时能读到群的近期上下文。 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: @@ -181,21 +181,21 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, 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. + // 廉价规则得不出结论。参与度引擎(design/18)已配置时 + // 由它接管,且只在真正触发(角色名提及 / 概率)或 + // 活跃窗口内才咨询轻量判定器——绝不按每条 + // 聊天消息咨询,成本因此可控(红线)。未配置参与度 + // 引擎时,桥接器保持引入引擎之前的旧行为:对每条无结论 + // 消息咨询可选的注入式轻量判定器,否则失败关闭。 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. +// 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{ @@ -215,9 +215,9 @@ func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessag 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. +// mentionsTriggerWord 报告消息内容是否通过当前角色的某个触发词 +// 提到该角色(design/18 §2 触发②)。它是廉价子串匹配—— +// 不调用 LLM,也绝不用于推断发送者是谁。 func (b *Bridge) mentionsTriggerWord(text string) bool { if text == "" { return false @@ -230,21 +230,21 @@ func (b *Bridge) mentionsTriggerWord(text string) bool { 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. +// registerSilenced 把一次被静默的非主人入站对话持久化进其隔离 +// 会话的平台日志,使适配器日志页仍能渲染非主人 +// 消息流(design/16 §4.6 §4),随后就此结束——不进循环、不回复。a 是 +// 收到该消息的账号适配器,日志因此带上适配器配置标签。 +// 它是最小打扰路径中「登记」的那一侧。 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. +// inQuietHours 报告 at 是否落在配置的免打扰窗口内 +// (与 backthink 语义一致:两个非零且相等的时刻关闭该窗口, +// 跨午夜的窗口也能正确处理)。它是 Bridge 上的纯方法,测试 +// 可通过 Now 源确定性地驱动它。 func (b *Bridge) inQuietHours(at time.Time) bool { h := at.Hour() start, end := b.quietStartHour, b.quietEndHour diff --git a/internal/services/channelbridge/light_test.go b/internal/services/channelbridge/light_test.go index edddad2..0fc46e5 100644 --- a/internal/services/channelbridge/light_test.go +++ b/internal/services/channelbridge/light_test.go @@ -14,7 +14,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) -// stubLightJudge is a configurable LightJudge recording how often it was consulted. +// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次。 type stubLightJudge struct { resp LightDecision err error @@ -26,8 +26,8 @@ func (s *stubLightJudge) Judge(context.Context, LightInput) (LightDecision, erro return s.resp, s.err } -// nonOwnerGroupMsg returns a third-party group message from a member sender, with -// the given mentions. Content is left untouched so tests control the signal. +// nonOwnerGroupMsg 返回一条来自成员发送者的第三方群消息,带 +// 指定的提及。内容不做加工,便于测试自行控制信号。 func nonOwnerGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, @@ -37,8 +37,8 @@ func nonOwnerGroupMsg(content string, mentions ...adapter.Mention) adapter.Unifi } } -// nonOwnerSession is the restricted (group / stranger) session binding used by the -// non-owner tests: read-only memory, no owner. +// nonOwnerSession 是非主人测试所用的受限(群聊 / 陌生人)会话 +// 绑定:记忆只读、无主人。 func nonOwnerSession() session.Session { return session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false} } @@ -57,8 +57,8 @@ func TestNonOwnerSilencedRegisteredOnly(t *testing.T) { t.Fatalf("New: %v", err) } - // A non-owner message with no @/keyword/name signal is silenced (设计/12 §6 策略二): - // it is registered to the isolated session log but never drives the loop. + // 无 @/关键词/名字信号的非主人消息被静默(设计/12 §6 策略二): + // 它只登记到隔离会话日志,绝不驱动循环。 b.process(context.Background(), nonOwnerGroupMsg("hello all")) if chat.sessionID != "" || len(chat.messages) != 0 { @@ -87,15 +87,15 @@ func TestNonOwnerMentionResponds(t *testing.T) { t.Fatalf("New: %v", err) } - // An addressed (mentioned) non-owner message is worth a response (策略二), so it - // drives the full loop against the isolated group session. + // 被点名(提及)的非主人消息值得回复(策略二),因此它 + // 针对隔离群会话驱动完整循环。 b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"})) if chat.sessionID != "sess-g" { t.Fatalf("loop session = %q, want isolated sess-g", chat.sessionID) } - // Restricted group context → the third-party boundary system message plus the - // user message (design/16 §4.6 §1), mirroring TestGroupMessageThirdPartySenderNameAndBoundary. + // 受限群上下文 → 第三方边界系统消息加上 + // 用户消息(design/16 §4.6 §1),与 TestGroupMessageThirdPartySenderNameAndBoundary 一致。 if len(chat.messages) != 2 || chat.messages[1].Role != model.RoleUser { t.Fatalf("loop messages = %+v, want boundary + user", chat.messages) } @@ -120,7 +120,7 @@ func TestNonOwnerHighValueKeywordResponds(t *testing.T) { t.Fatalf("New: %v", err) } - // A high-value keyword (求助) is worth a response even without an @ (策略二). + // 高价值关键词(求助)即使没有 @ 也值得回复(策略二)。 b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助")) if chat.sessionID != "sess-g" { t.Fatalf("loop session = %q, want isolated sess-g for a high-value keyword", chat.sessionID) @@ -142,8 +142,8 @@ func TestNonOwnerQuietHoursSilenced(t *testing.T) { t.Fatalf("New: %v", err) } - // 策略五: during quiet hours a no-signal non-owner message is silenced — it is - // registered but not answered. + // 策略五:安静时段内无信号的非主人消息被静默—— + // 只登记、不回复。 b.process(context.Background(), nonOwnerGroupMsg("hello all")) if chat.sessionID != "" || len(chat.messages) != 0 { @@ -168,8 +168,8 @@ func TestNonOwnerQuietHoursMentionResponds(t *testing.T) { t.Fatalf("New: %v", err) } - // 策略五 is not a hard silence: a directly-addressed message during quiet hours - // still gets a response. + // 策略五不是硬性静默:被直接点名的消息在安静时段 + // 仍会得到回复。 b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"})) if chat.sessionID != "sess-g" { t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID) @@ -192,9 +192,9 @@ func TestOwnerUnaffectedInQuietHours(t *testing.T) { } b.Add(ad) - // An owner message is never gated, even during quiet hours (策略五 only ever - // governs non-owner traffic): owner-private chat still routes to the main web - // session and replies. + // 主人消息绝不被拦,即使处于安静时段(策略五只管 + // 非主人流量):主人私聊仍路由到 Web 主会话 + // 并回复。 b.process(context.Background(), directMessage()) if chat.sessionID != history.MainSessionID { @@ -208,8 +208,8 @@ func TestOwnerUnaffectedInQuietHours(t *testing.T) { func TestLightJudgeConsultedWhenInconclusive(t *testing.T) { t.Parallel() - // A no-signal daytime message is inconclusive to the rules, so the injected - // LightJudge gets a chance to decide (设计/12 §6 策略二: 规则不确定才轻 LLM). + // 白天无信号的消息对规则而言无结论,因此注入的 + // LightJudge 获得一次判断机会(设计/12 §6 策略二:规则不确定才轻 LLM)。 judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "值得回"}} chat := &stubChatter{res: &loop.Result{Text: "嗯"}} sess := &stubSessions{sess: nonOwnerSession()} @@ -275,7 +275,7 @@ func TestLightJudgeErrorFailsClosedToSilence(t *testing.T) { t.Fatalf("New: %v", err) } - // A failing light judge is fail-closed toward silence (不打扰为第一原则). + // 轻量判定器出错时失败关闭为静默(不打扰为第一原则)。 b.process(context.Background(), nonOwnerGroupMsg("今天天气不错")) if judge.calls != 1 { t.Fatalf("light judge calls = %d, want 1", judge.calls) diff --git a/internal/services/channelbridge/outbound.go b/internal/services/channelbridge/outbound.go index 7c0f008..921a13d 100644 --- a/internal/services/channelbridge/outbound.go +++ b/internal/services/channelbridge/outbound.go @@ -7,15 +7,15 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// This file holds the outbound reply path (design/16 §4.6 §5): the loop's text is -// delivered back to the source conversation through the same account adapter that -// received the message. +// 本文件承载出站回复路径(design/16 §4.6 §5):循环产生的文本 +// 通过接收该消息的同一账号适配器 +// 送回来源会话。 -// sendReply delivers the loop's text back to the channel's source conversation -// through the account adapter that received the message (multi-account safe, -// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so -// the adapter resolves the same peer the message came from, and it exits through the -// shared IM bubble dispatcher (strip role-play/narration +

split + 0.5-2s gap). +// sendReply 把循环产生的文本送回渠道的来源会话, +// 经由接收该消息的账号适配器(多账号安全, +// design/16 §4.6 §5)。它照搬入站消息的渠道路由字段, +// 使适配器解析出消息来自的同一对端,并经 +// 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按

切分 + 0.5-2s 间隔)。 func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error { if a == nil { a = b.adapter(in.Platform) diff --git a/internal/services/channelbridge/persist.go b/internal/services/channelbridge/persist.go index c39e973..302c27b 100644 --- a/internal/services/channelbridge/persist.go +++ b/internal/services/channelbridge/persist.go @@ -8,40 +8,40 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// This file holds the channelbridge's chat-history persistence hooks (design/16 -// §4.4): an owner private chat is routed INTO the single main web session and -// tagged with the channel platform, then broadcast so the Web client mirrors it -// without a refresh. Kept separate from channelbridge.go so the bridge's inbound -// pipeline file stays under the module size bound. +// 本文件承载 channelbridge 的聊天历史持久化钩子(design/16 +// §4.4):主人的私聊被路由进唯一的 Web 主会话并 +// 打上渠道平台标签,随后广播出去,使 Web 客户端无需刷新 +// 即可镜像。它与 channelbridge.go 分开,好让桥接器的入站 +// 流水线文件保持在模块体积上限之内。 -// modeChat is the behavior mode written for every IM-persisted message: IM carries -// chat text alone (design/02 §1 单向回流), so no other mode ever applies there. +// modeChat 是每条 IM 持久化消息写入的行为模式:IM 只承载 +// 聊天文本(design/02 §1 单向回流),因此那里永远不会出现其他模式。 const modeChat = "chat" -// SetOnPersisted wires (or replaces) the transport hook called after an -// owner-private message is persisted into the main web session. The gateway is -// built after the bridge in the composition root, so the hook is attached post-New -// through this setter; it is a no-op-safe call and is safe for concurrent use with -// in-flight processing. +// SetOnPersisted 装配(或替换)主人私聊消息持久化进 Web 主会话 +// 之后所调用的传输钩子。组合根中 gateway 在桥接器 +// 之后构建,因此该钩子在 New 之后通过此 setter 挂上; +// 它可以安全空调用,也可与在途处理 +// 安全并发。 func (b *Bridge) SetOnPersisted(fn func(history.Message)) { b.mu.Lock() defer b.mu.Unlock() b.onPersisted = fn } -// SetOnRejected wires (or replaces) the hook called after a message is dropped by -// the allow/deny black-or-white lists (design/16 §4.6 §6). The composition root -// routes it into the gateway audit trail. It is a no-op-safe call and is safe for -// concurrent use with in-flight processing. +// SetOnRejected 装配(或替换)消息被允许/拒绝黑白名单 +// 丢弃后所调用的钩子(design/16 §4.6 §6)。组合根 +// 把它接进 gateway 审计轨迹。它可以安全空调用,也可与 +// 在途处理安全并发。 func (b *Bridge) SetOnRejected(fn func(adapter.UnifiedMessage, string)) { b.mu.Lock() defer b.mu.Unlock() b.onRejected = fn } -// notifyPersisted hands a just-persisted message to the transport hook when one is -// wired, so the gateway can broadcast a `message_persisted` (web chat) or -// `channel_persisted` (IM adapter log) event for cross-end realtime sync. +// notifyPersisted 在装配了传输钩子时,把刚持久化的消息交给它, +// 使 gateway 能广播 `message_persisted`(Web 聊天)或 +// `channel_persisted`(IM 适配器日志)事件,实现多端实时同步。 func (b *Bridge) notifyPersisted(msg history.Message) { b.mu.RLock() fn := b.onPersisted @@ -51,17 +51,17 @@ func (b *Bridge) notifyPersisted(msg history.Message) { } } -// persistToMain writes one message into the main web session with an explicit source -// platform and client deviceID, then notifies the transport hook so Web clients receive -// a `message_persisted` event without a refresh (design/16 §4.4). deviceID is the -// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid" -// for IM. adapterID is the account adapter registration name that received the message -// (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream; -// it is empty for Web-originated messages. mood is the persona's mood for that turn -// (design/01 §2 情绪状态机), carried on the assistant reply only (design/16 §3.3: the -// assistant message carries mode/mood); a user message and an untracked mood pass "". -// It is a no-op when no history store is wired, so the bridge runs unchanged without -// storage. +// persistToMain 把一条消息写入 Web 主会话,并显式带上来源平台 +// 与客户端 deviceID,随后通知传输钩子,让 Web 客户端无需刷新即可收到 +// `message_persisted` 事件(design/16 §4.4)。deviceID 是 +// 发送该消息的具体客户端(design/16 §4.5)——例如 IM 的 "weixin-ilink:conf0:wx_uid"。 +// adapterID 是接收该消息的账号适配器注册名 +// (如 "kook:3bef..."),便于管理日志按适配器查询收/发流; +// Web 来源的消息该字段为空。mood 是该轮角色的心情 +// (design/01 §2 情绪状态机),只挂在助手回复上(design/16 §3.3: +// 助手消息承载 mode/mood);用户消息与未跟踪的心情传 ""。 +// 未装配历史存储时为空操作,因此桥接器在没有 +// 存储的情况下行为不变。 func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, platform, deviceID, adapterID string) { if b.history == nil { return @@ -76,16 +76,16 @@ func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, platfor b.notifyPersisted(full) } -// persistIsolated writes one message into a restricted (group / stranger) session's -// own platform log (design/16 §4.6 §4): the message lands in the isolated sess-xxx -// session (NOT the main web session) and carries the channel platform, the client -// deviceID, the sender's display name and the account adapter registration name, so -// the IM adapter log page can render the non-owner message stream per adapter config. -// The message is still NOT mirrored into the Web main session (only owner-private -// messages mirror to the Web); it is merely handed to the transport hook so the -// gateway broadcasts a `channel_persisted` event and the IM adapter log page appends -// it without a refresh. It is a no-op when no history store is wired, so the bridge -// runs unchanged without storage. +// persistIsolated 把一条消息写入受限(群聊/陌生人)会话 +// 自有的平台日志(design/16 §4.6 §4):消息落在隔离的 sess-xxx +// 会话(不是 Web 主会话)中,并带上渠道平台、客户端 +// deviceID、发送者显示名与账号适配器注册名,便于 +// IM 适配器日志页按适配器配置渲染非主人消息流。 +// 该消息仍不会镜像进 Web 主会话(只有主人私聊 +// 消息才镜像到 Web);它只交给传输钩子,让 +// gateway 广播 `channel_persisted` 事件、IM 适配器日志页无需刷新 +// 即可追加。未装配历史存储时为空操作,因此桥接器在 +// 没有存储的情况下行为不变。 func (b *Bridge) persistIsolated(ctx context.Context, role, content, deviceID string, sess session.Session, platform, senderName, adapterID string) { if b.history == nil { return @@ -99,10 +99,10 @@ func (b *Bridge) persistIsolated(ctx context.Context, role, content, deviceID st "session", sess.SessionID, "platform", platform, "sender_name", senderName, "adapter", adapterID) return } - // Broadcast the isolated write as a `channel_persisted` event so the IM adapter - // log page appends it in realtime. The gateway splits the event type by session: - // this is NOT the main web session, so the web chat has no handler for it and the - // message itself still lands only in the isolated sess-xxx session (never the - // main/`web` session, design/16 §4.6 §3). + // 把隔离写入作为 `channel_persisted` 事件广播,让 IM 适配器 + // 日志页实时追加。gateway 按会话区分事件类型: + // 这不是 Web 主会话,因此 Web 聊天没有它的处理器, + // 消息本身仍只落在隔离的 sess-xxx 会话(绝不在 + // 主/`web` 会话,design/16 §4.6 §3)。 b.notifyPersisted(full) } diff --git a/internal/services/channelbridge/persona.go b/internal/services/channelbridge/persona.go index 4f49544..78b417c 100644 --- a/internal/services/channelbridge/persona.go +++ b/internal/services/channelbridge/persona.go @@ -8,58 +8,58 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) -// This file owns which role an inbound IM turn speaks as (DevDocs/design/22 §2 -// 裁决规则 + §7-3 + §7-4). Identity and permission stay structural — the actor -// metadata and the session's isolation boundary decide who may do what — while the -// role only scopes the prompt, the memory namespace, the emotion machine and the -// thinking budget. +// 本文件掌管一轮入站 IM 对话以哪个角色说话(DevDocs/design/22 §2 +// 裁决规则 + §7-3 + §7-4)。身份与权限仍是结构性的——actor +// 元数据与会话的隔离边界决定谁可以做什么——而 +// 角色只界定提示词、记忆命名空间、情绪机器与 +// 思考预算的范围。 // -// Precedence, top down (design/22 §2): -// 1. the session-level binding — for an owner private chat that is the main web -// session the message is mirrored into, so the IM mirror answers with the same -// role the Web client is talking to (design/22 §7-3); -// 2. the channel-level binding — the account's channels.json personaId, used when -// the main session binds no role of its own (design/22 §2 方案 b); -// 3. the global default — expressed as "", which the loop resolves to its -// configured default role. +// 优先顺序,自上而下(design/22 §2): +// 1. 会话级绑定——对主人私聊而言即消息所镜像进的 Web 主 +// 会话,因此 IM 镜像用 Web 客户端正在对话的同一 +// 角色作答(design/22 §7-3); +// 2. 渠道级绑定——账号在 channels.json 中的 personaId,用于 +// 主会话自身未绑定角色时(design/22 §2 方案 b); +// 3. 全局默认——以 "" 表示,循环会把它解析为 +// 自己配置的默认角色。 // -// A restricted conversation (group chat or stranger, i.e. every isolated sess-xxx -// session) is deliberately excluded from 1 and 2: it is forced onto the default -// role (design/22 §7-4, the conservative choice), so a channel binding can never -// put a third party's conversation into a role's memory namespace. +// 受限会话(群聊或陌生人,即每个隔离的 sess-xxx +// 会话)被刻意排除在 1 和 2 之外:它被强制使用默认 +// 角色(design/22 §7-4 的保守选择),因此渠道绑定绝不会 +// 把第三方的会话放进某个角色的记忆命名空间。 -// sessionPersonaReader is the optional narrower view of the history store used to -// read a session's bound role. *history.Store implements it; a bridge built -// without one (or with a store that predates design/22) simply resolves no session -// binding, which is the global default. +// sessionPersonaReader 是历史存储的可选窄接口视图,用于 +// 读取会话绑定的角色。*history.Store 实现了它;未装配它 +// (或所用存储早于 design/22)的桥接器解析不出会话 +// 绑定,即全局默认。 type sessionPersonaReader interface { SessionPersona(ctx context.Context, sessionID string) (string, error) } -// resolvePersona returns the role id one inbound turn runs as. target says where -// the turn's conversation lives (the mirrored main session vs an isolated -// session), sess carries the structurally resolved isolation boundary and account -// is the receiving adapter's registration name (platform:confId) used for the -// channel-level lookup. +// resolvePersona 返回一轮入站对话所用的角色 id。target 指明 +// 该轮对话所在之处(镜像的主会话还是隔离 +// 会话),sess 携带结构化解析出的隔离边界,account +// 是接收方适配器的注册名(platform:confId),用于 +// 渠道级查询。 func (b *Bridge) resolvePersona(ctx context.Context, sess session.Session, target inboundTarget, account string) string { - // design/22 §7-4: a group/stranger conversation never plays a bound role. + // design/22 §7-4:群聊/陌生人会话绝不扮演绑定角色。 if !target.private { return "" } - // design/22 §7-3: an owner private chat mirrors into the main session, so it - // answers as whatever role that session is bound to. + // design/22 §7-3:主人私聊镜像进主会话,因此它 + // 以该会话绑定的角色作答。 if pid := b.mainSessionPersona(ctx); pid != "" { return pid } - // design/22 §2 方案 b: the account's channel-level role, used only when the main - // session binds no role of its own. + // design/22 §2 方案 b:账号的渠道级角色,仅当主 + // 会话自身未绑定角色时使用。 return b.personaOfAdapter(account) } -// mainSessionPersona returns the role bound to the main web session, or "" when -// the session inherits the global default. A missing reader or a read error also -// yields "": a chat must never be blocked by a metadata lookup, and the fallback is -// the pre-existing default role. +// mainSessionPersona 返回 Web 主会话绑定的角色;会话继承 +// 全局默认时返回 ""。读取器缺失或读取出错同样 +// 返回 "":聊天绝不能因元数据查询而被阻塞,而回退 +// 就是原有的默认角色。 func (b *Bridge) mainSessionPersona(ctx context.Context) string { if b.history == nil { return "" @@ -75,20 +75,20 @@ func (b *Bridge) mainSessionPersona(ctx context.Context) string { return strings.TrimSpace(pid) } -// personaOfAdapter returns the cached channel-level role of one adapter -// registration name, or "" when none is bound (or none was resolved at connect -// time). +// personaOfAdapter 返回某个适配器注册名已缓存的渠道级角色, +// 未绑定(或连接时未解析出)时 +// 返回 ""。 func (b *Bridge) personaOfAdapter(account string) string { b.mu.RLock() defer b.mu.RUnlock() return b.adapterPersona[account] } -// rememberAdapterPersona resolves and caches the channel-level role of an adapter -// that just connected. Resolving at connect/reload time (rather than per message) -// keeps the inbound hot path free of config reads, while a panel save still takes -// effect immediately: it writes channels.json and calls Reload, which rebuilds and -// reconnects the platform's adapters, refreshing this cache. +// rememberAdapterPersona 解析并缓存刚连接的适配器的渠道级 +// 角色。在连接/重载时解析(而不是按消息解析) +// 让入站热路径无需读配置,同时面板保存仍能 +// 立即生效:它写 channels.json 并调用 Reload,后者重建并 +// 重连该平台的适配器,刷新此缓存。 func (b *Bridge) rememberAdapterPersona(account string) { if b.channelPersona == nil { return @@ -104,9 +104,9 @@ func (b *Bridge) rememberAdapterPersona(account string) { b.adapterPersona[account] = pid } -// forgetAdapterPersona drops an adapter's cached channel role when the account is -// removed (or renamed) by a reload, so a removed account can never keep answering -// as a role that is no longer configured. +// forgetAdapterPersona 在账号被重载移除(或更名)时丢弃该适配器 +// 缓存的渠道角色,使已移除的账号绝不会继续 +// 以不再配置的角色作答。 func (b *Bridge) forgetAdapterPersona(account string) { b.mu.Lock() defer b.mu.Unlock() diff --git a/internal/services/channelbridge/persona_test.go b/internal/services/channelbridge/persona_test.go index 379ef10..8dc70d9 100644 --- a/internal/services/channelbridge/persona_test.go +++ b/internal/services/channelbridge/persona_test.go @@ -12,12 +12,12 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) -// This file covers design/22 §2/§7-3/§7-4 from the channel side: which role an -// inbound IM turn answers as. The chain asserted is the structural session → the -// resolved SessionEnv the loop is driven with → the role named in it. +// 本文件从渠道侧覆盖 design/22 §2/§7-3/§7-4:一轮入站 IM 对话 +// 以哪个角色作答。断言的链路是「结构化会话 → 驱动循环所用的 +// 已解析 SessionEnv → 其中指定的角色」。 -// personaHistory is the bridge's history store with one bound session role, so the -// owner-private mirror can be asserted to follow the main session's role. +// personaHistory 是桥接器的历史存储,绑定了一个会话角色, +// 以便断言主人私聊的镜像会跟随主会话的角色。 type personaHistory struct { *captureHistory personas map[string]string @@ -27,8 +27,8 @@ func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (st return p.personas[sessionID], nil } -// envChatter is a Chatter that records the SessionEnv the bridge resolved for the -// turn, which is where the role travels (the loop reads it from ctx). +// envChatter 是一个 Chatter,记录桥接器为该轮解析出的 SessionEnv, +// 角色正是经由它传递(循环从 ctx 读取它)。 type envChatter struct { env loop.SessionEnv got bool @@ -40,9 +40,9 @@ func (e *envChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loo return &loop.Result{Text: "在呢"}, nil } -// personaBridge builds a started bridge whose single telegram account is bound to -// channelPersona, over a history store that binds mainSessionPersona to the main -// session ("" = unbound). +// personaBridge 构建一个已启动的桥接器:其唯一的 telegram 账号绑定到 +// channelPersona,底层历史存储把 mainSessionPersona 绑定到主 +// 会话("" = 未绑定)。 func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Bridge, *envChatter) { t.Helper() chat := &envChatter{} @@ -63,10 +63,10 @@ func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Br return b, chat } -// TestInboundOwnerPrivateFollowsMainSessionRole is design/22 §7-3: an owner -// private IM chat is mirrored into the main web session, so it must answer as the -// role that session is bound to — not as the global default and not as the -// account's channel-level role. +// TestInboundOwnerPrivateFollowsMainSessionRole 是 design/22 §7-3:主人 +// 私聊 IM 被镜像进 Web 主会话,因此它必须以该会话绑定的 +// 角色作答——既不是全局默认角色,也不是 +// 账号的渠道级角色。 func TestInboundOwnerPrivateFollowsMainSessionRole(t *testing.T) { t.Parallel() b, chat := personaBridge(t, "xilian", "yuki") @@ -79,15 +79,15 @@ func TestInboundOwnerPrivateFollowsMainSessionRole(t *testing.T) { if chat.env.PersonaID != "xilian" { t.Fatalf("resolved persona = %q, want the main session's binding (xilian)", chat.env.PersonaID) } - // The session binding wins, so the channel binding is never reached here. + // 会话绑定胜出,因此这里绝不会走到渠道绑定。 if got := b.personaOfAdapter("telegram"); got != "yuki" { t.Fatalf("cached channel persona = %q, want yuki (resolved at connect)", got) } } -// TestInboundChannelPersonaPrecedence pins the three-state precedence of design/22 -// §2: the channel-level binding applies only when neither the session nor anything -// stronger resolves a role. +// TestInboundChannelPersonaPrecedence 钉住 design/22 §2 的三态 +// 优先级:只有会话以及其他更强的来源都解析不出角色时, +// 渠道级绑定才生效。 func TestInboundChannelPersonaPrecedence(t *testing.T) { t.Parallel() cases := []struct { @@ -116,9 +116,9 @@ func TestInboundChannelPersonaPrecedence(t *testing.T) { } } -// TestInboundGroupForcesDefaultRole is design/22 §7-4: a group conversation — even -// the owner's own messages inside it — keeps the default role even when the account -// binds one, so the isolated group session can never be moved into a role's scope. +// TestInboundGroupForcesDefaultRole 是 design/22 §7-4:群聊——即使 +// 群里是主人自己的消息——即使账号绑定了角色也仍用默认 +// 角色,因此隔离的群会话绝不会被移入某个角色的作用域。 func TestInboundGroupForcesDefaultRole(t *testing.T) { t.Parallel() chat := &envChatter{} @@ -148,18 +148,18 @@ func TestInboundGroupForcesDefaultRole(t *testing.T) { if chat.env.PersonaID != "" { t.Fatalf("group conversation persona = %q, want the default role (empty)", chat.env.PersonaID) } - // The isolation boundary itself is untouched by role resolution. + // 隔离边界本身不受角色解析影响。 if chat.env.MemWritable { t.Fatalf("env = %+v, want the read-only restricted lane preserved", chat.env) } } -// TestResolvePersonaStrangerUsesDefaultRole covers the other restricted lane -// directly: a stranger's isolated private conversation is not the owner's mirror, -// so both the session binding and the channel binding are skipped (design/22 §7-4). -// The pipeline path is asserted for groups above; a stranger message only reaches -// the loop when the participation gate admits it (design/12 §6), which is -// orthogonal to role resolution and asserted elsewhere. +// TestResolvePersonaStrangerUsesDefaultRole 直接覆盖另一条受限通道: +// 陌生人的隔离私聊不是主人的镜像, +// 因此会话绑定与渠道绑定都被跳过(design/22 §7-4)。 +// 群聊的流水线路径已在上文断言;陌生人消息只有在参与度 +// 闸门放行时才会到达循环(design/12 §6),这与角色解析 +// 正交,已在别处断言。 func TestResolvePersonaStrangerUsesDefaultRole(t *testing.T) { t.Parallel() b, err := New(Options{ @@ -182,9 +182,9 @@ func TestResolvePersonaStrangerUsesDefaultRole(t *testing.T) { } } -// TestChannelPersonaLookupSplitsAccountName proves the account-level lookup: a -// multi-account platform resolves the role of the account that received the -// message (platform:confId), not of the platform as a whole. +// TestChannelPersonaLookupSplitsAccountName 证明查询是按账号的: +// 多账号平台解析的是接收到该消息的账号(platform:confId) +// 的角色,而不是整个平台的角色。 func TestChannelPersonaLookupSplitsAccountName(t *testing.T) { t.Parallel() var gotPlatform, gotAccount string @@ -219,8 +219,8 @@ func TestChannelPersonaLookupSplitsAccountName(t *testing.T) { } } -// TestRemovedAccountForgetsChannelPersona locks the reload contract: an account -// dropped by a reload must not keep answering as its old channel role. +// TestRemovedAccountForgetsChannelPersona 锁定重载约定:被 +// 重载移除的账号绝不能继续以它旧的渠道角色作答。 func TestRemovedAccountForgetsChannelPersona(t *testing.T) { t.Parallel() chat := &envChatter{} diff --git a/internal/services/channelbridge/proactive.go b/internal/services/channelbridge/proactive.go index 3575a0a..c3c9ee4 100644 --- a/internal/services/channelbridge/proactive.go +++ b/internal/services/channelbridge/proactive.go @@ -10,35 +10,35 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) -// proactiveTarget is the owner's IM address (platform + account id) resolved for -// a proactive push. The account id doubles as the DM peer on every adapter. +// proactiveTarget 是为一次主动推送解析出的主人 IM 地址 +// (平台 + 账号 id)。账号 id 在每个适配器上都兼作私聊对端。 type proactiveTarget struct { platform string accountID string } -// proactiveRecentCount is how many of the most recent main-session messages the -// proactive target selection scans to find "where the owner last talked", so a -// push lands on the channel she is actually using. Compile-time so no magic number -// appears in the run path. +// proactiveRecentCount 是主动推送目标选择扫描的最近主会话消息条数, +// 用于找出「主人最近在哪里说话」,使推送落在她 +// 实际在用的渠道上。编译期常量,运行路径上不会出现 +// 魔法数字。 const proactiveRecentCount = 20 -// SendProactive pushes msg to the owner's IM channel via the registered adapter -// for that platform. It is the backthink IMAdapter seam (design/12 §3 fallback) and -// delivers to exactly ONE owner channel, chosen by the single-target selection in -// proactiveTarget: her explicit [渠道:...] choice first, then the platform she last -// used, then the whitelist default (rotating so it is not always the same channel). -// WeChat is a first-class candidate (it is whitelisted and, when connected, the -// recent-active/fallback selection may pick it). +// SendProactive 通过该平台已注册的适配器把 msg 推送到主人的 IM 渠道。 +// 它是 backthink 的 IMAdapter 接缝(design/12 §3 回退), +// 且只投递到一个主人渠道,由 proactiveTarget 中的单目标选择 +// 决定:先看她显式的 [渠道:...] 选择,其次是她最近 +// 使用的平台,最后是白名单默认(轮转,以免总是同一个渠道)。 +// WeChat 是一等候选(它在白名单里,且连接后 +// 最近活跃/回退选择可能选中它)。 // -// The push exits through the shared IM bubble dispatcher (sendIMBubbles), so a -// multi-paragraph message is split into bubbles, role-play/narration is stripped, and -// sends are spaced 0.5-2s apart — the same behaviour as a reply, and the reason -// WeChat/other IM proactive pushes no longer arrive as one un-split

blob. +// 推送经共享的 IM 气泡派发器(sendIMBubbles)出口,因此 +// 多段消息会被拆成气泡、角色扮演/旁白被剥离、 +// 相邻发送间隔 0.5-2s ——与回复行为一致,也是 +// WeChat/其他 IM 主动推送不再以一整块未拆分的

到达的原因。 // -// When the owner profile has no whitelisted account that is backed by a connected -// adapter it returns a clear error so the caller records the reason rather than -// silently dropping the push. +// 当主人档案没有白名单账号、或该账号没有已连接的 +// 适配器支撑时,它返回明确错误,便于调用方记录原因, +// 而不是静默丢弃推送。 func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error { if err := ctx.Err(); err != nil { return err @@ -54,44 +54,44 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) out := msg out.Platform = target.platform out.ChannelType = adapter.ChannelDirect - // The owner's account id is the DM peer on every adapter: telegram/qq read - // ChannelID, kook/weixin read SenderID (with ChannelID as the fallback). Setting - // both keeps a direct message targetable regardless of which field the adapter - // prefers, without duplicating the message. + // 主人的账号 id 在每个适配器上都作私聊对端:telegram/qq 读 + // ChannelID,kook/weixin 读 SenderID(ChannelID 作回退)。两者 + // 都设置,无论适配器偏好哪个字段,私聊消息都可投达, + // 且不会重复发送。 out.ChannelID = target.accountID out.SenderID = target.accountID - // The push exits through the shared IM bubble dispatcher, so a multi-paragraph - // proactive message is split into bubbles and role-play/narration is stripped, with - // the same 0.5-2s gap as a reply — WeChat proactive pushes are no longer sent as a - // single un-split message. + // 推送经共享的 IM 气泡派发器出口,因此多段主动消息 + // 会被拆成气泡、角色扮演/旁白被剥离,并采用 + // 与回复相同的 0.5-2s 间隔——WeChat 主动推送不再作为 + // 一整条未拆分消息发送。 if err := b.sendIMBubbles(ctx, out, func(ctx context.Context, msg adapter.UnifiedMessage) error { return ad.SendProactive(ctx, msg) }, msg.Content); err != nil { return fmt.Errorf("channelbridge: send proactive via %q: %w", target.platform, err) } - // Mirror the proactive message into the main conversation context (design/16 - // §4.4 + §4.6.1). Without this the model would not see what it proactively said, so - // a follow-up owner reply would feel incoherent (the message "loses" its history). - // It is the same mirror path as owner-private IM replies, scoped to MainSessionID. + // 把主动消息镜像进主对话上下文(design/16 + // §4.4 + §4.6.1)。否则模型看不到自己主动说了什么, + // 主人后续回复就会显得不连贯(该消息「丢失」了历史)。 + // 它与主人私聊 IM 回复走同一条镜像路径,作用域为 MainSessionID。 b.persistToMain(ctx, history.RoleAssistant, msg.Content, "", target.platform, fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID), target.platform) return nil } -// proactiveTarget resolves the single owner account a proactive push should reach. -// The range is every whitelisted owner channel that is backed by a connected -// adapter (WeChat included), and the choice follows the backthink priority: +// proactiveTarget 解析主动推送应当到达的唯一主人账号。 +// 候选范围是每个有已连接适配器支撑的白名单主人渠道 +// (含 WeChat),选择遵循 backthink 的优先级: // -// 1. A concrete msg.Platform (her [渠道:...] choice) that is whitelisted AND -// connected selects that channel. -// 2. The channel she most recently used in the main session (an owner/dialog -// message on a whitelisted, connected platform) is preferred. -// 3. Otherwise the platform that the whitelist default selects, rotating from a -// cursor so the fallback is not always the same channel (e.g. always kook). +// 1. 具体 msg.Platform(她显式的 [渠道:...] 选择)既在白名单 +// 又已连接时,选中该渠道。 +// 2. 优先选她在主会话中最近使用的渠道(白名单、 +// 已连接平台上的主人/对话消息)。 +// 3. 否则取白名单默认选中的平台,并按游标轮转, +// 使回退不会总是同一个渠道(例如总是 kook)。 // -// A nil owner or an empty whitelist yields !ok, as does a whitelist with no -// connected adapter. +// 主人为 nil、白名单为空,或白名单中没有任何 +// 已连接适配器时,返回 !ok。 func (b *Bridge) proactiveTarget(ctx context.Context, msg adapter.UnifiedMessage) (proactiveTarget, bool) { accounts := b.ownerAccounts() if len(accounts) == 0 { @@ -116,8 +116,8 @@ func (b *Bridge) proactiveTarget(ctx context.Context, msg adapter.UnifiedMessage return proactiveTarget{}, false } -// ownerAccounts returns the owner's whitelisted accounts that carry an account id, -// in whitelist order. A nil owner or an empty whitelist yields nil. +// ownerAccounts 按白名单顺序返回主人带账号 id 的白名单账号。 +// 主人为 nil 或白名单为空时返回 nil。 func (b *Bridge) ownerAccounts() []user.OwnerAccount { if b.owner == nil { return nil @@ -134,8 +134,8 @@ func (b *Bridge) ownerAccounts() []user.OwnerAccount { return out } -// matchOwnerAccount returns the owner account whose platform equals platform, -// case-insensitively. +// matchOwnerAccount 返回平台与 platform 相等(忽略大小写)的 +// 主人账号。 func matchOwnerAccount(accounts []user.OwnerAccount, platform string) (user.OwnerAccount, bool) { for _, a := range accounts { if strings.EqualFold(a.Platform, platform) { @@ -145,10 +145,10 @@ func matchOwnerAccount(accounts []user.OwnerAccount, platform string) (user.Owne return user.OwnerAccount{}, false } -// recentActiveAccount returns the owner account for the most recent main-session -// message whose platform is a whitelisted, connected owner channel — the "she is -// active there" signal. A nil history store, a failed read or an empty tail yields -// ok=false so selection falls through to the default rather than guessing. +// recentActiveAccount 返回最近一条主会话消息所对应的主人账号, +// 该消息的平台须是白名单且已连接的主人渠道——即「她在 +// 那里活跃」的信号。历史存储为 nil、读取失败或尾部为空时 +// 返回 ok=false,使选择退到默认值而不是瞎猜。 func (b *Bridge) recentActiveAccount(ctx context.Context, accounts []user.OwnerAccount) (user.OwnerAccount, bool) { if b.history == nil { return user.OwnerAccount{}, false @@ -157,7 +157,7 @@ func (b *Bridge) recentActiveAccount(ctx context.Context, accounts []user.OwnerA if err != nil || len(recent) == 0 { return user.OwnerAccount{}, false } - // Recent is oldest-first; walk newest-first so the most recent channel wins. + // Recent 是最旧在前;从最新往回走,使最近的渠道胜出。 for i := len(recent) - 1; i >= 0; i-- { p := strings.ToLower(strings.TrimSpace(recent[i].Platform)) if p == "" || isGenericProactivePlatform(p) { @@ -170,10 +170,10 @@ func (b *Bridge) recentActiveAccount(ctx context.Context, accounts []user.OwnerA return user.OwnerAccount{}, false } -// nextConnectedAccount returns the next whitelisted owner account with a connected -// adapter, advancing a cursor so the no-signal fallback rotates instead of always -// landing on the first whitelisted channel (e.g. always kook). It holds the bridge -// lock to read the adapter map and update the cursor atomically. +// nextConnectedAccount 返回下一个有已连接适配器的白名单主人账号, +// 并推进游标,使无信号回退会轮转,而不是总落在 +// 第一个白名单渠道(例如总是 kook)。它持有桥接器 +// 锁,以原子地读取适配器 map 并更新游标。 func (b *Bridge) nextConnectedAccount(accounts []user.OwnerAccount) (user.OwnerAccount, bool) { b.mu.Lock() defer b.mu.Unlock() @@ -189,17 +189,17 @@ func (b *Bridge) nextConnectedAccount(accounts []user.OwnerAccount) (user.OwnerA return user.OwnerAccount{}, false } -// hasConnectedAdapter reports whether a registered adapter with this base platform -// is currently connected, so a channel is only ever selected when it can actually -// deliver a message. +// hasConnectedAdapter 报告该基础平台是否有已注册且当前已连接的 +// 适配器,因此只有真能投递消息的渠道才会 +// 被选中。 func (b *Bridge) hasConnectedAdapter(platform string) bool { b.mu.RLock() defer b.mu.RUnlock() return b.hasConnectedAdapterLocked(platform) } -// hasConnectedAdapterLocked is hasConnectedAdapter without taking the lock; the -// caller must hold b.mu (read or write). +// hasConnectedAdapterLocked 是不加锁版本的 hasConnectedAdapter; +// 调用方须持有 b.mu(读锁或写锁)。 func (b *Bridge) hasConnectedAdapterLocked(platform string) bool { for name, a := range b.adapters { if strings.EqualFold(basePlatform(name), platform) && a.IsConnected() { @@ -209,11 +209,11 @@ func (b *Bridge) hasConnectedAdapterLocked(platform string) bool { return false } -// isGenericProactivePlatform reports whether platform is a routing keyword rather -// than a concrete channel id. Only these fall through to the recent-active / default -// selection; a concrete id must resolve to a whitelisted, connected channel before -// it is honored, so an explicit channel choice is never silently replaced by a -// different channel. +// isGenericProactivePlatform 报告 platform 是路由关键词还是 +// 具体渠道 id。只有关键词才会退到最近活跃 / 默认 +// 选择;具体 id 必须先解析为白名单且已连接的渠道 +// 才被采纳,因此显式的渠道选择绝不会被静默替换成 +// 另一个渠道。 func isGenericProactivePlatform(platform string) bool { switch platform { case "", "cyrene-backthink": @@ -223,10 +223,10 @@ func isGenericProactivePlatform(platform string) bool { } } -// adapterForPlatform returns a registered adapter whose base platform matches -// platform (multi-account names like "kook:3be..." resolve to their bare base), -// preferring a connected one so a dead account is only tried when it is the only -// option. It returns nil when none is registered. +// adapterForPlatform 返回基础平台与 platform 匹配的已注册适配器 +// (多账号名如 "kook:3be..." 会归为其裸基础名), +// 优先取已连接的,使失效账号只在别无选择时才被尝试。 +// 没有已注册的匹配项时返回 nil。 func (b *Bridge) adapterForPlatform(platform string) adapter.PlatformAdapter { b.mu.RLock() defer b.mu.RUnlock() diff --git a/internal/services/channelbridge/proactive_test.go b/internal/services/channelbridge/proactive_test.go index e890f08..f3ca971 100644 --- a/internal/services/channelbridge/proactive_test.go +++ b/internal/services/channelbridge/proactive_test.go @@ -18,8 +18,8 @@ func newProactiveBridge(t *testing.T, owner *user.OwnerProfile, ads ...*fakeAdap return newProactiveBridgeHist(t, owner, nil, ads...) } -// newProactiveBridgeHist is newProactiveBridge with a history store injected, so a -// test can supply a recent window for the "she was last active here" selection. +// newProactiveBridgeHist 是注入了历史存储的 newProactiveBridge,便于 +// 测试为「她最近在哪里活跃」的选择供给近期窗口。 func newProactiveBridgeHist(t *testing.T, owner *user.OwnerProfile, hist history.HistoryStore, ads ...*fakeAdapter) *Bridge { t.Helper() chat := &stubChatter{res: &loop.Result{Text: "hi"}} @@ -36,8 +36,8 @@ func newProactiveBridgeHist(t *testing.T, owner *user.OwnerProfile, hist history return b } -// proactiveMsg builds a minimal autonomous proactive message as backthink's -// buildProactiveMessage would for a no-channel decision (the generic sentinel). +// proactiveMsg 构建一条最小化的自主主动消息,等同于 backthink 的 +// buildProactiveMessage 在无渠道决策(通用哨兵值)时的产物。 func proactiveMsg(content string) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "cyrene-backthink", @@ -48,9 +48,9 @@ func proactiveMsg(content string) adapter.UnifiedMessage { } } -// TestBridge_SendProactive_OwnerChannel verifies a proactive push with no signal -// falls back to a whitelisted owner channel: the adapter for the owner's platform -// is selected and the owner account id is threaded as the DM peer. +// TestBridge_SendProactive_OwnerChannel 验证无信号的主动推送 +// 回退到白名单主人渠道:选中主人平台对应的适配器, +// 并把主人账号 id 作为私聊对端串入。 func TestBridge_SendProactive_OwnerChannel(t *testing.T) { ad := &fakeAdapter{name: "telegram", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}} @@ -77,9 +77,9 @@ func TestBridge_SendProactive_OwnerChannel(t *testing.T) { } } -// TestBridge_SendProactive_PlatformHintSelectsAccount verifies msg.Platform -// selects the matching owner account when one exists and is connected, rather -// than defaulting to another channel. +// TestBridge_SendProactive_PlatformHintSelectsAccount 验证 msg.Platform +// 在存在且已连接匹配的主人账号时选中它, +// 而不是默认落到另一个渠道。 func TestBridge_SendProactive_PlatformHintSelectsAccount(t *testing.T) { ad := &fakeAdapter{name: "kook", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{ @@ -102,9 +102,9 @@ func TestBridge_SendProactive_PlatformHintSelectsAccount(t *testing.T) { } } -// TestBridge_SendProactive_DecisionHintPriority verifies a concrete [渠道:...] -// decision beats the recent-active platform: even though she last used WeChat, an -// explicit kook choice is honored (and still only one channel is used). +// TestBridge_SendProactive_DecisionHintPriority 验证具体的 [渠道:...] +// 决策优先于最近活跃平台:即使她上次用的是 WeChat, +// 显式的 kook 选择仍被采纳(且仍只用一个渠道)。 func TestBridge_SendProactive_DecisionHintPriority(t *testing.T) { kook := &fakeAdapter{name: "kook", connected: true} weixin := &fakeAdapter{name: "weixin-ilink", connected: true} @@ -132,9 +132,9 @@ func TestBridge_SendProactive_DecisionHintPriority(t *testing.T) { } } -// TestBridge_SendProactive_RecentActivePlatform verifies a no-decision push lands -// on the channel she used most recently, skipping the web session (web is not a -// whitelisted IM account) and honoring a whitelisted, connected IM channel. +// TestBridge_SendProactive_RecentActivePlatform 验证无决策的推送 +// 落在她最近使用的渠道上,跳过 Web 会话(Web 不是 +// 白名单 IM 账号),并采用白名单且已连接的 IM 渠道。 func TestBridge_SendProactive_RecentActivePlatform(t *testing.T) { kook := &fakeAdapter{name: "kook", connected: true} weixin := &fakeAdapter{name: "weixin-ilink", connected: true} @@ -142,9 +142,9 @@ func TestBridge_SendProactive_RecentActivePlatform(t *testing.T) { {Platform: "kook", AccountID: "329284282"}, {Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}, }} - // Recent is oldest-first: the newest message is the web session, then WeChat, - // then kook. The web session is skipped (not whitelisted), so the most recent - // whitelisted, connected channel — WeChat — is chosen. + // Recent 是最旧在前:最新消息来自 Web 会话,然后是 WeChat, + // 再是 kook。Web 会话被跳过(不在白名单),因此选中 + // 最近的白名单已连接渠道——WeChat。 hist := &contextHistory{recent: []history.Message{ {Role: history.RoleUser, Platform: "kook", Content: "一早聊过", CreatedAt: 1}, {Role: history.RoleUser, Platform: "weixin-ilink", Content: "微信聊的", CreatedAt: 2}, @@ -166,9 +166,9 @@ func TestBridge_SendProactive_RecentActivePlatform(t *testing.T) { } } -// TestBridge_SendProactive_WeChatCanBeSelected verifies WeChat is a first-class -// proactive target: with a WeChat account whitelisted and its adapter connected, a -// no-signal push lands on it rather than always defaulting to kook. +// TestBridge_SendProactive_WeChatCanBeSelected 验证 WeChat 是一等的 +// 主动推送目标:WeChat 账号在白名单且适配器已连接时, +// 无信号推送落在它上面,而不是总默认到 kook。 func TestBridge_SendProactive_WeChatCanBeSelected(t *testing.T) { weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}}} @@ -188,9 +188,9 @@ func TestBridge_SendProactive_WeChatCanBeSelected(t *testing.T) { } } -// TestBridge_SendProactive_NoSignalFallbackRotates verifies the no-signal fallback -// rotates across connected whitelisted channels (instead of always kook), and that -// each push reaches exactly one channel. +// TestBridge_SendProactive_NoSignalFallbackRotates 验证无信号回退 +// 会在已连接的白名单渠道间轮转(而不是总是 kook),且 +// 每次推送只到达一个渠道。 func TestBridge_SendProactive_NoSignalFallbackRotates(t *testing.T) { kook := &fakeAdapter{name: "kook", connected: true} weixin := &fakeAdapter{name: "weixin-ilink", connected: true} @@ -219,17 +219,17 @@ func TestBridge_SendProactive_NoSignalFallbackRotates(t *testing.T) { } } -// TestBridge_SendProactive_HintNotWhitelisted_FallsBackToWeChat verifies a concrete -// decision channel that is NOT whitelisted degrades to a whitelisted, connected -// owner channel rather than erroring or landing on a non-owner channel. This is -// how WeChat becomes reachable even though the old default never considered it. +// TestBridge_SendProactive_HintNotWhitelisted_FallsBackToWeChat 验证 +// 不在白名单的具体决策渠道会退化为白名单且已连接的 +// 主人渠道,而不是报错或落到非主人渠道。这正是 +// WeChat 变得可达的方式,尽管旧的默认逻辑从不考虑它。 func TestBridge_SendProactive_HintNotWhitelisted_FallsBackToWeChat(t *testing.T) { weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}}} b := newProactiveBridge(t, owner, weixin) msg := proactiveMsg("记得喝水哦") - msg.Platform = "kook" // kook is not whitelisted for this owner + msg.Platform = "kook" // kook 不在该主人的白名单里 if err := b.SendProactive(context.Background(), msg); err != nil { t.Fatalf("SendProactive err = %v (a non-whitelisted hint should fall back, not error)", err) } @@ -241,8 +241,8 @@ func TestBridge_SendProactive_HintNotWhitelisted_FallsBackToWeChat(t *testing.T) } } -// TestBridge_SendProactive_NoOwnerChannel verifies a clear error when the owner -// profile has no whitelisted account, so the caller records the reason. +// TestBridge_SendProactive_NoOwnerChannel 验证主人档案没有 +// 白名单账号时返回明确错误,便于调用方记录原因。 func TestBridge_SendProactive_NoOwnerChannel(t *testing.T) { b := newProactiveBridge(t, &user.OwnerProfile{}) @@ -255,8 +255,8 @@ func TestBridge_SendProactive_NoOwnerChannel(t *testing.T) { } } -// TestBridge_SendProactive_NoAdapter verifies the error when the owner has a -// whitelisted channel but no adapter is registered/connected for it. +// TestBridge_SendProactive_NoAdapter 验证主人有白名单渠道、 +// 但该渠道没有已注册/已连接适配器时报错。 func TestBridge_SendProactive_NoAdapter(t *testing.T) { owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "329284282"}}} b := newProactiveBridge(t, owner) @@ -270,8 +270,8 @@ func TestBridge_SendProactive_NoAdapter(t *testing.T) { } } -// TestBridge_SendProactive_AdapterError verifies an adapter send failure surfaces -// as a wrapped error with the platform (so the record/detail is honest). +// TestBridge_SendProactive_AdapterError 验证适配器发送失败会以 +// 带平台的包装错误暴露(使记录/详情如实)。 func TestBridge_SendProactive_AdapterError(t *testing.T) { ad := &fakeAdapter{name: "kook", connected: true, sendErr: errors.New("kook disconnected")} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "329284282"}}} diff --git a/internal/services/channelbridge/readloop_test.go b/internal/services/channelbridge/readloop_test.go index 22e667f..fff60ed 100644 --- a/internal/services/channelbridge/readloop_test.go +++ b/internal/services/channelbridge/readloop_test.go @@ -9,10 +9,10 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// deadSessionAdapter mimics a channel whose session dies while the bridge is -// running — today the iLink session rejection (errcode=-14): Connect -// reports success, the read loop then returns on its own (nothing cancelled it) -// and IsConnected reports false from that point on. +// deadSessionAdapter 模拟桥接器运行期间会话死掉的渠道 +// ——目前就是 iLink 会话被拒(errcode=-14):Connect +// 报告成功,随后读取循环自行返回(没有任何东西取消它), +// 从那一刻起 IsConnected 报告 false。 type deadSessionAdapter struct { name string mu sync.Mutex @@ -40,7 +40,7 @@ func (a *deadSessionAdapter) IsConnected() bool { } func (a *deadSessionAdapter) HealthCheck(context.Context) error { return adapter.ErrNotConnected } func (a *deadSessionAdapter) ReadMessages(context.Context, func(adapter.UnifiedMessage)) { - // The session died: the read loop ends by itself, no cancellation involved. + // 会话已死:读取循环自行结束,没有任何取消参与其中。 a.mu.Lock() a.up = false a.mu.Unlock() @@ -59,8 +59,8 @@ func (a *deadSessionAdapter) ListChannels(context.Context) ([]adapter.ChannelInf return nil, nil } -// signaledAdapter is a fakeAdapter whose read loop reports when it has returned, -// so a test can wait for a replaced adapter's exit deterministically. +// signaledAdapter 是一个 fakeAdapter,其读取循环在返回时发出信号, +// 使测试能确定性地等待被替换适配器的退出。 type signaledAdapter struct { *fakeAdapter exited chan struct{} @@ -71,10 +71,10 @@ func (a *signaledAdapter) ReadMessages(ctx context.Context, _ func(adapter.Unifi close(a.exited) } -// TestReadLoopExitClearsConnectedFlag is the regression for "the adapter goes mute -// while the bridge still claims it is connected": the read loop ends with the -// session down and nothing cancelled it, so IsConnected/ConnectedCount must stop -// reporting a live channel. +// TestReadLoopExitClearsConnectedFlag 是针对「适配器已哑掉、桥接器 +// 却仍声称它已连接」的回归测试:读取循环在会话中断、 +// 无人取消的情况下结束,因此 IsConnected/ConnectedCount 必须停止 +// 报告该渠道存活。 func TestReadLoopExitClearsConnectedFlag(t *testing.T) { t.Parallel() b := newReloadBridge(t, nil) @@ -95,10 +95,10 @@ func TestReadLoopExitClearsConnectedFlag(t *testing.T) { t.Fatal("bridge still reports weixin-ilink as connected after its read loop ended") } -// TestReplacedAdapterExitKeepsReplacementConnected guards the other direction: the -// read-loop exit accounting must not undo a replacement installed by a hot reload. -// The replaced adapter's loop is cancelled by Reload (a deliberate stop), so its -// exit says nothing about the adapter that now owns the name. +// TestReplacedAdapterExitKeepsReplacementConnected 守住另一方向: +// 读取循环退出统计绝不能撤销热重载装入的替换适配器。 +// 被替换适配器的循环由 Reload 取消(刻意停止),因此它的 +// 退出对如今占用该名称的适配器毫无说明力。 func TestReplacedAdapterExitKeepsReplacementConnected(t *testing.T) { t.Parallel() old := &signaledAdapter{ @@ -116,8 +116,8 @@ func TestReplacedAdapterExitKeepsReplacementConnected(t *testing.T) { t.Fatalf("Reload: %v", err) } <-old.exited - // The exit accounting runs immediately after the read path returns; a short - // settle window catches it without relying on a specific interleaving. + // 退出统计在读取路径返回后立即运行;短促的 + // 静置窗口即可捕获,无需依赖特定的交错时序。 settle := time.Now().Add(50 * time.Millisecond) for time.Now().Before(settle) { if !b.IsConnected("kook:conf0") { diff --git a/internal/services/channelbridge/reload.go b/internal/services/channelbridge/reload.go index 501b78b..d174f0c 100644 --- a/internal/services/channelbridge/reload.go +++ b/internal/services/channelbridge/reload.go @@ -1,12 +1,12 @@ package channelbridge -// This file holds the hot-reload machinery for the channel bridge (design/04 -// §3.1). The panel saves a platform's account config to channels.json and calls -// Bridge.Reload(platform): the factory rebuilds that platform's adapters, they -// connect first (new messages use the new config), then the old adapters are -// gracefully stopped. A message already in flight finishes on its old adapter -// because processing is spawned with the bridge-lifetime context, not the -// per-adapter read context that reload cancels. +// 本文件承载渠道桥接器的热重载机制(design/04 +// §3.1)。面板把某平台的账号配置保存到 channels.json 并调用 +// Bridge.Reload(platform):工厂重建该平台的适配器,它们 +// 先连接(新消息使用新配置),随后旧适配器 +// 被优雅停止。已在途的消息仍在其旧适配器上完成, +// 因为消息处理是以桥接器生命周期 context 派生的,而非 +// 重载所取消的按适配器读取 context。 import ( "context" @@ -16,29 +16,29 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// PlatformFactory rebuilds the adapters for a platform from its current effective -// account config (channels.json, falling back to env). It is injected so the -// bridge can hot-replace a platform's adapters when the panel saves a new config -// without a restart. The concrete implementation lives in the composition root -// (cmd/cyrene), which owns the platform adapter constructors. +// PlatformFactory 依据平台当前生效的账号配置 +// (channels.json,回退到环境变量)重建其适配器。它以注入方式提供, +// 使桥接器能在面板保存新配置时热替换该平台的适配器 +// 而无需重启。具体实现位于组合根 +// (cmd/cyrene),平台适配器的构造函数由那里持有。 type PlatformFactory interface { - // Build returns the adapters for platform from its effective account config. - // The returned slice may be empty when the platform has no configured account. + // Build 依据生效的账号配置返回该平台的适配器。 + // 平台没有已配置账号时,返回的切片可能为空。 Build(platform string) ([]adapter.PlatformAdapter, error) } -// ErrNilFactory is returned when a reload is requested without a platform factory -// injected (no hot-reload configuration source). +// ErrNilFactory 表示请求重载时未注入平台工厂 +// (没有热重载配置来源)。 var ErrNilFactory = errors.New("channelbridge: nil platform factory") -// ErrBridgeClosed is returned when a reload is requested on a stopped bridge. +// ErrBridgeClosed 表示在已停止的桥接器上请求重载。 var ErrBridgeClosed = errors.New("channelbridge: bridge is not running") -// connectAndRun dials a single adapter (bound by connectTimeout) and, on success, -// installs its inbound handler and launches its read loop under a per-adapter -// context. The per-adapter context is what a hot reload cancels to stop the old -// read loop; message processing is spawned with the bridge-lifetime context so an -// in-flight message started before a Reload completes on its old adapter. +// connectAndRun 拨号单个适配器(受 connectTimeout 约束),成功时 +// 装配其入站处理器,并在按适配器 context 下启动其读取 +// 循环。热重载正是取消这个按适配器 context 来停止旧 +// 读取循环;消息处理则以桥接器生命周期 context 派生, +// 因此 Reload 之前已在途的消息会在其旧适配器上完成。 func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) { if a == nil { return @@ -56,9 +56,9 @@ func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) { return } - // Close over the adapter so the inbound callback knows which account received - // the message — required for multi-account reply routing and for reading that - // account's allow/deny lists (design/16 §4.6 §5). + // 闭包捕获该适配器,使入站回调知道是哪个账号收到了 + // 消息——多账号回复路由,以及读取该账号的 + // 允许/拒绝名单都依赖它(design/16 §4.6 §5)。 a.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) { b.onMessage(b.ctx, msg, a) }) @@ -74,21 +74,21 @@ func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) { b.readCancels[a.Name()] = rcancel b.connected[a.Name()] = true b.mu.Unlock() - // Cache the account's channel-level role now that the adapter is live: the - // inbound path reads the cache instead of the config, and a reload (which the - // panel's save triggers) re-runs this to pick up a new binding. + // 适配器已上线,此时缓存该账号的渠道级角色: + // 入站路径读取缓存而不是配置,而重载(由面板保存 + // 触发)会重跑这里以拾取新的绑定。 b.rememberAdapterPersona(a.Name()) b.wg.Add(1) go b.readLoop(a, readCtx) b.log.Info("channel connected", "channel", a.Name()) } -// Reload rebuilds the adapters for a platform from its current effective config -// (via the injected factory) and hot-replaces the running ones without a restart -// (design/04 §3.1). The rebuilt adapters connect first; messages that arrived on -// the old adapters finish on them, and new messages are picked up by the new -// configuration. An account that was removed stops its old adapter, and an -// unchanged-name account stops the old adapter once the new one is live. +// Reload 依据平台当前生效的配置(经由注入的工厂)重建其适配器, +// 并在不重启的前提下热替换正在运行的适配器 +// (design/04 §3.1)。重建后的适配器先连接;到达旧适配器的 +// 消息在旧适配器上完成,新消息则由新配置 +// 接管。被移除的账号会停掉其旧适配器;名称未变的 +// 账号在新适配器上线后停掉旧适配器。 func (b *Bridge) Reload(platform string) error { if b.factory == nil { return ErrNilFactory @@ -115,22 +115,22 @@ func (b *Bridge) Reload(platform string) error { } for _, o := range old { if newNames[o.Name()] { - // Same-name account replaced by the rebuilt adapter: stop the old read - // loop but leave the registry entry — it now points at the new adapter. + // 同名账号被重建后的适配器替换:停止旧的读取 + // 循环,但保留注册表条目——它现在指向新适配器。 b.cancelAndDisconnect(o, oldCancels[o.Name()]) continue } - // Account removed (or renamed) entirely: stop it and drop its registry - // entries, so a subsequent snapshot no longer sees it. + // 账号被彻底移除(或更名):停止它并删除其注册表 + // 条目,后续 snapshot 便不再看到它。 b.removeAdapter(o, oldCancels[o.Name()]) } b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters)) return nil } -// adapterSnap returns the adapters currently registered for a platform, together -// with the context-cancel func that controls each one's read loop. It is used by -// Reload to stop the old adapters after the rebuilt ones are live. +// adapterSnap 返回某平台当前已注册的适配器,以及 +// 控制各自读取循环的 context 取消函数。Reload 用它 +// 在重建的适配器上线后停止旧适配器。 func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[string]context.CancelFunc) { b.mu.RLock() defer b.mu.RUnlock() @@ -148,10 +148,10 @@ func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[st return list, cancels } -// cancelAndDisconnect stops an adapter's read loop and tears down its session. -// It never touches the registry map: callers that replace a same-name adapter -// want the (new) entry to stay, and callers that remove an account use -// removeAdapter. +// cancelAndDisconnect 停止适配器的读取循环并拆除其会话。 +// 它绝不改动注册表 map:替换同名适配器的调用方 +// 希望(新)条目保留,而移除账号的调用方使用 +// removeAdapter。 func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) { if c != nil { c() @@ -162,9 +162,9 @@ func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.Cancel b.log.Info("channel (reload) stopped", "channel", o.Name()) } -// removeAdapter stops an account's adapter and drops its registry entries. It is -// only called for names that were NOT rebuilt by Reload, so the delete can never -// remove a replacement adapter that now owns the name. +// removeAdapter 停止账号的适配器并删除其注册表条目。它 +// 只对未被 Reload 重建的名称调用,因此删除绝不会 +// 误删如今占用该名称的替换适配器。 func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc) { b.cancelAndDisconnect(o, c) name := o.Name() diff --git a/internal/services/channelbridge/reload_test.go b/internal/services/channelbridge/reload_test.go index 0e50397..f395679 100644 --- a/internal/services/channelbridge/reload_test.go +++ b/internal/services/channelbridge/reload_test.go @@ -13,8 +13,8 @@ import ( var errReload = errors.New("build failed") -// stubFactory returns a fixed adapter set for a platform, so a reload test can -// drive the hot-replace without real network I/O. +// stubFactory 返回某平台的固定适配器集合,使重载测试 +// 无需真实网络 I/O 即可驱动热替换。 type stubFactory struct { adpts []adapter.PlatformAdapter err error @@ -65,14 +65,14 @@ func TestReloadReplacesAdapterWithNewConfig(t *testing.T) { t.Fatalf("Reload: %v", err) } - // The new adapters are connected and registered. + // 新适配器已连接并注册。 if !newConf0.IsConnected() || !newConf1.IsConnected() { t.Fatalf("new adapters not connected: conf0=%v conf1=%v", newConf0.IsConnected(), newConf1.IsConnected()) } if !b.IsConnected("kook:conf0") || !b.IsConnected("kook:conf1") { t.Fatalf("bridge does not report new adapters connected") } - // The old adapter instance was disconnected (its read loop stopped). + // 旧适配器实例已断开(其读取循环已停止)。 if old.IsConnected() { t.Fatal("old adapter should be disconnected after reload") } @@ -88,7 +88,7 @@ func TestReloadRemovesRemovedAccount(t *testing.T) { b.Add(conf1) b.Start() - // New config only keeps conf1 (conf0 is deleted). + // 新配置只保留 conf1(conf0 被删除)。 factory.adpts = []adapter.PlatformAdapter{ &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}, } diff --git a/internal/services/channelbridge/sanitize.go b/internal/services/channelbridge/sanitize.go index 0fcb2ee..3d00625 100644 --- a/internal/services/channelbridge/sanitize.go +++ b/internal/services/channelbridge/sanitize.go @@ -8,42 +8,42 @@ import ( "unicode/utf8" ) -// This file holds the chat-reply sanitizer used before a reply is delivered to an -// IM channel adapter (design/17 §2 + the role-play convention: the Web client -// renders / as muted italic; IM channels must receive plain chat -// text only, with the action/monologue markup removed). +// 本文件承载聊天回复清洗器,用于在回复投递给 +// IM 渠道适配器之前(design/17 §2 + 角色扮演约定:Web 客户端 +// 把 / 渲染为暗色斜体;IM 渠道只能收到纯聊天 +// 文本,动作/独白标记必须移除)。 var ( - // pairedRoleplayRe matches a complete ... / ... - // block including its inner text, so an action/monologue is dropped wholesale before - // IM delivery. Non-greedy and case-insensitive to tolerate a model that emits . + // pairedRoleplayRe 匹配完整的 ... / ... + // 块(含内部文本),因此动作/独白会在投递给 + // IM 前被整体丢弃。非贪婪且不区分大小写,以容忍模型写出 。 pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?`) - // strayRoleplayRe matches any remaining single opening/closing role-play tag (for a - // malformed or unpaired emission). + // strayRoleplayRe 匹配任何残留的单个角色扮演开/闭标签 + // (用于格式错误或未配对的情况)。 strayRoleplayRe = regexp.MustCompile(`(?is)]*?>`) - // paragraphRe matches

...

/

paragraph markers, keeping the inner text. + // paragraphRe 匹配

...

/

段落标记,保留内部文本。 paragraphRe = regexp.MustCompile(`(?is)]*?>`) - // blankLinesRe collapses runs of blank lines left behind after stripping. + // blankLinesRe 把剥离后残留的连续空行折叠掉。 blankLinesRe = regexp.MustCompile(`\n\s*\n+`) - // starRoleplayRe matches a full-width star-wrapped block *...* — the drift - // form when the model forgets the / XML tags. The body is - // captured so a non-role-play full-width emphasis can be left alone. + // starRoleplayRe 匹配全角星号包裹块 *...* ——这是模型忘记 + // / XML 标签时的漂移形式。正文被 + // 捕获,以便非角色扮演的全角强调可以原样保留。 starRoleplayRe = regexp.MustCompile(`(?s)*([\s\S]*?)*`) - // halfStarRoleplayRe matches a half-width star block that clearly carries a - // role-play keyword (动作/旁白/内心/神态/轻声), so *动作:弯起眼睛* is stripped - // while plain markdown *emphasis* is left untouched. + // halfStarRoleplayRe 匹配明确带有角色扮演 + // 关键词(动作/旁白/内心/神态/轻声)的半角星号块,因此 *动作:弯起眼睛* 会被剥离, + // 而普通的 markdown *emphasis* 保持不动。 halfStarRoleplayRe = regexp.MustCompile(`(?s)\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*`) ) -// shortRoleplayRunes caps how long a keyword-less full-width star block may be -// and still be treated as a role-play remark (design §9.5: conservative — a long -// keyword-less *...* paragraph is left as text rather than dropped wholesale). +// shortRoleplayRunes 限定无关键词的全角星号块最长可以是多少 +// 仍算作角色扮演批注(design §9.5:保守起见——长的 +// 无关键词 *...* 段落保留为文本,而不整体丢弃)。 const shortRoleplayRunes = 40 -// stripNarration returns the plain chat text of a reply with role-play/action/ -// narration markup removed, paragraph markers flattened and blank-line runs -// collapsed. It never raises or panics on malformed input; a reply that was only an -// action/monologue becomes an empty string (caller may then skip the send). +// stripNarration 返回去掉角色扮演/动作/ +// 旁白标记、段落标记被抹平、连续空行被折叠后的 +// 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的 +// 回复会变成空字符串(调用方可据此跳过发送)。 func stripNarration(text string) string { if strings.TrimSpace(text) == "" { return "" @@ -62,10 +62,10 @@ func stripNarration(text string) string { return strings.TrimSpace(s) } -// isStarRoleplay reports whether a full-width star block *...* holds a role-play -// remark: it carries a role-play keyword, or it is a short stand-alone fragment -// (no newline, no long body) — the drift shape *弯起眼睛*. A long keyword-less -// paragraph wrapped in stars is kept so a real message is not dropped. +// isStarRoleplay 报告全角星号块 *...* 是否承载角色扮演 +// 批注:它带角色扮演关键词,或者是短的独立片段 +// (无换行、无长正文)——即漂移形态 *弯起眼睛*。长的无关键词 +// 星号包裹段落会保留,以免丢掉真实消息。 func isStarRoleplay(inner string) bool { t := strings.TrimSpace(inner) if t == "" { @@ -77,7 +77,7 @@ func isStarRoleplay(inner string) bool { return !strings.Contains(t, "\n") && utf8.RuneCountInString(t) <= shortRoleplayRunes } -// hasRoleplayPrefix reports whether the block starts with a role-play keyword. +// hasRoleplayPrefix 报告该块是否以角色扮演关键词开头。 func hasRoleplayPrefix(t string) bool { for _, p := range []string{"动作", "旁白", "内心", "神态", "轻声"} { if strings.HasPrefix(t, p) { @@ -87,15 +87,15 @@ func hasRoleplayPrefix(t string) bool { return false } -// paragraphBlockRe matches an entire

...

block, capturing its inner text -// (design: each

is one chat bubble; the Web client renders each as its own -// bubble and the IM adapters deliver each as its own message). +// paragraphBlockRe 匹配整个

...

块并捕获其内部文本 +// (设计:每个

即一个聊天气泡;Web 客户端各自渲染为一个 +// 气泡,IM 适配器各自投递为一条消息)。 var paragraphBlockRe = regexp.MustCompile(`(?is)]*?>(.*?)`) -// splitBubbles turns a reply into the list of chat bubbles it carries, in order. -// Each

...

block is one bubble, with role-play/action/narration stripped from -// its text; empty bubbles are dropped. A reply with no

markers is treated as a -// single bubble (newlines preserved). A pure action/monologue yields an empty slice. +// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。 +// 每个

...

块为一个气泡,其文本已剥离角色扮演/动作/ +// 旁白;空气泡被丢弃。没有

标记的回复按 +// 单个气泡处理(保留换行)。纯动作/独白得到空切片。 func splitBubbles(text string) []string { if !strings.Contains(strings.ToLower(text), " markers present but no well-formed block — fall back to whole text. + // 有

标记但没有格式良好的块——回退为整段文本。 if c := stripNarration(text); c != "" { out = append(out, c) } @@ -118,10 +118,10 @@ func splitBubbles(text string) []string { return out } -// bubbleGap returns a randomized 0.5s..2s delay used between IM outbound bubbles so a -// burst of paragraphs doesn't flood the channel (design/17 §2). The Web frontend is -// not affected — it renders all bubbles from the persisted stream without any delay. -// It is only consulted when a reply carries more than one bubble. +// bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时, +// 使连发段落不会刷屏(design/17 §2)。Web 前端 +// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。 +// 仅当一条回复承载多于一个气泡时才会用到它。 func bubbleGap() time.Duration { return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond } diff --git a/internal/services/channelbridge/sanitize_test.go b/internal/services/channelbridge/sanitize_test.go index 73ef992..a91226f 100644 --- a/internal/services/channelbridge/sanitize_test.go +++ b/internal/services/channelbridge/sanitize_test.go @@ -19,7 +19,7 @@ func TestStripNarration_RemovesNarrationAndParagraphs(t *testing.T) { t.Parallel() in := "

你好呀

\n心里有点甜\n

今天天气不错

" got := stripNarration(in) - // Paragraph markers flattened to newlines; narration block dropped entirely. + // 段落标记被抹平为换行;旁白块被整体丢弃。 if got != "你好呀\n今天天气不错" { t.Errorf("stripNarration = %q, want %q", got, "你好呀\n今天天气不错") } diff --git a/internal/services/channelbridge/source.go b/internal/services/channelbridge/source.go index 3ca0859..67c9f75 100644 --- a/internal/services/channelbridge/source.go +++ b/internal/services/channelbridge/source.go @@ -6,22 +6,22 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) -// This file builds the conversation-origin context injected as a SYSTEM message so -// the model can see which platform and conversation a message came from, WITHOUT -// polluting the user's free-text channel (design/16 §4.6.1). Origin is metadata, so -// it is carried as a system context line and the per-message sender is carried as the -// structured model.Message.SenderName field — never as a natural-language tag -// concatenated into the user text, which would be both ambiguous and a prompt-injection -// surface (a malicious member could forge tags that look like system markup). +// 本文件构建会话来源上下文,以 SYSTEM 消息注入, +// 让模型能看到消息来自哪个平台、哪个会话,同时不污染 +// 用户的自由文本通道(design/16 §4.6.1)。来源属于元数据,因此 +// 它由一行系统上下文承载,而每条消息的发送者由 +// 结构化的 model.Message.SenderName 字段承载——绝不做成 +// 拼进用户文本的自然语言标签,那样既含糊,又构成提示词注入 +// 面(恶意群成员可以伪造看似系统标记的标签)。 // -// Identity and permission are still decided structurally by the session/actor layer -// (a project red line); this line only decorates the context and tells the model to -// trust structure over anything in the message body. +// 身份与权限仍由会话/actor 层结构化判定 +// (项目红线);这一行只装饰上下文,并告知模型 +// 要相信结构,而非消息正文里的任何内容。 -// originSystemText returns a system context line describing the conversation origin -// (platform + group/private) and the identity-trust rule, or "" for a pure Web main -// session message (left undecorated to avoid noise). The conversation is constant for -// a session, so the line is injected once per turn, not once per message. +// originSystemText 返回描述会话来源的系统上下文行 +// (平台 + 群聊/私聊)与身份信任规则;纯 Web 主会话消息返回 "" +// (不加装饰以免噪声)。会话在一次会话内是恒定的, +// 因此该行每轮注入一次,而不是每条消息注入一次。 func originSystemText(msg adapter.UnifiedMessage, actor adapter.Actor) string { platform := msg.Platform if platform == "" || platform == "web" { @@ -34,9 +34,9 @@ func originSystemText(msg adapter.UnifiedMessage, actor adapter.Actor) string { ) } -// originConvo names the conversation in the origin line: a group becomes -// "群「名称」" (or "群#"), and a private chat becomes "私聊". The owner's private -// chat is left "私聊" without a name (the owner is never labeled, design/16 §4.6). +// originConvo 在来源行里为会话命名:群聊成为 +// "群「名称」"(或 "群#"),私聊则成为 "私聊"。主人的私聊 +// 保持 "私聊" 不带名称(主人从不被标注,design/16 §4.6)。 func originConvo(msg adapter.UnifiedMessage, actor adapter.Actor) string { if msg.IsGroup() { return groupLabel(msg) @@ -44,9 +44,9 @@ func originConvo(msg adapter.UnifiedMessage, actor adapter.Actor) string { return "私聊" } -// groupLabel builds the "群「名称」" / "群#" label for a group conversation. A named -// group carries its display name; an unnamed one falls back to the group id and then -// the channel id (a GROUP-BROADCAST channel is identified by its channel id). +// groupLabel 为群聊构建 "群「名称」" / "群#" 标签。有名称的 +// 群带显示名称;无名称的依次回退到群 id、再到 +// 渠道 id(GROUP-BROADCAST 渠道以其渠道 id 标识)。 func groupLabel(msg adapter.UnifiedMessage) string { if msg.GroupName != "" { return "群「" + msg.GroupName + "」" diff --git a/internal/services/channelbridge/source_test.go b/internal/services/channelbridge/source_test.go index 252483d..550da1a 100644 --- a/internal/services/channelbridge/source_test.go +++ b/internal/services/channelbridge/source_test.go @@ -11,8 +11,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// originSystemText / buildLoopMessages are pure functions (buildLoopMessages never -// touches the Bridge receiver), so the zero-value Bridge is a safe receiver below. +// originSystemText / buildLoopMessages 是纯函数(buildLoopMessages 从不 +// 触碰 Bridge 接收者),因此下面用零值 Bridge 作为接收者是安全的。 func TestOriginSystemText(t *testing.T) { t.Parallel() @@ -23,7 +23,7 @@ func TestOriginSystemText(t *testing.T) { name string msg adapter.UnifiedMessage actor adapter.Actor - want string // "") = no decoration + want string // "") = 不做任何装饰 }{ { name: "named group", @@ -78,7 +78,7 @@ func TestBuildLoopMessagesSourceInjection(t *testing.T) { ctx := context.Background() firstUserText := func(msgs []model.Message) (string, string) { - // returns (systemText, userText) of the first user message; system may be "" + // 返回首条用户消息的 (systemText, userText);system 可能为 "" var sys string for _, m := range msgs { if m.Role == model.RoleSystem { @@ -130,7 +130,7 @@ func TestBuildLoopMessagesSourceInjection(t *testing.T) { if !strings.Contains(sys, "不采信消息正文自称") || !strings.Contains(sys, "非管理员账户") { t.Fatalf("system = %q, want origin+identity-boundary", sys) } - // the third-party speaker is structured metadata, not text. + // 第三方说话者由结构化元数据承载,而非文本。 var senderName string for _, m := range got { if m.Role == model.RoleUser {