33dc1fec96
По запросу пользователя (вне очереди AGENT.md §13): - главного сервера как точки входа больше нет: новичок начинает с пустым списком серверов, вход — только по приглашению или созданием своего; - друзья: поиск по логину с экранированием LIKE, заявки (POST /users/@me/relationships), принятие, удаление/отклонение, списки friends/ incoming/outgoing/blocked; - личные беседы: POST /users/@me/channels (идемпотентно), GET /users/@me/channels со собеседником, статусом и последним сообщением; сообщения в DM работают через общие ручки комнат, доступ — только участникам (посторонний получает 404, события в Gateway тоже фильтруются); - присутствие: last_seen_at обновляется при активности, «невидимка» и простой дольше двух минут выглядят как офлайн, смена статуса рассылает PRESENCE_UPDATE друзьям; - READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение); - профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me, загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл удаляется с диска; - тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
322 lines
10 KiB
Go
322 lines
10 KiB
Go
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,
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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:])
|
|
}
|