- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner - session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝 - 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效) - 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测) - 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
167 lines
5.9 KiB
Go
167 lines
5.9 KiB
Go
// Package user owns the "主人" (owner) profile and account whitelist that the
|
||
// channel-layer policy engine uses for identity recognition. Per DevDocs/design/11
|
||
// §0, owner identification is structural — channel actor metadata matched against
|
||
// this whitelist — and is never driven by prompt text or an LLM judgment.
|
||
package user
|
||
|
||
import (
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
// OwnerAccount is a single channel-fingerprint that belongs to the owner. The
|
||
// policy engine matches channel-layer actor metadata (platform + account id)
|
||
// against this list.
|
||
type OwnerAccount struct {
|
||
Platform string `yaml:"platform"`
|
||
AccountID string `yaml:"accountId"`
|
||
}
|
||
|
||
// OwnerProfile is the owner's identity record: a display name, the default
|
||
// persona bound to the owner, and the per-channel account whitelist. It is the
|
||
// source data for owner recognition; it is not injected into the persona's
|
||
// prompt as an authority.
|
||
//
|
||
// mu guards Accounts so a live profile can be hot-extended (RegisterAccount)
|
||
// while the channel bridge's ResolveActor reads it (IsOwner) concurrently. The
|
||
// mutex is unexported and carries no YAML tag, so it is invisible to encode and
|
||
// is not part of the persisted profile.
|
||
type OwnerProfile struct {
|
||
mu sync.RWMutex `yaml:"-"`
|
||
|
||
ID string `yaml:"id"`
|
||
DisplayName string `yaml:"displayName"`
|
||
DefaultPersona string `yaml:"defaultPersona"`
|
||
Accounts []OwnerAccount `yaml:"accounts"`
|
||
// IdentityFile is the path to the LLM-visible identity/relationship note
|
||
// (persona knowledge, NOT an access-control authority).
|
||
IdentityFile string `yaml:"identityFile,omitempty"`
|
||
Notes string `yaml:"notes,omitempty"`
|
||
|
||
// adminIDs 是「每渠道账号配置的管理员 id 列表」层(裁决 06.4.2):channels.json
|
||
// 里每个渠道账号可声明 adminIds[](design/08 §1.1、design/11 §0),加载/热重载时
|
||
// 由 SetPlatformAdmins 写入。它与 Accounts 白名单(owner.yaml)取并集后共同决定
|
||
// owner 车道,因此是内存态来源、不落盘——yaml:"-" 保证它不会被写回 owner.yaml。
|
||
adminIDs map[string][]string `yaml:"-"`
|
||
}
|
||
|
||
// IsOwner reports whether the given (platform, accountID) pair belongs to the
|
||
// owner according to the whitelist. It is safe for concurrent use with
|
||
// RegisterAccount (the bridge reads this on every inbound message while a
|
||
// scan-login registration may extend the whitelist).
|
||
//
|
||
// 判主只看两处结构化来源(裁决 06.4.2):owner.yaml 的 Accounts 白名单与每渠道配置的
|
||
// adminIds 层;空的 accountID 一律不算命中,避免白名单里的空占位把空发送者 id 判成主人。
|
||
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||
if p == nil || accountID == "" {
|
||
return false
|
||
}
|
||
p.mu.RLock()
|
||
defer p.mu.RUnlock()
|
||
for _, a := range p.Accounts {
|
||
if a.Platform == platform && a.AccountID == accountID {
|
||
return true
|
||
}
|
||
}
|
||
for _, id := range p.adminIDs[platform] {
|
||
if id == accountID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// RegisterAccount appends (platform, accountID) to the whitelist if it is not
|
||
// already present, leaving the profile otherwise untouched. It is the in-memory
|
||
// counterpart of RegisterOwnerAccount: the channel bridge holds the very same
|
||
// *OwnerProfile, so a scan-login confirmation can extend the whitelist without a
|
||
// restart. Duplicate (platform, accountID) pairs are a no-op, matching IsOwner.
|
||
func (p *OwnerProfile) RegisterAccount(platform, accountID string) {
|
||
if p == nil {
|
||
return
|
||
}
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
for _, a := range p.Accounts {
|
||
if a.Platform == platform && a.AccountID == accountID {
|
||
return
|
||
}
|
||
}
|
||
p.Accounts = append(p.Accounts, OwnerAccount{Platform: platform, AccountID: accountID})
|
||
}
|
||
|
||
// SetPlatformAdmins 用 ids 整体替换 platform 的每渠道管理员层(裁决 06.4.2)。整体
|
||
// 替换(而不是追加)语义保证 channels.json 的一次热重载不会留下已被移除的管理员 id;
|
||
// 空列表删除该平台条目。它只影响这一层,owner.yaml 的 Accounts 白名单不受影响。
|
||
// 与 IsOwner/RegisterAccount 并发安全。
|
||
func (p *OwnerProfile) SetPlatformAdmins(platform string, ids []string) {
|
||
if p == nil || platform == "" {
|
||
return
|
||
}
|
||
clean := normalizeAccountIDs(ids)
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if len(clean) == 0 {
|
||
delete(p.adminIDs, platform)
|
||
return
|
||
}
|
||
if p.adminIDs == nil {
|
||
p.adminIDs = make(map[string][]string, 1)
|
||
}
|
||
p.adminIDs[platform] = clean
|
||
}
|
||
|
||
// PlatformAdmins 返回 platform 当前声明的管理员 id 层副本(诊断/展示用),未声明时返回
|
||
// nil。与 SetPlatformAdmins 并发安全。
|
||
func (p *OwnerProfile) PlatformAdmins(platform string) []string {
|
||
if p == nil || platform == "" {
|
||
return nil
|
||
}
|
||
p.mu.RLock()
|
||
defer p.mu.RUnlock()
|
||
ids := p.adminIDs[platform]
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
return append([]string(nil), ids...)
|
||
}
|
||
|
||
// normalizeAccountIDs 清理配置里声明的管理员 id:去空白、丢弃空串、按首次出现顺序
|
||
// 去重。它保证 channels.json 里的重复/空白条目不会污染白名单。
|
||
func normalizeAccountIDs(ids []string) []string {
|
||
out := make([]string, 0, len(ids))
|
||
seen := make(map[string]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
trimmed := strings.TrimSpace(id)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[trimmed]; ok {
|
||
continue
|
||
}
|
||
seen[trimmed] = struct{}{}
|
||
out = append(out, trimmed)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AccountIdentifiers returns the distinct channel account ids owned by the
|
||
// owner, for diagnostics / listing. Safe for concurrent use with RegisterAccount.
|
||
func (p *OwnerProfile) AccountIdentifiers() []string {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
p.mu.RLock()
|
||
defer p.mu.RUnlock()
|
||
seen := make(map[string]struct{}, len(p.Accounts))
|
||
out := make([]string, 0, len(p.Accounts))
|
||
for _, a := range p.Accounts {
|
||
if _, ok := seen[a.Platform+":"+a.AccountID]; ok {
|
||
continue
|
||
}
|
||
seen[a.Platform+":"+a.AccountID] = struct{}{}
|
||
out = append(out, a.Platform+":"+a.AccountID)
|
||
}
|
||
return out
|
||
}
|