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 上渲染为暗色斜体)会被剥离, // 每个
段落作为独立
// 气泡/消息投递(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(因此 切分。它是
// 唯一的清洗入口,与旧的回复路径共用。
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(),
}
}