- Python 99.5%
- Dockerfile 0.5%
|
|
||
|---|---|---|
| .github/workflows | ||
| src/tgircbridge | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| config.example.yaml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| README_zh.md | ||
tgircbridge
A bidirectional Telegram ↔ IRC bridge: relay messages, media, and replies between Telegram groups and IRC channels with multi-channel mapping support.
Status: v1 ready — all 9 phases complete, 326 tests, 83% coverage.
Features
- Bidirectional: Telegram → IRC and IRC → Telegram on the same bridge.
- Multi-channel: map any number of TG groups to any number of IRC channels (1:1, 1:N, N:1, N:N).
- Media bridging: TG photos/videos/documents can be bridged to IRC via two
strategies:
- Upload mode (default): download media from Telegram, upload to a
pluggable image host (
0x0.storcatbox.moe), and post the link on IRC. - Proxy mode (opt-in): serve media directly through a local HTTP proxy that fetches from Telegram's File API, avoiding the download↔rehost cycle. See Telegram image proxy for setup. IRC image URLs are automatically detected, fetched, rehosted, and displayed as native TG photos — bidirectional image forwarding.
- Upload mode (default): download media from Telegram, upload to a
pluggable image host (
- Reply preservation: TG replies render as
<sender> > Bob: original ↩ bodyon IRC, and vice versa. - Nickname mapping: TG senders shown as
<first_name>on IRC (sanitized: ASCII fold, invalid-char strip, length cap, anti-collision); IRC nick shown verbatim on TG. Per-mappinganonymize: trueredacts senders touser_<id>. - CTCP /me actions translate between protocols.
- Edit awareness: TG message edits surface on IRC with a
*prefix. - Reliability: SASL PLAIN auth, NickServ fallback, exponential-backoff
reconnect, FloodWait retry, per-channel rate limiting, three-layer loop
protection (
via_botfilter + own-nick filter + 60-second TTL dedup). - Production-grade: structured JSON logs, Docker image with non-root user and heartbeat-file healthcheck, GitHub Actions CI on Python 3.12 / 3.13.
Quick start (Docker)
-
Create a Telegram bot via @BotFather and disable privacy mode (
/setprivacy → Disable). -
Copy
config.example.yamltoconfig.yamland edit channel mappings. -
Copy
.env.exampleto.envand fill inTG_BOT_TOKEN(and IRC SASL creds if your network supports SASL). -
Start:
docker compose up -d docker compose logs -f tgircbridge
Local development
Requires Python 3.12+.
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pytest # run tests with coverage gate
ruff check . && ruff format --check .
mypy src
Configuration
See config.example.yaml. The YAML expands
${ENV_VAR} and ${ENV_VAR:default} references against the process
environment, so you can keep secrets out of the file:
telegram:
token: ${TG_BOT_TOKEN} # fails to start if env var is unset
SSL certificate verification
By default the bridge uses certifi (Mozilla CA bundle) to verify Telegram API
TLS certificates. If you operate behind a corporate proxy or MITM appliance that
terminates TLS with a custom CA, set ssl_ca_bundle to point to your internal
CA certificate:
telegram:
token: ${TG_BOT_TOKEN}
ssl_ca_bundle: /path/to/your-ca-cert.pem # custom CA bundle
If ssl_ca_bundle is omitted (the default), certifi is used. If verification
fails with a certificate or hostname mismatch error, the bridge stops immediately
rather than retrying indefinitely — this is a non-transient error that retry
cannot fix.
Docker users: If your container lacks CA certs or your network intercepts TLS, mount a CA bundle into the container with a volume and set
ssl_ca_bundleaccordingly. The bridge validates the file at startup.
Finding telegram_chat_id
Add the bot to your group, send any message, then call:
https://api.telegram.org/bot<TOKEN>/getUpdates
Look for chat.id (groups are negative integers, super-groups start with
-100).
Sender names for Telegram channels
Telegram channels broadcast as the channel itself, not as the human who
posted. The Bot API does not expose individual posters in unsigned channel
posts, so messages forwarded to IRC will appear as <Channel Title>
unless the channel has Sign messages enabled (Channel settings →
Administrators → toggle "Sign messages"). When signing is on, the
bridge surfaces message.author_signature and IRC will show
<Alice> instead of <Channel Title>.
For per-user attribution without channel-wide signing, use a Telegram
group (or supergroup) instead of a channel — group messages always
include from_user.
Telegram image proxy
By default, the bridge downloads Telegram media to memory, uploads it to a third-party image host (0x0.st / catbox), and posts the resulting link on IRC. This works but adds latency and depends on an external service.
Proxy mode eliminates the download↔rehost round-trip — it serves files directly from Telegram's CDN through a local HTTP proxy, then embeds the proxy URL on IRC. The bot token never appears in public URLs or IRC logs.
Configuration
Uncomment and fill in the tg_media_proxy block in config.yaml:
media:
backend: zeroxzero
max_size_mb: 50
sticker_strategy: emoji_fallback
rehost_irc_urls: true
rehost_telegram_text_urls: false
url_fetch_max_size_mb: 10
url_fetch_timeout_sec: 15.0
tg_media_proxy:
base_url: "https://irc.example.com" # public-facing domain
listen_host: "127.0.0.1" # proxy bind address
listen_port: 9800 # proxy bind port
When tg_media_proxy is present, the bridge starts a lightweight HTTP server
on listen_host:listen_port and constructs URLs in the form
<base_url>/tgfile/<telegram_file_path>. IRC users click a link that points
back to your proxy, which streams the file from api.telegram.org without
ever storing it locally.
When the block is omitted or empty, the bridge falls back to upload mode — no code changes needed, zero regression risk.
Reverse proxy setup
The proxy listens on localhost by default and must be fronted by nginx or Caddy for TLS termination:
# nginx snippet
location /tgfile/ {
proxy_pass http://127.0.0.1:9800;
proxy_set_header Host $host;
proxy_buffering off; # streaming
proxy_cache_valid 200 1d; # Telegram file IDs are stable
}
# Caddyfile
irc.example.com {
reverse_proxy /tgfile/* 127.0.0.1:9800
}
Large deployments should set proxy_buffering off (nginx) so the proxy
streams chunks to IRC clients immediately instead of buffering the entire
file in memory per request.
The Lounge configuration
The Lounge performs link prefetching to generate
message previews. The default 5-second timeout may be too short for proxied
Telegram media, causing previews to fail. Raise it to 15 seconds in the
The Lounge config.js:
// config.js — under the defaults or per-network block
prefetchTimeout: 15000 // default is 5000
Project layout
src/tgircbridge/
├── __main__.py Module entry point
├── cli.py argparse, signal-aware run loop, build_application()
├── config.py Pydantic schema + ${ENV} expansion
├── logging.py structlog (JSON / console)
├── types.py BridgeEvent — protocol-agnostic event
├── adapters/
│ ├── _telegram_format.py Pure helpers (sender, reply, message-id, render)
│ ├── _irc_format.py UTF-8-safe chunking, nick sanitization, render
│ ├── telegram.py TelegramAdapter (aiogram 3)
│ └── irc.py IrcAdapter (bottom-py 3)
├── core/
│ ├── ttlset.py Time + size bounded set for dedup
│ ├── router.py Bidirectional channel index
│ └── bridge.py BridgeCore — fan-out + dedup + render
└── media/
├── uploader.py MediaUploader Protocol + NoOpUploader
├── zeroxzero.py 0x0.st backend
├── catbox.py catbox.moe backend
├── factory.py build_uploader(MediaConfig) → MediaUploader
├── proxy_server.py Local HTTP proxy for Telegram File API
├── url_extractor.py URL extraction from text with image detection
└── url_fetcher.py SSRF-safe HTTP fetcher with size/type guards
Roadmap
- Phase 1 — Scaffolding ✅
- Phase 2 — Telegram adapter (aiogram 3) ✅
- Phase 3 — IRC adapter (bottom-py) ✅
- Phase 4 — Bridge core: routing, formatting, dedup ✅
- Phase 5 — Media upload backends (0x0.st, catbox.moe) ✅
- Phase 6 — Reliability: reconnect, rate-limit, graceful shutdown ✅
- Phase 7 — Test suite (≥80% coverage, 326 tests) ✅
- Phase 8 — Docker hardening, CI, composition root ✅
- Phase 9 — Bidirectional image forwarding (URL rehost) ✅
Possible follow-ups (not yet implemented)
- SASL ACK state machine — replace the current sequential SASL flow with
one that waits for
CAP * ACK :saslandRPL_SASLSUCCESS(903) before proceeding. Works fine against Libera / OFTC today, but a stricter server could reject mid-handshake. - Sticker .webp → PNG conversion —
png_convertstrategy is available; animated stickers (.tgs) are always rendered to PNG and forwarded regardless ofsticker_strategy. Static sticker conversion behind the opt-in. - Self-hosted media (S3 / MinIO) — Protocol shape supports it; only the factory needs an extra branch.
- History catch-up on reconnect — currently messages received while the bridge is down are not replayed.
- Per-mime size caps beyond the global
media.max_size_mb.
License
Acknowledgements
Design influenced by:
RITlug/teleirc— Go reference for config schema and Docker workflow.26000/irchuu— Go reference for the feature matrix (SASL, markup preservation, replies).sfan5/pytgbridge— Python reference for module organization.42wim/matterbridge— multi-protocol abstraction patterns.