254 lines
11 KiB
Python
254 lines
11 KiB
Python
# this is where telegram-to-discord forwarding lives
|
|
import io
|
|
import json
|
|
import aiohttp
|
|
from telegram.ext import Application, MessageHandler, filters
|
|
import asyncio
|
|
import tempfile
|
|
import os
|
|
import shutil
|
|
|
|
class TelegramForwarder:
|
|
def __init__(self, token):
|
|
self.token = token
|
|
self.routes = {}
|
|
self._app = None
|
|
|
|
def add_route(self, group_id, webhook_url):
|
|
self.routes[group_id] = webhook_url
|
|
|
|
async def start(self):
|
|
self._app = Application.builder().token(self.token).build()
|
|
self._app.add_handler(MessageHandler(filters.ALL, self._on_message))
|
|
|
|
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')
|
|
|
|
async def stop(self):
|
|
if self._app:
|
|
await self._app.updater.stop()
|
|
await self._app.stop()
|
|
await self._app.shutdown()
|
|
print('telegram forwarder stopped')
|
|
|
|
# internal helpers
|
|
def _sender_name(self, update):
|
|
user = update.effective_user
|
|
if user:
|
|
name = user.first_name or ''
|
|
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:
|
|
return chat.title
|
|
return 'unknown'
|
|
|
|
|
|
|
|
async def _download_file(self, file_id):
|
|
tg_file = await self._app.bot.get_file(file_id)
|
|
buf = io.BytesIO()
|
|
await tg_file.download_to_memory(buf)
|
|
buf.seek(0)
|
|
filename = tg_file.file_path.split('/')[-1] if tg_file.file_path else 'file'
|
|
return buf.read(), filename
|
|
|
|
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]
|
|
|
|
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)
|
|
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
|
|
|
|
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()}')
|
|
|
|
# main message handler
|
|
async def _on_message(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:
|
|
return
|
|
|
|
webhook_url = self.routes[chat_id]
|
|
sender = self._sender_name(update)
|
|
caption = message.caption or ''
|
|
|
|
# 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)
|
|
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'):
|
|
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)
|
|
os.remove(tmp_path)
|
|
|
|
# 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
|
|
|
|
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)
|
|
|
|
else:
|
|
text = '*(unsupported message type)*'
|
|
await self._send_to_webhook(webhook_url, sender, text)
|
|
|
|
except Exception as e:
|
|
print(f'failed to forward message from {sender}: {e}') |