fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен только загрузившему. Добавлено назначение файла (`files.purpose`: attachment | avatar | banner, миграция 00006): аватары и баннеры отдаются любому авторизованному пользователю, вложения по-прежнему проверяются по правам комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { request } from './client';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
|
||||
/**
|
||||
* Друзья, поиск пользователей и личные беседы (AGENT.md §7.8).
|
||||
*
|
||||
* Все ответы разбираются вручную: сервер помечает необязательные поля
|
||||
* `omitempty`, поэтому доверять форме «как есть» нельзя — неизвестные и
|
||||
* пропущенные поля не должны ломать список.
|
||||
*/
|
||||
|
||||
/** Тип связи с пользователем. */
|
||||
export const relationshipKinds = ['friend', 'incoming', 'outgoing', 'blocked', 'none'] as const;
|
||||
export type RelationshipKind = (typeof relationshipKinds)[number];
|
||||
|
||||
/** Пользователь вместе с типом связи — форма `GET /users/@me/relationships`. */
|
||||
export interface RelationshipUser {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
/** Статус, выставленный пользователем (может быть `invisible`). */
|
||||
status: UserStatus;
|
||||
/** Статус, который видят другие: «невидимка» приходит как `offline`. */
|
||||
visible_status: UserStatus;
|
||||
custom_status?: string;
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
last_seen_at?: string;
|
||||
/** Часовой пояс собеседника (для справки; время показываем в своём). */
|
||||
timezone: string;
|
||||
relationship: RelationshipKind;
|
||||
}
|
||||
|
||||
export interface Relationships {
|
||||
friends: RelationshipUser[];
|
||||
incoming: RelationshipUser[];
|
||||
outgoing: RelationshipUser[];
|
||||
blocked: RelationshipUser[];
|
||||
}
|
||||
|
||||
export const relationshipsQueryKey = ['users', '@me', 'relationships'] as const;
|
||||
|
||||
export function userSearchQueryKey(query: string) {
|
||||
return ['users', 'search', query] 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 asStatus(value: unknown): UserStatus | undefined {
|
||||
return userStatuses.includes(value as UserStatus) ? (value as UserStatus) : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Разбирает пользователя со связью; `null` — запись не по контракту. */
|
||||
export function parseRelationshipUser(value: unknown): RelationshipUser | null {
|
||||
const record = asRecord(value);
|
||||
const userId = asString(record?.['user_id']);
|
||||
if (record === null || userId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const username = asString(record['username']) ?? userId;
|
||||
const status = asStatus(record['status']) ?? 'offline';
|
||||
const user: RelationshipUser = {
|
||||
user_id: userId,
|
||||
username,
|
||||
display_name: asString(record['display_name']) ?? username,
|
||||
// Видимый статус сервер считает сам; если его нет — берём обычный.
|
||||
status,
|
||||
visible_status: asStatus(record['visible_status']) ?? status,
|
||||
is_instance_admin: record['is_instance_admin'] === true,
|
||||
badges: asStringArray(record['badges']),
|
||||
timezone: asString(record['timezone']) ?? 'Europe/Moscow',
|
||||
relationship: (relationshipKinds as readonly string[]).includes(
|
||||
record['relationship'] as string,
|
||||
)
|
||||
? (record['relationship'] as RelationshipKind)
|
||||
: 'none',
|
||||
};
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
const customStatus = asString(record['custom_status']);
|
||||
const lastSeenAt = asString(record['last_seen_at']);
|
||||
if (avatar !== undefined) {
|
||||
user.avatar_file_id = avatar;
|
||||
}
|
||||
if (customStatus !== undefined) {
|
||||
user.custom_status = customStatus;
|
||||
}
|
||||
if (lastSeenAt !== undefined) {
|
||||
user.last_seen_at = lastSeenAt;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function parseList(value: unknown): RelationshipUser[] {
|
||||
return Array.isArray(value)
|
||||
? value.map(parseRelationshipUser).filter((item): item is RelationshipUser => item !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function parseRelationships(value: unknown): Relationships {
|
||||
const record = asRecord(value);
|
||||
return {
|
||||
friends: parseList(record?.['friends']),
|
||||
incoming: parseList(record?.['incoming']),
|
||||
outgoing: parseList(record?.['outgoing']),
|
||||
blocked: parseList(record?.['blocked']),
|
||||
};
|
||||
}
|
||||
|
||||
/** `GET /users/@me/relationships` — друзья, входящие и исходящие заявки. */
|
||||
export async function fetchRelationships(signal?: AbortSignal): Promise<Relationships> {
|
||||
const payload = await request<unknown>(
|
||||
'/users/@me/relationships',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return parseRelationships(payload);
|
||||
}
|
||||
|
||||
/** `GET /users/search?q=` — поиск по логину (минимум 2 символа). */
|
||||
export async function searchUsers(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RelationshipUser[]> {
|
||||
const payload = await request<{ users?: unknown }>(
|
||||
`/users/search?q=${encodeURIComponent(query)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return parseList(payload.users);
|
||||
}
|
||||
|
||||
/** `POST /users/@me/relationships` — заявка в друзья по логину или id. */
|
||||
export async function sendFriendRequest(input: {
|
||||
username?: string;
|
||||
user_id?: string;
|
||||
}): Promise<void> {
|
||||
await request<{ ok: true }>('/users/@me/relationships', { method: 'POST', body: input });
|
||||
}
|
||||
|
||||
/** `POST /users/@me/relationships/{id}/accept` — принять входящую заявку. */
|
||||
export async function acceptFriendRequest(userId: string): Promise<void> {
|
||||
await request<{ ok: true }>(`/users/@me/relationships/${encodeURIComponent(userId)}/accept`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
|
||||
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
|
||||
* тот же вызов (AGENT.md §7.8).
|
||||
*/
|
||||
export async function removeRelationship(userId: string): Promise<void> {
|
||||
await request<{ ok: true }>(`/users/@me/relationships/${encodeURIComponent(userId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/** Личная беседа в REST-форме (`GET /users/@me/channels`). */
|
||||
export interface DirectChannelPayload {
|
||||
id?: unknown;
|
||||
can_send?: unknown;
|
||||
can_view?: unknown;
|
||||
recipient?: unknown;
|
||||
last_message_id?: unknown;
|
||||
last_message_at?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит личную беседу из REST к общей форме `Channel`: чат и сайдбар
|
||||
* работают с одним типом, поэтому имя и аватар берём у собеседника.
|
||||
*/
|
||||
export function parseDirectChannel(value: unknown): Channel | null {
|
||||
const record = asRecord(value);
|
||||
const id = asString(record?.['id']);
|
||||
if (record === null || id === undefined) {
|
||||
return null;
|
||||
}
|
||||
const recipient = asRecord(record['recipient']);
|
||||
const username = asString(recipient?.['username']) ?? '';
|
||||
const channel: Channel = {
|
||||
id,
|
||||
name: asString(recipient?.['display_name']) ?? username,
|
||||
type: 'dm',
|
||||
position: 0,
|
||||
can_send: record['can_send'] !== false,
|
||||
can_view: record['can_view'] !== false,
|
||||
};
|
||||
const recipientId = asString(recipient?.['user_id']);
|
||||
const avatar = asString(recipient?.['avatar_file_id']);
|
||||
// Наружу отдаём видимый статус: «невидимка» выглядит как офлайн.
|
||||
const status = asStatus(recipient?.['visible_status']) ?? asStatus(recipient?.['status']);
|
||||
const lastMessageId = asString(record['last_message_id']);
|
||||
const lastMessageAt = asString(record['last_message_at']);
|
||||
if (recipientId !== undefined) {
|
||||
channel.recipient_id = recipientId;
|
||||
}
|
||||
if (avatar !== undefined) {
|
||||
channel.icon_file_id = avatar;
|
||||
}
|
||||
if (status !== undefined) {
|
||||
channel.status = status;
|
||||
}
|
||||
if (lastMessageId !== undefined) {
|
||||
channel.last_message_id = lastMessageId;
|
||||
}
|
||||
if (lastMessageAt !== undefined) {
|
||||
channel.last_message_at = lastMessageAt;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** `GET /users/@me/channels` — все личные беседы пользователя. */
|
||||
export async function fetchDirectChannels(signal?: AbortSignal): Promise<Channel[]> {
|
||||
const payload = await request<{ channels?: unknown }>(
|
||||
'/users/@me/channels',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return Array.isArray(payload.channels)
|
||||
? payload.channels.map(parseDirectChannel).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */
|
||||
export async function openDirectChannel(recipientId: string): Promise<Channel> {
|
||||
const payload = await request<{ channel?: unknown }>('/users/@me/channels', {
|
||||
method: 'POST',
|
||||
body: { recipient_id: recipientId },
|
||||
});
|
||||
const channel = parseDirectChannel(payload.channel);
|
||||
if (channel === null) {
|
||||
throw new Error('malformed direct channel payload');
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (id === undefined || name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category') {
|
||||
if (type !== 'text' && type !== 'voice' && type !== 'category' && type !== 'dm') {
|
||||
return null;
|
||||
}
|
||||
const channel: Channel = {
|
||||
@@ -226,9 +226,94 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (typeof record['can_view'] === 'boolean') {
|
||||
channel.can_view = record['can_view'];
|
||||
}
|
||||
// Личная беседа: собеседник и последнее сообщение (READY и CHANNEL_*).
|
||||
const recipientId = asString(record['recipient_id']);
|
||||
const iconFileId = asString(record['icon_file_id']);
|
||||
const status = asString(record['status']);
|
||||
const lastMessageId = asString(record['last_message_id']);
|
||||
const lastMessageAt = asString(record['last_message_at']);
|
||||
if (recipientId !== undefined) {
|
||||
channel.recipient_id = recipientId;
|
||||
}
|
||||
if (iconFileId !== undefined) {
|
||||
channel.icon_file_id = iconFileId;
|
||||
}
|
||||
if (userStatuses.includes(status as UserStatus)) {
|
||||
channel.status = status as UserStatus;
|
||||
}
|
||||
if (lastMessageId !== undefined) {
|
||||
channel.last_message_id = lastMessageId;
|
||||
}
|
||||
if (lastMessageAt !== undefined) {
|
||||
channel.last_message_at = lastMessageAt;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Друзья и личные беседы (AGENT.md §8.3 / 7.8) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface DmChannelEvent {
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
export interface RelationshipEvent {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface PresenceEvent {
|
||||
user_id: string;
|
||||
status?: UserStatus;
|
||||
visible_status?: UserStatus;
|
||||
custom_status?: string;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
|
||||
/** `DM_CHANNEL_CREATE`: создана личная беседа — перечитываем список бесед. */
|
||||
export function parseDmChannelEvent(value: unknown): DmChannelEvent | null {
|
||||
const record = asRecord(value);
|
||||
const channelId = asString(record?.['channel_id']);
|
||||
return record === null || channelId === undefined ? null : { channel_id: channelId };
|
||||
}
|
||||
|
||||
/** `RELATIONSHIP_UPDATE`: изменилась связь с пользователем. */
|
||||
export function parseRelationshipEvent(value: unknown): RelationshipEvent | null {
|
||||
const record = asRecord(value);
|
||||
const userId = asString(record?.['user_id']);
|
||||
return record === null || userId === undefined ? null : { user_id: userId };
|
||||
}
|
||||
|
||||
/**
|
||||
* `PRESENCE_UPDATE`: статус друга. Поля необязательны — сервер присылает
|
||||
* только изменившиеся, поэтому разбираем каждое отдельно.
|
||||
*/
|
||||
export function parsePresenceEvent(value: unknown): PresenceEvent | null {
|
||||
const record = asRecord(value);
|
||||
const userId = asString(record?.['user_id']);
|
||||
if (record === null || userId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const event: PresenceEvent = { user_id: userId };
|
||||
const status = asString(record['status']);
|
||||
const visibleStatus = asString(record['visible_status']);
|
||||
const customStatus = asString(record['custom_status']);
|
||||
const lastSeenAt = asString(record['last_seen_at']);
|
||||
if (userStatuses.includes(status as UserStatus)) {
|
||||
event.status = status as UserStatus;
|
||||
}
|
||||
if (userStatuses.includes(visibleStatus as UserStatus)) {
|
||||
event.visible_status = visibleStatus as UserStatus;
|
||||
}
|
||||
if (customStatus !== undefined) {
|
||||
event.custom_status = customStatus;
|
||||
}
|
||||
if (lastSeenAt !== undefined) {
|
||||
event.last_seen_at = lastSeenAt;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* События сообщений Фазы 2 (AGENT.md §8.3) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { request } from './client';
|
||||
|
||||
/**
|
||||
* Приглашения на сервер (AGENT.md §7.9).
|
||||
*
|
||||
* Присоединиться можно только по приглашению, поэтому клиент умеет:
|
||||
* разобрать код из ссылки `https://<домен>/invite/<код>`, показать
|
||||
* предпросмотр (`GET /invites/{code}`) и принять его (`POST /invites/{code}`).
|
||||
*/
|
||||
|
||||
export interface InviteGuildPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
description?: string;
|
||||
member_count: number;
|
||||
is_member: boolean;
|
||||
}
|
||||
|
||||
export interface InvitePreview {
|
||||
code: string;
|
||||
guild_id: string;
|
||||
guild: InviteGuildPreview;
|
||||
}
|
||||
|
||||
export const inviteQueryKey = (code: string) => ['invites', code] as const;
|
||||
|
||||
/**
|
||||
* Достаёт код приглашения из ссылки или «сырого» кода.
|
||||
* Возвращает `null`, если строка не похожа ни на то, ни на другое.
|
||||
*/
|
||||
export function parseInviteCode(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
const fromUrl = (value: string): string | null => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
||||
const index = segments.indexOf('invite');
|
||||
const code = index === -1 ? undefined : segments[index + 1];
|
||||
return code === undefined || code === '' ? null : code;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (/^https?:\/\//iu.test(trimmed)) {
|
||||
return fromUrl(trimmed);
|
||||
}
|
||||
// Сырой код: буквы и цифры без разделителей (алфавит сервера — a-z0-9).
|
||||
return /^[0-9a-z]+$/iu.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** `GET /invites/{code}` — предпросмотр приглашения. */
|
||||
export async function fetchInvite(code: string, signal?: AbortSignal): Promise<InvitePreview> {
|
||||
const payload = await request<unknown>(
|
||||
`/invites/${encodeURIComponent(code)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
const record = asRecord(payload);
|
||||
const invite = asRecord(record?.['invite']);
|
||||
const guild = asRecord(record?.['guild']);
|
||||
const guildId = asString(guild?.['id']);
|
||||
if (guild === null || guildId === undefined) {
|
||||
throw new Error('malformed invite payload');
|
||||
}
|
||||
const preview: InvitePreview = {
|
||||
code: asString(invite?.['code']) ?? code,
|
||||
guild_id: guildId,
|
||||
guild: {
|
||||
id: guildId,
|
||||
name: asString(guild['name']) ?? guildId,
|
||||
member_count: typeof guild['member_count'] === 'number' ? guild['member_count'] : 0,
|
||||
is_member: guild['is_member'] === true,
|
||||
},
|
||||
};
|
||||
const icon = asString(guild['icon_file_id']);
|
||||
const description = asString(guild['description']);
|
||||
if (icon !== undefined) {
|
||||
preview.guild.icon_file_id = icon;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
preview.guild.description = description;
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
/** `POST /invites/{code}` — вступить на сервер; сервер отвечает его id. */
|
||||
export async function acceptInvite(code: string): Promise<string> {
|
||||
const payload = await request<{ guild_id?: unknown }>(`/invites/${encodeURIComponent(code)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const guildId = asString(payload.guild_id);
|
||||
if (guildId === undefined) {
|
||||
throw new Error('malformed invite accept payload');
|
||||
}
|
||||
return guildId;
|
||||
}
|
||||
+12
-2
@@ -8,8 +8,8 @@
|
||||
export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const;
|
||||
export type UserStatus = (typeof userStatuses)[number];
|
||||
|
||||
/** Тип комнаты: текст, голосовой канал или категория. */
|
||||
export type ChannelType = 'text' | 'voice' | 'category';
|
||||
/** Тип комнаты: текст, голосовой канал, категория или личная беседа. */
|
||||
export type ChannelType = 'text' | 'voice' | 'category' | 'dm';
|
||||
|
||||
/** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */
|
||||
export interface User {
|
||||
@@ -75,6 +75,16 @@ export interface Channel {
|
||||
can_view?: boolean;
|
||||
can_send?: boolean;
|
||||
can_connect?: boolean;
|
||||
/**
|
||||
* Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый
|
||||
* статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке
|
||||
* бесед — внутри `recipient`.
|
||||
*/
|
||||
recipient_id?: string;
|
||||
icon_file_id?: string;
|
||||
status?: UserStatus;
|
||||
last_message_id?: string;
|
||||
last_message_at?: string;
|
||||
}
|
||||
|
||||
export interface Guild {
|
||||
|
||||
Reference in New Issue
Block a user