Last reviewed: November 3, 2026 (Asia/Shanghai).

Direct answer

Build a private CometAPI Telegram bot by registering a grammY /ask command, accepting it only from one configured user in a private chat, and passing its argument to a server-side Chat Completions request. Use long polling for the first integration, await the generated answer, and send one plain-text Telegram message. Keep model generation and message delivery as separate outcomes so a failed delivery does not trigger another generation.

The grammY commands guide documents bot.command() and ctx.match, which contains the command argument. The CometAPI chat reference supplies the authenticated POST endpoint, model and messages fields, and completion response shape. The code below joins those contracts with an explicit access rule and local resource limits.

This is a single-turn bot: /ask Explain idempotency in one sentence sends that argument to the configured model. It does not retain chat history. The example was checked with Node.js 24.22.1 and grammY 1.46.0 using synthetic updates and responses. No authenticated CometAPI call or Telegram message was sent during preparation.

Who this is for

Use this tutorial for a personal or small internal bot running as one persistent Node.js process. You need a dedicated Telegram bot created through BotFather, its token, your own numeric Telegram user ID from a trusted account record, a CometAPI key, and a currently available Chat Completions model ID.

Inject four server-side settings: TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USER_ID, COMETAPI_KEY, and COMETAPI_MODEL. The allowed user value is a numeric ID represented as a decimal string, not a username. Keep credentials in protected runtime configuration; the site’s repository key-handling guide explains that boundary.

The allowlist restricts who can invoke this handler. It does not make Telegram a secret-storage system or prevent an authorized user from submitting sensitive material. Tell users that command arguments go to the configured model service, and choose prompts appropriate for that data path.

Key takeaways

  • Check private-chat type and sender ID before calling CometAPI.
  • Read the command argument through ctx.match; do not send the /ask prefix as the prompt.
  • Use one bounded, non-streaming model request for each accepted invocation.
  • Require an ordinary completed text answer before delivering it.
  • Omit Telegram formatting modes and disable link previews for generated text.
  • Separate generation failure, confirmed delivery, and unconfirmed delivery in logs.
  • Test synthetic updates first, then perform a separate controlled account-backed check.

Create the bounded CometAPI adapter

Create a small project and install the checked grammY release:

mkdir telegram-comet-demo
cd telegram-comet-demo
npm init -y
npm install --save-exact [email protected]

Retain the manifest and lockfile with your application. Save the following as comet-text.mjs. It sends the configured model one user message with stream: false. The 30-second HTTP allowance and one-mebibyte accepted body ceiling are application choices, not CometAPI limits.

export class ChatFault extends Error {
  constructor(code) {
    super(code);
    this.code = code;
  }
}

export function makeComplete({ key, model, request = fetch, timeoutMs = 30000 }) {
  if (!key?.trim() || !model?.trim()) {
    throw new ChatFault('configuration');
  }
  return async function complete(prompt) {
    const signal = AbortSignal.timeout(timeoutMs);
    let response;
    let reader;
    try {
      response = await request('https://api.cometapi.com/v1/chat/completions', {
        method: 'POST',
        redirect: 'error',
        signal,
        headers: {
          authorization: `Bearer ${key}`,
          'content-type': 'application/json'
        },
        body: JSON.stringify({
          model, stream: false,
          messages: [{ role: 'user', content: prompt }]
        })
      });
      if (response.status !== 200) throw new ChatFault('upstream_http');
      const type = (response.headers.get('content-type') ?? '')
        .split(';')[0].trim().toLowerCase();
      if (type !== 'application/json' || !response.body) {
        throw new ChatFault('response_type');
      }
      reader = response.body.getReader();
      const parts = [];
      let size = 0;
      for (;;) {
        signal.throwIfAborted();
        const { done, value } = await reader.read();
        if (done) break;
        if (value.byteLength > 1048576 - size) {
          throw new ChatFault('response_size');
        }
        size += value.byteLength;
        parts.push(Buffer.from(value));
      }
      let data;
      try {
        const text = new TextDecoder('utf-8', { fatal: true })
          .decode(Buffer.concat(parts, size));
        data = JSON.parse(text);
      } catch {
        throw new ChatFault('response_json');
      }
      if (data?.error != null || data?.object !== 'chat.completion' ||
          !Array.isArray(data.choices) || data.choices.length !== 1) {
        throw new ChatFault('completion');
      }
      const choice = data.choices[0];
      const message = choice?.message;
      if (choice?.index !== 0 || choice.finish_reason !== 'stop' ||
          message?.role !== 'assistant' ||
          typeof message.content !== 'string' || !message.content.trim() ||
          message.refusal != null || message.tool_calls != null ||
          message.function_call != null) {
        throw new ChatFault('completion');
      }
      signal.throwIfAborted();
      return message.content.trim();
    } catch (error) {
      if (error instanceof ChatFault) throw error;
      throw new ChatFault(signal.aborted ? 'deadline' : 'transport');
    } finally {
      if (reader) {
        await reader.cancel().catch(() => {});
        reader.releaseLock();
      } else {
        await response?.body?.cancel().catch(() => {});
      }
    }
  };
}

