feat(backthink): 主动推送 sink 打通——hub.SendProactive + bridge.SendProactive + wire 接线
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 错误不静默
This commit is contained in:
1 parent
2283383b9e
commit
6a2b8b9f12
9 files changed
+615
-6
No files matched your search
@@ -0,0 +1,123 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user