## 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/` **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: ` - **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`. 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"`