Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions app/utils/delivery_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Shared HTTP-post transport for outbound message-delivery helpers.

Slack, Discord, and Telegram delivery modules each issue a JSON ``POST``
to a provider endpoint, parse the response body, and return a
``(success, error, ...)`` tuple. The transport pieces of that flow —
making the request, applying a timeout, catching network exceptions, and
attempting JSON decode — are identical; only the success criteria,
authentication scheme, and error-message extraction differ per provider.

This module hosts the shared transport so each delivery module can keep
its provider-specific payload building and result interpretation while
sharing one well-tested HTTP code path.

The helper deliberately does **not** decide whether the call succeeded
at the provider level. It returns ``ok=True`` whenever the request
completed without raising; callers then inspect ``status_code`` and
``data``/``text`` to apply provider semantics (e.g. ``data["ok"]`` for
Slack, ``status_code in (200, 201)`` for Discord, ``status_code == 200``
for Telegram).
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any

import httpx


@dataclass(frozen=True)
class DeliveryResponse:
"""Normalized result of a delivery POST.

Attributes:
ok: ``True`` iff the request completed without raising. This is a
transport-level signal only; provider-level success requires
inspecting ``status_code`` / ``data`` per provider semantics.
status_code: HTTP status code from the response, or ``0`` when the
request itself raised before a response was received.
data: Parsed JSON body when the response was a JSON object,
otherwise an empty mapping. Never ``None``, so callers can
chain ``.get(...)`` safely without a None-check. The mapping
is read-only (``MappingProxyType``) so the frozen dataclass
stays fully immutable end-to-end.
text: Raw response body, useful for fallback error extraction
when the body is not valid JSON or is empty.
error: String form of the exception that aborted the request.
Empty when ``ok`` is True.
exc_type: Class name of the exception that aborted the request
(e.g. ``"TimeoutError"``, ``"ConnectError"``). Empty when
``ok`` is True. Surfaced separately so callers can include
the exception shape in triage logs without parsing ``error``.
Named ``exc_type`` rather than ``type`` because ``type`` is
a Python builtin.
"""

ok: bool
status_code: int = 0
data: Mapping[str, Any] = field(default_factory=dict)
text: str = ""
error: str = ""
exc_type: str = ""

def __post_init__(self) -> None:
# Wrap ``data`` in a read-only view so callers cannot mutate the
# response after the fact and break the frozen-dataclass contract.
# ``object.__setattr__`` is required because ``frozen=True`` blocks
# normal attribute assignment.
if not isinstance(self.data, MappingProxyType):
object.__setattr__(self, "data", MappingProxyType(dict(self.data)))


def post_json(
url: str,
Comment thread
mayankbharati-ops marked this conversation as resolved.
payload: dict[str, Any],
*,
headers: dict[str, str] | None = None,
timeout: float = 15.0,
follow_redirects: bool = False,
) -> DeliveryResponse:
"""POST ``payload`` as JSON to ``url`` and return a normalized result.

On request exceptions the result carries ``ok=False`` and ``error``
set to the exception message — callers are not expected to handle
raised errors. The transport never re-raises.

Args:
url: Absolute URL to post to.
payload: JSON-serializable dict body.
headers: Optional headers (e.g. ``Authorization``). Defaults to
an empty dict; httpx will still set ``Content-Type`` and the
standard headers it manages.
timeout: Request timeout in seconds. Defaults to 15s, matching
the pre-existing per-provider timeouts.
follow_redirects: Whether to follow 3xx redirects. Disabled by
default to match Slack/Discord/Telegram REST APIs (which never
redirect on success). Slack incoming webhooks and the NextJS
``/api/slack`` proxy enable it.

Returns:
``DeliveryResponse`` with ``ok``, ``status_code``, ``data``,
``text``, and ``error`` populated. JSON decode failures are
non-fatal: ``data`` falls back to ``{}`` and ``text`` always
carries the raw body.
"""
try:
response = httpx.post(
url,
json=payload,
headers=headers or {},
timeout=timeout,
follow_redirects=follow_redirects,
)
except Exception as exc: # noqa: BLE001 — transport never re-raises
return DeliveryResponse(ok=False, error=str(exc), exc_type=type(exc).__name__)

