Files
Cyrene_v2/internal/services/channelbridge/persona.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

115 lines
4.6 KiB
Go

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)
}