The Node.js 24 globals reference documents Fetch and AbortSignal.timeout(). Passing the signal to Fetch keeps the HTTP allowance active during body consumption. It is not a hard deadline for the whole bot update: queue time, synchronous parsing, Telegram delivery, and cleanup are separate work.

The adapter checks HTTP status and media type before reading JSON. It counts actual body bytes rather than trusting Content-Length, then rejects before retaining a chunk that would exceed its ceiling. Incoming chunks already exist, and concatenation, decoding, parsing, and concurrent calls require additional memory. This is an accepted-body limit, not a process-memory guarantee.

The completion rule is deliberately narrow: one choice at index zero, assistant role, nonblank text, finish_reason: stop, and no refusal or tool-call field. CometAPI also documents length-limited, tool-call, and content-filter outcomes. Those need a richer application result; this bot does not convert them into successful text answers. Even an empty tool_calls field is rejected by this strict policy.

Errors carry fixed categories. Raw Fetch errors, upstream bodies, prompts, keys, and generated text are excluded from routine diagnostics. The adapter adds no application retry loop. A timeout can leave remote generation or usage uncertain, so it should not automatically launch another attempt.

Route the private command and send plain text

Save this as ask-handler.mjs:

import { ChatFault } from './comet-text.mjs';

export function telegramText(text) {
  if (text.length <= 3000) return text;
  const suffix = '\n[Answer shortened by the bot.]';
  let prefix = '';
  for (const character of text) {
    if (prefix.length + character.length > 3000 - suffix.length) break;
    prefix += character;
  }
  return prefix + suffix;
}

export function makeAsk({ complete, allowedUserId, writeLog }) {
  const log = record => {
    try { writeLog(record); } catch {}
  };
  return async ctx => {
    if (ctx.chat?.type !== 'private' ||
        String(ctx.from?.id) !== allowedUserId) return;
    const prompt = typeof ctx.match === 'string' ? ctx.match.trim() : '';
    if (!prompt || prompt.length > 1000) {
      await ctx.reply('Use /ask followed by 1 to 1000 characters.');
      return;
    }
    const started = Date.now();
    let answer;
    try {
      answer = await complete(prompt);
    } catch (error) {
      log({
        event: 'telegram_ask', outcome: 'generation_failed',
        code: error instanceof ChatFault ? error.code : 'unexpected',
        elapsed_ms: Date.now() - started
      });
      await ctx.reply('I could not complete this request.');
      return;
    }
    try {
      await ctx.reply(telegramText(answer), {
        link_preview_options: { is_disabled: true }
      });
    } catch {
      log({
        event: 'telegram_ask', outcome: 'delivery_unconfirmed',
        elapsed_ms: Date.now() - started
      });
      return;
    }
    log({
      event: 'telegram_ask', outcome: 'delivered',
      shortened: answer.length > 3000,
      elapsed_ms: Date.now() - started
    });
  };
}

Both authorization checks run before model work. A matching sender in a group is rejected, and another sender in a private chat is rejected. The 1,000-code-unit input ceiling is a local policy. It is not a token budget, so choose any additional generation limits from the selected model’s current contract.

The Telegram Bot API reference documents sendMessage text lengths of 1–4,096 characters after entity parsing. This example uses a smaller ceiling of 3,000 JavaScript UTF-16 code units, including its shortening notice. Iterating through characters avoids splitting a surrogate pair while constructing the prefix; it can still split a multi-character grapheme such as a joined emoji sequence. The notice makes presentation shortening visible to the user.

No parse_mode or entities field is set. As the grammY message guide explains, Markdown and HTML formatting require an explicit parsing mode. A model response containing <b>text</b> therefore stays literal text in this fresh bot configuration. Link previews are disabled as a separate choice. Telegram clients may still recognize clickable URLs, so plain text is not an endorsement of generated links.

Delivery has its own try block. Once generation has succeeded, a failed ctx.reply() records delivery_unconfirmed and returns. The handler does not regenerate the answer or send an automatic replacement. Telegram may have accepted a message before a connection failed; distinguish an acknowledged send from a send whose result you could not confirm.

If generation fails, the handler attempts one stable failure message. A failure to send that notice propagates to the bot-level error handler below. Logging callbacks are isolated so a logging failure does not change the answer-delivery decision.

Start one long-polling process

Save this as bot.mjs:

import { Bot } from 'grammy';
import { makeComplete } from './comet-text.mjs';
import { makeAsk } from './ask-handler.mjs';

async function main() {
  const { TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USER_ID,
    COMETAPI_KEY, COMETAPI_MODEL } = process.env;
  if (!TELEGRAM_BOT_TOKEN ||
      !/^[1-9]\d*$/.test(TELEGRAM_ALLOWED_USER_ID ?? '')) {
    throw new Error('configuration');
  }
  const complete = makeComplete({ key: COMETAPI_KEY, model: COMETAPI_MODEL });
  const writeLog = record => console.info(JSON.stringify(record));
  const bot = new Bot(TELEGRAM_BOT_TOKEN);
  bot.command('ask', makeAsk({
    complete, allowedUserId: TELEGRAM_ALLOWED_USER_ID, writeLog
  }));
  bot.catch(() => writeLog({ event: 'telegram_update_failed' }));
  const stop = () => {
    if (bot.isRunning()) {
      void bot.stop().catch(() => writeLog({ event: 'telegram_stop_failed' }));
    }
  };
  process.once('SIGINT', stop);
  process.once('SIGTERM', stop);
  await bot.start({ allowed_updates: ['message'] });
}

main().catch(() => {
  console.error(JSON.stringify({ event: 'telegram_bot_stopped' }));
  process.exitCode = 1;
});

Inject the four settings without printing their values, then run node bot.mjs. Open a private chat with the dedicated bot and send /ask Give one example of idempotency. A command-menu entry is optional: the command handler can receive a typed /ask without registration through setMyCommands.

The grammY Bot reference describes start() as simple long polling and explains that its promise resolves after the bot stops. stop() cancels polling while allowing active middleware to finish; it does not wait for that middleware itself. Here, the outer await bot.start() keeps ownership of the running lifecycle. Signal handlers request a stop and record a fixed event if that request fails.

Use a dedicated bot and one process. Telegram documents long polling and webhooks as mutually exclusive update-receiving methods. Do not reuse a bot serving an existing webhook application. Also avoid starting a second polling replica with the same token. A normal platform restart policy belongs outside the code, with enough termination time for active work and delivery cleanup.

Simple polling in the checked grammY release processes updates sequentially. A slow model call can therefore delay later commands, including authorization checks on those later updates. This design suits a small allowlisted demonstration. A multi-user service needs explicit queue admission, concurrency, fairness, and a reviewed deployment design before adding workers or switching to webhooks.

The handler also has no durable deduplication. Telegram’s getUpdates documentation ties confirmation to the polling offset. A crash between model work and update confirmation can leave work eligible for replay. Before adding restart recovery for valuable jobs, persist update identity and outcome state, and define how to reconcile an uncertain model call. This demonstration makes no exactly-once execution promise.

The grammY error guide recommends installing bot.catch() for long polling; the default handler otherwise stops the bot after middleware errors. This example emits only telegram_update_failed. It intentionally avoids logging the wrapped error or context, which can contain message content and request details. Top-level startup failures use a separate fixed event and a nonzero exit status.

Check the handler without credentials

Save the following as ask-check.mjs. It uses small synthetic context objects to test the exported handler, with no polling and no network dependency.

import assert from 'node:assert/strict';
import { makeAsk, telegramText } from './ask-handler.mjs';

let calls = 0;
const replies = [];
const events = [];
const ctx = (type, id, match) => ({
  chat: { type }, from: { id }, match,
  reply: async (...args) => replies.push(args)
});
const ask = makeAsk({
  allowedUserId: '42', writeLog: record => events.push(record),
  complete: async prompt => {
    calls++;
    assert.equal(prompt, 'Hello');
    return '<b>Literal answer</b>';
  }
});

await ask(ctx('group', 42, 'Hello'));
await ask(ctx('private', 43, 'Hello'));
await ask(ctx('private', 42, ''));
await ask(ctx('private', 42, 'x'.repeat(1001)));
assert.equal(calls, 0);
await ask(ctx('private', 42, ' Hello '));
assert.equal(calls, 1);
assert.equal(replies.at(-1)[0], '<b>Literal answer</b>');
assert.equal(replies.at(-1)[1].parse_mode, undefined);
assert.equal(events.at(-1).outcome, 'delivered');
assert.ok(telegramText('🌍'.repeat(2000)).length <= 3000);

await makeAsk({
  allowedUserId: '42', writeLog: record => events.push(record),
  complete: async () => 'Completed answer'
})({ ...ctx('private', 42, 'Hello'), reply: async () => {
  throw new Error('synthetic_delivery_failure');
} });
assert.equal(events.at(-1).outcome, 'delivery_unconfirmed');
console.info('Handler checks passed');

