Files
Cyrene_v2/internal/services/channelbridge/bubbles.go
T
AskaEth fd13b49b24 fix(channelbridge): 出站气泡不再携带入站载荷(真机:发文件后回复全丢)
根因(bubbles.go:43):`out := target` 把【整条入站消息】复制成出站气泡,
入站 Media 被一起带出 ⇒ 适配器去上传那个附件 ⇒ weixin_ilink 的 getUploadURL
拿不到 url ⇒ sendToOne 连已组装好的文本 item 一起丢掉 ⇒ 用户收不到任何回复。
修法:新增 outboundBubble(白名单只带路由字段 + 本气泡文本),一次清掉
Media / RawData / Mentions / ReplyToID / ReplyToText / MessageID / SenderName
六类入站专属字段;将来新加进 UnifiedMessage 的字段默认不外发(失败方向安全)。
测试 2 条:入站带附件 ⇒ 出站 Media 为空;带附件 vs 不带 ⇒ 出站除时间戳逐字节相同。
变异:破坏白名单把 Media 带出 ⇒ 2 条红。
影响面:仅「入站带附件」的消息;纯文本聊天不受影响(已真机确认)。
2026-10-04 22:30:03 +08:00

89 lines
4.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 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡:
// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离,
// 每个 <p> 段落作为独立
// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s,
// 以免刷屏。Web 前端完全不受影响——
// 它直接渲染已持久化的流,不经过这里。
//
// target 携带平台/渠道路由字段,并被复制到每个
// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律
// 不带 —— 见 outboundBubble);sender 是
// 适配器的出站调用。被剥成空的内容(纯动作行)
// 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。
func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) 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 err := sender(ctx, out); err != nil {
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
}
}
return nil
}
// outboundBubble 依据一条入站消息的**路由字段**构造一个纯文本出站气泡。
//
// 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的
// 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称)
// 一并带到出站。真机事故(2026-10-04 22:21:53):主人发了一条带附件的微信消息,
// 回复气泡因此捎上入站的 Media,weixin_ilink 去上传那个附件、拿不到 upload url
// (weixin_ilink/send.go 的 getUploadURL)⇒ sendToOne 在**已经组装好文本 item
// 之后**整条返回错误,主人连文本都收不到。
//
// 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage
// 的字段默认不外发:出站是纯文本气泡,本派发器只接收文本。
func outboundBubble(target adapter.UnifiedMessage, content string) 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,
},
// 本气泡的内容:出站永远是纯文本。
Content: content,
ContentType: adapter.ContentText,
Timestamp: time.Now().UTC(),
}
}