package gateway import ( "context" "errors" "fmt" "strconv" "time" "glchat/internal/permissions" "glchat/internal/store" ) // Snapshot собирает READY-снапшот из хранилища с учётом прав пользователя // (AGENT.md 8.3: только серверы и комнаты, которые он видит). type Snapshot struct { store *store.Store calculator *permissions.Calculator } // NewSnapshot собирает READY через переданный калькулятор прав: сервер и // Gateway должны использовать один кэш, иначе инвалидация не видна обоим. func NewSnapshot(st *store.Store, calculator *permissions.Calculator) *Snapshot { if calculator == nil { calculator = permissions.NewCalculator(newPermissionSource(st)) } return &Snapshot{store: st, calculator: calculator} } var ( _ SnapshotBuilder = (*Snapshot)(nil) _ Visibility = (*Snapshot)(nil) ) // CanViewChannel отвечает, видит ли пользователь комнату: используется для // адресной рассылки событий комнат (AGENT.md 8.3). func (s *Snapshot) CanViewChannel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) bool { resolved, err := s.calculator.Channel(ctx, guildID, channelID, userID, instanceAdmin) if err != nil { return false } return resolved.CanViewChannel() } func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) { ready := &Ready{ User: ReadyUser{ ID: formatID(user.ID), Username: user.Username, DisplayName: user.DisplayName, IsInstanceAdmin: user.IsInstanceAdmin, Badges: user.Badges, }, Guilds: []ReadyGuild{}, DMChannels: []ReadyChannel{}, ReadStates: []ReadyRead{}, } if ready.User.Badges == nil { ready.User.Badges = []string{} } if user.AvatarFileID != nil { ready.User.AvatarFileID = formatID(*user.AvatarFileID) } // Состояния прочтения: непрочитанные и упоминания синхронизируются между // устройствами пользователя (AGENT.md 7.16). readStates, err := s.store.ListReadStates(ctx, user.ID) if err != nil { return nil, fmt.Errorf("list read states: %w", err) } for _, state := range readStates { ready.ReadStates = append(ready.ReadStates, ReadyRead{ ChannelID: formatID(state.ChannelID), LastMessageID: formatID(state.LastMessageID), MentionCount: state.MentionCount, }) } // Личные беседы: попадают в отдельный список, чтобы не смешиваться с // серверами в интерфейсе (AGENT.md 7.8). dmChannels, err := s.store.ListDMChannels(ctx, user.ID) if err != nil { return nil, fmt.Errorf("list direct channels: %w", err) } for _, summary := range dmChannels { channel := ReadyChannel{ ID: formatID(summary.Channel.ID), Type: string(store.ChannelDM), Name: summary.RecipientName, CanSend: true, CanConnect: false, RecipientID: formatID(summary.RecipientID), Status: visibleStatus(summary.Status, summary.LastSeenAt), } if summary.AvatarFileID != nil { channel.IconFileID = formatID(*summary.AvatarFileID) } if summary.LastMessageID != 0 { channel.LastMessageID = formatID(summary.LastMessageID) } if summary.LastMessageAt != nil { channel.LastMessageAt = summary.LastMessageAt.UTC().Format(time.RFC3339) } ready.DMChannels = append(ready.DMChannels, channel) } guilds, err := s.store.ListGuildsForUser(ctx, user.ID) if err != nil { return nil, fmt.Errorf("list user guilds: %w", err) } for _, guild := range guilds { resolved, err := s.calculator.Guild(ctx, guild.ID, user.ID, user.IsInstanceAdmin) if err != nil { return nil, err } // Сервер без VIEW_GUILD не показываем (AGENT.md 6.2). if !resolved.Has(permissions.ViewGuild) { continue } readyGuild, err := s.buildGuild(ctx, guild, user, resolved) if err != nil { return nil, err } ready.Guilds = append(ready.Guilds, readyGuild) } return ready, nil } func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) { readyGuild := ReadyGuild{ ID: formatID(guild.ID), Name: guild.Name, OwnerID: formatID(guild.OwnerID), IsMain: guild.IsMain, AccentColor: guild.AccentColor, IconFileID: optionalFormatID(guild.IconFileID), BannerFileID: optionalFormatID(guild.BannerFileID), SplashFileID: optionalFormatID(guild.SplashFileID), Channels: []ReadyChannel{}, Roles: []ReadyRole{}, MemberIDs: []string{}, MyRoles: []string{}, MyPerms: readyPermissions(resolved), Emojis: []ReadyEmoji{}, VoiceStates: []ReadyVoiceState{}, Sounds: []ReadySound{}, } // Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте. emojis, err := s.store.ListGuildEmojis(ctx, guild.ID, 200) if err != nil { return readyGuild, err } for _, emoji := range emojis { token := "<:" + emoji.Name + ":" + formatID(emoji.FileID) + ">" if emoji.Animated { token = "" } readyGuild.Emojis = append(readyGuild.Emojis, ReadyEmoji{ ID: formatID(emoji.ID), Name: emoji.Name, FileID: formatID(emoji.FileID), Animated: emoji.Animated, Token: token, }) } // Звуки сервера: саундборд и палитра интерфейса. sounds, err := s.store.ListGuildSounds(ctx, guild.ID, "", 100) if err != nil { return readyGuild, err } for _, sound := range sounds { readyGuild.Sounds = append(readyGuild.Sounds, ReadySound{ ID: formatID(sound.ID), Name: sound.Name, FileID: formatID(sound.FileID), Kind: string(sound.Kind), Event: sound.Event, Emoji: sound.Emoji, }) } // Кто сейчас в голосовых комнатах сервера. voiceStates, err := s.store.ListVoiceStates(ctx, guild.ID) if err != nil { return readyGuild, err } for _, state := range voiceStates { readyGuild.VoiceStates = append(readyGuild.VoiceStates, ReadyVoiceState{ UserID: formatID(state.UserID), ChannelID: formatID(state.ChannelID), SelfMute: state.SelfMute, SelfDeaf: state.SelfDeaf, ServerMute: state.ServerMute, ServerDeaf: state.ServerDeaf, Camera: state.Camera, Screen: state.Screen, }) } roles, err := s.store.ListGuildRoles(ctx, guild.ID) if err != nil { return readyGuild, err } for _, role := range roles { readyGuild.Roles = append(readyGuild.Roles, ReadyRole{ ID: formatID(role.ID), Name: role.Name, Color: role.Color, Position: role.Position, Permissions: strconv.FormatUint(role.Permissions, 10), IsDefault: role.IsDefault, Mentionable: role.Mentionable, Hoist: role.Hoist, }) } memberRoles, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID) if err != nil { return readyGuild, err } for _, roleID := range memberRoles { readyGuild.MyRoles = append(readyGuild.MyRoles, formatID(roleID)) } channels, err := s.store.ListGuildChannels(ctx, guild.ID) if err != nil { return readyGuild, err } for _, channel := range channels { // Категории не запрашивают права: они видны вместе с сервером. if channel.Type == store.ChannelCategory && !resolved.Has(permissions.ManageChannels) { readyGuild.Channels = append(readyGuild.Channels, channelPayload(channel, false, false)) continue } channelResolved, err := s.calculator.Channel(ctx, guild.ID, channel.ID, user.ID, user.IsInstanceAdmin) if err != nil { return readyGuild, err } if !channelResolved.CanViewChannel() { continue } readyGuild.Channels = append(readyGuild.Channels, channelPayload(channel, channelResolved.Can(permissions.SendMessages), channelResolved.Can(permissions.ConnectVoice))) } members, err := s.store.ListGuildMembers(ctx, guild.ID) if err != nil { return readyGuild, err } for _, member := range members { readyGuild.MemberIDs = append(readyGuild.MemberIDs, formatID(member.UserID)) if member.UserID == user.ID { readyGuild.MyNickname = member.Nickname } } return readyGuild, nil } // readyPermissions отдаёт права пользователя на сервере списком имён — тем же // движком и в том же виде, что REST (`permissions.Names`). Без них клиент // выключал композер и прятал управление сервером (AGENT.md 11.6). func readyPermissions(resolved permissions.Resolved) []string { // У инстанс-админа права максимальные на любом сервере, включая чужой, где // он не участник (AGENT.md 7.19): в маске прав роли их нет. if resolved.IsInstanceAdmin { return permissions.Names(permissions.AllPermissions) } return permissions.Names(resolved.Guild) } func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel { payload := ReadyChannel{ ID: formatID(channel.ID), Type: string(channel.Type), Name: channel.Name, Description: channel.Description, Position: channel.Position, UserLimit: channel.UserLimit, Slowmode: channel.SlowmodeSeconds, CanSend: canSend, CanConnect: canConnect, } if channel.GuildID != nil { payload.GuildID = formatID(*channel.GuildID) } if channel.BackgroundFileID != nil { payload.BackgroundFileID = formatID(*channel.BackgroundFileID) } if channel.ParentID != nil { payload.ParentID = formatID(*channel.ParentID) } return payload } // permissionSource читает роли, оверрайды и участников для движка прав. type permissionSource struct{ store *store.Store } func newPermissionSource(st *store.Store) *permissionSource { return &permissionSource{store: st} } func (p *permissionSource) GuildRoles(ctx context.Context, guildID uint64) ([]permissions.RoleData, error) { roles, err := p.store.ListGuildRoles(ctx, guildID) if err != nil { return nil, err } result := make([]permissions.RoleData, 0, len(roles)) for _, role := range roles { result = append(result, permissions.RoleData{ ID: role.ID, Permissions: permissions.Permission(role.Permissions), IsDefault: role.IsDefault, Position: role.Position, }) } return result, nil } func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) { return p.store.MemberRoleIDs(ctx, guildID, userID) } func (p *permissionSource) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) { if _, err := p.store.GetGuildMember(ctx, guildID, userID); err != nil { if errors.Is(err, store.ErrNotFound) { return false, nil } return false, err } return true, nil } func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) { overrides, err := p.store.ListChannelOverrides(ctx, channelID) if err != nil { return nil, err } result := make([]permissions.OverrideData, 0, len(overrides)) for _, override := range overrides { result = append(result, permissions.OverrideData{ TargetType: override.TargetType, TargetID: override.TargetID, Allow: permissions.Permission(override.Allow), Deny: permissions.Permission(override.Deny), }) } return result, nil } func (p *permissionSource) GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error) { guild, err := p.store.GetGuild(ctx, guildID) if err != nil { return 0, err } return guild.OwnerID, nil } func (p *permissionSource) MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error) { member, err := p.store.GetGuildMember(ctx, guildID, userID) if err != nil { // Участника нет — тайм-аут не действует; прочие ошибки чтения не // должны блокировать выдачу прав (AGENT.md 7.4). return false, nil //nolint:nilerr // отсутствие участника не является ошибкой прав } if member.TimeoutUntil == nil { return false, nil } return member.TimeoutUntil.After(time.Now().UTC()), nil } // visibleStatus вычисляет статус, который видят другие: «невидимка» выглядит // как офлайн, как и пользователь без активности дольше двух минут. func visibleStatus(status string, lastSeen *time.Time) string { if status == "invisible" { return "offline" } if status == "" { status = "online" } if lastSeen != nil && time.Since(*lastSeen) > 2*time.Minute { return "offline" } return status } // optionalFormatID отдаёт идентификатор файла или пустую строку. func optionalFormatID(id *uint64) string { if id == nil || *id == 0 { return "" } return formatID(*id) } func formatID(id uint64) string { if id == 0 { return "" } var buf [20]byte pos := len(buf) for id > 0 { pos-- buf[pos] = byte('0' + id%10) id /= 10 } return string(buf[pos:]) }