Files
glchat/internal/gateway/ready.go
T
grendervill 0b55fbdfe2 feat(dm): ручки и события звонков в беседах (Фаза 7)
Звонки 1:1 и в групповых беседах до 10 участников поверх той же голосовой
инфраструктуры LiveKit: те же токены, что у серверных комнат, но без
серверных ролей — позвонить может участник беседы.

Ручки: POST /channels/{id}/call (создание, в ответе токен и адрес
сигналинга), accept, decline, end, PATCH /call/@me для флагов микрофона,
камеры и шаринга, GET /call (текущий звонок) и GET /calls (история: кто
звонил, когда, сколько длился, кто пропустил). Постороннему — 404,
существование чужой беседы не подтверждаем; в беседе больше десяти
участников звонить нельзя (dm.call_limit); выключенный голос отвечает
voice.disabled.

События: DM_CALL_RING приходит приглашённым, DM_CALL_UPDATE — участникам
при смене состояния, DM_CALL_END — с итогом и причиной. Звонок добавлен в
READY и в карточку беседы (active_call), поэтому «идёт звонок» переживает
перезагрузку. Вебхук LiveKit разбирает комнаты dm_call_* и синхронизирует
состояние, сторож голосовых состояний закрывает гудки без ответа и
опустевшие комнаты.

Тесты: жизненный цикл 1:1, отклонение и пропущенный, отказ постороннему
на всех ручках, предел десяти участников, поведение при выключенном
голосе, события Gateway и active_call в READY.
2026-09-26 17:02:55 +03:00

476 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.IsGroup {
// Групповая беседа: имя и состав вместо собеседника, иконка — своя
// загрузка (D-070), а не аватар участника.
channel.IsGroup = true
channel.Name = summary.GroupName
channel.MemberCount = summary.MemberCount
channel.MemberIDs = make([]string, 0, len(summary.Participants))
for _, participant := range summary.Participants {
channel.MemberIDs = append(channel.MemberIDs, formatID(participant.UserID))
}
if summary.OwnerID != nil {
channel.OwnerID = formatID(*summary.OwnerID)
}
if summary.Channel.IconFileID != nil {
channel.IconFileID = formatID(*summary.Channel.IconFileID)
}
} else if summary.AvatarFileID != nil {
// У 1:1 иконка беседы — аватар собеседника (AGENT.md 7.8).
channel.IconFileID = formatID(*summary.AvatarFileID)
}
// Звонок в беседе: клиент рисует маркер «идёт звонок» сразу после
// подключения, а не только по событию (AGENT.md 7.8).
if call, err := s.store.GetLiveDMCall(ctx, summary.Channel.ID); err == nil {
channel.ActiveCall = readyDMCall(call)
}
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)
}
// Инстанс-админ видит в снапшоте все серверы инстанса, включая чужие и
// приватные, где он не участник: без этого он не мог войти в чужой сервер
// из интерфейса, хотя права у него есть везде (AGENT.md 7.19, 11.5).
guilds, err := s.userGuilds(ctx, user)
if err != nil {
return nil, 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
}
// userGuilds выбирает серверы для снапшота: обычному пользователю — только
// свои, администратору инстанса — все серверы инстанса (AGENT.md 7.19, 11.5).
func (s *Snapshot) userGuilds(ctx context.Context, user *store.User) ([]store.Guild, error) {
if user.IsInstanceAdmin {
guilds, err := s.store.ListAllGuilds(ctx)
if err != nil {
return nil, fmt.Errorf("list instance guilds: %w", err)
}
return guilds, nil
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err)
}
return guilds, 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 = "<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
}
// 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)
}
// readyDMCall переводит звонок из хранилища в форму снапшота: та же форма, что
// у событий DM_CALL_* и REST-ручек звонков (AGENT.md 8.3).
func readyDMCall(call *store.DMCallWithParticipants) *ReadyDMCall {
payload := &ReadyDMCall{
ID: formatID(call.ID),
ChannelID: formatID(call.ChannelID),
InitiatorID: formatID(call.InitiatorID),
Status: call.Status,
Media: call.Media,
CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339),
Participants: make([]ReadyDMCallParticipant, 0, len(call.Participants)),
}
if call.StartedAt != nil {
payload.StartedAt = call.StartedAt.UTC().Format(time.RFC3339)
}
for _, participant := range call.Participants {
payload.Participants = append(payload.Participants, ReadyDMCallParticipant{
UserID: formatID(participant.UserID),
State: participant.State,
SelfMute: participant.SelfMute,
SelfDeaf: participant.SelfDeaf,
Camera: participant.Camera,
Screen: participant.Screen,
})
}
return payload
}
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:])
}