File size: 22,642 Bytes
5ef3828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
# extractet from our discord community bank :D
# =============================================================================
# app/discord_api.py
# Discord Integration β€” Modular API layer (REST + Interactions Endpoint)
# Universal My AI HUB (Sandboxed) - based on PyFundaments Architecture
# Copyright 2026 - Volkan KΓΌcΓΌkbudak
# This file is under GPL License V. 3 + ESOL 2.1
# Repo: https://github.com/VolkanSah/Multi-LLM-API-Gateway
# =============================================================================
# ARCHITECTURE NOTE:
#   Modular integration living in app/* only.
#   NO low-level access, NO fundaments access. Config comes from
#   app/.pyfun [DISCORD] via app/config.py β€” secrets stay in .env
#   (env_key = NAME only, same rule as every provider).
#
#   ZERO new dependencies β€” deliberately:
#     httpx        β†’ REST calls (already required by the hub)
#     cryptography β†’ Ed25519 signature verify (PyFundaments core!)
#   The discord.py library + PyNaCl are NOT needed. Pure HTTP.
#   (Only if you later want gateway events or voice: uncomment in
#   requirements.txt β€” this module never imports them.)
#
# WHY "discord_api.py" AND NOT "discord.py":
#   A file named discord.py that imports the discord library is the
#   classic self-import footgun (works in the package, explodes the
#   moment anything runs inside app/). app.py already references this
#   module as discord_api (line 43 + /discord route stub).
#
# THREE CAPABILITIES β€” use what you need, ignore the rest:
#
#   1. OUTBOUND (hub β†’ Discord):
#      send_message()   β€” bot posts to a channel (REST, 'Bot' token)
#      send_webhook()   β€” write-only channel webhook (no bot needed)
#      read_channel()   β€” last N messages (needs Message Content Intent)
#      send_followup()  β€” late answer after defer (LLM latency pattern)
#
#   2. INBOUND (Discord β†’ hub) β€” Interactions Endpoint:
#      verify_signature()    β€” Ed25519 check, rejects forgeries (401)
#      handle_interaction()  β€” PING/PONG + slash command dispatch
#      register_command_handler() β€” later: map commands to hub tools
#
#   3. SETUP HELPERS:
#      register_commands()  β€” register slash commands via REST
#      get_me()             β€” token sanity check (GET /users/@me)
#
# FLOW (interactions, the >3s LLM pattern):
#   Discord POSTs a command β†’ verify_signature (raw bytes, BEFORE parse!)
#   β†’ respond {"type": 5} (DEFERRED) β†’ run the LLM/tool in background
#   β†’ send_followup(interaction_token, answer)
#   Discord kills responses after 3 seconds β€” defer is not optional
#   for LLM latency. 3s is a hard Discord limit, not ours.
#
# STATUS: shipped INACTIVE (DISCORD_ACTIVE = "false" in .pyfun).
#   Wiring comes later β€” prepared hooks:
#     app/app.py:  from . import discord_api + /discord route (stub exists)
#     app/tools.py: elif provider_type == "discord": ...
#   See docs/discord_api.md for the exact snippets.
# =============================================================================

import asyncio
import json
import logging
import os
import time
from typing import Any, Callable, Dict, Optional

import httpx
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

from . import config

logger = logging.getLogger("discord_api")

# Discord interaction response types (only the ones we use)
RESPONSE_PONG                        = 1   # answer to PING (validation)
RESPONSE_CHANNEL_MESSAGE             = 4   # direct answer
RESPONSE_DEFERRED_CHANNEL_MESSAGE    = 5   # "thinking..." β€” followup later

# Interaction request types (from Discord)
TYPE_PING               = 1
TYPE_APPLICATION_COMMAND = 2

# Signature freshness β€” reject stale timestamps (replay protection).
# Discord signs timestamp + body; anything older than this is garbage.
_TIMESTAMP_MAX_AGE = 300  # seconds

# Content limit enforced by Discord β€” we reject instead of silently
# truncating. The caller (later: tools.py wrapper) decides how to split.
_MAX_CONTENT_LEN = 2000


