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:
@@ -0,0 +1,31 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Вебхуки серверов (AGENT.md 7.11): сообщения без пользовательской сессии.
|
||||||
|
-- Токен — секрет ссылки исполнения, его можно пересоздать.
|
||||||
|
CREATE TABLE webhooks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE,
|
||||||
|
channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
avatar_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX webhooks_token_idx ON webhooks (token);
|
||||||
|
CREATE INDEX webhooks_channel_idx ON webhooks (channel_id, id);
|
||||||
|
CREATE INDEX webhooks_guild_idx ON webhooks (guild_id, id);
|
||||||
|
|
||||||
|
-- Сообщение вебхука: автора-пользователя нет. Имя и аватар сохраняем снимком,
|
||||||
|
-- чтобы удаление вебхука не меняло уже отправленные сообщения.
|
||||||
|
ALTER TABLE messages ADD COLUMN webhook_id INTEGER REFERENCES webhooks (id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE messages ADD COLUMN webhook_name TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE messages ADD COLUMN webhook_avatar TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE messages DROP COLUMN webhook_avatar;
|
||||||
|
ALTER TABLE messages DROP COLUMN webhook_name;
|
||||||
|
ALTER TABLE messages DROP COLUMN webhook_id;
|
||||||
|
DROP INDEX webhooks_guild_idx;
|
||||||
|
DROP INDEX webhooks_channel_idx;
|
||||||
|
DROP INDEX webhooks_token_idx;
|
||||||
|
DROP TABLE webhooks;
|
||||||
@@ -300,7 +300,8 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
|||||||
}
|
}
|
||||||
// Аватары и баннеры видны всем авторизованным: они показываются в списках
|
// Аватары и баннеры видны всем авторизованным: они показываются в списках
|
||||||
// участников и друзей (AGENT.md 7.2).
|
// участников и друзей (AGENT.md 7.2).
|
||||||
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" || file.Purpose == "sound" {
|
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" ||
|
||||||
|
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
if file.ChannelID != nil {
|
if file.ChannelID != nil {
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ type messagePayload struct {
|
|||||||
Mentions []string `json:"mentions"`
|
Mentions []string `json:"mentions"`
|
||||||
Reactions []reactionPayload `json:"reactions"`
|
Reactions []reactionPayload `json:"reactions"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
// Поля вебхука (AGENT.md 7.11): у таких сообщений нет автора-пользователя,
|
||||||
|
// а имя и аватар отправителя лежат в самом сообщении.
|
||||||
|
WebhookID string `json:"webhook_id,omitempty"`
|
||||||
|
WebhookName string `json:"webhook_name,omitempty"`
|
||||||
|
WebhookAvatar string `json:"webhook_avatar,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type messageListOutput struct {
|
type messageListOutput struct {
|
||||||
@@ -861,6 +866,11 @@ func (s *Server) messagePayload(ctx context.Context, message *store.Message, vie
|
|||||||
if message.AuthorID != nil {
|
if message.AuthorID != nil {
|
||||||
payload.AuthorID = formatSnowflake(*message.AuthorID)
|
payload.AuthorID = formatSnowflake(*message.AuthorID)
|
||||||
}
|
}
|
||||||
|
if message.WebhookID != nil {
|
||||||
|
payload.WebhookID = formatSnowflake(*message.WebhookID)
|
||||||
|
}
|
||||||
|
payload.WebhookName = message.WebhookName
|
||||||
|
payload.WebhookAvatar = message.WebhookAvatar
|
||||||
if message.ReplyToID != nil {
|
if message.ReplyToID != nil {
|
||||||
payload.ReplyToID = formatSnowflake(*message.ReplyToID)
|
payload.ReplyToID = formatSnowflake(*message.ReplyToID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,576 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Лимиты вебхуков (AGENT.md 7.11, 8.6).
|
||||||
|
const (
|
||||||
|
// maxWebhooksPerChannel — сколько вебхуков можно завести в одной комнате.
|
||||||
|
maxWebhooksPerChannel = 20
|
||||||
|
// maxWebhookNameLength — длина имени вебхука.
|
||||||
|
maxWebhookNameLength = 80
|
||||||
|
// webhookRateLimit — 30 сообщений в минуту на вебхук.
|
||||||
|
webhookRateLimit = 30
|
||||||
|
// maxWebhookAvatarLength — предел для ссылки на аватар в запросе.
|
||||||
|
maxWebhookAvatarLength = 512
|
||||||
|
// maxWebhookAvatarSize — предел размера картинки аватара (2 МБ).
|
||||||
|
maxWebhookAvatarSize = 2 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type webhookPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GuildID string `json:"guild_id"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
// URL — готовая ссылка исполнения: клиенту не нужно собирать её самому.
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookListOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Webhooks []webhookPayload `json:"webhooks"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Webhook webhookPayload `json:"webhook"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookAvatarPayload struct {
|
||||||
|
FileID string `json:"file_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookMessageOutput struct {
|
||||||
|
Body struct {
|
||||||
|
Message messagePayload `json:"message"`
|
||||||
|
Webhook webhookAvatarPayload `json:"webhook"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerWebhookRoutes описывает вебхуки комнат (AGENT.md 7.11): управление
|
||||||
|
// требует MANAGE_WEBHOOKS и step-up, исполнение доступно без сессии по токену.
|
||||||
|
func (s *Server) registerWebhookRoutes(api huma.API, router chi.Router) {
|
||||||
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
||||||
|
|
||||||
|
// Аватар вебхука — multipart, поэтому ручка живёт на роутере (huma не
|
||||||
|
// принимает формы): файл хранится обычной загрузкой с purpose.
|
||||||
|
router.Post("/channels/{channel_id}/webhooks/avatar", s.handleWebhookAvatarUpload)
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "listChannelWebhooks",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/channels/{channel_id}/webhooks",
|
||||||
|
Summary: "Вебхуки комнаты",
|
||||||
|
Tags: []string{"Webhooks"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
ChannelID string `path:"channel_id"`
|
||||||
|
},
|
||||||
|
) (*webhookListOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
channelID, _, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ManageWebhooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webhooks, err := s.store.ListChannelWebhooks(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
output := &webhookListOutput{}
|
||||||
|
output.Body.Webhooks = make([]webhookPayload, 0, len(webhooks))
|
||||||
|
for i := range webhooks {
|
||||||
|
output.Body.Webhooks = append(output.Body.Webhooks, s.webhookPayload(&webhooks[i]))
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "createWebhook",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/channels/{channel_id}/webhooks",
|
||||||
|
Summary: "Создать вебхук",
|
||||||
|
Tags: []string{"Webhooks"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
ChannelID string `path:"channel_id"`
|
||||||
|
Body struct {
|
||||||
|
Name string `json:"name" minLength:"1" maxLength:"80"`
|
||||||
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||||
|
// Step-up обязателен для создания вебхука (AGENT.md 9.3).
|
||||||
|
StepUpPassword string `json:"step_up_password,omitempty"`
|
||||||
|
StepUpTOTP string `json:"step_up_totp,omitempty"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*webhookOutput, error) {
|
||||||
|
user, session, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
channelID, _, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ManageWebhooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if channel.GuildID == nil || channel.Type != store.ChannelText {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "webhooks can be created only in text channels")
|
||||||
|
}
|
||||||
|
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(input.Body.Name)
|
||||||
|
if name == "" || len([]rune(name)) > maxWebhookNameLength {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook name")
|
||||||
|
}
|
||||||
|
existing, err := s.store.ListChannelWebhooks(ctx, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if len(existing) >= maxWebhooksPerChannel {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "limits.reached", "too many webhooks in this channel")
|
||||||
|
}
|
||||||
|
avatar, err := s.webhookAvatarFile(ctx, user.ID, input.Body.AvatarFileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
token, err := newWebhookToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
webhook, err := s.store.CreateWebhook(ctx, store.CreateWebhookParams{
|
||||||
|
GuildID: *channel.GuildID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
Name: name,
|
||||||
|
AvatarFileID: avatar,
|
||||||
|
Token: token,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, *channel.GuildID, "webhook.create", "webhook", &webhook.ID, "")
|
||||||
|
output := &webhookOutput{}
|
||||||
|
output.Body.Webhook = s.webhookPayload(webhook)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "updateWebhook",
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
Path: "/webhooks/{webhook_id}",
|
||||||
|
Summary: "Изменить вебхук или пересоздать токен",
|
||||||
|
Tags: []string{"Webhooks"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
WebhookID string `path:"webhook_id"`
|
||||||
|
Body struct {
|
||||||
|
Name string `json:"name,omitempty" maxLength:"80"`
|
||||||
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||||
|
ClearAvatar bool `json:"clear_avatar,omitempty"`
|
||||||
|
RegenerateToken bool `json:"regenerate_token,omitempty"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*webhookOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webhook, err := s.requireWebhookPermission(ctx, input.WebhookID, user, permissions.ManageWebhooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params := store.UpdateWebhookParams{
|
||||||
|
Name: webhook.Name,
|
||||||
|
AvatarFileID: webhook.AvatarFileID,
|
||||||
|
Token: webhook.Token,
|
||||||
|
}
|
||||||
|
if name := strings.TrimSpace(input.Body.Name); name != "" {
|
||||||
|
if len([]rune(name)) > maxWebhookNameLength {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook name")
|
||||||
|
}
|
||||||
|
params.Name = name
|
||||||
|
}
|
||||||
|
if input.Body.ClearAvatar {
|
||||||
|
params.AvatarFileID = nil
|
||||||
|
}
|
||||||
|
if input.Body.AvatarFileID != "" {
|
||||||
|
avatar, err := s.webhookAvatarFile(ctx, user.ID, input.Body.AvatarFileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params.AvatarFileID = avatar
|
||||||
|
}
|
||||||
|
if input.Body.RegenerateToken {
|
||||||
|
token, err := newWebhookToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
params.Token = token
|
||||||
|
}
|
||||||
|
updated, err := s.store.UpdateWebhook(ctx, webhook.ID, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, webhook.GuildID, "webhook.update", "webhook", &webhook.ID, "")
|
||||||
|
output := &webhookOutput{}
|
||||||
|
output.Body.Webhook = s.webhookPayload(updated)
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "deleteWebhook",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/webhooks/{webhook_id}",
|
||||||
|
Summary: "Удалить вебхук",
|
||||||
|
Tags: []string{"Webhooks"},
|
||||||
|
Security: security,
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
WebhookID string `path:"webhook_id"`
|
||||||
|
},
|
||||||
|
) (*okOutput, error) {
|
||||||
|
user, _, err := requireUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webhook, err := s.requireWebhookPermission(ctx, input.WebhookID, user, permissions.ManageWebhooks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteWebhook(ctx, webhook.ID); err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, webhook.GuildID, "webhook.delete", "webhook", &webhook.ID, "")
|
||||||
|
return newOKOutput(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
s.registerWebhookExecution(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerWebhookExecution описывает исполнение вебхука: ручка работает без
|
||||||
|
// пользовательской сессии, единственный секрет — токен в адресе.
|
||||||
|
func (s *Server) registerWebhookExecution(api huma.API) {
|
||||||
|
huma.Register(api, huma.Operation{
|
||||||
|
OperationID: "executeWebhook",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/webhooks/{webhook_id}/{token}",
|
||||||
|
Summary: "Отправить сообщение через вебхук",
|
||||||
|
Tags: []string{"Webhooks"},
|
||||||
|
}, func(ctx context.Context, input *struct {
|
||||||
|
WebhookID string `path:"webhook_id"`
|
||||||
|
Token string `path:"token"`
|
||||||
|
Body struct {
|
||||||
|
Content string `json:"content,omitempty" maxLength:"4000"`
|
||||||
|
Username string `json:"username,omitempty" maxLength:"80"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty" maxLength:"512"`
|
||||||
|
FileIDs []string `json:"file_ids,omitempty" maxItems:"20"`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) (*webhookMessageOutput, error) {
|
||||||
|
webhookID, err := parseID("webhook_id", input.WebhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Лимит 30 сообщений в минуту на вебхук (AGENT.md 8.6).
|
||||||
|
if allowed, retryAfter := s.webhookLimiter.Allow(formatSnowflake(webhookID)); !allowed {
|
||||||
|
return nil, rateLimitedError(retryAfter)
|
||||||
|
}
|
||||||
|
webhook, err := s.store.GetWebhook(ctx, webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
// Токен сравниваем в постоянном времени: он и есть пароль вебхука.
|
||||||
|
if subtle.ConstantTimeCompare([]byte(webhook.Token), []byte(input.Token)) != 1 {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnauthorized, "webhook.unauthorized", "invalid webhook token")
|
||||||
|
}
|
||||||
|
channel, err := s.store.GetChannel(ctx, webhook.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if channel.Type != store.ChannelText {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "webhooks post only to text channels")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(input.Body.Content)
|
||||||
|
attachments, err := s.webhookAttachments(ctx, webhook, input.Body.FileIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if content == "" && len(attachments) == 0 {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message must have content or attachments")
|
||||||
|
}
|
||||||
|
settings, err := s.store.InstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if len([]rune(content)) > settings.MaxMessageLength {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "message is too long")
|
||||||
|
}
|
||||||
|
name, avatar, err := webhookIdentity(webhook, input.Body.Username, input.Body.AvatarURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mentions := s.extractMentions(ctx, webhook.ChannelID, content)
|
||||||
|
if len(mentions) > maxMentionsPerMessage {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "too many mentions in one message")
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := s.store.CreateMessage(ctx, store.CreateMessageParams{
|
||||||
|
ChannelID: webhook.ChannelID,
|
||||||
|
Content: content,
|
||||||
|
Type: store.MessageWebhook,
|
||||||
|
Attachments: attachments,
|
||||||
|
Mentions: mentions,
|
||||||
|
WebhookID: &webhook.ID,
|
||||||
|
WebhookName: name,
|
||||||
|
WebhookAvatar: avatar,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
for _, attachment := range attachments {
|
||||||
|
if err := s.store.AttachFileToMessage(ctx, attachment.FileID, message.ID); err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "failed to attach webhook file to message",
|
||||||
|
slog.String("file_id", formatSnowflake(attachment.FileID)), slog.Any("error", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := s.messagePayload(ctx, message, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.dispatchChannelEvent(ctx, webhook.ChannelID, "MESSAGE_CREATE", payload)
|
||||||
|
output := &webhookMessageOutput{}
|
||||||
|
output.Body.Message = payload
|
||||||
|
output.Body.Webhook = webhookAvatarPayload{Name: name, URL: avatar}
|
||||||
|
if webhook.AvatarFileID != nil {
|
||||||
|
output.Body.Webhook.FileID = formatSnowflake(*webhook.AvatarFileID)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookIdentity выбирает имя и аватар сообщения: переопределение из запроса
|
||||||
|
// важнее настроек вебхука (AGENT.md 7.11).
|
||||||
|
func webhookIdentity(webhook *store.Webhook, username, avatarURL string) (string, string, error) {
|
||||||
|
name := strings.TrimSpace(username)
|
||||||
|
if name == "" {
|
||||||
|
name = webhook.Name
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > maxWebhookNameLength {
|
||||||
|
return "", "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook username")
|
||||||
|
}
|
||||||
|
avatar := strings.TrimSpace(avatarURL)
|
||||||
|
switch {
|
||||||
|
case avatar == "":
|
||||||
|
if webhook.AvatarFileID != nil {
|
||||||
|
avatar = fileContentPath(*webhook.AvatarFileID)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(avatar, "/files/"):
|
||||||
|
// Ссылка на файл инстанса: принимаем как есть.
|
||||||
|
case isAbsoluteHTTPURL(avatar):
|
||||||
|
// Внешняя картинка: её отдаёт чужой сервер, поэтому только http(s).
|
||||||
|
default:
|
||||||
|
return "", "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook avatar url")
|
||||||
|
}
|
||||||
|
if len(avatar) > maxWebhookAvatarLength {
|
||||||
|
return "", "", humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "invalid webhook avatar url")
|
||||||
|
}
|
||||||
|
return name, avatar, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookAvatarFile проверяет, что файл аватара загружен этим пользователем
|
||||||
|
// (AGENT.md 7.7): чужие загрузки в вебхук не попадают.
|
||||||
|
func (s *Server) webhookAvatarFile(ctx context.Context, userID uint64, rawID string) (*uint64, error) {
|
||||||
|
if rawID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
fileID, err := parseID("avatar_file_id", rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := s.store.GetFile(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if file.UploaderID == nil || *file.UploaderID != userID {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "avatar belongs to another user")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(file.ContentType, "image/") {
|
||||||
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "webhook avatar must be an image")
|
||||||
|
}
|
||||||
|
return &fileID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookAttachments превращает ссылки на файлы в вложения сообщения. Брать
|
||||||
|
// можно только файлы, загруженные создателем вебхука: чужой файл не должен
|
||||||
|
// попадать в комнату в обход прав (AGENT.md 7.7).
|
||||||
|
func (s *Server) webhookAttachments(ctx context.Context, webhook *store.Webhook, rawIDs []string) ([]store.Attachment, error) {
|
||||||
|
if len(rawIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
attachments := make([]store.Attachment, 0, len(rawIDs))
|
||||||
|
for _, raw := range rawIDs {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Принимаем и идентификатор, и ссылку вида /files/{id}.
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "/files/")
|
||||||
|
fileID, err := parseID("file_ids", trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := s.store.GetFile(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if webhook.CreatedBy == nil || file.UploaderID == nil || *file.UploaderID != *webhook.CreatedBy {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "file belongs to another user")
|
||||||
|
}
|
||||||
|
attachments = append(attachments, store.Attachment{
|
||||||
|
FileID: file.ID,
|
||||||
|
Filename: file.Filename,
|
||||||
|
ContentType: file.ContentType,
|
||||||
|
SizeBytes: file.SizeBytes,
|
||||||
|
Width: file.Width,
|
||||||
|
Height: file.Height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attachments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireWebhookPermission ищет вебхук и проверяет права на управление им.
|
||||||
|
func (s *Server) requireWebhookPermission(ctx context.Context, rawID string, user *store.User, permission permissions.Permission) (*store.Webhook, error) {
|
||||||
|
webhookID, err := parseID("webhook_id", rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
webhook, err := s.store.GetWebhook(ctx, webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := s.requireChannelPermission(ctx, formatSnowflake(webhook.ChannelID), user, permission); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return webhook, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookPayload собирает ответ клиенту.
|
||||||
|
func (s *Server) webhookPayload(webhook *store.Webhook) webhookPayload {
|
||||||
|
payload := webhookPayload{
|
||||||
|
ID: formatSnowflake(webhook.ID),
|
||||||
|
GuildID: formatSnowflake(webhook.GuildID),
|
||||||
|
ChannelID: formatSnowflake(webhook.ChannelID),
|
||||||
|
Name: webhook.Name,
|
||||||
|
Token: webhook.Token,
|
||||||
|
CreatedAt: webhook.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
URL: s.cfg.BaseURL() + "/api/v1/webhooks/" + formatSnowflake(webhook.ID) + "/" + webhook.Token,
|
||||||
|
}
|
||||||
|
if webhook.AvatarFileID != nil {
|
||||||
|
payload.AvatarFileID = formatSnowflake(*webhook.AvatarFileID)
|
||||||
|
}
|
||||||
|
if webhook.CreatedBy != nil {
|
||||||
|
payload.CreatedBy = formatSnowflake(*webhook.CreatedBy)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebhookToken генерирует токен исполнения криптографическим источником.
|
||||||
|
func newWebhookToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAbsoluteHTTPURL проверяет, что ссылка ведёт на http(s)-ресурс.
|
||||||
|
func isAbsoluteHTTPURL(value string) bool {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileContentPath — путь выдачи файла на этом инстансе.
|
||||||
|
func fileContentPath(fileID uint64) string {
|
||||||
|
return "/files/" + formatSnowflake(fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebhookAvatarUpload принимает картинку аватара вебхука: право
|
||||||
|
// MANAGE_WEBHOOKS, изображение не больше 2 МБ (AGENT.md 7.11).
|
||||||
|
func (s *Server) handleWebhookAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _, ok := s.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
channelID, _, channel, err := s.requireChannelPermission(ctx, chi.URLParam(r, "channel_id"), user, permissions.ManageWebhooks)
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
if header.Size > maxWebhookAvatarSize {
|
||||||
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "webhook avatar is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Читаем в память: нужно убедиться, что это действительно изображение.
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, maxWebhookAvatarSize+1))
|
||||||
|
if err != nil || int64(len(data)) > maxWebhookAvatarSize {
|
||||||
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "webhook avatar is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(contentType, "image/") {
|
||||||
|
contentType = http.DetectContentType(data)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(contentType, "image/") {
|
||||||
|
httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "webhook avatar must be an image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stored, err := s.saveUpload(ctx, store.File{
|
||||||
|
UploaderID: &user.ID,
|
||||||
|
GuildID: channel.GuildID,
|
||||||
|
ChannelID: &channelID,
|
||||||
|
Filename: sanitizeFilename(header.Filename),
|
||||||
|
ContentType: contentType,
|
||||||
|
Purpose: "webhook_avatar",
|
||||||
|
}, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"file": s.uploadPayload(stored)})
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ type Server struct {
|
|||||||
soundboardLimiter *httpx.RateLimiter
|
soundboardLimiter *httpx.RateLimiter
|
||||||
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
||||||
inviteLimiter *httpx.RateLimiter
|
inviteLimiter *httpx.RateLimiter
|
||||||
|
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
|
||||||
|
webhookLimiter *httpx.RateLimiter
|
||||||
// slowmode — время последней отправки в комнату для режима медленной
|
// slowmode — время последней отправки в комнату для режима медленной
|
||||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||||
slowmodeMu sync.Mutex
|
slowmodeMu sync.Mutex
|
||||||
@@ -108,6 +110,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
editLimiter: httpx.NewRateLimiter(10, 10),
|
editLimiter: httpx.NewRateLimiter(10, 10),
|
||||||
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
|
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
|
||||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
|
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
|
||||||
slowmode: map[string]time.Time{},
|
slowmode: map[string]time.Time{},
|
||||||
presence: map[uint64]time.Time{},
|
presence: map[uint64]time.Time{},
|
||||||
webhookSeen: map[string]time.Time{},
|
webhookSeen: map[string]time.Time{},
|
||||||
@@ -149,6 +152,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerEmojiRoutes(s.api, apiRouter)
|
s.registerEmojiRoutes(s.api, apiRouter)
|
||||||
s.registerVoiceRoutes(s.api)
|
s.registerVoiceRoutes(s.api)
|
||||||
s.registerSoundsRoutes(s.api, apiRouter)
|
s.registerSoundsRoutes(s.api, apiRouter)
|
||||||
|
s.registerWebhookRoutes(s.api, apiRouter)
|
||||||
s.registerVoiceWebhook(apiRouter)
|
s.registerVoiceWebhook(apiRouter)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты вебхуков (AGENT.md 7.11): управление требует MANAGE_WEBHOOKS и step-up,
|
||||||
|
// исполнение работает без сессии, уважает токен и лимит 30 сообщений в минуту.
|
||||||
|
|
||||||
|
type webhookTestFixture struct {
|
||||||
|
srv *Server
|
||||||
|
owner *http.Cookie
|
||||||
|
outsider *http.Cookie
|
||||||
|
guildID string
|
||||||
|
channelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebhookFixture(t *testing.T) webhookTestFixture {
|
||||||
|
t.Helper()
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
owner := registerAndLogin(t, srv, "hook_owner", "hook-owner@example.com")
|
||||||
|
outsider := registerAndLogin(t, srv, "hook_guest", "hook-guest@example.com")
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Вебхуки"}`, owner)
|
||||||
|
guild := decodeResponse[struct {
|
||||||
|
Guild struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, created)
|
||||||
|
if guild.Guild.ID == "" {
|
||||||
|
t.Fatalf("guild not created: %s", created.Body.String())
|
||||||
|
}
|
||||||
|
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", owner)
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Channels []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"channels"`
|
||||||
|
}](t, channels)
|
||||||
|
channelID := ""
|
||||||
|
for _, channel := range list.Channels {
|
||||||
|
if channel.Type == "text" {
|
||||||
|
channelID = channel.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channelID == "" {
|
||||||
|
t.Fatalf("text channel not found: %s", channels.Body.String())
|
||||||
|
}
|
||||||
|
return webhookTestFixture{
|
||||||
|
srv: srv, owner: owner, outsider: outsider,
|
||||||
|
guildID: guild.Guild.ID, channelID: channelID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepUp подтверждает личность: создание вебхука требует step-up (AGENT.md 9.3).
|
||||||
|
func (f webhookTestFixture) stepUp(t *testing.T, cookie *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/auth/step-up",
|
||||||
|
`{"password":"correct-horse-battery"}`, cookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("step-up = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWebhookFor создаёт вебхук и возвращает его id, токен и ссылку.
|
||||||
|
func (f webhookTestFixture) createWebhookFor(t *testing.T, name string) (id, token, url string) {
|
||||||
|
t.Helper()
|
||||||
|
f.stepUp(t, f.owner)
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
|
||||||
|
`{"name":"`+name+`"}`, f.owner)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create webhook = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Webhook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"webhook"`
|
||||||
|
}](t, rec)
|
||||||
|
if payload.Webhook.ID == "" || payload.Webhook.Token == "" {
|
||||||
|
t.Fatalf("webhook payload is incomplete: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return payload.Webhook.ID, payload.Webhook.Token, payload.Webhook.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookExecuteWithoutSession(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
id, token, url := f.createWebhookFor(t, "Деплой")
|
||||||
|
|
||||||
|
if !strings.HasSuffix(url, token) || !strings.Contains(url, "/api/v1/webhooks/") {
|
||||||
|
t.Fatalf("unexpected webhook url: %s", url)
|
||||||
|
}
|
||||||
|
executePath := "/api/v1/webhooks/" + id + "/" + token
|
||||||
|
|
||||||
|
// Исполнение без сессии: единственный секрет — токен в адресе.
|
||||||
|
body := `{"content":"привет из CI","username":"Сборка"}`
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, executePath, body)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("execute = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Message struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AuthorID string `json:"author_id"`
|
||||||
|
WebhookName string `json:"webhook_name"`
|
||||||
|
} `json:"message"`
|
||||||
|
}](t, rec)
|
||||||
|
if payload.Message.Type != "webhook" {
|
||||||
|
t.Fatalf("type = %q, want webhook", payload.Message.Type)
|
||||||
|
}
|
||||||
|
if payload.Message.AuthorID != "" {
|
||||||
|
t.Fatalf("webhook message must have no author, got %q", payload.Message.AuthorID)
|
||||||
|
}
|
||||||
|
if payload.Message.WebhookName != "Сборка" {
|
||||||
|
t.Fatalf("webhook_name = %q, want Сборка", payload.Message.WebhookName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сообщение видно в истории комнаты обычным участникам сервера.
|
||||||
|
history := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.channelID+"/messages", "", f.owner)
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Messages []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WebhookName string `json:"webhook_name"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}](t, history)
|
||||||
|
found := false
|
||||||
|
for _, message := range list.Messages {
|
||||||
|
if message.ID == payload.Message.ID && message.WebhookName == "Сборка" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("webhook message not in history: %s", history.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsWrongToken(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
id, token, _ := f.createWebhookFor(t, "Секрет")
|
||||||
|
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+strings.Repeat("0", len(token)),
|
||||||
|
`{"content":"подделка"}`)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong token = %d, want 401 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "webhook.unauthorized" {
|
||||||
|
t.Fatalf("error code = %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRateLimit(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
id, token, _ := f.createWebhookFor(t, "Лимит")
|
||||||
|
path := "/api/v1/webhooks/" + id + "/" + token
|
||||||
|
|
||||||
|
for i := 0; i < webhookRateLimit; i++ {
|
||||||
|
if rec := doJSON(t, f.srv, http.MethodPost, path, `{"content":"строка"}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("message %d = %d (%s)", i+1, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, path, `{"content":"лишнее"}`)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("rate limit = %d, want 429 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "rate_limited" {
|
||||||
|
t.Fatalf("error code = %q", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookPermissionsAndStepUp(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
|
||||||
|
// Посторонний не видит комнату и не может завести в ней вебхук.
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
||||||
|
rec := doJSON(t, f.srv, method, "/api/v1/channels/"+f.channelID+"/webhooks",
|
||||||
|
`{"name":"Чужой"}`, f.outsider)
|
||||||
|
if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("outsider %s = %d (%s)", method, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежая сессия step-up не проходила: создание отклоняется.
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
|
||||||
|
`{"name":"Без подтверждения"}`, f.owner)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("create without step-up = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "auth.step_up_required" {
|
||||||
|
t.Fatalf("error code = %q", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// После подтверждения паролем вебхук создаётся.
|
||||||
|
f.stepUp(t, f.owner)
|
||||||
|
rec = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
|
||||||
|
`{"name":"С подтверждением"}`, f.owner)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create after step-up = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookUpdateRotatesTokenAndDelete(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
id, token, _ := f.createWebhookFor(t, "Старое имя")
|
||||||
|
|
||||||
|
renamed := doJSON(t, f.srv, http.MethodPatch, "/api/v1/webhooks/"+id, `{"name":"Новое имя"}`, f.owner)
|
||||||
|
if renamed.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rename = %d (%s)", renamed.Code, renamed.Body.String())
|
||||||
|
}
|
||||||
|
rotated := doJSON(t, f.srv, http.MethodPatch, "/api/v1/webhooks/"+id, `{"regenerate_token":true}`, f.owner)
|
||||||
|
if rotated.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rotate = %d (%s)", rotated.Code, rotated.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Webhook struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
} `json:"webhook"`
|
||||||
|
}](t, rotated)
|
||||||
|
if payload.Webhook.Name != "Новое имя" {
|
||||||
|
t.Fatalf("name = %q", payload.Webhook.Name)
|
||||||
|
}
|
||||||
|
if payload.Webhook.Token == token {
|
||||||
|
t.Fatal("token must be regenerated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Старый токен больше не работает, новый — работает.
|
||||||
|
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+token,
|
||||||
|
`{"content":"старый токен"}`); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old token = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+payload.Webhook.Token,
|
||||||
|
`{"content":"новый токен"}`); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("new token = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := doJSON(t, f.srv, http.MethodDelete, "/api/v1/webhooks/"+id, "", f.owner)
|
||||||
|
if deleted.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete = %d (%s)", deleted.Code, deleted.Body.String())
|
||||||
|
}
|
||||||
|
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+payload.Webhook.Token,
|
||||||
|
`{"content":"после удаления"}`); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("execute after delete = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookValidation(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
id, token, _ := f.createWebhookFor(t, "Проверки")
|
||||||
|
path := "/api/v1/webhooks/" + id + "/" + token
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"пустое сообщение", `{}`, http.StatusUnprocessableEntity},
|
||||||
|
{"слишком длинное", `{"content":"` + strings.Repeat("я", 5000) + `"}`, http.StatusUnprocessableEntity},
|
||||||
|
{"плохой аватар", `{"content":"текст","avatar_url":"javascript:alert(1)"}`, http.StatusUnprocessableEntity},
|
||||||
|
{"неизвестный файл", `{"content":"текст","file_ids":["1"]}`, http.StatusNotFound},
|
||||||
|
}
|
||||||
|
for _, item := range cases {
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, path, item.body)
|
||||||
|
if rec.Code != item.want {
|
||||||
|
t.Fatalf("%s: статус %d, ожидался %d (%s)", item.name, rec.Code, item.want, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsForeignFilesAndVoiceChannels(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
id, token, _ := f.createWebhookFor(t, "Вложения")
|
||||||
|
|
||||||
|
// Файл загружен другим пользователем: вложение вебхука отклоняется.
|
||||||
|
me := doJSON(t, f.srv, http.MethodGet, "/api/v1/users/@me", "", f.outsider)
|
||||||
|
outsiderID := decodeResponse[struct {
|
||||||
|
User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"user"`
|
||||||
|
}](t, me).User.ID
|
||||||
|
outsider, err := parseID("user_id", outsiderID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse outsider id: %v", err)
|
||||||
|
}
|
||||||
|
channel, err := parseID("channel_id", f.channelID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse channel id: %v", err)
|
||||||
|
}
|
||||||
|
file, err := f.srv.store.CreateFile(t.Context(), store.CreateFileParams{
|
||||||
|
UploaderID: outsider,
|
||||||
|
ChannelID: &channel,
|
||||||
|
Filename: "чужой.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
SizeBytes: 3,
|
||||||
|
StoragePath: "files/чужой.txt",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create file: %v", err)
|
||||||
|
}
|
||||||
|
rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+token,
|
||||||
|
`{"content":"вложение","file_ids":["`+formatSnowflake(file.ID)+`"]}`)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("foreign file = %d, want 403 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вебхук можно завести только в текстовой комнате.
|
||||||
|
voice := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
|
||||||
|
`{"name":"Голос","type":"voice"}`, f.owner)
|
||||||
|
voiceID := decodeResponse[struct {
|
||||||
|
Channel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"channel"`
|
||||||
|
}](t, voice).Channel.ID
|
||||||
|
if voiceID == "" {
|
||||||
|
t.Fatalf("voice channel not created: %s", voice.Body.String())
|
||||||
|
}
|
||||||
|
rec = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceID+"/webhooks",
|
||||||
|
`{"name":"В голосовой"}`, f.owner)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("webhook in voice channel = %d, want 422 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookAvatarUploadAndUse(t *testing.T) {
|
||||||
|
f := newWebhookFixture(t)
|
||||||
|
|
||||||
|
// Загрузка аватара: право MANAGE_WEBHOOKS у владельца есть.
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("file", "hook.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("multipart: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(pngBytes()); err != nil {
|
||||||
|
t.Fatalf("multipart write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart close: %v", err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
"/api/v1/channels/"+f.channelID+"/webhooks/avatar", &body)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
request.AddCookie(f.owner)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.srv.Handler().ServeHTTP(rec, request)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("avatar upload = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
uploaded := decodeResponse[struct {
|
||||||
|
File struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
} `json:"file"`
|
||||||
|
}](t, rec)
|
||||||
|
if uploaded.File.FileID == "" {
|
||||||
|
t.Fatalf("avatar file id is empty: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
f.stepUp(t, f.owner)
|
||||||
|
created := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks",
|
||||||
|
`{"name":"С аватаром","avatar_file_id":"`+uploaded.File.FileID+`"}`, f.owner)
|
||||||
|
if created.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create with avatar = %d (%s)", created.Code, created.Body.String())
|
||||||
|
}
|
||||||
|
webhook := decodeResponse[struct {
|
||||||
|
Webhook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
AvatarFileID string `json:"avatar_file_id"`
|
||||||
|
} `json:"webhook"`
|
||||||
|
}](t, created)
|
||||||
|
if webhook.Webhook.AvatarFileID != uploaded.File.FileID {
|
||||||
|
t.Fatalf("avatar_file_id = %q, want %q", webhook.Webhook.AvatarFileID, uploaded.File.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сообщение вебхука несёт ссылку на аватар: её видит клиент.
|
||||||
|
sent := doJSON(t, f.srv, http.MethodPost,
|
||||||
|
"/api/v1/webhooks/"+webhook.Webhook.ID+"/"+webhook.Webhook.Token, `{"content":"с аватаром"}`)
|
||||||
|
if sent.Code != http.StatusOK {
|
||||||
|
t.Fatalf("execute = %d (%s)", sent.Code, sent.Body.String())
|
||||||
|
}
|
||||||
|
payload := decodeResponse[struct {
|
||||||
|
Message struct {
|
||||||
|
WebhookAvatar string `json:"webhook_avatar"`
|
||||||
|
} `json:"message"`
|
||||||
|
}](t, sent)
|
||||||
|
if payload.Message.WebhookAvatar != "/files/"+uploaded.File.FileID {
|
||||||
|
t.Fatalf("webhook_avatar = %q", payload.Message.WebhookAvatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аватар вебхука доступен участникам сервера: файл отдаётся по ссылке.
|
||||||
|
avatar := doJSON(t, f.srv, http.MethodGet, "/files/"+uploaded.File.FileID, "", f.owner)
|
||||||
|
if avatar.Code != http.StatusOK {
|
||||||
|
t.Fatalf("avatar download = %d", avatar.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ const (
|
|||||||
MessageAction MessageType = "action"
|
MessageAction MessageType = "action"
|
||||||
MessageScream MessageType = "scream"
|
MessageScream MessageType = "scream"
|
||||||
MessagePrivate MessageType = "private"
|
MessagePrivate MessageType = "private"
|
||||||
|
// MessageWebhook — сообщение вебхука (AGENT.md 7.11): у него нет автора,
|
||||||
|
// а имя и аватар берутся из снимка в самом сообщении.
|
||||||
|
MessageWebhook MessageType = "webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsPrivate сообщает, что сообщение видно только автору и адресатам.
|
// IsPrivate сообщает, что сообщение видно только автору и адресатам.
|
||||||
@@ -39,6 +42,10 @@ type Message struct {
|
|||||||
Attachments []Attachment
|
Attachments []Attachment
|
||||||
Mentions []uint64
|
Mentions []uint64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
// Поля вебхука (AGENT.md 7.11): идентификатор и снимок имени с аватаром.
|
||||||
|
WebhookID *uint64
|
||||||
|
WebhookName string
|
||||||
|
WebhookAvatar string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment — метаданные вложения: файл регистрируется в таблице files.
|
// Attachment — метаданные вложения: файл регистрируется в таблице files.
|
||||||
@@ -69,7 +76,8 @@ type ReadState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageColumns = `id, channel_id, author_id, content, reply_to_id, type,
|
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 — параметры нового сообщения.
|
// CreateMessageParams — параметры нового сообщения.
|
||||||
type CreateMessageParams struct {
|
type CreateMessageParams struct {
|
||||||
@@ -81,6 +89,10 @@ type CreateMessageParams struct {
|
|||||||
Type MessageType
|
Type MessageType
|
||||||
Attachments []Attachment
|
Attachments []Attachment
|
||||||
Mentions []uint64
|
Mentions []uint64
|
||||||
|
// Заполняется только вебхуками: автора-пользователя у них нет.
|
||||||
|
WebhookID *uint64
|
||||||
|
WebhookName string
|
||||||
|
WebhookAvatar string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateMessage сохраняет сообщение; пустое содержимое без вложений запрещено
|
// CreateMessage сохраняет сообщение; пустое содержимое без вложений запрещено
|
||||||
@@ -106,11 +118,13 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) (
|
|||||||
}
|
}
|
||||||
_, err = s.writer.ExecContext(ctx, `
|
_, err = s.writer.ExecContext(ctx, `
|
||||||
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
|
INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type,
|
||||||
attachments_json, mentions_json, pinned, created_at, content_lower)
|
attachments_json, mentions_json, pinned, created_at, content_lower,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
webhook_id, webhook_name, webhook_avatar)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`,
|
||||||
int64(params.ID), int64(params.ChannelID), author, params.Content,
|
int64(params.ID), int64(params.ChannelID), author, params.Content,
|
||||||
nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions),
|
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 {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
@@ -568,9 +582,11 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
|
|||||||
pinned int
|
pinned int
|
||||||
createdAt string
|
createdAt string
|
||||||
contentLow string
|
contentLow string
|
||||||
|
webhookID sql.NullInt64
|
||||||
)
|
)
|
||||||
err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID,
|
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 {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
@@ -582,6 +598,10 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) {
|
|||||||
value := uint64(replyToID.Int64)
|
value := uint64(replyToID.Int64)
|
||||||
message.ReplyToID = &value
|
message.ReplyToID = &value
|
||||||
}
|
}
|
||||||
|
if webhookID.Valid {
|
||||||
|
value := uint64(webhookID.Int64)
|
||||||
|
message.WebhookID = &value
|
||||||
|
}
|
||||||
if err := json.Unmarshal([]byte(attachments), &message.Attachments); err != nil {
|
if err := json.Unmarshal([]byte(attachments), &message.Attachments); err != nil {
|
||||||
message.Attachments = 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
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ export const messageTypes = [
|
|||||||
'scream',
|
'scream',
|
||||||
'private',
|
'private',
|
||||||
'system',
|
'system',
|
||||||
|
/** Сообщение вебхука: автор — вебхук, а не пользователь (AGENT.md 7.11). */
|
||||||
|
'webhook',
|
||||||
] as const;
|
] as const;
|
||||||
export type MessageType = (typeof messageTypes)[number];
|
export type MessageType = (typeof messageTypes)[number];
|
||||||
|
|
||||||
@@ -56,8 +58,13 @@ export interface Message {
|
|||||||
author_id?: string;
|
author_id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
reply_to_id?: string;
|
reply_to_id?: string;
|
||||||
/** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`. */
|
/** Вид сообщения: `default`, `action`, `whisper`, `scream`, `private`, `system`, `webhook`. */
|
||||||
type: string;
|
type: string;
|
||||||
|
/** Поля вебхука: у таких сообщений нет `author_id` (AGENT.md 7.11). */
|
||||||
|
webhook_id?: string;
|
||||||
|
webhook_name?: string;
|
||||||
|
/** Ссылка на аватар вебхука: `/files/{id}` или внешний http(s) URL. */
|
||||||
|
webhook_avatar?: string;
|
||||||
edited_at?: string;
|
edited_at?: string;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
attachments: MessageAttachment[];
|
attachments: MessageAttachment[];
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { ApiError, isApiErrorPayload, parsePayload, request } from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вебхуки комнат (AGENT.md §7.11): управление требует MANAGE_WEBHOOKS,
|
||||||
|
* исполнение идёт по ссылке с токеном и не требует сессии. Ссылку собирает
|
||||||
|
* сервер — в ней правильный домен инстанса.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Webhook {
|
||||||
|
id: string;
|
||||||
|
guild_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
name: string;
|
||||||
|
avatar_file_id?: string;
|
||||||
|
token: string;
|
||||||
|
created_by?: string;
|
||||||
|
created_at: string;
|
||||||
|
/** Готовая ссылка исполнения. */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const channelWebhooksQueryKey = (channelId: string) =>
|
||||||
|
['channels', channelId, 'webhooks'] as const;
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбирает вебхук из ответа сервера, отбрасывая лишнее. */
|
||||||
|
function parseWebhook(value: unknown): Webhook | null {
|
||||||
|
const record = asRecord(value);
|
||||||
|
const id = asString(record?.['id']);
|
||||||
|
const channelId = asString(record?.['channel_id']);
|
||||||
|
const url = asString(record?.['url']);
|
||||||
|
if (record === null || id === undefined || channelId === undefined || url === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const webhook: Webhook = {
|
||||||
|
id,
|
||||||
|
channel_id: channelId,
|
||||||
|
guild_id: asString(record['guild_id']) ?? '',
|
||||||
|
name: asString(record['name']) ?? '',
|
||||||
|
token: asString(record['token']) ?? '',
|
||||||
|
created_at: asString(record['created_at']) ?? '',
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
const avatar = asString(record['avatar_file_id']);
|
||||||
|
const createdBy = asString(record['created_by']);
|
||||||
|
if (avatar !== undefined) {
|
||||||
|
webhook.avatar_file_id = avatar;
|
||||||
|
}
|
||||||
|
if (createdBy !== undefined) {
|
||||||
|
webhook.created_by = createdBy;
|
||||||
|
}
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWebhookList(payload: unknown): Webhook[] {
|
||||||
|
const record = asRecord(payload);
|
||||||
|
const list = record?.['webhooks'];
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return list.map((item) => parseWebhook(item)).filter((item): item is Webhook => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /channels/{id}/webhooks` — вебхуки комнаты (право MANAGE_WEBHOOKS). */
|
||||||
|
export async function fetchChannelWebhooks(
|
||||||
|
channelId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Webhook[]> {
|
||||||
|
const payload = await request<unknown>(
|
||||||
|
`/channels/${encodeURIComponent(channelId)}/webhooks`,
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return parseWebhookList(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWebhookInput {
|
||||||
|
name: string;
|
||||||
|
avatar_file_id?: string;
|
||||||
|
/** Step-up: создание вебхука требует свежего подтверждения (AGENT.md 9.3). */
|
||||||
|
step_up_password?: string;
|
||||||
|
step_up_totp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /channels/{id}/webhooks` — создать вебхук. */
|
||||||
|
export async function createWebhook(
|
||||||
|
channelId: string,
|
||||||
|
input: CreateWebhookInput,
|
||||||
|
): Promise<Webhook> {
|
||||||
|
const payload = await request<{ webhook?: unknown }>(
|
||||||
|
`/channels/${encodeURIComponent(channelId)}/webhooks`,
|
||||||
|
{ method: 'POST', body: input },
|
||||||
|
);
|
||||||
|
const webhook = parseWebhook(payload.webhook);
|
||||||
|
if (webhook === null) {
|
||||||
|
throw new Error('malformed webhook payload');
|
||||||
|
}
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /channels/{id}/webhooks/avatar` — картинка аватара (multipart, право
|
||||||
|
* MANAGE_WEBHOOKS). Тело — форма, поэтому запрос собирается напрямую.
|
||||||
|
*/
|
||||||
|
export async function uploadWebhookAvatar(channelId: string, file: File): Promise<string> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/channels/${encodeURIComponent(channelId)}/webhooks/avatar`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
body: form,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const payload = parsePayload(await response.text(), response.headers.get('Content-Type'));
|
||||||
|
if (!response.ok) {
|
||||||
|
const envelope =
|
||||||
|
typeof payload === 'object' && payload !== null
|
||||||
|
? (payload as { error?: unknown }).error
|
||||||
|
: undefined;
|
||||||
|
if (isApiErrorPayload(envelope)) {
|
||||||
|
throw new ApiError(response.status, envelope.code, envelope.message, envelope.details);
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||||
|
}
|
||||||
|
const uploaded = (payload as { file?: { file_id?: unknown } } | null)?.file;
|
||||||
|
const fileId = uploaded?.file_id;
|
||||||
|
if (typeof fileId !== 'string' || fileId === '') {
|
||||||
|
throw new ApiError(response.status, 'internal.error', 'unexpected response');
|
||||||
|
}
|
||||||
|
return fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWebhookInput {
|
||||||
|
name?: string;
|
||||||
|
avatar_file_id?: string;
|
||||||
|
clear_avatar?: boolean;
|
||||||
|
regenerate_token?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PATCH /webhooks/{id}` — переименовать, сменить аватар или токен. */
|
||||||
|
export async function updateWebhook(
|
||||||
|
webhookId: string,
|
||||||
|
input: UpdateWebhookInput,
|
||||||
|
): Promise<Webhook> {
|
||||||
|
const payload = await request<{ webhook?: unknown }>(
|
||||||
|
`/webhooks/${encodeURIComponent(webhookId)}`,
|
||||||
|
{ method: 'PATCH', body: input },
|
||||||
|
);
|
||||||
|
const webhook = parseWebhook(payload.webhook);
|
||||||
|
if (webhook === null) {
|
||||||
|
throw new Error('malformed webhook payload');
|
||||||
|
}
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /webhooks/{id}` — удалить вебхук. */
|
||||||
|
export async function deleteWebhook(webhookId: string): Promise<void> {
|
||||||
|
await request(`/webhooks/${encodeURIComponent(webhookId)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -239,7 +239,11 @@ export function MessageItem({
|
|||||||
const menuAnchor = useRef<HTMLButtonElement | null>(null);
|
const menuAnchor = useRef<HTMLButtonElement | null>(null);
|
||||||
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
const editorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const author = authors.resolve(message.author_id);
|
// Сообщение вебхука (AGENT.md 7.11): автора-пользователя у него нет.
|
||||||
|
const author =
|
||||||
|
message.type === 'webhook'
|
||||||
|
? authors.resolveWebhook(message)
|
||||||
|
: authors.resolve(message.author_id);
|
||||||
const isMine = currentUserId !== null && message.author_id === currentUserId;
|
const isMine = currentUserId !== null && message.author_id === currentUserId;
|
||||||
const canEdit = isMine && canSend && !pending;
|
const canEdit = isMine && canSend && !pending;
|
||||||
const canDelete = !pending && (isMine || canManageMessages);
|
const canDelete = !pending && (isMine || canManageMessages);
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ export function MessageList({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const headType = messageTypeOf(first.type);
|
const headType = messageTypeOf(first.type);
|
||||||
const author = authors.resolve(row.group.authorId ?? undefined);
|
const author =
|
||||||
|
headType === 'webhook'
|
||||||
|
? authors.resolveWebhook(first)
|
||||||
|
: authors.resolve(row.group.authorId ?? undefined);
|
||||||
// У системных сообщений нет ни аватара, ни шапки автора, а действие
|
// У системных сообщений нет ни аватара, ни шапки автора, а действие
|
||||||
// `/me` рисует имя внутри строки — шапка ему тоже не нужна.
|
// `/me` рисует имя внутри строки — шапка ему тоже не нужна.
|
||||||
const showHeader = headType !== 'system' && headType !== 'action';
|
const showHeader = headType !== 'system' && headType !== 'action';
|
||||||
@@ -275,6 +278,7 @@ export function MessageList({
|
|||||||
name={author.name}
|
name={author.name}
|
||||||
seed={author.id === '' ? row.key : author.id}
|
seed={author.id === '' ? row.key : author.id}
|
||||||
fileId={author.avatarFileId}
|
fileId={author.avatarFileId}
|
||||||
|
url={author.avatarUrl}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,6 +293,14 @@ export function MessageList({
|
|||||||
>
|
>
|
||||||
{author.name}
|
{author.name}
|
||||||
</span>
|
</span>
|
||||||
|
{headType === 'webhook' ? (
|
||||||
|
<span
|
||||||
|
data-testid="webhook-badge"
|
||||||
|
className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent"
|
||||||
|
>
|
||||||
|
{t('chat.webhookBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{author.isInstanceAdmin ? (
|
{author.isInstanceAdmin ? (
|
||||||
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
||||||
{t('chat.adminBadge')}
|
{t('chat.adminBadge')}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ChatAuthor {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarFileId: string | undefined;
|
avatarFileId: string | undefined;
|
||||||
|
/** Готовая ссылка на аватар: у вебхуков нет файла в профиле (AGM 7.11). */
|
||||||
|
avatarUrl?: string | undefined;
|
||||||
colorHex: string | null;
|
colorHex: string | null;
|
||||||
isInstanceAdmin: boolean;
|
isInstanceAdmin: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
@@ -27,6 +29,15 @@ export interface ChatAuthor {
|
|||||||
|
|
||||||
export interface AuthorDirectory {
|
export interface AuthorDirectory {
|
||||||
resolve: (userId: string | undefined) => ChatAuthor;
|
resolve: (userId: string | undefined) => ChatAuthor;
|
||||||
|
/**
|
||||||
|
* Автор сообщения вебхука (AGENT.md 7.11): имя и аватар лежат в самом
|
||||||
|
* сообщении, поэтому справочник участников для них не нужен.
|
||||||
|
*/
|
||||||
|
resolveWebhook: (message: {
|
||||||
|
webhook_id?: string | undefined;
|
||||||
|
webhook_name?: string | undefined;
|
||||||
|
webhook_avatar?: string | undefined;
|
||||||
|
}) => ChatAuthor;
|
||||||
resolveMention: (userId: string) => { name: string; isMe: boolean };
|
resolveMention: (userId: string) => { name: string; isMe: boolean };
|
||||||
/**
|
/**
|
||||||
* Имя участника или `null`, если он неизвестен (например, вышел с сервера
|
* Имя участника или `null`, если он неизвестен (например, вышел с сервера
|
||||||
@@ -161,8 +172,26 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveWebhook = (message: {
|
||||||
|
webhook_id?: string | undefined;
|
||||||
|
webhook_name?: string | undefined;
|
||||||
|
webhook_avatar?: string | undefined;
|
||||||
|
}): ChatAuthor => ({
|
||||||
|
id: message.webhook_id ?? '',
|
||||||
|
name:
|
||||||
|
message.webhook_name === undefined || message.webhook_name === ''
|
||||||
|
? t('chat.webhookAuthor')
|
||||||
|
: message.webhook_name,
|
||||||
|
avatarFileId: undefined,
|
||||||
|
avatarUrl: message.webhook_avatar,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resolve,
|
resolve,
|
||||||
|
resolveWebhook,
|
||||||
resolveMention: (userId) => {
|
resolveMention: (userId) => {
|
||||||
const author = resolve(userId);
|
const author = resolve(userId);
|
||||||
return { name: author.name, isMe: userId === currentUserId };
|
return { name: author.name, isMe: userId === currentUserId };
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { avatarUrl, hueFromId, initials } from '@/lib/identity';
|
import { avatarUrl, hueFromId, initials } from '@/lib/identity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасная ссылка на картинку аватара: файл инстанса или внешний http(s).
|
||||||
|
* Всё остальное (в том числе `javascript:`) игнорируется.
|
||||||
|
*/
|
||||||
|
function safeImageUrl(url: string | undefined): string | null {
|
||||||
|
if (url === undefined || url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return url.startsWith('/files/') || url.startsWith('https://') || url.startsWith('http://')
|
||||||
|
? url
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
name: string;
|
name: string;
|
||||||
/** Стабильный id для оттенка заглушки. */
|
/** Стабильный id для оттенка заглушки. */
|
||||||
seed: string;
|
seed: string;
|
||||||
fileId?: string | undefined;
|
fileId?: string | undefined;
|
||||||
|
/** Готовая ссылка (аватар вебхука) вместо `fileId`. */
|
||||||
|
url?: string | undefined;
|
||||||
/** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */
|
/** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */
|
||||||
preview?: string | undefined;
|
preview?: string | undefined;
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
@@ -28,11 +43,12 @@ export function Avatar({
|
|||||||
name,
|
name,
|
||||||
seed,
|
seed,
|
||||||
fileId,
|
fileId,
|
||||||
|
url,
|
||||||
preview,
|
preview,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
shape = 'circle',
|
shape = 'circle',
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
const src = preview ?? avatarUrl(fileId);
|
const src = preview ?? safeImageUrl(url) ?? avatarUrl(fileId);
|
||||||
const hue = hueFromId(seed);
|
const hue = hueFromId(seed);
|
||||||
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]';
|
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]';
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
|
"delete": "Delete",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
@@ -247,7 +248,8 @@
|
|||||||
"inviteLink": "Invite link",
|
"inviteLink": "Invite link",
|
||||||
"inviteCopy": "Copy",
|
"inviteCopy": "Copy",
|
||||||
"inviteCopied": "Copied",
|
"inviteCopied": "Copied",
|
||||||
"inviteNew": "New link"
|
"inviteNew": "New link",
|
||||||
|
"settingsChannel": "Channel settings"
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"title": "Voice room",
|
"title": "Voice room",
|
||||||
@@ -558,7 +560,9 @@
|
|||||||
"channelCreated": "Room #{{name}} created",
|
"channelCreated": "Room #{{name}} created",
|
||||||
"voiceChannelCreated": "Voice room {{name}} created",
|
"voiceChannelCreated": "Voice room {{name}} created",
|
||||||
"memberLeave": "{{name}} left the server"
|
"memberLeave": "{{name}} left the server"
|
||||||
}
|
},
|
||||||
|
"webhookBadge": "webhook",
|
||||||
|
"webhookAuthor": "webhook"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"panelLabel": "User panel",
|
"panelLabel": "User panel",
|
||||||
@@ -633,7 +637,8 @@
|
|||||||
"server": "Server",
|
"server": "Server",
|
||||||
"voice": "Audio & video",
|
"voice": "Audio & video",
|
||||||
"language": "Language & region",
|
"language": "Language & region",
|
||||||
"instance": "Instance"
|
"instance": "Instance",
|
||||||
|
"channel": "Channel"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"avatar.change": "Change avatar",
|
"avatar.change": "Change avatar",
|
||||||
@@ -1032,6 +1037,29 @@
|
|||||||
"locale": "Interface language",
|
"locale": "Interface language",
|
||||||
"localeHint": "Applies immediately.",
|
"localeHint": "Applies immediately.",
|
||||||
"saved": "Region settings saved."
|
"saved": "Region settings saved."
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"noGuild": "Pick a server to configure its channel.",
|
||||||
|
"noChannels": "This server has no text channels.",
|
||||||
|
"pick": "Channel",
|
||||||
|
"noPermission": "Only the server owner and members with MANAGE_WEBHOOKS can manage the channel.",
|
||||||
|
"webhooks": {
|
||||||
|
"title": "Webhooks",
|
||||||
|
"description": "A webhook posts messages to the channel without a user session: requests go to the tokenised URL.",
|
||||||
|
"empty": "No webhooks yet.",
|
||||||
|
"create": "Create webhook",
|
||||||
|
"name": "Webhook name",
|
||||||
|
"avatar": "Avatar",
|
||||||
|
"avatarHint": "PNG, JPEG, WebP or GIF up to 2 MB.",
|
||||||
|
"submit": "Create",
|
||||||
|
"rename": "Rename",
|
||||||
|
"rotate": "New link",
|
||||||
|
"url": "Webhook URL",
|
||||||
|
"createdAt": "created {{date}}",
|
||||||
|
"limit": "{{count}} used",
|
||||||
|
"deleteConfirm": "Delete the webhook? The link stops working, messages already sent stay.",
|
||||||
|
"stepUpBody": "Creating a webhook requires confirming your identity: enter your password{{totp}}."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
"back": "Назад",
|
"back": "Назад",
|
||||||
"copy": "Скопировать",
|
"copy": "Скопировать",
|
||||||
"copied": "Скопировано",
|
"copied": "Скопировано",
|
||||||
|
"delete": "Удалить",
|
||||||
"yes": "Да",
|
"yes": "Да",
|
||||||
"no": "Нет",
|
"no": "Нет",
|
||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
@@ -247,7 +248,8 @@
|
|||||||
"inviteLink": "Ссылка приглашения",
|
"inviteLink": "Ссылка приглашения",
|
||||||
"inviteCopy": "Скопировать",
|
"inviteCopy": "Скопировать",
|
||||||
"inviteCopied": "Скопировано",
|
"inviteCopied": "Скопировано",
|
||||||
"inviteNew": "Новая ссылка"
|
"inviteNew": "Новая ссылка",
|
||||||
|
"settingsChannel": "Настройки комнаты"
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"title": "Голосовая комната",
|
"title": "Голосовая комната",
|
||||||
@@ -558,7 +560,9 @@
|
|||||||
"channelCreated": "Комната #{{name}} создана",
|
"channelCreated": "Комната #{{name}} создана",
|
||||||
"voiceChannelCreated": "Голосовая комната {{name}} создана",
|
"voiceChannelCreated": "Голосовая комната {{name}} создана",
|
||||||
"memberLeave": "{{name}} покинул сервер"
|
"memberLeave": "{{name}} покинул сервер"
|
||||||
}
|
},
|
||||||
|
"webhookBadge": "вебхук",
|
||||||
|
"webhookAuthor": "вебхук"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"panelLabel": "Панель пользователя",
|
"panelLabel": "Панель пользователя",
|
||||||
@@ -633,7 +637,8 @@
|
|||||||
"server": "Сервер",
|
"server": "Сервер",
|
||||||
"voice": "Аудио-видео",
|
"voice": "Аудио-видео",
|
||||||
"language": "Язык и регион",
|
"language": "Язык и регион",
|
||||||
"instance": "Инстанс"
|
"instance": "Инстанс",
|
||||||
|
"channel": "Комната"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"avatar.change": "Сменить аватар",
|
"avatar.change": "Сменить аватар",
|
||||||
@@ -1032,6 +1037,29 @@
|
|||||||
"locale": "Язык интерфейса",
|
"locale": "Язык интерфейса",
|
||||||
"localeHint": "Переключается сразу.",
|
"localeHint": "Переключается сразу.",
|
||||||
"saved": "Настройки региона сохранены."
|
"saved": "Настройки региона сохранены."
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"noGuild": "Выберите сервер, чтобы настроить комнату.",
|
||||||
|
"noChannels": "В этом сервере нет текстовых комнат.",
|
||||||
|
"pick": "Комната",
|
||||||
|
"noPermission": "Управлять комнатой может владелец сервера и участники с правом MANAGE_WEBHOOKS.",
|
||||||
|
"webhooks": {
|
||||||
|
"title": "Вебхуки",
|
||||||
|
"description": "Вебхук отправляет сообщения в комнату без пользовательской сессии: запрос идёт на ссылку с токеном.",
|
||||||
|
"empty": "Вебхуков пока нет.",
|
||||||
|
"create": "Создать вебхук",
|
||||||
|
"name": "Имя вебхука",
|
||||||
|
"avatar": "Аватар",
|
||||||
|
"avatarHint": "PNG, JPEG, WebP или GIF до 2 МБ.",
|
||||||
|
"submit": "Создать",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"rotate": "Новая ссылка",
|
||||||
|
"url": "Ссылка вебхука",
|
||||||
|
"createdAt": "создан {{date}}",
|
||||||
|
"limit": "занято {{count}}",
|
||||||
|
"deleteConfirm": "Удалить вебхук? Ссылка перестанет работать, уже отправленные сообщения останутся.",
|
||||||
|
"stepUpBody": "Создание вебхука требует подтверждения личности: введите пароль{{totp}}."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const PERMISSION_CONNECT_VOICE = 'CONNECT_VOICE';
|
|||||||
export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
|
export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
|
||||||
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
|
||||||
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
|
||||||
|
export const PERMISSION_MANAGE_WEBHOOKS = 'MANAGE_WEBHOOKS';
|
||||||
|
|
||||||
/** Есть ли у пользователя право с указанным именем. */
|
/** Есть ли у пользователя право с указанным именем. */
|
||||||
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
export function hasPermission(permissions: readonly string[], name: string): boolean {
|
||||||
@@ -190,6 +191,25 @@ export function canManageEmojis(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Может ли пользователь управлять вебхуками комнаты (AGENT.md 7.11):
|
||||||
|
* владелец, ADMINISTRATOR, MANAGE_WEBHOOKS или администратор инстанса.
|
||||||
|
*/
|
||||||
|
export function canManageWebhooks(
|
||||||
|
permissions: readonly string[],
|
||||||
|
ownerId: string,
|
||||||
|
userId: string | undefined,
|
||||||
|
isInstanceAdmin = false,
|
||||||
|
): boolean {
|
||||||
|
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||||
|
hasPermission(permissions, PERMISSION_MANAGE_WEBHOOKS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Может ли пользователь банить участников (AGENT.md 7.17): владелец,
|
* Может ли пользователь банить участников (AGENT.md 7.17): владелец,
|
||||||
* ADMINISTRATOR, BAN_MEMBERS или администратор инстанса.
|
* ADMINISTRATOR, BAN_MEMBERS или администратор инстанса.
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export default function AppLayout() {
|
|||||||
if (guild === undefined || hydrated.current === guild.id) {
|
if (guild === undefined || hydrated.current === guild.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Пока запрос летел, мог прийти снапшот: READY — источник правды, и
|
||||||
|
// перезаписывать его ответом REST нельзя (права и оформление сервера).
|
||||||
|
if (useSessionStore.getState().guilds.some((item) => item.id === guild.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
hydrated.current = guild.id;
|
hydrated.current = guild.id;
|
||||||
upsertGuild({
|
upsertGuild({
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { SearchPanel } from '@/components/chat/SearchPanel';
|
|||||||
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
|
||||||
import { canManageMessages, canSendMessages } from '@/lib/identity';
|
import { canManageMessages, canManageWebhooks, canSendMessages } from '@/lib/identity';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
|
||||||
@@ -29,6 +30,7 @@ export default function GuildView() {
|
|||||||
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
|
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
|
||||||
const channelId = channel?.id ?? null;
|
const channelId = channel?.id ?? null;
|
||||||
|
|
||||||
|
const openSettings = useUiStore((state) => state.openSettings);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [pinsOpen, setPinsOpen] = useState(false);
|
const [pinsOpen, setPinsOpen] = useState(false);
|
||||||
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
|
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
|
||||||
@@ -73,6 +75,13 @@ export default function GuildView() {
|
|||||||
const permissions = guild?.my_permissions ?? [];
|
const permissions = guild?.my_permissions ?? [];
|
||||||
const isAdmin = sessionUser?.is_instance_admin === true;
|
const isAdmin = sessionUser?.is_instance_admin === true;
|
||||||
const canPin = canManageMessages(permissions, isAdmin) && canSendMessages(permissions, isAdmin);
|
const canPin = canManageMessages(permissions, isAdmin) && canSendMessages(permissions, isAdmin);
|
||||||
|
// Настройки комнаты (вебхуки, AGENT.md 7.11) — владельцу и MANAGE_WEBHOOKS.
|
||||||
|
const canConfigure = canManageWebhooks(
|
||||||
|
permissions,
|
||||||
|
guild?.owner_id ?? '',
|
||||||
|
sessionUser?.id,
|
||||||
|
isAdmin,
|
||||||
|
);
|
||||||
// Счётчик пинов: пока панель не открывали, ориентируемся на загруженную ленту.
|
// Счётчик пинов: пока панель не открывали, ориентируемся на загруженную ленту.
|
||||||
const pinCount = pinsLoaded
|
const pinCount = pinsLoaded
|
||||||
? (pins?.length ?? 0)
|
? (pins?.length ?? 0)
|
||||||
@@ -80,7 +89,10 @@ export default function GuildView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
<section id="app-content" className="absolute inset-0 flex flex-col">
|
||||||
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-3">
|
<header
|
||||||
|
data-testid="channel-header"
|
||||||
|
className="flex items-center gap-2 border-b border-border/40 px-4 py-3"
|
||||||
|
>
|
||||||
<span aria-hidden="true" className="text-fg-muted">
|
<span aria-hidden="true" className="text-fg-muted">
|
||||||
{isVoice ? '🔊' : '#'}
|
{isVoice ? '🔊' : '#'}
|
||||||
</span>
|
</span>
|
||||||
@@ -92,6 +104,24 @@ export default function GuildView() {
|
|||||||
)}
|
)}
|
||||||
{isVoice ? null : (
|
{isVoice ? null : (
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{canConfigure ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('channels.settingsChannel')}
|
||||||
|
title={t('channels.settingsChannel')}
|
||||||
|
data-testid="channel-settings"
|
||||||
|
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||||
|
onClick={() =>
|
||||||
|
openSettings({
|
||||||
|
section: 'channel',
|
||||||
|
guildId: guild?.id ?? null,
|
||||||
|
channelId: channel.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('chat.search.open')}
|
aria-label={t('chat.search.open')}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
|
||||||
|
import type { Channel } from '@/api/types';
|
||||||
|
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { SelectField } from '@/components/ui/Field';
|
||||||
|
import { Card } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { canManageWebhooks } from '@/lib/identity';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
|
||||||
|
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пункт «Комната» окна настроек: управление одной комнатой — пока это вебхуки
|
||||||
|
* (AGENT.md 7.11). Сервер выбирается тем же списком, что и в пункте «Сервер»,
|
||||||
|
* комната — выпадающим списком текстовых комнат.
|
||||||
|
*/
|
||||||
|
export function ChannelSettingsPage() {
|
||||||
|
const sessionGuilds = useSessionStore((state) => state.guilds);
|
||||||
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||||
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
|
const settingsGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||||
|
|
||||||
|
// Подстраховка снапшота: без неё список серверов пуст до подключения шлюза.
|
||||||
|
const myGuilds = useQuery({
|
||||||
|
queryKey: myGuildsQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||||
|
retry: 0,
|
||||||
|
enabled: sessionGuilds.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const guild of myGuilds.data) {
|
||||||
|
upsertGuild(guild);
|
||||||
|
}
|
||||||
|
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
|
||||||
|
|
||||||
|
const guilds = useMemo(
|
||||||
|
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
|
||||||
|
[sessionGuilds, myGuilds.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<GuildPicker
|
||||||
|
guilds={guilds}
|
||||||
|
isPending={myGuilds.isPending && guilds.length === 0}
|
||||||
|
onSelect={selectSettingsGuild}
|
||||||
|
/>
|
||||||
|
<ChannelSettingsBody guildId={settingsGuildId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело пункта: выбор комнаты и её секции. */
|
||||||
|
function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const settingsChannelId = useSessionStore((state) => state.settingsChannelId);
|
||||||
|
const selectSettingsChannel = useSessionStore((state) => state.selectSettingsChannel);
|
||||||
|
const guild = useSessionStore((state) =>
|
||||||
|
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Комнаты сервера: в снапшоте они могут быть пустыми (сервер только открыли),
|
||||||
|
// поэтому тянем их по REST.
|
||||||
|
const channels = useQuery({
|
||||||
|
queryKey: guildChannelsQueryKey(guildId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
|
||||||
|
enabled: guildId !== null,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const textChannels = useMemo(
|
||||||
|
() => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'),
|
||||||
|
[channels.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Первую текстовую комнату выбираем сами: список только что загрузился.
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsChannelId !== null || textChannels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = textChannels[0];
|
||||||
|
if (first !== undefined) {
|
||||||
|
selectSettingsChannel(first.id);
|
||||||
|
}
|
||||||
|
}, [settingsChannelId, textChannels, selectSettingsChannel]);
|
||||||
|
|
||||||
|
if (guildId === null || guild === null) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.channel.noGuild')}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInstanceAdmin = currentUser.data?.is_instance_admin === true;
|
||||||
|
const canManage = canManageWebhooks(
|
||||||
|
guild.my_permissions ?? [],
|
||||||
|
guild.owner_id,
|
||||||
|
currentUser.data?.id,
|
||||||
|
isInstanceAdmin,
|
||||||
|
);
|
||||||
|
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{channels.isPending ? (
|
||||||
|
<Card>
|
||||||
|
<SkeletonLines rows={3} />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
{channels.isError ? (
|
||||||
|
<Card>
|
||||||
|
<ErrorNotice error={channels.error} onRetry={() => void channels.refetch()} />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
{channels.isSuccess && textChannels.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.channel.noChannels')}</p>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{textChannels.length === 0 ? null : (
|
||||||
|
<Card>
|
||||||
|
<SelectField
|
||||||
|
label={t('settings.channel.pick')}
|
||||||
|
value={active?.id ?? ''}
|
||||||
|
onChange={(value) => selectSettingsChannel(value)}
|
||||||
|
options={textChannels.map((channel) => ({
|
||||||
|
value: channel.id,
|
||||||
|
label: `#${channel.name}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active === null ? null : (
|
||||||
|
<>
|
||||||
|
<ChannelWebhooksSection
|
||||||
|
key={`${active.id}-webhooks`}
|
||||||
|
channelId={active.id}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
{canManage ? null : (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.channel.noPermission')}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
channelWebhooksQueryKey,
|
||||||
|
createWebhook,
|
||||||
|
deleteWebhook,
|
||||||
|
fetchChannelWebhooks,
|
||||||
|
updateWebhook,
|
||||||
|
uploadWebhookAvatar,
|
||||||
|
type Webhook,
|
||||||
|
} from '@/api/webhooks';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { stepUp } from '@/api/auth';
|
||||||
|
import { errorCode } from '@/lib/format';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
|
/** Сколько держится подсказка «ссылка скопирована», мс. */
|
||||||
|
const COPIED_HINT_MS = 2_000;
|
||||||
|
|
||||||
|
interface ChannelWebhooksSectionProps {
|
||||||
|
channelId: string;
|
||||||
|
/** Право MANAGE_WEBHOOKS: без него секция не показывается. */
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Вебхуки» комнаты (AGENT.md 7.11): создание, смена имени и аватара,
|
||||||
|
* пересоздание токена и удаление. Ссылку собирает сервер — она и есть секрет
|
||||||
|
* исполнения, поэтому копируется целиком.
|
||||||
|
*
|
||||||
|
* Создание вебхука требует step-up (AGENT.md 9.3): если сервер ответил
|
||||||
|
* `auth.step_up_required`, показываем форму подтверждения и повторяем запрос.
|
||||||
|
*/
|
||||||
|
export function ChannelWebhooksSection({ channelId, canManage }: ChannelWebhooksSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const nameInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
const avatarInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||||
|
const [createError, setCreateError] = useState<unknown>(null);
|
||||||
|
const [stepUpOpen, setStepUpOpen] = useState(false);
|
||||||
|
const [stepUpPassword, setStepUpPassword] = useState('');
|
||||||
|
const [stepUpCode, setStepUpCode] = useState('');
|
||||||
|
const [renaming, setRenaming] = useState<Webhook | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const webhooks = useQuery({
|
||||||
|
queryKey: channelWebhooksQueryKey(channelId),
|
||||||
|
queryFn: ({ signal }) => fetchChannelWebhooks(channelId, signal),
|
||||||
|
enabled: canManage,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = (): void => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: channelWebhooksQueryKey(channelId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Загрузка аватара: файл уходит в общее хранилище, вебхук получает его id. */
|
||||||
|
const uploadAvatar = async (): Promise<string | undefined> => {
|
||||||
|
if (avatarFile === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return uploadWebhookAvatar(channelId, avatarFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async (stepUpFields: { password?: string; totp?: string }) => {
|
||||||
|
const avatarFileId = await uploadAvatar();
|
||||||
|
return createWebhook(channelId, {
|
||||||
|
name: name.trim(),
|
||||||
|
...(avatarFileId === undefined ? {} : { avatar_file_id: avatarFileId }),
|
||||||
|
...(stepUpFields.password === undefined ? {} : { step_up_password: stepUpFields.password }),
|
||||||
|
...(stepUpFields.totp === undefined ? {} : { step_up_totp: stepUpFields.totp }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setCreateError(null);
|
||||||
|
setStepUpOpen(false);
|
||||||
|
setStepUpPassword('');
|
||||||
|
setStepUpCode('');
|
||||||
|
setName('');
|
||||||
|
setAvatarFile(null);
|
||||||
|
if (avatarInput.current !== null) {
|
||||||
|
avatarInput.current.value = '';
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => {
|
||||||
|
setCreateError(cause);
|
||||||
|
if (errorCode(cause) === 'auth.step_up_required') {
|
||||||
|
setStepUpOpen(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmIdentity = useMutation({
|
||||||
|
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
|
||||||
|
onSuccess: () => {
|
||||||
|
setStepUpOpen(false);
|
||||||
|
create.mutate({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rename = useMutation({
|
||||||
|
mutationFn: (webhook: Webhook) => updateWebhook(webhook.id, { name: renameValue.trim() }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRenaming(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotate = useMutation({
|
||||||
|
mutationFn: (webhook: Webhook) => updateWebhook(webhook.id, { regenerate_token: true }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (webhook: Webhook) => deleteWebhook(webhook.id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = async (webhook: Webhook): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(webhook.url);
|
||||||
|
setCopiedId(webhook.id);
|
||||||
|
window.setTimeout(
|
||||||
|
() => setCopiedId((current) => (current === webhook.id ? null : current)),
|
||||||
|
COPIED_HINT_MS,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Буфер обмена недоступен: ссылку видно в поле.
|
||||||
|
setCopiedId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.channel.webhooks.title')}
|
||||||
|
description={t('settings.channel.webhooks.description')}
|
||||||
|
>
|
||||||
|
<div className="mt-4 flex flex-col gap-3" data-testid="channel-webhooks">
|
||||||
|
{webhooks.isPending ? <SkeletonLines rows={2} /> : null}
|
||||||
|
{webhooks.isError ? (
|
||||||
|
<ErrorNotice error={webhooks.error} onRetry={() => void webhooks.refetch()} />
|
||||||
|
) : null}
|
||||||
|
{webhooks.data?.length === 0 ? (
|
||||||
|
<p className="text-sm text-fg-muted" data-testid="channel-webhooks-empty">
|
||||||
|
{t('settings.channel.webhooks.empty')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{(webhooks.data ?? []).map((webhook) => (
|
||||||
|
<li
|
||||||
|
key={webhook.id}
|
||||||
|
data-testid={`webhook-${webhook.id}`}
|
||||||
|
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
name={webhook.name}
|
||||||
|
seed={webhook.id}
|
||||||
|
fileId={webhook.avatar_file_id}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{webhook.name}</p>
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t('settings.channel.webhooks.createdAt', {
|
||||||
|
date: webhook.created_at.slice(0, 10),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => void copy(webhook)}>
|
||||||
|
{t(copiedId === webhook.id ? 'common.copied' : 'common.copy')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`webhook-rotate-${webhook.id}`}
|
||||||
|
disabled={rotate.isPending}
|
||||||
|
onClick={() => rotate.mutate(webhook)}
|
||||||
|
>
|
||||||
|
{t('settings.channel.webhooks.rotate')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`webhook-edit-${webhook.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
setRenaming(webhook);
|
||||||
|
setRenameValue(webhook.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.channel.webhooks.rename')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
data-testid={`webhook-delete-${webhook.id}`}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('settings.channel.webhooks.deleteConfirm'))) {
|
||||||
|
remove.mutate(webhook);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={webhook.url}
|
||||||
|
aria-label={t('settings.channel.webhooks.url')}
|
||||||
|
data-testid={`webhook-url-${webhook.id}`}
|
||||||
|
onFocus={(event) => event.target.select()}
|
||||||
|
className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 font-mono text-xs text-fg"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{rotate.isError ? <ErrorNotice error={rotate.error} /> : null}
|
||||||
|
{remove.isError ? <ErrorNotice error={remove.error} /> : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button data-testid="webhook-create-open" onClick={() => setOpen(true)}>
|
||||||
|
{t('settings.channel.webhooks.create')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t('settings.channel.webhooks.limit', { count: webhooks.data?.length ?? 0 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('settings.channel.webhooks.create')}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setCreateError(null);
|
||||||
|
if (name.trim() === '') {
|
||||||
|
nameInput.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
create.mutate({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.channel.webhooks.name')}
|
||||||
|
name="webhook_name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">{t('settings.channel.webhooks.avatar')}</span>
|
||||||
|
<input
|
||||||
|
ref={avatarInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
data-testid="webhook-avatar-input"
|
||||||
|
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t('settings.channel.webhooks.avatarHint')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{createError === null || stepUpOpen ? null : <ErrorNotice error={createError} />}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={create.isPending}>
|
||||||
|
{t(create.isPending ? 'common.saving' : 'settings.channel.webhooks.submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{stepUpOpen ? (
|
||||||
|
<div className="mt-4 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-3">
|
||||||
|
<h4 className="text-sm font-semibold">{t('settings.security.stepUpTitle')}</h4>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('settings.channel.webhooks.stepUpBody', {
|
||||||
|
totp:
|
||||||
|
currentUser.data?.totp_enabled === true ? t('settings.security.stepUpTotp') : '',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="mt-3 flex flex-col gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
confirmIdentity.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.security.stepUpPassword')}
|
||||||
|
type="password"
|
||||||
|
name="step_up_password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={stepUpPassword}
|
||||||
|
onChange={(event) => setStepUpPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{currentUser.data?.totp_enabled === true ? (
|
||||||
|
<Field
|
||||||
|
label={t('auth.login.totp')}
|
||||||
|
name="step_up_totp"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={8}
|
||||||
|
value={stepUpCode}
|
||||||
|
onChange={(event) => setStepUpCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{confirmIdentity.isError ? <ErrorNotice error={confirmIdentity.error} /> : null}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={confirmIdentity.isPending}>
|
||||||
|
{t('settings.security.stepUpSubmit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={renaming !== null}
|
||||||
|
title={t('settings.channel.webhooks.rename')}
|
||||||
|
onClose={() => setRenaming(null)}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (renaming !== null && renameValue.trim() !== '') {
|
||||||
|
rename.mutate(renaming);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.channel.webhooks.name')}
|
||||||
|
name="webhook_rename"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(event) => setRenameValue(event.target.value)}
|
||||||
|
maxLength={80}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{rename.isError ? <ErrorNotice error={rename.error} /> : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={rename.isPending}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useCurrentUser } from '@/lib/hooks';
|
|||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore, type SettingsSection } from '@/stores/ui';
|
import { useUiStore, type SettingsSection } from '@/stores/ui';
|
||||||
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
|
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
|
||||||
|
import { ChannelSettingsPage } from '@/pages/settings/ChannelSettingsPage';
|
||||||
import GuildSettingsPage from '@/pages/settings/GuildSettingsPage';
|
import GuildSettingsPage from '@/pages/settings/GuildSettingsPage';
|
||||||
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
|
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
|
||||||
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
|
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
|
||||||
@@ -100,6 +101,7 @@ export function SettingsModal() {
|
|||||||
{ id: 'voice', label: t('settings.tabs.voice') },
|
{ id: 'voice', label: t('settings.tabs.voice') },
|
||||||
{ id: 'language', label: t('settings.tabs.language') },
|
{ id: 'language', label: t('settings.tabs.language') },
|
||||||
...(canManageGuilds ? [{ id: 'server' as const, label: t('settings.tabs.server') }] : []),
|
...(canManageGuilds ? [{ id: 'server' as const, label: t('settings.tabs.server') }] : []),
|
||||||
|
...(canManageGuilds ? [{ id: 'channel' as const, label: t('settings.tabs.channel') }] : []),
|
||||||
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
|
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -155,6 +157,7 @@ export function SettingsModal() {
|
|||||||
{section === 'voice' ? <VoiceSettingsSection /> : null}
|
{section === 'voice' ? <VoiceSettingsSection /> : null}
|
||||||
{section === 'language' ? <LanguageRegionSection /> : null}
|
{section === 'language' ? <LanguageRegionSection /> : null}
|
||||||
{section === 'server' ? <GuildSettingsPage key={selectedGuildId ?? 'none'} /> : null}
|
{section === 'server' ? <GuildSettingsPage key={selectedGuildId ?? 'none'} /> : null}
|
||||||
|
{section === 'channel' ? <ChannelSettingsPage key={selectedGuildId ?? 'none'} /> : null}
|
||||||
{section === 'instance' ? <InstanceSettingsPage /> : null}
|
{section === 'instance' ? <InstanceSettingsPage /> : null}
|
||||||
{section === 'profile' ? <ProfileTabs /> : null}
|
{section === 'profile' ? <ProfileTabs /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ interface SessionState {
|
|||||||
selectedChannelId: string | null;
|
selectedChannelId: string | null;
|
||||||
/** Последний сервер, открытый в настройках (`/settings/servers/:guildId`). */
|
/** Последний сервер, открытый в настройках (`/settings/servers/:guildId`). */
|
||||||
settingsGuildId: string | null;
|
settingsGuildId: string | null;
|
||||||
|
/** Последняя комната, открытая в настройках комнаты (вебхуки, AGENT.md 7.11). */
|
||||||
|
settingsChannelId: string | null;
|
||||||
|
|
||||||
applyReady: (snapshot: GatewaySnapshot) => void;
|
applyReady: (snapshot: GatewaySnapshot) => void;
|
||||||
/** RESUMED приходит без снапшота — обновляем только то, что пришло. */
|
/** RESUMED приходит без снапшота — обновляем только то, что пришло. */
|
||||||
@@ -117,6 +119,8 @@ interface SessionState {
|
|||||||
selectChannel: (guildId: string | null, channelId: string | null) => void;
|
selectChannel: (guildId: string | null, channelId: string | null) => void;
|
||||||
/** Выбор сервера в настройках — отдельно от выбора в приложении. */
|
/** Выбор сервера в настройках — отдельно от выбора в приложении. */
|
||||||
selectSettingsGuild: (guildId: string | null) => void;
|
selectSettingsGuild: (guildId: string | null) => void;
|
||||||
|
/** Выбор комнаты в настройках комнаты. */
|
||||||
|
selectSettingsChannel: (channelId: string | null) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +133,7 @@ interface SessionDefaults {
|
|||||||
selectedGuildId: null;
|
selectedGuildId: null;
|
||||||
selectedChannelId: null;
|
selectedChannelId: null;
|
||||||
settingsGuildId: null;
|
settingsGuildId: null;
|
||||||
|
settingsChannelId: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaults: SessionDefaults = {
|
const defaults: SessionDefaults = {
|
||||||
@@ -140,8 +145,20 @@ const defaults: SessionDefaults = {
|
|||||||
selectedGuildId: null,
|
selectedGuildId: null,
|
||||||
selectedChannelId: null,
|
selectedChannelId: null,
|
||||||
settingsGuildId: null,
|
settingsGuildId: null,
|
||||||
|
settingsChannelId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Убирает свойства со значением `undefined`: они не должны затирать данные. */
|
||||||
|
function withoutUndefined<T extends object>(value: T): Partial<T> {
|
||||||
|
const result: Partial<T> = {};
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (item !== undefined) {
|
||||||
|
result[key as keyof T] = item as T[keyof T];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
||||||
const summary: SessionGuild = {
|
const summary: SessionGuild = {
|
||||||
id: guild.id,
|
id: guild.id,
|
||||||
@@ -254,9 +271,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
|
|
||||||
upsertGuild: (guild) => {
|
upsertGuild: (guild) => {
|
||||||
const guilds = get().guilds;
|
const guilds = get().guilds;
|
||||||
const index = guilds.findIndex((item) => item.id === guild.id);
|
// Неполный ответ REST не должен затирать данные снапшота: свойства со
|
||||||
|
// значением `undefined` в слияние не попадают.
|
||||||
|
const patch = withoutUndefined(guild);
|
||||||
|
const index = guilds.findIndex((item) => item.id === patch.id);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] });
|
// Незнакомый сервер: сводки достаточно, комнаты догрузит сайдбар.
|
||||||
|
set({
|
||||||
|
guilds: [...guilds, { ...patch, channels: [], emojis: [], sounds: [] } as SessionGuild],
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = guilds[index];
|
const existing = guilds[index];
|
||||||
@@ -266,7 +289,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
// Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем.
|
// Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем.
|
||||||
const merged: SessionGuild = {
|
const merged: SessionGuild = {
|
||||||
...existing,
|
...existing,
|
||||||
...guild,
|
...patch,
|
||||||
channels: existing.channels,
|
channels: existing.channels,
|
||||||
emojis: existing.emojis,
|
emojis: existing.emojis,
|
||||||
sounds: existing.sounds,
|
sounds: existing.sounds,
|
||||||
@@ -529,6 +552,10 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
set({ settingsGuildId: guildId });
|
set({ settingsGuildId: guildId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
selectSettingsChannel: (channelId) => {
|
||||||
|
set({ settingsChannelId: channelId });
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
set({ ...defaults });
|
set({ ...defaults });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const settingsSections = [
|
|||||||
'language',
|
'language',
|
||||||
/** Администрирование выбранного сервера. */
|
/** Администрирование выбранного сервера. */
|
||||||
'server',
|
'server',
|
||||||
|
/** Настройки выбранной комнаты: вебхуки (AGENT.md 7.11). */
|
||||||
|
'channel',
|
||||||
'instance',
|
'instance',
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsSection = (typeof settingsSections)[number];
|
export type SettingsSection = (typeof settingsSections)[number];
|
||||||
@@ -28,6 +30,8 @@ export interface SettingsTarget {
|
|||||||
tab?: ProfileTab;
|
tab?: ProfileTab;
|
||||||
/** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */
|
/** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */
|
||||||
guildId?: string | null;
|
guildId?: string | null;
|
||||||
|
/** Комната для пункта «Комната». */
|
||||||
|
channelId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UiState {
|
interface UiState {
|
||||||
@@ -116,6 +120,9 @@ export const useUiStore = create<UiState>((set, get) => ({
|
|||||||
if (target?.guildId !== undefined) {
|
if (target?.guildId !== undefined) {
|
||||||
useSessionStore.getState().selectSettingsGuild(target.guildId);
|
useSessionStore.getState().selectSettingsGuild(target.guildId);
|
||||||
}
|
}
|
||||||
|
if (target?.channelId !== undefined) {
|
||||||
|
useSessionStore.getState().selectSettingsChannel(target.channelId);
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
settingsOpen: true,
|
settingsOpen: true,
|
||||||
...(target?.section === undefined ? {} : { settingsSection: target.section }),
|
...(target?.section === undefined ? {} : { settingsSection: target.section }),
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ const authors: AuthorDirectory = {
|
|||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
|
resolveWebhook: () => ({
|
||||||
|
id: '',
|
||||||
|
name: 'вебхук',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
}),
|
||||||
resolveMention: (userId) => ({
|
resolveMention: (userId) => ({
|
||||||
name: members.find((member) => member.user_id === userId)?.display_name ?? userId,
|
name: members.find((member) => member.user_id === userId)?.display_name ?? userId,
|
||||||
isMe: false,
|
isMe: false,
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bodyOf,
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
json,
|
||||||
|
makeReadySnapshot,
|
||||||
|
makeUser,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
installGatewaySocket,
|
||||||
|
messagesRoutes,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вебхуки комнаты (AGENT.md 7.11): секция «Комната» в настройках — список,
|
||||||
|
* создание со step-up, пересоздание ссылки, копирование URL и удаление.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser({ is_instance_admin: true });
|
||||||
|
|
||||||
|
const channel = { id: 'c-1', name: 'общий', type: 'text' as const, position: 0 };
|
||||||
|
|
||||||
|
function webhook(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'w-1',
|
||||||
|
guild_id: 'g-1',
|
||||||
|
channel_id: 'c-1',
|
||||||
|
name: 'Деплой',
|
||||||
|
token: 'token-1',
|
||||||
|
created_by: 'user-1',
|
||||||
|
created_at: '2026-09-20T10:00:00Z',
|
||||||
|
url: 'https://gl.test/api/v1/webhooks/w-1/token-1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routes(overrides: { list?: unknown[] } = {}): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/guilds',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
guilds: [
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: true,
|
||||||
|
member_count: 1,
|
||||||
|
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
|
||||||
|
my_role_ids: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
guild: {
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: true,
|
||||||
|
member_count: 1,
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
|
||||||
|
channels: [channel],
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/channels/c-1/webhooks',
|
||||||
|
response: () => json({ webhooks: overrides.list ?? [webhook()] }),
|
||||||
|
},
|
||||||
|
...messagesRoutes('c-1', []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Открывает настройки комнаты с готовым списком вебхуков. */
|
||||||
|
async function openChannelSettings(list?: unknown[]) {
|
||||||
|
const fetchMock = installFetch(routes(list === undefined ? {} : { list }));
|
||||||
|
const snapshot = makeReadySnapshot([
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
is_main: true,
|
||||||
|
my_permissions: ['MANAGE_WEBHOOKS', 'ADMINISTRATOR'],
|
||||||
|
channels: [channel],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const gateway = installGatewaySocket(snapshot);
|
||||||
|
renderApp('/app/g-1/c-1');
|
||||||
|
await gateway.greet();
|
||||||
|
await screen.findByTestId('message-list');
|
||||||
|
await userEvent.click(await screen.findByTestId('channel-settings'));
|
||||||
|
const content = await screen.findByTestId('settings-content');
|
||||||
|
await within(content).findByTestId('channel-webhooks');
|
||||||
|
return { fetchMock, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('вебхуки комнаты', () => {
|
||||||
|
it('показывает список со ссылкой и копирует её', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const { content } = await openChannelSettings();
|
||||||
|
|
||||||
|
const item = within(content).getByTestId('webhook-w-1');
|
||||||
|
expect(within(item).getByText('Деплой')).toBeVisible();
|
||||||
|
expect(within(item).getByTestId('webhook-url-w-1')).toHaveValue(
|
||||||
|
'https://gl.test/api/v1/webhooks/w-1/token-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(within(item).getByRole('button', { name: 'Скопировать' }));
|
||||||
|
expect(writeText).toHaveBeenCalledWith('https://gl.test/api/v1/webhooks/w-1/token-1');
|
||||||
|
expect(await within(item).findByText('Скопировано')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('создаёт вебхук и подтверждает личность по запросу сервера', async () => {
|
||||||
|
const { fetchMock, content } = await openChannelSettings([]);
|
||||||
|
// Первый раз сервер требует step-up: секция показывает форму подтверждения.
|
||||||
|
fetchMock.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)),
|
||||||
|
);
|
||||||
|
fetchMock.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(within(content).getByTestId('webhook-create-open'));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Создать вебхук' });
|
||||||
|
await userEvent.type(within(dialog).getByLabelText('Имя вебхука'), 'Новый');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' }));
|
||||||
|
|
||||||
|
const stepUpField = await within(dialog).findByLabelText('Ваш пароль');
|
||||||
|
await userEvent.type(stepUpField, 'correct-horse-battery');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Подтвердить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/auth/step-up', method: 'POST' })).toBeDefined();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
const created = findRequest(fetchMock, { url: '/channels/c-1/webhooks', method: 'POST' });
|
||||||
|
expect(bodyOf(created)).toMatchObject({ name: 'Новый' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пересоздаёт ссылку и удаляет вебхук', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
const { fetchMock, content } = await openChannelSettings();
|
||||||
|
|
||||||
|
const item = within(content).getByTestId('webhook-w-1');
|
||||||
|
await userEvent.click(within(item).getByTestId('webhook-rotate-w-1'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'PATCH' })?.body).toMatchObject(
|
||||||
|
{ regenerate_token: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(within(item).getByTestId('webhook-delete-w-1'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/webhooks/w-1', method: 'DELETE' })).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не показывает секцию без права MANAGE_WEBHOOKS', async () => {
|
||||||
|
const fetchMock = installFetch(routes({ list: [{ ...webhook(), id: 'w-2' }] }));
|
||||||
|
const snapshot = makeReadySnapshot([
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
is_main: true,
|
||||||
|
// Права есть, но владелец — другой пользователь и админа нет.
|
||||||
|
owner_id: 'user-9',
|
||||||
|
my_permissions: [],
|
||||||
|
channels: [channel],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const gateway = installGatewaySocket(snapshot);
|
||||||
|
renderApp('/app/g-1/c-1');
|
||||||
|
await gateway.greet();
|
||||||
|
await screen.findByTestId('message-list');
|
||||||
|
|
||||||
|
// Кнопки настроек комнаты нет вовсе: секция недоступна из интерфейса.
|
||||||
|
expect(screen.queryByTestId('channel-settings')).toBeNull();
|
||||||
|
expect(useSessionStore.getState().guilds[0]?.my_permissions).toEqual([]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открывает настройки комнаты из шапки чата', async () => {
|
||||||
|
await openChannelSettings();
|
||||||
|
|
||||||
|
expect(useUiStore.getState().settingsOpen).toBe(true);
|
||||||
|
expect(useUiStore.getState().settingsSection).toBe('channel');
|
||||||
|
expect(useSessionStore.getState().settingsChannelId).toBe('c-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,14 @@ const authors: AuthorDirectory = {
|
|||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
|
resolveWebhook: () => ({
|
||||||
|
id: '',
|
||||||
|
name: 'вебхук',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ const authors: AuthorDirectory = {
|
|||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
|
resolveWebhook: () => ({
|
||||||
|
id: '',
|
||||||
|
name: 'вебхук',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
resolveMention: (userId) => ({ name: userId, isMe: false }),
|
||||||
resolveMemberName: (userId) => `user ${userId}`,
|
resolveMemberName: (userId) => `user ${userId}`,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
|||||||
@@ -89,14 +89,22 @@ function routes(guild: Record<string, unknown> = guildDetail()): FetchRoute[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Рендерит приложение с READY-снапшотом сервера. */
|
/**
|
||||||
|
* Рендерит приложение с READY-снапшотом сервера. Права и владелец берутся из
|
||||||
|
* того же описания сервера, что и ответ REST: снапшот — источник правды.
|
||||||
|
*/
|
||||||
async function bootApp(guild: Record<string, unknown> = guildDetail()) {
|
async function bootApp(guild: Record<string, unknown> = guildDetail()) {
|
||||||
|
const permissions = Array.isArray(guild.my_permissions)
|
||||||
|
? (guild.my_permissions as string[])
|
||||||
|
: ['MANAGE_GUILD', 'CREATE_INVITES'];
|
||||||
|
const ownerId = typeof guild.owner_id === 'string' ? guild.owner_id : 'user-1';
|
||||||
const snapshot = makeReadySnapshot([
|
const snapshot = makeReadySnapshot([
|
||||||
{
|
{
|
||||||
id: 'g-1',
|
id: 'g-1',
|
||||||
name: 'Сервер',
|
name: 'Сервер',
|
||||||
is_main: true,
|
is_main: true,
|
||||||
my_permissions: ['MANAGE_GUILD', 'CREATE_INVITES'],
|
owner_id: ownerId,
|
||||||
|
my_permissions: permissions,
|
||||||
channels,
|
channels,
|
||||||
voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
|
voice_states: [makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' })],
|
||||||
// Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild.
|
// Оформление приходит в снапшоте шлюза — так же, как в ReadyGuild.
|
||||||
|
|||||||
@@ -21,10 +21,25 @@ const authors: AuthorDirectory = {
|
|||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
isMember: true,
|
isMember: true,
|
||||||
}),
|
}),
|
||||||
|
resolveWebhook: () => ({
|
||||||
|
id: '',
|
||||||
|
name: 'вебхук',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
resolveMention: (userId) => ({ name: userId === 'user-1' ? 'Аля' : 'Bob', isMe: false }),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Сообщение без автора-пользователя: так приходят сообщения вебхуков. */
|
||||||
|
function authorlessMessage(overrides: Partial<Message> & { id: string }): Message {
|
||||||
|
const base = message(overrides);
|
||||||
|
delete base.author_id;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
function message(overrides: Partial<Message> & { id: string }): Message {
|
function message(overrides: Partial<Message> & { id: string }): Message {
|
||||||
return {
|
return {
|
||||||
channel_id: 'c-1',
|
channel_id: 'c-1',
|
||||||
@@ -196,3 +211,59 @@ describe('виды сообщений: действия', () => {
|
|||||||
expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible();
|
expect(within(groups[1] as HTMLElement).getByText('Bob')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('сообщения вебхуков', () => {
|
||||||
|
it('показывает имя и аватар вебхука, а не «неизвестного автора»', () => {
|
||||||
|
const webhookAuthors: AuthorDirectory = {
|
||||||
|
...authors,
|
||||||
|
resolveWebhook: (message) => ({
|
||||||
|
id: message.webhook_id ?? '',
|
||||||
|
name: message.webhook_name ?? 'вебхук',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
avatarUrl: message.webhook_avatar,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
channelId="c-1"
|
||||||
|
channelName="общий"
|
||||||
|
messages={[
|
||||||
|
authorlessMessage({
|
||||||
|
id: 'w-1',
|
||||||
|
type: 'webhook',
|
||||||
|
content: 'сборка прошла',
|
||||||
|
webhook_id: 'hook-1',
|
||||||
|
webhook_name: 'Деплой',
|
||||||
|
webhook_avatar: '/files/777',
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
status="ready"
|
||||||
|
error={null}
|
||||||
|
hasMore={false}
|
||||||
|
loadingMore={false}
|
||||||
|
unreadAnchorId={null}
|
||||||
|
highlightId={null}
|
||||||
|
authors={webhookAuthors}
|
||||||
|
currentUserId="user-1"
|
||||||
|
canManageMessages
|
||||||
|
canSend
|
||||||
|
onReload={() => undefined}
|
||||||
|
onLoadMore={() => undefined}
|
||||||
|
onReply={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = article('w-1');
|
||||||
|
expect(body).toHaveAttribute('data-message-type', 'webhook');
|
||||||
|
// Имя, значок и аватар рисует шапка группы (само сообщение — только текст).
|
||||||
|
const group = screen.getByTestId('message-group');
|
||||||
|
expect(group).toHaveAttribute('data-message-type', 'webhook');
|
||||||
|
expect(within(group).getByText('Деплой')).toBeVisible();
|
||||||
|
expect(within(group).getByTestId('webhook-badge')).toHaveTextContent('вебхук');
|
||||||
|
expect(group.querySelector('img')?.getAttribute('src')).toBe('/files/777');
|
||||||
|
expect(within(group).queryByText('неизвестный')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
import { act } from '@testing-library/react';
|
import { act } from '@testing-library/react';
|
||||||
|
|
||||||
import { parseGatewaySnapshot } from '@/api/gateway';
|
import { parseGatewaySnapshot } from '@/api/gateway';
|
||||||
|
import type { GuildSummary } from '@/api/types';
|
||||||
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
|
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { makeReadySnapshot } from './helpers';
|
import { makeReadySnapshot } from './helpers';
|
||||||
@@ -63,6 +64,46 @@ describe('session store', () => {
|
|||||||
expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000);
|
expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('не затирает данные снапшота неполной сводкой REST', () => {
|
||||||
|
act(() => {
|
||||||
|
dispatchGatewayEvent({
|
||||||
|
op: 0,
|
||||||
|
t: 'READY',
|
||||||
|
s: 1,
|
||||||
|
d: makeReadySnapshot([
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Main',
|
||||||
|
owner_id: 'user-9',
|
||||||
|
is_main: true,
|
||||||
|
my_permissions: ['MANAGE_WEBHOOKS'],
|
||||||
|
channels: [{ id: 'c-1', name: 'general', position: 0 }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ответ REST пришёл без части полей: undefined не должен перетирать снапшот.
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().upsertGuild({
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Main из REST',
|
||||||
|
owner_id: undefined,
|
||||||
|
is_main: undefined,
|
||||||
|
member_count: undefined,
|
||||||
|
my_role_ids: undefined,
|
||||||
|
my_permissions: undefined,
|
||||||
|
} as unknown as GuildSummary);
|
||||||
|
});
|
||||||
|
|
||||||
|
const guild = useSessionStore.getState().guilds[0];
|
||||||
|
expect(guild?.name).toBe('Main из REST');
|
||||||
|
expect(guild?.owner_id).toBe('user-9');
|
||||||
|
expect(guild?.is_main).toBe(true);
|
||||||
|
expect(guild?.my_permissions).toEqual(['MANAGE_WEBHOOKS']);
|
||||||
|
expect(guild?.channels.map((channel) => channel.id)).toEqual(['c-1']);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps channels with can_send=false (only can_view=false is hidden)', () => {
|
it('keeps channels with can_send=false (only can_view=false is hidden)', () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
dispatchGatewayEvent({
|
dispatchGatewayEvent({
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ const authors: AuthorDirectory = {
|
|||||||
isInstanceAdmin: false,
|
isInstanceAdmin: false,
|
||||||
isMember: userId === 'user-2',
|
isMember: userId === 'user-2',
|
||||||
}),
|
}),
|
||||||
|
resolveWebhook: () => ({
|
||||||
|
id: '',
|
||||||
|
name: 'вебхук',
|
||||||
|
avatarFileId: undefined,
|
||||||
|
colorHex: null,
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
isMember: false,
|
||||||
|
}),
|
||||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
|
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Боб' : userId, isMe: false }),
|
||||||
resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null),
|
resolveMemberName: (userId) => (userId === 'user-2' ? 'Боб' : null),
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user