Skip to content

Repository files navigation

telegram-forwarder

CI Release License

Route any number of Telegram chats into any number of others. You pick them from a list; you never type an ID.

The command is called tgfwd. That is the binary this repository builds; telegram-forwarder is the project and crate name.

📢 Breaking News  ─┐          ┌─→ 👥 Team channel
📢 Market Alerts  ─┼─ tgfwd ──┼─→ 📌 Saved Messages
👤 A contact      ─┘          └─→ 👥 Archive group

What problem this solves

A native Telegram forward needs the source message to still exist. Point a scheduler at a channel that deletes its posts a second after publishing them and you get nothing: by the time the forward is issued, there is nothing to forward.

tgfwd copies the message into memory the instant the update arrives — before filtering, before routing, before any network call — and only then decides what to do with it. Deleting the source afterwards cannot take it back.

If the forward fails, delivery walks down a ladder instead of giving up:

What it does Cost Survives
Forward native forward, keeps "Forwarded from" 1 request
Copy re-sends as your own message, reusing the original media 1 request the source being deleted, and channels that forbid forwarding
Rehost re-sends using bytes captured locally uploads the file reference expiring

The detail that makes this practical: Copy does not re-upload anything. It reuses the file reference Telegram already handed over, so falling back costs the same as a forward. That is why auto — keep attribution when you can, fall back instantly when you cannot — is the default.

Messages that only arrived because of a fallback are counted separately as rescued, so you can see how often it actually mattered.

Requirements

That is the whole list. No database, no C toolchain, no native dependencies and no Rust toolchain: tgfwd is a single binary and its session is one JSON file. Only building it yourself needs rustup, and that is pinned so a fresh clone compiles with no further setup.

Install

macOS and Linux

curl -LsSf https://github.com/awdr74100/telegram-forwarder/releases/latest/download/telegram-forwarder-installer.sh | sh

Windows

powershell -ExecutionPolicy Bypass -c "irm https://github.com/awdr74100/telegram-forwarder/releases/latest/download/telegram-forwarder-installer.ps1 | iex"

Both fetch a prebuilt binary for your platform, check it against the published checksum, and put tgfwd on your PATH. Updating is the same command — there is no separate updater to keep track of.

Prefer to do it yourself? Every release carries an archive per platform, each with its own .sha256, on the releases page.

Shell completion is printed on demand rather than installed for you, so it goes wherever your shell already looks:

tgfwd completions zsh  > "${fpath[1]}/_tgfwd"
tgfwd completions bash > /etc/bash_completion.d/tgfwd
tgfwd completions fish > ~/.config/fish/completions/tgfwd.fish

elvish and powershell work the same way.

Building from source

Not required to use this — only to work on it.

git clone https://github.com/awdr74100/telegram-forwarder
cd telegram-forwarder
cargo build --release
./target/release/tgfwd --help

This crate is deliberately not published to crates.io: cargo install would ask for a Rust toolchain and a few minutes of compiling to deliver a binary the installer above hands over in seconds.

Quick start

tgfwd login          # API key walkthrough, then phone + code
tgfwd route add      # pick sources and targets from a searchable list
tgfwd start          # go

You never type a chat ID. route add lists the chats your account is actually in, searchable by name, @username or ID, and flags the channels you do not have posting rights in before you pick them:

? Which chats should be watched?
❯ ◻ 📢 Tech Daily        @twtech      -1001234567890
  ◻ 📢 Breaking News     @news        -1009876543210
  ◻ 👥 Team channel      —            -1005555555555  (no post rights)

Titles appear exactly as Telegram holds them, in any script. That is the point: they are full of emoji and symbols nobody can retype from memory, and a mistyped ID fails silently at delivery time — the worst possible moment to find out.

Where the login details come from

Three separate things are involved, and it is worth knowing which is which:

1. An API key identifies the application

Telegram requires every third-party client to register. This key cannot be shipped inside a public binary, so you create your own:

  1. Open https://my.telegram.org/auth and sign in with your phone number.
  2. Choose API development tools.
  3. Create an application. Any name and description will do — nobody reviews it.
  4. Copy the api_id (a number) and api_hash (32 hex characters).

tgfwd login prints these steps and then prompts for both values, so you do not have to do this in advance. They are written to the config file, not compiled in.

