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) 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) } 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, Channels: []ReadyChannel{}, Roles: []ReadyRole{}, MemberIDs: []string{}, MyRoles: []string{}, MyPerms: []string{}, } 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 } func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel { payload := ReadyChannel{ ID: formatID(channel.ID), Type: string(channel.Type), Name: channel.Name, Position: channel.Position, UserLimit: channel.UserLimit, Slowmode: channel.SlowmodeSeconds, CanSend: canSend, CanConnect: canConnect, } if channel.GuildID != nil { payload.GuildID = formatID(*channel.GuildID) } 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 } 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:]) }