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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 .
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/start.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 21 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <signed-jwt>` 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.
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "raidhub-discord"
version = "0.1.0"
Expand All @@ -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 = [
Expand All @@ -29,3 +38,4 @@ line-length = 100
[tool.mypy]
python_version = "3.14"
strict = false
ignore_missing_imports = true
201 changes: 201 additions & 0 deletions src/app_factory.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 3 additions & 9 deletions src/cli_sync_commands.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
from __future__ import annotations

from pathlib import Path
import sys

# Editable installs in this repo currently add `<repo>/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()
Loading
Loading