@molecule/api-channel-telegram
Provider bond · channel · API (Node) · v1.0.2 · Apache-2.0
Telegram channel bond — implements @molecule/api-channel for sendMessage, sendPhoto/sendDocument, secret-token webhook verification, and inbound update parsing (message/callback_query/inline_query).
npm install @molecule/api-channel-telegramnpm · Source on GitHub · Implements @molecule/api-channel
How it works
@molecule/api-channel-telegram is a provider bond on the API (Node) side: it implements the channel core interface (@molecule/api-channel) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/api-channel'
import { provider } from '@molecule/api-channel-telegram'
setProvider('telegram', provider)Works with: @molecule/api-secrets
Secrets: CHANNEL_TELEGRAM_BOT_TOKEN, CHANNEL_TELEGRAM_WEBHOOK_SECRET (optional)
Reference
Telegram channel provider for molecule.dev.
Implements the framework-agnostic ChannelProvider interface
over the Telegram Bot API. Bond under the named-multi-provider
'channel' category at app startup:
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/api-channel'
import { provider } from '@molecule/api-channel-telegram'
setProvider('telegram', provider)
Type
provider
Installation
npm install @molecule/api-channel-telegram @molecule/api-channel @molecule/api-secrets
API
Interfaces
TelegramConfig
Configuration for the Telegram channel provider.
The bot token is the credential that authorizes Bot API calls — it is
deliberately accepted only via this config (or the
CHANNEL_TELEGRAM_BOT_TOKEN env var) and is NEVER included in error
messages, log lines, or normalized payloads.
interface TelegramConfig {
/**
* Telegram bot token (`<bot_id>:<auth_string>`). Defaults to the
* `CHANNEL_TELEGRAM_BOT_TOKEN` env var.
*
* Treat as a secret — providers redact this value in any user-facing
* output.
*/
botToken?: string
/**
* Shared secret value Telegram echoes back via the
* `X-Telegram-Bot-Api-Secret-Token` header on every webhook request.
*
* Must match the `secret_token` registered via
* `setWebhook?secret_token=...`. Defaults to the
* `CHANNEL_TELEGRAM_WEBHOOK_SECRET` env var.
*
* If unset, `TelegramChannelProvider.verifyWebhookSignature`
* returns `false`.
*/
webhookSecret?: string
/**
* Default `parse_mode` for outbound messages — `'HTML'` or
* `'MarkdownV2'`. Defaults to `'HTML'`.
*/
defaultParseMode?: TelegramParseMode
/**
* Bot API base URL. Override only for tests / self-hosted Bot API
* servers. Defaults to `https://api.telegram.org`.
*/
apiBaseUrl?: string
/**
* Per-request timeout in milliseconds. Defaults to 10000.
*/
timeoutMs?: number
}
ProcessEnv
Environment variables consumed by the Telegram channel provider.
interface ProcessEnv {
/** Bot API token (`<bot_id>:<auth_string>`). */
CHANNEL_TELEGRAM_BOT_TOKEN: string
/** Shared `secret_token` value Telegram echoes back on webhook calls. */
CHANNEL_TELEGRAM_WEBHOOK_SECRET: string
}
TelegramUser
Subset of the Telegram User object used when normalizing inbound
payloads. Only fields the provider actually inspects are typed.
interface TelegramUser {
/** Telegram user identifier. */
id: number
/** Whether the user is a bot. */
is_bot?: boolean
/** First name (displayed in clients). */
first_name?: string
/** Optional last name. */
last_name?: string
/** Optional `@username`. */
username?: string
}
TelegramChat
Subset of the Telegram Chat object used for inbound normalization.
interface TelegramChat {
/** Chat identifier (positive for users, negative for groups). */
id: number
/** Chat kind (`'private'`, `'group'`, `'supergroup'`, `'channel'`). */
type?: string
/** Title for group / channel chats. */
title?: string
/** Username for public chats. */
username?: string
}
TelegramPhotoSize
Subset of the Telegram PhotoSize object.
interface TelegramPhotoSize {
/** Identifier used to download the file via `getFile`. */
file_id: string
/** Stable identifier across bots for the same file. */
file_unique_id?: string
/** Photo width in pixels. */
width?: number
/** Photo height in pixels. */
height?: number
/** File size in bytes, if known. */
file_size?: number
}
TelegramDocument
Subset of the Telegram Document object.
interface TelegramDocument {
/** Identifier used to download the file via `getFile`. */
file_id: string
/** Stable identifier across bots for the same file. */
file_unique_id?: string
/** Original filename, if available. */
file_name?: string
/** Reported MIME type. */
mime_type?: string
/** File size in bytes, if known. */
file_size?: number
}
TelegramMessage
Subset of the Telegram Message object inspected during inbound
normalization.
interface TelegramMessage {
/** Sequential message identifier within the chat. */
message_id: number
/** Unix timestamp (seconds) the message was sent. */
date?: number
/** Author of the message. */
from?: TelegramUser
/** Chat the message belongs to. */
chat: TelegramChat
/** Plain-text body, if any. */
text?: string
/** Caption on a media message. */
caption?: string
/** Photo size variants attached to the message. */
photo?: TelegramPhotoSize[]
/** Document attached to the message. */
document?: TelegramDocument
/** Forum topic identifier (when posted in a forum supergroup). */
message_thread_id?: number
/** Whether the message was sent inside a forum topic. */
is_topic_message?: boolean
}
TelegramCallbackQuery
Subset of the Telegram CallbackQuery object used to surface button
clicks via InboundMessage.payload.
interface TelegramCallbackQuery {
/** Identifier of this callback query. */
id: string
/** User who triggered the callback. */
from: TelegramUser
/** Message the inline keyboard was attached to. */
message?: TelegramMessage
/** Opaque payload originally set on the inline keyboard button. */
data?: string
}
TelegramInlineQuery
Subset of the Telegram InlineQuery object surfaced via
InboundMessage.payload.
interface TelegramInlineQuery {
/** Identifier of this inline query. */
id: string
/** User issuing the query. */
from: TelegramUser
/** Free-text query body. */
query: string
}
TelegramUpdate
Subset of the Telegram Update object delivered to the webhook
endpoint. Only the variants the provider parses are typed; additional
fields are passed through opaquely on
InboundMessage.payload.
interface TelegramUpdate {
/** Incremental update identifier. */
update_id: number
/** Standard chat message. */
message?: TelegramMessage
/** Edited standard chat message. */
edited_message?: TelegramMessage
/** Channel post. */
channel_post?: TelegramMessage
/** Edited channel post. */
edited_channel_post?: TelegramMessage
/** Inline keyboard button click. */
callback_query?: TelegramCallbackQuery
/** Inline-mode query. */
inline_query?: TelegramInlineQuery
}
Types
TelegramParseMode
Telegram message formatting flavours supported by the Bot API. Plain text is sent when no parse mode is set.
type TelegramParseMode = 'HTML' | 'MarkdownV2'
Classes
TelegramChannelProvider
Telegram channel provider — implements the framework-agnostic
ChannelProvider contract on top of the Bot API.
class TelegramChannelProvider implements ChannelProvider {
/**
* Stable channel name. Conventionally matches the bond name this
* provider is registered under (`bond('channel', 'telegram', ...)`).
*/
readonly name = 'telegram'
private readonly botToken: string | undefined
private readonly webhookSecret: string | undefined
private readonly defaultParseMode: TelegramParseMode
private readonly apiBaseUrl: string
private readonly timeoutMs: number
/**
* Constructs a Telegram channel provider.
*
* @param config - Optional configuration. Bot token and webhook secret
* fall back to `CHANNEL_TELEGRAM_BOT_TOKEN` and
* `CHANNEL_TELEGRAM_WEBHOOK_SECRET` environment variables when
* omitted.
*/
constructor(config: TelegramConfig = {}) {
this.botToken = config.botToken ?? process.env.CHANNEL_TELEGRAM_BOT_TOKEN
this.webhookSecret = config.webhookSecret ?? process.env.CHANNEL_TELEGRAM_WEBHOOK_SECRET
this.defaultParseMode = config.defaultParseMode ?? 'HTML'
this.apiBaseUrl = (config.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, '')
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
}
/**
* Sends an outbound message via the Bot API.
*
* Routes to `sendMessage`, `sendPhoto`, or `sendDocument` based on the
* normalized payload. When buttons are present an `inline_keyboard`
* `reply_markup` is attached. Forum-topic threading is honored via
* `message_thread_id`.
*
* @param to - Telegram chat id (numeric `chat.id`, or `@username` for
* public chats), passed straight through to the Bot API.
* @param message - Normalized `OutboundMessage`.
* @returns A `SendResult` keyed by Telegram's `message_id`.
* @throws {Error} If the bot token is unset or the Bot API rejects
* the call. The bot token is never included in error messages.
*/
async sendMessage(to: ChannelRecipient, message: OutboundMessage): Promise<SendResult> {
if (!this.botToken) {
throw new Error('Telegram bot token is not configured.')
}
const method = pickBotMethod(message)
const body = buildRequestBody(to, message, this.defaultParseMode, method)
const result = await this.callBotApi<TelegramMessage>(method, body)
const deliveredAt = result.date ? new Date(result.date * 1000) : new Date()
return {
messageId: String(result.message_id),
deliveredAt,
}
}
/**
* Verifies the inbound webhook signature.
*
* Telegram does not HMAC-sign webhooks; instead, when registering the
* webhook with `setWebhook?secret_token=...`, every incoming request
* carries an `X-Telegram-Bot-Api-Secret-Token` header equal to that
* value. We compare it constant-time against the configured secret.
*
* @param headers - Request headers (case-insensitive lookup).
* @param _body - Raw request body. Unused — included for interface
* parity with HMAC-signed providers.
* @returns `true` when the configured secret matches the header value.
*/
verifyWebhookSignature(headers: Record<string, string>, _body: string | Uint8Array): boolean {
if (!this.webhookSecret) {
return false
}
const provided = lookupHeader(headers, SECRET_TOKEN_HEADER)
if (!provided) {
return false
}
return constantTimeEqual(provided, this.webhookSecret)
}
/**
* Normalizes a raw Telegram `Update` into an `InboundMessage`.
*
* Handles `message`, `edited_message`, `channel_post`,
* `edited_channel_post`, `callback_query`, and `inline_query`.
* Anything not recognized throws — callers MUST verify the signature
* before reaching this method.
*
* @param payload - Raw Telegram `Update` object (as parsed JSON).
* @returns Canonical `InboundMessage`. The original update is
* preserved on `payload` for forensic / channel-specific handling.
* @throws {Error} If the payload does not match a recognized Telegram
* update variant.
*/
parseInbound(payload: unknown): InboundMessage {
if (!isPlainObject(payload)) {
throw new Error('Telegram inbound payload is not an object.')
}
const update = payload as unknown as TelegramUpdate
const msg =
update.message ?? update.edited_message ?? update.channel_post ?? update.edited_channel_post
if (msg) {
return inboundFromMessage(msg, update)
}
if (update.callback_query) {
const cb = update.callback_query
const inner = cb.message
const receivedAt = new Date()
const result: InboundMessage = {
from: String(cb.from.id),
channel: 'telegram',
text: cb.data,
payload: update,
receivedAt,
}
if (inner?.message_thread_id !== undefined) {
result.thread_id = String(inner.message_thread_id)
}
return result
}
if (update.inline_query) {
const iq = update.inline_query
return {
from: String(iq.from.id),
channel: 'telegram',
text: iq.query,
payload: update,
receivedAt: new Date(),
}
}
throw new Error('Telegram inbound payload did not match a supported update variant.')
}
/**
* Returns the static capability matrix for the Telegram Bot API.
*
* Telegram supports rich text (HTML / MarkdownV2), inline-keyboard
* buttons, photo and document attachments, and forum-topic threading
* via `message_thread_id`. Reactions are user-only on the Bot API
* (bots can't post reactions to arbitrary messages) and webhook
* authenticity is via the secret-token header rather than a
* cryptographic signature, so `signedWebhooks` is conservatively
* `false`.
*
* @returns The `ChannelFeatures` flags for Telegram.
*/
listSupportedFeatures(): ChannelFeatures {
return {
text: true,
buttons: true,
attachments: true,
threads: true,
signedWebhooks: false,
}
}
/**
* Performs a single Bot API method call. Centralizes redaction so the
* bot token never ends up in thrown error messages.
*
* @param method - Bot API method name (e.g. `'sendMessage'`).
* @param body - JSON-serializable request payload.
* @returns The `result` field of a `{ ok: true }` Bot API envelope.
* @throws {Error} On non-2xx responses, `{ ok: false }` envelopes, or
* network failures. Errors quote the redacted method URL only.
*/
private async callBotApi<T>(method: string, body: unknown): Promise<T> {
const url = `${this.apiBaseUrl}/bot${this.botToken}/${method}`
const safeUrl = `${this.apiBaseUrl}/bot<redacted>/${method}`
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
let response: Response
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Telegram Bot API request failed (${safeUrl}): ${message}`, { cause: error })
} finally {
clearTimeout(timer)
}
let parsed: { ok: boolean; result?: T; description?: string }
try {
parsed = (await response.json()) as { ok: boolean; result?: T; description?: string }
} catch (error) {
throw new Error(
`Telegram Bot API returned non-JSON response (${safeUrl}, HTTP ${response.status}).`,
{ cause: error },
)
}
if (!response.ok || !parsed.ok || parsed.result === undefined) {
const description = parsed.description ?? `HTTP ${response.status}`
throw new Error(`Telegram Bot API call ${method} failed: ${description}`)
}
return parsed.result
}
}
Functions
createProvider(config)
Convenience factory for the named-multi-provider bond pattern.
function createProvider(config?: TelegramConfig): TelegramChannelProvider
config— Optional Telegram config.
Returns: A new TelegramChannelProvider instance.
Constants
provider
Lazily-instantiated singleton instance for app-startup wiring. Reads configuration from environment variables on first use.
const provider: ChannelProvider
channelTelegramSecretDefinitions
Secret definitions required by the Telegram channel bond.
const channelTelegramSecretDefinitions: SecretDefinition[]
Core Interface
Implements @molecule/api-channel interface.
Injection Notes
Requirements
Peer dependencies:
@molecule/api-channel^1.0.1@molecule/api-secrets^1.0.1
Environment Variables
CHANNEL_TELEGRAM_BOT_TOKEN(required) — Telegram bot token- Setup: Create a bot with @BotFather in Telegram (/newbot) and copy the token.
- Get it here: https://t.me/BotFather
- Example:
123456789:ABC-DEF...
CHANNEL_TELEGRAM_WEBHOOK_SECRET(optional) — Telegram webhook secret- Auto-generated at scaffold — no manual setup.
Runtime Dependencies
-
@molecule/api-channel -
@molecule/api-secrets -
Inbound updates require a one-time webhook registration the bond does not perform. Call Telegram's
setWebhookonce with your public URL andsecret_token=CHANNEL_TELEGRAM_WEBHOOK_SECRET:https://api.telegram.org/bot<token>/setWebhook?url=<https-url>&secret_token=<secret>. OutboundsendMessage()works without this. -
verifyWebhookSignature()compares theX-Telegram-Bot-Api-Secret-Tokenheader againstCHANNEL_TELEGRAM_WEBHOOK_SECRETand is FAIL-CLOSED: with no secret configured (or a secret never passed tosetWebhook) every inbound update is rejected. Treat the secret as required whenever the app consumes inbound Telegram messages.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Each channel-notifying flow the app defines (a Slack/Discord alert on
a new order, a status-change message) actually produces a message. The
sandbox CAPTURES channel messages instead of sending — read them with the
read_activitytool (filter type 'channel'); never mock the flow or modify production code to expose the message. - The captured message targets the configured channel/provider name and
carries the app's real content (readable text, no
undefinedplaceholders, no secrets). - A failed send (unbonded or misconfigured provider) is visible in logs/UI — never silently swallowed.