test(web): e2e личных серверов, DM, друзей и блокировок
Сценариев личных серверов и личных бесед в матрице 11.6 не было (AGENT.md 7.2, 7.8, 7.9). Новый спек идёт под тем же флагом `GLCHAT_E2E_REALTIME=1` и тем же стилем: два браузера, `support.ts`, `trackLoads`, уборка в `finally`. - 21: личный (непубличный) сервер — вход по ссылке-приглашению в браузере, сервера нет в каталоге, вход без приглашения 403, вошедший видит историю; - 22: DM 1:1 — непрочитанное в списке бесед, история, «печатает», ответ из интерфейса, правка и удаление без перезагрузки; - 23: заявка в друзья и её принятие кнопкой в интерфейсе; - 24: блокировка из списка друзей — писать нельзя в обе стороны (`dm.blocked`), беседа не открывается, разблокировка возвращает переписку. Приглашение (1 на прогон) создаёт владелец: суточная квота — 10 на пользователя (AGENT.md 8.6).
This commit is contained in:
@@ -0,0 +1,415 @@
|
|||||||
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type APIRequestContext,
|
||||||
|
type APIResponse,
|
||||||
|
type Page,
|
||||||
|
} from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
apiAs,
|
||||||
|
cleanupGuild,
|
||||||
|
createGuild,
|
||||||
|
createInvite,
|
||||||
|
displayName,
|
||||||
|
dropStaleGuilds,
|
||||||
|
ensureFriends,
|
||||||
|
ensureProbeWithWait,
|
||||||
|
freshState,
|
||||||
|
openApp,
|
||||||
|
openChannel,
|
||||||
|
openDmChannel,
|
||||||
|
postMessage,
|
||||||
|
removeFriendship,
|
||||||
|
repeat429,
|
||||||
|
setBlocked,
|
||||||
|
stepUp,
|
||||||
|
trackLoads,
|
||||||
|
type Account,
|
||||||
|
type GuildFixture,
|
||||||
|
type ProbeLogin,
|
||||||
|
type Session,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Личные серверы, личные беседы и друзья (AGENT.md 7.2, 7.8, 7.9): сценарии,
|
||||||
|
* которых не было в realtime-матрице 11.6. Два браузера (A — владелец и
|
||||||
|
* инициатор, B — второй участник), действия идут и по REST, и через интерфейс.
|
||||||
|
* Запуск только по флагу, тест требует стенда:
|
||||||
|
*
|
||||||
|
* GLCHAT_E2E_REALTIME=1 GLCHAT_URL=https://gl.mhspx.su \
|
||||||
|
* PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \
|
||||||
|
* npx playwright test e2e/personal-dm.spec.ts
|
||||||
|
*/
|
||||||
|
const enabled = process.env.GLCHAT_E2E_REALTIME === '1';
|
||||||
|
const API = '/api/v1';
|
||||||
|
/** Префикс имени тестового сервера: по нему убираются серверы прошлых прогонов. */
|
||||||
|
const GUILD_PREFIX = 'Personal E2E ';
|
||||||
|
/** Постоянные пробные аккаунты: A — инициатор, B — второй браузер. */
|
||||||
|
const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' };
|
||||||
|
const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' };
|
||||||
|
|
||||||
|
let owner: Account;
|
||||||
|
let member: Account;
|
||||||
|
|
||||||
|
test.describe('личные серверы, DM и друзья (§7.2, §7.8, §7.9)', () => {
|
||||||
|
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_REALTIME=1');
|
||||||
|
|
||||||
|
// Лимит API — 120 запросов в минуту на сессию (AGENT.md 8.6): пауза между
|
||||||
|
// тестами даёт ведру лимита наполниться, иначе интерфейс получает 429.
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 4_000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeAll(async ({ playwright }) => {
|
||||||
|
const ownerApi = await playwright.request.newContext({ baseURL: standBase() });
|
||||||
|
const memberApi = await playwright.request.newContext({ baseURL: standBase() });
|
||||||
|
try {
|
||||||
|
// Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени.
|
||||||
|
const ownerProbe = await ensureProbeWithWait(ownerApi, OWNER_LOGIN);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||||
|
const memberProbe = await ensureProbeWithWait(memberApi, MEMBER_LOGIN);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||||
|
const ownerName = await displayName(ownerApi);
|
||||||
|
const memberName = await displayName(memberApi);
|
||||||
|
// Уборка серверов прошлых прогонов: удаление требует свежий step-up.
|
||||||
|
await stepUp(ownerApi, OWNER_LOGIN.password);
|
||||||
|
await dropStaleGuilds(ownerApi, GUILD_PREFIX);
|
||||||
|
await stepUp(memberApi, MEMBER_LOGIN.password);
|
||||||
|
await dropStaleGuilds(memberApi, GUILD_PREFIX);
|
||||||
|
owner = {
|
||||||
|
...ownerProbe,
|
||||||
|
displayName: ownerName,
|
||||||
|
state: await ownerApi.storageState(),
|
||||||
|
browserState: await freshState(ownerApi, OWNER_LOGIN),
|
||||||
|
};
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||||
|
member = {
|
||||||
|
...memberProbe,
|
||||||
|
displayName: memberName,
|
||||||
|
state: await memberApi.storageState(),
|
||||||
|
browserState: await freshState(memberApi, MEMBER_LOGIN),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await ownerApi.dispose();
|
||||||
|
await memberApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('21. Личный сервер: вход по инвайту и закрытость без приглашения', async ({
|
||||||
|
browser,
|
||||||
|
playwright,
|
||||||
|
}) => {
|
||||||
|
const ownerApi = await apiAs(playwright, owner.state);
|
||||||
|
const memberApi = await apiAs(playwright, member.state);
|
||||||
|
let session: Session | null = null;
|
||||||
|
let guildId = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const guild = await createGuild(ownerApi, `${GUILD_PREFIX}${Date.now()}`);
|
||||||
|
guildId = guild.id;
|
||||||
|
// Сообщение появляется в комнате ДО приглашения: вошедший по ссылке
|
||||||
|
// должен увидеть историю, а не гонку «лента загрузилась раньше отправки».
|
||||||
|
const channels = await repeat429(() => ownerApi.get(`${API}/guilds/${guildId}/channels`));
|
||||||
|
const textChannel = (
|
||||||
|
(await channels.json()) as { channels: { id: string; name: string; type: string }[] }
|
||||||
|
).channels.find((channel) => channel.type === 'text');
|
||||||
|
expect(textChannel, 'у сервера есть общая комната').toBeTruthy();
|
||||||
|
const text = `привет по инвайту ${Date.now()}`;
|
||||||
|
await postMessage(ownerApi, textChannel?.id ?? '', text);
|
||||||
|
|
||||||
|
// Приглашение создаёт владелец: у него есть CREATE_INVITES, а квота —
|
||||||
|
// 10 в сутки на пользователя (AGENT.md 8.6).
|
||||||
|
const invite = await createInvite(ownerApi, guildId);
|
||||||
|
|
||||||
|
// Сервер непубличный: в каталоге его нет, а вход без приглашения закрыт.
|
||||||
|
const catalog = await repeat429(() => memberApi.get(`${API}/guilds/public`));
|
||||||
|
const names = ((await catalog.json()) as { guilds: { name: string }[] }).guilds.map(
|
||||||
|
(item) => item.name,
|
||||||
|
);
|
||||||
|
expect(names, 'личный сервер не попадает в каталог публичных').not.toContain(guild.name);
|
||||||
|
const joinWithoutInvite = await repeat429(() =>
|
||||||
|
memberApi.post(`${API}/guilds/${guildId}/join`),
|
||||||
|
);
|
||||||
|
expect(joinWithoutInvite.status(), 'без приглашения в личный сервер не войти').toBe(403);
|
||||||
|
|
||||||
|
// Вход по ссылке-приглашению в браузере: карточка сервера и кнопка входа.
|
||||||
|
session = await openApp(browser, member.browserState);
|
||||||
|
const page = session.page;
|
||||||
|
await page.goto(`/invite/${invite}`);
|
||||||
|
await expect(page.getByTestId('invite-guild-name')).toContainText(guild.name, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByTestId('invite-accept').click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/app/${guildId}`, 'u'), { timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('guild-header')).toContainText(guild.name, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// Вошедший по инвайту видит комнату и её историю до входа.
|
||||||
|
await openChannel(page, textChannel?.name ?? '');
|
||||||
|
await expect(page.getByTestId('message-list')).toContainText(text, { timeout: 30_000 });
|
||||||
|
expect(await memberGuildNames(memberApi), 'сервер появился в списке участника').toContain(
|
||||||
|
guild.name,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await session?.context.close();
|
||||||
|
await cleanupGuild(ownerApi, fixtureOf(guildId), OWNER_LOGIN.password);
|
||||||
|
await ownerApi.dispose();
|
||||||
|
await memberApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('22. DM 1:1: отправка, правка, удаление, непрочитанные и «печатает»', async ({
|
||||||
|
browser,
|
||||||
|
playwright,
|
||||||
|
}) => {
|
||||||
|
const ownerApi = await apiAs(playwright, owner.state);
|
||||||
|
const session = await openApp(browser, member.browserState);
|
||||||
|
let dmId = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = session.page;
|
||||||
|
// Беседу открывает инициатор: у B она появляется событием DM_CHANNEL_CREATE.
|
||||||
|
dmId = await openDmChannel(ownerApi, member.id);
|
||||||
|
await openFriends(page);
|
||||||
|
const link = page.getByTestId(`dm-link-${dmId}`);
|
||||||
|
await expect(link).toBeVisible({ timeout: 30_000 });
|
||||||
|
const loads = trackLoads(page);
|
||||||
|
loads.reset();
|
||||||
|
|
||||||
|
// Непрочитанное: сообщение владельца подсвечивает беседу в списке.
|
||||||
|
const first = `личное ${Date.now()}`;
|
||||||
|
const firstId = await postMessage(ownerApi, dmId, first);
|
||||||
|
await expect(page.getByTestId(`dm-unread-${dmId}`)).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Открытие беседы: история видна, счётчик непрочитанных гаснет.
|
||||||
|
await link.click();
|
||||||
|
await expect(page.getByTestId('message-list')).toContainText(first, { timeout: 20_000 });
|
||||||
|
await expect(page.getByTestId(`dm-unread-${dmId}`)).toHaveCount(0, { timeout: 20_000 });
|
||||||
|
|
||||||
|
// «Печатает»: набор текста владельцем показывает индикатор у B.
|
||||||
|
const typing = await repeat429(() => ownerApi.post(`${API}/channels/${dmId}/typing`));
|
||||||
|
expect(typing.ok(), `typing: ${await typing.text()}`).toBeTruthy();
|
||||||
|
await expect(page.getByTestId('typing-indicator')).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.getByTestId('typing-indicator')).toContainText(owner.displayName, {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ответ участника из интерфейса: его видит инициатор.
|
||||||
|
const reply = `ответ ${Date.now()}`;
|
||||||
|
const composer = page.getByTestId('composer').locator('textarea').first();
|
||||||
|
await composer.fill(reply);
|
||||||
|
await composer.press('Enter');
|
||||||
|
await expect(page.getByTestId('message-list')).toContainText(reply, { timeout: 20_000 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => JSON.stringify(await channelMessages(ownerApi, dmId)), {
|
||||||
|
timeout: 20_000,
|
||||||
|
})
|
||||||
|
.toContain(reply);
|
||||||
|
|
||||||
|
// Правка владельцем: B видит новый текст и пометку «(изменено)».
|
||||||
|
const edited = await repeat429(() =>
|
||||||
|
ownerApi.patch(`${API}/channels/${dmId}/messages/${firstId}`, {
|
||||||
|
data: { content: `${first} (правка)` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(edited.ok(), `правка в личной беседе: ${await edited.text()}`).toBeTruthy();
|
||||||
|
const article = page.locator(`article[data-testid="message-${firstId}"]`);
|
||||||
|
await expect(article).toContainText('(правка)', { timeout: 20_000 });
|
||||||
|
await expect(article).toContainText('(изменено)', { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Удаление: сообщение пропадает из ленты без перезагрузки.
|
||||||
|
const removed = await repeat429(() =>
|
||||||
|
ownerApi.delete(`${API}/channels/${dmId}/messages/${firstId}`),
|
||||||
|
);
|
||||||
|
expect(removed.ok(), `удаление в личной беседе: ${await removed.text()}`).toBeTruthy();
|
||||||
|
await expect(article).toHaveCount(0, { timeout: 20_000 });
|
||||||
|
expect(loads.count(), 'личная беседа обновлялась без полной загрузки документа').toBe(0);
|
||||||
|
} finally {
|
||||||
|
await session.context.close();
|
||||||
|
await ownerApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('23. Друзья: заявка и принятие через интерфейс', async ({ browser, playwright }) => {
|
||||||
|
const ownerApi = await apiAs(playwright, owner.state);
|
||||||
|
const memberApi = await apiAs(playwright, member.state);
|
||||||
|
const session = await openApp(browser, member.browserState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = session.page;
|
||||||
|
// Начинаем с нуля: дружба могла остаться от прошлых прогонов.
|
||||||
|
await removeFriendship(ownerApi, member.id);
|
||||||
|
await removeFriendship(memberApi, owner.id);
|
||||||
|
|
||||||
|
const request = await repeat429(() =>
|
||||||
|
ownerApi.post(`${API}/users/@me/relationships`, { data: { user_id: member.id } }),
|
||||||
|
);
|
||||||
|
expect(request.ok(), `заявка в друзья: ${await request.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// B видит входящую заявку и принимает её кнопкой — без перезагрузки.
|
||||||
|
await openFriends(page);
|
||||||
|
const loads = trackLoads(page);
|
||||||
|
loads.reset();
|
||||||
|
const incoming = page.getByTestId('friends-incoming');
|
||||||
|
await expect(incoming).toContainText(owner.displayName, { timeout: 20_000 });
|
||||||
|
await incoming.getByRole('button', { name: 'Принять' }).first().click();
|
||||||
|
|
||||||
|
// После принятия владелец в списке друзей, заявка исчезает.
|
||||||
|
await expect.poll(async () => friendIds(ownerApi), { timeout: 20_000 }).toContain(member.id);
|
||||||
|
await expect(incoming).toHaveCount(0, { timeout: 20_000 });
|
||||||
|
await ensureFriendsListOpen(page);
|
||||||
|
await expect(page.getByTestId(`friend-row-${owner.id}`)).toBeVisible({ timeout: 20_000 });
|
||||||
|
expect(loads.count(), 'список друзей обновлялся без полной загрузки документа').toBe(0);
|
||||||
|
} finally {
|
||||||
|
// Дружба восстанавливается: на ней стоят проверки presence в матрице 11.6.
|
||||||
|
await ensureFriends(ownerApi, memberApi, owner.id, member.id).catch(() => undefined);
|
||||||
|
await session.context.close();
|
||||||
|
await ownerApi.dispose();
|
||||||
|
await memberApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('24. Блокировка: писать нельзя в обе стороны, разблокировка возвращает переписку', async ({
|
||||||
|
browser,
|
||||||
|
playwright,
|
||||||
|
}) => {
|
||||||
|
const ownerApi = await apiAs(playwright, owner.state);
|
||||||
|
const memberApi = await apiAs(playwright, member.state);
|
||||||
|
const session = await openApp(browser, member.browserState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = session.page;
|
||||||
|
// Блокировка доступна из списка друзей: сначала дружба, потом меню «…».
|
||||||
|
await ensureFriends(ownerApi, memberApi, owner.id, member.id);
|
||||||
|
const dmId = await openDmChannel(ownerApi, member.id);
|
||||||
|
await openFriends(page);
|
||||||
|
await ensureFriendsListOpen(page);
|
||||||
|
const row = page.getByTestId(`friend-row-${owner.id}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
|
await row.getByRole('button', { name: `Действия: ${owner.displayName}` }).click();
|
||||||
|
await page.getByTestId(`friend-block-${owner.id}`).click();
|
||||||
|
await expect(page.getByTestId('friends-blocked')).toContainText(owner.displayName, {
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Блокировка запрещает переписку в обе стороны (AGENT.md 7.2).
|
||||||
|
const fromOwner = await ownerApi.post(`${API}/channels/${dmId}/messages`, {
|
||||||
|
data: { content: 'после блокировки' },
|
||||||
|
});
|
||||||
|
expect(fromOwner.status(), 'заблокированный не пишет инициатору').toBe(403);
|
||||||
|
expect(await errorCode(fromOwner)).toBe('dm.blocked');
|
||||||
|
const fromMember = await memberApi.post(`${API}/channels/${dmId}/messages`, {
|
||||||
|
data: { content: 'ответ после блокировки' },
|
||||||
|
});
|
||||||
|
expect(fromMember.status(), 'инициатор не пишет заблокированному').toBe(403);
|
||||||
|
expect(await errorCode(fromMember)).toBe('dm.blocked');
|
||||||
|
const newDm = await repeat429(() =>
|
||||||
|
ownerApi.post(`${API}/users/@me/channels`, { data: { recipient_id: member.id } }),
|
||||||
|
);
|
||||||
|
expect(newDm.status(), 'беседа с заблокированным не открывается').toBe(403);
|
||||||
|
|
||||||
|
// Разблокировка из интерфейса возвращает переписку.
|
||||||
|
const blocked = page.getByTestId('friends-blocked');
|
||||||
|
await blocked.getByRole('button', { name: 'Разблокировать' }).first().click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => blockedUserIds(ownerApi), { timeout: 20_000 })
|
||||||
|
.not.toContain(member.id);
|
||||||
|
const restored = await ownerApi.post(`${API}/channels/${dmId}/messages`, {
|
||||||
|
data: { content: 'после разблокировки' },
|
||||||
|
});
|
||||||
|
expect(restored.ok(), `переписка вернулась: ${await restored.text()}`).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
// Страховка: блокировка и разрыв дружбы не должны остаться на стенде.
|
||||||
|
await setBlocked(memberApi, owner.id, false).catch(() => undefined);
|
||||||
|
await ensureFriends(ownerApi, memberApi, owner.id, member.id).catch(() => undefined);
|
||||||
|
await session.context.close();
|
||||||
|
await ownerApi.dispose();
|
||||||
|
await memberApi.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** standBase — адрес стенда: тот же, что у Playwright (GLCHAT_URL). */
|
||||||
|
function standBase(): string {
|
||||||
|
return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fixtureOf собирает минимальное описание сервера для уборки по id. */
|
||||||
|
function fixtureOf(guildId: string): GuildFixture | null {
|
||||||
|
if (guildId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
guildId,
|
||||||
|
guildName: '',
|
||||||
|
textChannelId: '',
|
||||||
|
textChannelName: '',
|
||||||
|
everyoneRoleId: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** openFriends открывает раздел «Друзья». */
|
||||||
|
async function openFriends(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('link', { name: 'Друзья' }).click();
|
||||||
|
// Заголовков «Друзья» на экране три (сайдбар, рейка и контент) — берём первый.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Друзья' }).first()).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
await expect(page).toHaveURL(/\/app\/friends/u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureFriendsListOpen раскрывает список друзей, если он свёрнут: состояние
|
||||||
|
* живёт в localStorage и по умолчанию закрыто.
|
||||||
|
*/
|
||||||
|
async function ensureFriendsListOpen(page: Page): Promise<void> {
|
||||||
|
const region = page.getByTestId('friends-list-region');
|
||||||
|
await expect(region).toBeAttached({ timeout: 20_000 });
|
||||||
|
if ((await region.getAttribute('data-state')) !== 'open') {
|
||||||
|
await page.getByTestId('friends-list-toggle').click({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
await expect(region).toHaveAttribute('data-state', 'open', { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** memberGuildNames читает список серверов пользователя. */
|
||||||
|
async function memberGuildNames(api: APIRequestContext): Promise<string[]> {
|
||||||
|
const response = await repeat429(() => api.get(`${API}/users/@me/guilds`));
|
||||||
|
expect(response.ok(), `мои серверы: ${await response.text()}`).toBeTruthy();
|
||||||
|
return ((await response.json()) as { guilds: { name: string }[] }).guilds.map(
|
||||||
|
(guild) => guild.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function friendIds(api: APIRequestContext): Promise<string[]> {
|
||||||
|
const response = await repeat429(() => api.get(`${API}/users/@me/relationships`));
|
||||||
|
if (!response.ok()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return ((await response.json()) as { friends: { user_id: string }[] }).friends.map(
|
||||||
|
(friend) => friend.user_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blockedUserIds(api: APIRequestContext): Promise<string[]> {
|
||||||
|
const response = await repeat429(() => api.get(`${API}/users/@me/relationships`));
|
||||||
|
if (!response.ok()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return ((await response.json()) as { blocked: { user_id: string }[] }).blocked.map(
|
||||||
|
(blocked) => blocked.user_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function channelMessages(api: APIRequestContext, channelId: string): Promise<unknown[]> {
|
||||||
|
const response = await repeat429(() => api.get(`${API}/channels/${channelId}/messages`));
|
||||||
|
if (!response.ok()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return ((await response.json()) as { messages: unknown[] }).messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** errorCode читает код ошибки из единого конверта API (AGENT.md 8.1). */
|
||||||
|
async function errorCode(response: APIResponse): Promise<string> {
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as { error?: { code?: string } };
|
||||||
|
return payload.error?.code ?? '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user