f9464130cd
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
308 lines
9.3 KiB
Go
308 lines
9.3 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
|
|
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`
|
|
|
|
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
|
|
}
|
|
|
|
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 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
|
|
)
|
|
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)
|
|
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.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()
|
|
}
|