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:
2026-09-21 00:26:50 +03:00
parent 4ea2965892
commit 2e038a1d3f
32 changed files with 2494 additions and 23 deletions
+30 -3
View File
@@ -57,6 +57,8 @@ interface SessionState {
selectedChannelId: string | null;
/** Последний сервер, открытый в настройках (`/settings/servers/:guildId`). */
settingsGuildId: string | null;
/** Последняя комната, открытая в настройках комнаты (вебхуки, AGENT.md 7.11). */
settingsChannelId: string | null;
applyReady: (snapshot: GatewaySnapshot) => void;
/** RESUMED приходит без снапшота — обновляем только то, что пришло. */
@@ -117,6 +119,8 @@ interface SessionState {
selectChannel: (guildId: string | null, channelId: string | null) => void;
/** Выбор сервера в настройках — отдельно от выбора в приложении. */
selectSettingsGuild: (guildId: string | null) => void;
/** Выбор комнаты в настройках комнаты. */
selectSettingsChannel: (channelId: string | null) => void;
reset: () => void;
}
@@ -129,6 +133,7 @@ interface SessionDefaults {
selectedGuildId: null;
selectedChannelId: null;
settingsGuildId: null;
settingsChannelId: null;
}
const defaults: SessionDefaults = {
@@ -140,8 +145,20 @@ const defaults: SessionDefaults = {
selectedGuildId: null,
selectedChannelId: 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 {
const summary: SessionGuild = {
id: guild.id,
@@ -254,9 +271,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
upsertGuild: (guild) => {
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) {
set({ guilds: [...guilds, { ...guild, channels: [], emojis: [], sounds: [] }] });
// Незнакомый сервер: сводки достаточно, комнаты догрузит сайдбар.
set({
guilds: [...guilds, { ...patch, channels: [], emojis: [], sounds: [] } as SessionGuild],
});
return;
}
const existing = guilds[index];
@@ -266,7 +289,7 @@ export const useSessionStore = create<SessionState>((set, get) => ({
// Сводка из REST не знает ни комнат, ни эмодзи, ни звуков — накопленное не теряем.
const merged: SessionGuild = {
...existing,
...guild,
...patch,
channels: existing.channels,
emojis: existing.emojis,
sounds: existing.sounds,
@@ -529,6 +552,10 @@ export const useSessionStore = create<SessionState>((set, get) => ({
set({ settingsGuildId: guildId });
},
selectSettingsChannel: (channelId) => {
set({ settingsChannelId: channelId });
},
reset: () => {
set({ ...defaults });
},
+7
View File
@@ -14,6 +14,8 @@ export const settingsSections = [
'language',
/** Администрирование выбранного сервера. */
'server',
/** Настройки выбранной комнаты: вебхуки (AGENT.md 7.11). */
'channel',
'instance',
] as const;
export type SettingsSection = (typeof settingsSections)[number];
@@ -28,6 +30,8 @@ export interface SettingsTarget {
tab?: ProfileTab;
/** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */
guildId?: string | null;
/** Комната для пункта «Комната». */
channelId?: string | null;
}
interface UiState {
@@ -116,6 +120,9 @@ export const useUiStore = create<UiState>((set, get) => ({
if (target?.guildId !== undefined) {
useSessionStore.getState().selectSettingsGuild(target.guildId);
}
if (target?.channelId !== undefined) {
useSessionStore.getState().selectSettingsChannel(target.channelId);
}
set({
settingsOpen: true,
...(target?.section === undefined ? {} : { settingsSection: target.section }),