package channelbridge import ( "context" "fmt" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 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 } // proactiveRecentCount is how many of the most recent main-session messages the // proactive target selection scans to find "where the owner last talked", so a // push lands on the channel she is actually using. Compile-time so no magic number // appears in the run path. const proactiveRecentCount = 20 // 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) and // delivers to exactly ONE owner channel, chosen by the single-target selection in // proactiveTarget: her explicit [渠道:...] choice first, then the platform she last // used, then the whitelist default (rotating so it is not always the same channel). // WeChat is a first-class candidate (it is whitelisted and, when connected, the // recent-active/fallback selection may pick it). // // The push exits through the shared IM bubble dispatcher (sendIMBubbles), so a // multi-paragraph message is split into bubbles, role-play/narration is stripped, and // sends are spaced 0.5-2s apart — the same behaviour as a reply, and the reason // WeChat/other IM proactive pushes no longer arrive as one un-split

blob. // // When the owner profile has no whitelisted account that is backed by a connected // adapter 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(ctx, msg) if !ok { return fmt.Errorf("channelbridge: no connected 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 // The push exits through the shared IM bubble dispatcher, so a multi-paragraph // proactive message is split into bubbles and role-play/narration is stripped, with // the same 0.5-2s gap as a reply — WeChat proactive pushes are no longer sent as a // single un-split message. if err := b.sendIMBubbles(ctx, out, func(ctx context.Context, msg adapter.UnifiedMessage) error { return ad.SendProactive(ctx, msg) }, msg.Content); 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 single owner account a proactive push should reach. // The range is every whitelisted owner channel that is backed by a connected // adapter (WeChat included), and the choice follows the backthink priority: // // 1. A concrete msg.Platform (her [渠道:...] choice) that is whitelisted AND // connected selects that channel. // 2. The channel she most recently used in the main session (an owner/dialog // message on a whitelisted, connected platform) is preferred. // 3. Otherwise the platform that the whitelist default selects, rotating from a // cursor so the fallback is not always the same channel (e.g. always kook). // // A nil owner or an empty whitelist yields !ok, as does a whitelist with no // connected adapter. func (b *Bridge) proactiveTarget(ctx context.Context, msg adapter.UnifiedMessage) (proactiveTarget, bool) { accounts := b.ownerAccounts() if len(accounts) == 0 { return proactiveTarget{}, false } platform := strings.ToLower(strings.TrimSpace(msg.Platform)) if !isGenericProactivePlatform(platform) { if acct, ok := matchOwnerAccount(accounts, platform); ok && b.hasConnectedAdapter(acct.Platform) { return proactiveTarget{acct.Platform, acct.AccountID}, true } } if acct, ok := b.recentActiveAccount(ctx, accounts); ok { return proactiveTarget{acct.Platform, acct.AccountID}, true } if acct, ok := b.nextConnectedAccount(accounts); ok { return proactiveTarget{acct.Platform, acct.AccountID}, true } return proactiveTarget{}, false } // ownerAccounts returns the owner's whitelisted accounts that carry an account id, // in whitelist order. A nil owner or an empty whitelist yields nil. func (b *Bridge) ownerAccounts() []user.OwnerAccount { if b.owner == nil { return nil } out := make([]user.OwnerAccount, 0, len(b.owner.Accounts)) for _, a := range b.owner.Accounts { if a.AccountID != "" { out = append(out, a) } } if len(out) == 0 { return nil } return out } // matchOwnerAccount returns the owner account whose platform equals platform, // case-insensitively. func matchOwnerAccount(accounts []user.OwnerAccount, platform string) (user.OwnerAccount, bool) { for _, a := range accounts { if strings.EqualFold(a.Platform, platform) { return a, true } } return user.OwnerAccount{}, false } // recentActiveAccount returns the owner account for the most recent main-session // message whose platform is a whitelisted, connected owner channel — the "she is // active there" signal. A nil history store, a failed read or an empty tail yields // ok=false so selection falls through to the default rather than guessing. func (b *Bridge) recentActiveAccount(ctx context.Context, accounts []user.OwnerAccount) (user.OwnerAccount, bool) { if b.history == nil { return user.OwnerAccount{}, false } recent, err := b.history.Recent(ctx, history.MainSessionID, proactiveRecentCount) if err != nil || len(recent) == 0 { return user.OwnerAccount{}, false } // Recent is oldest-first; walk newest-first so the most recent channel wins. for i := len(recent) - 1; i >= 0; i-- { p := strings.ToLower(strings.TrimSpace(recent[i].Platform)) if p == "" || isGenericProactivePlatform(p) { continue } if acct, ok := matchOwnerAccount(accounts, p); ok && b.hasConnectedAdapter(acct.Platform) { return acct, true } } return user.OwnerAccount{}, false } // nextConnectedAccount returns the next whitelisted owner account with a connected // adapter, advancing a cursor so the no-signal fallback rotates instead of always // landing on the first whitelisted channel (e.g. always kook). It holds the bridge // lock to read the adapter map and update the cursor atomically. func (b *Bridge) nextConnectedAccount(accounts []user.OwnerAccount) (user.OwnerAccount, bool) { b.mu.Lock() defer b.mu.Unlock() n := len(accounts) for i := 0; i < n; i++ { idx := (b.proactiveCursor + i) % n a := accounts[idx] if b.hasConnectedAdapterLocked(a.Platform) { b.proactiveCursor = (idx + 1) % n return a, true } } return user.OwnerAccount{}, false } // hasConnectedAdapter reports whether a registered adapter with this base platform // is currently connected, so a channel is only ever selected when it can actually // deliver a message. func (b *Bridge) hasConnectedAdapter(platform string) bool { b.mu.RLock() defer b.mu.RUnlock() return b.hasConnectedAdapterLocked(platform) } // hasConnectedAdapterLocked is hasConnectedAdapter without taking the lock; the // caller must hold b.mu (read or write). func (b *Bridge) hasConnectedAdapterLocked(platform string) bool { for name, a := range b.adapters { if strings.EqualFold(basePlatform(name), platform) && a.IsConnected() { return true } } return false } // isGenericProactivePlatform reports whether platform is a routing keyword rather // than a concrete channel id. Only these fall through to the recent-active / default // selection; a concrete id must resolve to a whitelisted, connected channel before // it is honored, so an explicit channel choice is never silently replaced by a // different channel. func isGenericProactivePlatform(platform string) bool { switch platform { case "", "cyrene-backthink": return true default: return 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 }