These identify the app, not you. They are not a password, but they are yours and are not meant to be committed anywhere.

2. Your phone number and login code identify you

tgfwd login then asks for:

  • your phone number in international format, e.g. +886912345678;
  • the login code Telegram sends to your other signed-in devices (not SMS, if you have another device active);
  • your two-factor password, if you have one set. Your own hint is shown.

A mistyped code or password is re-asked up to three times against the same request, because asking Telegram for a fresh code is rate-limited far more aggressively than retrying one.

3. The session file is what stays behind

On success, the resulting authorization key is stored so you never repeat the above. That file is a live credential: whoever copies it is signed into your account. It is written chmod 600, created with those permissions rather than tightened afterwards, and tgfwd logout revokes it server-side and deletes it.

Where your data lives

Nothing is stored in the project directory. Paths follow each platform's convention, which means they are not guessable — so ask the tool instead of memorising them:

tgfwd status                    # everything, in human form
tgfwd config path               # just the config path, for scripts
tgfwd config edit               # open it in $EDITOR, then re-check what you saved
$EDITOR "$(tgfwd config path)"  # quotes matter: the macOS path contains a space
What it is macOS Linux
config.toml your API key and routes ~/Library/Application Support/tgfwd/ ~/.config/tgfwd/
session.json the authorization key — a live credential ~/Library/Application Support/tgfwd/ ~/.local/share/tgfwd/
media cache snapshotted message bodies — private, but safe to delete ~/Library/Caches/tgfwd/ ~/.cache/tgfwd/

On Windows these resolve under %APPDATA% and %LOCALAPPDATA%. Rather than trust this table, run tgfwd status — it prints the real answer for your machine.

All three are written readable by you alone. The cache is the one that is easy to overlook: it holds the actual contents of messages from chats your account can see, for as long as snapshot.ttl allows, so it is treated as no more public than the credentials beside it.

Multiple accounts

TGFWD_HOME overrides all of it and lays one profile out under a single root, which is also the safe way to experiment without touching your real setup:

TGFWD_HOME=~/.tgfwd-work tgfwd login
TGFWD_HOME=~/.tgfwd-work tgfwd start

What a route is

A route is one rule: these source chats → these target chats, plus how to deliver and what to filter. Sources and targets are both lists, so one route can fan several channels into several destinations.

Each route gets a short name generated from its first source chat. You are never asked to invent one; it exists only so logs and shell scripts have something stable to refer to.

tgfwd route list      # see them
tgfwd route edit      # change sources, targets, delivery mode or filter
tgfwd route disable   # switch one off without deleting it
tgfwd route sync      # refresh the chat names stored in the config

edit, disable, enable and remove take no arguments: they show a picker listing what each route moves, not what it is called.

$ tgfwd route edit

? Which route?
❯ Breaking News → Team channel +1 more
  Market Alerts → Saved Messages       [disabled]

? Editing breaking-news — what would you like to change?
❯ Sources        (Breaking News)
  Targets        (Team channel +1 more)
  Delivery mode  (auto)
  Filter         (2 required, 1 blocked)

Editing a filter starts from the filter you already have — existing keywords come back in the input buffer, so nothing has to be retyped.

Naming a route explicitly, for scripts and cron
tgfwd route enable breaking-news    # no prompt, works unattended
tgfwd route list                    # shows the names

Filtering

Optional, per route. A message has to satisfy every condition to be forwarded.

Setting Effect
include drop the message unless it contains at least one of these
exclude drop the message if it contains any of these
kinds drop the message unless it is one of these kinds
require_media drop messages carrying no media
skip_forwarded drop messages that are themselves forwards

What include and exclude match against

The message text — and for a photo, video or file, that means its caption, since Telegram stores them in the same place. Nothing else is searched: not the sender, not the file name, not the contents of the file.

Matching is case-insensitive substring matching. There is no regex dialect and no word-boundary rule:

  • urgent matches URGENT, Urgently, and also insurgent
  • there is no word boundary to respect, which is also what makes it work unchanged for scripts that do not put spaces between words
  • exclude is checked first, so a message matching both is dropped

Two consequences worth knowing before you rely on them:

An include filter drops every post that has no caption. A photo posted with no text has nothing to match, so include = ["urgent"] on a photo channel keeps only the captioned photos. If you want media regardless of wording, use kinds or require_media instead.

