#discord-to-telegram forwarding##
"""Discord -> Telegram forwarding.
Sends as HTML, not Markdown: telegram 400s on any unpaired * _ ` or [, which
silently dropped most real messages. Only html can escape arbitrary user text.
"""
######################################################################
import html
import io
import logging
import discord
from discord.ext import commands
from telegram import (Bot, InputMediaAudio, InputMediaDocument,
InputMediaPhoto, InputMediaVideo, ReplyParameters)
from telegram.error import TelegramError
from . import config, entities, store
log = logging.getLogger(__name__)
TEXT_LIMIT = 4096
CAPTION_LIMIT = 1024
MEDIA_GROUP_MAX = 10
class DiscordToTelegramForwarder(commands.Cog):
"""Mirrors configured Discord channels into their paired Telegram chats."""
def __init__(self, bot: commands.Bot, tg_token: str, routes=None):
self.bot = bot
self._tg_bot = Bot(token=tg_token, **config.local_api())
# discord_channel_id -> telegram_chat_id
self.routes: dict[int, int] = {}
for route in routes or []:
if route.dc_to_tg and route.tg_chat_id:
self.routes[route.dc_channel_id] = route.tg_chat_id
self.forwarded = 0
self.last_error: str | None = None
def add_route(self, discord_channel_id: int, telegram_chat_id: int):
self.routes[int(discord_channel_id)] = int(telegram_chat_id)
async def cog_unload(self):
try:
await self._tg_bot.shutdown()
except Exception:
pass
# ── helpers ─────────────────────────────────────────────────────────────
def _header(self, message: discord.Message) -> str:
return f"{html.escape(message.author.display_name)}"
def _body(self, message: discord.Message) -> str:
content = entities.resolve_discord_references(message, message.content or "")
return entities.to_telegram_html(content)
async def _reply_params(self, message: discord.Message):
"""Point the telegram message at whatever this replies to."""
ref = getattr(message, "reference", None)
if not ref or not ref.message_id:
return None
try:
mapped = await store.tg_for_dc(message.channel.id, ref.message_id)
if not mapped:
return None
return ReplyParameters(message_id=mapped[1],
allow_sending_without_reply=True)
except Exception as exc:
log.debug("reply lookup failed: %s", exc)
return None
@staticmethod
def _split(text: str, limit: int) -> list[str]:
if not text:
return []
if len(text) <= limit:
return [text]
chunks, rest = [], text
while len(rest) > limit:
window = rest[:limit]
cut = window.rfind("\n")
if cut < limit // 2:
cut = window.rfind(" ")
if cut < limit // 2:
cut = limit
chunks.append(rest[:cut])
rest = rest[cut:].lstrip()
if rest:
chunks.append(rest)
return chunks
async def _send_text(self, chat_id: int, text: str, reply=None):
sent = []
for chunk in self._split(text, TEXT_LIMIT):
try:
msg = await self._tg_bot.send_message(
chat_id=chat_id, text=chunk, parse_mode="HTML",
reply_parameters=reply)
except TelegramError as exc:
# last resort: drop formatting rather than lose the message
log.warning("HTML send failed (%s) — retrying as plain text", exc)
self.last_error = str(exc)
try:
msg = await self._tg_bot.send_message(
chat_id=chat_id, text=_strip_tags(chunk),
reply_parameters=reply)
except TelegramError as exc2:
log.error("plain-text send also failed: %s", exc2)
self.last_error = str(exc2)
continue
sent.append(msg)
reply = None
return sent
# ── attachments ─────────────────────────────────────────────────────────
@staticmethod
def _kind(attachment: discord.Attachment) -> str:
mime = (attachment.content_type or "").lower()
name = (attachment.filename or "").lower()
if mime.startswith("image/gif") or name.endswith(".gif"):
return "animation"
if mime.startswith("image/"):
return "photo"
if mime.startswith("video/"):
return "video"
if mime.startswith("audio/"):
return "audio"
return "document"
async def _buffer(self, attachment: discord.Attachment):
buf = io.BytesIO(await attachment.read())
buf.name = attachment.filename
return buf
async def _send_attachments(self, chat_id, attachments, caption, reply):
"""Send attachments, batching into media groups where allowed."""
# photos and videos can share a group; documents and audio cannot
visual, documents, audio, animations = [], [], [], []
for attachment in attachments:
kind = self._kind(attachment)
(visual if kind in ("photo", "video")
else audio if kind == "audio"
else animations if kind == "animation"
else documents).append((kind, attachment))
sent = []
caption_left = caption
async def flush_group(items, factory):
nonlocal caption_left, reply
for batch in _chunks(items, MEDIA_GROUP_MAX):
# read each attachment once and reuse the buffer
prepared = []
for index, (kind, attachment) in enumerate(batch):
buf = await self._buffer(attachment)
caption = None
if index == 0 and caption_left:
caption = caption_left[:CAPTION_LIMIT]
caption_left = None
prepared.append((kind, buf, caption))
try:
if len(prepared) == 1:
kind, buf, caption = prepared[0]
sent.extend(await self._send_single(
chat_id, kind, buf, caption, reply))
else:
media = []
for kind, buf, caption in prepared:
kwargs = {"media": buf}
if caption:
kwargs["caption"] = caption
kwargs["parse_mode"] = "HTML"
media.append(factory(kind, kwargs))
sent.extend(await self._tg_bot.send_media_group(
chat_id=chat_id, media=media, reply_parameters=reply))
reply = None
except TelegramError as exc:
log.error("attachment send failed: %s", exc)
self.last_error = str(exc)
await flush_group(visual, lambda kind, kw:
InputMediaPhoto(**kw) if kind == "photo"
else InputMediaVideo(**kw))
await flush_group(animations, lambda kind, kw: InputMediaDocument(**kw))
await flush_group(documents, lambda kind, kw: InputMediaDocument(**kw))
await flush_group(audio, lambda kind, kw: InputMediaAudio(**kw))
if caption_left:
sent.extend(await self._send_text(chat_id, caption_left, reply))
return sent
async def _send_single(self, chat_id, kind, buf, caption, reply):
"""One attachment on its own — use the richest endpoint for its type."""
common = {"chat_id": chat_id, "caption": caption,
"parse_mode": "HTML" if caption else None,
"reply_parameters": reply}
if kind == "photo":
return [await self._tg_bot.send_photo(photo=buf, **common)]
if kind == "video":
return [await self._tg_bot.send_video(video=buf, **common)]
if kind == "audio":
return [await self._tg_bot.send_audio(audio=buf, **common)]
if kind == "animation":
return [await self._tg_bot.send_animation(animation=buf, **common)]
return [await self._tg_bot.send_document(document=buf, **common)]
async def _send_stickers(self, chat_id, stickers, reply):
"""Discord stickers have no telegram equivalent, send what we can."""
sent = []
for sticker in stickers:
try:
fmt = getattr(sticker, "format", None)
name = getattr(fmt, "name", "") or ""
if name.lower() == "lottie":
sent.extend(await self._send_text(
chat_id, f"(sticker: {html.escape(sticker.name)})",
reply))
else:
data = await sticker.read()
buf = io.BytesIO(data)
buf.name = f"{sticker.name}.{'gif' if name.lower() == 'gif' else 'png'}"
sent.append(await self._tg_bot.send_document(
chat_id=chat_id, document=buf, reply_parameters=reply))
reply = None
except Exception as exc:
log.warning("sticker forward failed: %s", exc)
return sent
# ── events ──────────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
# ignoring bots and webhooks is what breaks the echo loop
if message.author.bot or message.webhook_id:
return
chat_id = self.routes.get(message.channel.id)
if chat_id is None:
return
try:
reply = await self._reply_params(message)
header = self._header(message)
body = self._body(message)
caption = f"{header}\n{body}".strip() if body else header
sent = []
if message.attachments:
sent += await self._send_attachments(
chat_id, message.attachments, caption, reply)
elif getattr(message, "stickers", None):
sent += await self._send_text(chat_id, caption, reply)
sent += await self._send_stickers(chat_id, message.stickers, None)
else:
sent += await self._send_text(chat_id, caption, reply)
if sent:
await store.link(chat_id, [m.message_id for m in sent],
message.channel.id, message.id, "dc")
self.forwarded += 1
log.info("[D->TG] %s (channel %s) -> chat %s",
message.author, message.channel.id, chat_id)
except Exception as exc:
self.last_error = f"{type(exc).__name__}: {exc}"
log.exception("[D->TG] forward failed")
@commands.Cog.listener()
async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent):
# raw, not on_message_edit: the cached one never fires for messages
# sent before the last restart
chat_id = self.routes.get(payload.channel_id)
if chat_id is None:
return
data = payload.data or {}
if data.get("author", {}).get("bot") or data.get("webhook_id"):
return
content = data.get("content")
if content is None:
return # embed-only update, not an edit
try:
mapped = await store.tg_for_dc(payload.channel_id, payload.message_id)
if not mapped:
return
_, tg_msg_id = mapped
message = payload.cached_message
author = (message.author.display_name if message
else data.get("author", {}).get("global_name")
or data.get("author", {}).get("username") or "unknown")
resolved = entities.resolve_discord_references(message, content) \
if message else content
text = (f"{html.escape(str(author))}\n"
f"{entities.to_telegram_html(resolved)}\n"
f"(edited)")[:TEXT_LIMIT]
try:
await self._tg_bot.edit_message_text(
chat_id=chat_id, message_id=tg_msg_id, text=text,
parse_mode="HTML")
except TelegramError:
# the original was media, so the text is in the caption
await self._tg_bot.edit_message_caption(
chat_id=chat_id, message_id=tg_msg_id,
caption=text[:CAPTION_LIMIT], parse_mode="HTML")
except TelegramError as exc:
log.debug("[D->TG] edit sync failed: %s", exc)
except Exception:
log.exception("[D->TG] edit sync error")
@commands.Cog.listener()
async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent):
await self._delete(payload.channel_id, [payload.message_id])
@commands.Cog.listener()
async def on_raw_bulk_message_delete(
self, payload: discord.RawBulkMessageDeleteEvent):
await self._delete(payload.channel_id, list(payload.message_ids))
async def _delete(self, channel_id: int, message_ids: list[int]):
chat_id = self.routes.get(channel_id)
if chat_id is None:
return
for message_id in message_ids:
try:
mapped = await store.tg_for_dc(channel_id, message_id)
if not mapped:
continue
await self._tg_bot.delete_message(chat_id=chat_id,
message_id=mapped[1])
except TelegramError as exc:
# telegram refuses deletes older than 48h, which is normal
log.debug("[D->TG] delete sync skipped: %s", exc)
except Exception:
log.exception("[D->TG] delete sync error")
def _chunks(items, size):
for index in range(0, len(items), size):
yield items[index:index + size]
def _strip_tags(text: str) -> str:
import re
return html.unescape(re.sub(r"<[^>]+>", "", text))