package channelbridge import ( "context" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // This file owns which role an inbound IM turn speaks as (DevDocs/design/22 §2 // 裁决规则 + §7-3 + §7-4). Identity and permission stay structural — the actor // metadata and the session's isolation boundary decide who may do what — while the // role only scopes the prompt, the memory namespace, the emotion machine and the // thinking budget. // // Precedence, top down (design/22 §2): // 1. the session-level binding — for an owner private chat that is the main web // session the message is mirrored into, so the IM mirror answers with the same // role the Web client is talking to (design/22 §7-3); // 2. the channel-level binding — the account's channels.json personaId, used when // the main session binds no role of its own (design/22 §2 方案 b); // 3. the global default — expressed as "", which the loop resolves to its // configured default role. // // A restricted conversation (group chat or stranger, i.e. every isolated sess-xxx // session) is deliberately excluded from 1 and 2: it is forced onto the default // role (design/22 §7-4, the conservative choice), so a channel binding can never // put a third party's conversation into a role's memory namespace. // sessionPersonaReader is the optional narrower view of the history store used to // read a session's bound role. *history.Store implements it; a bridge built // without one (or with a store that predates design/22) simply resolves no session // binding, which is the global default. type sessionPersonaReader interface { SessionPersona(ctx context.Context, sessionID string) (string, error) } // resolvePersona returns the role id one inbound turn runs as. target says where // the turn's conversation lives (the mirrored main session vs an isolated // session), sess carries the structurally resolved isolation boundary and account // is the receiving adapter's registration name (platform:confId) used for the // channel-level lookup. func (b *Bridge) resolvePersona(ctx context.Context, sess session.Session, target inboundTarget, account string) string { // design/22 §7-4: a group/stranger conversation never plays a bound role. if !target.private { return "" } // design/22 §7-3: an owner private chat mirrors into the main session, so it // answers as whatever role that session is bound to. if pid := b.mainSessionPersona(ctx); pid != "" { return pid } // design/22 §2 方案 b: the account's channel-level role, used only when the main // session binds no role of its own. return b.personaOfAdapter(account) } // mainSessionPersona returns the role bound to the main web session, or "" when // the session inherits the global default. A missing reader or a read error also // yields "": a chat must never be blocked by a metadata lookup, and the fallback is // the pre-existing default role. func (b *Bridge) mainSessionPersona(ctx context.Context) string { if b.history == nil { return "" } reader, ok := b.history.(sessionPersonaReader) if !ok { return "" } pid, err := reader.SessionPersona(ctx, history.MainSessionID) if err != nil { return "" } return strings.TrimSpace(pid) } // personaOfAdapter returns the cached channel-level role of one adapter // registration name, or "" when none is bound (or none was resolved at connect // time). func (b *Bridge) personaOfAdapter(account string) string { b.mu.RLock() defer b.mu.RUnlock() return b.adapterPersona[account] } // rememberAdapterPersona resolves and caches the channel-level role of an adapter // that just connected. Resolving at connect/reload time (rather than per message) // keeps the inbound hot path free of config reads, while a panel save still takes // effect immediately: it writes channels.json and calls Reload, which rebuilds and // reconnects the platform's adapters, refreshing this cache. func (b *Bridge) rememberAdapterPersona(account string) { if b.channelPersona == nil { return } platform, accountID, _ := strings.Cut(account, ":") pid := strings.TrimSpace(b.channelPersona(platform, accountID)) b.mu.Lock() defer b.mu.Unlock() if pid == "" { delete(b.adapterPersona, account) return } b.adapterPersona[account] = pid } // forgetAdapterPersona drops an adapter's cached channel role when the account is // removed (or renamed) by a reload, so a removed account can never keep answering // as a role that is no longer configured. func (b *Bridge) forgetAdapterPersona(account string) { b.mu.Lock() defer b.mu.Unlock() delete(b.adapterPersona, account) }