Files
2026-05-27 23:29:53 +01:00

18 KiB

Requirements

Requirement: Send text messages with full Telegram options

The package SHALL expose a TelegramBotClient.sendMessage(message) method that POSTs a sendMessage Bot API call to the configured endpoint, translating the camelCase TelegramMessage shape into Telegram's snake_case wire format, including parse_mode, disable_web_page_preview, disable_notification, reply_to_message_id, and reply_markup (inline keyboard, custom keyboard, remove keyboard, one-time keyboard, request-contact, request-location, callback-data, URL buttons).

Scenario: minimal text payload

  • WHEN the caller invokes sendMessage({ chatId: 100, text: "Hello" })
  • THEN the POST body contains { chat_id: 100, text: "Hello" } and the URL ends in /sendMessage

Scenario: full options including inline keyboard

  • WHEN the caller passes parseMode: "HTML", disableWebPagePreview, disableNotification, replyToMessageId, and an inline keyboard with URL and callback-data buttons
  • THEN the POST body contains the snake_case equivalents and reply_markup.inline_keyboard is correctly nested

Scenario: custom reply keyboard with contact / location / one-time

  • WHEN the caller passes a keyboard with requestContact, requestLocation, and oneTimeKeyboard: true
  • THEN the POST body's reply_markup.keyboard carries request_contact: true, request_location: true, and the top-level one_time_keyboard: true

Scenario: removeKeyboard

  • WHEN the caller passes replyMarkup: { removeKeyboard: true }
  • THEN the POST body contains reply_markup.remove_keyboard: true

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"sends a minimal text message" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"sends a message with parse mode and inline keyboard" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"sends a message with custom keyboard" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"sends a message with removeKeyboard" AND src/utils/build-bodies.test.ts::"buildSendMessageBody includes every supplied option" AND src/utils/build-bodies.test.ts::"buildReplyMarkup snake-cases every button field"


Requirement: Edit and delete messages

The package SHALL expose editMessage(chatId, messageId, text, parseMode?) and deleteMessage(chatId, messageId) methods that map to the Telegram Bot API editMessageText and deleteMessage endpoints with the correct snake_case body and URL suffix.

Scenario: edit message text with parse mode

  • WHEN editMessage(100, 42, "Updated", "HTML") is invoked
  • THEN the URL ends in /editMessageText and the body carries chat_id: 100, message_id: 42, text: "Updated", parse_mode: "HTML"

Scenario: delete message

  • WHEN deleteMessage(100, 42) is invoked
  • THEN the URL ends in /deleteMessage and the body carries chat_id: 100, message_id: 42

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"edits a message" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"deletes a message"


Requirement: Send media via URL

The package SHALL expose sendMedia(chatId, type, url, caption?) that dispatches to the correct Bot API endpoint (sendPhoto, sendDocument, sendVideo, sendAudio, sendVoice, sendAnimation) and includes the URL under the type-keyed body field, with an optional caption.

Scenario: send photo with caption

  • WHEN sendMedia(100, "photo", "https://example.com/i.jpg", "A photo") is invoked
  • THEN the URL ends in /sendPhoto and the body carries chat_id: 100, photo: "https://example.com/i.jpg", caption: "A photo"

Scenario: send media without caption

  • WHEN sendMedia(100, "document", "https://example.com/f.pdf") is invoked
  • THEN the URL ends in /sendDocument, the body carries document: "...", and caption is absent

Scenario: every media type maps to its endpoint

  • WHEN each of video, audio, voice, animation is dispatched
  • THEN the URL ends in /sendVideo, /sendAudio, /sendVoice, /sendAnimation respectively

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"sends media with caption" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"sends media without caption" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"maps every media type to its endpoint"


Requirement: Retrieve chat and bot info

The package SHALL expose getChat(chatId) and getMe() methods that call getChat and getMe Bot API endpoints and parse the response.

Scenario: get chat returns parsed chat

  • WHEN getChat(100) is invoked and the API returns { id: 100, type: "group", title: "T" }
  • THEN the resolved value is the parsed TelegramChat object

Scenario: getMe returns parsed bot user

  • WHEN getMe() is invoked
  • THEN the resolved value is the parsed TelegramUser with isBot: true

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"retrieves chat info" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"retrieves bot info"