# =============================================================================
# Module State β€” populated by initialize()
# =============================================================================
_cfg          = {}
_active       = False
_token        = ""          # from .env via env_key β€” NEVER in .pyfun
_webhook_url  = ""          # from .env via webhook_env_key β€” NEVER in .pyfun
_default_channel = ""
_app_id       = ""          # Dev Portal β†’ Application ID (public)
_public_key   = ""          # Dev Portal β†’ Public Key (public β€” verify only!)
_api_base     = "https://discord.com/api/v10"
_timeout      = 15

# Slash command handlers β€” name β†’ async fn(interaction) β†’ response dict.
# Populated later by tools.py wiring; ships with the built-in "ping".
_command_handlers: Dict[str, Callable] = {}


# =============================================================================
# Initialization β€” called by app/app.py (later, when wired)
# =============================================================================

async def initialize() -> None:
    """
    Initialize Discord integration from app/.pyfun [DISCORD].
    Called by app/app.py β€” never reads os.environ beyond the named env_key
    (same sandbox boundary as shellmaster / every provider).

    No token + no webhook + no public key β†’ module stays inactive.
    No crash, no error β€” just a log line. Hub philosophy.
    """
    global _cfg, _active, _token, _webhook_url, _default_channel
    global _app_id, _public_key, _api_base, _timeout

    _cfg = config.get_discord()

    if not _cfg or _cfg.get("DISCORD_ACTIVE", "false").lower() != "true":
        logger.info("Discord not active in .pyfun [DISCORD] β€” skipped")
        return

    # Token + Webhook: ENV zuerst (.pyfun nennt nur den ENV-Namen).
    # Secrets gehΓΆren NIE in .pyfun β€” gleiche Regel wie bei allen Providern.
    env_key        = _cfg.get("DISCORD_ENV_KEY", "DISCORD_BOT_TOKEN")
    webhook_env    = _cfg.get("DISCORD_WEBHOOK_ENV_KEY", "DISCORD_WEBHOOK_URL")
    _token         = os.getenv(env_key, "") if env_key else ""
    _webhook_url   = os.getenv(webhook_env, "") if webhook_env else ""

    # Public values from .pyfun (safe β€” they are not secrets):
    _default_channel = _cfg.get("DISCORD_DEFAULT_CHANNEL", "")
    _app_id          = _cfg.get("DISCORD_APP_ID", "")
    _public_key      = _cfg.get("DISCORD_APP_PUBLIC_KEY", "")
    _api_base        = _cfg.get("DISCORD_API_BASE", "https://discord.com/api/v10")
    _timeout         = int(_cfg.get("DISCORD_TIMEOUT_SEC", "15"))

    if not _token and not _webhook_url:
        logger.warning(
            "Discord: neither %s nor %s set in .env β€” outbound disabled "
            "(set one of them, module stays importable)", env_key, webhook_env
        )
    if _app_id and not _token:
        logger.warning("Discord: DISCORD_APP_ID set but no bot token β€” followup/commands disabled")

    _active = True
    modes = []
    if _token:            modes.append("bot-rest")
    if _webhook_url:      modes.append("webhook")
    if _public_key:       modes.append("interactions-verify")
    logger.info("Discord initialized | modes: %s", ", ".join(modes) or "none (config only)")


def is_ready() -> bool:
    """True if initialize() ran with DISCORD_ACTIVE = "true"."""
    return _active


def get_config() -> dict:
    """Raw [DISCORD] config from .pyfun."""
    return _cfg


def get_status() -> dict:
    """
    Safe status snapshot β€” key NAMES and booleans only, never values.
    (Same principle as list_active_tools: nothing secret leaves the module.)
    """
    return {
        "active":          _active,
        "bot_token_set":   bool(_token),
        "webhook_set":     bool(_webhook_url),
        "public_key_set":  bool(_public_key),
        "app_id_set":      bool(_app_id),
        "default_channel": _default_channel,
        "api_base":        _api_base,
        "commands":        sorted(_command_handlers.keys()),
    }


# =============================================================================
# Internal REST helper β€” Bot auth, timeout, one rate-limit retry
# =============================================================================