text = response.text
data: dict[str, Any] = {}
try:
parsed = response.json()
if isinstance(parsed, dict):
data = parsed
except Exception: # noqa: BLE001 — non-JSON body is permitted
pass

return DeliveryResponse(
ok=True,
status_code=response.status_code,
data=data,
text=text,
)
89 changes: 42 additions & 47 deletions app/utils/discord_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@
from __future__ import annotations

import logging
from collections.abc import Mapping
from typing import Any

import httpx
from app.utils.delivery_transport import post_json

logger = logging.getLogger(__name__)


def _discord_auth_headers(bot_token: str) -> dict[str, str]:
# ``Content-Type: application/json`` is set automatically by httpx when
# the request uses the ``json=`` kwarg, so we only need to add auth.
return {"Authorization": f"Bot {bot_token}"}


def _discord_error_from_data(data: Mapping[str, Any]) -> str:
return str(data.get("message", data.get("error", "unknown")))


def post_discord_message(
channel_id: str,
embeds: list[dict[str, Any]],
Expand All @@ -21,29 +32,22 @@ def post_discord_message(
Returns True on success, False on expected failures.
"""
logger.debug("[discord] post message params channel_id: %s", channel_id)
try:
resp = httpx.post(
url=f"https://discord.com/api/v10/channels/{channel_id}/messages",
json={"content": content, "embeds": embeds},
headers={
"Authorization": f"Bot {bot_token}",
"Content-Type": "application/json; charset=utf-8",
},
timeout=15.0,
)
data = resp.json()
error_message = ""
if resp.status_code not in (200, 201):
logger.warning("[discord] post message failed: %s", resp.status_code)
logger.warning("[discord] api response %s", data)
error_message = data.get("message", data.get("error", "unknown"))
logger.warning("[discord] post message failed: %s", error_message)
return False, error_message, ""
message_id: str = str(data.get("id") or "")
return True, error_message, message_id
except Exception as exc: # noqa: BLE001
logger.warning("[discord] post message exception: %s", exc)
return False, str(exc), ""
response = post_json(
url=f"https://discord.com/api/v10/channels/{channel_id}/messages",
payload={"content": content, "embeds": embeds},
headers=_discord_auth_headers(bot_token),
)
if not response.ok:
logger.warning("[discord] post message exception: %s", response.error)
return False, response.error, ""
if response.status_code not in (200, 201):
logger.warning("[discord] post message failed: %s", response.status_code)
logger.warning("[discord] api response %s", response.data)
error_message = _discord_error_from_data(response.data)
logger.warning("[discord] post message failed: %s", error_message)
return False, error_message, ""
message_id = str(response.data.get("id") or "")
return True, "", message_id


def create_discord_thread(
Expand All @@ -56,29 +60,20 @@ def create_discord_thread(

Returns True on success, False on expected failures.
"""
try:
resp = httpx.post(
url=(
f"https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}/threads"
),
json={"name": name, "auto_archive_duration": 1440},
headers={
"Authorization": f"Bot {bot_token}",
"Content-Type": "application/json; charset=utf-8",
},
timeout=15.0,
)
data = resp.json()
error_message = ""
if resp.status_code not in (200, 201):
error_message = data.get("message", data.get("error", "unknown"))
logger.warning("[discord] create thread failed: %s", error_message)
return False, error_message, ""
thread_id: str = str(data.get("id") or "")
return True, error_message, thread_id
except Exception as exc: # noqa: BLE001
logger.warning("[discord] create thread exception: %s", exc)
return False, str(exc), ""
response = post_json(
url=f"https://discord.com/api/v10/channels/{channel_id}/messages/{message_id}/threads",
payload={"name": name, "auto_archive_duration": 1440},
headers=_discord_auth_headers(bot_token),
)
if not response.ok:
logger.warning("[discord] create thread exception: %s", response.error)
return False, response.error, ""
if response.status_code not in (200, 201):
error_message = _discord_error_from_data(response.data)
logger.warning("[discord] create thread failed: %s", error_message)
return False, error_message, ""
thread_id = str(response.data.get("id") or "")
return True, "", thread_id


_EMBED_TITLE_LIMIT = 256
Expand Down
Loading
Loading