Requirement: Request envelope with AbortController-based timeout

The package SHALL wrap every Bot API call in a POST with Content-Type: application/json, an AbortController whose timeout is configurable per client (default 10000 ms), and emit a TelegramClientError with code TIMEOUT on abort. The base URL defaults to https://api.telegram.org and may be overridden.

Scenario: timeout aborts and throws TIMEOUT

  • WHEN the fetch implementation never resolves and timeoutMs is set to 5
  • THEN a TelegramClientError with code TELEGRAM_CLIENT.TIMEOUT is thrown

Scenario: custom base URL is honoured

  • WHEN a client is constructed with baseUrl: "https://custom-api.example.com"
  • THEN every request URL starts with that base followed by /bot<token>/<method>

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws TIMEOUT when the request is aborted" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"uses a custom base URL"


Requirement: Typed error classification by HTTP status and Telegram description

The package SHALL classify non-OK Bot API responses into stable codes: AUTH_ERROR (401), BOT_BLOCKED (403 with "bot was blocked" / "user deactivated" description), FORBIDDEN (other 403, or 400 with "not enough rights" / "forbidden"), CHAT_NOT_FOUND (404 OR 400 with "chat not found" / "channel not found"), RATE_LIMITED (429 — parsed retry_after from body retry_after / parameters.retry_after, then headers retry-after / x-ratelimit-reset), and API_ERROR for any other non-OK body or status. Errors are thrown as TelegramClientError extending SuaveplanError.

Scenario: ok:false body becomes API_ERROR

  • WHEN the response is HTTP 200 with { ok: false, description: "Bad Request: chat not found", error_code: 400 }
  • THEN a TelegramClientError with code TELEGRAM_CLIENT.API_ERROR is thrown carrying the description

Scenario: 401 becomes AUTH_ERROR

  • WHEN the response is HTTP 401
  • THEN a TelegramClientError with code TELEGRAM_CLIENT.AUTH_ERROR is thrown

Scenario: 404 becomes CHAT_NOT_FOUND

  • WHEN the response is HTTP 404
  • THEN the thrown error has code TELEGRAM_CLIENT.CHAT_NOT_FOUND

Scenario: 400 "chat not found" description becomes CHAT_NOT_FOUND

  • WHEN the response is HTTP 400 with description "Bad Request: chat not found"
  • THEN the thrown error has code TELEGRAM_CLIENT.CHAT_NOT_FOUND

Scenario: 403 "bot blocked" becomes BOT_BLOCKED

  • WHEN the response is HTTP 403 with description "Forbidden: bot was blocked by the user"
  • THEN the thrown error has code TELEGRAM_CLIENT.BOT_BLOCKED

Scenario: 403 other becomes FORBIDDEN

  • WHEN the response is HTTP 403 with description "Forbidden: not enough rights"
  • THEN the thrown error has code TELEGRAM_CLIENT.FORBIDDEN

Scenario: 400 "not enough rights" becomes FORBIDDEN

  • WHEN the response is HTTP 400 with description "Bad Request: not enough rights to send messages"
  • THEN the thrown error has code TELEGRAM_CLIENT.FORBIDDEN

Scenario: 429 with body retry_after becomes RATE_LIMITED with retryAfterMs

  • WHEN the response is HTTP 429 with { ok: false, parameters: { retry_after: 30 } }
  • THEN the thrown error has code TELEGRAM_CLIENT.RATE_LIMITED and context.retryAfterMs === 30000

Scenario: 429 with top-level retry_after

  • WHEN the response body has top-level retry_after: 12
  • THEN context.retryAfterMs === 12000

Scenario: 429 with retry-after header seconds

  • WHEN the body lacks retry_after and the response carries header retry-after: 7
  • THEN context.retryAfterMs === 7000

Scenario: 429 with retry-after HTTP-date header

  • WHEN the header value is a valid HTTP date
  • THEN context.retryAfterMs is non-negative and finite

Scenario: 429 with x-ratelimit-reset epoch seconds

  • WHEN the body lacks retry_after and the response carries header x-ratelimit-reset: <future epoch seconds>
  • THEN context.retryAfterMs is non-negative

