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