0b55fbdfe2
Звонки 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.
476 lines
16 KiB
Go
476 lines
16 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.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:])
|
||
}
|