From fd13b49b24a52cc45d3d8c4dbe97f0805f26b17d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 22:30:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(channelbridge):=20=E5=87=BA=E7=AB=99?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=E4=B8=8D=E5=86=8D=E6=90=BA=E5=B8=A6=E5=85=A5?= =?UTF-8?q?=E7=AB=99=E8=BD=BD=E8=8D=B7=EF=BC=88=E7=9C=9F=E6=9C=BA=EF=BC=9A?= =?UTF-8?q?=E5=8F=91=E6=96=87=E4=BB=B6=E5=90=8E=E5=9B=9E=E5=A4=8D=E5=85=A8?= =?UTF-8?q?=E4=B8=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(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 条红。 影响面:仅「入站带附件」的消息;纯文本聊天不受影响(已真机确认)。 --- internal/services/channelbridge/bubbles.go | 45 ++++++- .../services/channelbridge/bubbles_test.go | 124 ++++++++++++++++++ 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/internal/services/channelbridge/bubbles.go b/internal/services/channelbridge/bubbles.go index 9850fd5..d9c8d4a 100644 --- a/internal/services/channelbridge/bubbles.go +++ b/internal/services/channelbridge/bubbles.go @@ -24,7 +24,8 @@ type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error // 它直接渲染已持久化的流,不经过这里。 // // target 携带平台/渠道路由字段,并被复制到每个 -// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同);sender 是 +// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律 +// 不带 —— 见 outboundBubble);sender 是 // 适配器的出站调用。被剥成空的内容(纯动作行) // 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。 func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error { @@ -40,10 +41,7 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag if i > 0 { time.Sleep(b.bubbleGapFn()) } - out := target - out.Content = bubble - out.ContentType = adapter.ContentText - out.Timestamp = time.Now().UTC() + 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) @@ -51,3 +49,40 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag } 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(), + } +} diff --git a/internal/services/channelbridge/bubbles_test.go b/internal/services/channelbridge/bubbles_test.go index 09bc9ec..e016e04 100644 --- a/internal/services/channelbridge/bubbles_test.go +++ b/internal/services/channelbridge/bubbles_test.go @@ -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: "

第一段。

第二段。

"}} + 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) + } + } +}