feat(gateway): WebSocket Gateway с HELLO, IDENTIFY и READY

Реализован Gateway по AGENT.md §8.3:

- оп-коды 0/1/2/3/4/7/10/11, единый конверт {op,t,d,s};
- HELLO с интервалом сердцебиения (45 с) и идентификатором сессии;
- IDENTIFY/RESUME с проверкой токена через auth.ResolveSession;
- READY: пользователь, серверы, видимые комнаты, роли, участники и права,
  собранные через движок прав (VIEW_CHANNEL-фильтрация);
- HEARTBEAT → HEARTBEAT_ACK, лимит частоты IDENTIFY (5 с);
- RESUME: буфер на 1000 событий с TTL 5 минут, догон пропущенных событий и
  подтверждение RESUMED, полный READY при расхождении состояния;
- рассылка Dispatch/SendToUser с буферизацией для отключённых сессий;
- проверка Origin по allowlist доменов инстанса (AGENT.md §9.7);
- httpx.statusRecorder пробрасывает Hijack, иначе апгрейд ломается.

Тесты: 6 сценариев Gateway (READY, скрытые комнаты, неверный токен,
heartbeat, dispatch, RESUME) и 2 теста маршрута /gateway через полный стек
middleware, включая отклонение чужого Origin.
This commit is contained in:
2026-09-19 21:42:41 +03:00
parent c0537d3c17
commit 86dff94a02
11 changed files with 1364 additions and 18 deletions
+235
View File
@@ -0,0 +1,235 @@
package gateway
import (
"context"
"fmt"
"strconv"
"time"
"glchat/internal/permissions"
"glchat/internal/store"
)
// Snapshot собирает READY-снапшот из хранилища с учётом прав пользователя
// (AGENT.md 8.3: только серверы и комнаты, которые он видит).
type Snapshot struct {
store *store.Store
calculator *permissions.Calculator
}
func NewSnapshot(st *store.Store) *Snapshot {
return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))}
}
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) 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:])
}