Skip to main content

Telegram AI Bot

Category: AI & Agents

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

A Telegram bot that understands text, voice notes and photos. Speak to it and it transcribes with Whisper; send it a picture and it describes what it sees; type at it and it works out whether you asked for something or just said something. One config, one deployment, no connector to install.

Telegram needs no connector​

There is nothing to install and no OAuth dance. The bot token goes in the URL path, and that is the entire auth story:

url: "a|var::tg_api|/bota|ap_var::TELEGRAM_BOT_TOKEN|/sendMessage"

Everything else — receiving updates, fetching a file, sending a reply, registering the webhook — is ordinary REST. The three routes in this pack cover all of it.

Binary files are not a separate lane​

Getting a voice note into a speech model usually means downloading a file, deciding where the bytes live, and converting between representations. Here a response that is not valid UTF-8 comes back as base64_bytes, so the download is the decode:

- name: DownloadVoice
http:
url: ".../file/bot<token>/a|GetVoiceFile::body.result.file_path|"

- name: Transcribe
http:
url: "a|var::openai_api|/audio/transcriptions"
multipart:
- name: file
b64: a|DownloadVoice::base64_bytes| # straight through
filename: voice.oga
mime: audio/ogg
- name: model
value: whisper-1

The photo branch does the same thing into a data: URI for GPT-4o vision. No intermediate steps.

Endpoints​

MethodRoutePurpose
POST/telegram/webhookWhere Telegram delivers updates. The bot itself.
POST/telegram/webhook/registerPoint Telegram's webhook at this deployment.
GET/telegram/webhook/infoWhat Telegram currently thinks the webhook is.

Two things worth copying​

Only you can talk to it. ALLOWED_USER_ID is checked on every update, and anyone else gets a polite refusal. A bot's username is guessable, so without this anyone who finds it is talking to your OpenAI key.

The allowed id is resolved in a config field and read through a binding, not interpolated into the script:

- name: Auth
json_output: |-
{ "allowed_id": "a|ap_var::ALLOWED_USER_ID|" }
const allowedId = String($('Auth').first().json.allowed_id ?? "");

That is not a style choice — a request-scoped marker is deliberately left alone inside a script, because splicing request data into a program is injection. Data reaches a script through bindings.

It always answers 200. Telegram retries any non-2xx delivery, so a bot that errors on a message it cannot handle receives that message again, forever. The final Ack uses depends_on rather than run_when_succeeded precisely so that a failed reply still produces a 200:

- name: Ack
depends_on: [Send] # not run_when_succeeded
json_output: |-
{ "ok": true }

This matters more than it looks. Telegram refuses a bot-initiated message to someone who never started the bot, so replying to a stranger genuinely fails — and with a success gate there, that failure becomes the infinite retry this action exists to prevent.

Setup​

1. Create the bot. Message @BotFather, send /newbot, and keep the token it gives you. Set TELEGRAM_BOT_TOKEN to the token without the bot prefix.

2. Find your user id. Message @userinfobot; it replies with your numeric id. That is ALLOWED_USER_ID.

3. Set the remaining variables:

VariableValue
TELEGRAM_BOT_TOKENFrom @BotFather, no bot prefix.
ALLOWED_USER_IDYour numeric Telegram id.
OPENAI_API_KEYUsed for Whisper, GPT-4o vision and classification.
PUBLIC_URLThis deployment's public base URL.

4. Register the webhook — Telegram needs a public HTTPS URL to deliver to:

curl -X POST https://<your-endpoint>/telegram/webhook/register -H "x-api-key: $KEY"
# {"registered": true, "description": "Webhook was set"}

Check it took:

curl https://<your-endpoint>/telegram/webhook/info -H "x-api-key: $KEY"

5. Message your bot.

What it does with each kind of message​

you sendit doesit replies
textclassifies the message✅ Added that as a task. / 💬 Noted.
a voice notegetFile → download → Whisper → classify🎤 I heard: "…" then the classification
a photogetFile → download → GPT-4o vision🖼 <description of the image>
anything elsenothingI can read text, voice notes and photos.
— from anyone elsenothingSorry, I don't talk to strangers.

Making it yours​

The classification is one prompt in the Classify action, and the reply wording is one script in Reply. Change the categories to whatever you actually want the bot to sort — urgent vs later, expense vs note — and the rest of the config does not move.

To do something with the result rather than just acknowledging it, add an action after Reply: write to a database, call an API, append to a sheet. a|Words::0.json.text| is what they said and a|Classify::body.choices.0.message.content| is what it was classified as.

Requirements​

  • An Air Pipe engine with the code: action (3.3.0+).
  • A Telegram bot token and an OpenAI API key.
  • A public HTTPS URL, because Telegram delivers updates by webhook.

Configuration​

config.yml​

name: TelegramAIBot
description: >
A Telegram bot that understands text, voice notes and photos. It transcribes
speech with Whisper, describes images with GPT-4o, classifies what you meant,
and replies — all from one config, with an allowlist so only you can talk to it.

docs: true

