Skip to content
Draft
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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,26 @@ DISCORD_SYNC_DRY_RUN=false
RAIDHUB_API_BASE_URL=http://localhost:8000
# Same string as a "key" entry in RaidHub-API/api-keys.json (required when API runs with PROD=true).
RAIDHUB_API_KEY=
# Same value as RaidHub-API CLIENT_SECRET — used to mint user JWTs for linked Discord users (POST /authorize/user).
# RAIDHUB_CLIENT_SECRET=
# Public site URL for /link and /register buttons.
# RAIDHUB_WEBSITE_BASE_URL=https://raidhub.io
# Redis TTL (seconds) for cached user JWTs (default 3600).
# RAIDHUB_USER_JWT_CACHE_TTL_SECONDS=3600
# Same value as RaidHub-API JWT_SECRET if you use signed Discord calls.
RAIDHUB_JWT_SECRET=
# Same libsql URL as RaidHub-Website (NextAuth). Used to resolve Discord user → Bungie + Destiny ids on the proxy.
# RAIDHUB_ACCOUNT_TURSO_URL=
# Redis cache for that lookup (same vars as RaidHub-Services). Optional: if unset, every hit goes to Turso.
# REDIS_URL=
# REDIS_HOST=redis
# REDIS_PORT=6379
# REDIS_PASSWORD=
# REDIS_DB=0
# TTL (seconds) for linked-account keys in Redis.
# RAIDHUB_ACCOUNT_LOOKUP_CACHE_TTL_SECONDS=90
# Bump to invalidate all cached Discord→account rows (key prefix includes this).
# RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS=1
# Leave empty unless you intend to send events to your own Sentry project.
SENTRY_DSN=
SENTRY_ENVIRONMENT=development
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ Backend for the RaidHub Discord application: it receives [Discord interactions](

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`).

## Linked-account cache (Redis)

If `RAIDHUB_ACCOUNT_TURSO_URL` is set, Discord user → Bungie / Destiny profile resolution is cached in **Redis** using the same connection style as RaidHub-Services (`REDIS_HOST` / `REDIS_PORT` / optional `REDIS_PASSWORD`, or `REDIS_URL`). TTL defaults to 90s (`RAIDHUB_ACCOUNT_LOOKUP_CACHE_TTL_SECONDS`).

**Bust cache:** increment `RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS` (e.g. `1` → `2`), or delete keys under `raidhub:discord:linked:<ns>:*` (prefer `SCAN` in production). If Redis is not configured, lookups always hit Turso.

**User JWT (scoped API):** with `RAIDHUB_CLIENT_SECRET` set (same as RaidHub-API `CLIENT_SECRET`), linked users get a short-lived cached **user JWT** via `POST /authorize/user`; outbound calls send it as `x-raidhub-user-authorization: Bearer …` alongside `Authorization: Discord …` where used (e.g. `/search`).

**Slash `/link` and `/register`:** ephemeral prompt with a button to `RAIDHUB_WEBSITE_BASE_URL/account` (default `https://raidhub.io`).

## Slash command sync

After changing command definitions, push them to Discord with the `sync-discord-commands` console script from this package.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ requires-python = ">=3.14"
dependencies = [
"discord-interactions",
"fastapi",
"libsql-client>=0.3.1",
"uvicorn",
"pynacl",
"httpx",
"python-dotenv",
"PyJWT",
"prometheus-client",
"redis>=5",
"sentry-sdk[fastapi]",
]

Expand Down
22 changes: 21 additions & 1 deletion src/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
)
from .config import Settings, get_settings
from .discord_auth import verify_discord_signature_with_reason
from .linked_account_context import (
attach_raidhub_linked_account_context,
close_linked_account_redis,
init_linked_account_redis,
)
from .log import ingress
from .pagination import try_handle_pager_component
from .prom_metrics import metrics_response, observe_interaction
Expand Down Expand Up @@ -63,7 +68,11 @@ def _validate_startup_settings() -> None:
@asynccontextmanager
async def lifespan(_: FastAPI):
_validate_startup_settings()
yield
await init_linked_account_redis(settings)
try:
yield
finally:
await close_linked_account_redis()

app = FastAPI(title="raidhub-discord", lifespan=lifespan)

