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:
esefandClaude Opus 5 committed 2026-09-07 12:19:18 +03:00
1 parent f6ba74394f
commit eac6dbda3a
12 files changed
+2325 -307

No files matched your search

+313 -63
View File
@@ -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))