diff --git a/main.py b/main.py index c810eb0..c0dbbc7 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,8 @@ import logging import asyncio from tgbridge.forwarder import TelegramForwarder +from tgbridge.discord_to_telegram import DiscordToTelegramForwarder + from twitterbridge.rss import TwitterRSSForwarder load_dotenv('credentials.env') @@ -21,6 +23,13 @@ if os.getenv('TELEGRAM_GROUP_ID') and os.getenv('DISCORD_WEBHOOK_URL'): if os.getenv('TELEGRAM_GROUP_ID_SHITPOST') and os.getenv('DISCORD_WEBHOOK_URL_SHITPOST'): forwarder.add_route(int(os.getenv('TELEGRAM_GROUP_ID_SHITPOST')), os.getenv('DISCORD_WEBHOOK_URL_SHITPOST')) +# Discord → Telegram (reverse bridge) +d_to_tg = DiscordToTelegramForwarder(bot=bot, tg_token=os.getenv('TELEGRAM_BOT_TOKEN')) +if os.getenv('DISCORD_CHANNEL_ID') and os.getenv('TELEGRAM_GROUP_ID'): + d_to_tg.add_route(int(os.getenv('DISCORD_CHANNEL_ID')), int(os.getenv('TELEGRAM_GROUP_ID'))) +if os.getenv('DISCORD_CHANNEL_ID_SHITPOST') and os.getenv('TELEGRAM_GROUP_ID_SHITPOST'): + d_to_tg.add_route(int(os.getenv('DISCORD_CHANNEL_ID_SHITPOST')), int(os.getenv('TELEGRAM_GROUP_ID_SHITPOST'))) + twitter = TwitterRSSForwarder( rss_url=os.getenv('TWITTER_RSS_URL'), webhook_url=os.getenv('TWITTER_DISCORD_WEBHOOK'), @@ -34,6 +43,7 @@ async def on_ready(): async def main(): async with bot: + await bot.add_cog(d_to_tg) await bot.load_extension('cogs.general') await bot.load_extension('cogs.chance') await bot.load_extension('cogs.gifs') diff --git a/tgbridge/discord_to_telegram.py b/tgbridge/discord_to_telegram.py new file mode 100644 index 0000000..61b2454 --- /dev/null +++ b/tgbridge/discord_to_telegram.py @@ -0,0 +1,96 @@ +# Discord → Telegram forwarding +import io +import discord +from discord.ext import commands +from telegram import Bot +from telegram.error import TelegramError + + +class DiscordToTelegramForwarder(commands.Cog): + """ + Listens for Discord messages in configured channels and forwards them to + the paired Telegram group. + + Routes are added via add_route(discord_channel_id, telegram_chat_id). + Bot messages and webhook messages are silently ignored to prevent loops. + """ + + def __init__(self, bot: commands.Bot, tg_token: str): + self.bot = bot + self._tg_bot = Bot(token=tg_token) + # discord_channel_id (int) -> telegram_chat_id (int) + self.routes: dict[int, int] = {} + + def add_route(self, discord_channel_id: int, telegram_chat_id: int): + self.routes[discord_channel_id] = telegram_chat_id + + # -- helpers -------------------------------------------------------------- + + def _format_header(self, message: discord.Message) -> str: + """Return a bold sender label: *Username* (Discord)""" + name = message.author.display_name + return f"*{discord.utils.escape_markdown(name)}* (Discord):" + + async def _send_text(self, chat_id: int, text: str): + try: + await self._tg_bot.send_message( + chat_id=chat_id, + text=text, + parse_mode='Markdown', + ) + except TelegramError as e: + print(f'[D->TG] send_message failed: {e}') + + async def _send_attachment(self, chat_id: int, attachment: discord.Attachment, caption: str): + """Download a Discord attachment and upload it to Telegram.""" + try: + file_bytes = await attachment.read() + buf = io.BytesIO(file_bytes) + buf.name = attachment.filename + + mime = attachment.content_type or '' + + if mime.startswith('image/') and not mime.startswith('image/gif'): + await self._tg_bot.send_photo(chat_id=chat_id, photo=buf, caption=caption, parse_mode='Markdown') + elif mime.startswith('video/') or mime == 'image/gif': + await self._tg_bot.send_video(chat_id=chat_id, video=buf, caption=caption, parse_mode='Markdown') + elif mime.startswith('audio/'): + await self._tg_bot.send_audio(chat_id=chat_id, audio=buf, caption=caption, parse_mode='Markdown') + else: + await self._tg_bot.send_document(chat_id=chat_id, document=buf, caption=caption, parse_mode='Markdown') + + except TelegramError as e: + print(f'[D->TG] send_attachment failed ({attachment.filename}): {e}') + except Exception as e: + print(f'[D->TG] unexpected error for attachment {attachment.filename}: {e}') + + # -- event handler -------------------------------------------------------- + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + # ignore bots (including ourselves) and webhook posts to prevent echo loops + if message.author.bot or message.webhook_id: + return + + chat_id = self.routes.get(message.channel.id) + if chat_id is None: + return + + header = self._format_header(message) + body = message.content or '' + + # text portion + if body or not message.attachments: + full_text = f"{header}\n{body}".strip() + await self._send_text(chat_id, full_text) + + # attachments + for i, attachment in enumerate(message.attachments): + # first attachment gets the header as caption; rest are bare + if i == 0: + caption = header if body else f"{header}\n{body}".strip() + else: + caption = '' + await self._send_attachment(chat_id, attachment, caption) + + print(f'[D->TG] {message.author} (channel {message.channel.id}) -> tg chat {chat_id}') diff --git a/tgbridge/forwarder.py b/tgbridge/forwarder.py index 3208136..30efbf7 100644 --- a/tgbridge/forwarder.py +++ b/tgbridge/forwarder.py @@ -48,28 +48,7 @@ class TelegramForwarder: return chat.title return 'unknown' - def _forward_prefix(self, message): - if message.forward_origin: - origin = message.forward_origin - - # user forward - if hasattr(origin, 'sender_user') and origin.sender_user: - u = origin.sender_user - name = u.first_name or '' - if u.last_name: - name += f' {u.last_name}' - return f'↩️ Forwarded from **{name.strip() or u.username or "unknown"}**:\n' - - # channel forward - if hasattr(origin, 'chat') and origin.chat: - return f'↩️ Forwarded from **{origin.chat.title or "unknown channel"}**:\n' - - # hidden user - if hasattr(origin, 'sender_user_name') and origin.sender_user_name: - return f'↩️ Forwarded from **{origin.sender_user_name}**:\n' - - return '↩️ Forwarded:\n' - return '' + async def _download_file(self, file_id): tg_file = await self._app.bot.get_file(file_id) @@ -111,7 +90,6 @@ class TelegramForwarder: webhook_url = self.routes[chat_id] sender = self._sender_name(update) - prefix = self._forward_prefix(message) caption = message.caption or '' # Log incoming message media types for debugging @@ -120,7 +98,7 @@ class TelegramForwarder: if message.photo: photo = message.photo[-1] file_data, filename = await self._download_file(photo.file_id) - text = f'{prefix}{caption}'.strip() + 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 @@ -182,17 +160,17 @@ class TelegramForwarder: print(f'ffmpeg conversion skipped/failed: {e}', flush=True) # <<< FFMPEG GIF CONVERSION END <<< - text = f'{prefix}{caption}'.strip() + 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 = f'{prefix}{caption}'.strip() + 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 = f'{prefix}{sticker_text}' + text = sticker_text # >>> STICKER TO GIF CONVERSION START >>> file_data, filename = await self._download_file(message.sticker.file_id) try: @@ -235,12 +213,12 @@ class TelegramForwarder: # <<< STICKER TO GIF CONVERSION END <<< elif message.text: - text = f'{prefix}{message.text}' + text = message.text await self._send_to_webhook(webhook_url, sender, text) else: - text = f'{prefix}*(unsupported message type)*' + 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}') + print(f'failed to forward message from {sender}: {e}') \ No newline at end of file