Expand Down Expand Up @@ -171,8 +180,19 @@ async def discord_interactions(

name = interaction.get("data", {}).get("name")
ingress.info("DISCORD_COMMAND_RECEIVED", {"command_name": name})
if name in ("link", "register"):
from .commands.link import link_interaction_response

observe_interaction(
handler="link_or_register_immediate",
status="ok",
started_monotonic=t0,
)
return JSONResponse(link_interaction_response(settings))

handler = command_handlers.get(name)
if handler:
await attach_raidhub_linked_account_context(interaction, settings)
background_tasks.add_task(handler, interaction, raidhub, settings)
handler_label = str(name).replace("-", "_")
observe_interaction(
Expand Down
44 changes: 44 additions & 0 deletions src/commands/link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from typing import Any

from discord_interactions import InteractionResponseType

from ..config import Settings


def link_interaction_response(settings: Settings) -> dict[str, Any]:
"""Ephemeral message + link button to RaidHub account (and optional docs)."""
base = settings.raidhub_website_base_url.rstrip("/")
account_url = f"{base}/account"
desc = (
"1. Open **RaidHub account** below (Bungie sign-in if asked).\n"
"2. Under **Linked accounts**, connect **Discord** and approve **linked roles** scopes.\n"
"3. Return here — slash commands can then use your RaidHub identity (when the bot is configured with `RAIDHUB_CLIENT_SECRET`)."
)
return {
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": {
"flags": 64,
"embeds": [
{
"title": "Link RaidHub ↔ Discord",
"description": desc[:4096],
"color": 0x5865_F2,
}
],
"components": [
{
"type": 1,
"components": [
{
"type": 2,
"style": 5,
"label": "Open RaidHub account",
"url": account_url[:512],
}
],
}
],
},
}
14 changes: 12 additions & 2 deletions src/commands/player_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ..pagination import store_paged_session
from ..prom_metrics import observe_deferred_completion
from ..raidhub_client import RaidHubClient
from ..raidhub_user_jwt import resolve_user_bearer_token
from .player_search_helpers import (
PLAYER_SEARCH_PAGE_SIZE,
player_search_render_from_state,
Expand Down Expand Up @@ -40,12 +41,21 @@ async def run_player_search_deferred(
query_params: dict[str, Any] = {"query": query}

page_size = PLAYER_SEARCH_PAGE_SIZE
user_bearer = await resolve_user_bearer_token(interaction, settings)
session_id = store_paged_session(
{"query_params": query_params, "page_size": page_size}
{
"query_params": query_params,
"page_size": page_size,
"user_bearer": user_bearer,
}
)
payload = await player_search_render_from_state(
raidhub,
{"query_params": query_params, "page_size": page_size},
{
"query_params": query_params,
"page_size": page_size,
"user_bearer": user_bearer,
},
session_id,
"0",
)
Expand Down
5 changes: 4 additions & 1 deletion src/commands/player_search_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ async def player_search_render_from_state(

params: dict[str, Any] = {"query": query, "count": page_size, "offset": offset}

env = await raidhub.request_envelope("GET", "/player/search", params=params)
user_bearer = state.get("user_bearer")
ub = user_bearer if isinstance(user_bearer, str) and user_bearer.strip() else None

env = await raidhub.request_envelope("GET", "/player/search", params=params, user_bearer=ub)
if not env.get("success"):
code = str(env.get("code", "Error"))
return {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/subscribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
player_target_from_subscribe_leaf,
subscription_envelope_error_message,
)
from .subscription_routes import SUB_ROUTE_PUT
from .subscription_routes import SUB_ROUTE_PUT, SUBSCRIPTION_WEBHOOKS_PATH
from .shared import (
USER_FACING_GENERIC,
application_id,
Expand Down Expand Up @@ -188,7 +188,7 @@ async def run_subscribe_deferred(
ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT)
env = await raidhub.request_envelope(
"PUT",
"/subscriptions/discord/webhooks",
SUBSCRIPTION_WEBHOOKS_PATH,
json=body,
discord_context=ctx,
)
Expand Down
4 changes: 2 additions & 2 deletions src/commands/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
format_subscription_status_embed,
subscription_envelope_error_message,
)
from .subscription_routes import SUB_ROUTE_STATUS
from .subscription_routes import SUB_ROUTE_STATUS, SUBSCRIPTION_WEBHOOKS_PATH
from .shared import (
USER_FACING_GENERIC,
application_id,
Expand Down Expand Up @@ -66,7 +66,7 @@ async def run_subscription_deferred(
ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_STATUS)
env = await raidhub.request_envelope(
"GET",
"/subscriptions/discord/webhooks",
SUBSCRIPTION_WEBHOOKS_PATH,
discord_context=ctx,
)

Expand Down
4 changes: 2 additions & 2 deletions src/commands/subscription_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from ..raidhub_client import RaidHubClient, discord_invocation_context
from .subscribe_resolution import format_player_display_name
from .subscription_routes import SUB_ROUTE_STATUS
from .subscription_routes import SUB_ROUTE_STATUS, SUBSCRIPTION_WEBHOOKS_PATH
from .shared import (
base_embed,
discord_message_for_failed_envelope,
Expand Down Expand Up @@ -217,7 +217,7 @@ async def fetch_subscription_status_envelope(
ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_STATUS)
return await raidhub.request_envelope(
"GET",
"/subscriptions/discord/webhooks",
SUBSCRIPTION_WEBHOOKS_PATH,
discord_context=ctx,
)

Expand Down
12 changes: 9 additions & 3 deletions src/commands/subscription_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
SUB_ROUTE_PUT = "PUT subscriptions/discord/webhooks"
SUB_ROUTE_DELETE = "DELETE subscriptions/discord/webhooks"
SUB_ROUTE_STATUS = "GET subscriptions/discord/webhooks"
"""RaidHub API routes for Discord subscription webhooks (under ``/internal``)."""

# HTTP path (leading slash), passed to ``RaidHubClient.request(..., path=...)``.
SUBSCRIPTION_WEBHOOKS_PATH = "/internal/subscriptions/discord/webhooks"

# route_id for Discord invocation JWT (METHOD + path without leading slash).
SUB_ROUTE_PUT = "PUT internal/subscriptions/discord/webhooks"
SUB_ROUTE_DELETE = "DELETE internal/subscriptions/discord/webhooks"
SUB_ROUTE_STATUS = "GET internal/subscriptions/discord/webhooks"
8 changes: 4 additions & 4 deletions src/commands/unsubscribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
subscription_active_player_ids,
subscription_envelope_error_message,
)
from .subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_PUT
from .subscription_routes import SUB_ROUTE_DELETE, SUB_ROUTE_PUT, SUBSCRIPTION_WEBHOOKS_PATH
from .shared import (
USER_FACING_GENERIC,
application_id,
Expand Down Expand Up @@ -99,7 +99,7 @@ async def run_unsubscribe_deferred(
ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_DELETE)
env = await raidhub.request_envelope(
"DELETE",
"/subscriptions/discord/webhooks",
SUBSCRIPTION_WEBHOOKS_PATH,
discord_context=ctx,
)
if not env.get("success"):
Expand Down Expand Up @@ -235,7 +235,7 @@ async def run_unsubscribe_player_deferred(
ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT)
env = await raidhub.request_envelope(
"PUT",
"/subscriptions/discord/webhooks",
SUBSCRIPTION_WEBHOOKS_PATH,
json=body,
discord_context=ctx,
)
Expand Down Expand Up @@ -369,7 +369,7 @@ async def run_unsubscribe_clan_deferred(
ctx = discord_invocation_context(interaction, route_id=SUB_ROUTE_PUT)
env = await raidhub.request_envelope(
"PUT",
"/subscriptions/discord/webhooks",
SUBSCRIPTION_WEBHOOKS_PATH,
json=body,
discord_context=ctx,
)
Expand Down
30 changes: 30 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ class Settings:
discord_sync_dry_run: bool
raidhub_api_base_url: str
raidhub_api_key: str
raidhub_client_secret: str
raidhub_website_base_url: str
raidhub_user_jwt_cache_ttl_seconds: int
raidhub_jwt_secret: str
raidhub_account_turso_url: str
raidhub_account_lookup_cache_ttl_seconds: int
raidhub_discord_linked_account_cache_ns: str
redis_url: str
redis_host: str
redis_port: int
redis_password: str
redis_db: int
sentry_dsn: str
sentry_environment: str
sentry_release: str
Expand All @@ -39,7 +50,26 @@ def get_settings() -> Settings:
"RAIDHUB_API_BASE_URL", "http://localhost:8000"
).strip(),
raidhub_api_key=os.getenv("RAIDHUB_API_KEY", "").strip(),
raidhub_client_secret=os.getenv("RAIDHUB_CLIENT_SECRET", "").strip(),
raidhub_website_base_url=os.getenv(
"RAIDHUB_WEBSITE_BASE_URL", "https://raidhub.io"
).strip(),
raidhub_user_jwt_cache_ttl_seconds=int(
os.getenv("RAIDHUB_USER_JWT_CACHE_TTL_SECONDS", "3600").strip() or "3600"
),
raidhub_jwt_secret=os.getenv("RAIDHUB_JWT_SECRET", "").strip(),
raidhub_account_turso_url=os.getenv("RAIDHUB_ACCOUNT_TURSO_URL", "").strip(),
raidhub_account_lookup_cache_ttl_seconds=int(
os.getenv("RAIDHUB_ACCOUNT_LOOKUP_CACHE_TTL_SECONDS", "90").strip() or "90"
),
raidhub_discord_linked_account_cache_ns=os.getenv(
"RAIDHUB_DISCORD_LINKED_ACCOUNT_CACHE_NS", "1"
).strip(),
redis_url=os.getenv("REDIS_URL", "").strip(),
redis_host=os.getenv("REDIS_HOST", "").strip(),
redis_port=int(os.getenv("REDIS_PORT", "6379").strip() or "6379"),
redis_password=os.getenv("REDIS_PASSWORD", "").strip(),
redis_db=int(os.getenv("REDIS_DB", "0").strip() or "0"),
sentry_dsn=os.getenv("SENTRY_DSN", "").strip(),
sentry_environment=os.getenv("SENTRY_ENVIRONMENT", "development").strip(),
sentry_release=os.getenv("SENTRY_RELEASE", "").strip(),
Expand Down
Loading
Loading