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 错误不静默
124 lines
3.9 KiB
Go
124 lines
3.9 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
)
|
|
|
|
// TestHub_SendProactive_OwnerAndAdmin verifies a proactive push reaches exactly
|
|
// the owner/admin connections and returns the delivered count; a viewer or
|
|
// unauthenticated (nil-claims) connection never receives it.
|
|
func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
hb := newTestHub(nil, nil)
|
|
owner := newFakeConn(hb, 8)
|
|
owner.claims = &auth.Claims{Role: auth.RoleOwner}
|
|
admin := newFakeConn(hb, 8)
|
|
admin.claims = &auth.Claims{Role: auth.RoleAdmin}
|
|
viewer := newFakeConn(hb, 8)
|
|
viewer.claims = &auth.Claims{Role: auth.RoleViewer}
|
|
bare := newFakeConn(hb, 8) // claims nil: unauthenticated
|
|
for _, c := range []*wsConn{owner, admin, viewer, bare} {
|
|
hb.register(c)
|
|
}
|
|
|
|
msg := adapter.UnifiedMessage{
|
|
Platform: "cyrene-backthink",
|
|
ChannelType: adapter.ChannelDirect,
|
|
Content: "记得喝水哦",
|
|
ContentType: adapter.ContentText,
|
|
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
|
|
ChannelID: "kook:123",
|
|
SenderID: "uid-999",
|
|
MessageID: "m-42",
|
|
}
|
|
n, err := hb.SendProactive(context.Background(), msg)
|
|
if err != nil {
|
|
t.Fatalf("SendProactive err = %v", err)
|
|
}
|
|
if n != 2 {
|
|
t.Fatalf("count = %d, want 2 (owner+admin; viewer and bare excluded)", n)
|
|
}
|
|
|
|
for _, c := range []*wsConn{owner, admin} {
|
|
ev := readFakeFrame(t, c)
|
|
if ev.Type != "proactive_message" {
|
|
t.Fatalf("frame type = %q, want proactive_message (frame=%+v)", ev.Type, ev)
|
|
}
|
|
data, ok := ev.Data.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
|
|
}
|
|
if data["content"] != "记得喝水哦" {
|
|
t.Fatalf("frame content = %q, want the proactive text", data["content"])
|
|
}
|
|
if data["sender"] != "Cyrene" {
|
|
t.Fatalf("frame sender = %q, want Cyrene", data["sender"])
|
|
}
|
|
if data["platform"] != "cyrene-backthink" {
|
|
t.Fatalf("frame platform = %q, want cyrene-backthink", data["platform"])
|
|
}
|
|
}
|
|
for _, c := range []*wsConn{viewer, bare} {
|
|
if err := connHasNoFrame(c); err != nil {
|
|
t.Fatalf("unexpected frame for a non-owner/admin conn: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHub_SendProactive_NoOnlineOwner verifies a zero count (no error) when no
|
|
// owner/admin connection is live, so the caller falls back to the IM bridge.
|
|
func TestHub_SendProactive_NoOnlineOwner(t *testing.T) {
|
|
t.Parallel()
|
|
hb := newTestHub(nil, nil)
|
|
viewer := newFakeConn(hb, 8)
|
|
viewer.claims = &auth.Claims{Role: auth.RoleViewer}
|
|
hb.register(viewer)
|
|
|
|
n, err := hb.SendProactive(context.Background(), adapter.UnifiedMessage{Content: "hi"})
|
|
if err != nil {
|
|
t.Fatalf("SendProactive err = %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("count = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestHub_SendProactive_CancelledContext verifies a cancelled context denies the
|
|
// push (fail closed) with a wrapped error.
|
|
func TestHub_SendProactive_CancelledContext(t *testing.T) {
|
|
t.Parallel()
|
|
hb := newTestHub(nil, nil)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
if _, err := hb.SendProactive(ctx, adapter.UnifiedMessage{Content: "hi"}); err == nil {
|
|
t.Fatalf("expected a context error, got nil")
|
|
}
|
|
}
|
|
|
|
// TestServer_SendProactive_DelegatesToHub verifies Server.SendProactive mirrors the
|
|
// hub so the composition root can hand the gateway itself to the sink setter.
|
|
func TestServer_SendProactive_DelegatesToHub(t *testing.T) {
|
|
t.Parallel()
|
|
env := newEnv(t, nil, false)
|
|
c := newFakeConn(env.srv.hub, 8)
|
|
c.claims = &auth.Claims{Role: auth.RoleOwner}
|
|
env.srv.hub.register(c)
|
|
|
|
n, err := env.srv.SendProactive(context.Background(), adapter.UnifiedMessage{Content: "hi"})
|
|
if err != nil {
|
|
t.Fatalf("SendProactive err = %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("count = %d, want 1", n)
|
|
}
|
|
ev := readFakeFrame(t, c)
|
|
if ev.Type != "proactive_message" {
|
|
t.Fatalf("frame type = %q, want proactive_message", ev.Type)
|
|
}
|
|
}
|