viperadnan-git/telegram-forwarder-bot

Forward Telegram messages between chats using Telegram Bot API and Webhooks

★ 57Forks 67TypeScriptGitHub ↗Compare

Project website ↗

forward-messagestelegramtelegram-bottelegram-bot-apitelegram-botstelegram-copiertelegram-forward-bottelegram-forwardertelegram-message-forwardertelegram-messagestelegram-webhook

README

telegram-forwarder-bot

Forwards messages from one chat to many, each destination with its own filters and caption rules — configured from a Telegram Mini App.

Build Container image grammY Svelte Drizzle ORM Image size Repo size License


Features

  • 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.

Commands

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.


Mini App

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.

Bot Management Mode

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:

  1. Open BotFather and send /mybots
  2. Pick the bot → Bot Settings → Bot Management Mode → Enable
  3. 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.

Open button

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.


Configuration

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

Running

bun install
bun run build      # Mini App, then the server bundle
bun start

Development: bun run dev for the server, bun run dev:web for the Mini App, bun run verify for lint, typecheck and tests.

Docker

docker build -t telegram-forwarder-bot .
docker run -d --env-file .env -p 3000:3000 telegram-forwarder-bot

Behind a tunnel

The webhook needs a public HTTPS URL. Cloudflare Tunnel gives you one:

cloudflared tunnel --url http://localhost:3000

Set WEBHOOK_HOST to the URL it prints.


Database

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:migrate

After 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:redis

Chats imported this way have placeholder names until you tap Refresh chat names in the Mini App.

Upgrading from bot-name modifiers

~ 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.


Contributing

Pull requests welcome. Open an issue first for anything substantial.

License

GPL-3.0-or-later — see LICENSE.