Send messages, files, images and videos to Telegram from the command line — a
single, dependency-light bash script that talks to the Telegram Bot API.
Handy for server notifications: cronjob results, backup status, monitoring alerts, or grabbing a small file off a box when SCP is inconvenient.
Lineage & license. This is a maintained continuation of fabianonline/telegram.sh (original author Fabian Schlenz; upstream inactive since 2022). It keeps the original GPLv3 license and the full commit history. See Changelog for what has been added since upstream v0.5.
- Send text, files (
-f), images (-i) and videos (-V). - Markdown / HTML formatting, monospace code blocks, message titles.
- Send to multiple chats in one call (
-crepeated). - Per-recipient retries (
-a) that retry only transient failures and fail fast on permanent ones — see Retries & exit code. - Read config/secrets from files or environment variables.
- Works behind a proxy (SOCKS/HTTP).
- Only needs
bashandcurl(jqoptional).
bashandcurl.jqis optional. It makes-l(listing chats) nicer and lets the retry logic read Telegram error codes precisely; without it the script falls back to plain text parsing and still works.
# 1. Grab the script and put it on your PATH.
curl -o /usr/local/bin/telegram https://raw.githubusercontent.com/anton-vinogradov/telegram.sh/master/telegram
chmod +x /usr/local/bin/telegramOr track the repository so you can update with git pull:
git clone https://github.com/anton-vinogradov/telegram.sh.git
ln -s "$PWD/telegram.sh/telegram" /usr/local/bin/telegramThen create a bot and find your chat id:
- Talk to
@BotFather, run/newbot, and keep the token it gives you. - Send your new bot any message from your Telegram client.
- Discover your chat id:
telegram -t <TOKEN> -l. Withjqinstalled the chats are listed nicely; the number in front is your chat id. - Send your first message:
telegram -t <TOKEN> -c <CHAT_ID> "Hello there."
telegram -t 123456:AbcDefGhi-JklMnoPrw -c 12345 "Hello, World."Once your token and chat id live in a config file or environment (see Configuration), you can simply run:
telegram "Hello, World."telegram [options] [message]
message may be -, in which case it is read from stdin.
| Option | Description |
|---|---|
-t <TOKEN> |
Bot token to use (see Configuration). |
-c <CHAT_ID> |
Recipient chat. Repeatable — send to several chats at once. |
-e <CHAT:MSGID> |
Edit an existing message instead of sending. Repeatable; replaces -c. With -V/-i/-f the message becomes that media (editMessageMedia — this also turns a plain text message into a media message); with text only, the text is replaced (editMessageText). |
-I |
Print msgid <chat> <message_id> for every delivered message, so you can -e it later. |
-q <DIR> |
Queue on failure (store-and-forward): recipients that still fail after all retries are written to DIR as queue files. See Offline queue. |
-d <DIR> |
Drain a queue directory: replay every queued delivery (oldest first, under a lock). |
-x <SECONDS> |
With -d: entries older than this expire — the message argument is delivered as an expiry notice instead. |
-a <N> |
Attempts per recipient (retries). Recipients are independent. See Retries. |
-p |
Deliver to all recipients in parallel (independently) instead of sequentially. |
-f <FILE> |
Send a file. |
-i <FILE> |
Send a file as an image (must be a real image). |
-V <FILE> |
Send a file as a video. |
-M |
Enable Markdown parsing. |
-H |
Enable HTML parsing. |
-C |
Send text as monospace code — handy when piping command output. |
-r |
Like -C, but a first line starting with + is specially formatted. |
-T <TITLE> |
Set a title (bold when -M/-H is used). |
-D |
Disable web-page preview (text messages only). |
-N |
Silent notification (no sound). |
-l |
List known chat ids. |
-m |
Print the last received message: <Message ID> <Sender ID> <Chat ID> <Text>. |
-R |
Receive a file that was sent to the bot. |
Debugging options
| Option | Description |
|---|---|
-v |
Verbose output (the bot token is masked). |
-j |
Pretend jq is not installed. |
-n |
Dry-run — print what would be sent, don't send. |
-a <N> retries delivery to each recipient independently up to N times.
It is deliberately conservative about what it retries:
- Retried (transient): curl/network errors, HTTP 5xx, and 429 rate
limits. On
429theretry_aftervalue from Telegram is honored. - Not retried (permanent):
400,401,403,404, … — these won't succeed on a retry (bad token, unknown chat, bot blocked), so they fail fast.
Recipients are independent: a failure to one chat does not stop delivery to the
others. The delay between attempts is RETRY_DELAY seconds (default 2,
overridable in a config file).
Exit code: 0 if every recipient was delivered, non-zero if any recipient
ultimately failed — convenient for scripting and monitoring.
# Try each recipient up to 10 times, retrying only transient failures.
telegram -c 111 -c 222 -a 10 -V event.mp4With -q <dir> a delivery that still fails after all retries is not lost: each
failed recipient becomes a small queue file capturing the exact send (target,
media file, text, parse mode — and the bot token, so keep the directory
private, e.g. chmod 700). A later telegram -d <dir> run — typically from
cron or a systemd timer — replays the queue oldest-first under a lock:
- delivered or permanently rejected entries are removed;
- transient failures (network down, 5xx, 429) stay for the next run;
- with
-x <seconds>, entries older than that expire: the message argument is delivered instead as a best-effort notice (as an edit for<chat>:<message_id>targets, as a plain message otherwise), and the entry is dropped.
# Sender: queue anything that can't be delivered right now.
telegram -c 1234 -a 3 -q /var/spool/telegram-queue -V clip.mp4
# Cron / systemd timer, every couple of minutes:
telegram -d /var/spool/telegram-queue -x 21600 "❌ delivery failed"Combined with -I/-e this gives full store-and-forward for the
placeholder-then-morph pattern: the placeholder is already in the chat, and the
queued edit turns it into the real media as soon as the network returns.
TOKEN and CHAT_ID can be provided in six ways. Later sources override
earlier ones, so you can set global defaults and override per call:
/etc/telegram.sh.conf~/.telegram.sh~/.telegram.sh.conf./.telegram.sh.conf(next to the script)- Environment variables
TELEGRAM_TOKENandTELEGRAM_CHAT - Command-line options
-tand-c
A config file is plain shell:
TELEGRAM_TOKEN="123456:AbcDefGhi-JlkMno"
TELEGRAM_CHAT="12345678"Multiple chats can be set as a bash array:
TELEGRAM_TOKEN="123456:AbcDefGhi-JlkMno"
CHATS=(12345678 23456789 34567)
⚠️ Keep your bot token secret. Config files with a token should not be world-readable. (-vand dry-run output mask the token since 0.10.)
Simplest — point curl at a proxy via the environment:
HTTPS_PROXY="socks5://127.0.0.1:1234" telegram "Hello, World."For a permanent, host-local setup you can override CURL_OPTIONS in a config
file. The default is -s --connect-timeout 10 --max-time 300; overriding
replaces it entirely, so keep the timeouts you want:
# /etc/telegram.sh.conf
CURL_OPTIONS="-s -x socks5://127.0.0.1:1234 --connect-timeout 10 --max-time 60"See the curl documentation for the supported proxy protocols.
Every option, grouped by task. TOKEN/CHAT_ID are assumed to come from a
config file or the environment (see Configuration) unless
-t/-c are shown explicitly.
# Explicit token and chat id (-t, -c) - override config and environment.
telegram -t 123456:AbcDefGhi-JklMnoPrw -c 12345 "Hello, World."
# Several recipients at once - repeat -c.
telegram -c 1234 -c 6789 "Hello, Planets."
# Multi-line message.
telegram "Hello,"$'\n'"World."
# Read the message from stdin ('-' as the message).
echo "Hello from a pipe." | telegram -
# Silent delivery (-N) - the client gets the message without a sound.
telegram -N "3am cron finished, don't wake anyone."# Markdown (-M) and HTML (-H) parsing.
telegram -M "To *boldly* go, where _no man_ has gone before."
telegram -H "To <b>boldly</b> go, where <i>no man</i> has gone before."
# Title above the message (-T; printed bold with -M or -H).
telegram -M -T "Backup report" "Everything is fine."
# Send piped command output as monospace code (-C).
df -h | telegram -C -
# Cron mode (-r): like -C, but exits silently unless the input contains
# a line starting with '+ ' (e.g. 'sh -x' traces) - keeps quiet crons quiet.
make deploy 2>&1 | telegram -r -
# Don't unfurl links into previews (-D; text messages only).
telegram -D "Docs: https://example.com/very/loud/page"# Document (-f, max 50MB) with a caption.
telegram -f results.txt "Here are the results."
# Photo (-i, max 10MB) - caption optional.
telegram -i solar_system.png "The neighbourhood."
# Video (-V, max 50MB). Streams inline; width/height/duration are probed
# with ffprobe when available, so the preview and aspect ratio are right.
telegram -V clip.mp4 "Look what the cat did."# Up to 5 attempts per recipient (-a). Only transient failures are retried
# (network errors, HTTP 5xx, 429 - retry_after is honored); permanent ones
# (bad token, blocked bot, ...) fail immediately without retrying.
telegram -a 5 "Important."
# Deliver to all recipients in parallel and independently (-p):
# one broken chat doesn't delay or block the others.
telegram -c 1234 -c 6789 -a 5 -p -V clip.mp4 "For both of you."
# Exit code is 0 only if EVERY recipient got the message; failed chats are
# listed on stderr as "Failed to deliver to: <ids>".How it works, in one breath: -I makes every successful send print
msgid <chat> <message_id> on stdout. Save those pairs. Later, pass them back
as -e <chat>:<message_id> instead of -c <chat> — the bot then edits
that message rather than sending a new one: with a message argument the text
is replaced, with -V/-i/-f the message becomes that media (a plain
text message really does turn into a photo or video in place). Edits are
silent — subscribers get exactly one notification, from the original send.
Real wiring — one Telegram message per camera event that upgrades itself (instant text → alarm snapshot a second later → full video at event end):
#!/bin/bash
CHATS=(-c 1234 -c 6789)
# Phase 1 - the instant something happens: cheap text, delivered in ~0.2s.
# -p delivers to all chats in parallel, -I prints one "msgid ..." line each.
out=$(telegram "${CHATS[@]}" -p -I -a 2 "🎥 $(date +%H:%M:%S)")
# Turn the msgid lines into "-e chat:id" targets for the next phases.
targets=()
while read -r _ chat id; do targets+=(-e "$chat:$id"); done < <(grep ^msgid <<< "$out")
# Phase 2 - a snapshot exists: the text message BECOMES a photo.
# Best-effort (-a 1): if it fails, the video will replace it anyway.
telegram "${targets[@]}" -p -a 1 -i alarm.jpg "$(date +%H:%M:%S)"
# Phase 3 - the video is ready: the same message becomes the video.
telegram "${targets[@]}" -p -a 3 -V event.mp4The chat shows a single message the whole time: it appears instantly as text, sprouts a picture a second later, and finally plays as a video. The notification sound fires once — for the text.
How it works, in one breath: with -q <dir>, every recipient that is still
undelivered after all -a retries is written into <dir> as one small file
describing the exact delivery (target, media file, text, token — so
chmod 700 the directory). A separate telegram -d <dir> run — from cron or
a systemd timer — replays those files oldest-first: delivered and permanently
rejected entries are deleted, transient failures stay queued for the next
run. With -x <seconds> an entry eventually expires: the message argument is
delivered instead of the payload (as an edit if the target was
chat:msgid, as a plain message otherwise), and the entry is dropped.
Real wiring — sender plus a retry timer:
# Sender (camera event, backup job, ...): 3 attempts now, spool on failure.
telegram -c 1234 -c 6789 -p -a 3 -q /var/spool/tg-queue -V event.mp4# /etc/cron.d/telegram-drain - retry every 2 minutes, give up after 6 hours.
*/2 * * * * root /usr/local/bin/telegram -d /var/spool/tg-queue -x 21600 "❌ delivery failed"What actually happens when the network dies mid-day:
14:02 send fails after 3 attempts -> queue file written, sender exits 1
14:04 drain: network still down -> entry stays queued
14:06 drain: still down -> entry stays queued
14:37 drain: network is back -> video delivered, entry removed
The two recipes compose. Add -q to phase 3 of the camera script above: the
placeholder is already in the chat, so when the network returns the queued
edit turns it into the video — and if 6 hours pass first, the very same
message turns into "❌ delivery failed" instead. Either way the subscriber
sees the event and its outcome in one message, in the right chronological
position.
# Which chats can the bot see? (Message the bot first, then:)
telegram -l
# Print the last message sent to the bot.
telegram -m # -> <Message ID> <Sender ID> <Chat ID> <Text>
# Download the last file sent to the bot into the current directory.
telegram -Rtelegram -v "Verbose run." # log every step (the token is masked)
telegram -n "Dry run." # print the curl invocation, send nothing
telegram -j "No jq." # pretend jq is absent (exercise fallbacks)
telegram -h # full usagedocker build -t telegram:latest .
docker run --rm telegram -t <TOKEN> -c <CHAT_ID> "Hello from Docker."- Offline queue (store-and-forward):
-q <dir>writes every recipient that still fails after all retries into a queue directory;-d <dir>replays the queue (oldest first, locked) — delivered/permanent entries are removed, transient ones stay;-x <seconds>expires old entries by delivering the message argument as a notice (edit forchat:msgidtargets) and dropping them. Queue files carry the bot token — keep the directorychmod 700.
- Editing messages: new
-e <chat>:<message_id>mode (repeatable, replaces-c). With a file it runseditMessageMedia— including turning a plain text message into a video/photo/document; with text only it runseditMessageText. Retries (-a), parallelism (-p) and the transient/ permanent error classification apply to edits exactly as to sends. -I: printmsgid <chat> <message_id>for every delivered message, so callers can edit it later (placeholder-then-morph workflows).ffprobemetadata (width/height/duration) is embedded in theInputMediaVideoobject on edits, same as onsendVideo.
- Fixed precedence:
-c/-ton the command line now really override config files (a config definingCHATS=(...)used to win silently). The documented order — config files < environment < options — now holds. - Files with spaces (and commas/semicolons) in their names now upload correctly: curl arguments are built as an array and file paths use curl's quoted-filename syntax.
- Default network timeouts (
--connect-timeout 10 --max-time 300), so a dead proxy or hung connection can't block a cronjob forever. Overridable viaCURL_OPTIONS. - Usage errors (unknown option, missing argument) now exit with code
2instead of printing help and exiting0. 429 retry_afteris honored even withoutjq.sendPhotois checked against its real 10MB limit; captions longer than 1024 characters produce a warning.-llists groups and channels by title, skips non-message updates and dedupes chats (no morenull - null null (@null)).- The bot token is masked in
-vand dry-run output. - Docker image now has an
ENTRYPOINTand shipsjq. - Real smoke-test suite (
test.sh, 26 checks) + GitHub Actions CI (shellcheck + tests).
sendVideonow sendssupports_streaming=trueand, whenffprobeis available,width/height/duration— for reliable inline playback and a correct preview/aspect ratio.-Dnow useslink_preview_options(the current Bot API field) instead of the removeddisable_web_page_preview.
- New
-poption: deliver to all recipients in parallel (independently) instead of sequentially, so a slow or unreachable recipient no longer holds up the others.
- Smarter
-aretries: only transient failures (network/curl errors, HTTP 5xx, 429) are retried, honoring the429 retry_afterhint; permanent errors (4xx) now fail fast instead of exhausting all attempts. - Fixed dry-run (
-n) falsely reporting a failure whenjqis unavailable.
- New
-a <N>option: retry delivery to each recipient up toNtimes. Recipients are independent, and the exit code is non-zero if any recipient ultimately failed.
- New option
-Vto send a video file. - Configuration can also be read from
./.telegram.sh.confnext to the script.
- New option
-mto output the last received message — useful for polling and reacting to commands.
Original author Fabian Schlenz and upstream contributors: abadroot, dbarthe, hugows, kgizdov, KOPACb, rerime, rusalex, sergiks.
GPLv3 — see LICENSE.