Files
discordbot/TGBRIDGE.md
T
esefandClaude Opus 5 eac6dbda3a tgbridge: rework both directions — avatars, stickers, replies, edits
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
2026-09-07 12:19:18 +03:00

209 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.