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:
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user