diff --git a/.gitignore b/.gitignore
index f4060cf8a1c9..eff588ff63e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -302,6 +302,7 @@ extensions/**/.openclaw-runtime-deps.json
extensions/**/.openclaw-runtime-deps-stamp.json
extensions/diffs/assets/viewer-runtime.js
extensions/diffs-language-pack/assets/viewer-runtime.js
+extensions/discord/assets/embedded-app-sdk.mjs
# Output dir for scripts/run-opengrep.sh (local opengrep scans)
/.opengrep-out/
diff --git a/config/knip.config.ts b/config/knip.config.ts
index 352c98c3acb5..1570ef814fcb 100644
--- a/config/knip.config.ts
+++ b/config/knip.config.ts
@@ -55,6 +55,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
"@a2ui/lit",
"@azure/identity",
"@clawdbot/lobster",
+ "@discord/embedded-app-sdk",
"@discordjs/opus",
"@homebridge/ciao",
"@lit/context",
diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256
index 863b99d73f6f..330fb50b4747 100644
--- a/docs/.generated/config-baseline.sha256
+++ b/docs/.generated/config-baseline.sha256
@@ -1,4 +1,4 @@
-8694a0cf7ce11cb605aba8a4b9f9cceef7310485d351f607952e2946a9abc985 config-baseline.json
-0883f41da0e064a58ac557e244a97d6052c3d262225d309cffbbef9e7300f48e config-baseline.core.json
-12ce7ba45d75c1753ce090fca8359a15708343cb2aeabb3dd99e8f6915062628 config-baseline.channel.json
-a7eac21a283d5eefa22a2b6168e1151f30519c19bc7efa2bd4ac2a4583ab0d59 config-baseline.plugin.json
+50c32bcc6b56dcfd033d131a5d616f84129d8f71e0ff078af01f53e2ad9b8534 config-baseline.json
+d6d937acf4f30846ba0122f3798f3e13dd60249414e31f9eeae87272991daac9 config-baseline.core.json
+7014814073220be9ff934dd1332d45d0a979d41a02842b337a0198596034562e config-baseline.channel.json
+5448611186a80c70279825b5a6ed6fbea21d5efb8d07eeba4dfb3055566bee4d config-baseline.plugin.json
diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256
index 25115aac8bc1..8aa3e84ae347 100644
--- a/docs/.generated/plugin-sdk-api-baseline.sha256
+++ b/docs/.generated/plugin-sdk-api-baseline.sha256
@@ -1,2 +1,2 @@
-308ef1abf94e5d086c0d7fcd048c50eba5b1cd256553e4abf673222037a730eb plugin-sdk-api-baseline.json
-5e4073d0ab87f9c3d12b5d7a13814503b0f844837cc3dd0d93145c6e9120e417 plugin-sdk-api-baseline.jsonl
+e93bd776603bf4246971ca7d69a36f262fe648986f850c8597a8ec64c8c94f46 plugin-sdk-api-baseline.json
+ccb2fee3a9d5764c875c0833e5a061c563df1afb77a7380198f9da12125af024 plugin-sdk-api-baseline.jsonl
diff --git a/docs/channels/discord-activities.md b/docs/channels/discord-activities.md
new file mode 100644
index 000000000000..00add07c8457
--- /dev/null
+++ b/docs/channels/discord-activities.md
@@ -0,0 +1,124 @@
+---
+summary: "Launch self-contained OpenClaw HTML widgets inside Discord Activities"
+read_when:
+ - Setting up or troubleshooting Discord Activity widgets
+title: "Discord Activities"
+---
+
+Discord Activities let an agent post an interactive, self-contained HTML widget to the current Discord channel. The message includes an **Open widget** button; clicking it launches the widget inside Discord.
+
+The feature is off by default. OpenClaw registers the Activity HTTP routes, `discord_widget` agent tool, and launch-button handler only when `channels.discord.activities` is present and a client secret resolves.
+
+## Prerequisites
+
+- an existing [OpenClaw Discord bot](/channels/discord)
+- a public HTTPS hostname that reaches the OpenClaw gateway
+- permission to configure Activities and OAuth2 for the bot's Discord application
+- an existing Discord user allowlist (`allowFrom` or `dm.allowFrom`), unless the account intentionally uses open DMs
+
+Any HTTPS reverse proxy or tunnel works. A named Cloudflare Tunnel provides a stable hostname without exposing the gateway port directly.
+
+```yaml
+# ~/.cloudflared/config.yml
+tunnel: openclaw-discord
+credentials-file: /home/you/.cloudflared/TUNNEL-ID.json
+ingress:
+ - hostname: openclaw.example.com
+ service: http://127.0.0.1:18789
+ - service: http_status:404
+```
+
+```bash
+cloudflared tunnel login
+cloudflared tunnel create openclaw-discord
+cloudflared tunnel route dns openclaw-discord openclaw.example.com
+cloudflared tunnel run openclaw-discord
+```
+
+Keep normal gateway authentication enabled. Only the Activity prefix is public, and the plugin validates OAuth, allowlists, sessions, and one-time document capabilities itself.
+
+## Setup
+
+
+
+ Start your tunnel or reverse proxy and verify that `https://openclaw.example.com/discord/activity/` reaches the gateway after Activities configuration is added. Replace the example hostname with your own.
+
+
+
+ Open the existing bot application in the [Discord Developer Portal](https://discord.com/developers/applications). Open **Activities**, enable Activities, and create a URL mapping:
+
+ - prefix: `ROOT` (`/`)
+ - target: `openclaw.example.com/discord/activity`
+
+ The target is the public hostname plus `/discord/activity`, without a trailing slash.
+
+
+
+
+ Open **OAuth2** in the Developer Portal. Discord requires at least one redirect URI, so add a local placeholder such as the loopback address if the application has none yet; the Embedded App SDK handles the Activity return flow. Copy or reset the application client secret. Treat it as a credential: do not paste it into chat, logs, or a committed configuration file.
+
+
+
+ Add one block to the Discord account that should offer widgets:
+
+ ```json5
+ {
+ channels: {
+ discord: {
+ token: "${DISCORD_BOT_TOKEN}",
+ allowFrom: ["YOUR_DISCORD_USER_ID"],
+ activities: {
+ clientSecret: "${DISCORD_CLIENT_SECRET}",
+ // Optional. Defaults to the bot application ID learned at startup.
+ applicationId: "YOUR_DISCORD_APPLICATION_ID",
+ },
+ },
+ },
+ }
+ ```
+
+ You may omit `clientSecret` from the block when `DISCORD_CLIENT_SECRET` is set. The block itself must remain present to opt in.
+
+
+
+
+ Restart the gateway. In a Discord conversation, ask the agent to show an interactive widget. The agent can call `discord_widget`; click **Open widget** on the posted message.
+
+
+
+## Security model
+
+- OAuth identifies the Discord user before widget metadata is returned.
+- The user must match the configured account's `allowFrom` or `dm.allowFrom`. An account with no allowlist allows everyone only when its DM policy is explicitly `open`.
+- OAuth sessions expire after 15 minutes. Widget document capabilities expire after 60 seconds and work once.
+- Widgets expire after seven days, with at most 64 retained per Discord plugin instance.
+- Widget HTML is authored by your agent and should be treated as trusted content. Do not embed secrets you would not want a buggy widget to expose.
+- The widget can navigate within its own nested frame. The `sandbox="allow-scripts"` iframe blocks top-level navigation, popups, and same-origin access, while its Content Security Policy blocks network connections and external resources. These controls are defense-in-depth, not a security boundary against the agent that authored the widget.
+- When Activities is disabled, `/discord/activity` is not registered at all.
+
+The public Activity shell and token-exchange route become reachable through your tunnel when enabled. They do not expose widget HTML without a valid OAuth session and one-time document capability.
+
+## Troubleshooting
+
+### The Activity says “Gateway offline”
+
+- confirm the tunnel is running and routes to the gateway's actual bind port
+- confirm the Developer Portal target includes `/discord/activity`
+- restart the gateway after changing Discord or OpenClaw configuration
+- check gateway logs for the one-line warning about a missing Activities client secret
+
+### Discord opens a blank page or reports `blocked:csp`
+
+- verify the URL mapping uses `ROOT` and does not add a second `/discord/activity` segment
+- confirm the shell, `shell.js`, and SDK module all return through the Discord proxy
+- inspect gateway logs for requests under `/discord/activity/`
+
+Widget network requests are intentionally blocked. Inline all CSS, JavaScript, images, and data needed by the widget.
+
+### “Not authorized”
+
+Add the user's stable Discord ID to `allowFrom` or `dm.allowFrom` on the same Discord account that owns Activities. Restart after editing configuration.
+
+### “Widget unavailable”
+
+Launch the button from the channel where the agent posted it. If Discord does not carry the button's custom ID into the Activity, OpenClaw falls back only when that channel has exactly one live widget; multiple widgets fail closed as unavailable.
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index 4f7928168de0..a9d96953f56c 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -1727,10 +1727,17 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
- actions: `actions.*`
- presence: `activity`, `status`, `activityType`, `activityUrl`, `autoPresence.*`
- UI: `ui.components.accentColor`
-- features: `threadBindings`, top-level `bindings[]` (`type: "acp"`), `pluralkit`, `execApprovals`, `intents`, `agentComponents.enabled`, `agentComponents.ttlMs`, `heartbeat`, `responsePrefix`
+- features: `threadBindings`, top-level `bindings[]` (`type: "acp"`), `pluralkit`, `execApprovals`, `intents`, `agentComponents.enabled`, `agentComponents.ttlMs`, `activities`, `heartbeat`, `responsePrefix`
+### Discord Activities
+
+Set `channels.discord.activities` to let agents post self-contained HTML widgets that open inside Discord. The block is opt-in; when absent, OpenClaw registers no Activity routes, tool, or interaction handler. See [Discord Activities](/channels/discord-activities) for the Developer Portal, tunnel, security, and troubleshooting setup.
+
+- `activities.clientSecret`: OAuth2 client secret for the Discord application; falls back to `DISCORD_CLIENT_SECRET`
+- `activities.applicationId`: optional Activity application ID; defaults to the bot application ID learned at gateway startup
+
## Safety and operations
- Treat bot tokens as secrets (`DISCORD_BOT_TOKEN` preferred in supervised environments).
@@ -1740,6 +1747,9 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
## Related
+
+ Launch interactive HTML widgets inside Discord.
+
Pair a Discord user to the gateway.
diff --git a/docs/docs.json b/docs/docs.json
index 3dcb55ded761..d46efa3a75e1 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1143,6 +1143,7 @@
"group": "Mainstream messaging",
"pages": [
"channels/discord",
+ "channels/discord-activities",
"channels/slack",
"channels/telegram",
"channels/whatsapp",
diff --git a/docs/docs_map.md b/docs/docs_map.md
index a4ba5f2d7090..fe84c3e5a9e9 100644
--- a/docs/docs_map.md
+++ b/docs/docs_map.md
@@ -321,6 +321,19 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Permissions
- H2: Troubleshooting
+## channels/discord-activities.md
+
+- Route: /channels/discord-activities
+- Headings:
+ - H2: Prerequisites
+ - H2: Setup
+ - H2: Security model
+ - H2: Troubleshooting
+ - H3: The Activity says “Gateway offline”
+ - H3: Discord opens a blank page or reports blocked:csp
+ - H3: “Not authorized”
+ - H3: “Widget unavailable”
+
## channels/discord.md
- Route: /channels/discord
@@ -342,6 +355,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Voice messages
- H2: Troubleshooting
- H2: Configuration reference
+ - H3: Discord Activities
- H2: Safety and operations
- H2: Related
diff --git a/extensions/discord/activities-api.ts b/extensions/discord/activities-api.ts
new file mode 100644
index 000000000000..1f30eab3f909
--- /dev/null
+++ b/extensions/discord/activities-api.ts
@@ -0,0 +1,9 @@
+// Discord API module exposes the plugin public contract.
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
+import { registerDiscordActivities as registerDiscordActivitiesImpl } from "./src/activities/register.js";
+
+// Bundled entrypoints may not statically import ./src, so Activities registration
+// is routed through this top-level barrel like the other Discord api surfaces.
+export function registerDiscordActivities(api: OpenClawPluginApi): void {
+ registerDiscordActivitiesImpl(api);
+}
diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts
index 3fce1e2116fb..702be65b54f0 100644
--- a/extensions/discord/index.ts
+++ b/extensions/discord/index.ts
@@ -1,5 +1,6 @@
// Discord plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
+import { registerDiscordActivities } from "./activities-api.js";
import { registerDiscordSubagentHooks } from "./subagent-hooks-api.js";
import { discordVoiceTranscriptsSourceProvider } from "./transcripts-source-api.js";
@@ -21,6 +22,7 @@ export default defineBundledChannelEntry({
exportName: "inspectDiscordReadOnlyAccount",
},
registerFull(api) {
+ registerDiscordActivities(api);
registerDiscordSubagentHooks(api);
api.registerTranscriptSourceProvider(discordVoiceTranscriptsSourceProvider);
},
diff --git a/extensions/discord/npm-shrinkwrap.json b/extensions/discord/npm-shrinkwrap.json
index 62373104c508..111f6c2b4b9c 100644
--- a/extensions/discord/npm-shrinkwrap.json
+++ b/extensions/discord/npm-shrinkwrap.json
@@ -8,6 +8,7 @@
"name": "@openclaw/discord",
"version": "2026.7.2",
"dependencies": {
+ "@discord/embedded-app-sdk": "2.5.0",
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.49",
"libopus-wasm": "0.2.0",
@@ -25,6 +26,22 @@
}
}
},
+ "node_modules/@discord/embedded-app-sdk": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@discord/embedded-app-sdk/-/embedded-app-sdk-2.5.0.tgz",
+ "integrity": "sha512-FNoe5PbSkoKEbqPubaBmq6tlEMOftMjR3gu55YrdrrKmBfKKM4i9P/Z+Zxl0RZdJcKviBwVcTyYMMPM/aGex/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash.transform": "^4.6.6",
+ "@types/uuid": "^10.0.0",
+ "big-integer": "^1.6.48",
+ "decimal.js-light": "^2.5.0",
+ "eventemitter3": "^5.0.0",
+ "lodash.transform": "^4.6.0",
+ "uuid": "^11.0.0",
+ "zod": "^3.9.8"
+ }
+ },
"node_modules/@discordjs/voice": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@discordjs/voice/-/voice-0.19.2.tgz",
@@ -325,6 +342,21 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash.transform": {
+ "version": "4.6.9",
+ "resolved": "https://registry.npmjs.org/@types/lodash.transform/-/lodash.transform-4.6.9.tgz",
+ "integrity": "sha512-1iIn+l7Vrj8hsr2iZLtxRkcV9AtjTafIyxKO9DX2EEcdOgz3Op5dhwKQFhMJgdfIRbYHBUF+SU97Y6P+zyLXNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
@@ -334,6 +366,12 @@
"undici-types": "~8.3.0"
}
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -343,6 +381,21 @@
"@types/node": "*"
}
},
+ "node_modules/big-integer": {
+ "version": "1.6.52",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
+ "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/discord-api-types": {
"version": "0.38.49",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz",
@@ -352,6 +405,12 @@
"scripts/actions/documentation"
]
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
"node_modules/libopus-wasm": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/libopus-wasm/-/libopus-wasm-0.2.0.tgz",
@@ -361,6 +420,12 @@
"node": ">=20"
}
},
+ "node_modules/lodash.transform": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz",
+ "integrity": "sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==",
+ "license": "MIT"
+ },
"node_modules/p-map": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
@@ -426,6 +491,19 @@
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
+ "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@@ -446,6 +524,15 @@
"optional": true
}
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/extensions/discord/openclaw.plugin.json b/extensions/discord/openclaw.plugin.json
index f2e75d5bd66a..8d7fc643e3f9 100644
--- a/extensions/discord/openclaw.plugin.json
+++ b/extensions/discord/openclaw.plugin.json
@@ -3,16 +3,27 @@
"name": "Discord",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"icon": "https://cdn.simpleicons.org/discord",
- "skills": ["./skills"],
+ "skills": [
+ "./skills"
+ ],
"activation": {
"onStartup": false
},
- "channels": ["discord"],
+ "channels": [
+ "discord"
+ ],
"contracts": {
- "transcriptSourceProviders": ["discord-voice"]
+ "tools": [
+ "discord_widget"
+ ],
+ "transcriptSourceProviders": [
+ "discord-voice"
+ ]
},
"channelEnvVars": {
- "discord": ["DISCORD_BOT_TOKEN"]
+ "discord": [
+ "DISCORD_BOT_TOKEN"
+ ]
},
"configSchema": {
"type": "object",
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index ef5a970ba2ab..71cfdb618c11 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -8,6 +8,7 @@
},
"type": "module",
"dependencies": {
+ "@discord/embedded-app-sdk": "2.5.0",
"@discordjs/voice": "0.19.2",
"discord-api-types": "0.38.49",
"libopus-wasm": "0.2.0",
@@ -71,7 +72,16 @@
"pluginApi": ">=2026.7.2"
},
"build": {
- "openclawVersion": "2026.7.2"
+ "openclawVersion": "2026.7.2",
+ "staticAssets": [
+ {
+ "source": "./assets/embedded-app-sdk.mjs",
+ "output": "assets/embedded-app-sdk.mjs"
+ }
+ ]
+ },
+ "assetScripts": {
+ "build": "node ../../scripts/build-discord-activity-sdk.mjs"
},
"release": {
"publishToClawHub": true,
diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts
index 8b3d31c68c4a..15008ed1b442 100644
--- a/extensions/discord/src/accounts.ts
+++ b/extensions/discord/src/accounts.ts
@@ -54,7 +54,7 @@ export function mergeDiscordAccountConfig(
| Record>
| undefined,
accountId,
- nestedObjectKeys: ["agentComponents", "botLoopProtection"],
+ nestedObjectKeys: ["activities", "agentComponents", "botLoopProtection"],
});
return merged;
}
diff --git a/extensions/discord/src/activities/allowlist.ts b/extensions/discord/src/activities/allowlist.ts
new file mode 100644
index 000000000000..21f8d9f4bb4c
--- /dev/null
+++ b/extensions/discord/src/activities/allowlist.ts
@@ -0,0 +1,32 @@
+import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts";
+import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
+import { allowListMatches, normalizeDiscordAllowList } from "../monitor/allow-list.js";
+
+const ACTIVITY_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"];
+
+type DiscordActivityUser = {
+ id: string;
+ username?: string;
+ discriminator?: string;
+};
+
+export function resolveActivityUserAuthorized(
+ account: DiscordAccountConfig,
+ user: DiscordActivityUser,
+): boolean {
+ const entries = [...(account.allowFrom ?? []), ...(account.dm?.allowFrom ?? [])];
+ const allowList = normalizeDiscordAllowList(entries, ACTIVITY_ALLOWLIST_PREFIXES);
+ if (!allowList) {
+ return (account.dmPolicy ?? account.dm?.policy) === "open";
+ }
+ const discriminator = user.discriminator?.trim();
+ const tag =
+ user.username && discriminator && discriminator !== "0"
+ ? `${user.username}#${discriminator}`
+ : user.username;
+ return allowListMatches(
+ allowList,
+ { id: user.id, name: user.username, tag },
+ { allowNameMatching: isDangerousNameMatchingEnabled(account) },
+ );
+}
diff --git a/extensions/discord/src/activities/config.test.ts b/extensions/discord/src/activities/config.test.ts
new file mode 100644
index 000000000000..26c31625b37b
--- /dev/null
+++ b/extensions/discord/src/activities/config.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DiscordConfigSchema } from "../../config-api.js";
+import { mergeDiscordAccountConfig } from "../accounts.js";
+import { resolveActivityUserAuthorized } from "./allowlist.js";
+import { resolveDiscordActivitiesConfig } from "./config.js";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("Discord Activities config", () => {
+ it("accepts the strict activities block", () => {
+ const parsed = DiscordConfigSchema.safeParse({
+ activities: { clientSecret: "secret", applicationId: "123456789012345678" },
+ });
+ expect(parsed.success).toBe(true);
+ });
+
+ it("rejects a non-snowflake Activity application ID", () => {
+ const parsed = DiscordConfigSchema.safeParse({
+ activities: { clientSecret: "secret", applicationId: "abc" },
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it("rejects unknown activities keys", () => {
+ const parsed = DiscordConfigSchema.safeParse({
+ activities: { clientSecret: "secret", publicUrl: "https://example.com" },
+ });
+ expect(parsed.success).toBe(false);
+ });
+
+ it("resolves config and environment secrets only when the block exists", () => {
+ expect(resolveDiscordActivitiesConfig({})).toEqual({
+ enabled: false,
+ reason: "not-configured",
+ });
+ expect(resolveDiscordActivitiesConfig({ activities: {} }, {})).toEqual({
+ enabled: false,
+ reason: "missing-client-secret",
+ });
+ expect(
+ resolveDiscordActivitiesConfig(
+ { activities: { applicationId: "123" } },
+ { DISCORD_CLIENT_SECRET: "envsec" },
+ ),
+ ).toEqual({ enabled: true, clientSecret: "envsec", applicationId: "123" });
+ expect(
+ resolveDiscordActivitiesConfig(
+ { activities: { clientSecret: "cfgsec" } },
+ { DISCORD_CLIENT_SECRET: "envsec" },
+ ),
+ ).toEqual({ enabled: true, clientSecret: "cfgsec" });
+ });
+
+ it("merges root Activity credentials with account-specific application IDs", () => {
+ const account = mergeDiscordAccountConfig(
+ {
+ channels: {
+ discord: {
+ activities: { clientSecret: "rootsec" },
+ accounts: { work: { activities: { applicationId: "123" } } },
+ },
+ },
+ },
+ "work",
+ );
+ expect(account.activities).toEqual({ clientSecret: "rootsec", applicationId: "123" });
+ });
+});
+
+describe("Discord Activity user authorization", () => {
+ it("always matches IDs but gates username matching behind the dangerous opt-in", () => {
+ expect(
+ resolveActivityUserAuthorized({ allowFrom: ["42"] }, { id: "42", username: "alice" }),
+ ).toBe(true);
+ expect(
+ resolveActivityUserAuthorized(
+ { dm: { allowFrom: ["alice"] } },
+ { id: "99", username: "Alice" },
+ ),
+ ).toBe(false);
+ expect(
+ resolveActivityUserAuthorized(
+ { dangerouslyAllowNameMatching: true, dm: { allowFrom: ["alice"] } },
+ { id: "99", username: "Alice" },
+ ),
+ ).toBe(true);
+ });
+
+ it("allows an empty allowlist only for open DMs", () => {
+ expect(resolveActivityUserAuthorized({ dmPolicy: "open" }, { id: "42" })).toBe(true);
+ expect(resolveActivityUserAuthorized({}, { id: "42" })).toBe(false);
+ });
+});
diff --git a/extensions/discord/src/activities/config.ts b/extensions/discord/src/activities/config.ts
new file mode 100644
index 000000000000..7dd604395551
--- /dev/null
+++ b/extensions/discord/src/activities/config.ts
@@ -0,0 +1,37 @@
+import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts";
+
+type DiscordActivitiesConfigResolution =
+ | {
+ enabled: true;
+ clientSecret: string;
+ applicationId?: string;
+ }
+ | {
+ enabled: false;
+ reason: "not-configured" | "missing-client-secret";
+ };
+
+function readNonEmpty(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed || undefined;
+}
+
+export function resolveDiscordActivitiesConfig(
+ account: DiscordAccountConfig,
+ env: NodeJS.ProcessEnv = process.env,
+): DiscordActivitiesConfigResolution {
+ if (!account.activities) {
+ return { enabled: false, reason: "not-configured" };
+ }
+ const clientSecret =
+ readNonEmpty(account.activities.clientSecret) ?? readNonEmpty(env.DISCORD_CLIENT_SECRET);
+ if (!clientSecret) {
+ return { enabled: false, reason: "missing-client-secret" };
+ }
+ const applicationId = readNonEmpty(account.activities.applicationId);
+ return {
+ enabled: true,
+ clientSecret,
+ ...(applicationId ? { applicationId } : {}),
+ };
+}
diff --git a/extensions/discord/src/activities/discord-api.ts b/extensions/discord/src/activities/discord-api.ts
new file mode 100644
index 000000000000..7d98db8f8e46
--- /dev/null
+++ b/extensions/discord/src/activities/discord-api.ts
@@ -0,0 +1,115 @@
+import fs from "node:fs/promises";
+import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
+import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
+
+export const DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token";
+export const DISCORD_USER_URL = "https://discord.com/api/v10/users/@me";
+const DISCORD_HOST = "discord.com";
+const JSON_MAX_BYTES = 64 * 1024;
+const INSTANCE_ID_MAX_LENGTH = 256;
+
+export { fetchWithSsrFGuard };
+export type FetchGuard = typeof fetchWithSsrFGuard;
+
+export function normalizeInstanceId(value: string | null): string | undefined {
+ const instanceId = value?.trim();
+ let hasControlCharacter = false;
+ for (let index = 0; index < (instanceId?.length ?? 0); index += 1) {
+ const codePoint = instanceId?.charCodeAt(index) ?? 0;
+ if (codePoint < 0x20 || codePoint === 0x7f) {
+ hasControlCharacter = true;
+ break;
+ }
+ }
+ if (!instanceId || instanceId.length > INSTANCE_ID_MAX_LENGTH || hasControlCharacter) {
+ return undefined;
+ }
+ return instanceId;
+}
+
+export async function fetchDiscordJson(params: {
+ fetchGuard: FetchGuard;
+ fetchImpl?: typeof fetch;
+ url: string;
+ init: RequestInit;
+ auditContext: string;
+}): Promise<{ ok: boolean; status: number; body?: Record }> {
+ const { response, release } = await params.fetchGuard({
+ url: params.url,
+ fetchImpl: params.fetchImpl,
+ init: params.init,
+ policy: { allowedHostnames: [DISCORD_HOST] },
+ auditContext: params.auditContext,
+ timeoutMs: 15_000,
+ });
+ try {
+ if (!response.ok) {
+ return { ok: false, status: response.status };
+ }
+ return {
+ ok: true,
+ status: response.status,
+ body: await readProviderJsonResponse>(
+ response,
+ "Discord Activity OAuth",
+ {
+ maxBytes: JSON_MAX_BYTES,
+ },
+ ),
+ };
+ } finally {
+ await release();
+ }
+}
+
+export async function resolveActivityInstanceChannel(params: {
+ fetchGuard: FetchGuard;
+ applicationId: string;
+ instanceId: string;
+ discordUserId: string;
+ botAuth: string;
+ proxyFetch?: typeof fetch;
+}): Promise {
+ let result: Awaited>;
+ try {
+ result = await fetchDiscordJson({
+ fetchGuard: params.fetchGuard,
+ fetchImpl: params.proxyFetch,
+ url: `https://discord.com/api/v10/applications/${encodeURIComponent(params.applicationId)}/activity-instances/${encodeURIComponent(params.instanceId)}`,
+ init: { headers: { Authorization: `Bot ${params.botAuth}` } },
+ auditContext: "discord.activities.instance",
+ });
+ } catch {
+ return undefined;
+ }
+ if (
+ !result.ok ||
+ !Array.isArray(result.body?.users) ||
+ !result.body.users.includes(params.discordUserId) ||
+ !result.body.location ||
+ typeof result.body.location !== "object"
+ ) {
+ return undefined;
+ }
+ const channelId = (result.body.location as Record).channel_id;
+ return typeof channelId === "string" && /^\d+$/.test(channelId) ? channelId : undefined;
+}
+
+// Source layout nests this file under src/activities/; the bundled dist flattens the
+// plugin next to its assets dir. Try both so the asset resolves in either layout.
+const VENDOR_ASSET_CANDIDATE_RELATIVE_PATHS = [
+ "../../assets/embedded-app-sdk.mjs",
+ "./assets/embedded-app-sdk.mjs",
+] as const;
+
+export async function defaultReadVendorAsset(): Promise {
+ let lastError: unknown;
+ for (const relativePath of VENDOR_ASSET_CANDIDATE_RELATIVE_PATHS) {
+ try {
+ return await fs.readFile(new URL(relativePath, import.meta.url));
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ throw lastError instanceof Error ? lastError : new Error("embedded-app-sdk asset missing");
+}
diff --git a/extensions/discord/src/activities/http.test.ts b/extensions/discord/src/activities/http.test.ts
new file mode 100644
index 000000000000..f1ce8e203199
--- /dev/null
+++ b/extensions/discord/src/activities/http.test.ts
@@ -0,0 +1,610 @@
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { buildDiscordActivityCustomId } from "../component-custom-id.js";
+import { createDiscordActivityHttpHandler } from "./http.js";
+import { DiscordActivitiesRuntime } from "./runtime.js";
+import {
+ createActivityTestConfig,
+ createActivityTestRuntime,
+ createMemoryActivityStore,
+} from "./test-helpers.test-support.js";
+
+const servers: Server[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ servers.splice(0).map(
+ (server) =>
+ new Promise((resolve) => {
+ server.close(() => {
+ resolve();
+ });
+ }),
+ ),
+ );
+});
+
+async function startServer(
+ runtime: DiscordActivitiesRuntime,
+ options: {
+ fetchGuard?: typeof fetchWithSsrFGuard;
+ now?: () => number;
+ readVendorAsset?: () => Promise;
+ } = {},
+): Promise {
+ const route = createDiscordActivityHttpHandler({ runtime, ...options });
+ const server = createServer((req, res) => {
+ void route.handleHttpRequest(req, res).then((handled) => {
+ if (!handled) {
+ res.statusCode = 404;
+ res.end("not found");
+ }
+ });
+ });
+ servers.push(server);
+ await new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ const address = server.address() as AddressInfo;
+ return `http://127.0.0.1:${address.port}`;
+}
+
+function guardedJsonFetch(params?: {
+ tokenStatus?: number;
+ userId?: string;
+ instanceStatus?: number;
+ channelId?: string;
+ instanceUsers?: string[];
+}) {
+ return vi.fn(async ({ url }: { url: string }) => {
+ const wantsExchange = url.includes("/oauth2/token");
+ const wantsInstance = url.includes("/activity-instances/");
+ const status = wantsExchange
+ ? (params?.tokenStatus ?? 200)
+ : wantsInstance
+ ? (params?.instanceStatus ?? 200)
+ : 200;
+ const body = wantsExchange
+ ? { access_token: "atoken" }
+ : wantsInstance
+ ? {
+ location: { channel_id: params?.channelId ?? "777" },
+ users: params?.instanceUsers ?? ["42"],
+ }
+ : { id: params?.userId ?? "42", username: "alice", discriminator: "0" };
+ return {
+ response: new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ }),
+ release: vi.fn(async () => undefined),
+ };
+ }) as unknown as typeof fetchWithSsrFGuard;
+}
+
+async function createWidget(
+ runtime: DiscordActivitiesRuntime,
+ params?: { createdAt?: number; channelId?: string; accountId?: string },
+) {
+ const widgetId = await runtime.store.createWidget({
+ html: "",
+ title: "Activity status",
+ channelId: params?.channelId ?? "777",
+ accountId: params?.accountId ?? "default",
+ createdAt: params?.createdAt ?? 1,
+ });
+ return widgetId;
+}
+
+function createProxyAwareRuntime(): DiscordActivitiesRuntime {
+ const cfg = createActivityTestConfig();
+ cfg.gateway = { trustedProxies: ["127.0.0.1"] };
+ return createActivityTestRuntime(cfg);
+}
+
+function fetchInputUrl(input: string | URL | Request): string {
+ return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+}
+
+describe("Discord Activity HTTP OAuth", () => {
+ it("exchanges a code, creates a session, and uses it on the widget endpoint", async () => {
+ const runtime = createActivityTestRuntime();
+ const widgetId = await createWidget(runtime);
+ const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
+
+ const tokenResponse = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ },
+ body: JSON.stringify({ code: "oauth-code" }),
+ });
+ const token = (await tokenResponse.json()) as {
+ access_token: string;
+ session_token: string;
+ };
+ expect(tokenResponse.status).toBe(200);
+ expect(token.access_token).toBe("atoken");
+ expect(token.session_token).toMatch(/^[A-Za-z0-9_-]{43}$/);
+
+ const widgetResponse = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=${encodeURIComponent(buildDiscordActivityCustomId(widgetId))}&instance_id=instance-1`,
+ { headers: { Authorization: `Bearer ${token.session_token}` } },
+ );
+ expect(widgetResponse.status).toBe(200);
+ await expect(widgetResponse.json()).resolves.toMatchObject({
+ id: widgetId,
+ title: "Activity status",
+ });
+ });
+
+ it("routes Discord API calls through the resolved account proxy fetch", async () => {
+ const runtime = createActivityTestRuntime();
+ const widgetId = await createWidget(runtime);
+ const original = runtime.resolveHttpAccount();
+ if (!original) {
+ throw new Error("missing account");
+ }
+ const prox = vi.fn(async (input: string | URL | Request) => {
+ const url = fetchInputUrl(input);
+ const body = url.includes("/oauth2/token")
+ ? { access_token: "atoken" }
+ : url.includes("/activity-instances/")
+ ? { location: { channel_id: "777" }, users: ["42"] }
+ : { id: "42", username: "alice", discriminator: "0" };
+ return new Response(JSON.stringify(body), {
+ headers: { "Content-Type": "application/json" },
+ });
+ });
+ const account = { ...original, proxyFetch: prox as unknown as typeof fetch };
+ vi.spyOn(runtime, "resolveHttpAccount").mockReturnValue(account);
+ vi.spyOn(runtime, "resolveAccount").mockReturnValue(account);
+ const guard = vi.fn(
+ async (params: { url: string; init?: RequestInit; fetchImpl?: typeof fetch }) => {
+ if (!params.fetchImpl) {
+ throw new Error("missing proxy fetch");
+ }
+ return {
+ response: await params.fetchImpl(params.url, params.init),
+ release: vi.fn(async () => undefined),
+ };
+ },
+ ) as unknown as typeof fetchWithSsrFGuard;
+ const base = await startServer(runtime, { fetchGuard: guard });
+
+ const tokenResponse = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ code: "oauth-code" }),
+ });
+ const token = (await tokenResponse.json()) as { session_token: string };
+ expect(tokenResponse.status).toBe(200);
+ const widgetResponse = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=${widgetId}&instance_id=instance-1`,
+ { headers: { Authorization: `Bearer ${token.session_token}` } },
+ );
+
+ expect(widgetResponse.status).toBe(200);
+ expect(prox).toHaveBeenCalledTimes(3);
+ expect(prox.mock.calls.map(([input]) => fetchInputUrl(input))).toEqual([
+ "https://discord.com/api/oauth2/token",
+ "https://discord.com/api/v10/users/@me",
+ "https://discord.com/api/v10/applications/123456789012345678/activity-instances/instance-1",
+ ]);
+ });
+
+ it("returns 401 for a rejected code", async () => {
+ const base = await startServer(createActivityTestRuntime(), {
+ fetchGuard: guardedJsonFetch({ tokenStatus: 400 }),
+ });
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ code: "bad-code" }),
+ });
+ expect(response.status).toBe(401);
+ });
+
+ it("returns 403 for a user outside the account allowlist", async () => {
+ const base = await startServer(createActivityTestRuntime(), {
+ fetchGuard: guardedJsonFetch({ userId: "99" }),
+ });
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ code: "oauth-code" }),
+ });
+ expect(response.status).toBe(403);
+ });
+
+ it("returns 503 when the configured account no longer resolves a secret", async () => {
+ const cfg = createActivityTestConfig({ clientSecret: "" });
+ const runtime = new DiscordActivitiesRuntime(createMemoryActivityStore(), cfg, undefined, {});
+ const base = await startServer(runtime);
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ code: "oauth-code" }),
+ });
+ expect(response.status).toBe(503);
+ });
+
+ it("limits token requests to ten per source IP per minute", async () => {
+ const base = await startServer(createActivityTestRuntime(), { now: () => 1_000 });
+ for (let index = 0; index < 10; index += 1) {
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ });
+ expect(response.status).toBe(401);
+ }
+ const limited = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ });
+ expect(limited.status).toBe(429);
+ });
+
+ it("does not charge malformed or unresolved requests to the global budget", async () => {
+ const base = await startServer(createProxyAwareRuntime(), {
+ fetchGuard: guardedJsonFetch(),
+ now: () => 1_000,
+ });
+ for (let index = 0; index < 61; index += 1) {
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: `https://${100_000_000_000_000_000n + BigInt(index)}.discordsays.com`,
+ "X-Forwarded-For": `198.51.100.${index + 1}`,
+ },
+ body: "{}",
+ });
+ expect(response.status).toBe(503);
+ }
+ for (let index = 0; index < 61; index += 1) {
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": `203.0.113.${index + 1}`,
+ },
+ body: "{}",
+ });
+ expect(response.status).toBe(401);
+ }
+ const legitimate = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": "192.0.2.250",
+ },
+ body: JSON.stringify({ code: "ok" }),
+ });
+ expect(legitimate.status).toBe(200);
+ });
+
+ it("does not charge Discord-rejected codes to the global budget", async () => {
+ // Nonempty codes that Discord rejects must not consume login capacity, or a few
+ // rotating sources could 429 every genuine launch with bogus-but-valid-shaped codes.
+ const rejectBogusCode = vi.fn(async ({ url, init }: { url: string; init?: RequestInit }) => {
+ const wantsExchange = url.includes("/oauth2/token");
+ const code = init?.body instanceof URLSearchParams ? init.body.get("code") : null;
+ const status = wantsExchange && code === "bogus" ? 400 : 200;
+ const body = wantsExchange
+ ? { access_token: "atoken" }
+ : { id: "42", username: "alice", discriminator: "0" };
+ return {
+ response: new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ }),
+ release: vi.fn(async () => undefined),
+ };
+ }) as unknown as typeof fetchWithSsrFGuard;
+ const base = await startServer(createProxyAwareRuntime(), {
+ fetchGuard: rejectBogusCode,
+ now: () => 1_000,
+ });
+ for (let index = 0; index < 61; index += 1) {
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": `198.51.100.${index + 1}`,
+ },
+ body: JSON.stringify({ code: "bogus" }),
+ });
+ expect(response.status).toBe(401);
+ }
+ const legitimate = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": "192.0.2.250",
+ },
+ body: JSON.stringify({ code: "ok" }),
+ });
+ expect(legitimate.status).toBe(200);
+ });
+
+ it("reserves global capacity before concurrent exchanges complete", async () => {
+ let releaseExchanges = () => {};
+ const exchangeGate = new Promise((resolve) => {
+ releaseExchanges = resolve;
+ });
+ let exchangeCalls = 0;
+ const guard = vi.fn(async ({ url }: { url: string }) => {
+ const wantsExchange = url.includes("/oauth2/token");
+ if (wantsExchange) {
+ exchangeCalls += 1;
+ await exchangeGate;
+ }
+ const body = wantsExchange
+ ? { access_token: "atoken" }
+ : { id: "42", username: "alice", discriminator: "0" };
+ return {
+ response: new Response(JSON.stringify(body), {
+ headers: { "Content-Type": "application/json" },
+ }),
+ release: vi.fn(async () => undefined),
+ };
+ }) as unknown as typeof fetchWithSsrFGuard;
+ const base = await startServer(createProxyAwareRuntime(), {
+ fetchGuard: guard,
+ now: () => 1_000,
+ });
+ const pending = Array.from({ length: 60 }, (_, index) =>
+ fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": `198.51.100.${index + 1}`,
+ },
+ body: JSON.stringify({ code: "ok" }),
+ }),
+ );
+ let limited: Response;
+ try {
+ await vi.waitFor(() => expect(exchangeCalls).toBe(60), { timeout: 5_000 });
+ limited = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": "192.0.2.250",
+ },
+ body: JSON.stringify({ code: "ok" }),
+ });
+ } finally {
+ releaseExchanges();
+ }
+ expect(limited.status).toBe(429);
+ const responses = await Promise.all(pending);
+ expect(responses.every((response) => response.status === 200)).toBe(true);
+ });
+
+ it("limits valid token exchanges globally across rotating source IPs", async () => {
+ const base = await startServer(createProxyAwareRuntime(), {
+ fetchGuard: guardedJsonFetch(),
+ now: () => 1_000,
+ });
+ for (let index = 0; index < 60; index += 1) {
+ const response = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": `198.51.100.${index + 1}`,
+ },
+ body: JSON.stringify({ code: "ok" }),
+ });
+ expect(response.status).toBe(200);
+ }
+ const limited = await fetch(`${base}/discord/activity/api/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://123456789012345678.discordsays.com",
+ "X-Forwarded-For": "192.0.2.250",
+ },
+ body: JSON.stringify({ code: "ok" }),
+ });
+ expect(limited.status).toBe(429);
+ });
+});
+
+describe("Discord Activity widget routes", () => {
+ it("requires a valid bearer session", async () => {
+ const base = await startServer(createActivityTestRuntime());
+ expect((await fetch(`${base}/discord/activity/api/widget?instance_id=abc`)).status).toBe(401);
+ expect(
+ (
+ await fetch(`${base}/discord/activity/api/widget?instance_id=abc`, {
+ headers: { Authorization: `Bearer ${"a".repeat(43)}` },
+ })
+ ).status,
+ ).toBe(401);
+ });
+
+ it("resolves a custom ID in the validated instance channel and consumes its doc token", async () => {
+ const runtime = createActivityTestRuntime();
+ const firstId = await createWidget(runtime, { createdAt: 1 });
+ await createWidget(runtime, { createdAt: 2 });
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
+
+ const direct = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=${encodeURIComponent(buildDiscordActivityCustomId(firstId))}&instance_id=instance-1`,
+ { headers: { Authorization: `Bearer ${session}` } },
+ );
+ expect(direct.status).toBe(200);
+ const metadata = (await direct.json()) as { id: string; docUrl: string };
+ expect(metadata.id).toBe(firstId);
+ const documentUrl = new URL(metadata.docUrl, base);
+ const firstDocument = await fetch(documentUrl);
+ expect(firstDocument.status).toBe(200);
+ const firstCsp = firstDocument.headers.get("content-security-policy");
+ expect(firstCsp).toContain("sandbox allow-scripts");
+ expect(firstCsp).toContain("connect-src 'none'");
+ expect(await firstDocument.text()).toContain("document.body.dataset.ready");
+ const secondDocument = await fetch(documentUrl);
+ expect(secondDocument.status).toBe(404);
+ const secondCsp = secondDocument.headers.get("content-security-policy");
+ expect(secondCsp).toContain("sandbox allow-scripts");
+ expect(secondCsp).toContain("connect-src 'none'");
+ });
+
+ it("rejects a custom ID outside the validated instance channel", async () => {
+ const runtime = createActivityTestRuntime();
+ const widgetId = await createWidget(runtime, { channelId: "888" });
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const base = await startServer(runtime, {
+ fetchGuard: guardedJsonFetch({ channelId: "777" }),
+ });
+ const response = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=${widgetId}&instance_id=instance-1`,
+ { headers: { Authorization: `Bearer ${session}` } },
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ it("ignores a forged channel ID when no Activity instance is supplied", async () => {
+ const runtime = createActivityTestRuntime();
+ await createWidget(runtime);
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const base = await startServer(runtime);
+ const response = await fetch(`${base}/discord/activity/api/widget?channel_id=777`, {
+ headers: { Authorization: `Bearer ${session}` },
+ });
+ expect(response.status).toBe(404);
+ });
+
+ it("uses the Activity Instance API channel when exactly one widget matches", async () => {
+ const runtime = createActivityTestRuntime();
+ await createWidget(runtime, { channelId: "888", createdAt: 1 });
+ const newestId = await createWidget(runtime, { channelId: "777", createdAt: 2 });
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const guard = guardedJsonFetch({ channelId: "777" });
+ const base = await startServer(runtime, { fetchGuard: guard });
+ const response = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=missing&instance_id=instance-1&channel_id=888`,
+ { headers: { Authorization: `Bearer ${session}` } },
+ );
+
+ expect(response.status).toBe(200);
+ expect((await response.json()) as { id: string }).toMatchObject({ id: newestId });
+ expect(guard).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: "https://discord.com/api/v10/applications/123456789012345678/activity-instances/instance-1",
+ init: expect.objectContaining({ headers: { Authorization: "Bot testtok" } }),
+ }),
+ );
+ });
+
+ it("returns 404 when multiple widgets match the Activity Instance API channel", async () => {
+ const runtime = createActivityTestRuntime();
+ await createWidget(runtime, { channelId: "777", createdAt: 1 });
+ await createWidget(runtime, { channelId: "777", createdAt: 2 });
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const base = await startServer(runtime, { fetchGuard: guardedJsonFetch() });
+ const response = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=missing&instance_id=instance-1`,
+ { headers: { Authorization: `Bearer ${session}` } },
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ it("returns 404 when the Activity instance cannot be resolved", async () => {
+ const runtime = createActivityTestRuntime();
+ await createWidget(runtime);
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const base = await startServer(runtime, {
+ fetchGuard: guardedJsonFetch({ instanceStatus: 404 }),
+ });
+ const response = await fetch(`${base}/discord/activity/api/widget?instance_id=missing`, {
+ headers: { Authorization: `Bearer ${session}` },
+ });
+ expect(response.status).toBe(404);
+ });
+
+ it("returns 404 for a custom ID when the session user is absent from the Activity instance", async () => {
+ const runtime = createActivityTestRuntime();
+ const widgetId = await createWidget(runtime);
+ const session = await runtime.store.createSession({
+ discordUserId: "42",
+ accountId: "default",
+ });
+ const base = await startServer(runtime, {
+ fetchGuard: guardedJsonFetch({ instanceUsers: ["99"] }),
+ });
+ const response = await fetch(
+ `${base}/discord/activity/api/widget?custom_id=${widgetId}&instance_id=instance-1`,
+ { headers: { Authorization: `Bearer ${session}` } },
+ );
+ expect(response.status).toBe(404);
+ });
+});
+
+describe("Discord Activity shell assets", () => {
+ it("serves the shell, module, and generated SDK asset", async () => {
+ // The SDK bundle is a gitignored build artifact absent from synced checkouts, so
+ // the vendor read is injected; generation is covered by bundled-plugin-assets tests.
+ const base = await startServer(createActivityTestRuntime(), {
+ readVendorAsset: async () => Buffer.from("export class DiscordSDK {}\n"),
+ });
+ const shell = await fetch(`${base}/discord/activity/`);
+ expect(shell.status).toBe(200);
+ expect(await shell.text()).toContain('