feat(backthink): 主动推送 sink 打通——hub.SendProactive + bridge.SendProactive + wire 接线
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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:
Aska committed 2026-09-07 19:43:48 +08:00
1 parent 2283383b9e
commit 6a2b8b9f12
9 files changed
+615 -6

No files matched your search

@@ -50,7 +50,10 @@ func (f *fakeAdapter) SendMessage(_ context.Context, resp adapter.UnifiedRespons
}
return f.sendErr
}
func (f *fakeAdapter) SendProactive(context.Context, adapter.UnifiedMessage) error { return nil }
func (f *fakeAdapter) SendProactive(_ context.Context, msg adapter.UnifiedMessage) error {
f.sends = append(f.sends, msg)
return f.sendErr
}
func (f *fakeAdapter) SetMessageHandler(fn adapter.MessageHandler) { f.handler = fn }
func (f *fakeAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
func (f *fakeAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) { return nil, nil }
@@ -0,0 +1,100 @@
package channelbridge
import (
"context"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// proactiveTarget is the owner's IM address (platform + account id) resolved for
// a proactive push. The account id doubles as the DM peer on every adapter.
type proactiveTarget struct {
platform string
accountID string
}
// SendProactive pushes msg to the owner's IM channel via the registered adapter
// for that platform. It is the backthink IMAdapter seam (design/12 §3 fallback).
// The target account is chosen by msg.Platform when it names one of the owner's
// whitelisted channels, otherwise the first whitelisted owner channel is used.
// When the owner profile has no whitelisted account (or none carries an account
// id) it returns a clear error so the caller records the reason rather than
// silently dropping the push.
func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if err := ctx.Err(); err != nil {
return err
}
target, ok := b.proactiveTarget(msg)
if !ok {
return fmt.Errorf("channelbridge: no owner IM channel for proactive push")
}
ad := b.adapterForPlatform(target.platform)
if ad == nil {
return fmt.Errorf("channelbridge: no adapter for owner platform %q", target.platform)
}
out := msg
out.Platform = target.platform
out.ChannelType = adapter.ChannelDirect
// The owner's account id is the DM peer on every adapter: telegram/qq read
// ChannelID, kook/weixin read SenderID (with ChannelID as the fallback). Setting
// both keeps a direct message targetable regardless of which field the adapter
// prefers, without duplicating the message.
out.ChannelID = target.accountID
out.SenderID = target.accountID
if err := ad.SendProactive(ctx, out); err != nil {
return fmt.Errorf("channelbridge: send proactive via %q: %w", target.platform, err)
}
return nil
}
// proactiveTarget resolves the owner account a proactive push should reach:
// msg.Platform, when it names one of the owner's whitelisted platforms, selects
// that account; otherwise the first whitelisted owner channel (with a non-empty
// account id) is used. A nil owner or an empty whitelist yields !ok.
func (b *Bridge) proactiveTarget(msg adapter.UnifiedMessage) (proactiveTarget, bool) {
if b.owner == nil {
return proactiveTarget{}, false
}
accounts := b.owner.Accounts
if len(accounts) == 0 {
return proactiveTarget{}, false
}
platform := strings.ToLower(strings.TrimSpace(msg.Platform))
if platform != "" {
for _, acct := range accounts {
if acct.AccountID != "" && strings.EqualFold(acct.Platform, platform) {
return proactiveTarget{acct.Platform, acct.AccountID}, true
}
}
}
for _, acct := range accounts {
if acct.AccountID != "" {
return proactiveTarget{acct.Platform, acct.AccountID}, true
}
}
return proactiveTarget{}, false
}
// adapterForPlatform returns a registered adapter whose base platform matches
// platform (multi-account names like "kook:3be..." resolve to their bare base),
// preferring a connected one so a dead account is only tried when it is the only
// option. It returns nil when none is registered.
func (b *Bridge) adapterForPlatform(platform string) adapter.PlatformAdapter {
b.mu.RLock()
defer b.mu.RUnlock()
var first adapter.PlatformAdapter
for name, a := range b.adapters {
if !strings.EqualFold(basePlatform(name), platform) {
continue
}
if a.IsConnected() {
return a
}
if first == nil {
first = a
}
}
return first
}
@@ -0,0 +1,137 @@
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)
}
}