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

+448 -225
View File
@@ -1,261 +1,484 @@
# this is where telegram-to-discord forwarding lives
import io
import json
import aiohttp
from telegram.ext import Application, MessageHandler, filters
#this is where telegram-to-discord forwarding lives##
"""Telegram -> Discord forwarding."""
######################################################################
import asyncio
import tempfile
import os
import shutil
import io
import logging
import time
from telegram.ext import Application, MessageHandler, filters
from . import config, entities, media, store
from .avatars import AvatarCache
from .webhook import WebhookClient
log = logging.getLogger(__name__)
# an album arrives as several updates; wait this long after the last one
ALBUM_DEBOUNCE = 1.5
ALBUM_MAX_FILES = 10
DOWNLOAD_TIMEOUT = 90.0
REPLY_SNIPPET = 120
class TelegramForwarder:
def __init__(self, token):
def __init__(self, token, discord_bot=None, avatar_channel_id=None,
routes=None, drop_pending=True):
self.token = token
self.routes = {}
self.bot = discord_bot
self.drop_pending = drop_pending
# chat_id -> Route
self.routes: dict[int, config.Route] = {}
for route in routes or []:
if route.tg_to_dc:
self.routes[route.tg_chat_id] = route
self.webhook = WebhookClient()
self.avatars = AvatarCache(discord_bot, avatar_channel_id)
self._app = None
self._running = False
self._albums: dict[str, dict] = {}
self.started_at = None
self.forwarded = 0
self.last_error = None
# ── lifecycle ───────────────────────────────────────────────────────────
def add_route(self, group_id, webhook_url):
self.routes[group_id] = webhook_url
"""Back-compatible single-route registration."""
existing = self.routes.get(int(group_id))
if existing:
existing.webhook_url = webhook_url
else:
self.routes[int(group_id)] = config.Route(
name=str(group_id), tg_chat_id=int(group_id),
webhook_url=webhook_url)
async def start(self):
self._app = Application.builder().token(self.token).build()
self._app.add_handler(MessageHandler(filters.ALL, self._on_message))
# on_ready fires again on every reconnect; a second poller on the same
# token means telegram 409s and duplicated posts
if self._running:
log.debug("telegram forwarder already running — start() ignored")
return
self._running = True
try:
await store.init_db()
removed = await store.prune()
if removed:
log.info("pruned %d expired message mappings", removed)
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')
self._app = Application.builder().token(self.token).build()
self._app.add_handler(MessageHandler(filters.ALL, self._on_update))
self._app.add_error_handler(self._on_error)
await self._app.initialize()
await self._app.start()
await self._app.updater.start_polling(
drop_pending_updates=self.drop_pending)
self.avatars.bind_telegram(self._app.bot)
self.started_at = time.time()
log.info("telegram forwarder started — watching %d chat(s), "
"avatars %s", len(self.routes),
"on" if self.avatars.enabled else "off")
except Exception:
self._running = False
raise
async def stop(self):
self._running = False
for pending in list(self._albums.values()):
task = pending.get("task")
if task:
task.cancel()
self._albums.clear()
if self._app:
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
print('telegram forwarder stopped')
try:
if self._app.updater.running:
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
except Exception as exc:
log.warning("error during telegram shutdown: %s", exc)
self._app = None
await self.webhook.close()
log.info("telegram forwarder stopped")
# internal helpers
def _sender_name(self, update):
user = update.effective_user
async def _on_error(self, update, context):
self.last_error = str(context.error)
log.error("telegram update failed: %s", context.error)
# ── naming ──────────────────────────────────────────────────────────────
def _sender_name(self, message) -> str:
user = getattr(message, "from_user", None)
if user:
name = user.first_name or ''
name = (user.first_name or "").strip()
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:
name = f"{name} {user.last_name}".strip()
return name or user.username or "unknown"
chat = getattr(message, "sender_chat", None) or getattr(message, "chat", None)
if chat and getattr(chat, "title", None):
return chat.title
return 'unknown'
return "unknown"
# ── downloads ───────────────────────────────────────────────────────────
async def _download_file(self, file_id):
async def _download(self, file_id: str):
"""-> (bytes, filename). Raises on failure."""
tg_file = await asyncio.wait_for(
self._app.bot.get_file(file_id), timeout=30.0
)
self._app.bot.get_file(file_id), timeout=30.0)
buf = io.BytesIO()
await asyncio.wait_for(
tg_file.download_to_memory(buf), timeout=60.0
)
buf.seek(0)
filename = tg_file.file_path.split('/')[-1] if tg_file.file_path else 'file'
return buf.read(), filename
tg_file.download_to_memory(buf), timeout=DOWNLOAD_TIMEOUT)
path = tg_file.file_path or ""
return buf.getvalue(), (path.split("/")[-1] or "file")
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]
# ── context lines ───────────────────────────────────────────────────────
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)
async def _reply_line(self, message, route) -> str:
"""Render a quote of whatever this message is replying to."""
replied = getattr(message, "reply_to_message", None)
if not replied:
return ""
try:
who = self._sender_name(replied)
snippet = (replied.text or replied.caption or "").strip()
if not snippet:
snippet = _describe_media(replied)
snippet = " ".join(snippet.split())
if len(snippet) > REPLY_SNIPPET:
snippet = snippet[:REPLY_SNIPPET].rstrip() + "…"
snippet = entities.escape_discord(snippet)
who = entities.escape_discord(who)
link = await self._jump_link(route, replied.message_id)
arrow = f"[↩ reply to **{who}**]({link})" if link else f"↩ reply to **{who}**"
return f"> {arrow}: {snippet}\n" if snippet else f"> {arrow}\n"
except Exception as exc:
log.debug("could not render reply context: %s", exc)
return ""
async def _jump_link(self, route, tg_msg_id: int):
"""Link to the Discord copy of a Telegram message, if we forwarded it."""
if not self.bot:
return None
try:
mapped = await store.dc_for_tg(route.tg_chat_id, tg_msg_id)
if not mapped:
return None
channel_id, message_id = mapped
channel = self.bot.get_channel(channel_id)
guild_id = channel.guild.id if channel and channel.guild else None
if not guild_id:
return None
return (f"https://discord.com/channels/"
f"{guild_id}/{channel_id}/{message_id}")
except Exception:
return None
def _forward_line(self, message) -> str:
"""Note where a forwarded message originally came from."""
try:
origin = getattr(message, "forward_origin", None)
source = None
if origin is not None:
otype = getattr(origin, "type", "")
if otype == "user" and getattr(origin, "sender_user", None):
user = origin.sender_user
source = (user.first_name or "") + (
f" {user.last_name}" if user.last_name else "")
elif otype == "hidden_user":
source = getattr(origin, "sender_user_name", None)
elif otype == "chat" and getattr(origin, "sender_chat", None):
source = origin.sender_chat.title
elif otype == "channel" and getattr(origin, "chat", None):
source = origin.chat.title
if not source:
return ""
return f"-# ↪ forwarded from {entities.escape_discord(source.strip())}\n"
except Exception:
return ""
# ── rendering ───────────────────────────────────────────────────────────
async def _render_text(self, message) -> str:
body = message.text or message.caption or ""
ents = message.entities if message.text else message.caption_entities
return entities.to_discord_markdown(body, ents)
async def _render_sticker(self, message):
"""-> (files, extra_text), per sticker format."""
sticker = message.sticker
emoji = sticker.emoji or ""
cached = await store.get_media(sticker.file_unique_id)
if cached:
return [cached], emoji
data, filename = await self._download(sticker.file_id)
stem = filename.rsplit(".", 1)[0] or "sticker"
if sticker.is_video:
# .webm vp9 with alpha -> animated webp keeps the transparency
converted = await media.to_animated_webp(data, ".webm")
if converted:
out = (f"{stem}.webp", converted)
await store.put_media(sticker.file_unique_id, *out)
return [out], emoji
return [(filename, data)], emoji
if sticker.is_animated:
# .tgs is gzipped lottie, ffmpeg cannot read it
rendered = await media.render_tgs(data)
if rendered:
out = (f"{stem}.gif", rendered)
await store.put_media(sticker.file_unique_id, *out)
return [out], emoji
thumb = getattr(sticker, "thumbnail", None)
if thumb:
try:
tdata, tname = await self._download(thumb.file_id)
return [(tname, tdata)], emoji
except Exception as exc:
log.debug("sticker thumbnail failed: %s", exc)
return [], f"{emoji} *(animated sticker)*".strip()
# static .webp renders natively with full alpha; converting to gif
# only destroyed the antialiased edges
return [(filename, data)], emoji
async def _render_media(self, message):
"""-> (files, extra_text) for whatever media this message carries."""
if message.sticker:
return await self._render_sticker(message)
if message.photo:
data, filename = await self._download(message.photo[-1].file_id)
return [(filename, data)], ""
animation = getattr(message, "animation", None)
document = getattr(message, "document", None)
# mime_type is nullable
doc_mime = (getattr(document, "mime_type", None) or "") if document else ""
if animation or message.video or doc_mime.startswith("video"):
if animation:
file_id, is_animation = animation.file_id, True
elif message.video:
file_id, is_animation = message.video.file_id, False
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
file_id, is_animation = document.file_id, False
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()}')
data, filename = await self._download(file_id)
suffix = "." + filename.rsplit(".", 1)[-1] if "." in filename else ".mp4"
# main message handler
async def _on_message(self, update, context):
# animations never have audio; elsewhere a silent clip is a gif in
# spirit and should loop, one with sound is a real video
convert = is_animation or not await media.has_audio_track(data, suffix)
if convert:
gif = await media.to_gif(data, suffix)
if gif:
return [(filename.rsplit(".", 1)[0] + ".gif", gif)], ""
return [(filename, data)], ""
if document:
data, filename = await self._download(document.file_id)
return [(filename, data)], ""
if message.voice:
data, filename = await self._download(message.voice.file_id)
return [(filename, data)], f"-# 🎤 voice message ({_duration(message.voice)})"
if message.video_note:
data, filename = await self._download(message.video_note.file_id)
return [(filename, data)], f"-# 📹 video note ({_duration(message.video_note)})"
if message.audio:
audio = message.audio
data, filename = await self._download(audio.file_id)
label = " — ".join(x for x in (audio.performer, audio.title) if x)
return [(filename, data)], f"-# 🎵 {label}" if label else ""
return [], _describe_nonmedia(message)
# ── dispatch ────────────────────────────────────────────────────────────
async def _on_update(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:
chat = update.effective_chat
route = self.routes.get(chat.id) if chat else None
if route is None:
return
webhook_url = self.routes[chat_id]
sender = self._sender_name(update)
caption = message.caption or ''
# echo guard: never re-forward what the bridge itself posted
me = getattr(self._app.bot, "id", None)
sender = getattr(message, "from_user", None)
if me and sender and sender.id == me:
return
# 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)
is_edit = bool(update.edited_message or update.edited_channel_post)
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'):
tmp_path = None
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)
# 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
finally:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
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)
if is_edit:
await self._handle_edit(route, message)
elif message.media_group_id:
self._buffer_album(route, message)
else:
text = '*(unsupported message type)*'
await self._send_to_webhook(webhook_url, sender, text)
await self._forward(route, [message])
except Exception as exc:
self.last_error = f"{type(exc).__name__}: {exc}"
log.exception("failed to forward message from %s",
self._sender_name(message))
except Exception as e:
print(f'failed to forward message from {sender}: {e}')
def _buffer_album(self, route, message):
key = f"{route.tg_chat_id}:{message.media_group_id}"
pending = self._albums.get(key)
if pending is None:
pending = self._albums[key] = {"messages": [], "task": None,
"route": route}
pending["messages"].append(message)
if pending["task"]:
pending["task"].cancel()
pending["task"] = asyncio.create_task(self._flush_album(key))
async def _flush_album(self, key):
try:
await asyncio.sleep(ALBUM_DEBOUNCE)
except asyncio.CancelledError:
return
pending = self._albums.pop(key, None)
if not pending or not pending["messages"]:
return
messages = sorted(pending["messages"], key=lambda m: m.message_id)
try:
await self._forward(pending["route"], messages)
except Exception as exc:
self.last_error = f"{type(exc).__name__}: {exc}"
log.exception("failed to forward album")
async def _forward(self, route, messages):
"""Post one or more Telegram messages as a single Discord message."""
lead = messages[0]
sender = self._sender_name(lead)
files, notes = [], []
for message in messages:
try:
got, note = await self._render_media(message)
except Exception as exc:
log.warning("media render failed: %s", exc)
got, note = [], "*(attachment could not be fetched)*"
files.extend(got)
if note:
notes.append(note)
body_parts = []
for message in messages:
rendered = await self._render_text(message)
if rendered:
body_parts.append(rendered)
prefix = self._forward_line(lead) + await self._reply_line(lead, route)
content = prefix + "\n".join(body_parts + notes).strip()
if not content.strip() and not files:
return
avatar_url = None
try:
if getattr(lead, "from_user", None):
avatar_url = await self.avatars.url_for_user(lead.from_user)
else:
avatar_url = await self.avatars.url_for_chat(
getattr(lead, "sender_chat", None) or lead.chat)
except Exception as exc:
log.debug("avatar lookup failed: %s", exc)
result = await self.webhook.send(
route.webhook_url, sender, content,
files=files[:ALBUM_MAX_FILES] or None, avatar_url=avatar_url)
self.forwarded += 1
if result and result.get("id"):
# every telegram id in an album maps to the one discord post, so
# a reply to any photo in it resolves
await store.link(route.tg_chat_id,
[m.message_id for m in messages],
int(result["channel_id"]), int(result["id"]), "tg")
async def _handle_edit(self, route, message):
mapped = await store.dc_for_tg(route.tg_chat_id, message.message_id)
if not mapped:
log.debug("edit for unmapped message %s — ignoring", message.message_id)
return
_, dc_msg_id = mapped
content = await self._render_text(message)
if not content:
return
await self.webhook.edit(route.webhook_url, dc_msg_id,
content + "\n-# *(edited)*")
# ── small helpers ───────────────────────────────────────────────────────────
def _duration(obj) -> str:
seconds = getattr(obj, "duration", 0) or 0
return f"{seconds // 60}:{seconds % 60:02d}"
def _describe_media(message) -> str:
"""Short label for a message quoted in a reply."""
for attr, label in (("photo", "photo"), ("sticker", "sticker"),
("animation", "GIF"), ("video", "video"),
("voice", "voice message"), ("video_note", "video note"),
("audio", "audio"), ("document", "file")):
if getattr(message, attr, None):
return f"({label})"
return ""
def _describe_nonmedia(message) -> str:
"""The message types that carry no file at all."""
poll = getattr(message, "poll", None)
if poll:
options = "\n".join(f"• {entities.escape_discord(o.text)}"
for o in poll.options)
return f"📊 **{entities.escape_discord(poll.question)}**\n{options}"
location = getattr(message, "location", None)
venue = getattr(message, "venue", None)
if venue:
return (f"📍 **{entities.escape_discord(venue.title)}**\n"
f"{entities.escape_discord(venue.address)}\n"
f"<https://www.google.com/maps?q="
f"{venue.location.latitude},{venue.location.longitude}>")
if location:
return (f"📍 location: <https://www.google.com/maps?q="
f"{location.latitude},{location.longitude}>")
contact = getattr(message, "contact", None)
if contact:
name = " ".join(x for x in (contact.first_name, contact.last_name) if x)
return (f"👤 **{entities.escape_discord(name)}**\n"
f"{entities.escape_discord(contact.phone_number or '')}")
dice = getattr(message, "dice", None)
if dice:
return f"{dice.emoji} rolled **{dice.value}**"
if getattr(message, "new_chat_members", None):
names = ", ".join(entities.escape_discord(
u.first_name or u.username or "someone")
for u in message.new_chat_members)
return f"-# → {names} joined the group"
left = getattr(message, "left_chat_member", None)
if left:
name = entities.escape_discord(left.first_name or left.username or "someone")
return f"-# ← {name} left the group"
if getattr(message, "pinned_message", None):
return "-# 📌 pinned a message"
return ""