Files
glchat/internal/gateway/ready.go
T

390 lines
12 KiB
Go
Raw Normal View History

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: []string{},
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 = "<a:" + emoji.Name + ":" + formatID(emoji.FileID) + ">"
}
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
}
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
}
// 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:])
}