fixes that were dropping messages: - d->tg sent parse_mode=Markdown with an unescaped body, so any message with a stray * _ ` or [ got a 400 from telegram and vanished. now html with proper escaping + a plain-text retry - no allowed_mentions on webhook posts, so telegram could @everyone - forwarder.start() ran from on_ready, which fires again on every gateway reconnect -> second poller on the same token -> 409s and dupes - document.mime_type is nullable and was called directly - sticker finally block hit UnboundLocalError when mkstemp raised - ffmpeg timeouts leaked temp files and orphaned the process stickers, per format instead of one blanket gif convert: - static .webp now passes through (the gif convert crushed 228 alpha levels to 2 and made the file 4x bigger) - video .webm -> animated webp, keeps 8-bit alpha, ~7x smaller than gif. needs -c:v libvpx-vp9 before -i or ffmpeg silently drops the alpha - .tgs is gzipped lottie, ffmpeg exits 183 on it and we were uploading the raw file. renders via optional lottie lib, else thumbnail + emoji added: telegram profile photos as webhook avatars (re-hosted in a private channel, AVATAR_CACHE_CHANNEL_ID), reply threading both ways, edit sync both ways, delete sync d->tg, album batching, voice/poll/location/contact/ dice/forward attribution, utf-16-safe entity conversion, mention and custom emoji resolution, message splitting instead of truncation, shared aiohttp session with 429 backoff, route discovery from env, !bridge status main.py just loads the extension now; routes come from credentials.env Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RXrxDFpyq3VjvxWGuW3CZe
209 lines
10 KiB
Markdown
209 lines
10 KiB
Markdown
# Telegram bridge — rebuild notes
|
||
|
||
Everything below is in `tgbridge/`. Routes, behaviour and env-var names stay
|
||
backwards compatible: **your existing `credentials.env` works unchanged.**
|
||
|
||
---
|
||
|
||
## 1. What you need to do
|
||
|
||
One optional step, for the avatars you asked for:
|
||
|
||
1. Make a channel in your server (e.g. `#tg-avatar-cache`) and deny
|
||
`@everyone` → *View Channel*.
|
||
2. Right-click it → **Copy Channel ID**.
|
||
3. Paste it into the `AVATAR_CACHE_CHANNEL_ID=` line already added at the
|
||
bottom of `credentials.env`.
|
||
|
||
Leave it empty and everything else still works — forwarded messages just keep
|
||
the default webhook avatar.
|
||
|
||
Optional: `pip install lottie` to make animated `.tgs` stickers actually
|
||
animate. Without it they fall back to a static thumbnail, which is still
|
||
better than the broken attachment they produce today.
|
||
|
||
`main.py` now loads the bridge as one extension:
|
||
|
||
```python
|
||
await bot.load_extension('tgbridge')
|
||
```
|
||
|
||
**Behavioural change worth knowing:** the Telegram poller now starts when the
|
||
extension loads, not from `on_ready`. That's what fixes the duplicate-poller
|
||
bug (A3), but it means startup happens before the Discord gateway connects. A
|
||
Telegram failure at that moment is caught and retried in the background (5
|
||
attempts, backing off 30 s → 10 min) — it will **not** stop the rest of the bot
|
||
from booting. Verified with a deliberately invalid token: the bot loaded fine,
|
||
both cogs registered, and `!bridge status` showed the error.
|
||
|
||
---
|
||
|
||
## 2. Bugs fixed
|
||
|
||
These were all live defects, not hypotheticals.
|
||
|
||
| # | Bug | Effect before |
|
||
|---|---|---|
|
||
| A1 | `parse_mode='Markdown'` with an unescaped body | **Every** Discord message containing a stray `*`, `_`, `` ` `` or `[` was rejected by Telegram with a 400 and silently dropped. Now HTML parse mode with proper escaping, plus a plain-text retry if Telegram still complains. |
|
||
| A2 | No `allowed_mentions` on webhook posts | Anyone in the Telegram group could type `@everyone` and ping your whole server. Now `{"parse": []}` on every post. |
|
||
| A3 | `forwarder.start()` called from `on_ready` | `on_ready` fires again after every gateway reconnect, starting a second poller on the same token → Telegram 409 Conflict and duplicated messages. Now starts once at extension load, with a `_running` guard. |
|
||
| A4 | `document.mime_type.startswith(...)` | `mime_type` is nullable; any document without one raised into the bare `except` and the message was dropped. |
|
||
| A5 | `temp_in_path` used in `finally`, assigned in `try` | If `NamedTemporaryFile` raised, cleanup threw `UnboundLocalError` and masked the real error. |
|
||
| A6 | ffmpeg cleanup after `wait_for`, no kill on timeout | A conversion timeout leaked the temp file **and** left an orphaned ffmpeg process forever. Now killed and reaped in every path. |
|
||
|
||
---
|
||
|
||
## 3. Stickers — measured, not guessed
|
||
|
||
Your old code ran all three Telegram sticker formats through one GIF
|
||
conversion. I tested each:
|
||
|
||
| Format | Before | Now |
|
||
|---|---|---|
|
||
| **Static `.webp`** | Re-encoded to GIF. Measured: the antialiased edge went from **228 alpha levels to 2**, i.e. a visible jagged halo on every sticker — and the file got **4× bigger** (942 B → 3771 B) and was downscaled 512→320. | Passed through untouched. Discord renders `.webp` natively with full alpha. Zero CPU. |
|
||
| **Animated `.tgs`** | ffmpeg exits **183 "Invalid data found"** (it's gzipped Lottie JSON, ffmpeg cannot read it). The `returncode == 0` check failed, so the raw `.tgs` was uploaded as an **unopenable junk attachment**. | Rendered via the optional `lottie` library; falls back to Telegram's own static thumbnail + emoji. |
|
||
| **Video `.webm`** | Converted to GIF, same alpha crushing. | Converted to **animated WebP** — loops in Discord, keeps 8-bit alpha, and measured **7.4× smaller** than the GIF (16 KB vs 122 KB). |
|
||
|
||
**One subtle bug worth calling out.** ffmpeg's *default* VP9 decoder silently
|
||
discards the alpha channel of VP9-in-WebM — every transparent pixel decodes to
|
||
opaque black. Only `libvpx-vp9`, selected **before** `-i`, reads it. Telegram
|
||
video stickers are exactly this format. Verified: without the flag, 1 alpha
|
||
level; with it, 217. This is now handled in `media.py:_decoder_args`.
|
||
|
||
Converted stickers are cached in sqlite by `file_unique_id`, so each one is
|
||
only ever converted once.
|
||
|
||
---
|
||
|
||
## 4. Profile-photo avatars
|
||
|
||
Telegram's only file URL embeds your bot token
|
||
(`api.telegram.org/file/bot<TOKEN>/...`), so it can never go in a webhook
|
||
payload. Instead the photo is re-uploaded once to your private cache channel
|
||
and that link is used as `avatar_url`.
|
||
|
||
I verified against the live API that **Discord re-hosts the image at post
|
||
time**: `author.avatar` comes back as a fresh hash distinct from the webhook's
|
||
own. So the source link only needs to be alive at the moment of the POST, and
|
||
already-forwarded messages keep their avatar forever.
|
||
|
||
Caching behaviour, all verified by test:
|
||
|
||
- repeat lookups cost **zero** API calls
|
||
- users with no photo (or privacy-hidden) are cached negatively, so it doesn't
|
||
re-ask on every message
|
||
- when the hosted link goes stale it re-uploads **from the stored blob**,
|
||
without re-downloading from Telegram
|
||
- when someone changes their photo it's detected and refreshed
|
||
- channel posts use the chat photo instead of a user photo
|
||
|
||
---
|
||
|
||
## 5. Features added
|
||
|
||
**Reply threading (both directions).** A Telegram reply renders as a Discord
|
||
quote block with a clickable jump link to the original forwarded message; a
|
||
Discord reply becomes a native Telegram reply. Backed by a message-ID map in
|
||
`data/tgbridge.db`.
|
||
|
||
**Edit sync (both directions).** Telegram `edited_message` updates patch the
|
||
Discord message; Discord edits patch the Telegram one. The Discord side uses
|
||
`on_raw_message_edit`, **not** `on_message_edit` — the cached variant never
|
||
fires for messages sent before the last restart, so edit sync would have
|
||
silently worked in testing and failed in production.
|
||
|
||
**Delete sync (Discord → Telegram only).** Including bulk deletes. The reverse
|
||
isn't possible: Telegram's Bot API gives bots no delete events at all.
|
||
|
||
**Albums.** Telegram sends an album as N separate updates, so a 5-photo post
|
||
used to become 5 separate Discord messages. Now debounced (1.5 s) into one
|
||
message with up to 10 attachments. All N Telegram IDs map to the single
|
||
Discord message, so a reply to *any* photo in the album still resolves.
|
||
|
||
**Message types that used to say `*(unsupported message type)*`:** voice notes
|
||
(with duration), video notes, audio (with artist/title), polls (question +
|
||
options), locations and venues (with a maps link), contacts, dice rolls,
|
||
join/leave notices, pins, and "forwarded from X" attribution.
|
||
|
||
**Rich text.** Telegram entities → Discord markdown and back: bold, italic,
|
||
underline, strikethrough, spoilers, inline code, code blocks with language,
|
||
links, and blockquotes.
|
||
|
||
> Telegram entity offsets are counted in **UTF-16 code units**, not Python
|
||
> characters, so any message containing an emoji shifts every following offset
|
||
> and naive slicing corrupts the formatting. The converter works on a UTF-16
|
||
> buffer and decodes once at the end. Verified with emoji before, inside and
|
||
> after formatted spans.
|
||
|
||
**Discord references resolved.** `<@123>`, `<@&123>`, `<#123>` and
|
||
`<:emoji:123>` used to forward to Telegram as raw snowflakes; they now become
|
||
readable names.
|
||
|
||
**Long messages split** instead of being truncated at 2000 chars (`content[:2000]`),
|
||
on the nicest available boundary. Same on the Telegram side at 4096.
|
||
|
||
**Rate limits and retries.** One shared `aiohttp` session instead of a new one
|
||
per message; 429s now wait the `retry_after` Discord asks for; 5xx retries with
|
||
exponential backoff.
|
||
|
||
**Oversized uploads** are detected and posted as a notice naming the file,
|
||
instead of vanishing.
|
||
|
||
**Markdown injection blocked.** Telegram text is escaped before it reaches
|
||
Discord, and Discord text is HTML-escaped before it reaches Telegram, so
|
||
neither side can inject formatting or HTML into the other.
|
||
|
||
**Route auto-discovery.** Any `TELEGRAM_GROUP_ID_<SUFFIX>` +
|
||
`DISCORD_WEBHOOK_URL_<SUFFIX>` + `DISCORD_CHANNEL_ID_<SUFFIX>` trio becomes a
|
||
route automatically — a third bridged channel is now an `.env` edit, not a code
|
||
change. `data/bridge_routes.json` can add or override routes. Your existing
|
||
`main` and `shitpost` routes were confirmed to load unchanged.
|
||
|
||
**`!bridge status`** — routes, uptime, forwarded counts, webhook success/failure,
|
||
cached avatars and stickers, whether Lottie is installed, and the last error.
|
||
|
||
**Explicit echo guard.** The bridge now checks the sender against its own bot
|
||
ID. Previously loops were avoided only incidentally, which would have bitten as
|
||
soon as edit sync or a second token was added.
|
||
|
||
---
|
||
|
||
## 6. Files
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `__init__.py` | extension entry point; wires and starts everything |
|
||
| `config.py` | route discovery from env + optional JSON |
|
||
| `store.py` | sqlite: message map, avatar cache, sticker cache |
|
||
| `entities.py` | Telegram entities ↔ Discord markdown ↔ Telegram HTML |
|
||
| `media.py` | ffmpeg / Lottie conversion, with process kill + cleanup |
|
||
| `avatars.py` | profile photos → Discord CDN |
|
||
| `webhook.py` | Discord webhook client: session, retries, splitting |
|
||
| `forwarder.py` | Telegram → Discord |
|
||
| `discord_to_telegram.py` | Discord → Telegram |
|
||
| `status_cog.py` | `!bridge status` |
|
||
|
||
New database at `data/tgbridge.db` (created automatically, `data/` is already
|
||
gitignored). Message mappings are pruned after 30 days.
|
||
|
||
---
|
||
|
||
## 7. What I could not test
|
||
|
||
I have no access to your Telegram group or a live Discord channel, so the
|
||
end-to-end path is **unverified against the real APIs**. What I did verify:
|
||
|
||
- every module imports, and `main.py` parses
|
||
- both directions exercised with mocked Telegram/Discord objects, covering
|
||
~25 message shapes (text, entities, photos, albums, replies, edits, echo
|
||
guard, null MIME types, polls, locations, dice, voice, splitting)
|
||
- ffmpeg conversions run on real generated media, with alpha fidelity measured
|
||
- avatar caching logic, including staleness and photo-change paths
|
||
- route discovery against your actual `credentials.env`
|
||
- the Discord avatar re-hosting question, against the live API (one test
|
||
message, deleted immediately afterwards)
|
||
|
||
Worth watching on first run: whether Telegram accepts the generated HTML for
|
||
unusual messages (there's a plain-text fallback if it doesn't), and whether the
|
||
bot has permission to delete messages in the Telegram group for delete sync.
|