Files
glchat/internal/store/webhooks.go
T
grendervill 2e038a1d3f feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
2026-09-21 00:26:50 +03:00

128 lines
4.1 KiB
Go

package store
import (
"context"
"database/sql"
"time"
)
// Webhook — вебхук комнаты (AGENT.md 7.11): отправляет сообщения без сессии.
type Webhook struct {
ID uint64
GuildID uint64
ChannelID uint64
Name string
AvatarFileID *uint64
Token string
CreatedBy *uint64
CreatedAt time.Time
}
// CreateWebhookParams — параметры нового вебхука.
type CreateWebhookParams struct {
ID uint64
GuildID uint64
ChannelID uint64
Name string
AvatarFileID *uint64
Token string
CreatedBy uint64
}
// UpdateWebhookParams — новое состояние вебхука: имя, аватар и токен
// передаются целиком, чтобы пересоздание токена было обычной правкой.
type UpdateWebhookParams struct {
Name string
AvatarFileID *uint64
Token string
}
const webhookColumns = `id, guild_id, channel_id, name, avatar_file_id, token,
created_by, created_at`
// CreateWebhook сохраняет вебхук комнаты.
func (s *Store) CreateWebhook(ctx context.Context, params CreateWebhookParams) (*Webhook, error) {
if params.ID == 0 {
params.ID = s.NextID()
}
_, err := s.writer.ExecContext(ctx, `
INSERT INTO webhooks (id, guild_id, channel_id, name, avatar_file_id, token, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
int64(params.ID), int64(params.GuildID), int64(params.ChannelID), params.Name,
nullableID(params.AvatarFileID), params.Token, int64(params.CreatedBy), s.Now())
if err != nil {
return nil, mapError(err)
}
return s.GetWebhook(ctx, params.ID)
}
func (s *Store) GetWebhook(ctx context.Context, id uint64) (*Webhook, error) {
row := s.reader.QueryRowContext(ctx, `SELECT `+webhookColumns+` FROM webhooks WHERE id = ?`, int64(id))
return scanWebhook(row)
}
// ListChannelWebhooks возвращает вебхуки комнаты: их показывает панель
// управления комнатой (AGENT.md 7.11).
func (s *Store) ListChannelWebhooks(ctx context.Context, channelID uint64) ([]Webhook, error) {
rows, err := s.reader.QueryContext(ctx,
`SELECT `+webhookColumns+` FROM webhooks WHERE channel_id = ? ORDER BY id`, int64(channelID))
if err != nil {
return nil, err
}
defer rows.Close()
webhooks := make([]Webhook, 0, 8)
for rows.Next() {
webhook, err := scanWebhook(rows)
if err != nil {
return nil, err
}
webhooks = append(webhooks, *webhook)
}
return webhooks, rows.Err()
}
// UpdateWebhook заменяет имя, аватар и токен вебхука.
func (s *Store) UpdateWebhook(ctx context.Context, id uint64, params UpdateWebhookParams) (*Webhook, error) {
result, err := s.writer.ExecContext(ctx,
`UPDATE webhooks SET name = ?, avatar_file_id = ?, token = ? WHERE id = ?`,
params.Name, nullableID(params.AvatarFileID), params.Token, int64(id))
if err != nil {
return nil, mapError(err)
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return nil, ErrNotFound
}
return s.GetWebhook(ctx, id)
}
// DeleteWebhook удаляет вебхук; уже отправленные сообщения остаются (в них
// хранится снимок имени и аватара).
func (s *Store) DeleteWebhook(ctx context.Context, id uint64) error {
result, err := s.writer.ExecContext(ctx, `DELETE FROM webhooks WHERE id = ?`, int64(id))
if err != nil {
return mapError(err)
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return ErrNotFound
}
return nil
}
func scanWebhook(scanner interface{ Scan(...any) error }) (*Webhook, error) {
var (
webhook Webhook
avatar sql.NullInt64
createdBy sql.NullInt64
createdAt string
)
if err := scanner.Scan(&webhook.ID, &webhook.GuildID, &webhook.ChannelID, &webhook.Name, &avatar,
&webhook.Token, &createdBy, &createdAt); err != nil {
return nil, mapError(err)
}
webhook.AvatarFileID = optionalID(avatar)
webhook.CreatedBy = optionalID(createdBy)
webhook.CreatedAt = parseTimestamp(createdAt)
return &webhook, nil
}