Ctrl K
ComunidadGuías…

discord.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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/<id>/<token>`` 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://(?P<host>discordapp\.com|discord\.com)"
    r"/api/webhooks/(?P<webhook_id>\d{16,20})/(?P<token>[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/<id>/<token>`` 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}"