For an album, all the captions are searched together. Telegram puts the caption on one member of the group, not necessarily the first, so the whole post is judged as a whole — a keyword on the third photo still counts, and a blocked word anywhere drops the entire group.

What skip_forwarded is for

It drops messages that already carry Telegram's "Forwarded from" header — posts the source chat did not write itself but relayed from somewhere else.

Two situations it exists for:

  • Aggregator channels. Many channels repost other channels. If you only want what this one actually publishes, this removes the relayed noise.
  • Avoiding duplicates. If you already mirror the original channel, its posts would otherwise reach your target twice — once from the original, once via whoever reposted it.

It says nothing about this tool's own deliveries. Those are recognised separately and are never treated as new source material, whether or not you set this.

Running it

tgfwd start              # forward until interrupted
tgfwd start --catch-up   # also process messages that arrived while it was stopped

Every delivery is announced as it happens, including the ones that went fine — silence would read exactly like a tool that is quietly broken:

12:04:31 ✔ delivered to Tech News   route=mirror via=forward took=812ms what=Markets open higher after…
12:04:33 ✔ rescued into Tech News   route=mirror via=copy    took=1.2s  what=photo message

via is the rung of the ladder that did the work, and rescued means the source was already gone by the time it was delivered. On exit you get the totals, the same numbers broken down per route, and how many rungs below the top were needed.

Stopping. Ctrl+C finishes in-flight deliveries first, which can take a moment if one is waiting out a server-issued rate limit. Press it a second time to leave immediately.

Exit codes — useful under systemd, launchd or a supervisor:

Code Meaning
0 stopped cleanly, including after one Ctrl+C
1 configuration or connection problem, explained on stderr
130 a second Ctrl+C, or a prompt cancelled with Esc

Note which side of that line Ctrl+C falls on: one press is a request to stop, it finishes what is in flight, and that is a clean exit. Only pressing it again — declining to wait — reports the interruption.

Losing the connection to Telegram is an error, not a clean stop, so a supervisor will restart instead of assuming all is well.

Configuration

tgfwd route add writes this for you, but it is plain TOML and meant to be read and edited by hand. tgfwd config edit opens it and re-validates on save, so a mistake is caught there instead of at the next start.

[telegram]
api_id = 1234567
api_hash = ""

[defaults]
mode = "auto"                     # auto | copy | forward

[defaults.snapshot]
enabled = true                    # download media as deletion insurance
max_bytes = 52428800              # skip files larger than this; 0 disables the limit
ttl = "1h"                        # how long snapshots stay on disk

[defaults.dispatch]
album_window = "400ms"            # how long to wait for an album's other parts
per_target_interval = "300ms"     # minimum gap per destination chat
max_attempts = 5                  # per delivery strategy
max_flood_wait = "5m"             # refuse server-requested waits longer than this
max_in_flight = 64                # concurrent deliveries across all routes

[[route]]
id = "news-mirror"
enabled = true
sources = [{ id = -1001234567890, title = "Breaking News" }]
targets = [
  { id = -1009876543210, title = "Team channel" },
  { id = -1005555555555, title = "Archive group" },
]

[route.filter]
include = ["urgent", "breaking"]
exclude = ["sponsored"]
require_media = false
skip_forwarded = false

Anything under [defaults] can be overridden per route. Chat IDs use the -100… form Telegram Desktop shows; titles are labels only, refreshed by tgfwd route sync.

Pacing is enforced per target chat, so fanning out to ten channels runs at full speed while one busy channel is throttled on its own. An album counts once, not once per photo.

Tuning album_window

Telegram sends the members of an album as separate updates and never signals that the last one has gone, so the only way to know a group is complete is that it stopped growing. This is how long to give it.

Waiting costs latency and never content — every part is captured before the timer starts, so a source deleted during the window is still delivered. Set it longer if you ever see one post arrive as two (a straggler that missed the window forms a group of its own); there is no reason to set it shorter.

0 turns grouping off, forwarding each member as its own message. It is the one value here that changes what the target receives rather than when.

Tuning per_target_interval

It only bites during a burst into a single chat — a channel posting a few times a minute never touches it. The trade it makes is deliberately lopsided: a few hundred milliseconds spent here costs exactly that, while earning a FLOOD_WAIT instead costs whatever Telegram decides, holds a delivery slot while it waits, and fails outright once the wait exceeds max_flood_wait.

