No description
  • Python 99.5%
  • Dockerfile 0.5%
Find a file
Lumorian 0c59ec0c61
Some checks failed
CI / lint-typecheck-test (3.12) (push) Has been cancelled
CI / lint-typecheck-test (3.13) (push) Has been cancelled
CI / docker-build (push) Has been cancelled
docs: update test count and sticker status in README_zh
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:31:19 +08:00
.github/workflows feat: phase 6+8 composition root, lifecycle, healthcheck, ci 2026-05-04 17:17:43 +08:00
src/tgircbridge fix: forward animated stickers as media regardless of sticker_strategy 2026-05-27 03:25:47 +08:00
tests test: add reproducer for animated sticker not forwarding with emoji_fallback 2026-05-27 03:23:27 +08:00
.dockerignore feat: phase 1 scaffolding for telegram <-> irc bridge 2026-05-04 12:11:11 +08:00
.env.example feat: phase 1 scaffolding for telegram <-> irc bridge 2026-05-04 12:11:11 +08:00
.gitignore feat: phase 1 scaffolding for telegram <-> irc bridge 2026-05-04 12:11:11 +08:00
config.example.yaml feat: telegram media proxy for image forwarding to irc 2026-05-05 23:21:48 +08:00
docker-compose.yml fix: improve Telegram polling resilience and add startup diagnostics 2026-05-07 16:58:46 +08:00
Dockerfile feat: phase 6+8 composition root, lifecycle, healthcheck, ci 2026-05-04 17:17:43 +08:00
LICENSE feat: phase 1 scaffolding for telegram <-> irc bridge 2026-05-04 12:11:11 +08:00
pyproject.toml feat: remove edit awareness, add sticker PNG conversion, and add history replay on reconnect 2026-05-10 20:55:02 +08:00
README.md docs: update test count and sticker status in README 2026-05-27 10:27:53 +08:00
README_zh.md docs: update test count and sticker status in README_zh 2026-05-27 10:31:19 +08:00

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.st or catbox.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.
  • Reply preservation: TG replies render as <sender> > Bob: original ↩ body on 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-mapping anonymize: true redacts senders to user_<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_bot filter + 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)

  1. Create a Telegram bot via @BotFather and disable privacy mode (/setprivacy → Disable).

  2. Copy config.example.yaml to config.yaml and edit channel mappings.

  3. Copy .env.example to .env and fill in TG_BOT_TOKEN (and IRC SASL creds if your network supports SASL).

  4. 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_bundle accordingly. 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 :sasl and RPL_SASLSUCCESS (903) before proceeding. Works fine against Libera / OFTC today, but a stricter server could reject mid-handshake.
  • Sticker .webp → PNG conversionpng_convert strategy is available; animated stickers (.tgs) are always rendered to PNG and forwarded regardless of sticker_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

MIT

Acknowledgements

Design influenced by:

Built on aiogram and bottom.