backthink 决策+路由(P2)就绪但无投递 sink(ActiveClient/IMAdapter 无实现)。补齐: - gateway/proactive.go: hub.SendProactive 向 owner/admin 连接广播 proactive_message WS事件, 内容脱敏(去RawData/media/token),返回接收数,count=0→IM兜底;Server.SendProactive 委托 - channelbridge/proactive.go: Bridge.SendProactive 解析 owner 账户(msg.Platform→匹配白名单账号, 否则首个已配置主人频道),ChannelType=Direct+ChannelID/SenderID=owner id,走 adapter.SendProactive - backthink/sinks.go: SetActiveSink/SetIMSink 晚注入(New后重建pusher,currentPusher 并发安全) - wire.go: 在 gw/bridge 建好后 bt.SetActiveSink(gw)/SetIMSink(bridge) - 投递链: think→parseProactive→ShouldPush→currentPusher.Push→gate→active(active_client)/IM(im_bridge) →record(route+送达数) - 测试: sinks/gateway proactive/channelbridge proactive; 后端 build/vet/test 全绿 - 注: -race 本机 TSan 不可用(环境); 多平台IM按msg.Platform匹配 owner 账号(单sink诚实); weixin 24h窗口按 ErrProactiveWindow 记录为 im_bridge 错误不静默
138 lines
5.1 KiB
Go
138 lines
5.1 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/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
)
|
|
|
|
func newProactiveBridge(t *testing.T, owner *user.OwnerProfile, ad *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})
|
|
if err != nil {
|
|
t.Fatalf("new bridge: %v", err)
|
|
}
|
|
if ad != nil {
|
|
b.Add(ad)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// proactiveMsg builds a minimal autonomous proactive message as backthink's
|
|
// buildProactiveMessage would (no channel targeting yet).
|
|
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 lands on the
|
|
// owner's IM 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, rather than defaulting.
|
|
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_NoOwnerChannel verifies a clearly-worded 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{}, nil)
|
|
|
|
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 owner IM channel") {
|
|
t.Fatalf("error = %q, want a 'no owner IM channel' note", err)
|
|
}
|
|
}
|
|
|
|
// TestBridge_SendProactive_NoAdapter verifies the error names the platform when the
|
|
// owner has a whitelisted channel but no adapter is registered for it.
|
|
func TestBridge_SendProactive_NoAdapter(t *testing.T) {
|
|
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "329284282"}}}
|
|
b := newProactiveBridge(t, owner, nil)
|
|
|
|
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 adapter for owner platform") {
|
|
t.Fatalf("error = %q, want a 'no adapter for owner platform' 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)
|
|
}
|
|
}
|