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 条红。
影响面:仅「入站带附件」的消息;纯文本聊天不受影响(已真机确认)。
This commit is contained in:
AskaEth committed 2026-10-04 22:30:03 +08:00
1 parent fbbbb4ea64
commit fd13b49b24
2 files changed
+164 -5

No files matched your search

@@ -2,10 +2,13 @@ package channelbridge
import (
"context"
"reflect"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
@@ -154,3 +157,124 @@ func TestSendProactive_SplitsWeChatBubbles(t *testing.T) {
t.Fatalf("weixin peer = %+v, want platform weixin-ilink account %s", weixin.sends[0], acct)
}
}
// runInboundReplyFixture 用**真入站流水线**(process → 会话/actor → 循环 → 落库 →
// 出站)跑一条主人入站消息,并在这条消息上填满**入站专属载荷字段**(附件、原始
// 平台载荷、提及、回复引用、入站消息 id、发送者昵称),使「这些字段有没有被外发」
// 可以被真正观察到。只有模型那一侧是桩(stubChatter),适配器是记录型 fakeAdapter。
// 返回值是那条入站消息本身与记录下来的出站消息。
func runInboundReplyFixture(t *testing.T, media []adapter.Media) (adapter.UnifiedMessage, *fakeAdapter) {
t.Helper()
chat := &stubChatter{res: &loop.Result{Text: "<p>第一段。</p><p>第二段。</p>"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
// 测试里把多气泡延时归零,否则真的会 sleep 0.5-2s。
b.bubbleGapFn = func() time.Duration { return 0 }
in := directMessage()
in.MessageID = "in-msg-1"
in.ReplyToID = "in-msg-0"
in.ReplyToText = "上一条消息"
in.SenderName = "主人"
in.Mentions = []adapter.Mention{{UserID: "9", UserName: "别人"}}
in.RawData = []byte(`{"native":"inbound payload"}`)
in.Media = media
if len(media) > 0 {
in.ContentType = adapter.ContentFile
}
b.process(context.Background(), in)
return in, ad
}
// assertOutboundBubbles 断言出站气泡的数量与内容,并逐个点名:
// - **路由字段**必须原样保留(适配器据此解析投递对端);
// - **入站载荷字段**一个都不许外发(本热修的核心)。
func assertOutboundBubbles(t *testing.T, in adapter.UnifiedMessage, ad *fakeAdapter, want []string) {
t.Helper()
if len(ad.sends) != len(want) {
t.Fatalf("出站气泡 = %d 条, want %d: %+v", len(ad.sends), len(want), ad.sends)
}
for i, out := range ad.sends {
if out.Content != want[i] {
t.Fatalf("气泡[%d] 内容 = %q, want %q", i, out.Content, want[i])
}
if out.ContentType != adapter.ContentText {
t.Fatalf("气泡[%d] ContentType = %q, want text", i, out.ContentType)
}
if out.Platform != in.Platform || out.ChannelID != in.ChannelID ||
out.ChannelType != in.ChannelType || out.SenderID != in.SenderID {
t.Fatalf("气泡[%d] 路由字段被改动: %+v(入站 %+v)", i, out, in)
}
if out.Sender.Role != in.Sender.Role || out.Sender.UserID != in.Sender.UserID ||
out.Sender.GroupID != in.Sender.GroupID {
t.Fatalf("气泡[%d] Sender 路由子字段被改动: %+v", i, out.Sender)
}
// 入站 Media 是本次真机事故的直接原因:weixin_ilink 会拿它去上传附件,
// 拿不到 upload url 就在组装好文本 item 之后整条失败 —— 主人连文本都收不到。
if len(out.Media) != 0 {
t.Fatalf("气泡[%d] 带出了入站 Media %+v —— 适配器会去上传附件并丢掉整条文本", i, out.Media)
}
if len(out.RawData) != 0 || len(out.Mentions) != 0 || out.ReplyToID != "" ||
out.ReplyToText != "" || out.MessageID != "" || out.SenderName != "" {
t.Fatalf("气泡[%d] 带出了入站载荷字段: raw=%d mentions=%d reply_to_id=%q reply_to_text=%q msg_id=%q sender_name=%q",
i, len(out.RawData), len(out.Mentions), out.ReplyToID, out.ReplyToText, out.MessageID, out.SenderName)
}
}
}
// TestSendReply_InboundMediaDoesNotReachOutbound 是本热修的回归项(真机报障:
// 主人发一条带附件的微信消息 ⇒ 一点回复都收不到;纯文本正常)。
//
// 入站消息带着附件(以及原始载荷、提及、回复引用、入站消息 id、发送者昵称)走完
// 真流水线后,出站气泡必须仍然只是纯文本 + 路由字段。
func TestSendReply_InboundMediaDoesNotReachOutbound(t *testing.T) {
t.Parallel()
t.Run("入站带附件", func(t *testing.T) {
t.Parallel()
in, ad := runInboundReplyFixture(t, []adapter.Media{{
Kind: adapter.MediaFile, Name: "报告.pdf", MIMEType: "application/pdf",
Size: 1024, URL: "https://cdn.example.com/报告.pdf",
}})
assertOutboundBubbles(t, in, ad, []string{"第一段。", "第二段。"})
})
t.Run("入站纯文本", func(t *testing.T) {
t.Parallel()
in, ad := runInboundReplyFixture(t, nil)
assertOutboundBubbles(t, in, ad, []string{"第一段。", "第二段。"})
})
}
// TestSendReply_InboundMediaLeavesOutboundByteIdentical 是上一条的对照项:带附件
// 与不带附件的入站消息,其出站气泡必须**逐字段相同**(只有 Timestamp 允许不同)。
// 也就是说本修复只做减法(剥掉入站载荷),对纯文本路径是零行为变化。
func TestSendReply_InboundMediaLeavesOutboundByteIdentical(t *testing.T) {
t.Parallel()
_, withMedia := runInboundReplyFixture(t, []adapter.Media{{
Kind: adapter.MediaFile, Name: "报告.pdf", URL: "https://cdn.example.com/报告.pdf",
}})
_, plain := runInboundReplyFixture(t, nil)
if len(withMedia.sends) != len(plain.sends) {
t.Fatalf("气泡数不同:带附件 %d 条、纯文本 %d 条", len(withMedia.sends), len(plain.sends))
}
for i := range plain.sends {
a, b := withMedia.sends[i], plain.sends[i]
a.Timestamp, b.Timestamp = time.Time{}, time.Time{}
if !reflect.DeepEqual(a, b) {
t.Fatalf("气泡[%d] 因入站带附件而不同:\n 带附件=%+v\n 纯文本=%+v", i, a, b)
}
}
}