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:
@@ -239,7 +239,11 @@ export function MessageItem({
|
||||
const menuAnchor = useRef<HTMLButtonElement | 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 canEdit = isMine && canSend && !pending;
|
||||
const canDelete = !pending && (isMine || canManageMessages);
|
||||
|
||||
@@ -219,7 +219,10 @@ export function MessageList({
|
||||
return null;
|
||||
}
|
||||
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` рисует имя внутри строки — шапка ему тоже не нужна.
|
||||
const showHeader = headType !== 'system' && headType !== 'action';
|
||||
@@ -275,6 +278,7 @@ export function MessageList({
|
||||
name={author.name}
|
||||
seed={author.id === '' ? row.key : author.id}
|
||||
fileId={author.avatarFileId}
|
||||
url={author.avatarUrl}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
@@ -289,6 +293,14 @@ export function MessageList({
|
||||
>
|
||||
{author.name}
|
||||
</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 ? (
|
||||
<span className="rounded-full border border-accent/40 px-1.5 text-[10px] uppercase text-accent">
|
||||
{t('chat.adminBadge')}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ChatAuthor {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarFileId: string | undefined;
|
||||
/** Готовая ссылка на аватар: у вебхуков нет файла в профиле (AGM 7.11). */
|
||||
avatarUrl?: string | undefined;
|
||||
colorHex: string | null;
|
||||
isInstanceAdmin: boolean;
|
||||
isMember: boolean;
|
||||
@@ -27,6 +29,15 @@ export interface ChatAuthor {
|
||||
|
||||
export interface AuthorDirectory {
|
||||
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 };
|
||||
/**
|
||||
* Имя участника или `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 {
|
||||
resolve,
|
||||
resolveWebhook,
|
||||
resolveMention: (userId) => {
|
||||
const author = resolve(userId);
|
||||
return { name: author.name, isMe: userId === currentUserId };
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
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 {
|
||||
name: string;
|
||||
/** Стабильный id для оттенка заглушки. */
|
||||
seed: string;
|
||||
fileId?: string | undefined;
|
||||
/** Готовая ссылка (аватар вебхука) вместо `fileId`. */
|
||||
url?: string | undefined;
|
||||
/** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */
|
||||
preview?: string | undefined;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
@@ -28,11 +43,12 @@ export function Avatar({
|
||||
name,
|
||||
seed,
|
||||
fileId,
|
||||
url,
|
||||
preview,
|
||||
size = 'md',
|
||||
shape = 'circle',
|
||||
}: AvatarProps) {
|
||||
const src = preview ?? avatarUrl(fileId);
|
||||
const src = preview ?? safeImageUrl(url) ?? avatarUrl(fileId);
|
||||
const hue = hueFromId(seed);
|
||||
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user