Files
discordbot/tgbridge/forwarder.py
T
esefandClaude Opus 5 78c755a7a2 tgbridge: support a self-hosted bot api server, and say when a file is too big
The cloud api refuses getFile over 20 MB, so an oversize video or gif never
reached ffmpeg and the bridge blamed the fetch. Six of the nine media
failures in the last month were that; the other three were a dropped read.

- config.local_api() turns TELEGRAM_API_BASE_URL into base_url/base_file_url/
  local_mode. Both directions read it, or only one of them migrates.
- in local mode getFile hands back a path on disk and ptb reads it directly,
  so there is no 20 MB cap and no http transfer to drop.
- MAX_INGEST_BYTES keeps that from being a way to pull a gigabyte into the
  bot's memory; falls back to stat() when the server omits file_size.
- explicit read_timeout: ptb defaults to 5s, which is what "Timed out" was.
  one retry covers a dropped read. BadRequest subclasses NetworkError, so it
  is re-raised first or an oversize rejection gets re-sent.
- the note now distinguishes too-large from could-not-fetch, and the warning
  records which attachment and which message.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-31 13:36:11 +03:00

525 lines
22 KiB
Python

#this is where telegram-to-discord forwarding lives##
"""Telegram -> Discord forwarding."""
######################################################################
import asyncio
import io
import logging
import os
import time
from telegram.error import BadRequest, NetworkError
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
# ptb defaults read_timeout to 5s, which a slow transfer trips long before
# the ceilings above ever matter. kept under 24s so pool+connect+read stays
# inside the 30s get_file ceiling and ptb stays the one reporting the failure
READ_TIMEOUT = 20.0
# a local api server lifts telegram's 20 MB cap, but _download still buffers
# the whole file in memory — past this, post the note instead of eating the box
MAX_INGEST_BYTES = 200 * 1024 * 1024
REPLY_SNIPPET = 120
class TooLarge(Exception):
"""An attachment we will not pull into memory."""
def __init__(self, megabytes: float | None = None):
super().__init__("attachment too large to bridge")
self.megabytes = megabytes
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)
builder = Application.builder().token(self.token)
for name, value in config.local_api().items():
builder = getattr(builder, name)(value)
self._app = builder.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.
Retried once: a dropped read mid-transfer is the one failure here
nothing upstream recovers from. BadRequest subclasses NetworkError,
so it has to be ruled out first or a rejection gets re-sent.
"""
for attempt in (1, 2):
try:
tg_file = await asyncio.wait_for(
self._app.bot.get_file(file_id, read_timeout=READ_TIMEOUT),
timeout=30.0)
size = tg_file.file_size or 0
if not size and os.path.isabs(tg_file.file_path or ""):
# local mode hands back a path, not a url — if the
# server left file_size out, ask the filesystem
try:
size = os.path.getsize(tg_file.file_path)
except OSError:
size = 0
if size > MAX_INGEST_BYTES:
raise TooLarge(size / 1048576)
buf = io.BytesIO()
await asyncio.wait_for(
tg_file.download_to_memory(buf, read_timeout=READ_TIMEOUT),
timeout=DOWNLOAD_TIMEOUT)
except BadRequest as exc:
# only the cloud api refuses outright; a local server will
# hand the file over and MAX_INGEST_BYTES becomes the limit
if "too big" in str(exc).lower():
raise TooLarge() from exc
raise
except NetworkError as exc:
if attempt == 2:
raise
log.info("download retry after %s: %s",
type(exc).__name__, exc)
continue
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:
"""DEPRECATED: forwarded-origin note removed by request."""
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 TooLarge as exc:
what = _describe_media(message) or "attachment"
size = f" — {exc.megabytes:.0f} MB" if exc.megabytes else ""
log.info("%s in message %s too large to bridge%s",
what, message.message_id, size)
got, note = [], f"*(attachment too large to bridge{size})*"
except Exception as exc:
log.warning("media render failed for %s in message %s: %s: %s",
_describe_media(message) or "attachment",
message.message_id, type(exc).__name__, 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 = 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 ""