package chatclient import ( "context" "sync" "time" pb "gitea.fe.kaien.ru/kaien/l-ai-chat-proto" ) // ParticipantCache is a bounded in-memory cache keyed by author_id. // Callers should prefer BatchGetParticipants over N+1 GetParticipant. type ParticipantCache struct { mu sync.RWMutex byID map[string]*pb.Participant maxSize int } // NewParticipantCache creates a cache. maxSize <= 0 defaults to 512. func NewParticipantCache(maxSize int) *ParticipantCache { if maxSize <= 0 { maxSize = 512 } return &ParticipantCache{ byID: make(map[string]*pb.Participant), maxSize: maxSize, } } // Get returns a cached participant if present. func (c *ParticipantCache) Get(authorID string) (*pb.Participant, bool) { c.mu.RLock() defer c.mu.RUnlock() p, ok := c.byID[authorID] return p, ok } // Put stores a participant, preferring newer profile/presence versions. func (c *ParticipantCache) Put(p *pb.Participant) { if p == nil || p.GetAuthorId() == "" { return } c.mu.Lock() defer c.mu.Unlock() if existing, ok := c.byID[p.GetAuthorId()]; ok { if p.GetProfileVersion() < existing.GetProfileVersion() { // Keep newer profile fields but allow presence-only updates below. merged := cloneParticipant(existing) if p.GetPresenceVersion() > existing.GetPresenceVersion() { merged.Online = p.GetOnline() merged.PresenceVersion = p.GetPresenceVersion() merged.LastSeenAt = p.GetLastSeenAt() } c.byID[p.GetAuthorId()] = merged return } if p.GetProfileVersion() == existing.GetProfileVersion() && p.GetPresenceVersion() < existing.GetPresenceVersion() { return } } if len(c.byID) >= c.maxSize { // Simple eviction: clear half (bounded cache, not LRU). n := 0 for id := range c.byID { delete(c.byID, id) n++ if n >= c.maxSize/2 { break } } } c.byID[p.GetAuthorId()] = cloneParticipant(p) } // PutAll stores many participants. func (c *ParticipantCache) PutAll(ps []*pb.Participant) { for _, p := range ps { c.Put(p) } } // MissingIDs returns author IDs not present in the cache. func (c *ParticipantCache) MissingIDs(ids []string) []string { c.mu.RLock() defer c.mu.RUnlock() var missing []string seen := make(map[string]struct{}, len(ids)) for _, id := range ids { if id == "" { continue } if _, dup := seen[id]; dup { continue } seen[id] = struct{}{} if _, ok := c.byID[id]; !ok { missing = append(missing, id) } } return missing } func cloneParticipant(p *pb.Participant) *pb.Participant { if p == nil { return nil } out := &pb.Participant{ AuthorId: p.AuthorId, AuthorType: p.AuthorType, DisplayName: p.DisplayName, Role: p.Role, Bio: p.Bio, StatusText: p.StatusText, AvatarHash: p.AvatarHash, Online: p.Online, ProfileVersion: p.ProfileVersion, PresenceVersion: p.PresenceVersion, AgentId: p.AgentId, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, LastSeenAt: p.LastSeenAt, } if len(p.Aliases) > 0 { out.Aliases = make(map[string]string, len(p.Aliases)) for k, v := range p.Aliases { out.Aliases[k] = v } } return out } // EnsureParticipants fetches missing IDs via BatchGetParticipants and caches them. func (c *Client) EnsureParticipants(ctx context.Context, cache *ParticipantCache, authorIDs []string) error { if cache == nil { return nil } missing := cache.MissingIDs(authorIDs) if len(missing) == 0 { return nil } ps, err := c.BatchGetParticipants(ctx, missing) if err != nil { return err } cache.PutAll(ps) return nil } // ResolveDisplayName picks a localized alias then falls back to display_name. // locale examples: "ru-RU", "ru", "en-US". func ResolveDisplayName(p *pb.Participant, locale string) string { if p == nil { return "" } if locale != "" && len(p.Aliases) > 0 { if v, ok := p.Aliases[locale]; ok && v != "" { return v } // primary language subtag if i := indexByte(locale, '-'); i > 0 { lang := locale[:i] if v, ok := p.Aliases[lang]; ok && v != "" { return v } } } if p.DisplayName != "" { return p.DisplayName } return "" } func indexByte(s string, c byte) int { for i := 0; i < len(s); i++ { if s[i] == c { return i } } return -1 } // AvatarUploadNeeded reports whether localHash differs from the server hash // (empty local means skip; empty server means upload if local present). func AvatarUploadNeeded(serverHash, localHash string) bool { if localHash == "" { return false } return serverHash != localHash } // Idle helper kept for callers that want a soft TTL hint (cache itself is version-based). func CacheSoftTTL() time.Duration { return 5 * time.Minute }