diff --git a/docs/02-listen-and-respond.md b/docs/02-listen-and-respond.md index 4b68f69..020013e 100644 --- a/docs/02-listen-and-respond.md +++ b/docs/02-listen-and-respond.md @@ -70,6 +70,24 @@ console.log(message.body.mid); > }); > ``` +### Отмена отправки +Методы `sendMessageToChat` и `sendMessageToUser` поддерживают `AbortSignal` для отмены запроса. Это полезно, когда сервер долго обрабатывает вложение (`attachment.not.ready`) и вы хотите прервать ожидание: +```typescript +const controller = new AbortController(); + +// Отменить через 30 секунд +setTimeout(() => controller.abort(), 30_000); + +await bot.api.sendMessageToChat(54321, 'Текст', { + attachments: [image.toJson()], + signal: controller.signal, +}); +``` + +> ℹ️ При получении ошибки `attachment.not.ready` SDK автоматически повторяет +> запрос до 10 раз с экспоненциальной задержкой (1 с, 2 с, 4 с, ...). +> Если передан `signal`, повторы прекращаются при его отмене. + Или воспользоваться методом контекста `reply`: ```typescript bot.hears('ping', async (ctx) => { diff --git a/src/api.ts b/src/api.ts index 0f3f825..be303b6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -71,11 +71,12 @@ export class Api { text: string, extra?: SendMessageExtra, ) => { + const { signal, ...rest } = extra ?? {}; const { message } = await this.raw.messages.send({ chat_id: chatId, text, - ...extra, - }); + ...rest, + }, { signal }); return message; }; @@ -84,11 +85,12 @@ export class Api { text: string, extra?: SendMessageExtra, ) => { + const { signal, ...rest } = extra ?? {}; const { message } = await this.raw.messages.send({ user_id: userId, text, - ...extra, - }); + ...rest, + }, { signal }); return message; }; diff --git a/src/core/network/api/base-api.ts b/src/core/network/api/base-api.ts index 1b7f951..fc4d01f 100644 --- a/src/core/network/api/base-api.ts +++ b/src/core/network/api/base-api.ts @@ -6,7 +6,7 @@ import type { ApiMethods } from './modules/types'; type ApiCallFn = ( method: Method, // @ts-ignore - options: ApiMethods[HTTPMethod][Method]['req'] + options: ApiMethods[HTTPMethod][Method]['req'] & { signal?: AbortSignal } // @ts-ignore ) => Promise; diff --git a/src/core/network/api/client.ts b/src/core/network/api/client.ts index 1aea457..f333dab 100644 --- a/src/core/network/api/client.ts +++ b/src/core/network/api/client.ts @@ -14,7 +14,8 @@ export type ReqOptions = { method?: HTTPMethod; body?: object | null, query?: Record, - path?: Record + path?: Record, + signal?: AbortSignal, }; type CallOptions = { @@ -49,6 +50,9 @@ export const createClient = (token: string, options: ClientOptions = {}) => { const init: RequestInit = { ...getResponseInit(callOptions?.body), method: httpMethod }; init.headers = { ...init.headers, Authorization: token }; + if (callOptions.signal) { + init.signal = callOptions.signal; + } const res = await fetch(url.href, init); diff --git a/src/core/network/api/modules/messages/api.ts b/src/core/network/api/modules/messages/api.ts index e47d102..96b0c62 100644 --- a/src/core/network/api/modules/messages/api.ts +++ b/src/core/network/api/modules/messages/api.ts @@ -1,4 +1,5 @@ import { setTimeout } from 'node:timers/promises'; +import createDebug from 'debug'; import { MaxError } from '../../error'; import { BaseApi } from '../../base-api'; import type { @@ -11,6 +12,14 @@ import type { } from '../types'; import type { SendMessageDTO, DeleteMessageDTO } from './types'; +const debug = createDebug('one-me:messages'); + +/** Максимальное количество повторов при ошибке attachment.not.ready */ +const ATTACHMENT_NOT_READY_MAX_RETRIES = 10; + +/** Начальная задержка между повторами (мс). Удваивается после каждой попытки. */ +const ATTACHMENT_NOT_READY_BASE_DELAY = 1_000; + export class MessagesApi extends BaseApi { get = async ({ ...query }: FlattenReq): Promise => { return this._get('messages', { @@ -24,26 +33,53 @@ export class MessagesApi extends BaseApi { }); }; - send = async ({ - chat_id, user_id, disable_link_preview, ...body - }: FlattenReq): Promise => { - try { - return await this._post('messages', { - body, - query: { chat_id, user_id, disable_link_preview }, - }); - } catch (err) { - if (err instanceof MaxError) { - if (err.code === 'attachment.not.ready') { - console.log('Attachment not ready'); - await setTimeout(1000); - return this.send({ - chat_id, user_id, disable_link_preview, ...body, - }); + send = async ( + { + chat_id, user_id, disable_link_preview, ...body + }: FlattenReq, + options?: { signal?: AbortSignal }, + ): Promise => { + const signal = options?.signal; + let lastError: MaxError | undefined; + + for ( + let attempt = 0; + attempt < ATTACHMENT_NOT_READY_MAX_RETRIES; + attempt += 1 + ) { + signal?.throwIfAborted(); + + try { + return await this._post('messages', { + body, + query: { chat_id, user_id, disable_link_preview }, + signal, + }); + } catch (err) { + if ( + !(err instanceof MaxError) + || err.code !== 'attachment.not.ready' + ) { + throw err; } + lastError = err; + const delay = ATTACHMENT_NOT_READY_BASE_DELAY * (2 ** attempt); + debug( + 'Attachment not ready (attempt %d/%d), retrying in %dms', + attempt + 1, + ATTACHMENT_NOT_READY_MAX_RETRIES, + delay, + ); + await setTimeout(delay, undefined, { signal }); } - throw err; } + + throw lastError ?? new MaxError(500, { + code: 'attachment.not.ready', + message: `Attachment not ready after ${ + ATTACHMENT_NOT_READY_MAX_RETRIES + } retries`, + }); }; edit = async ({ message_id, ...body }) => { diff --git a/src/core/network/api/modules/messages/types.ts b/src/core/network/api/modules/messages/types.ts index 6308cc3..17ab9f3 100644 --- a/src/core/network/api/modules/messages/types.ts +++ b/src/core/network/api/modules/messages/types.ts @@ -44,7 +44,9 @@ export type SendMessageDTO = { } }; -export type SendMessageExtra = Omit, 'chat_id' | 'user_id' | 'text'>; +export type SendMessageExtra = Omit, 'chat_id' | 'user_id' | 'text'> & { + signal?: AbortSignal; +}; export type SendMessageResponse = { message: Message