Files
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

281 lines
11 KiB
Go
Raw Permalink 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"
"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"
)
// gapCounter 返回一个 bubbleGapFn:它统计气泡间延时的咨询次数
// 并返回零,因此无需为每个间隔真的 sleep 0.5-2s
// 就能断言多气泡间隔。
func gapCounter(counter *int) func() time.Duration {
return func() time.Duration {
*counter++
return 0
}
}
func TestSendIMBubbles_SplitsStripsAndGaps(t *testing.T) {
t.Parallel()
ad := &fakeAdapter{name: "kook", connected: true}
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
if err != nil {
t.Fatalf("New: %v", err)
}
gaps := 0
b.bubbleGapFn = gapCounter(&gaps)
target := adapter.UnifiedMessage{
Platform: "kook", ChannelID: "329284282", ChannelType: adapter.ChannelDirect,
SenderID: "329284282",
}
content := "<p><action>弯起眼睛笑了</action>当然看得到呀。</p><p>第二句话。</p>"
if err := b.sendIMBubbles(context.Background(), target, func(ctx context.Context, m adapter.UnifiedMessage) error {
return ad.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{m}})
}, content); err != nil {
t.Fatalf("sendIMBubbles err = %v", err)
}
if len(ad.sends) != 2 {
t.Fatalf("sends = %d, want 2 <p> bubbles", len(ad.sends))
}
if ad.sends[0].Content != "当然看得到呀。" {
t.Fatalf("bubble[0] = %q, want the role-play-stripped text", ad.sends[0].Content)
}
if ad.sends[1].Content != "第二句话。" {
t.Fatalf("bubble[1] = %q, want the second paragraph", ad.sends[1].Content)
}
if ad.sends[0].Platform != "kook" || ad.sends[0].ChannelID != "329284282" {
t.Fatalf("bubble[0] target = %+v, want kook 329284282", ad.sends[0])
}
// 两个气泡 → 恰好咨询一次气泡间延时(回复与主动推送两条路径
// 都适用,因为驱动的是共享派发器)。
if gaps != 1 {
t.Fatalf("gap calls = %d, want 1 (2 bubbles → one gap)", gaps)
}
}
func TestSendIMBubbles_SingleBubbleNoGap(t *testing.T) {
t.Parallel()
ad := &fakeAdapter{name: "weixin-ilink", connected: true}
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
if err != nil {
t.Fatalf("New: %v", err)
}
gaps := 0
b.bubbleGapFn = gapCounter(&gaps)
target := adapter.UnifiedMessage{
Platform: "weixin-ilink", ChannelID: "o9cq@im.wechat", ChannelType: adapter.ChannelDirect,
SenderID: "o9cq@im.wechat",
}
if err := b.sendIMBubbles(context.Background(), target, func(ctx context.Context, m adapter.UnifiedMessage) error {
return ad.SendProactive(ctx, m)
}, "记得喝水哦"); err != nil {
t.Fatalf("sendIMBubbles err = %v", err)
}
if len(ad.sends) != 1 {
t.Fatalf("sends = %d, want 1 bubble", len(ad.sends))
}
if gaps != 0 {
t.Fatalf("gap calls = %d, want 0 for a single bubble", gaps)
}
}
func TestSendIMBubbles_PureActionDropped(t *testing.T) {
t.Parallel()
ad := &fakeAdapter{name: "kook", connected: true}
b, err := New(Options{Loop: &stubChatter{}, Sessions: &stubSessions{}})
if err != nil {
t.Fatalf("New: %v", err)
}
target := adapter.UnifiedMessage{
Platform: "kook", ChannelID: "1", ChannelType: adapter.ChannelDirect, SenderID: "1",
}
if err := b.sendIMBubbles(context.Background(), target, func(ctx context.Context, m adapter.UnifiedMessage) error {
return ad.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{m}})
}, "<action>拍拍你</action>"); err != nil {
t.Fatalf("sendIMBubbles err = %v", err)
}
if len(ad.sends) != 0 {
t.Fatalf("sends = %d, want 0 for a pure action line", len(ad.sends))
}
}
// TestSendReply_SplitsBubblesAndStrips 覆盖重构后的回复出口(sendReply):
// 含多个 <p> 的回复经共享派发器以多个已剥离的气泡
// 投递,目标为来源会话。
func TestSendReply_SplitsBubblesAndStrips(t *testing.T) {
t.Parallel()
b, _, _, ad := newTestBridge(t)
b.bubbleGapFn = func() time.Duration { return 0 }
in := directMessage()
content := "<p><action>弯起眼睛笑了</action>当然看得到呀。</p><p>怎么突然这么问。</p>"
if err := b.sendReply(context.Background(), in, ad, content); err != nil {
t.Fatalf("sendReply err = %v", err)
}
if len(ad.sends) != 2 {
t.Fatalf("sends = %d, want 2 (reply splits into <p> bubbles)", len(ad.sends))
}
if ad.sends[0].Content != "当然看得到呀。" || ad.sends[1].Content != "怎么突然这么问。" {
t.Fatalf("bubbles = %#v, want stripped narration + both paragraphs", ad.sends)
}
if ad.sends[0].Platform != "telegram" || ad.sends[0].ChannelID != "100" {
t.Fatalf("reply target = %+v, want telegram channel 100", ad.sends[0])
}
}
// TestSendProactive_SplitsWeChatBubbles 验证主动推送现在会在 WeChat 上
// 把多段消息拆成气泡(上报的回归问题:WeChat
// 主动推送曾以一整块未拆分的 <p> 到达)。
func TestSendProactive_SplitsWeChatBubbles(t *testing.T) {
t.Parallel()
const acct = "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"
weixin := &fakeAdapter{name: "weixin-ilink", connected: true}
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: acct}}}
b := newProactiveBridge(t, owner, weixin)
b.bubbleGapFn = func() time.Duration { return 0 }
msg := proactiveMsg("<p>第一段正文。</p><p><action>拍拍</action>第二段。</p>")
if err := b.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
if len(weixin.sends) != 2 {
t.Fatalf("weixin sends = %d, want 2 (proactive splits into <p> bubbles)", len(weixin.sends))
}
if weixin.sends[0].Content != "第一段正文。" || weixin.sends[1].Content != "第二段。" {
t.Fatalf("weixin bubbles = %#v, want stripped narration + both paragraphs", weixin.sends)
}
if weixin.sends[0].Platform != "weixin-ilink" ||
weixin.sends[0].ChannelID != acct || weixin.sends[0].SenderID != acct {
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)
}
}
}