The default is a conservative guess — Telegram does not publish the limits that apply to user accounts, and the numbers usually quoted are the Bot API's, which do not apply. So tune it by observation rather than by arithmetic: waiting out a rate limit in the log is the signal. If it appears at all, the interval is too short for your traffic. Setting it to 0 disables pacing altogether.

Loop protection

Forwarding chains can multiply messages without end, so two things prevent it:

  • Configuration is rejected if the routes form a cycle (A → B plus B → A).
  • At runtime, messages this tool produced are remembered and never treated as new source material, so A → B plus B → C does not re-forward your own delivery.

Why a user account

Only a user account can list the chats you are in and read channels you have merely joined; a bot cannot enumerate its own dialogs, which would force you back to pasting chat IDs. Two consequences worth stating plainly:

  • The session file is a live credential (see above).
  • Automating a user account is your responsibility under Telegram's terms. The defaults pace conservatively, but forwarding aggressively into many chats can still get an account limited.

Troubleshooting

Start here:

tgfwd doctor

It checks that the config parses, that the routes are consistent, that credentials are present, that a session exists, and that every configured chat is still reachable by this account — the failure people hit most, and the one that stays invisible until a delivery fails.

Symptom Likely cause
… is not reachable by this account the account left the chat, or the cache is stale — run tgfwd login to refresh it
Nothing is forwarded check the route is enabled (tgfwd route list) and the filter is not rejecting everything
Only some targets receive usually a permissions problem in that one chat; tgfwd doctor names it
Constantly rate-limited raise defaults.dispatch.per_target_interval

-v adds this tool's debug output; -vv adds the underlying Telegram stack. RUST_LOG overrides both if you want finer control.

Development

just            # list the recipes
just check      # everything CI checks, in one command
just fix        # rewrite formatting in place

CI runs the same recipes, so a clean just check is a clean pipeline. It covers formatting, clippy, the tests, spelling, unused dependencies, the workflow files, and that the generated release workflow is still in step with its config.

brew install just actionlint   # macOS; on Windows use `winget install --id Casey.Just`
just setup                     # the rest, as prebuilt binaries

actionlint is written in Go and is not a crate, so it comes from a package manager or its releases page; just setup fetches everything else. Installs land in the cargo bin directory, which the recipes put on PATH themselves — nothing to add to a shell profile, on any platform.

CI also builds for release on Linux, macOS and Windows: the session file's permissions and the config directory layout both differ on Windows, and building on Linux alone would exercise neither.

The toolchain is pinned in rust-toolchain.toml, so rustup installs the right version automatically on first build — there is no setup step. Clippy runs with pedantic enabled; the handful of exceptions live in Cargo.toml under [lints.clippy], each with a written reason. unsafe is forbidden crate-wide, including in tests.

Tests live beside the code in #[cfg(test)] mod tests and are named as sentences, e.g. a_deleted_source_degrades_to_the_snapshot.

To try changes against a real account without touching your own setup, point TGFWD_HOME somewhere disposable:

export TGFWD_HOME=/tmp/tgfwd-test
cargo run -- login
cargo run -- route add
cargo run -- start
rm -rf /tmp/tgfwd-test        # start over

AGENTS.md documents the architecture, the design rules that must not be broken, and the Telegram API traps that cost the most to discover.

Status

Working, and young. Everything documented here is implemented and covered by tests; it has been run against a real account, including the case it exists for — a post deleted seconds after it appeared, delivered from the local snapshot.

Known gaps, written down here so you do not have to find them yourself:

  • A dead network is invisible for up to fifteen minutes. Nothing is lost — the missed messages are replayed once the link returns — but until the underlying library gives up on its own, a silent connection and a quiet channel look identical. Closing that needs a heartbeat, which means adding periodic traffic on purpose.
  • Not on Homebrew, or anywhere but the GitHub Release.
  • Not tested against Telegram's rate limits at volume. The pacing defaults are a conservative guess, because Telegram does not publish the limits that apply to user accounts.

License

MIT © 2026-present Roya

About

Many-to-many Telegram forwarder. Route any number of chats into any number of others, each with its own filter and delivery mode. One self-hosted binary, no bot, no database.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages