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
This commit is contained in:
esefandClaude Opus 5 committed 2026-09-07 12:19:18 +03:00
1 parent f6ba74394f
commit eac6dbda3a
12 files changed
+2325 -307

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
#bridge routes, discovered from env instead of hardcoded in main.py##
"""Route configuration for the bridge.
Any TELEGRAM_GROUP_ID_<SUFFIX> + DISCORD_WEBHOOK_URL_<SUFFIX> +
DISCORD_CHANNEL_ID_<SUFFIX> trio becomes a route, so a new bridged channel is
an .env edit. The unsuffixed trio still works, as "main".
data/bridge_routes.json adds to or overrides them.
"""
######################################################################
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
ROUTES_FILE = Path(__file__).parent.parent / "data" / "bridge_routes.json"
_TG_PREFIX = "TELEGRAM_GROUP_ID"
_WH_PREFIX = "DISCORD_WEBHOOK_URL"
_CH_PREFIX = "DISCORD_CHANNEL_ID"
@dataclass
class Route:
name: str
tg_chat_id: int
dc_channel_id: int | None = None
webhook_url: str | None = None
@property
def tg_to_dc(self) -> bool:
return bool(self.webhook_url)
@property
def dc_to_tg(self) -> bool:
return bool(self.dc_channel_id)
def _int_or_none(value):
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _env_routes() -> dict[str, Route]:
routes: dict[str, Route] = {}
for key, value in os.environ.items():
if not key.startswith(_TG_PREFIX):
continue
suffix = key[len(_TG_PREFIX):] # "" or "_SHITPOST"
chat_id = _int_or_none(value)
if chat_id is None:
log.warning("%s is not a valid chat id, skipping", key)
continue
name = suffix.lstrip("_").lower() or "main"
routes[name] = Route(
name=name,
tg_chat_id=chat_id,
dc_channel_id=_int_or_none(os.getenv(_CH_PREFIX + suffix)),
webhook_url=os.getenv(_WH_PREFIX + suffix) or None,
)
return routes
def _file_routes() -> dict[str, Route]:
if not ROUTES_FILE.exists():
return {}
try:
raw = json.loads(ROUTES_FILE.read_text(encoding="utf-8"))
except Exception as exc:
log.error("could not read %s: %s", ROUTES_FILE, exc)
return {}
routes = {}
for index, entry in enumerate(raw or []):
chat_id = _int_or_none(entry.get("telegram_chat_id"))
if chat_id is None:
log.warning("route %d in %s has no telegram_chat_id", index, ROUTES_FILE)
continue
name = entry.get("name") or f"route{index}"
routes[name] = Route(
name=name,
tg_chat_id=chat_id,
dc_channel_id=_int_or_none(entry.get("discord_channel_id")),
webhook_url=entry.get("webhook_url") or None,
)
return routes
def load_routes() -> list[Route]:
"""Env routes first; the json file wins on a name clash."""
routes = _env_routes()
routes.update(_file_routes())
usable = []
for route in routes.values():
if not (route.tg_to_dc or route.dc_to_tg):
log.warning("route %r has neither a webhook nor a channel — ignored",
route.name)
continue
usable.append(route)
for route in usable:
directions = []
if route.tg_to_dc:
directions.append("tg->dc")
if route.dc_to_tg:
directions.append("dc->tg")
log.info("bridge route %r: chat %s <-> channel %s (%s)",
route.name, route.tg_chat_id, route.dc_channel_id,
", ".join(directions))
return usable
def avatar_channel_id() -> int | None:
return _int_or_none(os.getenv("AVATAR_CACHE_CHANNEL_ID"))