根因(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 条红。 影响面:仅「入站带附件」的消息;纯文本聊天不受影响(已真机确认)。
281 lines
11 KiB
Go
281 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|