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,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
|
||||
}
|
||||
Reference in new issue
Block a user