@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-telegram

npm · 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.ts JSDoc, 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 setWebhook once with your public URL and secret_token=CHANNEL_TELEGRAM_WEBHOOK_SECRET: https://api.telegram.org/bot<token>/setWebhook?url=<https-url>&secret_token=<secret>. Outbound sendMessage() works without this.

  • verifyWebhookSignature() compares the X-Telegram-Bot-Api-Secret-Token header against CHANNEL_TELEGRAM_WEBHOOK_SECRET and is FAIL-CLOSED: with no secret configured (or a secret never passed to setWebhook) 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_activity tool (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 undefined placeholders, no secrets).
  • A failed send (unbonded or misconfigured provider) is visible in logs/UI — never silently swallowed.