Files
discordbot/tgbridge/forwarder.py
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

485 lines
20 KiB
Python

#this is where telegram-to-discord forwarding lives##
"""Telegram -> Discord forwarding."""
######################################################################
import asyncio
import io
import logging
import time
from telegram.ext import Application, MessageHandler, filters
from . import config, entities, media, store
from .avatars import AvatarCache
from .webhook import WebhookClient
log = logging.getLogger(__name__)
# an album arrives as several updates; wait this long after the last one
ALBUM_DEBOUNCE = 1.5
ALBUM_MAX_FILES = 10
DOWNLOAD_TIMEOUT = 90.0
REPLY_SNIPPET = 120
class TelegramForwarder:
def __init__(self, token, discord_bot=None, avatar_channel_id=None,
routes=None, drop_pending=True):
self.token = token
self.bot = discord_bot
self.drop_pending = drop_pending
# chat_id -> Route
self.routes: dict[int, config.Route] = {}
for route in routes or []:
if route.tg_to_dc:
self.routes[route.tg_chat_id] = route
self.webhook = WebhookClient()
self.avatars = AvatarCache(discord_bot, avatar_channel_id)
self._app = None
self._running = False
self._albums: dict[str, dict] = {}
self.started_at = None
self.forwarded = 0
self.last_error = None
# ── lifecycle ───────────────────────────────────────────────────────────
def add_route(self, group_id, webhook_url):
"""Back-compatible single-route registration."""
existing = self.routes.get(int(group_id))
if existing:
existing.webhook_url = webhook_url
else:
self.routes[int(group_id)] = config.Route(
name=str(group_id), tg_chat_id=int(group_id),
webhook_url=webhook_url)
async def start(self):
# on_ready fires again on every reconnect; a second poller on the same
# token means telegram 409s and duplicated posts
if self._running:
log.debug("telegram forwarder already running — start() ignored")
return
self._running = True
try:
await store.init_db()
removed = await store.prune()
if removed:
log.info("pruned %d expired message mappings", removed)
self._app = Application.builder().token(self.token).build()
self._app.add_handler(MessageHandler(filters.ALL, self._on_update))
self._app.add_error_handler(self._on_error)
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(
drop_pending_updates=self.drop_pending)
self.avatars.bind_telegram(self._app.bot)
self.started_at = time.time()
log.info("telegram forwarder started — watching %d chat(s), "
"avatars %s", len(self.routes),
"on" if self.avatars.enabled else "off")
except Exception:
self._running = False
raise
async def stop(self):
self._running = False
for pending in list(self._albums.values()):
task = pending.get("task")
if task:
task.cancel()
self._albums.clear()
if self._app:
try:
if self._app.updater.running:
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
except Exception as exc:
log.warning("error during telegram shutdown: %s", exc)
self._app = None
await self.webhook.close()
log.info("telegram forwarder stopped")
async def _on_error(self, update, context):
self.last_error = str(context.error)
log.error("telegram update failed: %s", context.error)
# ── naming ──────────────────────────────────────────────────────────────
def _sender_name(self, message) -> str:
user = getattr(message, "from_user", None)
if user:
name = (user.first_name or "").strip()
if user.last_name:
name = f"{name} {user.last_name}".strip()
return name or user.username or "unknown"
chat = getattr(message, "sender_chat", None) or getattr(message, "chat", None)
if chat and getattr(chat, "title", None):
return chat.title
return "unknown"
# ── downloads ───────────────────────────────────────────────────────────
async def _download(self, file_id: str):
"""-> (bytes, filename). Raises on failure."""
tg_file = await asyncio.wait_for(
self._app.bot.get_file(file_id), timeout=30.0)
buf = io.BytesIO()
await asyncio.wait_for(
tg_file.download_to_memory(buf), timeout=DOWNLOAD_TIMEOUT)
path = tg_file.file_path or ""
return buf.getvalue(), (path.split("/")[-1] or "file")
# ── context lines ───────────────────────────────────────────────────────
async def _reply_line(self, message, route) -> str:
"""Render a quote of whatever this message is replying to."""
replied = getattr(message, "reply_to_message", None)
if not replied:
return ""
try:
who = self._sender_name(replied)
snippet = (replied.text or replied.caption or "").strip()
if not snippet:
snippet = _describe_media(replied)
snippet = " ".join(snippet.split())
if len(snippet) > REPLY_SNIPPET:
snippet = snippet[:REPLY_SNIPPET].rstrip() + "…"
snippet = entities.escape_discord(snippet)
who = entities.escape_discord(who)
link = await self._jump_link(route, replied.message_id)
arrow = f"[↩ reply to **{who}**]({link})" if link else f"↩ reply to **{who}**"
return f"> {arrow}: {snippet}\n" if snippet else f"> {arrow}\n"
except Exception as exc:
log.debug("could not render reply context: %s", exc)
return ""
async def _jump_link(self, route, tg_msg_id: int):
"""Link to the Discord copy of a Telegram message, if we forwarded it."""
if not self.bot:
return None
try:
mapped = await store.dc_for_tg(route.tg_chat_id, tg_msg_id)
if not mapped:
return None
channel_id, message_id = mapped
channel = self.bot.get_channel(channel_id)
guild_id = channel.guild.id if channel and channel.guild else None
if not guild_id:
return None
return (f"https://discord.com/channels/"
f"{guild_id}/{channel_id}/{message_id}")
except Exception:
return None
def _forward_line(self, message) -> str:
"""Note where a forwarded message originally came from."""
try:
origin = getattr(message, "forward_origin", None)
source = None
if origin is not None:
otype = getattr(origin, "type", "")
if otype == "user" and getattr(origin, "sender_user", None):
user = origin.sender_user
source = (user.first_name or "") + (
f" {user.last_name}" if user.last_name else "")
elif otype == "hidden_user":
source = getattr(origin, "sender_user_name", None)
elif otype == "chat" and getattr(origin, "sender_chat", None):
source = origin.sender_chat.title
elif otype == "channel" and getattr(origin, "chat", None):
source = origin.chat.title
if not source:
return ""
return f"-# ↪ forwarded from {entities.escape_discord(source.strip())}\n"
except Exception:
return ""
# ── rendering ───────────────────────────────────────────────────────────
async def _render_text(self, message) -> str:
body = message.text or message.caption or ""
ents = message.entities if message.text else message.caption_entities
return entities.to_discord_markdown(body, ents)
async def _render_sticker(self, message):
"""-> (files, extra_text), per sticker format."""
sticker = message.sticker
emoji = sticker.emoji or ""
cached = await store.get_media(sticker.file_unique_id)
if cached:
return [cached], emoji
data, filename = await self._download(sticker.file_id)
stem = filename.rsplit(".", 1)[0] or "sticker"
if sticker.is_video:
# .webm vp9 with alpha -> animated webp keeps the transparency
converted = await media.to_animated_webp(data, ".webm")
if converted:
out = (f"{stem}.webp", converted)
await store.put_media(sticker.file_unique_id, *out)
return [out], emoji
return [(filename, data)], emoji
if sticker.is_animated:
# .tgs is gzipped lottie, ffmpeg cannot read it
rendered = await media.render_tgs(data)
if rendered:
out = (f"{stem}.gif", rendered)
await store.put_media(sticker.file_unique_id, *out)
return [out], emoji
thumb = getattr(sticker, "thumbnail", None)
if thumb:
try:
tdata, tname = await self._download(thumb.file_id)
return [(tname, tdata)], emoji
except Exception as exc:
log.debug("sticker thumbnail failed: %s", exc)
return [], f"{emoji} *(animated sticker)*".strip()
# static .webp renders natively with full alpha; converting to gif
# only destroyed the antialiased edges
return [(filename, data)], emoji
async def _render_media(self, message):
"""-> (files, extra_text) for whatever media this message carries."""
if message.sticker:
return await self._render_sticker(message)
if message.photo:
data, filename = await self._download(message.photo[-1].file_id)
return [(filename, data)], ""
animation = getattr(message, "animation", None)
document = getattr(message, "document", None)
# mime_type is nullable
doc_mime = (getattr(document, "mime_type", None) or "") if document else ""
if animation or message.video or doc_mime.startswith("video"):
if animation:
file_id, is_animation = animation.file_id, True
elif message.video:
file_id, is_animation = message.video.file_id, False
else:
file_id, is_animation = document.file_id, False
data, filename = await self._download(file_id)
suffix = "." + filename.rsplit(".", 1)[-1] if "." in filename else ".mp4"
# animations never have audio; elsewhere a silent clip is a gif in
# spirit and should loop, one with sound is a real video
convert = is_animation or not await media.has_audio_track(data, suffix)
if convert:
gif = await media.to_gif(data, suffix)
if gif:
return [(filename.rsplit(".", 1)[0] + ".gif", gif)], ""
return [(filename, data)], ""
if document:
data, filename = await self._download(document.file_id)
return [(filename, data)], ""
if message.voice:
data, filename = await self._download(message.voice.file_id)
return [(filename, data)], f"-# 🎤 voice message ({_duration(message.voice)})"
if message.video_note:
data, filename = await self._download(message.video_note.file_id)
return [(filename, data)], f"-# 📹 video note ({_duration(message.video_note)})"
if message.audio:
audio = message.audio
data, filename = await self._download(audio.file_id)
label = " — ".join(x for x in (audio.performer, audio.title) if x)
return [(filename, data)], f"-# 🎵 {label}" if label else ""
return [], _describe_nonmedia(message)
# ── dispatch ────────────────────────────────────────────────────────────
async def _on_update(self, update, context):
message = update.effective_message
if not message:
return
chat = update.effective_chat
route = self.routes.get(chat.id) if chat else None
if route is None:
return
# echo guard: never re-forward what the bridge itself posted
me = getattr(self._app.bot, "id", None)
sender = getattr(message, "from_user", None)
if me and sender and sender.id == me:
return
is_edit = bool(update.edited_message or update.edited_channel_post)
try:
if is_edit:
await self._handle_edit(route, message)
elif message.media_group_id:
self._buffer_album(route, message)
else:
await self._forward(route, [message])
except Exception as exc:
self.last_error = f"{type(exc).__name__}: {exc}"
log.exception("failed to forward message from %s",
self._sender_name(message))
def _buffer_album(self, route, message):
key = f"{route.tg_chat_id}:{message.media_group_id}"
pending = self._albums.get(key)
if pending is None:
pending = self._albums[key] = {"messages": [], "task": None,
"route": route}
pending["messages"].append(message)
if pending["task"]:
pending["task"].cancel()
pending["task"] = asyncio.create_task(self._flush_album(key))
async def _flush_album(self, key):
try:
await asyncio.sleep(ALBUM_DEBOUNCE)
except asyncio.CancelledError:
return
pending = self._albums.pop(key, None)
if not pending or not pending["messages"]:
return
messages = sorted(pending["messages"], key=lambda m: m.message_id)
try:
await self._forward(pending["route"], messages)
except Exception as exc:
self.last_error = f"{type(exc).__name__}: {exc}"
log.exception("failed to forward album")
async def _forward(self, route, messages):
"""Post one or more Telegram messages as a single Discord message."""
lead = messages[0]
sender = self._sender_name(lead)
files, notes = [], []
for message in messages:
try:
got, note = await self._render_media(message)
except Exception as exc:
log.warning("media render failed: %s", exc)
got, note = [], "*(attachment could not be fetched)*"
files.extend(got)
if note:
notes.append(note)
body_parts = []
for message in messages:
rendered = await self._render_text(message)
if rendered:
body_parts.append(rendered)
prefix = self._forward_line(lead) + await self._reply_line(lead, route)
content = prefix + "\n".join(body_parts + notes).strip()
if not content.strip() and not files:
return
avatar_url = None
try:
if getattr(lead, "from_user", None):
avatar_url = await self.avatars.url_for_user(lead.from_user)
else:
avatar_url = await self.avatars.url_for_chat(
getattr(lead, "sender_chat", None) or lead.chat)
except Exception as exc:
log.debug("avatar lookup failed: %s", exc)
result = await self.webhook.send(
route.webhook_url, sender, content,
files=files[:ALBUM_MAX_FILES] or None, avatar_url=avatar_url)
self.forwarded += 1
if result and result.get("id"):
# every telegram id in an album maps to the one discord post, so
# a reply to any photo in it resolves
await store.link(route.tg_chat_id,
[m.message_id for m in messages],
int(result["channel_id"]), int(result["id"]), "tg")
async def _handle_edit(self, route, message):
mapped = await store.dc_for_tg(route.tg_chat_id, message.message_id)
if not mapped:
log.debug("edit for unmapped message %s — ignoring", message.message_id)
return
_, dc_msg_id = mapped
content = await self._render_text(message)
if not content:
return
await self.webhook.edit(route.webhook_url, dc_msg_id,
content + "\n-# *(edited)*")
# ── small helpers ───────────────────────────────────────────────────────────
def _duration(obj) -> str:
seconds = getattr(obj, "duration", 0) or 0
return f"{seconds // 60}:{seconds % 60:02d}"
def _describe_media(message) -> str:
"""Short label for a message quoted in a reply."""
for attr, label in (("photo", "photo"), ("sticker", "sticker"),
("animation", "GIF"), ("video", "video"),
("voice", "voice message"), ("video_note", "video note"),
("audio", "audio"), ("document", "file")):
if getattr(message, attr, None):
return f"({label})"
return ""
def _describe_nonmedia(message) -> str:
"""The message types that carry no file at all."""
poll = getattr(message, "poll", None)
if poll:
options = "\n".join(f"• {entities.escape_discord(o.text)}"
for o in poll.options)
return f"📊 **{entities.escape_discord(poll.question)}**\n{options}"
location = getattr(message, "location", None)
venue = getattr(message, "venue", None)
if venue:
return (f"📍 **{entities.escape_discord(venue.title)}**\n"
f"{entities.escape_discord(venue.address)}\n"
f"<https://www.google.com/maps?q="
f"{venue.location.latitude},{venue.location.longitude}>")
if location:
return (f"📍 location: <https://www.google.com/maps?q="
f"{location.latitude},{location.longitude}>")
contact = getattr(message, "contact", None)
if contact:
name = " ".join(x for x in (contact.first_name, contact.last_name) if x)
return (f"👤 **{entities.escape_discord(name)}**\n"
f"{entities.escape_discord(contact.phone_number or '')}")
dice = getattr(message, "dice", None)
if dice:
return f"{dice.emoji} rolled **{dice.value}**"
if getattr(message, "new_chat_members", None):
names = ", ".join(entities.escape_discord(
u.first_name or u.username or "someone")
for u in message.new_chat_members)
return f"-# → {names} joined the group"
left = getattr(message, "left_chat_member", None)
if left:
name = entities.escape_discord(left.first_name or left.username or "someone")
return f"-# ← {name} left the group"
if getattr(message, "pinned_message", None):
return "-# 📌 pinned a message"
return ""