Forwards messages from one chat to many, each destination with its own filters and caption rules — configured from a Telegram Mini App.
Features · Commands · Mini App · Configuration · Running · Contributing
- One source, many destinations. Each destination is configured independently — its own filters, mode and caption rules.
- Copy or forward. Plus protected content, silent delivery and button removal.
- Filters that compose. Allow and block lists over keywords, patterns, media type or sender. Blocking always wins.
- Caption transforms. Prepend, append, find and replace, strip links or mentions.
- Formatting survives. Entity offsets are recomputed around every edit, not dropped.
- Albums stay albums. Grouped media arrives grouped, not split into separate messages.
- Linear-time patterns. Regex runs on RE2, so a user pattern cannot hang the bot.
- Native chat picker. Telegram's own chat list, so there are no ids to type.
- Self-service cloning. Telegram creates the bot, or paste a BotFather token. Tokens are never stored.
| Command | |
|---|---|
/set |
Pick source and destination from a list |
/set (source) (destination) |
Add a forward without the picker |
/rem (source) (destination) |
Remove one destination, or all with /rem (source) |
/get (source) |
List a source's destinations, or everything with /get |
/settings |
Open the Mini App |
/cancel |
Stop a half-finished /set |
/set_owner (user_id) |
Transfer ownership |
/help |
How it works |
/set, /get and /rem are owner-only.
Anywhere a chat is expected you can pass a chat id, an @username or a t.me
link — including a message link, which yields the chat it belongs to. Invite
links carry no id and cannot be used. /set_owner takes a numeric user id:
Telegram resolves a @username only for public chats, never for a person.
Reachable from the chat menu button, /settings or /help. Requires
WEBHOOK_HOST.
- Status — pause a destination without deleting it
- Delivery — copy or forward, protected, silent, remove buttons
- Filters — allow-only and never-forward, each on its own screen
- Caption — remove, prepend, append, find and replace, strip links or mentions
Rules are validated as you type, against the same schema the server saves with. Lookbehind and backreferences are unsupported by RE2 and rejected. Text that is matched against is trimmed, so an invisible trailing space cannot silently stop a rule matching. Prepended and appended text keeps its own newlines and goes on its own line, so a signature needs no blank line typed in front of it.
Only the owner can open it. Everyone else lands on a page for setting up their own bot, created by Telegram or from a BotFather token. Pasting the token of a bot already running here hands it back, which is how you recover a bot whose ownership you lost.
With it on, a stranger taps Create your own bot, names it, and Telegram creates it. Off, only the BotFather route is offered.
Enable it on the bot that does the creating; the bots it creates do not need it:
- Open BotFather and send
/mybots - Pick the bot → Bot Settings → Bot Management Mode → Enable
- Restart this instance — the flag is read at startup
Owners of a created bot also get Replace token and Delete setup under Settings → Owner, both acting through the bot that created it.
Telegram shows an Open button for bots with a Main Mini App. No Bot API method sets it, so each bot's owner does it once in BotFather:
/mybots → pick the bot → Bot Settings → Configure Mini App → Enable Mini App
Then give it https://<WEBHOOK_HOST>/app/settings?bot=<bot_id>. Keep the
?bot= — a Main Mini App launched from a profile carries no start parameter,
so the query string is what tells the page which bot it is configuring.
Copy .env.sample to .env.
| Variable | |
|---|---|
BOT_TOKEN |
Required. From BotFather |
DATABASE_URL |
Required. PostgreSQL connection string, the source of truth |
WEBHOOK_HOST |
Required. Public HTTPS URL of this server |
REDIS_URI |
Shares the cache between instances and across restarts. One long-running container does not need it — the in-process cache is faster |
CACHE_TTL_SECONDS |
Default 3600. Writes invalidate what they affect, so this only bounds staleness from direct SQL or unshared instances |
CACHE_MAX_ENTRIES |
In-process cache cap, default 10000 |
DIRECT_DATABASE_URL |
Non-pooled connection for migrations. Set it when DATABASE_URL points at a transaction pooler |
DATABASE_POOL_MAX |
Default 10 |
LOG_LEVEL |
Default debug, or info when NODE_ENV=production |
bun install
bun run build # Mini App, then the server bundle
bun startDevelopment: bun run dev for the server, bun run dev:web for the Mini App,
bun run verify for lint, typecheck and tests.
docker build -t telegram-forwarder-bot .
docker run -d --env-file .env -p 3000:3000 telegram-forwarder-botThe webhook needs a public HTTPS URL. Cloudflare Tunnel gives you one:
cloudflared tunnel --url http://localhost:3000Set WEBHOOK_HOST to the URL it prints.
Migrations use the drizzle-kit CLI, a dev dependency, so run them from a
checkout rather than the production image:
bun install
bun run db:migrateAfter editing src/db/schema.ts, regenerate with bun run db:generate and
apply. bun run db:studio opens a browser UI over the database.
Migrate before deploying the code that needs it, and roll back in the reverse order — revert the deploy, then the schema.
Upgrading from a Redis-backed version? Backfill once — it reads Redis, writes Postgres and deletes nothing:
REDIS_URI=<old-redis-uri> bun run migrate:redisChats imported this way have placeholder names until you tap Refresh chat names in the Mini App.
~ and | in a bot's name used to set protected content and caption stripping
for every route. They no longer do anything — both are per-destination settings,
set in the Mini App. Drop the characters from the name and turn the settings on
for the destinations that want them.
Pull requests welcome. Open an issue first for anything substantial.
GPL-3.0-or-later — see LICENSE.