# Managed variables to set before deploying:
# TELEGRAM_BOT_TOKEN — from @BotFather. The token only, without the "bot" prefix.
# ALLOWED_USER_ID — your numeric Telegram user id. Message @userinfobot to find it.
# OPENAI_API_KEY — used for transcription, vision and classification.
# PUBLIC_URL — this deployment's public base URL, for registering the webhook.

global:
variables:
tg_api: "https://api.telegram.org"
openai_api: "https://api.openai.com/v1"

interfaces:

# ─────────────────────────────────────────────────────────────────────────
# The bot. Telegram POSTs every update here.
# ─────────────────────────────────────────────────────────────────────────
telegram/webhook:
output: http
method: POST
summary: Receive a Telegram update, understand it, and reply
tags: [telegram, bot, openai]

actions:
# ── who is this, and what did they send ─────────────────────────────
# One action decides everything the branches below gate on, so the
# routing reads as a table rather than as conditions scattered over
# nine actions.
# The allowed id arrives through a binding, not through the script text.
# A marker for request-scoped data is deliberately NOT substituted inside
# `source` -- splicing request data into a program is injection -- so the
# value is resolved in a config field here and read with $() below.
- name: Auth
hide_data_on_success: true
json_output: |-
{ "allowed_id": "a|ap_var::ALLOWED_USER_ID|" }

- name: Route
run_when_succeeded: [Auth]
input: a|body|
code:
language: js
source: |
const u = $input.first().json || {};
const m = u.message || {};
const from = m.from || {};
const allowedId = String($('Auth').first().json.allowed_id ?? "");
const allowed = String(from.id || "") === allowedId;
// Telegram sends photos as an array of sizes, smallest first. The
// last is the largest, which is the one worth analysing.
const photo = Array.isArray(m.photo) && m.photo.length
? m.photo[m.photo.length - 1] : null;
const kind = !allowed ? "denied"
: m.voice ? "audio"
: (typeof m.text === "string" && m.text.length) ? "text"
: photo ? "image"
: "unsupported";
return [{ json: {
kind,
allowed,
chat_id: m.chat?.id ?? from.id ?? null,
text: m.text ?? "",
caption: m.caption ?? "",
voice_file_id: m.voice?.file_id ?? null,
photo_file_id: photo?.file_id ?? null,
} }];

# ── voice: get the file, download it, transcribe it ─────────────────
- name: GetVoiceFile
run_when_succeeded: [Route]
run_on_assertion:
tests:
- action: Route
value: 0.json.kind
is_equal_to: audio
http:
method: GET
url: "a|var::tg_api|/bota|ap_var::TELEGRAM_BOT_TOKEN|/getFile?file_id=a|Route::0.json.voice_file_id|"

# A response that is not valid UTF-8 comes back as `base64_bytes`, so the
# download IS the decode — there is no separate "read the binary" step,
# and the bytes go straight into a data: URI or a multipart upload below.
- name: DownloadVoice
run_when_succeeded: [GetVoiceFile]
http:
method: GET
url: "a|var::tg_api|/file/bota|ap_var::TELEGRAM_BOT_TOKEN|/a|GetVoiceFile::body.result.file_path|"

- name: Transcribe
run_when_succeeded: [DownloadVoice]
http:
method: POST
url: "a|var::openai_api|/audio/transcriptions"
bearer_auth: a|ap_var::OPENAI_API_KEY|
multipart:
- name: file
b64: a|DownloadVoice::base64_bytes|
filename: voice.oga
mime: audio/ogg
- name: model
value: whisper-1

# ── image: same shape, different endpoint ───────────────────────────
- name: GetPhotoFile
run_when_succeeded: [Route]
run_on_assertion:
tests:
- action: Route
value: 0.json.kind
is_equal_to: image
http:
method: GET
url: "a|var::tg_api|/bota|ap_var::TELEGRAM_BOT_TOKEN|/getFile?file_id=a|Route::0.json.photo_file_id|"

- name: DownloadPhoto
run_when_succeeded: [GetPhotoFile]
http:
method: GET
url: "a|var::tg_api|/file/bota|ap_var::TELEGRAM_BOT_TOKEN|/a|GetPhotoFile::body.result.file_path|"

- name: AnalyseImage
run_when_succeeded: [DownloadPhoto]
http:
method: POST
url: "a|var::openai_api|/chat/completions"
bearer_auth: a|ap_var::OPENAI_API_KEY|
headers:
content-type: application/json
body: |
{
"model": "gpt-4o-mini",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "Describe this image in one or two sentences. Caption from the sender: a|Route::0.json.caption|" },
{ "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,a|DownloadPhoto::base64_bytes|" } }
]
}]
}

