package channelbridge import ( "context" "fmt" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // bubbleSender 是共享 IM 派发器所用的单气泡出站调用。 // 回复经适配器的 SendMessage (UnifiedResponse) 路径出口,主动推送 // 经其 SendProactive 路径出口——两者都归结为「向平台派发一个聊天气泡」, // 这正是回复与主动推送能共用同一套 // 清洗/切分/间隔流水线、而无需各自重写的原因。 type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error // sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送) // 共用的唯一 IM 出站出口(带音频的回复走 sendIMBubblesVoice,它只是多挂一条 Media)。 // 它把内容变成 IM 渠道应收到的聊天气泡: // 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离, // 每个

段落作为独立 // 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s, // 以免刷屏。Web 前端完全不受影响—— // 它直接渲染已持久化的流,不经过这里。 // // target 携带平台/渠道路由字段,并被复制到每个 // 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律 // 不带 —— 见 outboundRoute);sender 是 // 适配器的出站调用。被剥成空的内容(纯动作行) // 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。 func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error { return b.sendIMBubblesVoice(ctx, target, sender, content, nil) } // sendIMBubblesVoice 是 sendIMBubbles 的**带音频**变体:voice 非 nil 时,那一份音频只 // 挂在**第一个**气泡上,其余气泡仍是纯文本。这条「只挂第一个」不是优化而是正确性 —— // 同一条合成语音若跟着每个气泡各发一次,对方会收到 N 份同样的音频。 // // voice 为 nil 时行为与 sendIMBubbles 逐字相同(因此主动推送等纯文本出口不受影响)。 func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string, voice *adapter.Media) error { // 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) return nil } for i, bubble := range bubbles { if i > 0 { time.Sleep(b.bubbleGapFn()) } out := outboundBubble(target, bubble) if i == 0 && voice != nil { out = outboundVoiceBubble(target, bubble, *voice) } // 每个气泡各自发送一次,间隔才会落在消息之间。 if err := sender(ctx, out); err != nil { return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err) } } return nil } // outboundRoute 依据一条入站消息的**路由字段**构造一个出站信封:只有路由字段, // 没有内容。outboundBubble(纯文本)与 outboundVoiceBubble(文本 + 音频)都从它出发, // 因此「哪些字段允许出站」只有一份白名单。 // // 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的 // 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称) // 一并带到出站。真机事故(2026-10-04 22:21:53):主人发了一条带附件的微信消息, // 回复气泡因此捎上入站的 Media,weixin_ilink 去上传那个附件、拿不到 upload url // (weixin_ilink/send.go 的 getUploadURL)⇒ sendToOne 在**已经组装好文本 item // 之后**整条返回错误,主人连文本都收不到。 // // 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage // 的字段默认不外发。⚠️ 出站的 `Media` 只能由出站语音自己填(那份副本是我们刚合成的, // 见 outbound_voice.go),**绝不**从 target 继承。 func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage { return adapter.UnifiedMessage{ // 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。 Platform: target.Platform, ChannelID: target.ChannelID, ChannelType: target.ChannelType, GroupID: target.GroupID, GroupName: target.GroupName, ThreadID: target.ThreadID, SenderID: target.SenderID, // Sender 只保留路由子字段:qq-obv11 的 resolveTarget 用 // Sender.GroupID / Sender.UserID 作为对端回退,而 DisplayName / // GroupName 是入站的展示信息,出站不需要。 Sender: adapter.Actor{ Role: target.Sender.Role, UserID: target.Sender.UserID, IsGroup: target.Sender.IsGroup, GroupID: target.Sender.GroupID, }, } } // outboundBubble 构造一个**纯文本**出站气泡:路由字段 + 文本内容。 func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage { out := outboundRoute(target) out.Content = content out.ContentType = adapter.ContentText out.Timestamp = time.Now().UTC() return out } // outboundVoiceBubble 构造**带音频**的出站气泡:与纯文本气泡同一套路由字段与同一份 // 正文,只是把 ContentType 标成 voice 并挂上那一条 Media。 // // 正文**照发**(不是「有音频就不发文字」):适配器把它当「文字 item + 语音 item」一起 // 投递(weixin_ilink 的 sendToOne),对方既看得到文字也点得开音频;而任何一侧失败都 // 只影响那一侧(音频发不出去时文字仍在)。 func outboundVoiceBubble(target adapter.UnifiedMessage, content string, md adapter.Media) adapter.UnifiedMessage { out := outboundRoute(target) out.Content = content out.ContentType = adapter.ContentVoice out.Media = []adapter.Media{md} out.Timestamp = time.Now().UTC() return out }