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) } }