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
+313
-63
@@ -1,96 +1,346 @@
|
||||
# Discord → Telegram forwarding
|
||||
#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
|
||||
from telegram import (Bot, InputMediaAudio, InputMediaDocument,
|
||||
InputMediaPhoto, InputMediaVideo, ReplyParameters)
|
||||
from telegram.error import TelegramError
|
||||
|
||||
from . import entities, store
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TEXT_LIMIT = 4096
|
||||
CAPTION_LIMIT = 1024
|
||||
MEDIA_GROUP_MAX = 10
|
||||
|
||||
|
||||
class DiscordToTelegramForwarder(commands.Cog):
|
||||
"""
|
||||
Listens for Discord messages in configured channels and forwards them to
|
||||
the paired Telegram group.
|
||||
"""Mirrors configured Discord channels into their paired Telegram chats."""
|
||||
|
||||
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):
|
||||
def __init__(self, bot: commands.Bot, tg_token: str, routes=None):
|
||||
self.bot = bot
|
||||
self._tg_bot = Bot(token=tg_token)
|
||||
# discord_channel_id (int) -> telegram_chat_id (int)
|
||||
# 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[discord_channel_id] = telegram_chat_id
|
||||
self.routes[int(discord_channel_id)] = int(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)}"
|
||||
|
||||
async def _send_text(self, chat_id: int, text: str):
|
||||
async def cog_unload(self):
|
||||
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}')
|
||||
await self._tg_bot.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _send_attachment(self, chat_id: int, attachment: discord.Attachment, caption: str):
|
||||
"""Download a Discord attachment and upload it to Telegram."""
|
||||
# ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _header(self, message: discord.Message) -> str:
|
||||
return f"<b>{html.escape(message.author.display_name)}</b>"
|
||||
|
||||
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:
|
||||
file_bytes = await attachment.read()
|
||||
buf = io.BytesIO(file_bytes)
|
||||
buf.name = attachment.filename
|
||||
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
|
||||
|
||||
mime = attachment.content_type or ''
|
||||
@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
|
||||
|
||||
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')
|
||||
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
|
||||
|
||||
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}')
|
||||
# ── attachments ─────────────────────────────────────────────────────────
|
||||
|
||||
# -- event handler --------------------------------------------------------
|
||||
@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"<i>(sticker: {html.escape(sticker.name)})</i>",
|
||||
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):
|
||||
# ignore bots (including ourselves) and webhook posts to prevent echo loops
|
||||
# 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
|
||||
|
||||
header = self._format_header(message)
|
||||
body = message.content or ''
|
||||
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
|
||||
|
||||
# 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()
|
||||
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:
|
||||
caption = ''
|
||||
await self._send_attachment(chat_id, attachment, caption)
|
||||
sent += await self._send_text(chat_id, caption, reply)
|
||||
|
||||
print(f'[D->TG] {message.author} (channel {message.channel.id}) -> tg chat {chat_id}')
|
||||
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"<b>{html.escape(str(author))}</b>\n"
|
||||
f"{entities.to_telegram_html(resolved)}\n"
|
||||
f"<i>(edited)</i>")[: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))
|
||||
Reference in new issue
Block a user