Files
Cyrene_v2/internal/services/channelbridge/coalesce_prepare.go
T
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00

152 lines
7.5 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 是窗口准入的**第二道保险**(第一道、也是真正生效的那道在车道头:
// coalesce.go 的 laneWindowAdmits —— 窗口等待阶段就按它分类,非准入条目根本进不了批)。
//
// 它比车道头多知道一件事:`notice.Applicable`(那要 preEntryReview 跑完才有结论)。
// 但 isSystemNoticeMessage 只按 ContentType=action 判定,而 action 帧在车道头已经被挡下,
// 所以这里实际上永远为真 —— 保留它是为了让「未来某个适配器把平台事件标成 text」这类
// 变更仍然在组批前被 solo 切出去(runBatchSegments),而不是混进群友的同一轮回答。
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) }
}