Run node ask-check.mjs. These assertions check the handler boundary; they do not validate Telegram’s network behavior. During preparation, additional synthetic checks passed for targeted-command routing through grammY, generation failure, sanitized events, response envelope rejection, HTTP errors, malformed JSON, and the body ceiling. A loopback HTTP server sent JSON headers and then stalled; the adapter’s deadline rejected the read and the server observed connection closure.

The checks used Node.js 24.22.1 and grammY 1.46.0. They establish local behavior only. They do not establish current model access, Telegram delivery, production throughput, or a billing outcome. To extend HTTP request matching in CI, use the site’s offline MockAgent tutorial and retain the same request-construction and completion checks.

Contract details to verify

BoundaryExpected behaviorControlled check
AccessOnly the configured sender in a private chat reaches generation.A group update and another sender cause zero model calls.
CommandThe argument follows /ask in ctx.match.Test whitespace, empty input, and an over-limit argument.
Model requestOne configured model, one user message, stream: false.Capture the synthetic request’s method, path, and parsed JSON.
CompletionExactly one accepted ordinary text answer.Reject refusal, tools, truncation, missing content, and empty choices.
HTTP resourcesDeadline and accepted-body ceiling apply before result acceptance.Stall the body and exceed the ceiling with synthetic bytes.
Telegram outputOne bounded plain-text answer with previews disabled.Inspect reply arguments and the explicit shortening notice.
Delivery failureGeneration success remains distinct from confirmed delivery.Make the reply method reject and assert delivery_unconfirmed.

For the live happy path, use one harmless prompt from the allowlisted private account. Confirm one answer and a delivered log event without prompt or response text. For a controlled error path, inject a completion function that throws locally, then confirm the stable failure notice and generation_failed. Restore the real adapter afterward.

Do not deliberately break live credentials to exercise failures that a test double can reproduce. If account-backed setup fails, inspect only configuration presence, selected model, HTTP category, and the official contract. The bot’s public failure message should remain independent of provider error bodies.

Failure modes

The bot replies to nobody. Confirm the decimal sender ID and private-chat type. A Telegram username is not the allowlist identifier. Do not weaken authorization to diagnose the transport; use synthetic contexts first.

The command argument includes the command itself. Read ctx.match, which grammY documents as the argument string. Its command matcher also handles targeted forms such as /ask@your_bot_name.

The process starts but later commands wait. A long model request occupies this simple polling handler. Keep the audience small and the HTTP allowance bounded. More users require an admission and scheduling policy.

Generation succeeds but the answer is missing. Check for delivery_unconfirmed. Investigate Telegram delivery separately before deciding on another send. Repeating generation adds cost and can produce different text without resolving the original send’s status.

A 200 response becomes a blank or partial answer. Retain the envelope and finish-state checks. A transport success does not establish the text outcome this application promises.

A debugging session exposes a token. Telegram Bot API request URLs contain the bot token, according to its official reference. Avoid raw request URL logs, client debug dumps, configuration dumps, and wrapped errors. The fixed application events deliberately omit those details.

Shutdown appears stuck. Stopping polling allows active middleware to continue. Inspect the model request, Telegram send, and cleanup as separate stages. The 30-second HTTP allowance does not cap every stage of shutdown.

Sources checked

These primary technical references were fetched on November 2, 2026 UTC, which was November 3 in Asia/Shanghai. They support an evergreen implementation task. They are current contract references checked live, not evidence of a newly announced integration or a successful account-backed deployment.

FAQ

Can I run the bot without a public webhook URL?

Yes. This example uses long polling from a persistent process. Keep that process separate from any webhook consumer using the same bot identity.

Does the bot remember earlier Telegram messages?

No. Each accepted command sends one user message to CometAPI. Adding history requires explicit storage, retention, access checks, and context limits.

Why send plain text instead of Markdown?

Generated Markdown may contain Telegram-specific syntax or escaping errors. Plain text gives this first integration a predictable delivery format. A richer renderer needs a separate formatting policy and fixtures.

Does the allowlist prevent all unwanted spending?

It restricts this handler to one sender, but that sender can submit repeated commands. Add workload budgets and admission rules before treating it as a shared service.

Should the bot automatically retry a failed Telegram send?

The example records an unconfirmed outcome and stops. A failed network response can leave delivery uncertain. A production resend policy needs to account for possible duplicate messages and must not repeat generation merely to retry delivery.

Reader next step

Run the synthetic checks, verify the four runtime settings, and start one process with a dedicated Telegram bot. Exercise one accepted private command and the controlled generation-failure path. Confirm that group messages and another sender cause no model call, then inspect only the fixed metadata events.

For the separate account-backed check, Start with CometAPI , select a current Chat Completions model, and use a harmless prompt. Keep the model-access and delivery observations separate from synthetic test results before expanding the bot’s audience or capabilities.