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 测试(含变异打红验证时序)
This commit is contained in:
1 parent
9f6243a729
commit
28afa11aeb
13 files changed
+755
-44
No files matched your search
@@ -16,7 +16,8 @@ import (
|
||||
type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
|
||||
|
||||
// sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送)
|
||||
// 共用的唯一 IM 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡:
|
||||
// 共用的唯一 IM 出站出口(带音频的回复走 sendIMBubblesVoice,它只是多挂一条 Media)。
|
||||
// 它把内容变成 IM 渠道应收到的聊天气泡:
|
||||
// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离,
|
||||
// 每个 <p> 段落作为独立
|
||||
// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s,
|
||||
@@ -25,10 +26,19 @@ type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
|
||||
//
|
||||
// target 携带平台/渠道路由字段,并被复制到每个
|
||||
// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律
|
||||
// 不带 —— 见 outboundBubble);sender 是
|
||||
// 不带 —— 见 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> 切分。它是
|
||||
// 唯一的清洗入口,与旧的回复路径共用。
|
||||
@@ -42,6 +52,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
|
||||
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)
|
||||
@@ -50,7 +63,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
|
||||
return nil
|
||||
}
|
||||
|
||||
// outboundBubble 依据一条入站消息的**路由字段**构造一个纯文本出站气泡。
|
||||
// outboundRoute 依据一条入站消息的**路由字段**构造一个出站信封:只有路由字段,
|
||||
// 没有内容。outboundBubble(纯文本)与 outboundVoiceBubble(文本 + 音频)都从它出发,
|
||||
// 因此「哪些字段允许出站」只有一份白名单。
|
||||
//
|
||||
// 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的
|
||||
// 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称)
|
||||
@@ -60,8 +75,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
|
||||
// 之后**整条返回错误,主人连文本都收不到。
|
||||
//
|
||||
// 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage
|
||||
// 的字段默认不外发:出站是纯文本气泡,本派发器只接收文本。
|
||||
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
|
||||
// 的字段默认不外发。⚠️ 出站的 `Media` 只能由出站语音自己填(那份副本是我们刚合成的,
|
||||
// 见 outbound_voice.go),**绝不**从 target 继承。
|
||||
func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
// 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。
|
||||
Platform: target.Platform,
|
||||
@@ -80,9 +96,29 @@ func outboundBubble(target adapter.UnifiedMessage, content string) adapter.Unifi
|
||||
IsGroup: target.Sender.IsGroup,
|
||||
GroupID: target.Sender.GroupID,
|
||||
},
|
||||
// 本气泡的内容:出站永远是纯文本。
|
||||
Content: content,
|
||||
ContentType: adapter.ContentText,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user