async def _api_request(
    method: str,
    path: str,
    payload: Optional[dict] = None,
    auth: bool = True,
) -> dict:
    """
    One Discord REST call with the house rules:
    - 'Bot <token>' auth only when auth=True (webhook/followup URLs carry their own).
      NOTE: Discord bot tokens use the 'Bot' scheme β€” 'Bearer' is for OAuth2 user tokens.
    - 429 β†’ sleep Retry-After (capped at 10s), retry ONCE, then give up
    - never raises for flow errors β€” returns {"ok": False, "error": ...}
    """
    if auth and not _token:
        return {"ok": False, "error": "no bot token β€” set DISCORD_BOT_TOKEN in .env"}

    headers = {"Content-Type": "application/json"}
    if auth:
        headers["Authorization"] = f"Bot {_token}"

    url = path if path.startswith("http") else f"{_api_base}{path}"

    for attempt in (1, 2):
        try:
            async with httpx.AsyncClient() as client:
                r = await client.request(
                    method, url, json=payload, headers=headers, timeout=_timeout,
                )

            if r.status_code == 429 and attempt == 1:
                retry_after = float(r.headers.get("Retry-After", "1"))
                retry_after = min(retry_after, 10.0)
                logger.warning("Discord rate limit β€” retrying in %.1fs", retry_after)
                await asyncio.sleep(retry_after)
                continue

            if 200 <= r.status_code < 300:
                data = r.json() if r.content else {}
                return {"ok": True, "status": r.status_code, "data": data}

            # Discord error body: {"message": ..., "code": ...} β€” keep it, drop secrets
            try:
                err = r.json().get("message", "")
            except Exception:
                err = ""
            logger.warning("Discord API error HTTP %s: %s", r.status_code, err[:120])
            return {"ok": False, "status": r.status_code, "error": err or f"HTTP {r.status_code}"}

        except httpx.ConnectError:
            return {"ok": False, "error": "connection failed β€” Discord unreachable?"}
        except httpx.TimeoutException:
            return {"ok": False, "error": f"timeout after {_timeout}s"}
        except Exception as e:
            # Sanitized β€” only the exception type, no payload details
            logger.warning("Discord request failed: %s", type(e).__name__)
            return {"ok": False, "error": type(e).__name__}

    return {"ok": False, "error": "rate limited twice β€” try later"}


# =============================================================================
# OUTBOUND β€” hub β†’ Discord
# =============================================================================

async def get_me() -> dict:
    """
    Token sanity check β€” GET /users/@me.
    Returns the bot's own user object (id, username, ...) on success.
    Best first live test after wiring the token.
    """
    return await _api_request("GET", "/users/@me")


async def send_message(
    channel_id: str,
    content: str,
    embed: Optional[dict] = None,
) -> dict:
    """
    Post a message to a channel via Bot REST.

    Args:
        channel_id: target channel (falls back to DISCORD_DEFAULT_CHANNEL).
        content:    message text. Discord hard limit: 2000 chars β€” we REJECT
                    longer input instead of silently truncating.
        embed:      raw Discord embed JSON dict (pass-through, no builder).

    Returns:
        {"ok": True, "status": 200, "data": <message object>} on success,
        {"ok": False, "error": ...} on failure. Never raises.
    """
    if not _active:
        return {"ok": False, "error": "Discord not active β€” set DISCORD_ACTIVE = \"true\" in .pyfun"}
    if not _token:
        return {"ok": False, "error": "no bot token β€” set DISCORD_BOT_TOKEN in .env"}

    channel_id = channel_id or _default_channel
    if not channel_id:
        return {"ok": False, "error": "no channel_id given and DISCORD_DEFAULT_CHANNEL empty"}
    if content and len(content) > _MAX_CONTENT_LEN:
        return {"ok": False, "error": f"content exceeds {_MAX_CONTENT_LEN} chars (Discord limit)"}
    if not content and not embed:
        return {"ok": False, "error": "content and embed are both empty"}

    payload = {"content": content}
    if embed:
        payload["embeds"] = [embed]

    result = await _api_request("POST", f"/channels/{channel_id}/messages", payload)
    if result.get("ok"):
        logger.info("Discord: message sent to channel %s", channel_id)
    return result


