Files
glchat/internal/store/channels.go
T
grendervill 8696d85514 feat(dm): иконка групповой беседы в хранилище и API (Фаза 7)
D-070: у групповой беседы не было своей иконки — показывались имя и первая
буква аватара, как у 1:1 без аватара. Колонки под файл у канала не было.

Миграция 00024 добавляет `channels.icon_file_id` (REFERENCES files ON DELETE
SET NULL): NULL у 1:1 и комнат сервера, файл — у группы. Store читает и пишет
поле через `Channel`/`UpdateChannelParams` (`IconFileID`/`ClearIcon`), отдаёт
его в `ListDMChannels` и в READY, а уборка сирот больше не считает иконку
беседы мусором. Когда в группе остаётся два участника, беседа снова обычная
личная — иконка сбрасывается вместе с именем и владельцем.

Ручки `POST/DELETE /channels/{id}/icon` принимают multipart (назначение файла
`dm_icon`, аватарный лимит, проверка `image/`) и доступны только владельцу
беседы: участнику-не-владельцу 403 `perm.denied`, посторонним 404 (существование
чужой беседы не подтверждаем), у 1:1 своей иконки нет — 422 `dm.not_group`.
Файл иконки отдаётся только участникам беседы. После изменения участникам
уходит `DM_CHANNEL_UPDATE`, поэтому иконка меняется без перезагрузки
(AGENT.md 7.7, 7.8, 8.3, 11.6; D-042, D-070).
2026-09-26 16:48:01 +03:00

325 lines
10 KiB
Go

