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:
1 parent
f6ba74394f
commit
eac6dbda3a
12 files changed
+2325
-307
No files matched your search
+448
-225
@@ -1,261 +1,484 @@
|
||||
# this is where telegram-to-discord forwarding lives
|
||||
import io
|
||||
import json
|
||||
import aiohttp
|
||||
from telegram.ext import Application, MessageHandler, filters
|
||||
#this is where telegram-to-discord forwarding lives##
|
||||
"""Telegram -> Discord forwarding."""
|
||||
######################################################################
|
||||
import asyncio
|
||||
import tempfile
|
||||
import os
|
||||
import shutil
|
||||
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):
|
||||
def __init__(self, token, discord_bot=None, avatar_channel_id=None,
|
||||
routes=None, drop_pending=True):
|
||||
self.token = token
|
||||
self.routes = {}
|
||||
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):
|
||||
self.routes[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):
|
||||
self._app = Application.builder().token(self.token).build()
|
||||
self._app.add_handler(MessageHandler(filters.ALL, self._on_message))
|
||||
# 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)
|
||||
|
||||
await self._app.initialize()
|
||||
await self._app.start()
|
||||
await self._app.updater.start_polling(drop_pending_updates=True)
|
||||
print(f'telegram forwarder started — watching {len(self.routes)} groups')
|
||||
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:
|
||||
await self._app.updater.stop()
|
||||
await self._app.stop()
|
||||
await self._app.shutdown()
|
||||
print('telegram forwarder stopped')
|
||||
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")
|
||||
|
||||
# internal helpers
|
||||
def _sender_name(self, update):
|
||||
user = update.effective_user
|
||||
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 ''
|
||||
name = (user.first_name or "").strip()
|
||||
if user.last_name:
|
||||
name += f' {user.last_name}'
|
||||
return name.strip() or user.username or 'unknown'
|
||||
|
||||
# channel posts have no user — use chat title
|
||||
chat = update.effective_chat
|
||||
if chat and chat.title:
|
||||
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'
|
||||
return "unknown"
|
||||
|
||||
# ── downloads ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _download_file(self, file_id):
|
||||
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
|
||||
)
|
||||
self._app.bot.get_file(file_id), timeout=30.0)
|
||||
buf = io.BytesIO()
|
||||
await asyncio.wait_for(
|
||||
tg_file.download_to_memory(buf), timeout=60.0
|
||||
)
|
||||
buf.seek(0)
|
||||
filename = tg_file.file_path.split('/')[-1] if tg_file.file_path else 'file'
|
||||
return buf.read(), filename
|
||||
tg_file.download_to_memory(buf), timeout=DOWNLOAD_TIMEOUT)
|
||||
path = tg_file.file_path or ""
|
||||
return buf.getvalue(), (path.split("/")[-1] or "file")
|
||||
|
||||
async def _send_to_webhook(self, webhook_url, username, content='', file_data=None, filename=None):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = aiohttp.FormData()
|
||||
payload = {'username': username}
|
||||
if content:
|
||||
payload['content'] = content[:2000]
|
||||
# ── context lines ───────────────────────────────────────────────────────
|
||||
|
||||
if file_data and filename:
|
||||
data.add_field('payload_json', json.dumps(payload), content_type='application/json')
|
||||
data.add_field('file', file_data, filename=filename)
|
||||
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:
|
||||
async with session.post(webhook_url, json=payload) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
print(f'webhook post failed: {resp.status} {await resp.text()}')
|
||||
return
|
||||
file_id, is_animation = document.file_id, False
|
||||
|
||||
async with session.post(webhook_url, data=data) as resp:
|
||||
if resp.status not in (200, 204):
|
||||
print(f'webhook post failed: {resp.status} {await resp.text()}')
|
||||
data, filename = await self._download(file_id)
|
||||
suffix = "." + filename.rsplit(".", 1)[-1] if "." in filename else ".mp4"
|
||||
|
||||
# main message handler
|
||||
async def _on_message(self, update, context):
|
||||
# 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_id = update.effective_chat.id if update.effective_chat else None
|
||||
if chat_id not in self.routes:
|
||||
chat = update.effective_chat
|
||||
route = self.routes.get(chat.id) if chat else None
|
||||
if route is None:
|
||||
return
|
||||
|
||||
webhook_url = self.routes[chat_id]
|
||||
sender = self._sender_name(update)
|
||||
caption = message.caption or ''
|
||||
# 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
|
||||
|
||||
# Log incoming message media types for debugging
|
||||
print(f"[HANDLER] Types - photo:{bool(message.photo)} video:{bool(message.video)} animation:{bool(message.animation)} document:{bool(message.document)}", flush=True)
|
||||
is_edit = bool(update.edited_message or update.edited_channel_post)
|
||||
try:
|
||||
if message.photo:
|
||||
photo = message.photo[-1]
|
||||
file_data, filename = await self._download_file(photo.file_id)
|
||||
text = caption.strip()
|
||||
await self._send_to_webhook(webhook_url, sender, text, file_data, filename)
|
||||
|
||||
# video and document handling are now managed by the enhanced GIF block below
|
||||
|
||||
# --- START OF ENHANCED GIF HANDLING BLOCK ---
|
||||
# Telegram may send a GIF as an `animation` (the normal case) *or*
|
||||
# as a muted `video` / `document` with a video MIME type.
|
||||
# Videos with an audio track are real videos → sent as-is.
|
||||
# Silent videos are treated as GIFs → converted for Discord auto-loop.
|
||||
elif getattr(message, "animation", None) or (
|
||||
getattr(message, "document", None) and message.document.mime_type.startswith("video")
|
||||
) or message.video:
|
||||
if message.animation:
|
||||
file_id = message.animation.file_id
|
||||
is_animation = True
|
||||
elif message.video:
|
||||
file_id = message.video.file_id
|
||||
is_animation = False
|
||||
else:
|
||||
file_id = message.document.file_id
|
||||
is_animation = False
|
||||
|
||||
print(f"[GIF-HANDLER] Detected potential media (file_id={file_id})", flush=True)
|
||||
file_data, filename = await self._download_file(file_id)
|
||||
|
||||
# Probe for audio track to decide: real video or GIF?
|
||||
convert_to_gif = is_animation # animations never have audio
|
||||
|
||||
if not is_animation and shutil.which('ffprobe'):
|
||||
tmp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as tmp:
|
||||
tmp.write(file_data)
|
||||
tmp_path = tmp.name
|
||||
|
||||
probe = await asyncio.create_subprocess_exec(
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 'a',
|
||||
'-show_entries', 'stream=codec_type',
|
||||
'-of', 'csv=p=0',
|
||||
tmp_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(probe.communicate(), timeout=15.0)
|
||||
|
||||
# ffprobe outputs "audio" if an audio stream is present
|
||||
convert_to_gif = b'audio' not in stdout
|
||||
if not convert_to_gif:
|
||||
print("[GIF-HANDLER] Audio track detected — sending as video", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[GIF-HANDLER] ffprobe failed, defaulting to convert: {e}", flush=True)
|
||||
convert_to_gif = True
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
if convert_to_gif:
|
||||
# >>> FFMPEG GIF CONVERSION START >>>
|
||||
try:
|
||||
if not shutil.which('ffmpeg'):
|
||||
raise RuntimeError("ffmpeg binary not found in PATH")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_mp4:
|
||||
temp_mp4.write(file_data)
|
||||
temp_mp4_path = temp_mp4.name
|
||||
|
||||
temp_gif_path = temp_mp4_path + '.gif'
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'ffmpeg', '-y', '-i', temp_mp4_path,
|
||||
'-filter_complex', 'fps=15,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
|
||||
'-loop', '0', temp_gif_path,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30.0)
|
||||
|
||||
if process.returncode == 0 and os.path.exists(temp_gif_path):
|
||||
gif_size = os.path.getsize(temp_gif_path)
|
||||
if gif_size < 10 * 1024 * 1024:
|
||||
with open(temp_gif_path, 'rb') as f:
|
||||
file_data = f.read()
|
||||
filename = filename.rsplit('.', 1)[0] + '.gif'
|
||||
else:
|
||||
print(f"[GIF-HANDLER] ffmpeg error: {stderr.decode()}", flush=True)
|
||||
|
||||
if os.path.exists(temp_mp4_path): os.remove(temp_mp4_path)
|
||||
if os.path.exists(temp_gif_path): os.remove(temp_gif_path)
|
||||
except Exception as e:
|
||||
print(f'ffmpeg conversion skipped/failed: {e}', flush=True)
|
||||
# <<< FFMPEG GIF CONVERSION END <<<
|
||||
|
||||
text = caption.strip()
|
||||
await self._send_to_webhook(webhook_url, sender, text, file_data, filename)
|
||||
|
||||
elif message.document:
|
||||
file_data, filename = await self._download_file(message.document.file_id)
|
||||
text = caption.strip()
|
||||
await self._send_to_webhook(webhook_url, sender, text, file_data, filename)
|
||||
|
||||
elif message.sticker:
|
||||
sticker_text = message.sticker.emoji or '(sticker)'
|
||||
text = sticker_text
|
||||
# >>> STICKER TO GIF CONVERSION START >>>
|
||||
file_data, filename = await self._download_file(message.sticker.file_id)
|
||||
try:
|
||||
# Determine original file suffix for temporary file
|
||||
suffix = os.path.splitext(filename)[1] or '.dat'
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp_in:
|
||||
temp_in.write(file_data)
|
||||
temp_in_path = temp_in.name
|
||||
|
||||
temp_gif_path = temp_in_path + '.gif'
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'ffmpeg', '-y', '-i', temp_in_path,
|
||||
'-filter_complex', 'fps=15,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
|
||||
'-loop', '0', temp_gif_path,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30.0)
|
||||
|
||||
if process.returncode == 0 and os.path.exists(temp_gif_path):
|
||||
gif_size = os.path.getsize(temp_gif_path)
|
||||
if gif_size < 10 * 1024 * 1024:
|
||||
with open(temp_gif_path, 'rb') as f:
|
||||
file_data = f.read()
|
||||
filename = filename.rsplit('.', 1)[0] + '.gif'
|
||||
else:
|
||||
print('[STICKER-HANDLER] Converted GIF exceeds size limit; sending original file', flush=True)
|
||||
else:
|
||||
print(f"[STICKER-HANDLER] ffmpeg error: {stderr.decode()}", flush=True)
|
||||
except Exception as e:
|
||||
print(f'Sticker conversion skipped/failed: {e}', flush=True)
|
||||
finally:
|
||||
if os.path.exists(temp_in_path):
|
||||
os.remove(temp_in_path)
|
||||
if os.path.exists(temp_gif_path):
|
||||
os.remove(temp_gif_path)
|
||||
await self._send_to_webhook(webhook_url, sender, text, file_data, filename)
|
||||
# <<< STICKER TO GIF CONVERSION END <<<
|
||||
|
||||
elif message.text:
|
||||
text = message.text
|
||||
await self._send_to_webhook(webhook_url, sender, text)
|
||||
|
||||
if is_edit:
|
||||
await self._handle_edit(route, message)
|
||||
elif message.media_group_id:
|
||||
self._buffer_album(route, message)
|
||||
else:
|
||||
text = '*(unsupported message type)*'
|
||||
await self._send_to_webhook(webhook_url, sender, text)
|
||||
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))
|
||||
|
||||
except Exception as e:
|
||||
print(f'failed to forward message from {sender}: {e}')
|
||||
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 ""
|
||||
Reference in new issue
Block a user