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 | null { return typeof value === 'object' && value !== null ? (value as Record) : 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 { const payload = await request( '/users/@me/relationships', signal === undefined ? {} : { signal }, ); return parseRelationships(payload); } /** `GET /users/search?q=` — поиск по логину (минимум 2 символа). */ export async function searchUsers( query: string, signal?: AbortSignal, ): Promise { 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 { await request<{ ok: true }>('/users/@me/relationships', { method: 'POST', body: input }); } /** `POST /users/@me/relationships/{id}/accept` — принять входящую заявку. */ export async function acceptFriendRequest(userId: string): Promise { 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 { 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 { 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 { 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; }