async def send_webhook(content: str = "", embed: Optional[dict] = None) -> dict:
    """
    Post via channel webhook β€” write-only, needs NO bot, NO invite,
    NO intents. Dead simple notifications. The URL is the secret
    (anyone holding it can post) β†’ lives in .env only.

    Args:
        content: message text (same 2000 char limit, rejected not truncated).
        embed:   raw Discord embed JSON dict (pass-through).
    """
    if not _active:
        return {"ok": False, "error": "Discord not active β€” set DISCORD_ACTIVE = \"true\" in .pyfun"}
    if not _webhook_url:
        return {"ok": False, "error": "no webhook β€” set DISCORD_WEBHOOK_URL in .env"}
    if content and len(content) > _MAX_CONTENT_LEN:
        return {"ok": False, "error": f"content exceeds {_MAX_CONTENT_LEN} chars (Discord limit)"}
    if not content and not embed:
        return {"ok": False, "error": "content and embed are both empty"}

    payload: Dict[str, Any] = {}
    if content:
        payload["content"] = content
    if embed:
        payload["embeds"] = [embed]

    # Webhook URL carries its own auth β€” no Bearer header
    result = await _api_request("POST", _webhook_url, payload, auth=False)
    if result.get("ok"):
        logger.info("Discord: webhook message sent")
    return result


async def read_channel(channel_id: str, limit: int = 10) -> dict:
    """
    Read the last N messages from a channel (newest first).

    NOTE: message TEXT only comes back if the bot has the
    "Message Content Intent" enabled in the Discord Developer Portal
    (Bot tab). Without it you get metadata, content comes back empty.
    Send-only setups don't need the intent.
    """
    if not _active:
        return {"ok": False, "error": "Discord not active β€” set DISCORD_ACTIVE = \"true\" in .pyfun"}
    if not _token:
        return {"ok": False, "error": "no bot token β€” set DISCORD_BOT_TOKEN in .env"}

    channel_id = channel_id or _default_channel
    if not channel_id:
        return {"ok": False, "error": "no channel_id given and DISCORD_DEFAULT_CHANNEL empty"}

    limit = max(1, min(int(limit), 50))  # Discord caps at 100, we cap tighter
    return await _api_request("GET", f"/channels/{channel_id}/messages?limit={limit}")


async def send_followup(interaction_token: str, content: str, embed: Optional[dict] = None) -> dict:
    """
    Late answer after a DEFERRED response (type 5).

    The interaction token authorizes the call itself (webhook-style) β€”
    no bot token needed. Valid for 15 minutes after the interaction.

    Args:
        interaction_token: token from the interaction payload.
        content:           the (possibly slow) LLM answer.
        embed:             optional raw embed dict.
    """
    if not _app_id:
        return {"ok": False, "error": "no app id β€” set DISCORD_APP_ID in .pyfun"}
    if not interaction_token:
        return {"ok": False, "error": "no interaction_token given"}
    if content and len(content) > _MAX_CONTENT_LEN:
        return {"ok": False, "error": f"content exceeds {_MAX_CONTENT_LEN} chars (Discord limit)"}

    payload: Dict[str, Any] = {"content": content}
    if embed:
        payload["embeds"] = [embed]

    path = f"/webhooks/{_app_id}/{interaction_token}"
    return await _api_request("POST", path, payload, auth=False)


# =============================================================================
# SETUP HELPER β€” slash command registration via REST
# =============================================================================

async def register_commands(commands: list, guild_id: str = "") -> dict:
    """
    Bulk-register slash commands (PUT β€” overwrites existing set).

    Args:
        commands:  list of Discord command defs, e.g.
                   [{"name": "ask", "description": "Ask the hub",
                     "type": 1,
                     "options": [{"name": "prompt", "type": 3,
                                  "description": "your question",
                                  "required": True}]}]
        guild_id:  optional β€” guild-scoped = instant propagation (testing).
                   Global commands take up to 1 hour to propagate (Discord rule).

    Returns:
        {"ok": True, "data": <registered commands>} on success.
    """
    if not _active or not _token:
        return {"ok": False, "error": "Discord not active or no bot token"}
    if not _app_id:
        return {"ok": False, "error": "no app id β€” set DISCORD_APP_ID in .pyfun"}
    if not commands or not isinstance(commands, list):
        return {"ok": False, "error": "commands must be a non-empty list"}

    if guild_id:
        path = f"/applications/{_app_id}/guilds/{guild_id}/commands"
    else:
        path = f"/applications/{_app_id}/commands"

    return await _api_request("PUT", path, commands)


# =============================================================================
# INBOUND β€” Interactions Endpoint (Discord β†’ hub)
# =============================================================================

