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 is newProactiveBridge with a history store injected, so a // test can supply a recent window for the "she was last active here" selection. 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 builds a minimal autonomous proactive message as backthink's // buildProactiveMessage would for a no-channel decision (the generic sentinel). 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 verifies a proactive push with no signal // falls back to a whitelisted owner channel: the adapter for the owner's platform // is selected and the owner account id is threaded as the DM peer. 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 verifies msg.Platform // selects the matching owner account when one exists and is connected, rather // than defaulting to another channel. 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 verifies a concrete [渠道:...] // decision beats the recent-active platform: even though she last used WeChat, an // explicit kook choice is honored (and still only one channel is used). 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 verifies a no-decision push lands // on the channel she used most recently, skipping the web session (web is not a // whitelisted IM account) and honoring a whitelisted, connected IM channel. 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 is oldest-first: the newest message is the web session, then WeChat, // then kook. The web session is skipped (not whitelisted), so the most recent // whitelisted, connected channel — WeChat — is chosen. 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 verifies WeChat is a first-class // proactive target: with a WeChat account whitelisted and its adapter connected, a // no-signal push lands on it rather than always defaulting to 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 verifies the no-signal fallback // rotates across connected whitelisted channels (instead of always kook), and that // each push reaches exactly one channel. 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 verifies a concrete // decision channel that is NOT whitelisted degrades to a whitelisted, connected // owner channel rather than erroring or landing on a non-owner channel. This is // how WeChat becomes reachable even though the old default never considered it. 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 is not whitelisted for this owner 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 verifies a clear error when the owner // profile has no whitelisted account, so the caller records the reason. 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 verifies the error when the owner has a // whitelisted channel but no adapter is registered/connected for it. 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 verifies an adapter send failure surfaces // as a wrapped error with the platform (so the record/detail is honest). 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) } }