A .gif uploaded as a document never matches the animation branch, so it went to discord untouched and came back 413. First real test of the local api server hit exactly this: a 27.2 MB gif fetched fine, then bounced. Same ladder, same .gif out — that file lands at 5.73 MB. Co-Authored-By: Claude Opus 5 <[email protected]>
533 lines
22 KiB
Python
533 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)
|
|
# a gif sent as a file never reaches the animation branch, so it
|
|
# went to discord at full size — same ladder, same .gif out
|
|
is_gif = (doc_mime == "image/gif"
|
|
or filename.lower().endswith(".gif"))
|
|
if is_gif and len(data) > media.MAX_CONVERTED_BYTES:
|
|
smaller = await media.to_gif(data, ".gif")
|
|
if smaller:
|
|
return [(filename, smaller)], ""
|
|
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 ""
|