package channelbridge import ( "context" "fmt" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 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) } // Mirror the proactive message into the main conversation context (design/16 // §4.4 + §4.6.1). Without this the model would not see what it proactively said, so // a follow-up owner reply would feel incoherent (the message "loses" its history). // It is the same mirror path as owner-private IM replies, scoped to MainSessionID. b.persistToMain(ctx, history.RoleAssistant, msg.Content, target.platform, fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID), target.platform) 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 }