18 KiB
Capability Spec Delta: telegram-client (add-telegram-client)
Package: @suaveplan/telegram-client
Status: PROPOSED
Every requirement carries a
**Verified by:**line at authoring time.
ADDED 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_keyboardis correctly nested
Scenario: custom reply keyboard with contact / location / one-time
- WHEN the caller passes a
keyboardwithrequestContact,requestLocation, andoneTimeKeyboard: true - THEN the POST body's
reply_markup.keyboardcarriesrequest_contact: true,request_location: true, and the top-levelone_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
/editMessageTextand the body carrieschat_id: 100, message_id: 42, text: "Updated", parse_mode: "HTML"
Scenario: delete message
- WHEN
deleteMessage(100, 42)is invoked - THEN the URL ends in
/deleteMessageand the body carrieschat_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
/sendPhotoand the body carrieschat_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 carriesdocument: "...", andcaptionis absent
Scenario: every media type maps to its endpoint
- WHEN each of
video,audio,voice,animationis dispatched - THEN the URL ends in
/sendVideo,/sendAudio,/sendVoice,/sendAnimationrespectively
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
TelegramChatobject
Scenario: getMe returns parsed bot user
- WHEN
getMe()is invoked - THEN the resolved value is the parsed
TelegramUserwithisBot: 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
timeoutMsis set to 5 - THEN a
TelegramClientErrorwith codeTELEGRAM_CLIENT.TIMEOUTis 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
TelegramClientErrorwith codeTELEGRAM_CLIENT.API_ERRORis thrown carrying the description
Scenario: 401 becomes AUTH_ERROR
- WHEN the response is HTTP 401
- THEN a
TelegramClientErrorwith codeTELEGRAM_CLIENT.AUTH_ERRORis 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_LIMITEDandcontext.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.retryAfterMsis 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.retryAfterMsis non-negative
Scenario: 429 with x-ratelimit-reset epoch millis
- WHEN
x-ratelimit-resetvalue is ≥ 1e12 (epoch millis) - THEN
context.retryAfterMsis non-negative
Scenario: 429 with x-ratelimit-reset ISO date
- WHEN
x-ratelimit-resetis an ISO date string - THEN
context.retryAfterMsis 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_LIMITEDandcontext.retryAfterMsis undefined
Scenario: unparseable JSON body still throws API_ERROR
- WHEN the response status is 500 and body is invalid JSON
- THEN a
TelegramClientErrorwith codeTELEGRAM_CLIENT.API_ERRORis 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_ERRORis 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
TelegramClientErrorwith codeCONFIG_MISSINGis thrown
Scenario: undefined token rejected
- WHEN the client is constructed with
{}cast to the config type - THEN a
TelegramClientErrorwith codeCONFIG_MISSINGis thrown
Scenario: invalid baseUrl rejected
- WHEN
{ token: "x", baseUrl: "not-a-url" }is passed - THEN a
TelegramClientErrorwith codeCONFIG_MISSINGis thrown
Scenario: invalid timeoutMs rejected
- WHEN
{ token: "x", timeoutMs: -1 }is passed - THEN a
TelegramClientErrorwith codeCONFIG_MISSINGis 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
injectedFetchis invoked with the constructed URL and POST body, andglobalThis.fetchis 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.totalkeyed by{ method, outcome }whereoutcome ∈ { "success", "error", "timeout" } - histogram
telegram_client.request.duration_mskeyed by{ method, outcome }
Scenario: success increments counter and records histogram
- WHEN
getMe()succeeds against a real in-memoryMeterProvider - 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
sendMessagecall yields HTTP 401 - THEN the counter records one observation with
outcome: "error"
Scenario: timeout increments counter with outcome:timeout
- WHEN a
sendMessagecall 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.jsonis read - THEN the sorted set of
@suaveplan/*runtime deps equals["@suaveplan/error","@suaveplan/types"]
Scenario: full runtime dep set locked
- WHEN
package.jsonis read - THEN the sorted runtime dep set equals
["@opentelemetry/api","@suaveplan/error","@suaveplan/types","tslib","zod"]
Scenario: sideEffects: false
- WHEN
package.jsonis read - THEN
sideEffects === falseandversion === "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"