Scenario: 429 with x-ratelimit-reset epoch millis

  • WHEN x-ratelimit-reset value is ≥ 1e12 (epoch millis)
  • THEN context.retryAfterMs is non-negative

Scenario: 429 with x-ratelimit-reset ISO date

  • WHEN x-ratelimit-reset is an ISO date string
  • THEN context.retryAfterMs is non-negative

Scenario: 429 with no parseable retry hint

  • WHEN the body and headers carry nothing parseable
  • THEN the error still throws with code RATE_LIMITED and context.retryAfterMs is undefined

Scenario: unparseable JSON body still throws API_ERROR

  • WHEN the response status is 500 and body is invalid JSON
  • THEN a TelegramClientError with code TELEGRAM_CLIENT.API_ERROR is thrown

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws API_ERROR for ok:false body" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws AUTH_ERROR for HTTP 401" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws CHAT_NOT_FOUND for HTTP 404" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws CHAT_NOT_FOUND for HTTP 400 with chat not found description" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws BOT_BLOCKED for HTTP 403 with blocked description" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws FORBIDDEN for HTTP 403 generic" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws FORBIDDEN for HTTP 400 with not enough rights" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws RATE_LIMITED with retryAfterMs from parameters.retry_after" AND src/utils/classify-error.test.ts::"parseRetryAfterMs prefers top-level body retry_after" AND src/utils/classify-error.test.ts::"parseRetryAfterMs honours retry-after seconds header" AND src/utils/classify-error.test.ts::"parseRetryAfterMs honours retry-after HTTP-date header" AND src/utils/classify-error.test.ts::"parseRetryAfterMs honours x-ratelimit-reset epoch seconds" AND src/utils/classify-error.test.ts::"parseRetryAfterMs honours x-ratelimit-reset epoch millis" AND src/utils/classify-error.test.ts::"parseRetryAfterMs honours x-ratelimit-reset ISO date" AND src/utils/classify-error.test.ts::"parseRetryAfterMs returns undefined when no hint is parseable" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws API_ERROR on unparseable JSON body"


Requirement: Stable error code registry with metadata

The package SHALL expose TELEGRAM_CLIENT_CODES as the literal-typed return of defineCodes("TELEGRAM_CLIENT", […]) covering the 8 failure modes (API_ERROR, AUTH_ERROR, RATE_LIMITED, CHAT_NOT_FOUND, BOT_BLOCKED, CONFIG_MISSING, TIMEOUT, FORBIDDEN) and TelegramClientError extending SuaveplanError<TelegramClientCode>. Every code MUST be thrown by at least one production code path.

Scenario: every code constant is a literal string of the namespaced form

  • WHEN TELEGRAM_CLIENT_CODES.API_ERROR is read
  • THEN it equals "TELEGRAM_CLIENT.API_ERROR"

Scenario: error class carries the code and extends SuaveplanError

  • WHEN an error is constructed with TELEGRAM_CLIENT_CODES.CONFIG_MISSING
  • THEN the instance is instanceof SuaveplanError, instanceof TelegramClientError, and exposes .code === "TELEGRAM_CLIENT.CONFIG_MISSING"

Scenario: every declared code is reachable from a production path

  • WHEN the suite covers config-missing, timeout, and every classifier branch
  • THEN each of the 8 codes is thrown by ≥ 1 production call site verified by a passing test

Verified by: src/errors/telegram-client-error.test.ts::"code constants are namespaced literals" AND src/errors/telegram-client-error.test.ts::"TelegramClientError extends SuaveplanError and exposes the code" AND src/errors/telegram-client-error.test.ts::"every declared code is thrown by a production path"


Requirement: Config validation with Zod schema

The package SHALL validate TelegramBotConfig at construction using a Zod schema. A missing or empty token MUST throw a TelegramClientError with code CONFIG_MISSING. baseUrl MUST be a valid URL if supplied. timeoutMs MUST be a positive integer if supplied.

Scenario: missing token rejected

  • WHEN the client is constructed with { token: "" }
  • THEN a TelegramClientError with code CONFIG_MISSING is thrown

