"""Discord webhook client — posts feed items into Discord channels via webhooks. FeedEcho connects with a Discord **webhook URL** (Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL). No bot account or OAuth: the URL is the credential and the posting endpoint in one. Anyone with the URL can post to that channel, so it is treated like a token — stored, never rendered back in full. Flow at connect time (``connect``): 1. ``normalize_discord_webhook_url`` validates and canonicalizes the pasted URL: https, a Discord host (``discord.com`` or the legacy ``discordapp.com`` alias), and an ``/api/webhooks//`` path. discordapp.com URLs are rewritten to discord.com so the same webhook never becomes two account rows. 2. ``inspect_webhook`` GETs the URL — Discord returns the webhook's metadata (name, channel id) without posting anything, proving the URL works and pre-filling a display name. Sending is ``POST`` to the same URL with a JSON body: ``content`` (the rendered template, truncated to Discord's 2000-character limit) plus one embed carrying title/url/image when image attachments are on and the feed item has one. Discord fetches embed images server-side, so FeedEcho never downloads or uploads the image — a private or oversized image simply renders the post without the embed picture, and the text delivery is unaffected. Rate limits: Discord allows 30 webhook messages per minute per channel (429 with a Retry-After header beyond that). 429s are transient errors and ride the scheduler's existing bounded retry pipeline; Discord's suggested wait is carried on the exception and logged. """ from __future__ import annotations import logging import re from urllib.parse import urlsplit import httpx from feed_parser import SSRFError, pinned_request from utils import ( DEFAULT_REQUEST_TIMEOUT, DestinationAuthError, DestinationError, DestinationNotFoundError, DestinationRateLimitError, json_error_detail, parse_retry_after, truncate_chars, ) logger = logging.getLogger(__name__) REQUEST_TIMEOUT = DEFAULT_REQUEST_TIMEOUT # Discord message `content` limit. Embeds add their own smaller caps # (title 256); truncated locally rather than discovering the limit via 400s. MAX_CONTENT_CHARS = 2000 MAX_EMBED_TITLE_CHARS = 256 # Webhook IDs are snowflakes (17-20 digits); tokens are a long opaque # base64-ish string. Loose bounds on purpose: Discord has changed token # lengths before, and the GET verification below is the real check. _WEBHOOK_URL_RE = re.compile( r"^https://(?Pdiscordapp\.com|discord\.com)" r"/api/webhooks/(?P\d{16,20})/(?P[A-Za-z0-9_-]{20,120})$", re.IGNORECASE, ) class DiscordError(DestinationError): """Base error for Discord webhook API interactions.""" class DiscordRateLimitError(DiscordError, DestinationRateLimitError): """Discord 429 rate limit. Carries Discord's suggested wait in seconds. Still a transient error (the scheduler's bounded backoff retries it), but the retry_after value is logged so operators can see the throttle. """ def __init__(self, retry_after: float | None = None): self.retry_after = retry_after msg = "Discord rate limit hit" if retry_after is not None: msg += f" (retry after {retry_after:.0f}s)" super().__init__(msg) class DiscordAuthError(DiscordError, DestinationAuthError): """Webhook URL rejected (invalid token).""" class DiscordNotFoundError(DiscordError, DestinationNotFoundError): """Webhook no longer exists (deleted from the server).""" class DiscordBadRequestError(DiscordError): """Discord refused the payload (400) — a formatting bug, not retryable.""" def _error_detail(response) -> str: """Discord errors are ``{"message": "...", "code": ...}``.""" return json_error_detail(response, "message") def _rate_limit_seconds(response) -> float | None: """Discord's suggested wait, from the Retry-After header or the body.""" return parse_retry_after(response) def _raise_for_status(response, action: str) -> None: if response.status_code in (200, 204): return detail = _error_detail(response) if response.status_code == 429: raise DiscordRateLimitError(_rate_limit_seconds(response)) if response.status_code == 401: raise DiscordAuthError( "Discord rejected this webhook URL (invalid token)." " Recreate the webhook on the server and connect again." ) if response.status_code == 404: raise DiscordNotFoundError( "This webhook no longer exists. It was deleted on the Discord" " server — create a new one and connect again." ) if response.status_code == 400: raise DiscordBadRequestError( f"Discord refused the {action} (HTTP 400)" + (f": {detail}" if detail else "") ) if 300 <= response.status_code < 400: # Webhook endpoints never legitimately redirect; following one could # replay the request (and the payload) at a different host. raise DiscordError( f"Discord {action} redirected (HTTP {response.status_code})" ) raise DiscordError( f"Discord {action} failed (HTTP {response.status_code})" + (f": {detail}" if detail else "") ) def normalize_discord_webhook_url(raw: str) -> str: """Validate a pasted webhook URL and return the canonical form. Accepts only https URLs on discord.com / discordapp.com with the ``/api/webhooks//`` shape. The legacy discordapp.com host is rewritten to discord.com (official alias, same API) so one webhook can never be stored twice. Raises ValueError with a user-facing message. """ value = (raw or "").strip() if not value: raise ValueError("Paste the Discord webhook URL.") match = _WEBHOOK_URL_RE.match(value) if not match: raise ValueError( "That does not look like a Discord webhook URL. In Discord, open" " the channel you want FeedEcho to post to, then Server Settings" " (or channel settings) → Integrations → Webhooks → New Webhook →" " Copy Webhook URL, and paste it here." ) return ( f"https://discord.com/api/webhooks/" f"{match.group('webhook_id')}/{match.group('token')}" ) def inspect_webhook(webhook_url: str) -> dict: """GET the webhook's metadata. Returns ``{name, channel_id}``. Discord lets anyone holding the URL GET its metadata without posting anything, which makes this the connect-time and test-time verification. The URL is re-validated here even though connect already normalized it, so a tampered stored row cannot point this request elsewhere. """ try: webhook_url = normalize_discord_webhook_url(webhook_url) except ValueError as e: # Stored rows are normalized at connect; this only triggers for a # tampered DB row. Converted so callers get a DiscordError, not a # bare ValueError from deep inside the pipeline. raise DiscordError(f"Stored webhook URL is malformed: {e}") from e try: resp = pinned_request("GET", webhook_url, timeout=REQUEST_TIMEOUT) except (httpx.HTTPError, SSRFError) as e: # httpx exception text embeds the request URL, which carries the # webhook token — never interpolate it into logs or user messages. raise DiscordError(f"Could not reach Discord ({type(e).__name__})") from e _raise_for_status(resp, "webhook check") try: data = resp.json() except ValueError as e: raise DiscordError("Discord returned a non-JSON webhook response") from e if not isinstance(data, dict): raise DiscordError("Discord returned an unexpected webhook response") name = data.get("name") channel_id = data.get("channel_id") if not isinstance(name, str) or not name.strip(): name = "" if not isinstance(channel_id, str): channel_id = "" return {"name": name.strip(), "channel_id": channel_id.strip()} def connect(raw_url: str) -> dict: """Verify a pasted webhook URL and return everything needed to store it. Returns ``{webhook_url, name, channel_id}``. Raises ValueError for a malformed URL, DiscordAuthError for a bad token, DiscordNotFoundError for a deleted webhook, and DiscordError for network/API failures. """ webhook_url = normalize_discord_webhook_url(raw_url) info = inspect_webhook(webhook_url) return {"webhook_url": webhook_url, **info} def truncate_content(text: str) -> str: return truncate_chars(text, MAX_CONTENT_CHARS) def build_embed(title: str, url: str, image_url: str) -> dict | None: """An embed carrying title + link + image, or None when nothing fits. Discord fetches embed image URLs server-side. ``image_url`` and ``url`` must be http(s); anything else is dropped rather than shipped to the channel (and rather than failing the post). """ fields = {} if title: fields["title"] = title[:MAX_EMBED_TITLE_CHARS] if url and urlsplit(url).scheme in ("http", "https"): fields["url"] = url if image_url and urlsplit(image_url).scheme in ("http", "https"): fields["image"] = {"url": image_url} if not fields: return None return fields def send_webhook( webhook_url: str, content: str, embed: dict | None = None, ) -> None: """POST one message to the webhook. No return value on success. Discord replies 204 (no ``wait`` query param, so it does not echo the created message back). Content is truncated to Discord's limit here so a long template cannot produce a 400. The URL is re-validated even though connect normalized it, so a tampered stored row cannot redirect the POST. """ try: webhook_url = normalize_discord_webhook_url(webhook_url) except ValueError as e: # A tampered DB row; permanent, since retries cannot fix the URL. raise DiscordBadRequestError(f"Stored webhook URL is malformed: {e}") from e text = truncate_content(content) if not text.strip(): # Permanent: retries cannot turn an empty render into a message. raise DiscordBadRequestError("Cannot send an empty message to Discord") payload: dict = {"content": text} if embed: payload["embeds"] = [embed] try: resp = pinned_request( "POST", webhook_url, timeout=REQUEST_TIMEOUT, json=payload ) except (httpx.HTTPError, SSRFError) as e: # See inspect_webhook: httpx exception text embeds the webhook token. raise DiscordError(f"Could not reach Discord ({type(e).__name__})") from e _raise_for_status(resp, "message send") def test_connection(webhook_url: str) -> tuple[bool, str]: """Verify a stored webhook still works. Backs the accounts page Test button. Deliberately read-only (a GET metadata check, no test message into the user's channel), matching the Matrix and Bluesky test buttons. """ try: info = inspect_webhook(webhook_url) except DiscordAuthError as e: return False, str(e) except DiscordNotFoundError as e: return False, str(e) except DiscordError as e: return False, str(e) where = f" — posts as {info['name']}" if info["name"] else "" return True, f"Webhook OK{where}"