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
@@ -0,0 +1,201 @@
|
||||
#discord webhook poster — shared session, retries, splitting##
|
||||
"""Discord webhook client for the bridge.
|
||||
|
||||
One shared session, allowed_mentions on every payload, 429 backoff, splitting
|
||||
instead of truncation, and ?wait=true so we learn the message id for edits.
|
||||
"""
|
||||
######################################################################
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CONTENT_LIMIT = 2000
|
||||
MAX_RETRIES = 4
|
||||
# discord never tells us the limit (it moves with boost tier), so react to the
|
||||
# actual rejection instead of guessing a threshold
|
||||
_TOO_LARGE_STATUSES = {413}
|
||||
|
||||
NO_MENTIONS = {"parse": []}
|
||||
|
||||
|
||||
class WebhookClient:
|
||||
"""Shared-session Discord webhook poster."""
|
||||
|
||||
def __init__(self):
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self.sent = 0
|
||||
self.failed = 0
|
||||
self.last_error: str | None = None
|
||||
|
||||
async def session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def split_content(text: str, limit: int = CONTENT_LIMIT) -> list[str]:
|
||||
"""Break text into discord-sized chunks on the nicest boundary."""
|
||||
if not text:
|
||||
return []
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks, remaining = [], text
|
||||
while len(remaining) > limit:
|
||||
window = remaining[:limit]
|
||||
cut = window.rfind("\n")
|
||||
if cut < limit // 2:
|
||||
cut = window.rfind(" ")
|
||||
if cut < limit // 2:
|
||||
cut = limit # no sane boundary, hard cut
|
||||
chunks.append(remaining[:cut].rstrip())
|
||||
remaining = remaining[cut:].lstrip()
|
||||
if remaining:
|
||||
chunks.append(remaining)
|
||||
return [c for c in chunks if c]
|
||||
|
||||
async def _request(self, method: str, url: str, *, build_body=None,
|
||||
json_body=None):
|
||||
"""Request with retries. build_body is a callable because a FormData
|
||||
cannot be replayed once consumed, so each attempt rebuilds it."""
|
||||
session = await self.session()
|
||||
delay = 1.0
|
||||
for attempt in range(MAX_RETRIES):
|
||||
kwargs = {}
|
||||
if build_body is not None:
|
||||
kwargs["data"] = build_body()
|
||||
elif json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
try:
|
||||
async with session.request(method, url, **kwargs) as resp:
|
||||
if resp.status in (200, 204):
|
||||
self.sent += 1
|
||||
if resp.status == 204:
|
||||
return None
|
||||
try:
|
||||
return await resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
body = (await resp.text())[:400]
|
||||
|
||||
if resp.status == 429:
|
||||
retry_after = delay
|
||||
try:
|
||||
retry_after = float((await resp.json()).get(
|
||||
"retry_after", delay))
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("rate limited, waiting %.2fs", retry_after)
|
||||
await asyncio.sleep(min(retry_after + 0.25, 30))
|
||||
continue
|
||||
|
||||
if resp.status in _TOO_LARGE_STATUSES:
|
||||
raise PayloadTooLarge(body)
|
||||
|
||||
if 500 <= resp.status < 600:
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
continue
|
||||
|
||||
# 4xx won't improve on retry
|
||||
self.failed += 1
|
||||
self.last_error = f"{resp.status} {body}"
|
||||
log.error("webhook %s %s -> %s %s", method, url, resp.status, body)
|
||||
return None
|
||||
|
||||
except PayloadTooLarge:
|
||||
raise
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
log.warning("webhook request failed (attempt %d): %s",
|
||||
attempt + 1, exc)
|
||||
self.last_error = str(exc)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
self.failed += 1
|
||||
self.last_error = self.last_error or "exhausted retries"
|
||||
return None
|
||||
|
||||
# ── public api ───────────────────────────────────────────────────────
|
||||
|
||||
async def send(self, webhook_url: str, username: str, content: str = "",
|
||||
files: list | None = None, avatar_url: str | None = None):
|
||||
"""Post to a webhook, splitting long text and attaching files.
|
||||
|
||||
files is a list of (filename, bytes). Returns the first message, which
|
||||
is what replies and edits anchor to.
|
||||
"""
|
||||
chunks = self.split_content(content) or ([""] if files else [])
|
||||
if not chunks:
|
||||
return None
|
||||
|
||||
first = None
|
||||
for index, chunk in enumerate(chunks):
|
||||
is_last = index == len(chunks) - 1
|
||||
attach = files if (is_last and files) else None
|
||||
payload = {
|
||||
"username": username[:80] or "unknown",
|
||||
"allowed_mentions": NO_MENTIONS,
|
||||
}
|
||||
if chunk:
|
||||
payload["content"] = chunk
|
||||
if avatar_url:
|
||||
payload["avatar_url"] = avatar_url
|
||||
|
||||
result = await self._post_one(webhook_url, payload, attach)
|
||||
if index == 0:
|
||||
first = result
|
||||
return first
|
||||
|
||||
async def _post_one(self, webhook_url: str, payload: dict, files):
|
||||
url = webhook_url + "?wait=true"
|
||||
if not files:
|
||||
return await self._request("POST", url, json_body=payload)
|
||||
|
||||
def build():
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("payload_json", json.dumps(payload),
|
||||
content_type="application/json")
|
||||
for index, (filename, data) in enumerate(files):
|
||||
form.add_field(f"files[{index}]", data, filename=filename)
|
||||
return form
|
||||
|
||||
try:
|
||||
return await self._request("POST", url, build_body=build)
|
||||
except PayloadTooLarge:
|
||||
names = ", ".join(name for name, _ in files)
|
||||
log.info("upload rejected as too large: %s", names)
|
||||
notice = payload.get("content", "")
|
||||
payload = dict(payload)
|
||||
payload["content"] = (
|
||||
f"{notice}\n*(attachment too large for this server: "
|
||||
f"{names})*").strip()[:CONTENT_LIMIT]
|
||||
return await self._request("POST", url, json_body=payload)
|
||||
|
||||
async def edit(self, webhook_url: str, message_id: int, content: str):
|
||||
"""Mirror an edit onto an already-forwarded message."""
|
||||
url = f"{webhook_url}/messages/{message_id}"
|
||||
body = {"content": content[:CONTENT_LIMIT],
|
||||
"allowed_mentions": NO_MENTIONS}
|
||||
return await self._request("PATCH", url, json_body=body)
|
||||
|
||||
async def delete(self, webhook_url: str, message_id: int):
|
||||
url = f"{webhook_url}/messages/{message_id}"
|
||||
return await self._request("DELETE", url)
|
||||
|
||||
|
||||
class PayloadTooLarge(Exception):
|
||||
"""Discord rejected the upload for size."""
|
||||
Reference in new issue
Block a user