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-снапшота
This commit is contained in:
@@ -21,6 +21,9 @@ const (
|
||||
MessageAction MessageType = "action"
|
||||
MessageScream MessageType = "scream"
|
||||
MessagePrivate MessageType = "private"
|
||||
// MessageWebhook — сообщение вебхука (AGENT.md 7.11): у него нет автора,
|
||||
// а имя и аватар берутся из снимка в самом сообщении.
|
||||
MessageWebhook MessageType = "webhook"
|
||||
)
|
||||
|
||||
// IsPrivate сообщает, что сообщение видно только автору и адресатам.
|
||||
@@ -39,6 +42,10 @@ type Message struct {
|
||||
Attachments []Attachment
|
||||
Mentions []uint64
|
||||
CreatedAt time.Time
|
||||
// Поля вебхука (AGENT.md 7.11): идентификатор и снимок имени с аватаром.
|
||||
WebhookID *uint64
|
||||
WebhookName string
|
||||
WebhookAvatar string
|
||||
}
|
||||
|
||||
// Attachment — метаданные вложения: файл регистрируется в таблице files.
|
||||
@@ -69,7 +76,8 @@ type ReadState struct {
|
||||
}
|
||||
|
||||
const messageColumns = `id, channel_id, author_id, content, reply_to_id, type,
|
||||
attachments_json, mentions_json, edited_at, pinned, created_at, content_lower`
|
||||
attachments_json, mentions_json, edited_at, pinned, created_at, content_lower,
|
||||
webhook_id, webhook_name, webhook_avatar`
|
||||
|
||||
// CreateMessageParams — параметры нового сообщения.
|
||||
type CreateMessageParams struct {
|
||||
@@ -81,6 +89,10 @@ type CreateMessageParams struct {
|
||||
Type MessageType
|
||||
Attachments []Attachment
|
||||
Mentions []uint64
|
||||
// Заполняется только вебхуками: автора-пользователя у них нет.
|
||||
WebhookID *uint64
|
||||
WebhookName string
|
||||
WebhookAvatar string
|
||||
}
|
||||
|
||||
// CreateMessage сохраняет сообщение; пустое содержимое без вложений запрещено
|
||||
@@ -106,11 +118,13 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) (
|
||||
}
|
||||
_, err = s.writer.ExecContext(ctx, `
|
||||
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
|
||||
attachments_json, mentions_json, pinned, created_at, content_lower)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
attachments_json, mentions_json, pinned, created_at, content_lower,
|
||||
webhook_id, webhook_name, webhook_avatar)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`,
|
||||
int64(params.ID), int64(params.ChannelID), author, params.Content,
|
||||
nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions),
|
||||
s.Now(), strings.ToLower(params.Content))
|
||||
s.Now(), strings.ToLower(params.Content), nullableID(params.WebhookID),
|
||||
params.WebhookName, params.WebhookAvatar)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -568,9 +582,11 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
|
||||
pinned int
|
||||
createdAt string
|
||||
contentLow string
|
||||
webhookID sql.NullInt64
|
||||
)
|
||||
err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID,
|
||||
&message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt, &contentLow)
|
||||
&message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt, &contentLow,
|
||||
&webhookID, &message.WebhookName, &message.WebhookAvatar)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
@@ -582,6 +598,10 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
|
||||
value := uint64(replyToID.Int64)
|
||||
message.ReplyToID = &value
|
||||
}
|
||||
if webhookID.Valid {
|
||||
value := uint64(webhookID.Int64)
|
||||
message.WebhookID = &value
|
||||
}
|
||||
if err := json.Unmarshal([]byte(attachments), &message.Attachments); err != nil {
|
||||
message.Attachments = nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user