Files
Cyrene_v2/internal/services/channelbridge/coalesce_prepare.go
T
AskaEth ca8577f05b feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」
此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答
(批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。

四条不可走样:

- 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器
  工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定,
  配置无权翻转;
- 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点
  (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前;
- 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复;
- 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom),
  被静默的消息进不了批。

实现落点:
- lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记;
- coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数;
- coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车;
- coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch;
- runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键);
- gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 +
  writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批);
- 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。

M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条
按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。

回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build
+ 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
2026-10-24 21:11:22 +08:00

152 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
// coalesce_prepare.go 把入站流水线拆成两段(M23 蓝图 §11 步 1):
//
// prepareInbound —— **逐条**闸门(语音归一 → 传入前审查/事件留存 → 空内容 →
// 黑白名单 → 会话解析 → 最小打扰/owner 车道 → 陌生人回合槽),
// 行为与 M22 的 processFrom 逐字一致,**一个字都没改**;
// runBatch —— **一批**一轮回合(见 coalesce_batch.go)。
//
// 为什么要拆:静默窗口只决定「什么时候把一批已经决定要说的东西说出来」,它不该
// 参与「说不说」的判断。拆开之后,闸门仍然逐条跑(每条消息照旧独立判静默/限流),
// 窗口只作用在闸门**之后**的调度上 —— 因此「批不得把一条本应静默的消息捞回来」
// 在结构上成立:被静默的消息在 prepareInbound 就 return false 了,根本进不了批。
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// preparedInbound 是一条**已过全部逐条闸门**、确定要驱动回合的入站消息。
type preparedInbound struct {
// item 是车道条目(msg 已被语音归一/通知剥离就地改写过,actor 是本次解析结果)。
item laneItem
// sess 是解析出的会话绑定(主人私聊 → 主会话,群聊/陌生人 → 隔离会话)。
sess session.Session
// text 是本回合的正文(语音已归一成文本、通知已剥离)。
text string
// voiceIn 是那条语音的归一结论(只在放行之后才会落副本)。
voiceIn voiceTurn
// solo 为 true ⇒ 这条不能与别人并进同一批(传入前审查升级的真会话):
// 它单独一轮,以免把「平台事件升级」与「群友聊天」混成一次回答。
solo bool
}
// prepareInbound 逐条过闸门,返回「这条要不要驱动回合」以及驱动所需的一切。
//
// 它逐字保留了 M22 `processFrom` 的闸门顺序与日志,唯一的变化是**副作用分家**:
// 落库、装配上下文、跑回合、发回复都搬到了 runBatch;本函数只做判断与一次性 I/O
// (语音转写、事件留存)。
func (b *Bridge) prepareInbound(ctx context.Context, item laneItem) (preparedInbound, bool) {
msg := item.msg
// 语音 → 文本归一是入站流水线的**第一件事**(design/06 §1.1):它必须在「丢空判据」
// 之前发生,否则 telegram 的纯语音(无 caption ⇒ Content 为空)会被整条丢掉。
voiceIn := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」。
notice := b.preEntryReview(ctx, &msg, item.a)
// design/26 §4/§5:渠道事件留存的唯一插入点(无条件、早于所有早退分支、绝不决定回合走不走)。
b.recordEvent(ctx, msg, item.a, notice)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, item.a, notice)
return preparedInbound{}, false
}
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容(带附件的纯文件/纯语音/纯图片消息
// 必须继续往下走:附件就是它的内容,design/24 §4.4「不许静默丢」)。
if text == "" && !hasUsableMedia(msg) {
b.log.Debug("inbound message has no chat text, skipped",
"platform", msg.Platform, "channel", msg.ChannelID)
return preparedInbound{}, false
}
// design/16 §4.6 §6:黑白名单在流水线的最前端执行(早于会话解析与循环驱动)。
actor := session.ResolveActor(msg, b.owner)
if !actor.IsOwner() && b.blacklistDrop(msg, item.a) {
return preparedInbound{}, false
}
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.log.Warn("session resolve failed", "platform", msg.Platform,
"channel", msg.ChannelID, "error", err)
return preparedInbound{}, false
}
b.log.Debug("inbound → session",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
// design/12 §6 策略一/二 + design/18 §11「乙 = C」:owner 车道不再无条件短路这道闸。
// 被拦下的消息只登记到其隔离会话日志并静默 —— 它**进不了批**(这就是「批不得把
// 本应静默的消息捞回来」的结构保证)。
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一终局判定。
if !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, item.a); !ok {
b.registerSilenced(ctx, msg, actor, sess, item.a)
b.log.Debug("inbound message silenced (minimal disturbance)",
"platform", msg.Platform, "channel", msg.ChannelID,
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
return preparedInbound{}, false
}
}
return preparedInbound{
item: laneItem{msg: msg, a: item.a, actor: actor, arrivedAt: item.arrivedAt},
sess: sess,
text: text,
voiceIn: voiceIn,
solo: !coalesceAdmitsMessage(msg, notice),
}, true
}
// coalesceAdmitsMessage 报告这条入站是否允许进静默窗口(M23 蓝图 §11 第 4 条):
//
// exit 条件只有两类 —— **系统通知**(`notice.Applicable`,含升级成真会话的那些)与
// **非聊天内容类型**(action 帧:typing/join/leave/recall 等)。
//
// 理由不是省事:延迟 2s 只为合并它们毫无收益,还会污染留存时序;而「升级的真会话」
// 与「群友聊天」混成一次回答,等于让平台事件替群友说话。它们因此走 solo(单独一轮)。
func coalesceAdmitsMessage(msg adapter.UnifiedMessage, notice noticeOutcome) bool {
if notice.Applicable {
return false
}
switch msg.ContentType {
case adapter.ContentText, adapter.ContentImage, adapter.ContentVoice, adapter.ContentFile:
return true
default:
return false
}
}
// acquireStrangerTurn 施加「陌生人私聊同一发送者同时最多一个回合」的既有闸(裁决①)。
//
// 它由 runBatch 在**开跑前**取、**回合结束后**还 —— 也就是「一批 = 一个回合」占一个槽,
// 与 M22 的 `defer gate.ReleaseTurn(msg)` 同一语义(那一次 defer 从 processFrom 搬到了
// 回合本身)。放在这里而不是逐条闸门里是必须的:一批里同一发送者的第 2..N 条不该
// 被自己这一批占的槽挡下。
//
// M23 的车道已经给出**更强**的保证(同一会话串行),因此这道闸在车道里几乎不可能
// 拒绝;万一拒绝(闸门键与车道键分叉的缺陷),这里**绝不**再退回「这条不回」——
// 主人 2026-10-24 的口径是「宁可晚回,不许不回」,而本函数返回 nil 时消息仍会被本轮
// 回答,只是少了一个可以同时占用的槽。拒绝会留一条带 source 的 WARN,不会被吞掉。
func (b *Bridge) acquireStrangerTurn(msg adapter.UnifiedMessage, sess session.Session, a adapter.PlatformAdapter) func() {
if !isStrangerPrivate(sess) {
return nil
}
gate := b.gateFor(a)
if gate == nil {
return nil
}
if !gate.AcquireTurn(msg) {
b.log.Warn("stranger private turn slot busy inside a serialized lane, answering anyway",
"platform", msg.Platform, "channel", msg.ChannelID, "sender", msg.SenderID)
return nil
}
return func() { gate.ReleaseTurn(msg) }
}