Telegram AI Bot
Category: AI & Agents
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
| Method | Route | Purpose |
|---|---|---|
| POST | /telegram/webhook | Where Telegram delivers updates. The bot itself. |
| POST | /telegram/webhook/register | Point Telegram's webhook at this deployment. |
| GET | /telegram/webhook/info | What 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:
| Variable | Value |
|---|---|
TELEGRAM_BOT_TOKEN | From @BotFather, no bot prefix. |
ALLOWED_USER_ID | Your numeric Telegram id. |
OPENAI_API_KEY | Used for Whisper, GPT-4o vision and classification. |
PUBLIC_URL | This 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 send | it does | it replies |
|---|---|---|
| text | classifies the message | ✅ Added that as a task. / 💬 Noted. |
| a voice note | getFile → download → Whisper → classify | 🎤 I heard: "…" then the classification |
| a photo | getFile → download → GPT-4o vision | 🖼 <description of the image> |
| anything else | nothing | I can read text, voice notes and photos. |
| — from anyone else | nothing | Sorry, 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| }