# ── what did they mean: one classifier for both text and speech ─────
# The text and voice branches converge here, so the classification prompt
# is written once rather than duplicated per branch.
# depends_on, not run_when_succeeded: Transcribe is skipped on the text
# branch, and a skipped dependency silently stops a run_when_succeeded
# dependent from running at all. Ordering is what is wanted here, not a
# success gate.
- name: Words
depends_on: [Route, Transcribe]
run_on_assertion:
tests:
- action: Route
value: 0.json.kind
is_not_equal_to: image
input: a|Route|
code:
language: js
source: |
const r = $('Route').first().json;
let text = r.text;
if (r.kind === "audio") {
const t = $('Transcribe').first();
text = t?.json?.body?.text ?? t?.json?.text ?? "";
}
return [{ json: { text: text || "", kind: r.kind } }];

- name: Classify
run_when_succeeded: [Words]
run_on_assertion:
tests:
- action: Route
value: 0.json.kind
is_not_equal_to: image
- action: Route
value: 0.json.kind
is_not_equal_to: denied
- action: Route
value: 0.json.kind
is_not_equal_to: unsupported
http:
method: POST
url: "a|var::openai_api|/chat/completions"
bearer_auth: a|ap_var::OPENAI_API_KEY|
headers:
content-type: application/json
body: |
{
"model": "gpt-4o-mini",
"response_format": { "type": "json_object" },
"messages": [
{ "role": "system", "content": "Classify the user message. Answer as JSON {\"category\":\"task\"} if it is about creating a task or todo, otherwise {\"category\":\"other\"}." },
{ "role": "user", "content": "a|Words::0.json.text|" }
]
}

# ── compose one reply, then send it once ────────────────────────────
# Every branch ends here rather than carrying its own send action, so
# there is one place that talks to Telegram and one message format.
# Depends on every branch it reads with $(), not just on Route. A $()
# binding is invisible to the reference linter -- it only sees a|markers| --
# so an undeclared one here races the action it reads and reports
# "Cannot read properties of undefined". Skipped branches still count as
# succeeded, so naming all three is safe whichever branch ran.
- name: Reply
depends_on: [Route, Words, Classify, AnalyseImage]
input: a|Route|
code:
language: js
source: |
const r = $('Route').first().json;
// The reply is JSON-escaped HERE, not quoted in the request body. A
// transcript containing a quote or a newline would otherwise break
// the body it is spliced into.
const reply = t => [{ json: { text: t, text_json: JSON.stringify(t) } }];
if (r.kind === "denied") return reply("Sorry, I don't talk to strangers.");
if (r.kind === "unsupported") return reply("I can read text, voice notes and photos.");
if (r.kind === "image") {
const a = $('AnalyseImage').first();
const desc = a?.json?.body?.choices?.[0]?.message?.content ?? "(no description)";
return reply(`🖼 ${desc}`);
}
const words = $('Words').first().json.text;
const c = $('Classify').first();
let category = "other";
try {
category = JSON.parse(c?.json?.body?.choices?.[0]?.message?.content ?? "{}").category ?? "other";
} catch (_) {}
const heard = r.kind === "audio" ? `🎤 I heard: "${words}"\n` : "";
return reply(category === "task"
? `${heard}✅ Added that as a task.`
: `${heard}💬 Noted.`);

- name: Send
run_when_succeeded: [Reply]
http:
method: POST
url: "a|var::tg_api|/bota|ap_var::TELEGRAM_BOT_TOKEN|/sendMessage"
headers:
content-type: application/json
body: |
{
"chat_id": a|Route::0.json.chat_id|,
"text": a|Reply::0.json.text_json|
}

# Telegram retries any non-2xx, so always answer 200 — a bot that errors
# on a message it cannot handle gets the same message again, forever.
#
# depends_on, NOT run_when_succeeded. Telegram refuses a bot-initiated
# message to someone who never started the bot, so replying to a stranger
# fails with "chat not found" -- and with a success gate here that failure
# propagates into a non-200, which is precisely the infinite retry this
# action exists to prevent. Verified live: it retried.
- name: Ack
depends_on: [Send]
json_output: |-
{ "ok": true }

# ─────────────────────────────────────────────────────────────────────────
# Webhook administration. In the template this is a second disconnected flow
# of six nodes; here it is two routes.
# ─────────────────────────────────────────────────────────────────────────
telegram/webhook/register:
output: http
method: POST
summary: Point Telegram's webhook at this deployment
tags: [telegram, setup]
actions:
- name: SetWebhook
http:
method: POST
url: "a|var::tg_api|/bota|ap_var::TELEGRAM_BOT_TOKEN|/setWebhook"
headers:
content-type: application/json
body: |
{ "url": "a|ap_var::PUBLIC_URL|/telegram/webhook" }
- name: Respond
run_when_succeeded: [SetWebhook]
json_output: |-
{ "registered": a|SetWebhook::body.ok|, "description": "a|SetWebhook::body.description|" }

telegram/webhook/info:
output: http
method: GET
summary: What Telegram thinks the webhook is
tags: [telegram, setup]
actions:
- name: Info
http:
method: GET
url: "a|var::tg_api|/bota|ap_var::TELEGRAM_BOT_TOKEN|/getWebhookInfo"
- name: Respond
run_when_succeeded: [Info]
json_output: |-
{ "webhook": a|Info::body.result| }