主人 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/。
152 lines
7.4 KiB
Go
152 lines
7.4 KiB
Go
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) }
|
||
}
|