Files
Cyrene_v2/internal/services/channelbridge/bubbles.go
T
AskaEth 28afa11aeb feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)
- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
2026-10-27 22:08:38 +08:00

125 lines
6.1 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
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 上渲染为暗色斜体)会被剥离,
// 每个 <p> 段落作为独立
// 气泡/消息投递(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(因此 <action>/<narration> 与
// 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是
// 唯一的清洗入口,与旧的回复路径共用。
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
}