package channelbridge import ( "context" "errors" "strings" "testing" "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/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) func newProactiveBridge(t *testing.T, owner *user.OwnerProfile, ads ...*fakeAdapter) *Bridge { t.Helper() return newProactiveBridgeHist(t, owner, nil, ads...) } // newProactiveBridgeHist 是注入了历史存储的 newProactiveBridge,便于 // 测试为「她最近在哪里活跃」的选择供给近期窗口。 func newProactiveBridgeHist(t *testing.T, owner *user.OwnerProfile, hist history.HistoryStore, ads ...*fakeAdapter) *Bridge { t.Helper() chat := &stubChatter{res: &loop.Result{Text: "hi"}} sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}} b, err := New(Options{Loop: chat, Sessions: sess, Owner: owner, History: hist}) if err != nil { t.Fatalf("new bridge: %v", err) } for _, ad := range ads { if ad != nil { b.Add(ad) } } return b } // proactiveMsg 构建一条最小化的自主主动消息,等同于 backthink 的 // buildProactiveMessage 在无渠道决策(通用哨兵值)时的产物。 func proactiveMsg(content string) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: "cyrene-backthink", ChannelType: adapter.ChannelDirect, Content: content, ContentType: adapter.ContentText, Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"}, } } // TestBridge_SendProactive_OwnerChannel 验证无信号的主动推送 // 回退到白名单主人渠道:选中主人平台对应的适配器, // 并把主人账号 id 作为私聊对端串入。 func TestBridge_SendProactive_OwnerChannel(t *testing.T) { ad := &fakeAdapter{name: "telegram", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}} b := newProactiveBridge(t, owner, ad) if err := b.SendProactive(context.Background(), proactiveMsg("记得喝水哦")); err != nil { t.Fatalf("SendProactive err = %v", err) } if len(ad.sends) != 1 { t.Fatalf("sends = %d, want 1", len(ad.sends)) } got := ad.sends[0] if got.Platform != "telegram" { t.Fatalf("platform = %q, want telegram (owner account platform)", got.Platform) } if got.Content != "记得喝水哦" { t.Fatalf("content = %q, want the proactive text", got.Content) } if got.ChannelID != "100" || got.SenderID != "100" { t.Fatalf("peer = %q/%q, want the owner account id 100/100", got.ChannelID, got.SenderID) } if got.ChannelType != adapter.ChannelDirect { t.Fatalf("channel type = %q, want direct", got.ChannelType) } } // TestBridge_SendProactive_PlatformHintSelectsAccount 验证 msg.Platform // 在存在且已连接匹配的主人账号时选中它, // 而不是默认落到另一个渠道。 func TestBridge_SendProactive_PlatformHintSelectsAccount(t *testing.T) { ad := &fakeAdapter{name: "kook", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{ {Platform: "telegram", AccountID: "100"}, {Platform: "kook", AccountID: "329284282"}, }} b := newProactiveBridge(t, owner, ad) msg := proactiveMsg("洗完了") msg.Platform = "kook" if err := b.SendProactive(context.Background(), msg); err != nil { t.Fatalf("SendProactive err = %v", err) } if len(ad.sends) != 1 { t.Fatalf("sends = %d, want 1", len(ad.sends)) } got := ad.sends[0] if got.Platform != "kook" || got.SenderID != "329284282" { t.Fatalf("target = %q/%q, want the kook owner account", got.Platform, got.SenderID) } } // TestBridge_SendProactive_DecisionHintPriority 验证具体的 [渠道:...] // 决策优先于最近活跃平台:即使她上次用的是 WeChat, // 显式的 kook 选择仍被采纳(且仍只用一个渠道)。 func TestBridge_SendProactive_DecisionHintPriority(t *testing.T) { kook := &fakeAdapter{name: "kook", connected: true} weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{ {Platform: "telegram", AccountID: "100"}, {Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}, {Platform: "kook", AccountID: "329284282"}, }} hist := &contextHistory{recent: []history.Message{{Role: history.RoleUser, Platform: "weixin-ilink", Content: "hi", CreatedAt: 2}}} b := newProactiveBridgeHist(t, owner, hist, kook, weixin) msg := proactiveMsg("洗完了") msg.Platform = "kook" if err := b.SendProactive(context.Background(), msg); err != nil { t.Fatalf("SendProactive err = %v", err) } if len(kook.sends) != 1 { t.Fatalf("kook sends = %d, want 1 (explicit choice beats recent-active)", len(kook.sends)) } if len(weixin.sends) != 0 { t.Fatalf("weixin sends = %d, want 0 (only one channel per push)", len(weixin.sends)) } if kook.sends[0].Platform != "kook" || kook.sends[0].SenderID != "329284282" { t.Fatalf("target = %q/%q, want the kook owner account", kook.sends[0].Platform, kook.sends[0].SenderID) } } // TestBridge_SendProactive_RecentActivePlatform 验证无决策的推送 // 落在她最近使用的渠道上,跳过 Web 会话(Web 不是 // 白名单 IM 账号),并采用白名单且已连接的 IM 渠道。 func TestBridge_SendProactive_RecentActivePlatform(t *testing.T) { kook := &fakeAdapter{name: "kook", connected: true} weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{ {Platform: "kook", AccountID: "329284282"}, {Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}, }} // Recent 是最旧在前:最新消息来自 Web 会话,然后是 WeChat, // 再是 kook。Web 会话被跳过(不在白名单),因此选中 // 最近的白名单已连接渠道——WeChat。 hist := &contextHistory{recent: []history.Message{ {Role: history.RoleUser, Platform: "kook", Content: "一早聊过", CreatedAt: 1}, {Role: history.RoleUser, Platform: "weixin-ilink", Content: "微信聊的", CreatedAt: 2}, {Role: history.RoleAssistant, Platform: "web", Content: "网页端", CreatedAt: 3}, }} b := newProactiveBridgeHist(t, owner, hist, kook, weixin) if err := b.SendProactive(context.Background(), proactiveMsg("记得喝水哦")); err != nil { t.Fatalf("SendProactive err = %v", err) } if len(weixin.sends) != 1 { t.Fatalf("weixin sends = %d, want 1 (recent-active picks WeChat)", len(weixin.sends)) } if len(kook.sends) != 0 { t.Fatalf("kook sends = %d, want 0", len(kook.sends)) } if weixin.sends[0].Platform != "weixin-ilink" { t.Fatalf("platform = %q, want weixin-ilink", weixin.sends[0].Platform) } } // TestBridge_SendProactive_WeChatCanBeSelected 验证 WeChat 是一等的 // 主动推送目标:WeChat 账号在白名单且适配器已连接时, // 无信号推送落在它上面,而不是总默认到 kook。 func TestBridge_SendProactive_WeChatCanBeSelected(t *testing.T) { weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}}} b := newProactiveBridge(t, owner, weixin) if err := b.SendProactive(context.Background(), proactiveMsg("记得喝水哦")); err != nil { t.Fatalf("SendProactive err = %v", err) } if len(weixin.sends) != 1 { t.Fatalf("sends = %d, want 1", len(weixin.sends)) } if weixin.sends[0].Platform != "weixin-ilink" { t.Fatalf("platform = %q, want weixin-ilink", weixin.sends[0].Platform) } if weixin.sends[0].Content != "记得喝水哦" { t.Fatalf("content = %q, want the proactive text", weixin.sends[0].Content) } } // TestBridge_SendProactive_NoSignalFallbackRotates 验证无信号回退 // 会在已连接的白名单渠道间轮转(而不是总是 kook),且 // 每次推送只到达一个渠道。 func TestBridge_SendProactive_NoSignalFallbackRotates(t *testing.T) { kook := &fakeAdapter{name: "kook", connected: true} weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{ {Platform: "kook", AccountID: "329284282"}, {Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}, }} b := newProactiveBridge(t, owner, kook, weixin) if err := b.SendProactive(context.Background(), proactiveMsg("第一次")); err != nil { t.Fatalf("first SendProactive err = %v", err) } if err := b.SendProactive(context.Background(), proactiveMsg("第二次")); err != nil { t.Fatalf("second SendProactive err = %v", err) } if len(kook.sends) != 1 || len(weixin.sends) != 1 { t.Fatalf("sends = kook:%d weixin:%d, want 1 each (one channel per push, rotating)", len(kook.sends), len(weixin.sends)) } if kook.sends[0].Content != "第一次" { t.Fatalf("kook content = %q, want '第一次'", kook.sends[0].Content) } if weixin.sends[0].Content != "第二次" { t.Fatalf("weixin content = %q, want '第二次'", weixin.sends[0].Content) } } // TestBridge_SendProactive_HintNotWhitelisted_FallsBackToWeChat 验证 // 不在白名单的具体决策渠道会退化为白名单且已连接的 // 主人渠道,而不是报错或落到非主人渠道。这正是 // WeChat 变得可达的方式,尽管旧的默认逻辑从不考虑它。 func TestBridge_SendProactive_HintNotWhitelisted_FallsBackToWeChat(t *testing.T) { weixin := &fakeAdapter{name: "weixin-ilink", connected: true} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "weixin-ilink", AccountID: "o9cq802QPKPscBenJAp-SMZPBlnQ@im.wechat"}}} b := newProactiveBridge(t, owner, weixin) msg := proactiveMsg("记得喝水哦") msg.Platform = "kook" // kook 不在该主人的白名单里 if err := b.SendProactive(context.Background(), msg); err != nil { t.Fatalf("SendProactive err = %v (a non-whitelisted hint should fall back, not error)", err) } if len(weixin.sends) != 1 { t.Fatalf("sends = %d, want 1 (fell back to the whitelisted WeChat channel)", len(weixin.sends)) } if weixin.sends[0].Platform != "weixin-ilink" { t.Fatalf("platform = %q, want weixin-ilink", weixin.sends[0].Platform) } } // TestBridge_SendProactive_NoOwnerChannel 验证主人档案没有 // 白名单账号时返回明确错误,便于调用方记录原因。 func TestBridge_SendProactive_NoOwnerChannel(t *testing.T) { b := newProactiveBridge(t, &user.OwnerProfile{}) err := b.SendProactive(context.Background(), proactiveMsg("hi")) if err == nil { t.Fatalf("expected an error for a missing owner channel, got nil") } if !strings.Contains(err.Error(), "no connected owner IM channel") { t.Fatalf("error = %q, want a 'no connected owner IM channel' note", err) } } // TestBridge_SendProactive_NoAdapter 验证主人有白名单渠道、 // 但该渠道没有已注册/已连接适配器时报错。 func TestBridge_SendProactive_NoAdapter(t *testing.T) { owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "329284282"}}} b := newProactiveBridge(t, owner) err := b.SendProactive(context.Background(), proactiveMsg("hi")) if err == nil { t.Fatalf("expected an error for a missing adapter, got nil") } if !strings.Contains(err.Error(), "no connected owner IM channel") { t.Fatalf("error = %q, want a 'no connected owner IM channel' note", err) } } // TestBridge_SendProactive_AdapterError 验证适配器发送失败会以 // 带平台的包装错误暴露(使记录/详情如实)。 func TestBridge_SendProactive_AdapterError(t *testing.T) { ad := &fakeAdapter{name: "kook", connected: true, sendErr: errors.New("kook disconnected")} owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "329284282"}}} b := newProactiveBridge(t, owner, ad) err := b.SendProactive(context.Background(), proactiveMsg("hi")) if err == nil { t.Fatalf("expected an error, got nil") } if !strings.Contains(err.Error(), "kook") || !strings.Contains(err.Error(), "kook disconnected") { t.Fatalf("error = %q, want it to wrap the adapter error and name the platform", err) } }