def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
    """
    Ed25519 verification of an incoming Discord interaction.

    Discord signs (timestamp + raw body) with the app's private key;
    we verify against the PUBLIC key from the Dev Portal. Forged or
    replayed requests fail here β†’ route answers 401. This is the ONLY
    thing that makes a public /discord endpoint safe.

    IMPORTANT: the route must pass the RAW request bytes (request.get_data()),
    NOT re-serialized JSON β€” re-serialization changes one byte and the
    signature dies. Verify BEFORE json.loads, always.

    Args:
        raw_body:  raw request body bytes (exactly as received).
        signature: hex string from X-Signature-Ed25519 header.
        timestamp: string from X-Signature-Timestamp header.

    Returns:
        True = authentic and fresh. False = reject (401), never crash.
    """
    if not _public_key or not signature or not timestamp:
        return False

    # Replay protection β€” stale timestamp = someone replaying an old request
    try:
        if abs(time.time() - int(timestamp)) > _TIMESTAMP_MAX_AGE:
            logger.warning("Discord: interaction timestamp stale β€” rejected")
            return False
    except ValueError:
        return False

    try:
        pub_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(_public_key))
        pub_key.verify(
            bytes.fromhex(signature),
            timestamp.encode("utf-8") + raw_body,
        )
        return True
    except (InvalidSignature, ValueError, TypeError) as e:
        logger.warning("Discord: signature invalid (%s) β€” rejected", type(e).__name__)
        return False


def register_command_handler(name: str, handler: Callable) -> None:
    """
    Map a slash command name to a handler β€” LATER wiring point for tools.py.

    Handler contract:
        async def handler(interaction: dict) -> dict
        Returns a full Discord response payload, e.g.
        {"type": 4, "data": {"content": "answer"}}         β€” direct
        {"type": 5}                                        β€” defer, then
            await discord_api.send_followup(token, answer) β€” followup

    The built-in "ping" answers without any tools β€” proves the endpoint.
    """
    if not name or not callable(handler):
        logger.warning("Discord: register_command_handler ignored (bad name/handler)")
        return
    _command_handlers[name] = handler
    logger.info("Discord: command handler registered: %s", name)


async def _builtin_ping(interaction: dict) -> dict:
    """Built-in health command β€” no tools needed, proves endpoint + config."""
    hub = config.get_hub()
    name = hub.get("HUB_NAME", "Hub")
    return {"type": RESPONSE_CHANNEL_MESSAGE, "data": {"content": f"πŸ“ {name} is alive"}}


# Ship the builtin β€” removable via unregister if you hate it
register_command_handler("ping", _builtin_ping)


async def handle_interaction(payload: dict) -> Optional[dict]:
    """
    Process a verified interaction payload β†’ response dict for Discord.

    Handles:
        type 1 (PING)          β†’ {"type": 1}  (PONG β€” Dev Portal URL check)
        type 2 (APPLICATION_COMMAND) β†’ dispatch to registered handler;
                                       unknown command β†’ honest error message.
        anything else          β†’ None (route answers 400 + logs β€” extend here
                                       when you need components/modals).

    Args:
        payload: parsed JSON body β€” only AFTER verify_signature passed.

    Returns:
        Response dict (route: jsonify + 200) or None (route: 400).
    """
    itype = payload.get("type")

    if itype == TYPE_PING:
        logger.info("Discord: PING β†’ PONG (endpoint validation)")
        return {"type": RESPONSE_PONG}

    if itype == TYPE_APPLICATION_COMMAND:
        data = payload.get("data", {})
        name = data.get("name", "")
        handler = _command_handlers.get(name)

        if handler is None:
            logger.warning("Discord: no handler for command '%s'", name)
            return {
                "type": RESPONSE_CHANNEL_MESSAGE,
                "data": {"content": f"Unknown command: {name} β€” no handler registered."},
            }

        try:
            return await handler(payload)
        except Exception as e:
            # Sanitized β€” exception type only, never payload details
            logger.warning("Discord: handler '%s' failed: %s", name, type(e).__name__)
            return {
                "type": RESPONSE_CHANNEL_MESSAGE,
                "data": {"content": "Handler error β€” see hub logs."},
            }

    logger.warning("Discord: unhandled interaction type %s", itype)
    return None