Scenario: undefined token rejected

  • WHEN the client is constructed with {} cast to the config type
  • THEN a TelegramClientError with code CONFIG_MISSING is thrown

Scenario: invalid baseUrl rejected

  • WHEN { token: "x", baseUrl: "not-a-url" } is passed
  • THEN a TelegramClientError with code CONFIG_MISSING is thrown

Scenario: invalid timeoutMs rejected

  • WHEN { token: "x", timeoutMs: -1 } is passed
  • THEN a TelegramClientError with code CONFIG_MISSING is thrown

Scenario: valid config accepted

  • WHEN { token: "valid", baseUrl: "https://api.telegram.org", timeoutMs: 1000 } is passed
  • THEN construction succeeds and the public schema accepts the value

Verified by: src/types/telegram-bot-config.test.ts::"rejects empty token" AND src/types/telegram-bot-config.test.ts::"rejects missing token" AND src/types/telegram-bot-config.test.ts::"rejects invalid baseUrl" AND src/types/telegram-bot-config.test.ts::"rejects non-positive timeoutMs" AND src/types/telegram-bot-config.test.ts::"accepts a valid config" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"throws CONFIG_MISSING when constructed with empty token"


Requirement: Injectable fetch and clock (no mocks)

The package SHALL accept an injectable fetch and clock via the factory options bundle. Tests MUST use these injection points to drive deterministic behaviour — no vi.mock, no globalThis.fetch spy, no Date patch.

Scenario: injected fetch is used

  • WHEN the factory is invoked with { fetch: injectedFetch } and the client makes any Bot API call
  • THEN injectedFetch is invoked with the constructed URL and POST body, and globalThis.fetch is not touched

Scenario: injected clock drives OTel timestamps and timeout math

  • WHEN a fake monotonic clock is injected
  • THEN OTel histogram observations and timeout calculations use the injected clock, not Date.now

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"uses the injected fetch for every call" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"uses the injected clock for duration measurement"


Requirement: OTel request counter and duration histogram

The package SHALL emit two OTel instruments under the meter name @suaveplan/telegram-client:

  • counter telegram_client.request.total keyed by { method, outcome } where outcome ∈ { "success", "error", "timeout" }
  • histogram telegram_client.request.duration_ms keyed by { method, outcome }

Scenario: success increments counter and records histogram

  • WHEN getMe() succeeds against a real in-memory MeterProvider
  • THEN the counter records one observation with method: "getMe", outcome: "success" and the histogram records one duration

Scenario: error increments counter with outcome:error

  • WHEN a sendMessage call yields HTTP 401
  • THEN the counter records one observation with outcome: "error"

Scenario: timeout increments counter with outcome:timeout

  • WHEN a sendMessage call aborts
  • THEN the counter records one observation with outcome: "timeout"

Verified by: src/core/telegram-bot-client/telegram-bot-client.test.ts::"emits OTel success metrics against a real MeterProvider" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"emits OTel error outcome on classified failure" AND src/core/telegram-bot-client/telegram-bot-client.test.ts::"emits OTel timeout outcome on abort"


Requirement: Dep-graph and surface invariants

The package SHALL declare exactly these @suaveplan/* runtime deps: @suaveplan/error, @suaveplan/types. Externals: tslib, zod, @opentelemetry/api, @opentelemetry/sdk-metrics (dev). It SHALL ship "sideEffects": false and version 0.1.0.

Scenario: runtime @suaveplan deps locked

  • WHEN package.json is read
  • THEN the sorted set of @suaveplan/* runtime deps equals ["@suaveplan/error","@suaveplan/types"]

Scenario: full runtime dep set locked

  • WHEN package.json is read
  • THEN the sorted runtime dep set equals ["@opentelemetry/api","@suaveplan/error","@suaveplan/types","tslib","zod"]

Scenario: sideEffects: false

  • WHEN package.json is read
  • THEN sideEffects === false and version === "0.1.0"

Verified by: tests/dep-graph.test.ts::"@suaveplan/* runtime deps are exactly error + types" AND tests/dep-graph.test.ts::"runtime deps are exactly the expected set (sorted)" AND tests/dep-graph.test.ts::"sideEffects is false and version is 0.1.0"