diff --git a/.env.example b/.env.example index 1921a33..6fab4f2 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ RAIDHUB_API_BASE_URL=http://localhost:8000 RAIDHUB_API_KEY= # Same value as RaidHub-API JWT_SECRET if you use signed Discord calls. RAIDHUB_JWT_SECRET= +# Leave empty unless you intend to send events to your own Sentry project. +SENTRY_DSN= +SENTRY_ENVIRONMENT=development +SENTRY_RELEASE= +SENTRY_SEND_DEFAULT_PII=true +SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 17a10fd..f52a2e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,11 @@ jobs: python -m pip install --upgrade pip python -m pip install -e . + - name: Build package + run: | + python -m pip install build + python -m build + - name: Dry run Discord command sync env: DISCORD_APPLICATION_ID: "0" diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index bfc4498..1d7b6fd 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.14" cache: "pip" - name: Install dependencies @@ -26,7 +26,7 @@ jobs: python -m pip install -e ".[dev]" - name: Syntax check - run: python -m py_compile src/*.py + run: python -m compileall -q src - name: Lint run: python -m ruff check . diff --git a/.github/workflows/start.yml b/.github/workflows/start.yml index a0dbfbb..281e1db 100644 --- a/.github/workflows/start.yml +++ b/.github/workflows/start.yml @@ -28,6 +28,11 @@ jobs: python -m pip install --upgrade pip python -m pip install -e . + - name: Build package + run: | + python -m pip install build + python -m build + - name: Dry run Discord command sync env: DISCORD_APPLICATION_ID: "0" diff --git a/README.md b/README.md index 1f62bca..ae64d5a 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,23 @@ # raidhub-discord -Python Discord ingress service for RaidHub. - -## Setup - -1. Copy `.env.example` to `.env` and fill required values. For local RaidHub API with `PROD=true`, add the same key to `RaidHub-API/api-keys.json` (see `api-keys.example.json`) and set `RAIDHUB_API_KEY` here to match. -2. Create a venv (Python 3.14+) and install: - - `/usr/local/opt/python@3.14/bin/python3.14 -m venv .venv` - - `.venv/bin/python -m pip install --upgrade pip` - - `.venv/bin/python -m pip install -e ".[dev]"` -3. Run **using that interpreter** (otherwise you get `ModuleNotFoundError: No module named 'jwt'` — PyJWT is only installed in the venv): - - `.venv/bin/uvicorn src.main:app --reload --port 8787` - -## Sync commands - -- Global sync: - - `sync-discord-commands` -- Guild sync (faster propagation): - - set `DISCORD_GUILD_ID` in `.env`, then run `sync-discord-commands` -- Dry run: - - set `DISCORD_SYNC_DRY_RUN=true` - -## Notes - -- Logging: import a subsystem logger from `src/log.py` (`ingress`, `raidhub_api`, `pagination`, `handlers`) — do not construct `Logger` outside that file. Implementation is `src/structured_logger.py` (same line shape as RaidHub-Services: `{ISO8601} [LEVEL][PREFIX] -- LOG_KEY >> k=v`), uppercase event keys, optional `LOG_LEVEL` (`debug` / `info` / `warn` / `error`). -- Interaction callback numeric types come from Discord’s official [`discord-interactions`](https://pypi.org/project/discord-interactions/) package (`InteractionType`, `InteractionResponseType`). Command/component option types use `src/discord_v10_enums.py` (mirrors API v10; there is no small third-party package that covers every enum). -- `/interactions` verifies Discord signatures. -- RaidHub API calls send `x-api-key` when `RAIDHUB_API_KEY` is set (required when the API runs with `PROD=true`). -- Optional `Authorization: Discord ` uses `RAIDHUB_JWT_SECRET` (same value as RaidHub `JWT_SECRET` if you sign Discord-context payloads). -- Slash commands **`player-search`** and **`instance`** call the RaidHub API (`GET /player/search`, `GET /instance/:id`), defer the interaction, then PATCH the follow-up message. -- **Discord vs RaidHub errors:** Discord’s **POST `/interactions`** must return **HTTP 2xx** within a few seconds (this app usually responds with deferred `type: 5` first). You cannot retroactively change that to 504 after deferring. When RaidHub returns **HTTP 5xx**, `request_envelope` maps to `RaidHubApiServerError` and the **PATCH** to `@original` uses a short user-facing message (no raw URLs, tokens, or stack traces). Discord **PATCH** failures (e.g. 400 invalid form body) are logged with response body; users see a generic “could not update” line only. -- **Pagination:** `src/pagination/` stores session state and dispatches `prefix:session_id:nav_token`. Offset rows from `build_pager_action_row` use **`p{n}` / `n{n}`** tokens (unique `custom_id`s even for one page); decode with `parse_offset_page_nav_token`. Use `build_dual_nav_action_row` for arbitrary cursor/action tokens. Single-process unless you replace the store. +Backend for the RaidHub Discord application: it receives [Discord interactions](https://discord.com/developers/docs/interactions/receiving-and-responding) over HTTP, checks request signatures, registers slash commands, and proxies the relevant work to the RaidHub API (search, instances, channel subscriptions, and related flows). It is meant to run as a small always-on service (for example behind your ingress), not as something you embed in other apps. + +## Quick start + +1. Copy `.env.example` to `.env` and set Discord and RaidHub values there. Use an API base URL this process can actually reach (a cloud-hosted bot cannot call `http://localhost:8000` on your laptop). +2. Use Python **3.14+**, create a virtualenv, then `pip install -e ".[dev]"` from this directory. +3. Run the app, for example: `uvicorn src.main:app --reload --port 8787` (use the same interpreter you installed into). + +When the RaidHub API runs with production-style auth, configure the API key in `.env` to match what the API expects (see comments in `.env.example`). + +## Slash command sync + +After changing command definitions, push them to Discord with the `sync-discord-commands` console script from this package. + +- Leave `DISCORD_GUILD_ID` unset for **global** commands (slower to propagate everywhere). +- Set `DISCORD_GUILD_ID` for **guild** commands while iterating (updates show up quickly in that server). +- Set `DISCORD_SYNC_DRY_RUN=true` to print the payload without calling Discord. + +## Observability (optional) + +Logging level is controlled with `LOG_LEVEL`. You can point `SENTRY_DSN` (and related `SENTRY_*` variables) at Sentry for error reporting; see `.env.example` for the full set. diff --git a/pyproject.toml b/pyproject.toml index 3daa113..ab0891a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + [project] name = "raidhub-discord" version = "0.1.0" @@ -12,10 +16,15 @@ dependencies = [ "python-dotenv", "PyJWT", "prometheus-client", + "sentry-sdk[fastapi]", ] [project.scripts] -sync-discord-commands = "cli_sync_commands:cli" +sync-discord-commands = "src.cli_sync_commands:cli" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src"] [project.optional-dependencies] dev = [ @@ -29,3 +38,4 @@ line-length = 100 [tool.mypy] python_version = "3.14" strict = false +ignore_missing_imports = true diff --git a/src/app_factory.py b/src/app_factory.py new file mode 100644 index 0000000..9eea212 --- /dev/null +++ b/src/app_factory.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import json +import time +from contextlib import asynccontextmanager +from typing import Any, Awaitable, Callable + +from discord_interactions import InteractionResponseType, InteractionType +from fastapi import BackgroundTasks, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from starlette.responses import Response + +from .commands import ( + register_player_search_pager, + run_instance_deferred, + run_player_search_deferred, + run_subscribe_deferred, + run_subscription_deferred, + run_unsubscribe_clan_deferred, + run_unsubscribe_deferred, + run_unsubscribe_player_deferred, +) +from .config import Settings, get_settings +from .discord_auth import verify_discord_signature_with_reason +from .log import ingress +from .pagination import try_handle_pager_component +from .prom_metrics import metrics_response, observe_interaction +from .raidhub_client import RaidHubClient +from .sentry_init import init_sentry + +CommandHandler = Callable[[dict[str, Any], RaidHubClient, Settings], Awaitable[None]] + + +def create_app() -> FastAPI: + settings = get_settings() + init_sentry(settings) + + raidhub = RaidHubClient( + settings.raidhub_api_base_url, + settings.raidhub_jwt_secret, + api_key=settings.raidhub_api_key, + ) + register_player_search_pager(raidhub) + + command_handlers: dict[str, CommandHandler] = { + "instance": run_instance_deferred, + "player-search": run_player_search_deferred, + "subscribe": run_subscribe_deferred, + "subscription": run_subscription_deferred, + "unsubscribe": run_unsubscribe_deferred, + "unsubscribe-player": run_unsubscribe_player_deferred, + "unsubscribe-clan": run_unsubscribe_clan_deferred, + } + + def _msg(content: str) -> dict[str, Any]: + return { + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": {"content": content}, + } + + def _validate_startup_settings() -> None: + if not settings.discord_public_key: + ingress.warn( + "DISCORD_PUBLIC_KEY_NOT_CONFIGURED", + None, + {"env_key": "DISCORD_PUBLIC_KEY"}, + ) + + @asynccontextmanager + async def lifespan(_: FastAPI): + _validate_startup_settings() + yield + + app = FastAPI(title="raidhub-discord", lifespan=lifespan) + + @app.get("/metrics") + async def prometheus_metrics() -> Response: + return metrics_response() + + @app.post("/interactions") + async def discord_interactions( + request: Request, + background_tasks: BackgroundTasks, + ) -> JSONResponse: + t0 = time.perf_counter() + signature = request.headers.get("X-Signature-Ed25519", "") + timestamp = request.headers.get("X-Signature-Timestamp", "") + raw_body = await request.body() + signature_ok, signature_reason = verify_discord_signature_with_reason( + settings.discord_public_key, timestamp, raw_body, signature + ) + + ingress.info( + "DISCORD_INTERACTION_RECEIVED", + { + "path": str(request.url.path), + "remote_ip": request.client.host if request.client else None, + "has_signature": bool(signature), + "has_timestamp": bool(timestamp), + "has_public_key": bool(settings.discord_public_key), + "signature_valid": signature_ok, + "signature_reason": signature_reason, + "signature_len": len(signature.strip()), + "timestamp_len": len(timestamp.strip()), + "body_len": len(raw_body), + }, + ) + + if not signature_ok: + ingress.warn( + "DISCORD_SIGNATURE_INVALID", + None, + { + "reason": signature_reason, + "signature_len": len(signature.strip()), + "timestamp_len": len(timestamp.strip()), + "body_len": len(raw_body), + }, + ) + observe_interaction( + handler="signature_invalid", status="rejected", started_monotonic=t0 + ) + raise HTTPException(status_code=401, detail="Invalid Discord signature") + + try: + interaction = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + ingress.warn( + "DISCORD_INTERACTION_INVALID_JSON", + None, + {"body_len": len(raw_body)}, + ) + observe_interaction( + handler="invalid_json", status="rejected", started_monotonic=t0 + ) + return JSONResponse(_msg("Invalid interaction payload."), status_code=400) + interaction_type = interaction.get("type") + ingress.info("DISCORD_INTERACTION_TYPE", {"type": interaction_type}) + + if interaction_type == InteractionType.PING: + ingress.info("DISCORD_PING_RECEIVED", {}) + observe_interaction(handler="ping", status="ok", started_monotonic=t0) + return JSONResponse({"type": InteractionResponseType.PONG}) + + if interaction_type == InteractionType.MESSAGE_COMPONENT: + updated = await try_handle_pager_component(interaction) + if not updated: + ingress.warn("DISCORD_COMPONENT_UNSUPPORTED", None, {}) + observe_interaction( + handler="message_component_unsupported", + status="ok", + started_monotonic=t0, + ) + return JSONResponse( + { + "type": InteractionResponseType.UPDATE_MESSAGE, + "data": {"content": "Unsupported interaction component."}, + } + ) + observe_interaction( + handler="message_component_pager", + status="ok", + started_monotonic=t0, + ) + return JSONResponse( + {"type": InteractionResponseType.UPDATE_MESSAGE, "data": updated} + ) + + if interaction_type != InteractionType.APPLICATION_COMMAND: + ingress.warn("DISCORD_INTERACTION_UNSUPPORTED", None, {"type": interaction_type}) + observe_interaction( + handler="application_command_unsupported_type", + status="ok", + started_monotonic=t0, + ) + return JSONResponse(_msg("Unsupported interaction type.")) + + name = interaction.get("data", {}).get("name") + ingress.info("DISCORD_COMMAND_RECEIVED", {"command_name": name}) + handler = command_handlers.get(name) + if handler: + background_tasks.add_task(handler, interaction, raidhub, settings) + handler_label = str(name).replace("-", "_") + observe_interaction( + handler=f"application_command_{handler_label}_deferred", + status="ok", + started_monotonic=t0, + ) + return JSONResponse( + {"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} + ) + + ingress.warn("DISCORD_COMMAND_NOT_ENABLED", None, {"command_name": name}) + observe_interaction( + handler="application_command_unknown", + status="ok", + started_monotonic=t0, + ) + return JSONResponse(_msg("Command not enabled yet."), status_code=200) + + return app diff --git a/src/cli_sync_commands.py b/src/cli_sync_commands.py index 52860fe..54407d7 100644 --- a/src/cli_sync_commands.py +++ b/src/cli_sync_commands.py @@ -1,13 +1,7 @@ from __future__ import annotations -from pathlib import Path -import sys -# Editable installs in this repo currently add `/src` to sys.path. -# Ensure the repository root is also present so `src.sync_commands` resolves. -_REPO_ROOT = str(Path(__file__).resolve().parent.parent) -if _REPO_ROOT not in sys.path: - sys.path.insert(0, _REPO_ROOT) - -from src.sync_commands import cli +def cli() -> int: + from src.sync_commands import cli as sync_cli + return sync_cli() diff --git a/src/command_manifest.py b/src/command_manifest.py deleted file mode 100644 index 6f8057a..0000000 --- a/src/command_manifest.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import IntEnum -from typing import Any - -from .discord_v10_enums import ApplicationCommandOptionType, ApplicationCommandType - - -class CommandType(IntEnum): - CHAT_INPUT = int(ApplicationCommandType.CHAT_INPUT) - - -class CommandOptionType(IntEnum): - SUB_COMMAND = int(ApplicationCommandOptionType.SUB_COMMAND) - STRING = int(ApplicationCommandOptionType.STRING) - INTEGER = int(ApplicationCommandOptionType.INTEGER) - BOOLEAN = int(ApplicationCommandOptionType.BOOLEAN) - - -@dataclass(frozen=True, slots=True) -class CommandOptionDto: - type: CommandOptionType - name: str - description: str - required: bool | None = None - options: list["CommandOptionDto"] | None = None - - def to_json(self) -> dict[str, Any]: - data: dict[str, Any] = { - "type": int(self.type), - "name": self.name, - "description": self.description, - } - if self.required is not None: - data["required"] = self.required - if self.options is not None: - data["options"] = [o.to_json() for o in self.options] - return data - - -@dataclass(frozen=True, slots=True) -class CommandDto: - name: str - description: str - type: CommandType = CommandType.CHAT_INPUT - dm_permission: bool | None = None - options: list[CommandOptionDto] | None = None - - def to_json(self) -> dict[str, Any]: - data: dict[str, Any] = { - "name": self.name, - "description": self.description, - "type": int(self.type), - } - if self.dm_permission is not None: - data["dm_permission"] = self.dm_permission - if self.options is not None: - data["options"] = [o.to_json() for o in self.options] - return data - - -def _subscription_filter_options() -> list[CommandOptionDto]: - return [ - CommandOptionDto( - type=CommandOptionType.BOOLEAN, - name="require_fresh", - description="Only notify for fresh (first-week) clears", - required=False, - ), - CommandOptionDto( - type=CommandOptionType.BOOLEAN, - name="require_completed", - description="Only notify when the activity completes", - required=False, - ), - CommandOptionDto( - type=CommandOptionType.STRING, - name="players", - description="Comma-separated Destiny membership IDs to filter to", - required=False, - ), - CommandOptionDto( - type=CommandOptionType.STRING, - name="clans", - description="Comma-separated Bungie clan group IDs to filter to", - required=False, - ), - ] - - -def build_command_manifest() -> list[dict[str, Any]]: - commands: list[CommandDto] = [ - CommandDto( - name="instance", - description="Lookup a RaidHub instance by id.", - options=[ - CommandOptionDto( - type=CommandOptionType.STRING, - name="raid_instance_id", - description="Instance id to lookup", - required=False, - ) - ], - ), - CommandDto( - name="player-search", - description="Search RaidHub players by Bungie name or platform name.", - options=[ - CommandOptionDto( - type=CommandOptionType.STRING, - name="search_query", - description="Search text", - required=True, - ), - CommandOptionDto( - type=CommandOptionType.INTEGER, - name="max_results", - description="Max results to return", - required=False, - ), - CommandOptionDto( - type=CommandOptionType.INTEGER, - name="destiny_membership_type", - description="Destiny membership type", - required=False, - ), - CommandOptionDto( - type=CommandOptionType.BOOLEAN, - name="use_global_name_search", - description="Search by Bungie name", - required=False, - ), - ], - ), - CommandDto( - name="subscribe", - description="Subscribe this channel to a player or clan (resolves names & URLs here).", - dm_permission=False, - options=[ - CommandOptionDto( - type=CommandOptionType.SUB_COMMAND, - name="player", - description="Subscribe by membership id or player name (top search hit).", - options=[ - CommandOptionDto( - type=CommandOptionType.STRING, - name="player_id_or_search_text", - description="Destiny membership id (digits) or player search text", - required=True, - ) - ], - ), - CommandOptionDto( - type=CommandOptionType.SUB_COMMAND, - name="clan", - description="Subscribe by Bungie clan group id or clan page URL.", - options=[ - CommandOptionDto( - type=CommandOptionType.STRING, - name="clan_group_id_or_url", - description="Numeric group id, raidhub.io/clan/…, or Bungie clan URL", - required=True, - ) - ], - ), - ], - ), - CommandDto( - name="subscription", - description="Manage RaidHub subscription webhooks for this channel.", - dm_permission=False, - options=[ - CommandOptionDto( - type=CommandOptionType.SUB_COMMAND, - name="register", - description="Create a webhook here and register it with RaidHub.", - options=[ - CommandOptionDto( - type=CommandOptionType.STRING, - name="discord_webhook_name", - description="Name shown in Discord for the incoming webhook", - required=False, - ), - *_subscription_filter_options(), - ], - ), - CommandOptionDto( - type=CommandOptionType.SUB_COMMAND, - name="update", - description="Update filters or targets for this channel.", - options=_subscription_filter_options(), - ), - CommandOptionDto( - type=CommandOptionType.SUB_COMMAND, - name="status", - description="Show whether this channel is registered and delivery health.", - options=[], - ), - ], - ), - CommandDto( - name="unsubscribe", - description="Remove the RaidHub subscription webhook from this channel.", - dm_permission=False, - options=[], - ), - ] - return [c.to_json() for c in commands] diff --git a/src/commands/__init__.py b/src/commands/__init__.py index d2d987a..b89d706 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -2,7 +2,11 @@ from .player_search import register_player_search_pager, run_player_search_deferred from .subscribe import run_subscribe_deferred from .subscription import run_subscription_deferred -from .unsubscribe import run_unsubscribe_deferred +from .unsubscribe import ( + run_unsubscribe_clan_deferred, + run_unsubscribe_deferred, + run_unsubscribe_player_deferred, +) __all__ = [ "register_player_search_pager", @@ -10,5 +14,7 @@ "run_player_search_deferred", "run_subscribe_deferred", "run_subscription_deferred", + "run_unsubscribe_clan_deferred", "run_unsubscribe_deferred", + "run_unsubscribe_player_deferred", ] diff --git a/src/commands/instance.py b/src/commands/instance.py index 8cfd9aa..a65f9c8 100644 --- a/src/commands/instance.py +++ b/src/commands/instance.py @@ -3,7 +3,6 @@ from typing import Any from ..config import Settings -from ..log import handlers from ..prom_metrics import observe_deferred_completion from ..raidhub_client import RaidHubClient from .shared import ( @@ -12,6 +11,7 @@ discord_message_for_failed_envelope, flatten_options, patch_discord_followup_best_effort, + report_deferred_exception, ) @@ -102,9 +102,13 @@ async def run_instance_deferred( await patch_discord_followup_best_effort(app_id, token, {"embeds": [embed]}) except Exception as err: outcome = "error" - handlers.error("INSTANCE_DEFERRED_FAILED", err, {}) - await patch_discord_followup_best_effort( - app_id, token, {"content": USER_FACING_GENERIC} + await report_deferred_exception( + command="instance", + log_key="INSTANCE_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload={"content": USER_FACING_GENERIC}, ) finally: observe_deferred_completion(command="instance", outcome=outcome) diff --git a/src/commands/player_search.py b/src/commands/player_search.py index f70b8da..63738c1 100644 --- a/src/commands/player_search.py +++ b/src/commands/player_search.py @@ -1,185 +1,24 @@ from __future__ import annotations -from datetime import datetime, timezone from typing import Any from ..config import Settings -from ..log import handlers -from ..pagination import ( - build_triple_nav_action_row, - parse_offset_page_nav_token, - register_pager, - store_paged_session, -) +from ..pagination import store_paged_session from ..prom_metrics import observe_deferred_completion from ..raidhub_client import RaidHubClient +from .player_search_helpers import ( + PLAYER_SEARCH_PAGE_SIZE, + player_search_render_from_state, + register_player_search_pager, +) from .shared import ( USER_FACING_GENERIC, application_id, - discord_message_for_failed_envelope, flatten_options, patch_discord_followup_best_effort, + report_deferred_exception, ) -PLAYER_SEARCH_PREFIX = "ps" -PLAYER_SEARCH_PAGE_SIZE = 10 -_PLAYER_SEARCH_FIRST_PAGE_TOKEN = "0" - - -def _format_player_name(player: dict[str, Any]) -> str: - bungie = player.get("bungieGlobalDisplayName") - if bungie: - code = player.get("bungieGlobalDisplayNameCode") - suffix = f"#{code}" if code else "" - return f"{bungie}{suffix}" - return str(player.get("displayName") or "Unknown Player") - - -def _membership_id_str(player: dict[str, Any]) -> str: - mid = player.get("membershipId") - if mid is None: - return "" - return str(mid) - - -def _raidhub_profile_url(membership_id: str) -> str: - return f"https://raidhub.io/profile/{membership_id}" - - -def _discord_relative_timestamp(iso_value: Any) -> str: - if iso_value is None: - return "—" - s = str(iso_value).strip() - if not s: - return "—" - if s.endswith("Z"): - s = s[:-1] + "+00:00" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - ts = int(dt.timestamp()) - return f"" - - -def _embed_markdown_link_label(label: str) -> str: - return label.replace("]", "") - - -def _format_player_search_line(rank: int, player: dict[str, Any]) -> str: - name = _format_player_name(player) - mid = _membership_id_str(player) - seen = _discord_relative_timestamp(player.get("lastSeen")) - if not mid: - return f"**{rank}.** {name} · last seen {seen}" - label = _embed_markdown_link_label(name) - rh = _raidhub_profile_url(mid) - return f"**{rank}.** [{label}]({rh}) · last seen {seen}" - - -async def _player_search_render_from_state( - raidhub: RaidHubClient, - state: dict[str, Any], - session_id: str, - nav_token: str, -) -> dict[str, Any]: - page = parse_offset_page_nav_token(nav_token, default=0) - if page < 0: - page = 0 - - qp = dict(state.get("query_params") or {}) - query = str(qp.get("query") or "").strip() - if not query: - return {"content": USER_FACING_GENERIC} - - page_size = int(state.get("page_size") or PLAYER_SEARCH_PAGE_SIZE) - offset = page * page_size - - params: dict[str, Any] = {"query": query, "count": page_size, "offset": offset} - if "membershipType" in qp: - params["membershipType"] = qp["membershipType"] - if "global" in qp: - params["global"] = qp["global"] - - env = await raidhub.request_envelope("GET", "/player/search", params=params) - if not env.get("success"): - code = str(env.get("code", "Error")) - return { - "content": discord_message_for_failed_envelope(code, ""), - "components": [], - } - - inner = env.get("response") or {} - results = list(inner.get("results") or []) - q_label = str((inner.get("params") or {}).get("query", query)) - has_more = len(results) == page_size - - if not results: - if page == 0: - return {"content": f"No players found for `{q_label}`."} - return { - "content": ( - f"No more results on this page for `{q_label}`. " - "Use **Start** to return to the first page or **Prev**." - ), - "components": [ - build_triple_nav_action_row( - prefix=PLAYER_SEARCH_PREFIX, - session_id=session_id, - first_nav_token=_PLAYER_SEARCH_FIRST_PAGE_TOKEN, - prev_nav_token=f"p{page - 1}", - next_nav_token=f"n{page}", - first_disabled=False, - prev_disabled=False, - next_disabled=True, - ) - ], - } - - lines = [ - _format_player_search_line(offset + i + 1, player) - for i, player in enumerate(results) - ] - range_hi = offset + len(results) - header = ( - f"Query: `{q_label}`\nPage **{page + 1}** — **{offset + 1}–{range_hi}** " - f"({len(results)} on this page).\n\n" - ) - description = header + "\n".join(lines) - if len(description) > 4096: - description = description[:4093] + "..." - - embed = { - "title": "Player Search Results", - "description": description, - "color": 0x57_F287, - } - pager = build_triple_nav_action_row( - prefix=PLAYER_SEARCH_PREFIX, - session_id=session_id, - first_nav_token=_PLAYER_SEARCH_FIRST_PAGE_TOKEN, - prev_nav_token=f"p{page - 1}", - next_nav_token=f"n{page + 1}", - first_disabled=page <= 0, - prev_disabled=page <= 0, - next_disabled=not has_more, - ) - return {"embeds": [embed], "components": [pager]} - - -def register_player_search_pager(raidhub: RaidHubClient) -> None: - async def _render( - st: dict[str, Any], session_id: str, nav_token: str - ) -> dict[str, Any]: - return await _player_search_render_from_state( - raidhub, st, session_id, nav_token - ) - - register_pager( - PLAYER_SEARCH_PREFIX, - _render, - expired_message="This player search session expired. Run the command again.", - ) - async def run_player_search_deferred( interaction: dict[str, Any], @@ -212,7 +51,7 @@ async def run_player_search_deferred( session_id = store_paged_session( {"query_params": query_params, "page_size": page_size} ) - payload = await _player_search_render_from_state( + payload = await player_search_render_from_state( raidhub, {"query_params": query_params, "page_size": page_size}, session_id, @@ -221,9 +60,13 @@ async def run_player_search_deferred( await patch_discord_followup_best_effort(app_id, token, payload) except Exception as err: outcome = "error" - handlers.error("PLAYER_SEARCH_DEFERRED_FAILED", err, {}) - await patch_discord_followup_best_effort( - app_id, token, {"content": USER_FACING_GENERIC} + await report_deferred_exception( + command="player-search", + log_key="PLAYER_SEARCH_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload={"content": USER_FACING_GENERIC}, ) finally: observe_deferred_completion(command="player-search", outcome=outcome) diff --git a/src/commands/player_search_helpers.py b/src/commands/player_search_helpers.py new file mode 100644 index 0000000..11d384b --- /dev/null +++ b/src/commands/player_search_helpers.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any + +from ..pagination import ( + build_triple_nav_action_row, + parse_offset_page_nav_token, + register_pager, +) +from ..raidhub_client import RaidHubClient +from .shared import ( + USER_FACING_GENERIC, + discord_message_for_failed_envelope, + iso_to_discord_relative, +) + +PLAYER_SEARCH_PREFIX = "ps" +PLAYER_SEARCH_PAGE_SIZE = 10 +PLAYER_SEARCH_FIRST_PAGE_TOKEN = "0" + + +def format_player_name(player: dict[str, Any]) -> str: + bungie = player.get("bungieGlobalDisplayName") + if bungie: + code = player.get("bungieGlobalDisplayNameCode") + suffix = f"#{code}" if code else "" + return f"{bungie}{suffix}" + return str(player.get("displayName") or "Unknown Player") + + +def membership_id_str(player: dict[str, Any]) -> str: + mid = player.get("membershipId") + if mid is None: + return "" + return str(mid) + + +def raidhub_profile_url(membership_id: str) -> str: + return f"https://raidhub.io/profile/{membership_id}" + + +def embed_markdown_link_label(label: str) -> str: + return label.replace("]", "") + + +def format_player_search_line(rank: int, player: dict[str, Any]) -> str: + name = format_player_name(player) + mid = membership_id_str(player) + seen = iso_to_discord_relative(player.get("lastSeen")) + if not mid: + return f"**{rank}.** {name} · last seen {seen}" + label = embed_markdown_link_label(name) + rh = raidhub_profile_url(mid) + return f"**{rank}.** [{label}]({rh}) · last seen {seen}" + + +async def player_search_render_from_state( + raidhub: RaidHubClient, + state: dict[str, Any], + session_id: str, + nav_token: str, +) -> dict[str, Any]: + page = parse_offset_page_nav_token(nav_token, default=0) + if page < 0: + page = 0 + + qp = dict(state.get("query_params") or {}) + query = str(qp.get("query") or "").strip() + if not query: + return {"content": USER_FACING_GENERIC} + + page_size = int(state.get("page_size") or PLAYER_SEARCH_PAGE_SIZE) + offset = page * page_size + + params: dict[str, Any] = {"query": query, "count": page_size, "offset": offset} + if "membershipType" in qp: + params["membershipType"] = qp["membershipType"] + if "global" in qp: + params["global"] = qp["global"] + + env = await raidhub.request_envelope("GET", "/player/search", params=params) + if not env.get("success"): + code = str(env.get("code", "Error")) + return { + "content": discord_message_for_failed_envelope(code, ""), + "components": [], + } + + inner = env.get("response") or {} + results = list(inner.get("results") or []) + q_label = str((inner.get("params") or {}).get("query", query)) + has_more = len(results) == page_size + + if not results: + if page == 0: + return {"content": f"No players found for `{q_label}`."} + return { + "content": ( + f"No more results on this page for `{q_label}`. " + "Use **Start** to return to the first page or **Prev**." + ), + "components": [ + build_triple_nav_action_row( + prefix=PLAYER_SEARCH_PREFIX, + session_id=session_id, + first_nav_token=PLAYER_SEARCH_FIRST_PAGE_TOKEN, + prev_nav_token=f"p{page - 1}", + next_nav_token=f"n{page}", + first_disabled=False, + prev_disabled=False, + next_disabled=True, + ) + ], + } + + lines = [ + format_player_search_line(offset + i + 1, player) + for i, player in enumerate(results) + ] + range_hi = offset + len(results) + header = ( + f"Query: `{q_label}`\nPage **{page + 1}** — **{offset + 1}–{range_hi}** " + f"({len(results)} on this page).\n\n" + ) + description = header + "\n".join(lines) + if len(description) > 4096: + description = description[:4093] + "..." + + embed = { + "title": "Player Search Results", + "description": description, + "color": 0x57_F287, + } + pager = build_triple_nav_action_row( + prefix=PLAYER_SEARCH_PREFIX, + session_id=session_id, + first_nav_token=PLAYER_SEARCH_FIRST_PAGE_TOKEN, + prev_nav_token=f"p{page - 1}", + next_nav_token=f"n{page + 1}", + first_disabled=page <= 0, + prev_disabled=page <= 0, + next_disabled=not has_more, + ) + return {"embeds": [embed], "components": [pager]} + + +def register_player_search_pager(raidhub: RaidHubClient) -> None: + async def _render( + st: dict[str, Any], session_id: str, nav_token: str + ) -> dict[str, Any]: + return await player_search_render_from_state(raidhub, st, session_id, nav_token) + + register_pager( + PLAYER_SEARCH_PREFIX, + _render, + expired_message="This player search session expired. Run the command again.", + ) diff --git a/src/commands/shared.py b/src/commands/shared.py index 4f1373d..e2b7ea1 100644 --- a/src/commands/shared.py +++ b/src/commands/shared.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from typing import Any import httpx @@ -21,12 +22,24 @@ def base_embed( description: str, color: int, fields: list[dict[str, Any]] | None = None, + thumbnail_url: str | None = None, + author_name: str | None = None, + author_icon_url: str | None = None, ) -> dict[str, Any]: embed: dict[str, Any] = { "title": title, "description": description[:4096], "color": color, } + if author_name or author_icon_url: + author: dict[str, Any] = {} + if author_name: + author["name"] = author_name[:256] + if author_icon_url: + author["icon_url"] = author_icon_url[:2048] + embed["author"] = author + if thumbnail_url: + embed["thumbnail"] = {"url": thumbnail_url[:2048]} if fields: embed["fields"] = fields[:25] return {"embeds": [embed], "components": []} @@ -36,8 +49,22 @@ def info_embed(title: str, description: str) -> dict[str, Any]: return base_embed(title=title, description=description, color=0x5865_F2) -def success_embed(title: str, description: str) -> dict[str, Any]: - return base_embed(title=title, description=description, color=0x57_F287) +def success_embed( + title: str, + description: str, + *, + thumbnail_url: str | None = None, + author_name: str | None = None, + author_icon_url: str | None = None, +) -> dict[str, Any]: + return base_embed( + title=title, + description=description, + color=0x57_F287, + thumbnail_url=thumbnail_url, + author_name=author_name, + author_icon_url=author_icon_url, + ) def warn_embed(title: str, description: str) -> dict[str, Any]: @@ -79,6 +106,24 @@ def flatten_options(options: list[dict[str, Any]] | None) -> dict[str, Any]: return out +def iso_to_discord_relative(iso_value: Any) -> str: + if iso_value is None: + return "—" + s = str(iso_value).strip() + if not s: + return "—" + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(s) + except ValueError: + return "—" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + ts = int(dt.timestamp()) + return f"" + + def application_id(interaction: dict[str, Any], settings: Settings) -> str: return str( interaction.get("application_id") or settings.discord_application_id or "" @@ -125,3 +170,27 @@ async def patch_discord_followup_best_effort( interaction_token, {"content": USER_FACING_DISCORD_UPDATE_FAILED}, ) + + +async def report_deferred_exception( + *, + command: str, + log_key: str, + err: Exception, + discord_application_id: str, + interaction_token: str, + user_message_payload: dict[str, Any], +) -> None: + handlers.error( + log_key, + err, + { + "command": command, + "component": "discord_deferred_handler", + "application_id": discord_application_id, + "has_interaction_token": bool(interaction_token), + }, + ) + await patch_discord_followup_best_effort( + discord_application_id, interaction_token, user_message_payload + ) diff --git a/src/commands/subscribe.py b/src/commands/subscribe.py index e8082b0..3ed7622 100644 --- a/src/commands/subscribe.py +++ b/src/commands/subscribe.py @@ -4,74 +4,36 @@ from __future__ import annotations -import re from typing import Any -from urllib.parse import urlparse from ..config import Settings -from ..log import handlers from ..prom_metrics import observe_deferred_completion from ..raidhub_client import RaidHubClient, discord_invocation_context +from .subscribe_resolution import ( + bungie_emblem_url, + format_player_display_name, + parse_clan_group_id, + resolve_player_membership_id, + resolve_player_subscription_row, +) +from .subscription_helpers import ( + fetch_subscription_status_envelope, + format_clan_display_name, + subscription_active_clan_ids, + subscription_active_player_ids, + subscription_envelope_error_message, +) +from .subscription_routes import SUB_ROUTE_PUT from .shared import ( USER_FACING_GENERIC, application_id, error_embed, flatten_options, patch_discord_followup_best_effort, + report_deferred_exception, success_embed, warn_embed, ) -from .subscription import subscription_envelope_error_message - -_ROUTE_PUT = "PUT subscriptions/discord/webhooks" - -_CLAN_GROUP_ID_PATTERNS = ( - re.compile(r"(?:https?://)?(?:www\.)?raidhub\.io/clan/(\d+)", re.I), - re.compile(r"(?:https?://)?(?:www\.)?bungie\.net/[^?\s]*[?&]group(?:id|Id)=(\d+)", re.I), - re.compile(r"/GroupV2/(\d+)", re.I), - re.compile(r"/clan/(\d+)", re.I), -) - - -def parse_clan_group_id(raw: str) -> str | None: - s = raw.strip() - if not s: - return None - if re.fullmatch(r"\d+", s): - return s - for pat in _CLAN_GROUP_ID_PATTERNS: - m = pat.search(s) - if m: - return m.group(1) - try: - path = urlparse(s).path or "" - for seg in reversed([p for p in path.split("/") if p]): - if seg.isdigit() and len(seg) >= 5: - return seg - except Exception: - pass - return None - - -async def resolve_player_membership_id(raidhub: RaidHubClient, raw: str) -> str | None: - q = raw.strip() - if not q: - return None - if re.fullmatch(r"\d+", q): - return q - env = await raidhub.request_envelope( - "GET", - "/player/search", - params={"query": q, "count": 1, "offset": 0}, - ) - if not env.get("success"): - return None - inner = env.get("response") or {} - results = list(inner.get("results") or []) - if not results: - return None - mid = results[0].get("membershipId") - return str(mid) if mid is not None else None async def run_subscribe_deferred( @@ -106,12 +68,7 @@ async def run_subscribe_deferred( return leaf = flatten_options(top_opts[0].get("options")) - target_raw = str( - leaf.get("player_id_or_search_text") - or leaf.get("clan_group_id_or_url") - or leaf.get("target") - or "" - ).strip() + target_raw = str(leaf.get("player") or leaf.get("clan") or "").strip() if not target_raw: await patch_discord_followup_best_effort( app_id, @@ -134,9 +91,23 @@ async def run_subscribe_deferred( ) return + status_env = await fetch_subscription_status_envelope(raidhub, interaction) + if not status_env.get("success"): + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Subscribe Failed", + subscription_envelope_error_message(status_env), + ), + ) + return + status_inner = status_env.get("response") or {} + registered = bool(status_inner.get("registered")) + if sub == "player": - mid = await resolve_player_membership_id(raidhub, target_raw) - if not mid: + prow = await resolve_player_subscription_row(raidhub, target_raw) + if not prow: await patch_discord_followup_best_effort( app_id, token, @@ -147,9 +118,30 @@ async def run_subscribe_deferred( ), ) return - resolved_id = mid - kind_label = "player" - body: dict[str, Any] = {"targets": {"playerMembershipIds": [resolved_id]}} + raw_mid = prow.get("membershipId") + resolved_id = str(int(str(raw_mid).strip())) if raw_mid is not None else "" + if not resolved_id or not resolved_id.isdigit(): + await patch_discord_followup_best_effort( + app_id, + token, + error_embed("Player Not Found", "Missing membership id for that player."), + ) + return + if registered: + merged_players = subscription_active_player_ids(status_inner) + if resolved_id not in merged_players: + merged_players.append(resolved_id) + merged_players.sort() + body: dict[str, Any] = {"targets": {"playerMembershipIds": merged_players}} + else: + body = {"targets": {"playerMembershipIds": [resolved_id]}} + display_name = format_player_display_name(prow) + icon_raw = prow.get("iconPath") + thumb_url = ( + bungie_emblem_url(str(icon_raw)) + if isinstance(icon_raw, str) + else bungie_emblem_url(None) + ) else: gid = parse_clan_group_id(target_raw) if not gid: @@ -163,11 +155,17 @@ async def run_subscribe_deferred( ), ) return - resolved_id = gid - kind_label = "clan" - body = {"targets": {"clanGroupIds": [resolved_id]}} - - ctx = discord_invocation_context(interaction, route_id=_ROUTE_PUT) + resolved_id = str(int(gid)) + if registered: + merged_clans = subscription_active_clan_ids(status_inner) + if resolved_id not in merged_clans: + merged_clans.append(resolved_id) + merged_clans.sort() + body = {"targets": {"clanGroupIds": merged_clans}} + else: + body = {"targets": {"clanGroupIds": [resolved_id]}} + + ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) env = await raidhub.request_envelope( "PUT", "/subscriptions/discord/webhooks", @@ -186,30 +184,49 @@ async def run_subscribe_deferred( ) return - inner_put = env.get("response") or {} - action = ( - "updated" - if inner_put.get("updated") - else "registered" - if inner_put.get("created") or inner_put.get("webhookUrl") - else "saved" - ) - await patch_discord_followup_best_effort( - app_id, - token, - success_embed( + if sub == "player": + desc = ( + f"Subscribed to **{display_name}** (`{resolved_id}`) for this channel. " + "Use `/subscription status` to see all rules for this channel." + ) + msg = success_embed( "Subscription Saved", - f"Subscribed ({action}) to {kind_label} `{resolved_id}` for this channel. " - "Use `/subscription status` to inspect delivery health and rules.", - ), - ) + desc, + thumbnail_url=thumb_url, + ) + else: + c_env = await raidhub.request_envelope("GET", f"/clan/{resolved_id}/basic") + clan_row: dict[str, Any] = {} + if c_env.get("success") and isinstance(c_env.get("response"), dict): + clan_row = c_env["response"] + c_disp = ( + format_clan_display_name(clan_row) if clan_row else f"Clan `{resolved_id}`" + ) + apath = clan_row.get("avatarPath") + c_thumb = ( + bungie_emblem_url(str(apath)) + if clan_row and isinstance(apath, str) + else None + ) + desc = ( + f"Subscribed to **{c_disp}** (`{resolved_id}`) for this channel. " + "Use `/subscription status` to see all rules for this channel." + ) + msg = success_embed( + "Subscription Saved", + desc, + thumbnail_url=c_thumb, + ) + await patch_discord_followup_best_effort(app_id, token, msg) except Exception as err: outcome = "error" - handlers.error("SUBSCRIBE_DEFERRED_FAILED", err, {}) - await patch_discord_followup_best_effort( - app_id, - token, - error_embed("Subscribe Failed", USER_FACING_GENERIC), + await report_deferred_exception( + command="subscribe", + log_key="SUBSCRIBE_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload=error_embed("Subscribe Failed", USER_FACING_GENERIC), ) finally: observe_deferred_completion(command="subscribe", outcome=outcome) diff --git a/src/commands/subscribe_resolution.py b/src/commands/subscribe_resolution.py new file mode 100644 index 0000000..e66f52d --- /dev/null +++ b/src/commands/subscribe_resolution.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import urlparse + +from ..raidhub_client import RaidHubClient + +_CLAN_GROUP_ID_PATTERNS = ( + re.compile(r"(?:https?://)?(?:www\.)?raidhub\.io/clan/(\d+)", re.I), + re.compile(r"(?:https?://)?(?:www\.)?bungie\.net/[^?\s]*[?&]group(?:id|Id)=(\d+)", re.I), + re.compile(r"/GroupV2/(\d+)", re.I), + re.compile(r"/clan/(\d+)", re.I), +) + + +def parse_clan_group_id(raw: str) -> str | None: + s = raw.strip() + if not s: + return None + if re.fullmatch(r"\d+", s): + return s + for pat in _CLAN_GROUP_ID_PATTERNS: + m = pat.search(s) + if m: + return m.group(1) + try: + path = urlparse(s).path or "" + for seg in reversed([p for p in path.split("/") if p]): + if seg.isdigit() and len(seg) >= 5: + return seg + except Exception: + pass + return None + + +def _norm_membership_id(value: Any) -> str | None: + if value is None: + return None + s = str(value).strip() + if not s or not re.fullmatch(r"\d+", s): + return None + return str(int(s)) + + +def bungie_emblem_url(icon_path: str | None) -> str | None: + if not icon_path: + return None + path = icon_path.strip() + if not path: + return None + if path.startswith("http://") or path.startswith("https://"): + return path + return f"https://www.bungie.net{path}" + + +def format_player_display_name(player: dict[str, Any]) -> str: + bungie = player.get("bungieGlobalDisplayName") + if bungie: + code = player.get("bungieGlobalDisplayNameCode") + suffix = f"#{code}" if code else "" + return f"{bungie}{suffix}" + dn = player.get("displayName") + if dn: + return str(dn) + mid = _norm_membership_id(player.get("membershipId")) + return mid or "Unknown Player" + + +async def resolve_player_membership_id(raidhub: RaidHubClient, raw: str) -> str | None: + info = await resolve_player_subscription_row(raidhub, raw) + if not info: + return None + return _norm_membership_id(info.get("membershipId")) + + +async def resolve_player_subscription_row( + raidhub: RaidHubClient, raw: str +) -> dict[str, Any] | None: + """ + Resolve search text or digits to a RaidHub ``PlayerInfo``-shaped dict (for subscribe UX). + """ + q = raw.strip() + if not q: + return None + if re.fullmatch(r"\d+", q): + mid = str(int(q)) + env = await raidhub.request_envelope("GET", f"/player/{mid}/basic") + if env.get("success"): + row = env.get("response") + if isinstance(row, dict): + return row + return {"membershipId": mid} + env = await raidhub.request_envelope( + "GET", + "/player/search", + params={"query": q, "count": 1, "offset": 0}, + ) + if not env.get("success"): + return None + inner = env.get("response") or {} + results = list(inner.get("results") or []) + if not results: + return None + row = results[0] + return row if isinstance(row, dict) else None diff --git a/src/commands/subscription.py b/src/commands/subscription.py index 48b2f84..2e5e1fd 100644 --- a/src/commands/subscription.py +++ b/src/commands/subscription.py @@ -1,174 +1,25 @@ from __future__ import annotations -import re -from datetime import datetime, timezone from typing import Any from ..config import Settings -from ..log import handlers from ..prom_metrics import observe_deferred_completion from ..raidhub_client import RaidHubClient, discord_invocation_context +from .subscription_helpers import ( + format_subscription_status_embed, + subscription_envelope_error_message, +) +from .subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_STATUS from .shared import ( USER_FACING_GENERIC, application_id, - base_embed, - discord_message_for_failed_envelope, error_embed, - flatten_options, - info_embed, patch_discord_followup_best_effort, + report_deferred_exception, success_embed, warn_embed, ) -# Must match RaidHub route ids for subscription Discord routes. -_SUB_ROUTE_PUT = "PUT subscriptions/discord/webhooks" -_SUB_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks" -_SUB_ROUTE_STATUS = "GET subscriptions/discord/webhooks" - - -def _comma_separated_digit_ids(raw: str) -> list[str]: - out: list[str] = [] - for part in raw.replace(",", " ").split(): - s = part.strip() - if s and re.fullmatch(r"\d+", s): - out.append(s) - return out - - -def _subscription_json_body(leaf_opts: dict[str, Any]) -> dict[str, Any]: - body: dict[str, Any] = {} - wn = str( - leaf_opts.get("discord_webhook_name") - or leaf_opts.get("webhook_name") - or "" - ).strip() - if wn: - body["name"] = wn[:80] - filters: dict[str, Any] = {} - if "require_fresh" in leaf_opts: - filters["requireFresh"] = bool(leaf_opts["require_fresh"]) - if "require_completed" in leaf_opts: - filters["requireCompleted"] = bool(leaf_opts["require_completed"]) - if filters: - body["filters"] = filters - targets: dict[str, Any] = {} - players = _comma_separated_digit_ids(str(leaf_opts.get("players") or "")) - if players: - targets["playerMembershipIds"] = players - clans = _comma_separated_digit_ids(str(leaf_opts.get("clans") or "")) - if clans: - targets["clanGroupIds"] = clans - if targets: - body["targets"] = targets - return body - - -def _subscription_rules_suffix(rules: dict[str, Any]) -> str: - p = rules.get("players") or {} - c = rules.get("clans") or {} - pl = int(p.get("inserted", 0)) + int(p.get("updated", 0)) - cl = int(c.get("inserted", 0)) + int(c.get("updated", 0)) - if pl or cl: - return f"Rule changes: {pl} player row(s), {cl} clan row(s)." - return "" - - -def _discord_relative_timestamp(iso_value: Any) -> str: - if iso_value is None: - return "—" - s = str(iso_value).strip() - if not s: - return "—" - if s.endswith("Z"): - s = s[:-1] + "+00:00" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - ts = int(dt.timestamp()) - return f"" - - -def _format_subscription_status_embed(data: dict[str, Any]) -> dict[str, Any]: - if not data.get("registered"): - return info_embed( - "Subscription Status", - "No RaidHub subscription webhook is registered for this channel.", - ) - active = "**yes**" if data.get("destinationActive") else "**no**" - fails = int(data.get("consecutiveDeliveryFailures") or 0) - fields: list[dict[str, Any]] = [ - {"name": "Destination Active", "value": active, "inline": True}, - {"name": "Webhook ID", "value": f"`{data.get('webhookId', '—')}`", "inline": True}, - {"name": "Delivery Failures", "value": f"**{fails}**", "inline": True}, - ] - ls = data.get("lastDeliverySuccessAt") - lf = data.get("lastDeliveryFailureAt") - fields.append( - { - "name": "Last Delivery Success", - "value": _discord_relative_timestamp(ls) if ls else "—", - "inline": True, - } - ) - fields.append( - { - "name": "Last Delivery Failure", - "value": _discord_relative_timestamp(lf) if lf else "—", - "inline": True, - } - ) - err = data.get("lastDeliveryError") - if err: - fields.append({"name": "Last Error", "value": str(err)[:280], "inline": False}) - - pl_raw = list(data.get("players") or []) - cl_raw = list(data.get("clans") or []) - pl_ids = [ - str(item.get("membershipId") if isinstance(item, dict) else item) - for item in pl_raw - if item is not None - ] - cl_ids = [ - str(item.get("groupId") if isinstance(item, dict) else item) - for item in cl_raw - if item is not None - ] - pc = len(pl_ids) - cc = len(cl_ids) - max_show = 25 - p_show = pl_ids[:max_show] - c_show = cl_ids[:max_show] - p_list = ", ".join(p_show) if p_show else "—" - if pc > len(p_show): - p_list = f"{p_list} (+{pc - len(p_show)} more)" - c_list = ", ".join(c_show) if c_show else "—" - if cc > len(c_show): - c_list = f"{c_list} (+{cc - len(c_show)} more)" - fields.append({"name": f"Player Rules ({pc})", "value": p_list[:1024], "inline": False}) - fields.append({"name": f"Clan Rules ({cc})", "value": c_list[:1024], "inline": False}) - return base_embed( - title="Subscription Status", - description="Current webhook destination and delivery health.", - color=0x5865_F2, - fields=fields, - ) - - -def subscription_envelope_error_message(env: dict[str, Any]) -> str: - code = str(env.get("code", "")) - if code == "InsufficientPermissionsError": - return ( - "RaidHub rejected this request. Use a **server text channel** where the bot can " - "**Manage Webhooks**, and ensure the API trusts your bot JWT." - ) - if code == "BodyValidationError": - return ( - "RaidHub could not validate the payload. Use digits only for **players** / **clans** " - "lists (comma-separated)." - ) - return discord_message_for_failed_envelope(code, "") - async def run_subscription_deferred( interaction: dict[str, Any], @@ -187,13 +38,13 @@ async def run_subscription_deferred( token, warn_embed( "Subscription Command", - "Pick `register`, `update`, `delete`, or `status` under `/subscription`.", + "Pick `status` or `delete` under `/subscription`.", ), ) return sub = str(top_opts[0].get("name") or "").strip().lower() - if sub not in ("register", "update", "delete", "status"): + if sub not in ("delete", "status"): await patch_discord_followup_best_effort( app_id, token, @@ -212,13 +63,7 @@ async def run_subscription_deferred( ) return - leaf_opts = flatten_options(top_opts[0].get("options")) - route_id = { - "register": _SUB_ROUTE_PUT, - "update": _SUB_ROUTE_PUT, - "delete": _SUB_ROUTE_DELETE, - "status": _SUB_ROUTE_STATUS, - }[sub] + route_id = {"delete": SUB_ROUTE_DELETE, "status": SUB_ROUTE_STATUS}[sub] ctx = discord_invocation_context(interaction, route_id=route_id) if sub == "status": @@ -227,18 +72,10 @@ async def run_subscription_deferred( "/subscriptions/discord/webhooks", discord_context=ctx, ) - elif sub == "delete": - env = await raidhub.request_envelope( - "DELETE", - "/subscriptions/discord/webhooks", - discord_context=ctx, - ) else: - payload = _subscription_json_body(leaf_opts) env = await raidhub.request_envelope( - "PUT", + "DELETE", "/subscriptions/discord/webhooks", - json=payload if payload else {}, discord_context=ctx, ) @@ -255,44 +92,26 @@ async def run_subscription_deferred( inner = env.get("response") or {} if sub == "status": - msg = _format_subscription_status_embed(inner) - elif sub == "delete": + msg = await format_subscription_status_embed(raidhub, inner) + else: msg = success_embed( "Subscription Removed", "RaidHub will no longer use a webhook in this channel.", ) - elif sub == "register": - parts = [ - "RaidHub subscription events will post to this channel.", - ] - if inner.get("created"): - parts.append("A new subscription destination was created.") - if inner.get("activated"): - parts.append("The destination was re-activated.") - rules = inner.get("rules") or {} - rs = _subscription_rules_suffix(rules) - if rs: - parts.append(rs) - msg = success_embed("Subscription Registered", " ".join(parts)) - else: - parts = ["Subscription rules for this channel were saved."] - if inner.get("activated"): - parts.append("The destination was re-activated.") - rules = inner.get("rules") or {} - rs = _subscription_rules_suffix(rules) - if rs: - parts.append(rs) - msg = success_embed("Subscription Updated", " ".join(parts)) await patch_discord_followup_best_effort(app_id, token, msg) except Exception as err: outcome = "error" - handlers.error("SUBSCRIPTION_DEFERRED_FAILED", err, {}) - await patch_discord_followup_best_effort( - app_id, token, error_embed("Subscription Failed", USER_FACING_GENERIC) + await report_deferred_exception( + command="subscription", + log_key="SUBSCRIPTION_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload=error_embed("Subscription Failed", USER_FACING_GENERIC), ) finally: observe_deferred_completion(command="subscription", outcome=outcome) -__all__ = ["run_subscription_deferred", "subscription_envelope_error_message"] +__all__ = ["run_subscription_deferred"] diff --git a/src/commands/subscription_helpers.py b/src/commands/subscription_helpers.py new file mode 100644 index 0000000..3da73de --- /dev/null +++ b/src/commands/subscription_helpers.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from ..raidhub_client import RaidHubClient, discord_invocation_context +from .subscribe_resolution import format_player_display_name +from .subscription_routes import SUB_ROUTE_STATUS +from .shared import ( + base_embed, + discord_message_for_failed_envelope, + info_embed, + iso_to_discord_relative, +) + + +def subscription_active_player_ids(inner: dict[str, Any]) -> list[str]: + out: list[str] = [] + for item in inner.get("players") or []: + if isinstance(item, dict): + raw = item.get("membershipId") + else: + raw = item + if raw is None: + continue + s = str(raw).strip() + if s.isdigit(): + out.append(str(int(s))) + return sorted(set(out)) + + +def subscription_active_clan_ids(inner: dict[str, Any]) -> list[str]: + out: list[str] = [] + for item in inner.get("clans") or []: + if isinstance(item, dict): + raw = item.get("groupId") + else: + raw = item + if raw is None: + continue + s = str(raw).strip() + if s.isdigit(): + out.append(str(int(s))) + return sorted(set(out)) + + +async def fetch_subscription_status_envelope( + raidhub: RaidHubClient, + interaction: dict[str, Any], +) -> dict[str, Any]: + ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_STATUS) + return await raidhub.request_envelope( + "GET", + "/subscriptions/discord/webhooks", + discord_context=ctx, + ) + + +def build_subscription_json_body(leaf_opts: dict[str, Any]) -> dict[str, Any]: + body: dict[str, Any] = {} + wn = str( + leaf_opts.get("discord_webhook_name") + or leaf_opts.get("webhook_name") + or "" + ).strip() + if wn: + body["name"] = wn[:80] + filters: dict[str, Any] = {} + if "require_fresh" in leaf_opts: + filters["requireFresh"] = bool(leaf_opts["require_fresh"]) + if "require_completed" in leaf_opts: + filters["requireCompleted"] = bool(leaf_opts["require_completed"]) + if filters: + body["filters"] = filters + targets: dict[str, Any] = {} + players = _comma_separated_digit_ids(str(leaf_opts.get("players") or "")) + if players: + targets["playerMembershipIds"] = players + clans = _comma_separated_digit_ids(str(leaf_opts.get("clans") or "")) + if clans: + targets["clanGroupIds"] = clans + if targets: + body["targets"] = targets + return body + + +def subscription_rules_suffix(rules: dict[str, Any]) -> str: + p = rules.get("players") or {} + c = rules.get("clans") or {} + pl = int(p.get("inserted", 0)) + int(p.get("updated", 0)) + cl = int(c.get("inserted", 0)) + int(c.get("updated", 0)) + if pl or cl: + return f"Rule changes: {pl} player row(s), {cl} clan row(s)." + return "" + + +def _embed_safe_label(label: str) -> str: + return label.replace("]", "") + + +def _raidhub_player_url(membership_id: str) -> str: + return f"https://raidhub.io/profile/{membership_id}" + + +def _raidhub_clan_url(group_id: str) -> str: + return f"https://raidhub.io/clan/{group_id}" + + +def format_clan_display_name(row: dict[str, Any]) -> str: + name = str(row.get("name") or "").strip() + tag = str(row.get("callSign") or "").strip() + if name and tag: + return f"{name} [{tag}]" + return name or tag or "Unknown clan" + + +def _ordered_membership_ids(pl_raw: list[Any]) -> list[str]: + out: list[str] = [] + for item in pl_raw: + raw = item.get("membershipId") if isinstance(item, dict) else item + if raw is None: + continue + s = str(raw).strip() + if s.isdigit(): + norm = str(int(s)) + if norm not in out: + out.append(norm) + return out + + +def _ordered_group_ids(cl_raw: list[Any]) -> list[str]: + out: list[str] = [] + for item in cl_raw: + raw = item.get("groupId") if isinstance(item, dict) else item + if raw is None: + continue + s = str(raw).strip() + if s.isdigit(): + norm = str(int(s)) + if norm not in out: + out.append(norm) + return out + + +async def _fetch_player_basic_card(raidhub: RaidHubClient, membership_id: str) -> dict[str, Any]: + env = await raidhub.request_envelope("GET", f"/player/{membership_id}/basic") + inner = env.get("response") + return inner if env.get("success") and isinstance(inner, dict) else {} + + +async def _fetch_clan_basic_card(raidhub: RaidHubClient, group_id: str) -> dict[str, Any]: + env = await raidhub.request_envelope("GET", f"/clan/{group_id}/basic") + inner = env.get("response") + return inner if env.get("success") and isinstance(inner, dict) else {} + + +def _player_rule_line(membership_id: str, card: dict[str, Any]) -> str: + if card: + label = _embed_safe_label(format_player_display_name(card)) + url = _raidhub_player_url(membership_id) + return f"• [{label}]({url}) · `{membership_id}`" + return f"• `{membership_id}`" + + +def _clan_rule_line(group_id: str, card: dict[str, Any]) -> str: + if card: + label = _embed_safe_label(format_clan_display_name(card)) + url = _raidhub_clan_url(group_id) + return f"• [{label}]({url}) · `{group_id}`" + return f"• `{group_id}`" + + +def _id_only_rule_lines(ids: list[str]) -> str: + max_show = 25 + shown = ids[:max_show] + lines = [f"• `{i}`" for i in shown] + body = "\n".join(lines) if lines else "—" + extra = len(ids) - len(shown) + if extra > 0: + body = f"{body}\n… and **{extra}** more (showing names requires API access)." + return body[:1024] + + +async def format_subscription_status_embed( + raidhub: RaidHubClient | None, + data: dict[str, Any], +) -> dict[str, Any]: + if not data.get("registered"): + return info_embed( + "Subscription Status", + "No RaidHub subscription webhook is registered for this channel.", + ) + active = "**yes**" if data.get("destinationActive") else "**no**" + fails = int(data.get("consecutiveDeliveryFailures") or 0) + fields: list[dict[str, Any]] = [ + {"name": "Destination Active", "value": active, "inline": True}, + {"name": "Webhook ID", "value": f"`{data.get('webhookId', '—')}`", "inline": True}, + {"name": "Delivery Failures", "value": f"**{fails}**", "inline": True}, + ] + ls = data.get("lastDeliverySuccessAt") + lf = data.get("lastDeliveryFailureAt") + fields.append( + { + "name": "Last Delivery Success", + "value": iso_to_discord_relative(ls) if ls else "—", + "inline": True, + } + ) + fields.append( + { + "name": "Last Delivery Failure", + "value": iso_to_discord_relative(lf) if lf else "—", + "inline": True, + } + ) + err = data.get("lastDeliveryError") + if err: + fields.append({"name": "Last Error", "value": str(err)[:280], "inline": False}) + + pl_raw = list(data.get("players") or []) + cl_raw = list(data.get("clans") or []) + pl_ids = _ordered_membership_ids(pl_raw) + cl_ids = _ordered_group_ids(cl_raw) + pc = len(pl_ids) + cc = len(cl_ids) + + player_cards: dict[str, dict[str, Any]] = {} + clan_cards: dict[str, dict[str, Any]] = {} + if raidhub is not None and (pl_ids or cl_ids): + p_res = ( + await asyncio.gather(*[_fetch_player_basic_card(raidhub, mid) for mid in pl_ids]) + if pl_ids + else [] + ) + c_res = ( + await asyncio.gather(*[_fetch_clan_basic_card(raidhub, gid) for gid in cl_ids]) + if cl_ids + else [] + ) + for mid, card in zip(pl_ids, p_res, strict=True): + player_cards[mid] = card if isinstance(card, dict) else {} + for gid, card in zip(cl_ids, c_res, strict=True): + clan_cards[gid] = card if isinstance(card, dict) else {} + + if raidhub is None: + p_body = _id_only_rule_lines(pl_ids) if pl_ids else "—" + c_body = _id_only_rule_lines(cl_ids) if cl_ids else "—" + else: + p_lines = [_player_rule_line(mid, player_cards.get(mid, {})) for mid in pl_ids] + p_body = "\n".join(p_lines) if p_lines else "—" + if len(p_body) > 1024: + p_body = p_body[:1021] + "..." + c_lines = [_clan_rule_line(gid, clan_cards.get(gid, {})) for gid in cl_ids] + c_body = "\n".join(c_lines) if c_lines else "—" + if len(c_body) > 1024: + c_body = c_body[:1021] + "..." + + fields.append({"name": f"Player Rules ({pc})", "value": p_body, "inline": False}) + fields.append({"name": f"Clan Rules ({cc})", "value": c_body, "inline": False}) + + desc = "Current webhook destination and delivery health." + if raidhub is not None and (pc + cc) > 1: + desc = ( + f"{desc}\n\nShowing **{pc}** player(s) and **{cc}** clan(s) by name below " + "(name and id view)." + ) + + return base_embed( + title="Subscription Status", + description=desc, + color=0x5865_F2, + fields=fields, + ) + + +def subscription_envelope_error_message(env: dict[str, Any]) -> str: + code = str(env.get("code", "")) + if code == "InsufficientPermissionsError": + return ( + "RaidHub rejected this request. Use a **server text channel** where the bot can " + "**Manage Webhooks**, and ensure the API trusts your bot JWT." + ) + if code == "BodyValidationError": + return ( + "RaidHub could not validate the payload. Use digits only for **players** / **clans** " + "lists (comma-separated)." + ) + return discord_message_for_failed_envelope(code, "") + + +def _comma_separated_digit_ids(raw: str) -> list[str]: + out: list[str] = [] + for part in raw.replace(",", " ").split(): + s = part.strip() + if s and re.fullmatch(r"\d+", s): + out.append(s) + return out + + diff --git a/src/commands/subscription_routes.py b/src/commands/subscription_routes.py new file mode 100644 index 0000000..990363e --- /dev/null +++ b/src/commands/subscription_routes.py @@ -0,0 +1,3 @@ +SUB_ROUTE_PUT = "PUT subscriptions/discord/webhooks" +SUB_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks" +SUB_ROUTE_STATUS = "GET subscriptions/discord/webhooks" diff --git a/src/commands/unsubscribe.py b/src/commands/unsubscribe.py index 451e322..b99646d 100644 --- a/src/commands/unsubscribe.py +++ b/src/commands/unsubscribe.py @@ -3,20 +3,26 @@ from typing import Any from ..config import Settings -from ..log import handlers from ..prom_metrics import observe_deferred_completion from ..raidhub_client import RaidHubClient, discord_invocation_context +from .subscribe_resolution import parse_clan_group_id, resolve_player_membership_id +from .subscription_helpers import ( + fetch_subscription_status_envelope, + subscription_active_clan_ids, + subscription_active_player_ids, + subscription_envelope_error_message, +) +from .subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_PUT from .shared import ( USER_FACING_GENERIC, application_id, error_embed, + flatten_options, patch_discord_followup_best_effort, + report_deferred_exception, success_embed, warn_embed, ) -from .subscription import subscription_envelope_error_message - -_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks" async def run_unsubscribe_deferred( @@ -39,7 +45,7 @@ async def run_unsubscribe_deferred( ) return - ctx = discord_invocation_context(interaction, route_id=_ROUTE_DELETE) + ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_DELETE) env = await raidhub.request_envelope( "DELETE", "/subscriptions/discord/webhooks", @@ -66,14 +72,269 @@ async def run_unsubscribe_deferred( ) except Exception as err: outcome = "error" - handlers.error("UNSUBSCRIBE_DEFERRED_FAILED", err, {}) + await report_deferred_exception( + command="unsubscribe", + log_key="UNSUBSCRIBE_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload=error_embed("Unsubscribe Failed", USER_FACING_GENERIC), + ) + finally: + observe_deferred_completion(command="unsubscribe", outcome=outcome) + + +async def run_unsubscribe_player_deferred( + interaction: dict[str, Any], + raidhub: RaidHubClient, + settings: Settings, +) -> None: + app_id = application_id(interaction, settings) + token = str(interaction.get("token") or "") + outcome = "completed" + try: + leaf = flatten_options((interaction.get("data") or {}).get("options")) + target_raw = str(leaf.get("player") or "").strip() + if not target_raw: + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Player", + "Provide a Destiny membership id or player search text.", + ), + ) + return + + if not interaction.get("guild_id") or not interaction.get("channel_id"): + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Player", + "Run this command in a server text channel, not a DM.", + ), + ) + return + + resolved_id = await resolve_player_membership_id(raidhub, target_raw) + if not resolved_id: + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Player Not Found", + "Could not resolve that player. Try a membership id or a clearer name.", + ), + ) + return + + status_env = await fetch_subscription_status_envelope(raidhub, interaction) + if not status_env.get("success"): + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Unsubscribe Failed", + subscription_envelope_error_message(status_env), + ), + ) + return + inner = status_env.get("response") or {} + if not inner.get("registered"): + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Player", + "This channel has no RaidHub subscription to update.", + ), + ) + return + + players = subscription_active_player_ids(inner) + if resolved_id not in players: + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Player", + f"This channel is not subscribed to player `{resolved_id}`.", + ), + ) + return + + next_players = sorted([p for p in players if p != resolved_id]) + body: dict[str, Any] = {"targets": {"playerMembershipIds": next_players}} + + ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) + env = await raidhub.request_envelope( + "PUT", + "/subscriptions/discord/webhooks", + json=body, + discord_context=ctx, + ) + if not env.get("success"): + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Unsubscribe Failed", + subscription_envelope_error_message(env), + ), + ) + return + await patch_discord_followup_best_effort( app_id, token, - error_embed("Unsubscribe Failed", USER_FACING_GENERIC), + success_embed( + "Player Unsubscribed", + f"Removed player `{resolved_id}` from this channel. Other subscription rules are" + " unchanged.", + ), + ) + except Exception as err: + outcome = "error" + await report_deferred_exception( + command="unsubscribe-player", + log_key="UNSUBSCRIBE_PLAYER_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload=error_embed("Unsubscribe Failed", USER_FACING_GENERIC), ) finally: - observe_deferred_completion(command="unsubscribe", outcome=outcome) + observe_deferred_completion(command="unsubscribe-player", outcome=outcome) + + +async def run_unsubscribe_clan_deferred( + interaction: dict[str, Any], + raidhub: RaidHubClient, + settings: Settings, +) -> None: + app_id = application_id(interaction, settings) + token = str(interaction.get("token") or "") + outcome = "completed" + try: + leaf = flatten_options((interaction.get("data") or {}).get("options")) + target_raw = str(leaf.get("clan") or "").strip() + if not target_raw: + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Clan", + "Provide a clan group id or clan URL.", + ), + ) + return + + if not interaction.get("guild_id") or not interaction.get("channel_id"): + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Clan", + "Run this command in a server text channel, not a DM.", + ), + ) + return + + gid = parse_clan_group_id(target_raw) + if not gid: + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Clan ID Not Recognized", + "Could not parse a clan group id from that value.", + ), + ) + return + resolved_id = str(int(gid)) + + status_env = await fetch_subscription_status_envelope(raidhub, interaction) + if not status_env.get("success"): + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Unsubscribe Failed", + subscription_envelope_error_message(status_env), + ), + ) + return + inner = status_env.get("response") or {} + if not inner.get("registered"): + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Clan", + "This channel has no RaidHub subscription to update.", + ), + ) + return + + clans = subscription_active_clan_ids(inner) + if resolved_id not in clans: + await patch_discord_followup_best_effort( + app_id, + token, + warn_embed( + "Unsubscribe Clan", + f"This channel is not subscribed to clan `{resolved_id}`.", + ), + ) + return + + next_clans = sorted([c for c in clans if c != resolved_id]) + body = {"targets": {"clanGroupIds": next_clans}} + + ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT) + env = await raidhub.request_envelope( + "PUT", + "/subscriptions/discord/webhooks", + json=body, + discord_context=ctx, + ) + if not env.get("success"): + await patch_discord_followup_best_effort( + app_id, + token, + error_embed( + "Unsubscribe Failed", + subscription_envelope_error_message(env), + ), + ) + return + + await patch_discord_followup_best_effort( + app_id, + token, + success_embed( + "Clan Unsubscribed", + f"Removed clan `{resolved_id}` from this channel. Other subscription rules are" + " unchanged.", + ), + ) + except Exception as err: + outcome = "error" + await report_deferred_exception( + command="unsubscribe-clan", + log_key="UNSUBSCRIBE_CLAN_DEFERRED_FAILED", + err=err, + discord_application_id=app_id, + interaction_token=token, + user_message_payload=error_embed("Unsubscribe Failed", USER_FACING_GENERIC), + ) + finally: + observe_deferred_completion(command="unsubscribe-clan", outcome=outcome) -__all__ = ["run_unsubscribe_deferred"] +__all__ = [ + "run_unsubscribe_clan_deferred", + "run_unsubscribe_deferred", + "run_unsubscribe_player_deferred", +] diff --git a/src/config.py b/src/config.py index 453bc43..26e2ff1 100644 --- a/src/config.py +++ b/src/config.py @@ -19,6 +19,11 @@ class Settings: raidhub_api_base_url: str raidhub_api_key: str raidhub_jwt_secret: str + sentry_dsn: str + sentry_environment: str + sentry_release: str + sentry_send_default_pii: bool + sentry_traces_sample_rate: float def get_settings() -> Settings: @@ -35,4 +40,14 @@ def get_settings() -> Settings: ).strip(), raidhub_api_key=os.getenv("RAIDHUB_API_KEY", "").strip(), raidhub_jwt_secret=os.getenv("RAIDHUB_JWT_SECRET", "").strip(), + sentry_dsn=os.getenv("SENTRY_DSN", "").strip(), + sentry_environment=os.getenv("SENTRY_ENVIRONMENT", "development").strip(), + sentry_release=os.getenv("SENTRY_RELEASE", "").strip(), + sentry_send_default_pii=os.getenv("SENTRY_SEND_DEFAULT_PII", "true") + .strip() + .lower() + == "true", + sentry_traces_sample_rate=float( + os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.1").strip() + ), ) diff --git a/src/interaction_handlers.py b/src/interaction_handlers.py deleted file mode 100644 index ecfe4b1..0000000 --- a/src/interaction_handlers.py +++ /dev/null @@ -1,731 +0,0 @@ -from __future__ import annotations - -import re -from datetime import datetime, timezone -from typing import Any - -import httpx - -from .config import Settings -from .log import handlers -from .prom_metrics import observe_deferred_completion -from .pagination import ( - build_triple_nav_action_row, - parse_offset_page_nav_token, - register_pager, - store_paged_session, -) -from .raidhub_client import ( - RaidHubClient, - RaidHubEnvelopeCode, - discord_invocation_context, -) - -PLAYER_SEARCH_PREFIX = "ps" -PLAYER_SEARCH_PAGE_SIZE = 10 -# Nav token for first page (decoded by ``parse_offset_page_nav_token``). -_PLAYER_SEARCH_FIRST_PAGE_TOKEN = "0" - -# Must match RaidHub ``RaidHubRoute.getDerivedRouteId()`` for subscription Discord routes. -_SUB_ROUTE_PUT = "PUT subscriptions/discord/webhooks" -_SUB_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks" -_SUB_ROUTE_STATUS = "GET subscriptions/discord/webhooks" - -# Never put raw HTTP exceptions, webhook URLs, or interaction tokens in these strings. -USER_FACING_GENERIC = "Something went wrong. Try the command again." -USER_FACING_DISCORD_UPDATE_FAILED = ( - "Could not update this message. Try the command again." -) - - -def _base_embed( - *, - title: str, - description: str, - color: int, - fields: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - embed: dict[str, Any] = { - "title": title, - "description": description[:4096], - "color": color, - } - if fields: - embed["fields"] = fields[:25] - return {"embeds": [embed], "components": []} - - -def _info_embed(title: str, description: str) -> dict[str, Any]: - return _base_embed(title=title, description=description, color=0x5865_F2) - - -def _success_embed(title: str, description: str) -> dict[str, Any]: - return _base_embed(title=title, description=description, color=0x57_F287) - - -def _warn_embed(title: str, description: str) -> dict[str, Any]: - return _base_embed(title=title, description=description, color=0xFEE7_5C) - - -def _error_embed(title: str, description: str) -> dict[str, Any]: - return _base_embed(title=title, description=description, color=0xED42_45) - - -def _discord_message_for_failed_envelope(code: str, _detail: str) -> str: - if code == RaidHubEnvelopeCode.RAIDHUB_API_UNREACHABLE.value: - return ( - "Could not connect to the RaidHub API. Set `RAIDHUB_API_BASE_URL` to a URL this " - "host can reach from the network (for a cloud Discord app, `http://localhost:8000` " - "is not reachable — use your public API base URL)." - ) - if code == RaidHubEnvelopeCode.RAIDHUB_API_SERVER_ERROR.value: - return ( - "RaidHub returned a server error (temporary outage or gateway timeout). " - "Try again shortly." - ) - if code == RaidHubEnvelopeCode.RAIDHUB_API_CLIENT_ERROR.value: - return "RaidHub could not process this request." - if code == RaidHubEnvelopeCode.NON_JSON_RESPONSE.value: - return "RaidHub returned an unexpected response." - return USER_FACING_GENERIC - - -def flatten_options(options: list[dict[str, Any]] | None) -> dict[str, Any]: - out: dict[str, Any] = {} - if not options: - return out - for opt in options: - if opt.get("options"): - out.update(flatten_options(opt["options"])) - if "value" in opt: - out[opt["name"]] = opt["value"] - return out - - -def _format_duration(seconds: int) -> str: - h = seconds // 3600 - m = (seconds % 3600) // 60 - s = seconds % 60 - return f"{h}h {m}m {s}s" - - -def _format_player_name(player: dict[str, Any]) -> str: - bungie = player.get("bungieGlobalDisplayName") - if bungie: - code = player.get("bungieGlobalDisplayNameCode") - suffix = f"#{code}" if code else "" - return f"{bungie}{suffix}" - return str(player.get("displayName") or "Unknown Player") - - -def _membership_id_str(player: dict[str, Any]) -> str: - mid = player.get("membershipId") - if mid is None: - return "" - return str(mid) - - -def _raidhub_profile_url(membership_id: str) -> str: - return f"https://raidhub.io/profile/{membership_id}" - - -def _discord_relative_timestamp(iso_value: Any) -> str: - """Discord auto-updating relative time from an API ISO-8601 string (e.g. ``lastSeen``).""" - if iso_value is None: - return "—" - s = str(iso_value).strip() - if not s: - return "—" - - if s.endswith("Z"): - s = s[:-1] + "+00:00" - dt = datetime.fromisoformat(s) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - ts = int(dt.timestamp()) - return f"" - - -def _embed_markdown_link_label(label: str) -> str: - """Strip ``]`` so ``[label](url)`` in embeds does not break on display names that contain ``]``.""" - return label.replace("]", "") - - -def _format_player_search_line(rank: int, player: dict[str, Any]) -> str: - """One row: RaidHub profile link on the name, plus Discord-formatted last seen.""" - name = _format_player_name(player) - mid = _membership_id_str(player) - seen = _discord_relative_timestamp(player.get("lastSeen")) - if not mid: - return f"**{rank}.** {name} · last seen {seen}" - label = _embed_markdown_link_label(name) - rh = _raidhub_profile_url(mid) - return f"**{rank}.** [{label}]({rh}) · last seen {seen}" - - -async def _player_search_render_from_state( - raidhub: RaidHubClient, - state: dict[str, Any], - session_id: str, - nav_token: str, -) -> dict[str, Any]: - """ - Each page is a fresh ``GET /player/search`` with ``count=page_size`` and ``offset=page*page_size``. - Session holds only query inputs, not result rows. - """ - page = parse_offset_page_nav_token(nav_token, default=0) - if page < 0: - page = 0 - - qp = dict(state.get("query_params") or {}) - query = str(qp.get("query") or "").strip() - if not query: - return {"content": USER_FACING_GENERIC} - - page_size = int(state.get("page_size") or PLAYER_SEARCH_PAGE_SIZE) - offset = page * page_size - - params: dict[str, Any] = {"query": query, "count": page_size, "offset": offset} - if "membershipType" in qp: - params["membershipType"] = qp["membershipType"] - if "global" in qp: - params["global"] = qp["global"] - - env = await raidhub.request_envelope("GET", "/player/search", params=params) - if not env.get("success"): - code = str(env.get("code", "Error")) - return { - "content": _discord_message_for_failed_envelope(code, ""), - "components": [], - } - - inner = env.get("response") or {} - results = list(inner.get("results") or []) - q_label = str((inner.get("params") or {}).get("query", query)) - has_more = len(results) == page_size - - if not results: - if page == 0: - return {"content": f"No players found for `{q_label}`."} - return { - "content": ( - f"No more results on this page for `{q_label}`. " - "Use **Start** to return to the first page or **Prev**." - ), - "components": [ - build_triple_nav_action_row( - prefix=PLAYER_SEARCH_PREFIX, - session_id=session_id, - first_nav_token=_PLAYER_SEARCH_FIRST_PAGE_TOKEN, - prev_nav_token=f"p{page - 1}", - next_nav_token=f"n{page}", - first_disabled=False, - prev_disabled=False, - next_disabled=True, - ) - ], - } - - lines = [ - _format_player_search_line(offset + i + 1, player) - for i, player in enumerate(results) - ] - - range_hi = offset + len(results) - header = ( - f"Query: `{q_label}`\nPage **{page + 1}** — **{offset + 1}–{range_hi}** " - f"({len(results)} on this page).\n\n" - ) - body = "\n".join(lines) - description = header + body - if len(description) > 4096: - description = description[:4093] + "..." - embed = { - "title": "Player Search Results", - "description": description, - "color": 0x57_F287, - } - pager = build_triple_nav_action_row( - prefix=PLAYER_SEARCH_PREFIX, - session_id=session_id, - first_nav_token=_PLAYER_SEARCH_FIRST_PAGE_TOKEN, - prev_nav_token=f"p{page - 1}", - next_nav_token=f"n{page + 1}", - first_disabled=page <= 0, - prev_disabled=page <= 0, - next_disabled=not has_more, - ) - return {"embeds": [embed], "components": [pager]} - - -def register_player_search_pager(raidhub: RaidHubClient) -> None: - async def _render( - st: dict[str, Any], session_id: str, nav_token: str - ) -> dict[str, Any]: - return await _player_search_render_from_state( - raidhub, st, session_id, nav_token - ) - - register_pager( - PLAYER_SEARCH_PREFIX, - _render, - expired_message="This player search session expired. Run the command again.", - ) - - -async def patch_discord_original( - application_id: str, interaction_token: str, data: dict[str, Any] -) -> bool: - """PATCH the deferred interaction message. Returns ``False`` on failure (never raises).""" - url = f"https://discord.com/api/v10/webhooks/{application_id}/{interaction_token}/messages/@original" - try: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.patch(url, json=data) - except httpx.RequestError as e: - handlers.warn( - "DISCORD_PATCH_ORIGINAL_NETWORK", - e, - {"application_id": application_id}, - ) - return False - if not r.is_success: - handlers.warn( - "DISCORD_PATCH_ORIGINAL_FAILED", - None, - { - "application_id": application_id, - "status_code": r.status_code, - "body": r.text[:800], - }, - ) - return False - return True - - -async def _patch_discord_followup_best_effort( - application_id: str, interaction_token: str, data: dict[str, Any] -) -> None: - """Send primary payload; on failure send a generic line (never echo httpx URLs or tokens).""" - if await patch_discord_original(application_id, interaction_token, data): - return - await patch_discord_original( - application_id, - interaction_token, - {"content": USER_FACING_DISCORD_UPDATE_FAILED}, - ) - - -def _application_id(interaction: dict[str, Any], settings: Settings) -> str: - return str( - interaction.get("application_id") or settings.discord_application_id or "" - ) - - -def _comma_separated_digit_ids(raw: str) -> list[str]: - out: list[str] = [] - for part in raw.replace(",", " ").split(): - s = part.strip() - if s and re.fullmatch(r"\d+", s): - out.append(s) - return out - - -def _subscription_json_body(leaf_opts: dict[str, Any]) -> dict[str, Any]: - body: dict[str, Any] = {} - wn = str( - leaf_opts.get("discord_webhook_name") - or leaf_opts.get("webhook_name") - or "" - ).strip() - if wn: - body["name"] = wn[:80] - filters: dict[str, Any] = {} - if "require_fresh" in leaf_opts: - filters["requireFresh"] = bool(leaf_opts["require_fresh"]) - if "require_completed" in leaf_opts: - filters["requireCompleted"] = bool(leaf_opts["require_completed"]) - if filters: - body["filters"] = filters - targets: dict[str, Any] = {} - players = _comma_separated_digit_ids(str(leaf_opts.get("players") or "")) - if players: - targets["playerMembershipIds"] = players - clans = _comma_separated_digit_ids(str(leaf_opts.get("clans") or "")) - if clans: - targets["clanGroupIds"] = clans - if targets: - body["targets"] = targets - return body - - -def _subscription_rules_suffix(rules: dict[str, Any]) -> str: - p = rules.get("players") or {} - c = rules.get("clans") or {} - pl = int(p.get("inserted", 0)) + int(p.get("updated", 0)) - cl = int(c.get("inserted", 0)) + int(c.get("updated", 0)) - if pl or cl: - return f"Rule changes: {pl} player row(s), {cl} clan row(s)." - return "" - - -def _format_subscription_status_embed(data: dict[str, Any]) -> dict[str, Any]: - if not data.get("registered"): - return _info_embed( - "Subscription Status", - "No RaidHub subscription webhook is registered for this channel.", - ) - active = "**yes**" if data.get("destinationActive") else "**no**" - fails = int(data.get("consecutiveDeliveryFailures") or 0) - fields: list[dict[str, Any]] = [ - {"name": "Destination Active", "value": active, "inline": True}, - {"name": "Webhook ID", "value": f"`{data.get('webhookId', '—')}`", "inline": True}, - {"name": "Delivery Failures", "value": f"**{fails}**", "inline": True}, - ] - ls = data.get("lastDeliverySuccessAt") - lf = data.get("lastDeliveryFailureAt") - fields.append( - { - "name": "Last Delivery Success", - "value": _discord_relative_timestamp(ls) if ls else "—", - "inline": True, - } - ) - fields.append( - { - "name": "Last Delivery Failure", - "value": _discord_relative_timestamp(lf) if lf else "—", - "inline": True, - } - ) - err = data.get("lastDeliveryError") - if err: - fields.append({"name": "Last Error", "value": str(err)[:280], "inline": False}) - - pl_raw = list(data.get("players") or []) - cl_raw = list(data.get("clans") or []) - pl_ids = [ - str(item.get("membershipId") if isinstance(item, dict) else item) - for item in pl_raw - if item is not None - ] - cl_ids = [ - str(item.get("groupId") if isinstance(item, dict) else item) - for item in cl_raw - if item is not None - ] - pc = len(pl_ids) - cc = len(cl_ids) - max_show = 25 - p_show = pl_ids[:max_show] - c_show = cl_ids[:max_show] - p_list = ", ".join(p_show) if p_show else "—" - if pc > len(p_show): - p_list = f"{p_list} (+{pc - len(p_show)} more)" - c_list = ", ".join(c_show) if c_show else "—" - if cc > len(c_show): - c_list = f"{c_list} (+{cc - len(c_show)} more)" - fields.append({"name": f"Player Rules ({pc})", "value": p_list[:1024], "inline": False}) - fields.append({"name": f"Clan Rules ({cc})", "value": c_list[:1024], "inline": False}) - return _base_embed( - title="Subscription Status", - description="Current webhook destination and delivery health.", - color=0x5865_F2, - fields=fields, - ) - - -def _subscription_envelope_error_message(env: dict[str, Any]) -> str: - code = str(env.get("code", "")) - if code == "InsufficientPermissionsError": - return ( - "RaidHub rejected this request. Use a **server text channel** where the bot can " - "**Manage Webhooks**, and ensure the API trusts your bot JWT." - ) - if code == "BodyValidationError": - return ( - "RaidHub could not validate the payload. Use digits only for **players** / **clans** " - "lists (comma-separated)." - ) - return _discord_message_for_failed_envelope(code, "") - - -def _log_envelope_failure(log_key: str, env: dict[str, Any], extra: dict[str, Any]) -> None: - err = env.get("error") or {} - handlers.warn( - log_key, - None, - { - **extra, - "code": str(env.get("code") or ""), - "error_code": str(err.get("code") or ""), - "http_status": int(err.get("httpStatus") or 0), - "error_message": str(err.get("message") or "")[:200], - }, - ) - - -async def run_subscription_deferred( - interaction: dict[str, Any], - raidhub: RaidHubClient, - settings: Settings, -) -> None: - app_id = _application_id(interaction, settings) - token = str(interaction.get("token") or "") - outcome = "completed" - try: - data = interaction.get("data") or {} - top_opts = data.get("options") or [] - if not top_opts or not isinstance(top_opts[0], dict): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed( - "Subscription Command", - "Pick `register`, `update`, `delete`, or `status` under `/subscription`.", - ), - ) - return - - sub = str(top_opts[0].get("name") or "").strip().lower() - if sub not in ("register", "update", "delete", "status"): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed("Subscription Command", "Unknown `/subscription` subcommand."), - ) - return - - if not interaction.get("guild_id") or not interaction.get("channel_id"): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed( - "Subscription Command", - "Run this command in a server channel, not a DM.", - ), - ) - return - - leaf_opts = flatten_options(top_opts[0].get("options")) - route_id = { - "register": _SUB_ROUTE_PUT, - "update": _SUB_ROUTE_PUT, - "delete": _SUB_ROUTE_DELETE, - "status": _SUB_ROUTE_STATUS, - }[sub] - ctx = discord_invocation_context(interaction, route_id=route_id) - - if sub == "status": - env = await raidhub.request_envelope( - "GET", - "/subscriptions/discord/webhooks", - discord_context=ctx, - ) - elif sub == "delete": - env = await raidhub.request_envelope( - "DELETE", - "/subscriptions/discord/webhooks", - discord_context=ctx, - ) - else: - payload = _subscription_json_body(leaf_opts) - env = await raidhub.request_envelope( - "PUT", - "/subscriptions/discord/webhooks", - json=payload if payload else {}, - discord_context=ctx, - ) - - if not env.get("success"): - _log_envelope_failure( - "SUBSCRIPTION_ENVELOPE_FAILED", - env, - {"subcommand": sub, "route_id": route_id}, - ) - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed( - "Subscription Request Failed", - _subscription_envelope_error_message(env), - ), - ) - return - - inner = env.get("response") or {} - if sub == "status": - msg = _format_subscription_status_embed(inner) - elif sub == "delete": - msg = _success_embed( - "Subscription Removed", - "RaidHub will no longer use a webhook in this channel.", - ) - elif sub == "register": - parts = [ - "RaidHub subscription events will post to this channel.", - ] - if inner.get("created"): - parts.append("A new subscription destination was created.") - if inner.get("activated"): - parts.append("The destination was re-activated.") - rules = inner.get("rules") or {} - rs = _subscription_rules_suffix(rules) - if rs: - parts.append(rs) - msg = _success_embed("Subscription Registered", " ".join(parts)) - else: - parts = ["Subscription rules for this channel were saved."] - if inner.get("activated"): - parts.append("The destination was re-activated.") - rules = inner.get("rules") or {} - rs = _subscription_rules_suffix(rules) - if rs: - parts.append(rs) - msg = _success_embed("Subscription Updated", " ".join(parts)) - - await _patch_discord_followup_best_effort(app_id, token, msg) - except Exception as e: - outcome = "error" - handlers.error("SUBSCRIPTION_DEFERRED_FAILED", e, {}) - await _patch_discord_followup_best_effort( - app_id, token, _error_embed("Subscription Failed", USER_FACING_GENERIC) - ) - finally: - observe_deferred_completion(command="subscription", outcome=outcome) - - -async def run_player_search_deferred( - interaction: dict[str, Any], - raidhub: RaidHubClient, - settings: Settings, -) -> None: - app_id = _application_id(interaction, settings) - token = str(interaction.get("token") or "") - outcome = "completed" - try: - opts = flatten_options(interaction.get("data", {}).get("options")) - query = str(opts.get("search_query") or opts.get("query") or "").strip() - if not query: - await _patch_discord_followup_best_effort( - app_id, token, {"content": "Provide a **search_query** option to search."} - ) - return - - query_params: dict[str, Any] = {"query": query} - if "destiny_membership_type" in opts: - query_params["membershipType"] = opts["destiny_membership_type"] - elif "membership_type" in opts: - query_params["membershipType"] = opts["membership_type"] - if "use_global_name_search" in opts: - query_params["global"] = opts["use_global_name_search"] - elif "global" in opts: - query_params["global"] = opts["global"] - - page_size = PLAYER_SEARCH_PAGE_SIZE - session_id = store_paged_session( - {"query_params": query_params, "page_size": page_size} - ) - session_state: dict[str, Any] = { - "query_params": query_params, - "page_size": page_size, - } - payload = await _player_search_render_from_state( - raidhub, session_state, session_id, "0" - ) - await _patch_discord_followup_best_effort(app_id, token, payload) - except Exception as e: - outcome = "error" - handlers.error("PLAYER_SEARCH_DEFERRED_FAILED", e, {}) - await _patch_discord_followup_best_effort( - app_id, token, {"content": USER_FACING_GENERIC} - ) - finally: - observe_deferred_completion(command="player-search", outcome=outcome) - - -async def run_instance_deferred( - interaction: dict[str, Any], - raidhub: RaidHubClient, - settings: Settings, -) -> None: - app_id = _application_id(interaction, settings) - token = str(interaction.get("token") or "") - outcome = "completed" - try: - opts = flatten_options(interaction.get("data", {}).get("options")) - raw_id = opts.get("raid_instance_id") or opts.get("instance_id") - if raw_id is None or str(raw_id).strip() == "": - await _patch_discord_followup_best_effort( - app_id, token, {"content": "Provide a **raid_instance_id** to look up."} - ) - return - instance_id = str(raw_id).strip() - - env = await raidhub.request_envelope("GET", f"/instance/{instance_id}") - if not env.get("success"): - code = str(env.get("code", "")) - if code == "InstanceNotFoundError": - await _patch_discord_followup_best_effort( - app_id, token, {"content": "Instance not found."} - ) - return - await _patch_discord_followup_best_effort( - app_id, - token, - {"content": _discord_message_for_failed_envelope(code, "")}, - ) - return - - inst = env.get("response") or {} - meta = inst.get("metadata") or {} - title = str(meta.get("activityName") or "Raid instance") - desc = f"Instance `{inst.get('instanceId', instance_id)}`" - date_done = inst.get("dateCompleted") - date_s = str(date_done) if date_done else "—" - embed = { - "title": title, - "description": desc, - "color": 0x5865_F2, - "fields": [ - { - "name": "Version", - "value": str(meta.get("versionName") or "—"), - "inline": True, - }, - { - "name": "Players", - "value": str(inst.get("playerCount", "—")), - "inline": True, - }, - { - "name": "Duration", - "value": _format_duration(int(inst.get("duration") or 0)), - "inline": True, - }, - { - "name": "Completed", - "value": "Yes" if inst.get("completed") else "No", - "inline": True, - }, - { - "name": "Fresh", - "value": "Yes" if inst.get("fresh") else "No", - "inline": True, - }, - { - "name": "Flawless", - "value": "Yes" if inst.get("flawless") else "No", - "inline": True, - }, - {"name": "Completed At", "value": date_s, "inline": False}, - ], - } - await _patch_discord_followup_best_effort(app_id, token, {"embeds": [embed]}) - except Exception as e: - outcome = "error" - handlers.error("INSTANCE_DEFERRED_FAILED", e, {}) - await _patch_discord_followup_best_effort( - app_id, token, {"content": USER_FACING_GENERIC} - ) - finally: - observe_deferred_completion(command="instance", outcome=outcome) diff --git a/src/main.py b/src/main.py index 1f1a0e6..913b93a 100644 --- a/src/main.py +++ b/src/main.py @@ -1,202 +1,3 @@ -from __future__ import annotations +from .app_factory import create_app -import json -import time -from typing import Any - -from discord_interactions import InteractionResponseType, InteractionType -from fastapi import BackgroundTasks, FastAPI, HTTPException, Request -from fastapi.responses import JSONResponse -from starlette.responses import Response - -from .config import get_settings -from .discord_auth import verify_discord_signature_with_reason -from .log import ingress -from .prom_metrics import metrics_response, observe_interaction -from .commands import ( - register_player_search_pager, - run_instance_deferred, - run_player_search_deferred, - run_subscribe_deferred, - run_subscription_deferred, - run_unsubscribe_deferred, -) -from .pagination import try_handle_pager_component -from .raidhub_client import RaidHubClient - -app = FastAPI(title="raidhub-discord") -settings = get_settings() - - -@app.get("/metrics") -async def prometheus_metrics() -> Response: - return metrics_response() -raidhub = RaidHubClient( - settings.raidhub_api_base_url, - settings.raidhub_jwt_secret, - api_key=settings.raidhub_api_key, -) -register_player_search_pager(raidhub) - - -def _msg(content: str) -> dict[str, Any]: - return { - "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - "data": {"content": content}, - } - - -@app.post("/interactions") -async def discord_interactions( - request: Request, - background_tasks: BackgroundTasks, -) -> JSONResponse: - t0 = time.perf_counter() - signature = request.headers.get("X-Signature-Ed25519", "") - timestamp = request.headers.get("X-Signature-Timestamp", "") - raw_body = await request.body() - signature_ok, signature_reason = verify_discord_signature_with_reason( - settings.discord_public_key, timestamp, raw_body, signature - ) - - ingress.info( - "DISCORD_INTERACTION_RECEIVED", - { - "path": str(request.url.path), - "remote_ip": request.client.host if request.client else None, - "has_signature": bool(signature), - "has_timestamp": bool(timestamp), - "has_public_key": bool(settings.discord_public_key), - "signature_valid": signature_ok, - "signature_reason": signature_reason, - "signature_len": len(signature.strip()), - "timestamp_len": len(timestamp.strip()), - "body_len": len(raw_body), - }, - ) - - if not signature_ok: - ingress.warn( - "DISCORD_SIGNATURE_INVALID", - None, - { - "reason": signature_reason, - "signature_len": len(signature.strip()), - "timestamp_len": len(timestamp.strip()), - "body_len": len(raw_body), - }, - ) - observe_interaction(handler="signature_invalid", status="rejected", started_monotonic=t0) - raise HTTPException(status_code=401, detail="Invalid Discord signature") - - interaction = json.loads(raw_body.decode("utf-8")) - interaction_type = interaction.get("type") - ingress.info("DISCORD_INTERACTION_TYPE", {"type": interaction_type}) - - if interaction_type == InteractionType.PING: - ingress.info("DISCORD_PING_RECEIVED", {}) - observe_interaction(handler="ping", status="ok", started_monotonic=t0) - return JSONResponse({"type": InteractionResponseType.PONG}) - - if interaction_type == InteractionType.MESSAGE_COMPONENT: - updated = await try_handle_pager_component(interaction) - if not updated: - ingress.warn("DISCORD_COMPONENT_UNSUPPORTED", None, {}) - observe_interaction( - handler="message_component_unsupported", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - { - "type": InteractionResponseType.UPDATE_MESSAGE, - "data": {"content": "Unsupported interaction component."}, - } - ) - observe_interaction( - handler="message_component_pager", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - {"type": InteractionResponseType.UPDATE_MESSAGE, "data": updated} - ) - - if interaction_type != InteractionType.APPLICATION_COMMAND: - ingress.warn("DISCORD_INTERACTION_UNSUPPORTED", None, {"type": interaction_type}) - observe_interaction( - handler="application_command_unsupported_type", - status="ok", - started_monotonic=t0, - ) - return JSONResponse(_msg("Unsupported interaction type.")) - - name = interaction.get("data", {}).get("name") - ingress.info("DISCORD_COMMAND_RECEIVED", {"command_name": name}) - - if name == "instance": - background_tasks.add_task(run_instance_deferred, interaction, raidhub, settings) - observe_interaction( - handler="application_command_instance_deferred", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - {"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} - ) - - if name == "player-search": - background_tasks.add_task( - run_player_search_deferred, interaction, raidhub, settings - ) - observe_interaction( - handler="application_command_player_search_deferred", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - {"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} - ) - - if name == "subscribe": - background_tasks.add_task(run_subscribe_deferred, interaction, raidhub, settings) - observe_interaction( - handler="application_command_subscribe_deferred", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - {"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} - ) - - if name == "subscription": - background_tasks.add_task( - run_subscription_deferred, interaction, raidhub, settings - ) - observe_interaction( - handler="application_command_subscription_deferred", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - {"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} - ) - - if name == "unsubscribe": - background_tasks.add_task(run_unsubscribe_deferred, interaction, raidhub, settings) - observe_interaction( - handler="application_command_unsubscribe_deferred", - status="ok", - started_monotonic=t0, - ) - return JSONResponse( - {"type": InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE} - ) - - ingress.warn("DISCORD_COMMAND_NOT_ENABLED", None, {"command_name": name}) - observe_interaction( - handler="application_command_unknown", - status="ok", - started_monotonic=t0, - ) - return JSONResponse(_msg("Command not enabled yet."), status_code=200) +app = create_app() diff --git a/src/manifest/__init__.py b/src/manifest/__init__.py new file mode 100644 index 0000000..ed90112 --- /dev/null +++ b/src/manifest/__init__.py @@ -0,0 +1,8 @@ +from .builders import build_commands + + +def build_command_manifest() -> list[dict]: + return [c.to_json() for c in build_commands()] + + +__all__ = ["build_command_manifest", "build_commands"] diff --git a/src/manifest/builders.py b/src/manifest/builders.py new file mode 100644 index 0000000..0b4567d --- /dev/null +++ b/src/manifest/builders.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from .schema import CommandDto, CommandOptionDto, CommandOptionType + + +def build_commands() -> list[CommandDto]: + return [ + CommandDto( + name="instance", + description="Lookup a RaidHub instance by id.", + options=[ + CommandOptionDto( + type=CommandOptionType.STRING, + name="raid_instance_id", + description="Instance id to lookup", + required=False, + ) + ], + ), + CommandDto( + name="player-search", + description="Search RaidHub players by Bungie name or platform name.", + options=[ + CommandOptionDto( + type=CommandOptionType.STRING, + name="search_query", + description="Search text", + required=True, + ), + CommandOptionDto( + type=CommandOptionType.INTEGER, + name="destiny_membership_type", + description="Destiny membership type", + required=False, + ), + CommandOptionDto( + type=CommandOptionType.BOOLEAN, + name="use_global_name_search", + description="Search by Bungie name", + required=False, + ), + ], + ), + CommandDto( + name="subscribe", + description="Subscribe this channel to a player or clan (resolves names & URLs here).", + dm_permission=False, + options=[ + CommandOptionDto( + type=CommandOptionType.SUB_COMMAND, + name="player", + description="Subscribe by membership id or player name (top search hit).", + options=[ + CommandOptionDto( + type=CommandOptionType.STRING, + name="player", + description="Destiny membership id (digits) or player name", + required=True, + ) + ], + ), + CommandOptionDto( + type=CommandOptionType.SUB_COMMAND, + name="clan", + description="Subscribe by Bungie clan group id or clan page URL.", + options=[ + CommandOptionDto( + type=CommandOptionType.STRING, + name="clan", + description="Numeric group id, raidhub.io/clan/…, or Bungie clan URL", + required=True, + ) + ], + ), + ], + ), + CommandDto( + name="subscription", + description="Inspect or remove the RaidHub subscription webhook for this channel.", + dm_permission=False, + options=[ + CommandOptionDto( + type=CommandOptionType.SUB_COMMAND, + name="status", + description="Show whether this channel is registered and delivery health.", + options=[], + ), + CommandOptionDto( + type=CommandOptionType.SUB_COMMAND, + name="delete", + description="Remove the webhook destination and rules for this channel.", + options=[], + ), + ], + ), + CommandDto( + name="unsubscribe", + description="Remove the RaidHub subscription webhook from this channel.", + dm_permission=False, + options=[], + ), + CommandDto( + name="unsubscribe-player", + description="Remove one subscribed Destiny player from this channel (other rules unchanged).", + dm_permission=False, + options=[ + CommandOptionDto( + type=CommandOptionType.STRING, + name="player", + description="Destiny membership id (digits) or player name", + required=True, + ) + ], + ), + CommandDto( + name="unsubscribe-clan", + description="Remove one subscribed Bungie clan from this channel (other rules unchanged).", + dm_permission=False, + options=[ + CommandOptionDto( + type=CommandOptionType.STRING, + name="clan", + description="Numeric group id, raidhub.io/clan/…, or Bungie clan URL", + required=True, + ) + ], + ), + ] diff --git a/src/manifest/schema.py b/src/manifest/schema.py new file mode 100644 index 0000000..ae4b53d --- /dev/null +++ b/src/manifest/schema.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Any + +from ..discord_v10_enums import ApplicationCommandOptionType, ApplicationCommandType + + +class CommandType(IntEnum): + CHAT_INPUT = int(ApplicationCommandType.CHAT_INPUT) + + +class CommandOptionType(IntEnum): + SUB_COMMAND = int(ApplicationCommandOptionType.SUB_COMMAND) + STRING = int(ApplicationCommandOptionType.STRING) + INTEGER = int(ApplicationCommandOptionType.INTEGER) + BOOLEAN = int(ApplicationCommandOptionType.BOOLEAN) + + +@dataclass(frozen=True, slots=True) +class CommandOptionDto: + type: CommandOptionType + name: str + description: str + required: bool | None = None + options: list["CommandOptionDto"] | None = None + + def to_json(self) -> dict[str, Any]: + data: dict[str, Any] = { + "type": int(self.type), + "name": self.name, + "description": self.description, + } + if self.required is not None: + data["required"] = self.required + if self.options is not None: + data["options"] = [o.to_json() for o in self.options] + return data + + +@dataclass(frozen=True, slots=True) +class CommandDto: + name: str + description: str + type: CommandType = CommandType.CHAT_INPUT + dm_permission: bool | None = None + options: list[CommandOptionDto] | None = None + + def to_json(self) -> dict[str, Any]: + data: dict[str, Any] = { + "name": self.name, + "description": self.description, + "type": int(self.type), + } + if self.dm_permission is not None: + data["dm_permission"] = self.dm_permission + if self.options is not None: + data["options"] = [o.to_json() for o in self.options] + return data diff --git a/src/pagination/__init__.py b/src/pagination/__init__.py index 3e107d0..68d7697 100644 --- a/src/pagination/__init__.py +++ b/src/pagination/__init__.py @@ -1,21 +1,27 @@ """Discord message-component navigation (session + opaque nav_token per click).""" -from .session_pager import ( - InMemoryPagedSessionStore, +from .components import ( build_dual_nav_action_row, build_pager_action_row, build_triple_nav_action_row, - clamp_page, - default_expired_session_content, +) +from .ids import ( pager_custom_id, - parse_nav_token_as_int, - parse_offset_page_nav_token, parse_pager_custom_id, +) +from .runtime import ( + InMemoryPagedSessionStore, + default_expired_session_content, register_pager, store_paged_session, - total_page_count, try_handle_pager_component, ) +from .tokens import ( + clamp_page, + parse_nav_token_as_int, + parse_offset_page_nav_token, + total_page_count, +) __all__ = [ "InMemoryPagedSessionStore", diff --git a/src/pagination/components.py b/src/pagination/components.py new file mode 100644 index 0000000..8d5776a --- /dev/null +++ b/src/pagination/components.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any + +from ..discord_v10_enums import ButtonStyle, ComponentType +from .ids import pager_custom_id + + +def build_dual_nav_action_row( + *, + prefix: str, + session_id: str, + prev_nav_token: str, + next_nav_token: str, + prev_disabled: bool, + next_disabled: bool, + prev_label: str = "Prev", + next_label: str = "Next", +) -> dict[str, Any]: + return { + "type": int(ComponentType.ACTION_ROW), + "components": [ + { + "type": int(ComponentType.BUTTON), + "style": int(ButtonStyle.SECONDARY), + "custom_id": pager_custom_id(prefix, session_id, prev_nav_token), + "label": prev_label, + "disabled": prev_disabled, + }, + { + "type": int(ComponentType.BUTTON), + "style": int(ButtonStyle.SECONDARY), + "custom_id": pager_custom_id(prefix, session_id, next_nav_token), + "label": next_label, + "disabled": next_disabled, + }, + ], + } + + +def build_triple_nav_action_row( + *, + prefix: str, + session_id: str, + first_nav_token: str, + prev_nav_token: str, + next_nav_token: str, + first_disabled: bool, + prev_disabled: bool, + next_disabled: bool, + first_label: str = "Start", + prev_label: str = "Prev", + next_label: str = "Next", +) -> dict[str, Any]: + return { + "type": int(ComponentType.ACTION_ROW), + "components": [ + { + "type": int(ComponentType.BUTTON), + "style": int(ButtonStyle.SECONDARY), + "custom_id": pager_custom_id(prefix, session_id, first_nav_token), + "label": first_label, + "disabled": first_disabled, + }, + { + "type": int(ComponentType.BUTTON), + "style": int(ButtonStyle.SECONDARY), + "custom_id": pager_custom_id(prefix, session_id, prev_nav_token), + "label": prev_label, + "disabled": prev_disabled, + }, + { + "type": int(ComponentType.BUTTON), + "style": int(ButtonStyle.SECONDARY), + "custom_id": pager_custom_id(prefix, session_id, next_nav_token), + "label": next_label, + "disabled": next_disabled, + }, + ], + } + + +def build_pager_action_row( + *, + prefix: str, + session_id: str, + current_page: int, + total_pages: int, + prev_label: str = "Prev", + next_label: str = "Next", +) -> dict[str, Any]: + prev_token = f"p{current_page - 1}" + next_token = f"n{current_page + 1}" + return build_dual_nav_action_row( + prefix=prefix, + session_id=session_id, + prev_nav_token=prev_token, + next_nav_token=next_token, + prev_disabled=current_page <= 0, + next_disabled=current_page >= total_pages - 1, + prev_label=prev_label, + next_label=next_label, + ) diff --git a/src/pagination/ids.py b/src/pagination/ids.py new file mode 100644 index 0000000..7ca6acc --- /dev/null +++ b/src/pagination/ids.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +def parse_pager_custom_id(custom_id: str) -> tuple[str, str, str] | None: + parts = custom_id.split(":", 2) + if len(parts) != 3: + return None + prefix, session_id, nav_token = parts[0], parts[1], parts[2] + if not prefix or not session_id: + return None + return prefix, session_id, nav_token + + +def pager_custom_id(prefix: str, session_id: str, nav_token: str) -> str: + return f"{prefix}:{session_id}:{nav_token}" diff --git a/src/pagination/runtime.py b/src/pagination/runtime.py new file mode 100644 index 0000000..690eb66 --- /dev/null +++ b/src/pagination/runtime.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import secrets +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from ..log import pagination as pagination_log +from ..prom_metrics import observe_pager_render_failure +from .ids import parse_pager_custom_id + +DEFAULT_SESSION_TTL_SEC = 600.0 +DEFAULT_EXPIRED_MESSAGE = "This session expired. Run the command again." + +PagerRenderFn = Callable[[dict[str, Any], str, str], Awaitable[dict[str, Any]]] + + +@dataclass(frozen=True) +class _PagerRegistration: + render: PagerRenderFn + expired_message: str + + +class InMemoryPagedSessionStore: + def __init__(self, *, ttl_sec: float = DEFAULT_SESSION_TTL_SEC) -> None: + self._ttl_sec = ttl_sec + self._sessions: dict[str, tuple[float, dict[str, Any]]] = {} + + def _purge(self) -> None: + now = time.time() + dead = [k for k, (created, _) in self._sessions.items() if now - created > self._ttl_sec] + for k in dead: + del self._sessions[k] + + def put(self, state: dict[str, Any]) -> str: + self._purge() + session_id = secrets.token_hex(8) + self._sessions[session_id] = (time.time(), state) + return session_id + + def get(self, session_id: str) -> dict[str, Any] | None: + self._purge() + entry = self._sessions.get(session_id) + if not entry: + return None + return entry[1] + + +_global_store = InMemoryPagedSessionStore() +_registrations: dict[str, _PagerRegistration] = {} + + +def default_expired_session_content() -> dict[str, Any]: + return {"content": DEFAULT_EXPIRED_MESSAGE} + + +def register_pager( + prefix: str, + render: PagerRenderFn, + *, + expired_message: str | None = None, +) -> None: + if ":" in prefix: + raise ValueError("pager prefix must not contain ':'") + _registrations[prefix] = _PagerRegistration( + render=render, + expired_message=expired_message or DEFAULT_EXPIRED_MESSAGE, + ) + + +def store_paged_session(state: dict[str, Any]) -> str: + return _global_store.put(state) + + +async def try_handle_pager_component(interaction: dict[str, Any]) -> dict[str, Any] | None: + custom_id = (interaction.get("data") or {}).get("custom_id") or "" + parsed = parse_pager_custom_id(custom_id) + if not parsed: + return None + prefix, session_id, nav_token = parsed + reg = _registrations.get(prefix) + if not reg: + return None + state = _global_store.get(session_id) + if state is None: + return {"content": reg.expired_message} + try: + return await reg.render(state, session_id, nav_token) + except Exception as e: + pagination_log.error( + "PAGER_RENDER_FAILED", e, {"prefix": prefix, "nav_token": nav_token} + ) + observe_pager_render_failure(prefix) + return {"content": "Something went wrong updating this page."} diff --git a/src/pagination/session_pager.py b/src/pagination/session_pager.py deleted file mode 100644 index 0d1b024..0000000 --- a/src/pagination/session_pager.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -Reusable Discord "pager" sessions + message-component navigation. - -Sessions - Opaque JSON-serializable state keyed by a short id, TTL in memory (single-process). - -custom_id contract - ``{prefix}:{session_id}:{nav_token}`` - - ``nav_token`` is **opaque to this module** — only your registered renderer interprets it. - Examples: - - - **Offset / page index:** ``build_pager_action_row`` uses ``p{target}`` / ``n{target}`` (see - ``parse_offset_page_nav_token``); initial slash render and a **Start** control may use plain - ``"0"`` for page 0. - - **Cursor-style:** ``"prev"`` / ``"next"`` (renderer reads cursors from ``state``); or a - short opaque key that maps to a cursor stored server-side in ``state``. - - Use ``str.split(":", 2)`` so ``nav_token`` may contain ``:`` if you ever need it (keep total - ``custom_id`` length ≤ 100 per Discord limits). - -Register ``prefix`` with ``async render(state, session_id, nav_token) ->`` Discord message body. -""" - -from __future__ import annotations - -import secrets -import time -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from ..discord_v10_enums import ButtonStyle, ComponentType -from ..log import pagination as pagination_log -from ..prom_metrics import observe_pager_render_failure - -DEFAULT_SESSION_TTL_SEC = 600.0 -DEFAULT_EXPIRED_MESSAGE = "This session expired. Run the command again." - -PagerRenderFn = Callable[[dict[str, Any], str, str], Awaitable[dict[str, Any]]] - - -@dataclass(frozen=True) -class _PagerRegistration: - render: PagerRenderFn - expired_message: str - - -class InMemoryPagedSessionStore: - """TTL map ``session_id -> opaque state`` (single-process; not for multi-worker).""" - - def __init__(self, *, ttl_sec: float = DEFAULT_SESSION_TTL_SEC) -> None: - self._ttl_sec = ttl_sec - self._sessions: dict[str, tuple[float, dict[str, Any]]] = {} - - def _purge(self) -> None: - now = time.time() - dead = [k for k, (created, _) in self._sessions.items() if now - created > self._ttl_sec] - for k in dead: - del self._sessions[k] - - def put(self, state: dict[str, Any]) -> str: - self._purge() - session_id = secrets.token_hex(8) - self._sessions[session_id] = (time.time(), state) - return session_id - - def get(self, session_id: str) -> dict[str, Any] | None: - self._purge() - entry = self._sessions.get(session_id) - if not entry: - return None - return entry[1] - - -_global_store = InMemoryPagedSessionStore() -_registrations: dict[str, _PagerRegistration] = {} - - -def default_expired_session_content() -> dict[str, Any]: - return {"content": DEFAULT_EXPIRED_MESSAGE} - - -def register_pager( - prefix: str, - render: PagerRenderFn, - *, - expired_message: str | None = None, -) -> None: - """ - Register a component handler for ``prefix``. - - ``render(state, session_id, nav_token)`` (async) returns the full Discord message update body - (``content``, ``embeds``, ``components``, …). Interpret ``nav_token`` however fits the - feature (integer page, ``next``/``prev``, encoded cursor, …). - """ - if ":" in prefix: - raise ValueError("pager prefix must not contain ':'") - _registrations[prefix] = _PagerRegistration( - render=render, - expired_message=expired_message or DEFAULT_EXPIRED_MESSAGE, - ) - - -def store_paged_session(state: dict[str, Any]) -> str: - """Persist state for any registered pager; returns ``session_id`` for custom_ids.""" - return _global_store.put(state) - - -def clamp_page(page: int, total_pages: int) -> int: - """Clamp 0-based page index (offset UIs).""" - if total_pages < 1: - return 0 - return max(0, min(page, total_pages - 1)) - - -def total_page_count(item_count: int, page_size: int) -> int: - """Number of 0-indexed pages for ``item_count`` items at ``page_size`` per page (min 1).""" - if page_size < 1: - return 1 - return max(1, (max(0, item_count) + page_size - 1) // page_size) - - -def parse_nav_token_as_int(nav_token: str, *, default: int = 0) -> int: - """Parse ``nav_token`` as a plain decimal page index.""" - try: - return int(nav_token.strip(), 10) - except ValueError: - return default - - -def parse_offset_page_nav_token(nav_token: str, *, default: int = 0) -> int: - """ - Decode page index from ``build_pager_action_row`` tokens ``p{target}`` / ``n{target}`` - (prev vs next so both buttons never share the same ``custom_id`` on a one-page result), - or a plain decimal for legacy / initial loads. - """ - t = nav_token.strip() - if len(t) >= 2 and t[0] in ("p", "n"): - try: - return int(t[1:], 10) - except ValueError: - return default - return parse_nav_token_as_int(t, default=default) - - -def parse_pager_custom_id(custom_id: str) -> tuple[str, str, str] | None: - """ - Parse ``prefix:session_id:nav_token``. - - ``nav_token`` is returned verbatim (may be empty string — renderers should treat as invalid). - """ - parts = custom_id.split(":", 2) - if len(parts) != 3: - return None - prefix, session_id, nav_token = parts[0], parts[1], parts[2] - if not prefix or not session_id: - return None - return prefix, session_id, nav_token - - -def pager_custom_id(prefix: str, session_id: str, nav_token: str) -> str: - return f"{prefix}:{session_id}:{nav_token}" - - -def build_dual_nav_action_row( - *, - prefix: str, - session_id: str, - prev_nav_token: str, - next_nav_token: str, - prev_disabled: bool, - next_disabled: bool, - prev_label: str = "Prev", - next_label: str = "Next", -) -> dict[str, Any]: - """ - Prev/Next buttons with arbitrary ``nav_token`` strings (cursor flows, named actions, …). - - Keep tokens short so ``custom_id`` stays within Discord's 100-character limit. - """ - return { - "type": int(ComponentType.ACTION_ROW), - "components": [ - { - "type": int(ComponentType.BUTTON), - "style": int(ButtonStyle.SECONDARY), - "custom_id": pager_custom_id(prefix, session_id, prev_nav_token), - "label": prev_label, - "disabled": prev_disabled, - }, - { - "type": int(ComponentType.BUTTON), - "style": int(ButtonStyle.SECONDARY), - "custom_id": pager_custom_id(prefix, session_id, next_nav_token), - "label": next_label, - "disabled": next_disabled, - }, - ], - } - - -def build_triple_nav_action_row( - *, - prefix: str, - session_id: str, - first_nav_token: str, - prev_nav_token: str, - next_nav_token: str, - first_disabled: bool, - prev_disabled: bool, - next_disabled: bool, - first_label: str = "Start", - prev_label: str = "Prev", - next_label: str = "Next", -) -> dict[str, Any]: - """Start (first page) / Prev / Next in one row (e.g. ``first_nav_token`` ``\"0\"`` for page 0).""" - return { - "type": int(ComponentType.ACTION_ROW), - "components": [ - { - "type": int(ComponentType.BUTTON), - "style": int(ButtonStyle.SECONDARY), - "custom_id": pager_custom_id(prefix, session_id, first_nav_token), - "label": first_label, - "disabled": first_disabled, - }, - { - "type": int(ComponentType.BUTTON), - "style": int(ButtonStyle.SECONDARY), - "custom_id": pager_custom_id(prefix, session_id, prev_nav_token), - "label": prev_label, - "disabled": prev_disabled, - }, - { - "type": int(ComponentType.BUTTON), - "style": int(ButtonStyle.SECONDARY), - "custom_id": pager_custom_id(prefix, session_id, next_nav_token), - "label": next_label, - "disabled": next_disabled, - }, - ], - } - - -def build_pager_action_row( - *, - prefix: str, - session_id: str, - current_page: int, - total_pages: int, - prev_label: str = "Prev", - next_label: str = "Next", -) -> dict[str, Any]: - """ - Offset pagination: prev encodes target ``current_page - 1`` as ``p{int}``, next as ``n{int}`` - (always distinct ``custom_id`` values, including single-page results). Decode with - ``parse_offset_page_nav_token``. - - For cursor-based APIs, use ``build_dual_nav_action_row`` and interpret tokens in your renderer. - """ - prev_token = f"p{current_page - 1}" - next_token = f"n{current_page + 1}" - return build_dual_nav_action_row( - prefix=prefix, - session_id=session_id, - prev_nav_token=prev_token, - next_nav_token=next_token, - prev_disabled=current_page <= 0, - next_disabled=current_page >= total_pages - 1, - prev_label=prev_label, - next_label=next_label, - ) - - -async def try_handle_pager_component(interaction: dict[str, Any]) -> dict[str, Any] | None: - """ - If ``data.custom_id`` matches a registered pager, return the new message payload - (for ``UPDATE_MESSAGE``). Otherwise return ``None``. - """ - custom_id = (interaction.get("data") or {}).get("custom_id") or "" - parsed = parse_pager_custom_id(custom_id) - if not parsed: - return None - prefix, session_id, nav_token = parsed - reg = _registrations.get(prefix) - if not reg: - return None - state = _global_store.get(session_id) - if state is None: - return {"content": reg.expired_message} - try: - return await reg.render(state, session_id, nav_token) - except Exception as e: - pagination_log.error( - "PAGER_RENDER_FAILED", e, {"prefix": prefix, "nav_token": nav_token} - ) - observe_pager_render_failure(prefix) - return {"content": "Something went wrong updating this page."} diff --git a/src/pagination/tokens.py b/src/pagination/tokens.py new file mode 100644 index 0000000..6801c15 --- /dev/null +++ b/src/pagination/tokens.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +def clamp_page(page: int, total_pages: int) -> int: + if total_pages < 1: + return 0 + return max(0, min(page, total_pages - 1)) + + +def total_page_count(item_count: int, page_size: int) -> int: + if page_size < 1: + return 1 + return max(1, (max(0, item_count) + page_size - 1) // page_size) + + +def parse_nav_token_as_int(nav_token: str, *, default: int = 0) -> int: + try: + return int(nav_token.strip(), 10) + except ValueError: + return default + + +def parse_offset_page_nav_token(nav_token: str, *, default: int = 0) -> int: + t = nav_token.strip() + if len(t) >= 2 and t[0] in ("p", "n"): + try: + return int(t[1:], 10) + except ValueError: + return default + return parse_nav_token_as_int(t, default=default) diff --git a/src/raidhub_client.py b/src/raidhub_client.py index ccdd542..da1e6e7 100644 --- a/src/raidhub_client.py +++ b/src/raidhub_client.py @@ -1,24 +1,18 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone -from enum import StrEnum from typing import Any import httpx import jwt from .log import raidhub_api +from .raidhub_client_envelope import normalize_envelope_response +from .raidhub_client_types import RaidHubEnvelopeCode DISCORD_AUTH_SCHEME = "Discord" -class RaidHubEnvelopeCode(StrEnum): - RAIDHUB_API_UNREACHABLE = "RaidHubApiUnreachable" - NON_JSON_RESPONSE = "NonJsonResponse" - RAIDHUB_API_SERVER_ERROR = "RaidHubApiServerError" - RAIDHUB_API_CLIENT_ERROR = "RaidHubApiClientError" - - def discord_invocation_context( interaction: dict[str, Any], *, @@ -63,6 +57,16 @@ def _sign_discord_jwt(self, discord_context: dict[str, Any]) -> str: } return jwt.encode(payload, self._jwt_secret, algorithm="HS256") + def _headers(self, discord_context: dict[str, Any] | None = None) -> dict[str, str]: + headers: dict[str, str] = {} + if self._api_key: + headers["x-api-key"] = self._api_key + if discord_context: + headers["authorization"] = ( + f"{DISCORD_AUTH_SCHEME} {self._sign_discord_jwt(discord_context)}" + ) + return headers + async def request( self, method: str, @@ -71,13 +75,7 @@ async def request( params: dict[str, Any] | None = None, discord_context: dict[str, Any] | None = None, ) -> dict[str, Any]: - headers: dict[str, str] = {} - if self._api_key: - headers["x-api-key"] = self._api_key - if discord_context: - headers["authorization"] = ( - f"{DISCORD_AUTH_SCHEME} {self._sign_discord_jwt(discord_context)}" - ) + headers = self._headers(discord_context) async with httpx.AsyncClient(base_url=self._base_url, timeout=15) as client: response = await client.request(method, path, params=params, headers=headers) response.raise_for_status() @@ -93,13 +91,7 @@ async def request_envelope( discord_context: dict[str, Any] | None = None, ) -> dict[str, Any]: """Normalize RaidHub JSON envelopes and HTTP status without raising on transport/HTTP errors.""" - headers: dict[str, str] = {} - if self._api_key: - headers["x-api-key"] = self._api_key - if discord_context: - headers["authorization"] = ( - f"{DISCORD_AUTH_SCHEME} {self._sign_discord_jwt(discord_context)}" - ) + headers = self._headers(discord_context) try: async with httpx.AsyncClient(base_url=self._base_url, timeout=30) as client: response = await client.request( @@ -131,74 +123,11 @@ async def request_envelope( except Exception: data = None - status = response.status_code - if 200 <= status < 300: - if isinstance(data, dict): - return data - raidhub_api.warn( - "RAIDHUB_API_NON_JSON_SUCCESS", - None, - { - "base_url": self._base_url, - "method": method, - "path": path, - "http_status": status, - "body_preview": response.text[:200], - }, - ) - return { - "success": False, - "code": RaidHubEnvelopeCode.NON_JSON_RESPONSE.value, - "error": {"message": response.text[:500], "httpStatus": status}, - } - - if status >= 500: - raidhub_api.warn( - "RAIDHUB_API_SERVER_ERROR_RESPONSE", - None, - { - "base_url": self._base_url, - "method": method, - "path": path, - "http_status": status, - }, - ) - return { - "success": False, - "code": RaidHubEnvelopeCode.RAIDHUB_API_SERVER_ERROR.value, - "error": {"httpStatus": status}, - } - - if isinstance(data, dict) and data.get("success") is False: - err = data.get("error") or {} - raidhub_api.warn( - "RAIDHUB_API_ENVELOPE_FAILED", - None, - { - "base_url": self._base_url, - "method": method, - "path": path, - "http_status": status, - "code": str(data.get("code") or ""), - "error_code": str(err.get("code") or ""), - "error_message": str(err.get("message") or "")[:200], - }, - ) - return data - - raidhub_api.warn( - "RAIDHUB_API_CLIENT_ERROR_RESPONSE", - None, - { - "base_url": self._base_url, - "method": method, - "path": path, - "http_status": status, - "body_preview": response.text[:200], - }, + return normalize_envelope_response( + base_url=self._base_url, + method=method, + path=path, + status=response.status_code, + response_text=response.text, + data=data, ) - return { - "success": False, - "code": RaidHubEnvelopeCode.RAIDHUB_API_CLIENT_ERROR.value, - "error": {"httpStatus": status}, - } diff --git a/src/raidhub_client_envelope.py b/src/raidhub_client_envelope.py new file mode 100644 index 0000000..c3a29bc --- /dev/null +++ b/src/raidhub_client_envelope.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any + +from .log import raidhub_api +from .raidhub_client_types import RaidHubEnvelopeCode + + +def normalize_envelope_response( + *, + base_url: str, + method: str, + path: str, + status: int, + response_text: str, + data: Any, +) -> dict[str, Any]: + if 200 <= status < 300: + if isinstance(data, dict): + return data + raidhub_api.warn( + "RAIDHUB_API_NON_JSON_SUCCESS", + None, + { + "base_url": base_url, + "method": method, + "path": path, + "http_status": status, + "body_preview": response_text[:200], + }, + ) + return { + "success": False, + "code": RaidHubEnvelopeCode.NON_JSON_RESPONSE.value, + "error": {"message": response_text[:500], "httpStatus": status}, + } + + if status >= 500: + raidhub_api.warn( + "RAIDHUB_API_SERVER_ERROR_RESPONSE", + None, + { + "base_url": base_url, + "method": method, + "path": path, + "http_status": status, + }, + ) + return { + "success": False, + "code": RaidHubEnvelopeCode.RAIDHUB_API_SERVER_ERROR.value, + "error": {"httpStatus": status}, + } + + if isinstance(data, dict) and data.get("success") is False: + err = data.get("error") or {} + raidhub_api.warn( + "RAIDHUB_API_ENVELOPE_FAILED", + None, + { + "base_url": base_url, + "method": method, + "path": path, + "http_status": status, + "code": str(data.get("code") or ""), + "error_code": str(err.get("code") or ""), + "error_message": str(err.get("message") or "")[:200], + }, + ) + return data + + raidhub_api.warn( + "RAIDHUB_API_CLIENT_ERROR_RESPONSE", + None, + { + "base_url": base_url, + "method": method, + "path": path, + "http_status": status, + "body_preview": response_text[:200], + }, + ) + return { + "success": False, + "code": RaidHubEnvelopeCode.RAIDHUB_API_CLIENT_ERROR.value, + "error": {"httpStatus": status}, + } diff --git a/src/raidhub_client_types.py b/src/raidhub_client_types.py new file mode 100644 index 0000000..e05d362 --- /dev/null +++ b/src/raidhub_client_types.py @@ -0,0 +1,8 @@ +from enum import StrEnum + + +class RaidHubEnvelopeCode(StrEnum): + RAIDHUB_API_UNREACHABLE = "RaidHubApiUnreachable" + NON_JSON_RESPONSE = "NonJsonResponse" + RAIDHUB_API_SERVER_ERROR = "RaidHubApiServerError" + RAIDHUB_API_CLIENT_ERROR = "RaidHubApiClientError" diff --git a/src/sentry_init.py b/src/sentry_init.py new file mode 100644 index 0000000..ffe469b --- /dev/null +++ b/src/sentry_init.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sentry_sdk + +from .config import Settings +from .log import ingress + + +def init_sentry(settings: Settings) -> None: + dsn = settings.sentry_dsn.strip() + if not dsn: + ingress.info("SENTRY_DISABLED", {}) + return + + sentry_sdk.init( + dsn=dsn, + environment=settings.sentry_environment or "development", + release=settings.sentry_release or None, + # FastAPI integration auto-enables when fastapi is installed. + send_default_pii=settings.sentry_send_default_pii, + traces_sample_rate=settings.sentry_traces_sample_rate, + ) + ingress.info( + "SENTRY_ENABLED", + { + "environment": settings.sentry_environment or "development", + "has_release": bool(settings.sentry_release), + "send_default_pii": settings.sentry_send_default_pii, + "traces_sample_rate": settings.sentry_traces_sample_rate, + }, + ) diff --git a/src/structured_logger.py b/src/structured_logger.py index 6442608..dd23e3e 100644 --- a/src/structured_logger.py +++ b/src/structured_logger.py @@ -13,6 +13,8 @@ from datetime import datetime, timezone from typing import Any, TextIO +import sentry_sdk + _LOG_LEVEL_PRIORITY = {"debug": 0, "info": 1, "warn": 2, "error": 3} _LEVEL_DEBUG = "DEBUG" @@ -82,6 +84,19 @@ def _emit(self, level: str, stream: TextIO, key: str, fields: dict[str, Any]) -> stream.write(line) stream.flush() + def _capture_sentry(self, *, log_key: str, err: BaseException, fields: dict[str, Any]) -> None: + if sentry_sdk.Hub.current.client is None: + return + with sentry_sdk.push_scope() as scope: + scope.set_tag("logger", self.prefix) + scope.set_tag("log_key", log_key) + for k, v in fields.items(): + if k.startswith("$"): + scope.set_tag(k[1:], str(v)) + else: + scope.set_extra(k, v) + sentry_sdk.capture_exception(err) + def debug(self, key: str, fields: dict[str, Any] | None = None) -> None: if not _should_log("debug"): return @@ -100,11 +115,11 @@ def warn(self, key: str, err: BaseException | None, fields: dict[str, Any] | Non self._emit(_LEVEL_WARN, sys.stderr, key, merged) def error(self, key: str, err: BaseException, fields: dict[str, Any] | None = None) -> None: - if not _should_log("error"): - return merged = dict(fields or {}) merged["error"] = str(err) - self._emit(_LEVEL_ERROR, sys.stderr, key, merged) + if _should_log("error"): + self._emit(_LEVEL_ERROR, sys.stderr, key, merged) + self._capture_sentry(log_key=key, err=err, fields=merged) def fatal(self, key: str, err: BaseException | None, fields: dict[str, Any] | None = None) -> None: merged = dict(fields or {}) diff --git a/src/subscribe_handlers.py b/src/subscribe_handlers.py deleted file mode 100644 index 967f585..0000000 --- a/src/subscribe_handlers.py +++ /dev/null @@ -1,306 +0,0 @@ -""" -``/subscribe`` — resolve clan URLs / player names in-process, then call RaidHub -``PUT /subscriptions/discord/webhooks`` with numeric ids only. -""" - -from __future__ import annotations - -import re -from typing import Any -from urllib.parse import urlparse - -from .config import Settings -from .interaction_handlers import ( - _application_id, - _error_embed, - _patch_discord_followup_best_effort, - _success_embed, - _subscription_envelope_error_message, - _warn_embed, - USER_FACING_GENERIC, - flatten_options, -) -from .log import handlers -from .prom_metrics import observe_deferred_completion -from .raidhub_client import RaidHubClient, discord_invocation_context - -# Match RaidHub ``RaidHubRoute.getDerivedRouteId()`` for subscription routes. -_ROUTE_PUT = "PUT subscriptions/discord/webhooks" -_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks" -_ROUTE_STATUS = "GET subscriptions/discord/webhooks" - -_CLAN_GROUP_ID_PATTERNS = ( - re.compile(r"(?:https?://)?(?:www\.)?raidhub\.io/clan/(\d+)", re.I), - re.compile(r"(?:https?://)?(?:www\.)?bungie\.net/[^?\s]*[?&]group(?:id|Id)=(\d+)", re.I), - re.compile(r"/GroupV2/(\d+)", re.I), - re.compile(r"/clan/(\d+)", re.I), -) - - -def parse_clan_group_id(raw: str) -> str | None: - """ - Extract a Bungie **clan group id** from a bare id or common RaidHub / Bungie URLs. - Heuristic path fallback: last path segment that is all digits (length ≥ 5). - """ - s = raw.strip() - if not s: - return None - if re.fullmatch(r"\d+", s): - return s - for pat in _CLAN_GROUP_ID_PATTERNS: - m = pat.search(s) - if m: - return m.group(1) - try: - path = urlparse(s).path or "" - for seg in reversed([p for p in path.split("/") if p]): - if seg.isdigit() and len(seg) >= 5: - return seg - except Exception: - pass - return None - - -async def resolve_player_membership_id(raidhub: RaidHubClient, raw: str) -> str | None: - """Digits-only → as-is; otherwise first ``GET /player/search`` hit (score order).""" - q = raw.strip() - if not q: - return None - if re.fullmatch(r"\d+", q): - return q - env = await raidhub.request_envelope( - "GET", - "/player/search", - params={"query": q, "count": 1, "offset": 0}, - ) - if not env.get("success"): - return None - inner = env.get("response") or {} - results = list(inner.get("results") or []) - if not results: - return None - mid = results[0].get("membershipId") - return str(mid) if mid is not None else None - - -async def run_subscribe_deferred( - interaction: dict[str, Any], - raidhub: RaidHubClient, - settings: Settings, -) -> None: - app_id = _application_id(interaction, settings) - token = str(interaction.get("token") or "") - outcome = "completed" - try: - data = interaction.get("data") or {} - top_opts = data.get("options") or [] - if not top_opts or not isinstance(top_opts[0], dict): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed( - "Subscribe Command", - "Use `/subscribe player` or `/subscribe clan` with a target.", - ), - ) - return - - sub = str(top_opts[0].get("name") or "").strip().lower() - if sub not in ("player", "clan"): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed("Subscribe Command", "Unknown `/subscribe` subcommand."), - ) - return - - leaf = flatten_options(top_opts[0].get("options")) - target_raw = str( - leaf.get("player_id_or_search_text") - or leaf.get("clan_group_id_or_url") - or leaf.get("target") - or "" - ).strip() - if not target_raw: - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed( - "Subscribe Command", - "Provide a target (membership id / player name, or clan id / URL).", - ), - ) - return - - if not interaction.get("guild_id") or not interaction.get("channel_id"): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed( - "Subscribe Command", - "Run `/subscribe` in a server text channel, not a DM.", - ), - ) - return - - if sub == "player": - mid = await resolve_player_membership_id(raidhub, target_raw) - if not mid: - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed( - "Player Not Found", - "Could not resolve that player. Try a Destiny membership id or a clearer" - " name (first RaidHub search hit is used).", - ), - ) - return - resolved_id = mid - kind_label = "player" - body: dict[str, Any] = {"targets": {"playerMembershipIds": [resolved_id]}} - else: - gid = parse_clan_group_id(target_raw) - if not gid: - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed( - "Clan ID Not Recognized", - "Could not parse a clan group id from that value. Use digits only, or a" - " raidhub.io/clan/... / Bungie clan URL containing the group id.", - ), - ) - return - resolved_id = gid - kind_label = "clan" - body = {"targets": {"clanGroupIds": [resolved_id]}} - - ctx = discord_invocation_context(interaction, route_id=_ROUTE_PUT) - env = await raidhub.request_envelope( - "PUT", - "/subscriptions/discord/webhooks", - json=body, - discord_context=ctx, - ) - - if not env.get("success"): - err = env.get("error") or {} - handlers.warn( - "SUBSCRIBE_ENVELOPE_FAILED", - None, - { - "subcommand": sub, - "route_id": _ROUTE_PUT, - "code": str(env.get("code") or ""), - "error_code": str(err.get("code") or ""), - "http_status": int(err.get("httpStatus") or 0), - "error_message": str(err.get("message") or "")[:200], - }, - ) - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed( - "Subscribe Failed", - _subscription_envelope_error_message(env), - ), - ) - return - - inner_put = env.get("response") or {} - action = ( - "updated" - if inner_put.get("updated") - else "registered" - if inner_put.get("created") or inner_put.get("webhookUrl") - else "saved" - ) - await _patch_discord_followup_best_effort( - app_id, - token, - _success_embed( - "Subscription Saved", - f"Subscribed ({action}) to {kind_label} `{resolved_id}` for this channel. " - "Use `/subscription status` to inspect delivery health and rules.", - ), - ) - except Exception as e: - outcome = "error" - handlers.error("SUBSCRIBE_DEFERRED_FAILED", e, {}) - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed("Subscribe Failed", USER_FACING_GENERIC), - ) - finally: - observe_deferred_completion(command="subscribe", outcome=outcome) - - -async def run_unsubscribe_deferred( - interaction: dict[str, Any], - raidhub: RaidHubClient, - settings: Settings, -) -> None: - app_id = _application_id(interaction, settings) - token = str(interaction.get("token") or "") - outcome = "completed" - try: - if not interaction.get("guild_id") or not interaction.get("channel_id"): - await _patch_discord_followup_best_effort( - app_id, - token, - _warn_embed( - "Unsubscribe Command", - "Run `/unsubscribe` in a server text channel, not a DM.", - ), - ) - return - - ctx = discord_invocation_context(interaction, route_id=_ROUTE_DELETE) - env = await raidhub.request_envelope( - "DELETE", - "/subscriptions/discord/webhooks", - discord_context=ctx, - ) - if not env.get("success"): - err = env.get("error") or {} - handlers.warn( - "UNSUBSCRIBE_ENVELOPE_FAILED", - None, - { - "route_id": _ROUTE_DELETE, - "code": str(env.get("code") or ""), - "error_code": str(err.get("code") or ""), - "http_status": int(err.get("httpStatus") or 0), - "error_message": str(err.get("message") or "")[:200], - }, - ) - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed( - "Unsubscribe Failed", - _subscription_envelope_error_message(env), - ), - ) - return - - await _patch_discord_followup_best_effort( - app_id, - token, - _success_embed( - "Subscription Removed", - "RaidHub will no longer use a webhook in this channel.", - ), - ) - except Exception as e: - outcome = "error" - handlers.error("UNSUBSCRIBE_DEFERRED_FAILED", e, {}) - await _patch_discord_followup_best_effort( - app_id, - token, - _error_embed("Unsubscribe Failed", USER_FACING_GENERIC), - ) - finally: - observe_deferred_completion(command="unsubscribe", outcome=outcome) diff --git a/src/sync_commands.py b/src/sync_commands.py index 084ab7b..2427427 100644 --- a/src/sync_commands.py +++ b/src/sync_commands.py @@ -6,7 +6,7 @@ import httpx -from .command_manifest import build_command_manifest +from .manifest import build_command_manifest from .config import get_settings diff --git a/tests/test_manifest_commands.py b/tests/test_manifest_commands.py new file mode 100644 index 0000000..47f0d40 --- /dev/null +++ b/tests/test_manifest_commands.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import unittest + +from src.manifest import build_command_manifest, build_commands + + +class ManifestCommandsTests(unittest.TestCase): + def test_build_commands_has_stable_slash_names(self) -> None: + names = {c.name for c in build_commands()} + self.assertEqual( + names, + { + "instance", + "player-search", + "subscribe", + "subscription", + "unsubscribe", + "unsubscribe-clan", + "unsubscribe-player", + }, + ) + + def test_instance_command_has_raid_instance_id_option(self) -> None: + cmds = {c.name: c for c in build_commands()} + inst = cmds["instance"] + self.assertIsNotNone(inst.options) + opt_names = {o.name for o in (inst.options or [])} + self.assertIn("raid_instance_id", opt_names) + + def test_subscribe_dm_permission_false(self) -> None: + cmds = {c.name: c for c in build_commands()} + self.assertIs(cmds["subscribe"].dm_permission, False) + + def test_manifest_json_serializable_shape(self) -> None: + manifest = build_command_manifest() + self.assertIsInstance(manifest, list) + self.assertGreater(len(manifest), 0) + first = manifest[0] + self.assertIn("name", first) + self.assertIn("type", first) + self.assertIn("description", first) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pagination_tokens_and_ids.py b/tests/test_pagination_tokens_and_ids.py new file mode 100644 index 0000000..94513c9 --- /dev/null +++ b/tests/test_pagination_tokens_and_ids.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import unittest + +from src.pagination.ids import pager_custom_id, parse_pager_custom_id +from src.pagination.tokens import ( + parse_nav_token_as_int, + parse_offset_page_nav_token, + total_page_count, +) + + +class PaginationTokensAndIdsTests(unittest.TestCase): + def test_custom_id_round_trip(self) -> None: + cid = pager_custom_id("ps", "abc123", "n2") + self.assertEqual(parse_pager_custom_id(cid), ("ps", "abc123", "n2")) + + def test_parse_custom_id_rejects_bad_shape(self) -> None: + self.assertIsNone(parse_pager_custom_id("bad")) + self.assertIsNone(parse_pager_custom_id("::")) + + def test_parse_offset_tokens(self) -> None: + self.assertEqual(parse_offset_page_nav_token("p3"), 3) + self.assertEqual(parse_offset_page_nav_token("n4"), 4) + self.assertEqual(parse_offset_page_nav_token("5"), 5) + self.assertEqual(parse_offset_page_nav_token("x"), 0) + + def test_parse_nav_token_as_int_default(self) -> None: + self.assertEqual(parse_nav_token_as_int("7"), 7) + self.assertEqual(parse_nav_token_as_int("bad", default=2), 2) + + def test_total_page_count(self) -> None: + self.assertEqual(total_page_count(0, 10), 1) + self.assertEqual(total_page_count(11, 10), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_raidhub_client_envelope.py b/tests/test_raidhub_client_envelope.py new file mode 100644 index 0000000..7834068 --- /dev/null +++ b/tests/test_raidhub_client_envelope.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import unittest + +from src.raidhub_client_envelope import normalize_envelope_response +from src.raidhub_client_types import RaidHubEnvelopeCode + + +class NormalizeEnvelopeResponseTests(unittest.TestCase): + def test_success_returns_dict_unchanged(self) -> None: + payload = {"success": True, "response": {}} + out = normalize_envelope_response( + base_url="http://api", + method="GET", + path="/x", + status=200, + response_text="", + data=payload, + ) + self.assertIs(out, payload) + + def test_success_non_dict_returns_non_json_envelope(self) -> None: + out = normalize_envelope_response( + base_url="http://api", + method="GET", + path="/x", + status=200, + response_text="not json", + data=None, + ) + self.assertFalse(out["success"]) + self.assertEqual(out["code"], RaidHubEnvelopeCode.NON_JSON_RESPONSE.value) + + def test_server_error(self) -> None: + out = normalize_envelope_response( + base_url="http://api", + method="GET", + path="/x", + status=503, + response_text="", + data=None, + ) + self.assertFalse(out["success"]) + self.assertEqual(out["code"], RaidHubEnvelopeCode.RAIDHUB_API_SERVER_ERROR.value) + + def test_client_error_plain(self) -> None: + out = normalize_envelope_response( + base_url="http://api", + method="GET", + path="/subscriptions/discord/webhooks", + status=404, + response_text="Cannot GET", + data=None, + ) + self.assertFalse(out["success"]) + self.assertEqual(out["code"], RaidHubEnvelopeCode.RAIDHUB_API_CLIENT_ERROR.value) + + def test_client_error_returns_api_envelope(self) -> None: + api_body = { + "success": False, + "code": "InsufficientPermissionsError", + "error": {"message": "no"}, + } + out = normalize_envelope_response( + base_url="http://api", + method="GET", + path="/x", + status=403, + response_text="", + data=api_body, + ) + self.assertIs(out, api_body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shared_time_format.py b/tests/test_shared_time_format.py new file mode 100644 index 0000000..23c0c9c --- /dev/null +++ b/tests/test_shared_time_format.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import unittest + +from src.commands.shared import iso_to_discord_relative + + +class IsoToDiscordRelativeTests(unittest.TestCase): + def test_returns_dash_for_empty_like_values(self) -> None: + self.assertEqual(iso_to_discord_relative(None), "—") + self.assertEqual(iso_to_discord_relative(""), "—") + self.assertEqual(iso_to_discord_relative(" "), "—") + + def test_returns_dash_for_invalid_iso(self) -> None: + self.assertEqual(iso_to_discord_relative("not-an-iso"), "—") + + def test_formats_valid_utc_iso(self) -> None: + out = iso_to_discord_relative("2026-04-23T03:20:43Z") + self.assertTrue(out.startswith("")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_subscribe_resolution.py b/tests/test_subscribe_resolution.py new file mode 100644 index 0000000..d83faa1 --- /dev/null +++ b/tests/test_subscribe_resolution.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import unittest + +from src.commands.subscribe_resolution import parse_clan_group_id + + +class ParseClanGroupIdTests(unittest.TestCase): + def test_accepts_numeric_id(self) -> None: + self.assertEqual(parse_clan_group_id("123456"), "123456") + + def test_parses_raidhub_url(self) -> None: + self.assertEqual( + parse_clan_group_id("https://raidhub.io/clan/987654321"), + "987654321", + ) + + def test_parses_bungie_url_query(self) -> None: + self.assertEqual( + parse_clan_group_id( + "https://www.bungie.net/7/en/Clan/Profile?groupId=42424242" + ), + "42424242", + ) + + def test_returns_none_for_unparseable_value(self) -> None: + self.assertIsNone(parse_clan_group_id("not-a-clan-id")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_subscription_helpers.py b/tests/test_subscription_helpers.py new file mode 100644 index 0000000..beb56d2 --- /dev/null +++ b/tests/test_subscription_helpers.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import asyncio +import unittest + +from src.commands.subscription_helpers import ( + build_subscription_json_body, + format_subscription_status_embed, + subscription_envelope_error_message, + subscription_rules_suffix, +) +from src.commands.subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_PUT, SUB_ROUTE_STATUS + + +class SubscriptionRoutesTests(unittest.TestCase): + def test_route_ids_match_expected_api_shape(self) -> None: + self.assertIn("subscriptions/discord/webhooks", SUB_ROUTE_PUT) + self.assertIn("subscriptions/discord/webhooks", SUB_ROUTE_DELETE) + self.assertIn("subscriptions/discord/webhooks", SUB_ROUTE_STATUS) + + +class BuildSubscriptionJsonBodyTests(unittest.TestCase): + def test_empty_leaf_returns_empty_dict(self) -> None: + self.assertEqual(build_subscription_json_body({}), {}) + + def test_webhook_name_truncation_and_alias(self) -> None: + long_name = "x" * 100 + body = build_subscription_json_body({"discord_webhook_name": long_name}) + self.assertEqual(body["name"], "x" * 80) + + body2 = build_subscription_json_body({"webhook_name": "short"}) + self.assertEqual(body2["name"], "short") + + def test_filters_and_targets(self) -> None: + body = build_subscription_json_body( + { + "require_fresh": True, + "require_completed": False, + "players": "1, 2 3", + "clans": "9", + } + ) + self.assertEqual(body["filters"]["requireFresh"], True) + self.assertEqual(body["filters"]["requireCompleted"], False) + self.assertEqual(body["targets"]["playerMembershipIds"], ["1", "2", "3"]) + self.assertEqual(body["targets"]["clanGroupIds"], ["9"]) + + def test_ignores_non_digit_tokens_in_lists(self) -> None: + body = build_subscription_json_body({"players": "1, abc, 2"}) + self.assertEqual(body["targets"]["playerMembershipIds"], ["1", "2"]) + + +class SubscriptionEnvelopeErrorMessageTests(unittest.TestCase): + def test_known_codes(self) -> None: + msg = subscription_envelope_error_message( + {"code": "InsufficientPermissionsError"} + ) + self.assertIn("Manage Webhooks", msg) + msg2 = subscription_envelope_error_message({"code": "BodyValidationError"}) + self.assertIn("digits only", msg2) + + def test_unknown_code_uses_generic_mapping(self) -> None: + msg = subscription_envelope_error_message({"code": "RaidHubApiUnreachable"}) + self.assertIn("RAIDHUB_API_BASE_URL", msg) + + +class FormatSubscriptionStatusEmbedTests(unittest.TestCase): + def test_unregistered_channel(self) -> None: + out = asyncio.run(format_subscription_status_embed(None, {"registered": False})) + self.assertIn("embeds", out) + self.assertEqual(out["embeds"][0]["title"], "Subscription Status") + self.assertIn("No RaidHub subscription webhook", out["embeds"][0]["description"]) + + def test_registered_minimal(self) -> None: + out = asyncio.run( + format_subscription_status_embed( + None, + { + "registered": True, + "destinationActive": True, + "webhookId": "123", + "consecutiveDeliveryFailures": 0, + }, + ) + ) + fields = {f["name"]: f["value"] for f in out["embeds"][0]["fields"]} + self.assertIn("Destination Active", fields) + self.assertIn("`123`", fields["Webhook ID"]) + + +class SubscriptionRulesSuffixTests(unittest.TestCase): + def test_empty_rules(self) -> None: + self.assertEqual(subscription_rules_suffix({}), "") + + def test_counts(self) -> None: + s = subscription_rules_suffix( + {"players": {"inserted": 1, "updated": 2}, "clans": {"inserted": 0, "updated": 1}} + ) + self.assertIn("3 player", s) + self.assertIn("1 clan", s) + + +if __name__ == "__main__": + unittest.main()