- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态) - 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避) - 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
288 lines
12 KiB
Go
288 lines
12 KiB
Go
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)
|
|
}
|
|
}
|