package store
import (
"context"
"database/sql"
"strings"
"time"
)
type ChannelType string
const (
ChannelText ChannelType = "text"
ChannelVoice ChannelType = "voice"
ChannelCategory ChannelType = "category"
ChannelDM ChannelType = "dm"
)
type Channel struct {
ID uint64
GuildID *uint64
Type ChannelType
Name string
Description string
Position int
ParentID *uint64
SlowmodeSeconds int
NSFW bool
Background string
// BackgroundFileID — фон комнаты: файл-картинка (AGENT.md 7.5).
BackgroundFileID *uint64
VoiceStatus string
UserLimit int
// DMOwnerID — владелец групповой личной беседы (AGENT.md 7.8): только он
// добавляет и удаляет участников и переименовывает беседу. У 1:1 — NULL.
DMOwnerID *uint64
// IconFileID — иконка групповой беседы (AGENT.md 7.8, D-070): обычная
// загрузка, менять её может только владелец. У 1:1 иконка — аватар
// собеседника, поэтому колонка пустая.
IconFileID *uint64
CreatedAt time.Time
}
type ChannelOverride struct {
ChannelID uint64
TargetType string // role | user
TargetID uint64
Allow uint64
Deny uint64
}
type CreateChannelParams struct {
ID uint64
GuildID *uint64
Type ChannelType
Name string
Description string
Position int
ParentID *uint64
SlowmodeSeconds int
NSFW bool
UserLimit int
}
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, dm_owner_id,
created_at, icon_file_id`
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO channels (id, guild_id, type, name, description, position, parent_id,
slowmode_seconds, nsfw, user_limit, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
mustID(params.ID), nullableID(params.GuildID), string(params.Type), params.Name,
params.Description, params.Position, nullableID(params.ParentID), params.SlowmodeSeconds,
boolToInt(params.NSFW), params.UserLimit, s.Now())
if err != nil {
return nil, err
}
return s.GetChannel(ctx, params.ID)
}
func (s *Store) GetChannel(ctx context.Context, id uint64) (*Channel, error) {
return scanChannel(s.reader.QueryRowContext(ctx, `SELECT `+channelColumns+` FROM channels WHERE id = ?`, int64(id)))
}
func (s *Store) ListGuildChannels(ctx context.Context, guildID uint64) ([]Channel, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT `+channelColumns+` FROM channels WHERE guild_id = ? ORDER BY position, id`, int64(guildID))
if err != nil {
return nil, err
}
return collectChannels(rows)
}
type UpdateChannelParams struct {
Name *string
Description *string
Position *int
ParentID *uint64
ClearParent bool
SlowmodeSeconds *int
NSFW *bool
Background *string
// BackgroundFileID задаёт новый фон, ClearBackground снимает его.
BackgroundFileID *uint64
ClearBackground bool
VoiceStatus *string
UserLimit *int
// IconFileID задаёт иконку групповой беседы, ClearIcon снимает её (D-070).
IconFileID *uint64
ClearIcon bool
}
func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) {
sets := []string{}
args := []any{}
if params.Name != nil {
sets = append(sets, "name = ?")
args = append(args, *params.Name)
}
if params.Description != nil {
sets = append(sets, "description = ?")
args = append(args, *params.Description)
}
if params.Position != nil {
sets = append(sets, "position = ?")
args = append(args, *params.Position)
}
if params.ParentID != nil {
sets = append(sets, "parent_id = ?")
args = append(args, int64(*params.ParentID))
}
if params.ClearParent {
sets = append(sets, "parent_id = NULL")
}
if params.SlowmodeSeconds != nil {
sets = append(sets, "slowmode_seconds = ?")
args = append(args, *params.SlowmodeSeconds)
}
if params.NSFW != nil {
sets = append(sets, "nsfw = ?")
args = append(args, boolToInt(*params.NSFW))
}
if params.Background != nil {
sets = append(sets, "background = ?")
args = append(args, nullableString(*params.Background))
}
if params.BackgroundFileID != nil {
sets = append(sets, "background_file_id = ?")
args = append(args, int64(*params.BackgroundFileID))
}
if params.ClearBackground {
sets = append(sets, "background_file_id = NULL")
}
if params.VoiceStatus != nil {
sets = append(sets, "voice_status = ?")
args = append(args, *params.VoiceStatus)
}
if params.UserLimit != nil {
sets = append(sets, "user_limit = ?")
args = append(args, *params.UserLimit)
}
if params.IconFileID != nil {
sets = append(sets, "icon_file_id = ?")
args = append(args, int64(*params.IconFileID))
}
if params.ClearIcon {
sets = append(sets, "icon_file_id = NULL")
}
if len(sets) == 0 {
return s.GetChannel(ctx, id)
}
idValue, err := idToInt(id)
if err != nil {
return nil, err
}
args = append(args, idValue)
result, err := s.writer.ExecContext(ctx, buildQuery(updateChannelTemplate, strings.Join(sets, ", ")), args...)
if err != nil {
return nil, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetChannel(ctx, id)
}
func (s *Store) DeleteChannel(ctx context.Context, id uint64) error {
idValue, err := idToInt(id)
if err != nil {
return err
}
_, err = s.writer.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, idValue)
return err
}
// SetChannelOverride создаёт или заменяет оверрайд прав на комнату.
func (s *Store) SetChannelOverride(ctx context.Context, override ChannelOverride) error {
_, err := s.writer.ExecContext(ctx, `
INSERT INTO channel_overrides (channel_id, target_type, target_id, allow, deny)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (channel_id, target_type, target_id) DO UPDATE SET
allow = excluded.allow, deny = excluded.deny`,
int64(override.ChannelID), override.TargetType, int64(override.TargetID),
int64(override.Allow), int64(override.Deny))
return err
}
// ListGuildChannelOverrides отдаёт оверрайды всех комнат сервера одним
// запросом: список комнат в API не должен превращаться в N+1 (AGENT.md 6.2).
func (s *Store) ListGuildChannelOverrides(ctx context.Context, guildID uint64) (map[uint64][]ChannelOverride, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT o.channel_id, o.target_type, o.target_id, o.allow, o.deny
FROM channel_overrides o
JOIN channels c ON c.id = o.channel_id
WHERE c.guild_id = ?`, int64(guildID))
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[uint64][]ChannelOverride)
for rows.Next() {
var (
override ChannelOverride
allow, deny int64
)
if err := rows.Scan(&override.ChannelID, &override.TargetType, &override.TargetID, &allow, &deny); err != nil {
return nil, err
}
override.Allow = intToID(allow)
override.Deny = intToID(deny)
result[override.ChannelID] = append(result[override.ChannelID], override)
}
return result, rows.Err()
}
// DeleteChannelOverride снимает оверрайд: признак «был ли он» нужен аудиту.
func (s *Store) DeleteChannelOverride(ctx context.Context, channelID uint64, targetType string, targetID uint64) (bool, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM channel_overrides WHERE channel_id = ? AND target_type = ? AND target_id = ?`,
int64(channelID), targetType, int64(targetID))
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]ChannelOverride, error) {
rows, err := s.reader.QueryContext(ctx, `
SELECT channel_id, target_type, target_id, allow, deny
FROM channel_overrides WHERE channel_id = ?`, int64(channelID))
if err != nil {
return nil, err
}
defer rows.Close()
overrides := make([]ChannelOverride, 0, 4)
for rows.Next() {
var (
override ChannelOverride
allow, deny int64
)
if err := rows.Scan(&override.ChannelID, &override.TargetType, &override.TargetID, &allow, &deny); err != nil {
return nil, err
}
override.Allow = intToID(allow)
override.Deny = intToID(deny)
overrides = append(overrides, override)
}
return overrides, rows.Err()
}
func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
var (
channel Channel
guildID sql.NullInt64
parentID sql.NullInt64
nsfw int
background sql.NullString
backgroundID sql.NullInt64
dmOwnerID sql.NullInt64
createdAt string
iconID sql.NullInt64
)
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &dmOwnerID, &createdAt, &iconID)
if err != nil {
return nil, mapError(err)
}
channel.GuildID = optionalID(guildID)
channel.ParentID = optionalID(parentID)
channel.NSFW = nsfw == 1
if background.Valid {
channel.Background = background.String
}
channel.BackgroundFileID = optionalID(backgroundID)
channel.DMOwnerID = optionalID(dmOwnerID)
channel.IconFileID = optionalID(iconID)
channel.CreatedAt = parseTimestamp(createdAt)
return &channel, nil
}
func collectChannels(rows *sql.Rows) ([]Channel, error) {
defer rows.Close()
channels := make([]Channel, 0, 8)
for rows.Next() {
channel, err := scanChannel(rows)
if err != nil {
return nil, err
}
channels = append(channels, *channel)
}
return channels, rows.Err()
}