mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(discord): opt-in Discord Activities widget support (#107442)
Adds a Discord Activities integration so an agent can show a self-contained HTML widget to Discord users, opened as a sandboxed Activity inside the client. Off by default: routes, the discord_widget tool, and the launch handler register only when channels.discord.activities is configured. OAuth identifies the user and gates on the account allowlist; widget lookup is capability- or instance-validated; token exchange is rate-limited; widget HTML runs in a no-network sandboxed iframe.
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -55,6 +55,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
|
||||
"@a2ui/lit",
|
||||
"@azure/identity",
|
||||
"@clawdbot/lobster",
|
||||
"@discord/embedded-app-sdk",
|
||||
"@discordjs/opus",
|
||||
"@homebridge/ciao",
|
||||
"@lit/context",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
<Steps>
|
||||
<Step title="Expose the gateway over HTTPS">
|
||||
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.
|
||||
</Step>
|
||||
|
||||
<Step title="Enable Activities in Discord">
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Copy the OAuth2 client secret">
|
||||
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.
|
||||
</Step>
|
||||
|
||||
<Step title="Configure OpenClaw">
|
||||
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.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Restart and test">
|
||||
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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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.
|
||||
@@ -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`
|
||||
|
||||
</Accordion>
|
||||
|
||||
### 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
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Discord Activities" icon="window" href="/channels/discord-activities">
|
||||
Launch interactive HTML widgets inside Discord.
|
||||
</Card>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
Pair a Discord user to the gateway.
|
||||
</Card>
|
||||
|
||||
@@ -1143,6 +1143,7 @@
|
||||
"group": "Mainstream messaging",
|
||||
"pages": [
|
||||
"channels/discord",
|
||||
"channels/discord-activities",
|
||||
"channels/slack",
|
||||
"channels/telegram",
|
||||
"channels/whatsapp",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Generated
+87
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,7 +54,7 @@ export function mergeDiscordAccountConfig(
|
||||
| Record<string, Partial<DiscordAccountConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
nestedObjectKeys: ["agentComponents", "botLoopProtection"],
|
||||
nestedObjectKeys: ["activities", "agentComponents", "botLoopProtection"],
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> }> {
|
||||
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<Record<string, unknown>>(
|
||||
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<string | undefined> {
|
||||
let result: Awaited<ReturnType<typeof fetchDiscordJson>>;
|
||||
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<string, unknown>).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<Buffer> {
|
||||
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");
|
||||
}
|
||||
@@ -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<void>((resolve) => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
async function startServer(
|
||||
runtime: DiscordActivitiesRuntime,
|
||||
options: {
|
||||
fetchGuard?: typeof fetchWithSsrFGuard;
|
||||
now?: () => number;
|
||||
readVendorAsset?: () => Promise<Buffer>;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
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<void>((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: "<!doctype html><html><body><script>document.body.dataset.ready='yes'</script></body></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<void>((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('<script type="module" src="./shell.js">');
|
||||
const script = await fetch(`${base}/discord/activity/shell.js`);
|
||||
const scriptBody = await script.text();
|
||||
expect(scriptBody).toContain('from "./vendor/embedded-app-sdk.mjs"');
|
||||
expect(scriptBody).toContain("url.pathname.slice(gatewayPrefix.length)");
|
||||
expect(scriptBody).toContain("instance_id: sdk.instanceId");
|
||||
expect(scriptBody).not.toContain("channel_id: sdk.channelId");
|
||||
expect(scriptBody).toContain('frame.setAttribute("sandbox", "allow-scripts")');
|
||||
const vendor = await fetch(`${base}/discord/activity/vendor/embedded-app-sdk.mjs`);
|
||||
expect(vendor.status).toBe(200);
|
||||
expect(await vendor.text()).toContain("DiscordSDK");
|
||||
});
|
||||
|
||||
it("returns 404 for the vendor asset when the bundle is missing", async () => {
|
||||
const base = await startServer(createActivityTestRuntime(), {
|
||||
readVendorAsset: async () => {
|
||||
throw new Error("missing");
|
||||
},
|
||||
});
|
||||
const vendor = await fetch(`${base}/discord/activity/vendor/embedded-app-sdk.mjs`);
|
||||
expect(vendor.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { resolveRequestClientIp } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { parseDiscordActivityCustomId } from "../component-custom-id.js";
|
||||
import { resolveActivityUserAuthorized } from "./allowlist.js";
|
||||
import {
|
||||
defaultReadVendorAsset,
|
||||
DISCORD_TOKEN_URL,
|
||||
DISCORD_USER_URL,
|
||||
fetchDiscordJson,
|
||||
fetchWithSsrFGuard,
|
||||
type FetchGuard,
|
||||
normalizeInstanceId,
|
||||
resolveActivityInstanceChannel,
|
||||
} from "./discord-api.js";
|
||||
import { TokenRateLimiter } from "./rate-limit.js";
|
||||
import type { DiscordActivitiesRuntime } from "./runtime.js";
|
||||
import {
|
||||
DISCORD_ACTIVITY_ROUTE_PREFIX,
|
||||
DISCORD_ACTIVITY_SHELL_CSP,
|
||||
DISCORD_ACTIVITY_SHELL_HTML,
|
||||
DISCORD_ACTIVITY_SHELL_JS,
|
||||
} from "./shell.js";
|
||||
|
||||
const BODY_MAX_BYTES = 8 * 1024;
|
||||
const WIDGET_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
||||
const DOC_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
const DISCORD_ACTIVITY_WIDGET_CSP =
|
||||
// Discord is an ancestor of the same-origin Activity shell, so every frame ancestor must pass.
|
||||
// The one-time document capability and nested sandbox remain the embedding boundary.
|
||||
"sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; " +
|
||||
"style-src 'unsafe-inline'; img-src data: blob:; font-src data:; " +
|
||||
"connect-src 'none'; frame-ancestors *";
|
||||
|
||||
type DiscordActivityHttpDeps = {
|
||||
runtime: DiscordActivitiesRuntime;
|
||||
fetchGuard?: FetchGuard;
|
||||
now?: () => number;
|
||||
readVendorAsset?: () => Promise<Buffer>;
|
||||
};
|
||||
|
||||
type DiscordOauthUser = {
|
||||
id?: string;
|
||||
username?: string;
|
||||
discriminator?: string;
|
||||
};
|
||||
|
||||
function setCommonHeaders(res: ServerResponse): void {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
|
||||
function respond(
|
||||
res: ServerResponse,
|
||||
statusCode: number,
|
||||
body: string | Buffer,
|
||||
contentType: string,
|
||||
headers?: Record<string, string>,
|
||||
): true {
|
||||
res.statusCode = statusCode;
|
||||
setCommonHeaders(res);
|
||||
res.setHeader("Content-Type", contentType);
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
res.end(body);
|
||||
return true;
|
||||
}
|
||||
|
||||
function respondJson(res: ServerResponse, statusCode: number, body: unknown): true {
|
||||
return respond(res, statusCode, `${JSON.stringify(body)}\n`, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
function notFound(res: ServerResponse, widgetDocument = false): true {
|
||||
// Indistinguishable 404s prevent document-capability probes from revealing widget state.
|
||||
return respond(
|
||||
res,
|
||||
404,
|
||||
"not found",
|
||||
"text/plain; charset=utf-8",
|
||||
widgetDocument ? { "Content-Security-Policy": DISCORD_ACTIVITY_WIDGET_CSP } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of req) {
|
||||
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += data.byteLength;
|
||||
if (total > BODY_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
chunks.push(data);
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(req: IncomingMessage, name: string): string | undefined {
|
||||
const value = req.headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function extractApplicationId(req: IncomingMessage): string | undefined {
|
||||
for (const header of [readHeader(req, "origin"), readHeader(req, "referer")]) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const match = new URL(header).hostname.match(/^(\d+)\.discordsays\.com$/i);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bearerToken(req: IncomingMessage): string | undefined {
|
||||
const authorization = readHeader(req, "authorization")?.trim();
|
||||
const match = authorization?.match(/^Bearer\s+([A-Za-z0-9_-]{43})$/i);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function widgetIdFromCustomId(customId: string): string | undefined {
|
||||
if (WIDGET_ID_PATTERN.test(customId)) {
|
||||
return customId;
|
||||
}
|
||||
return parseDiscordActivityCustomId(customId)?.widgetId;
|
||||
}
|
||||
|
||||
export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps): {
|
||||
handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
||||
} {
|
||||
const fetchGuard = deps.fetchGuard ?? fetchWithSsrFGuard;
|
||||
const limiter = new TokenRateLimiter(deps.now ?? Date.now);
|
||||
const readVendorAsset = deps.readVendorAsset ?? defaultReadVendorAsset;
|
||||
let vendorAsset: Promise<Buffer> | undefined;
|
||||
|
||||
async function handleToken(req: IncomingMessage, res: ServerResponse): Promise<true> {
|
||||
const cfg = deps.runtime.currentConfig();
|
||||
const sourceIp =
|
||||
resolveRequestClientIp(
|
||||
req,
|
||||
cfg.gateway?.trustedProxies,
|
||||
cfg.gateway?.allowRealIpFallback === true,
|
||||
) ?? "unknown";
|
||||
if (!limiter.allowKey(sourceIp)) {
|
||||
return respondJson(res, 429, { error: "too many token requests" });
|
||||
}
|
||||
const applicationId = extractApplicationId(req);
|
||||
const account = deps.runtime.resolveHttpAccount(applicationId);
|
||||
if (!account) {
|
||||
return respondJson(res, 503, { error: "Discord Activities is not fully configured" });
|
||||
}
|
||||
const body = await readJsonBody(req);
|
||||
const code = typeof body?.code === "string" ? body.code.trim() : "";
|
||||
if (!code) {
|
||||
return respondJson(res, 401, { error: "invalid authorization code" });
|
||||
}
|
||||
const reservation = limiter.reserveGlobal();
|
||||
if (reservation === null) {
|
||||
return respondJson(res, 429, { error: "too many token requests" });
|
||||
}
|
||||
let completed = false;
|
||||
try {
|
||||
let tokenResponse: Awaited<ReturnType<typeof fetchDiscordJson>>;
|
||||
try {
|
||||
tokenResponse = await fetchDiscordJson({
|
||||
fetchGuard,
|
||||
fetchImpl: account.proxyFetch,
|
||||
url: DISCORD_TOKEN_URL,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: account.applicationId,
|
||||
client_secret: account.clientSecret,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
auditContext: "discord.activities.oauth.token",
|
||||
});
|
||||
} catch {
|
||||
return respondJson(res, 503, { error: "Discord token exchange unavailable" });
|
||||
}
|
||||
const granted =
|
||||
typeof tokenResponse.body?.access_token === "string"
|
||||
? tokenResponse.body.access_token.trim()
|
||||
: "";
|
||||
if (!tokenResponse.ok || !granted) {
|
||||
return respondJson(res, 401, { error: "invalid authorization code" });
|
||||
}
|
||||
let userResponse: Awaited<ReturnType<typeof fetchDiscordJson>>;
|
||||
try {
|
||||
userResponse = await fetchDiscordJson({
|
||||
fetchGuard,
|
||||
fetchImpl: account.proxyFetch,
|
||||
url: DISCORD_USER_URL,
|
||||
init: { headers: { Authorization: `Bearer ${granted}` } },
|
||||
auditContext: "discord.activities.oauth.user",
|
||||
});
|
||||
} catch {
|
||||
return respondJson(res, 503, { error: "Discord user lookup unavailable" });
|
||||
}
|
||||
const user: DiscordOauthUser = {
|
||||
id: typeof userResponse.body?.id === "string" ? userResponse.body.id : undefined,
|
||||
username:
|
||||
typeof userResponse.body?.username === "string" ? userResponse.body.username : undefined,
|
||||
discriminator:
|
||||
typeof userResponse.body?.discriminator === "string"
|
||||
? userResponse.body.discriminator
|
||||
: undefined,
|
||||
};
|
||||
if (!userResponse.ok || !user.id) {
|
||||
return respondJson(res, 401, { error: "Discord user lookup failed" });
|
||||
}
|
||||
if (!resolveActivityUserAuthorized(account.config, { ...user, id: user.id })) {
|
||||
return respondJson(res, 403, { error: "Discord user is not allowlisted" });
|
||||
}
|
||||
const minted = await deps.runtime.store.createSession({
|
||||
discordUserId: user.id,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
completed = true;
|
||||
return respondJson(res, 200, {
|
||||
access_token: granted,
|
||||
session_token: minted,
|
||||
});
|
||||
} finally {
|
||||
if (!completed) {
|
||||
limiter.releaseGlobal(reservation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWidget(req: IncomingMessage, res: ServerResponse, url: URL): Promise<true> {
|
||||
const token = bearerToken(req);
|
||||
const session = token ? await deps.runtime.store.lookupSession(token) : undefined;
|
||||
if (!session) {
|
||||
return respondJson(res, 401, { error: "invalid session" });
|
||||
}
|
||||
const customId = url.searchParams.get("custom_id")?.trim() ?? "";
|
||||
const instanceId = normalizeInstanceId(url.searchParams.get("instance_id"));
|
||||
const account = deps.runtime.resolveAccount(session.accountId);
|
||||
const channelId =
|
||||
instanceId && account
|
||||
? await resolveActivityInstanceChannel({
|
||||
fetchGuard,
|
||||
applicationId: account.applicationId,
|
||||
instanceId,
|
||||
discordUserId: session.discordUserId,
|
||||
botAuth: account.botAuth,
|
||||
proxyFetch: account.proxyFetch,
|
||||
})
|
||||
: undefined;
|
||||
if (!channelId) {
|
||||
return respondJson(res, 404, { error: "widget not found" });
|
||||
}
|
||||
let resolved: {
|
||||
id: string;
|
||||
widget: NonNullable<Awaited<ReturnType<typeof deps.runtime.store.lookupWidget>>>;
|
||||
} | null;
|
||||
const requestedWidgetId = widgetIdFromCustomId(customId);
|
||||
if (requestedWidgetId) {
|
||||
const widget = await deps.runtime.store.lookupWidget(requestedWidgetId);
|
||||
if (widget?.accountId !== session.accountId || widget.channelId !== channelId) {
|
||||
return respondJson(res, 404, { error: "widget not found" });
|
||||
}
|
||||
resolved = { id: requestedWidgetId, widget };
|
||||
} else {
|
||||
resolved = await deps.runtime.store.singleWidgetForChannel(session.accountId, channelId);
|
||||
}
|
||||
if (!resolved) {
|
||||
return respondJson(res, 404, { error: "widget not found" });
|
||||
}
|
||||
const docToken = await deps.runtime.store.createDocToken({
|
||||
widgetId: resolved.id,
|
||||
accountId: session.accountId,
|
||||
});
|
||||
return respondJson(res, 200, {
|
||||
id: resolved.id,
|
||||
title: resolved.widget.title,
|
||||
docUrl: `${DISCORD_ACTIVITY_ROUTE_PREFIX}/api/widget/${encodeURIComponent(resolved.id)}/doc?wt=${encodeURIComponent(docToken)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDocument(
|
||||
res: ServerResponse,
|
||||
widgetId: string,
|
||||
token: string,
|
||||
): Promise<true> {
|
||||
if (!WIDGET_ID_PATTERN.test(widgetId) || !DOC_TOKEN_PATTERN.test(token)) {
|
||||
return notFound(res, true);
|
||||
}
|
||||
// Consume before lookup: a valid capability is single-use even if its widget was evicted.
|
||||
const capability = await deps.runtime.store.consumeDocToken(token);
|
||||
if (!capability || capability.widgetId !== widgetId) {
|
||||
return notFound(res, true);
|
||||
}
|
||||
const widget = await deps.runtime.store.lookupWidget(widgetId);
|
||||
if (!widget || widget.accountId !== capability.accountId) {
|
||||
return notFound(res, true);
|
||||
}
|
||||
return respond(res, 200, widget.html, "text/html; charset=utf-8", {
|
||||
"Content-Security-Policy": DISCORD_ACTIVITY_WIDGET_CSP,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async handleHttpRequest(req, res) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (
|
||||
url.pathname !== DISCORD_ACTIVITY_ROUTE_PREFIX &&
|
||||
!url.pathname.startsWith(`${DISCORD_ACTIVITY_ROUTE_PREFIX}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const relative = url.pathname.slice(DISCORD_ACTIVITY_ROUTE_PREFIX.length) || "/";
|
||||
if (req.method === "GET" && (relative === "/" || relative === "/index.html")) {
|
||||
return respond(res, 200, DISCORD_ACTIVITY_SHELL_HTML, "text/html; charset=utf-8", {
|
||||
"Content-Security-Policy": DISCORD_ACTIVITY_SHELL_CSP,
|
||||
});
|
||||
}
|
||||
if (req.method === "GET" && relative === "/shell.js") {
|
||||
return respond(res, 200, DISCORD_ACTIVITY_SHELL_JS, "text/javascript; charset=utf-8");
|
||||
}
|
||||
if (req.method === "GET" && relative === "/vendor/embedded-app-sdk.mjs") {
|
||||
try {
|
||||
vendorAsset ??= readVendorAsset();
|
||||
return respond(res, 200, await vendorAsset, "text/javascript; charset=utf-8");
|
||||
} catch {
|
||||
return notFound(res);
|
||||
}
|
||||
}
|
||||
if (req.method === "POST" && relative === "/api/token") {
|
||||
return await handleToken(req, res);
|
||||
}
|
||||
if (req.method === "GET" && relative === "/api/widget") {
|
||||
return await handleWidget(req, res, url);
|
||||
}
|
||||
const documentMatch = relative.match(/^\/api\/widget\/([^/]+)\/doc$/);
|
||||
if (req.method === "GET" && documentMatch?.[1]) {
|
||||
let widgetId: string;
|
||||
try {
|
||||
widgetId = decodeURIComponent(documentMatch[1]);
|
||||
} catch {
|
||||
return notFound(res, true);
|
||||
}
|
||||
return await handleDocument(res, widgetId, url.searchParams.get("wt") ?? "");
|
||||
}
|
||||
return notFound(res);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ComponentType, InteractionResponseType } from "discord-api-types/v10";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ButtonInteraction } from "../internal/discord.js";
|
||||
import { createInteraction } from "../internal/interactions.js";
|
||||
import {
|
||||
attachRestMock,
|
||||
createInternalComponentInteractionPayload,
|
||||
createInternalTestClient,
|
||||
} from "../internal/test-builders.test-support.js";
|
||||
import type { AgentComponentContext } from "../monitor/agent-components.types.js";
|
||||
import { createDiscordActivityButton } from "./interaction.js";
|
||||
import { setDiscordActivitiesRuntime } from "./runtime.js";
|
||||
import {
|
||||
createActivityTestConfig,
|
||||
createActivityTestRuntime,
|
||||
} from "./test-helpers.test-support.js";
|
||||
|
||||
afterEach(() => {
|
||||
setDiscordActivitiesRuntime(undefined);
|
||||
});
|
||||
|
||||
function componentContext(): AgentComponentContext {
|
||||
const cfg = createActivityTestConfig();
|
||||
return {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
discordConfig: cfg.channels?.discord,
|
||||
allowFrom: ["42"],
|
||||
dmPolicy: "allowlist",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Discord Activity interaction", () => {
|
||||
it("does not claim unrelated component custom IDs", () => {
|
||||
setDiscordActivitiesRuntime(createActivityTestRuntime());
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678");
|
||||
expect(button?.customIdParser("other:key=value").key).toBe("other");
|
||||
});
|
||||
|
||||
it("registers from the configured Activity application ID without a learned ID", () => {
|
||||
setDiscordActivitiesRuntime(createActivityTestRuntime());
|
||||
expect(createDiscordActivityButton(componentContext())).not.toBeNull();
|
||||
});
|
||||
|
||||
it("posts a raw LAUNCH_ACTIVITY callback", async () => {
|
||||
const post = vi.fn(async () => undefined);
|
||||
const client = createInternalTestClient();
|
||||
attachRestMock(client, { post });
|
||||
const interaction = createInteraction(
|
||||
client,
|
||||
createInternalComponentInteractionPayload({
|
||||
id: "interaction-1",
|
||||
token: "itoken",
|
||||
data: { component_type: ComponentType.Button, custom_id: "ocactivity:v=1;wid=x" },
|
||||
}),
|
||||
) as ButtonInteraction;
|
||||
|
||||
await interaction.launchActivity();
|
||||
|
||||
expect(post).toHaveBeenCalledWith("/interactions/interaction-1/itoken/callback", {
|
||||
body: { type: InteractionResponseType.LaunchActivity },
|
||||
});
|
||||
});
|
||||
|
||||
it("launches for an authorized component click", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const authorize = vi.fn(async () => ({ commandAuthorized: true }));
|
||||
const reply = vi.fn(async () => undefined);
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
authorize: authorize as never,
|
||||
reply: reply as never,
|
||||
});
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = { launchActivity } as unknown as ButtonInteraction;
|
||||
|
||||
await button?.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
|
||||
expect(authorize).toHaveBeenCalledOnce();
|
||||
expect(launchActivity).toHaveBeenCalledOnce();
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replies ephemerally and does not launch when unauthorized", async () => {
|
||||
setDiscordActivitiesRuntime(createActivityTestRuntime());
|
||||
const reply = vi.fn(async () => undefined);
|
||||
const button = createDiscordActivityButton(componentContext(), "123456789012345678", {
|
||||
authorize: vi.fn(async () => ({ commandAuthorized: false })) as never,
|
||||
reply: reply as never,
|
||||
});
|
||||
const launchActivity = vi.fn(async () => undefined);
|
||||
const interaction = { launchActivity } as unknown as ButtonInteraction;
|
||||
|
||||
await button?.run(interaction, { widgetId: "AAAAAAAAAAAAAAAAAAAAAA" });
|
||||
|
||||
expect(reply).toHaveBeenCalledWith(interaction, { content: "not allowed", ephemeral: true });
|
||||
expect(launchActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
buildDiscordActivityCustomId,
|
||||
parseDiscordActivityCustomIdForInteraction,
|
||||
} from "../component-custom-id.js";
|
||||
import type { ButtonInteraction, ComponentData } from "../internal/discord.js";
|
||||
import { Button } from "../internal/discord.js";
|
||||
import { resolveAuthorizedComponentInteraction } from "../monitor/agent-components-auth.js";
|
||||
import { replySilently } from "../monitor/agent-components-reply.js";
|
||||
import type { AgentComponentContext } from "../monitor/agent-components.types.js";
|
||||
import { getDiscordActivitiesRuntime } from "./runtime.js";
|
||||
|
||||
const REGISTRATION_WIDGET_ID = "AAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
class DiscordActivityButton extends Button {
|
||||
label = "Open widget";
|
||||
customId = buildDiscordActivityCustomId(REGISTRATION_WIDGET_ID);
|
||||
override customIdParser = parseDiscordActivityCustomIdForInteraction;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: AgentComponentContext,
|
||||
private readonly deps: {
|
||||
authorize: typeof resolveAuthorizedComponentInteraction;
|
||||
reply: typeof replySilently;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
||||
if (typeof data.widgetId !== "string") {
|
||||
await this.deps.reply(interaction, {
|
||||
content: "This widget is no longer valid.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const authorized = await this.deps.authorize({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
label: "discord activity",
|
||||
componentLabel: "widget button",
|
||||
unauthorizedReply: "not allowed",
|
||||
defer: false,
|
||||
});
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
if (!authorized.commandAuthorized) {
|
||||
await this.deps.reply(interaction, { content: "not allowed", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await interaction.launchActivity();
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiscordActivityButton(
|
||||
ctx: AgentComponentContext,
|
||||
applicationId?: string,
|
||||
deps: {
|
||||
authorize?: typeof resolveAuthorizedComponentInteraction;
|
||||
reply?: typeof replySilently;
|
||||
} = {},
|
||||
): DiscordActivityButton | null {
|
||||
const runtime = getDiscordActivitiesRuntime();
|
||||
if (!runtime || !runtime.isAccountEnabled(ctx.accountId, ctx.cfg)) {
|
||||
return null;
|
||||
}
|
||||
if (applicationId) {
|
||||
runtime.registerApplicationId(ctx.accountId, applicationId);
|
||||
}
|
||||
if (!runtime.resolveAccount(ctx.accountId, ctx.cfg)) {
|
||||
return null;
|
||||
}
|
||||
return new DiscordActivityButton(ctx, {
|
||||
authorize: deps.authorize ?? resolveAuthorizedComponentInteraction,
|
||||
reply: deps.reply ?? replySilently,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const TOKEN_RATE_LIMIT = 10;
|
||||
const TOKEN_GLOBAL_RATE_LIMIT = 60;
|
||||
const TOKEN_RATE_WINDOW_MS = 60_000;
|
||||
const TOKEN_RATE_MAX_KEYS = 1_024;
|
||||
|
||||
export class TokenRateLimiter {
|
||||
private readonly attempts = new Map<string, number[]>();
|
||||
private globalAttempts: Array<{ at: number }> = [];
|
||||
|
||||
constructor(private readonly now: () => number) {}
|
||||
|
||||
allowKey(key: string): boolean {
|
||||
const now = this.now();
|
||||
const active = (this.attempts.get(key) ?? []).filter(
|
||||
(timestamp) => timestamp > now - TOKEN_RATE_WINDOW_MS,
|
||||
);
|
||||
if (active.length >= TOKEN_RATE_LIMIT) {
|
||||
this.attempts.delete(key);
|
||||
this.attempts.set(key, active);
|
||||
return false;
|
||||
}
|
||||
if (!this.attempts.has(key) && this.attempts.size >= TOKEN_RATE_MAX_KEYS) {
|
||||
const oldestKey = this.attempts.keys().next().value;
|
||||
if (typeof oldestKey === "string") {
|
||||
this.attempts.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
active.push(now);
|
||||
this.attempts.delete(key);
|
||||
this.attempts.set(key, active);
|
||||
return true;
|
||||
}
|
||||
|
||||
reserveGlobal(): { at: number } | null {
|
||||
const now = this.now();
|
||||
this.globalAttempts = this.globalAttempts.filter(
|
||||
(reservation) => reservation.at > now - TOKEN_RATE_WINDOW_MS,
|
||||
);
|
||||
if (this.globalAttempts.length >= TOKEN_GLOBAL_RATE_LIMIT) {
|
||||
return null;
|
||||
}
|
||||
const reservation = { at: now };
|
||||
this.globalAttempts.push(reservation);
|
||||
return reservation;
|
||||
}
|
||||
|
||||
releaseGlobal(reservation: { at: number }): void {
|
||||
const index = this.globalAttempts.indexOf(reservation);
|
||||
if (index >= 0) {
|
||||
this.globalAttempts.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerDiscordActivities } from "./register.js";
|
||||
import { getDiscordActivitiesRuntime, setDiscordActivitiesRuntime } from "./runtime.js";
|
||||
import { createMemoryKeyedStore } from "./test-helpers.test-support.js";
|
||||
|
||||
afterEach(() => {
|
||||
setDiscordActivitiesRuntime(undefined);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function createApi(config: Record<string, unknown>) {
|
||||
const routes: unknown[] = [];
|
||||
const tools: unknown[] = [];
|
||||
const warn = vi.fn();
|
||||
const api = {
|
||||
config,
|
||||
logger: { warn },
|
||||
runtime: {
|
||||
state: { openKeyedStore: vi.fn(() => createMemoryKeyedStore()) },
|
||||
config: { current: () => config },
|
||||
},
|
||||
registerHttpRoute: vi.fn((route) => routes.push(route)),
|
||||
registerTool: vi.fn((tool) => tools.push(tool)),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
return { api, routes, tools, warn };
|
||||
}
|
||||
|
||||
describe("Discord Activities registration", () => {
|
||||
it("registers no route, tool, or runtime when unconfigured", () => {
|
||||
const test = createApi({ channels: { discord: { token: "test" } } });
|
||||
registerDiscordActivities(test.api);
|
||||
expect(test.routes).toHaveLength(0);
|
||||
expect(test.tools).toHaveLength(0);
|
||||
expect(getDiscordActivitiesRuntime()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns and remains disabled when the secret is missing", () => {
|
||||
vi.stubEnv("DISCORD_CLIENT_SECRET", "");
|
||||
const test = createApi({
|
||||
channels: { discord: { token: "test", activities: { applicationId: "123" } } },
|
||||
});
|
||||
registerDiscordActivities(test.api);
|
||||
expect(test.warn).toHaveBeenCalledWith(expect.stringContaining("no client secret resolved"));
|
||||
expect(test.routes).toHaveLength(0);
|
||||
expect(test.tools).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("registers nothing for an explicitly disabled Discord account", () => {
|
||||
const test = createApi({
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: false,
|
||||
token: "test",
|
||||
activities: { clientSecret: "secret", applicationId: "123" },
|
||||
},
|
||||
},
|
||||
});
|
||||
registerDiscordActivities(test.api);
|
||||
expect(test.routes).toHaveLength(0);
|
||||
expect(test.tools).toHaveLength(0);
|
||||
expect(getDiscordActivitiesRuntime()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers the public plugin route and Discord-only tool factory when configured", () => {
|
||||
const test = createApi({
|
||||
channels: {
|
||||
discord: {
|
||||
token: "test",
|
||||
activities: { clientSecret: "secret", applicationId: "123" },
|
||||
},
|
||||
},
|
||||
});
|
||||
registerDiscordActivities(test.api);
|
||||
expect(test.routes).toHaveLength(1);
|
||||
expect(test.routes[0]).toMatchObject({
|
||||
path: "/discord/activity",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(test.tools).toHaveLength(1);
|
||||
const factory = test.tools[0] as (context: { messageChannel?: string }) => unknown;
|
||||
expect(factory({ messageChannel: "slack" })).toBeNull();
|
||||
expect(factory({ messageChannel: "discord" })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
isDiscordAccountEnabledForRuntime,
|
||||
listDiscordAccountIds,
|
||||
resolveDiscordAccount,
|
||||
} from "../accounts.js";
|
||||
import { resolveDiscordActivitiesConfig } from "./config.js";
|
||||
import { createDiscordActivityHttpHandler } from "./http.js";
|
||||
import { DiscordActivitiesRuntime, setDiscordActivitiesRuntime } from "./runtime.js";
|
||||
import { DISCORD_ACTIVITY_ROUTE_PREFIX } from "./shell.js";
|
||||
import { DiscordActivityStore, openDiscordActivityStores } from "./store.js";
|
||||
import { createDiscordWidgetTool } from "./tool.js";
|
||||
|
||||
export function registerDiscordActivities(api: OpenClawPluginApi): void {
|
||||
setDiscordActivitiesRuntime(undefined);
|
||||
const enabledAccountIds: string[] = [];
|
||||
for (const accountId of listDiscordAccountIds(api.config)) {
|
||||
const account = resolveDiscordAccount({ cfg: api.config, accountId });
|
||||
if (!isDiscordAccountEnabledForRuntime(account, api.config)) {
|
||||
continue;
|
||||
}
|
||||
const resolution = resolveDiscordActivitiesConfig(account.config);
|
||||
if (resolution.enabled) {
|
||||
enabledAccountIds.push(account.accountId);
|
||||
continue;
|
||||
}
|
||||
if (resolution.reason === "missing-client-secret") {
|
||||
api.logger.warn(
|
||||
`[discord] activities configured for account ${account.accountId}, but no client secret resolved; feature disabled`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (enabledAccountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = new DiscordActivityStore(
|
||||
openDiscordActivityStores(<T>(options: OpenKeyedStoreOptions) =>
|
||||
api.runtime.state.openKeyedStore<T>(options),
|
||||
),
|
||||
);
|
||||
const runtime = new DiscordActivitiesRuntime(
|
||||
store,
|
||||
api.config,
|
||||
api.runtime.config?.current
|
||||
? () => api.runtime.config.current() as typeof api.config
|
||||
: undefined,
|
||||
);
|
||||
setDiscordActivitiesRuntime(runtime);
|
||||
const http = createDiscordActivityHttpHandler({ runtime });
|
||||
api.registerHttpRoute({
|
||||
path: DISCORD_ACTIVITY_ROUTE_PREFIX,
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
handler: async (req, res) => await http.handleHttpRequest(req, res),
|
||||
});
|
||||
api.registerTool((context) => createDiscordWidgetTool(context, { runtime }), {
|
||||
name: "discord_widget",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
isDiscordAccountEnabledForRuntime,
|
||||
listDiscordAccountIds,
|
||||
resolveDiscordAccount,
|
||||
} from "../accounts.js";
|
||||
import { resolveDiscordProxyFetchForAccount } from "../proxy-fetch.js";
|
||||
import { resolveDiscordActivitiesConfig } from "./config.js";
|
||||
import type { DiscordActivityStore } from "./store.js";
|
||||
|
||||
type ResolvedDiscordActivityAccount = {
|
||||
accountId: string;
|
||||
applicationId: string;
|
||||
botAuth: string;
|
||||
clientSecret: string;
|
||||
config: ReturnType<typeof resolveDiscordAccount>["config"];
|
||||
proxyFetch?: typeof fetch;
|
||||
};
|
||||
|
||||
export class DiscordActivitiesRuntime {
|
||||
private readonly learnedApplicationIds = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
readonly store: DiscordActivityStore,
|
||||
private readonly startupConfig: OpenClawConfig,
|
||||
private readonly getCurrentConfig?: () => OpenClawConfig | undefined,
|
||||
private readonly env: NodeJS.ProcessEnv = process.env,
|
||||
) {}
|
||||
|
||||
currentConfig(): OpenClawConfig {
|
||||
return this.getCurrentConfig?.() ?? this.startupConfig;
|
||||
}
|
||||
|
||||
registerApplicationId(accountId: string, applicationId: string): void {
|
||||
const trimmed = applicationId.trim();
|
||||
if (trimmed) {
|
||||
this.learnedApplicationIds.set(accountId, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
resolveAccount(
|
||||
accountId: string,
|
||||
cfg = this.currentConfig(),
|
||||
): ResolvedDiscordActivityAccount | null {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
if (!isDiscordAccountEnabledForRuntime(account, cfg)) {
|
||||
return null;
|
||||
}
|
||||
const activities = resolveDiscordActivitiesConfig(account.config, this.env);
|
||||
if (!activities.enabled) {
|
||||
return null;
|
||||
}
|
||||
const applicationId =
|
||||
activities.applicationId ??
|
||||
this.learnedApplicationIds.get(account.accountId) ??
|
||||
account.config.applicationId?.trim();
|
||||
if (!applicationId) {
|
||||
return null;
|
||||
}
|
||||
const { clientSecret } = activities;
|
||||
const bot = account.token.trim();
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
applicationId,
|
||||
botAuth: bot,
|
||||
clientSecret,
|
||||
config: account.config,
|
||||
proxyFetch: resolveDiscordProxyFetchForAccount(account, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
resolveHttpAccount(applicationId?: string): ResolvedDiscordActivityAccount | null {
|
||||
const cfg = this.currentConfig();
|
||||
const accounts = listDiscordAccountIds(cfg)
|
||||
.map((accountId) => this.resolveAccount(accountId, cfg))
|
||||
.filter((account): account is ResolvedDiscordActivityAccount => account !== null);
|
||||
if (applicationId) {
|
||||
return accounts.find((account) => account.applicationId === applicationId) ?? null;
|
||||
}
|
||||
return accounts.length === 1 ? (accounts[0] ?? null) : null;
|
||||
}
|
||||
|
||||
isAccountEnabled(accountId: string, cfg = this.currentConfig()): boolean {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
return (
|
||||
isDiscordAccountEnabledForRuntime(account, cfg) &&
|
||||
resolveDiscordActivitiesConfig(account.config, this.env).enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let activeRuntime: DiscordActivitiesRuntime | undefined;
|
||||
|
||||
export function setDiscordActivitiesRuntime(runtime: DiscordActivitiesRuntime | undefined): void {
|
||||
activeRuntime = runtime;
|
||||
}
|
||||
|
||||
export function getDiscordActivitiesRuntime(): DiscordActivitiesRuntime | undefined {
|
||||
return activeRuntime;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export const DISCORD_ACTIVITY_ROUTE_PREFIX = "/discord/activity";
|
||||
|
||||
export const DISCORD_ACTIVITY_SHELL_CSP =
|
||||
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
||||
"connect-src 'self'; frame-src 'self'; img-src data:; base-uri 'none'; frame-ancestors *";
|
||||
|
||||
export const DISCORD_ACTIVITY_SHELL_HTML = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>OpenClaw widget</title><style>
|
||||
:root{color-scheme:dark;font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#1e1f22;color:#dbdee1}
|
||||
*{box-sizing:border-box}html,body,#app{height:100%;margin:0}#app{display:grid;place-items:center}main{max-width:560px;padding:32px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 8px;color:#f2f3f5}p{margin:0;color:#b5bac1;line-height:1.5}.widget{display:grid;grid-template-rows:42px 1fr;width:100%;height:100%;background:#111214}
|
||||
.bar{display:flex;align-items:center;padding:0 14px;border-bottom:1px solid #2b2d31;font-weight:600;color:#f2f3f5}.widget iframe{border:0;width:100%;height:100%;background:#111214}
|
||||
</style></head><body><div id="app"><main><h1>Opening widget</h1><p>Connecting to Discord…</p></main></div>
|
||||
<script type="module" src="./shell.js"></script></body></html>`;
|
||||
|
||||
export const DISCORD_ACTIVITY_SHELL_JS = `import { DiscordSDK } from "./vendor/embedded-app-sdk.mjs";
|
||||
|
||||
const app = document.querySelector("#app");
|
||||
function show(message, detail) {
|
||||
app.className = "";
|
||||
app.innerHTML = "";
|
||||
const main = document.createElement("main");
|
||||
const heading = document.createElement("h1");
|
||||
const paragraph = document.createElement("p");
|
||||
heading.textContent = message;
|
||||
paragraph.textContent = detail;
|
||||
main.append(heading, paragraph);
|
||||
app.append(main);
|
||||
}
|
||||
function proxiedDocUrl(value) {
|
||||
const url = new URL(value, window.location.origin);
|
||||
if (window.location.hostname.endsWith(".discordsays.com")) {
|
||||
// The ROOT mapping target already includes this prefix; strip it before proxying.
|
||||
const gatewayPrefix = "/discord/activity";
|
||||
const mappedPath = url.pathname.startsWith(gatewayPrefix + "/")
|
||||
? url.pathname.slice(gatewayPrefix.length)
|
||||
: url.pathname;
|
||||
return "/.proxy" + mappedPath + url.search;
|
||||
}
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
async function readJson(response) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(typeof body.error === "string" ? body.error : "request failed");
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
async function run() {
|
||||
const match = window.location.hostname.match(/^(\\d+)\\.discordsays\\.com$/i);
|
||||
if (!match) {
|
||||
show("Open inside Discord", "This widget must be launched from its Discord button.");
|
||||
return;
|
||||
}
|
||||
const clientId = match[1];
|
||||
const sdk = new DiscordSDK(clientId);
|
||||
await sdk.ready();
|
||||
const { code } = await sdk.commands.authorize({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
state: "",
|
||||
prompt: "none",
|
||||
scope: ["identify"],
|
||||
});
|
||||
const auth = await readJson(await fetch("./api/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
}));
|
||||
await sdk.commands.authenticate({ access_token: auth.access_token });
|
||||
const query = new URLSearchParams({
|
||||
custom_id: sdk.customId ?? "",
|
||||
instance_id: sdk.instanceId,
|
||||
});
|
||||
const widget = await readJson(await fetch("./api/widget?" + query, {
|
||||
headers: { Authorization: "Bearer " + auth.session_token },
|
||||
}));
|
||||
app.className = "widget";
|
||||
app.innerHTML = "";
|
||||
const bar = document.createElement("div");
|
||||
const frame = document.createElement("iframe");
|
||||
bar.className = "bar";
|
||||
bar.textContent = widget.title;
|
||||
frame.title = widget.title;
|
||||
// Intentionally minimal: no top-navigation, popups, or same-origin access.
|
||||
frame.setAttribute("sandbox", "allow-scripts");
|
||||
frame.referrerPolicy = "no-referrer";
|
||||
frame.src = proxiedDocUrl(widget.docUrl);
|
||||
app.append(bar, frame);
|
||||
}
|
||||
run().catch((error) => {
|
||||
if (error?.status === 403) {
|
||||
show("Not authorized", "This Discord account is not allowed to open this widget.");
|
||||
} else if (error?.status === 404) {
|
||||
show("Widget unavailable", "No widget could be resolved for this channel.");
|
||||
} else {
|
||||
show("Gateway offline", "The OpenClaw gateway could not load this widget. Try again shortly.");
|
||||
}
|
||||
});
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WIDGET_TTL_MS = 7 * DAY_MS;
|
||||
const SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
const DOC_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
type DiscordActivityWidget = {
|
||||
html: string;
|
||||
title: string;
|
||||
channelId: string;
|
||||
accountId: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type DiscordActivitySession = {
|
||||
discordUserId: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
type DiscordActivityDocToken = {
|
||||
widgetId: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
type DiscordActivityStores = {
|
||||
widgets: PluginStateKeyedStore<DiscordActivityWidget>;
|
||||
sessions: PluginStateKeyedStore<DiscordActivitySession>;
|
||||
docTokens: PluginStateKeyedStore<DiscordActivityDocToken>;
|
||||
};
|
||||
|
||||
type OpenKeyedStore = <T>(options: {
|
||||
namespace: string;
|
||||
maxEntries: number;
|
||||
overflowPolicy: "evict-oldest";
|
||||
defaultTtlMs: number;
|
||||
}) => PluginStateKeyedStore<T>;
|
||||
|
||||
export function openDiscordActivityStores(openKeyedStore: OpenKeyedStore): DiscordActivityStores {
|
||||
return {
|
||||
widgets: openKeyedStore<DiscordActivityWidget>({
|
||||
namespace: "activities-widgets",
|
||||
maxEntries: 64,
|
||||
overflowPolicy: "evict-oldest",
|
||||
defaultTtlMs: WIDGET_TTL_MS,
|
||||
}),
|
||||
sessions: openKeyedStore<DiscordActivitySession>({
|
||||
namespace: "activities-sessions",
|
||||
maxEntries: 256,
|
||||
overflowPolicy: "evict-oldest",
|
||||
defaultTtlMs: SESSION_TTL_MS,
|
||||
}),
|
||||
docTokens: openKeyedStore<DiscordActivityDocToken>({
|
||||
namespace: "activities-doc-tokens",
|
||||
maxEntries: 256,
|
||||
overflowPolicy: "evict-oldest",
|
||||
defaultTtlMs: DOC_TOKEN_TTL_MS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export class DiscordActivityStore {
|
||||
private lastWidgetCreatedAt = 0;
|
||||
|
||||
constructor(private readonly stores: DiscordActivityStores) {}
|
||||
|
||||
async createWidget(value: DiscordActivityWidget): Promise<string> {
|
||||
const id = randomBytes(16).toString("base64url");
|
||||
const createdAt = Math.max(value.createdAt, this.lastWidgetCreatedAt + 1);
|
||||
this.lastWidgetCreatedAt = createdAt;
|
||||
await this.stores.widgets.register(id, { ...value, createdAt });
|
||||
return id;
|
||||
}
|
||||
|
||||
async deleteWidget(id: string): Promise<void> {
|
||||
await this.stores.widgets.delete(id);
|
||||
}
|
||||
|
||||
async lookupWidget(id: string): Promise<DiscordActivityWidget | undefined> {
|
||||
return await this.stores.widgets.lookup(id);
|
||||
}
|
||||
|
||||
async singleWidgetForChannel(
|
||||
accountId: string,
|
||||
channelId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
widget: DiscordActivityWidget;
|
||||
} | null> {
|
||||
const entries = await this.stores.widgets.entries();
|
||||
let match: (typeof entries)[number] | undefined;
|
||||
for (const entry of entries) {
|
||||
if (entry.value.accountId !== accountId || entry.value.channelId !== channelId) {
|
||||
continue;
|
||||
}
|
||||
if (match) {
|
||||
return null;
|
||||
}
|
||||
match = entry;
|
||||
}
|
||||
return match ? { id: match.key, widget: match.value } : null;
|
||||
}
|
||||
|
||||
async createSession(value: DiscordActivitySession): Promise<string> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
await this.stores.sessions.register(token, value);
|
||||
return token;
|
||||
}
|
||||
|
||||
async lookupSession(token: string): Promise<DiscordActivitySession | undefined> {
|
||||
return await this.stores.sessions.lookup(token);
|
||||
}
|
||||
|
||||
async createDocToken(value: DiscordActivityDocToken): Promise<string> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
await this.stores.docTokens.register(token, value);
|
||||
return token;
|
||||
}
|
||||
|
||||
async consumeDocToken(token: string): Promise<DiscordActivityDocToken | undefined> {
|
||||
return await this.stores.docTokens.consume(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type {
|
||||
PluginStateEntry,
|
||||
PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { DiscordActivitiesRuntime } from "./runtime.js";
|
||||
import { DiscordActivityStore } from "./store.js";
|
||||
|
||||
type DiscordActivityWidget = NonNullable<Awaited<ReturnType<DiscordActivityStore["lookupWidget"]>>>;
|
||||
type DiscordActivitySession = NonNullable<
|
||||
Awaited<ReturnType<DiscordActivityStore["lookupSession"]>>
|
||||
>;
|
||||
type DiscordActivityDocToken = NonNullable<
|
||||
Awaited<ReturnType<DiscordActivityStore["consumeDocToken"]>>
|
||||
>;
|
||||
type DiscordActivityStores = ConstructorParameters<typeof DiscordActivityStore>[0];
|
||||
|
||||
export function createMemoryKeyedStore<T>(): PluginStateKeyedStore<T> {
|
||||
const values = new Map<string, PluginStateEntry<T>>();
|
||||
return {
|
||||
async register(key, value) {
|
||||
values.set(key, { key, value, createdAt: Date.now() });
|
||||
},
|
||||
async registerIfAbsent(key, value) {
|
||||
if (values.has(key)) {
|
||||
return false;
|
||||
}
|
||||
values.set(key, { key, value, createdAt: Date.now() });
|
||||
return true;
|
||||
},
|
||||
async update(key, updateValue) {
|
||||
const next = updateValue(values.get(key)?.value);
|
||||
if (next === undefined) {
|
||||
return values.delete(key);
|
||||
}
|
||||
values.set(key, { key, value: next, createdAt: values.get(key)?.createdAt ?? Date.now() });
|
||||
return true;
|
||||
},
|
||||
async lookup(key) {
|
||||
return values.get(key)?.value;
|
||||
},
|
||||
async consume(key) {
|
||||
const value = values.get(key)?.value;
|
||||
values.delete(key);
|
||||
return value;
|
||||
},
|
||||
async delete(key) {
|
||||
return values.delete(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...values.values()];
|
||||
},
|
||||
async clear() {
|
||||
values.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryActivityStore(): DiscordActivityStore {
|
||||
const stores: DiscordActivityStores = {
|
||||
widgets: createMemoryKeyedStore<DiscordActivityWidget>(),
|
||||
sessions: createMemoryKeyedStore<DiscordActivitySession>(),
|
||||
docTokens: createMemoryKeyedStore<DiscordActivityDocToken>(),
|
||||
};
|
||||
return new DiscordActivityStore(stores);
|
||||
}
|
||||
|
||||
export function createActivityTestConfig(params?: {
|
||||
userId?: string;
|
||||
clientSecret?: string;
|
||||
applicationId?: string;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "testtok",
|
||||
allowFrom: [params?.userId ?? "42"],
|
||||
activities: {
|
||||
...(params?.clientSecret === undefined
|
||||
? { clientSecret: "testsec" }
|
||||
: params.clientSecret
|
||||
? { clientSecret: params.clientSecret }
|
||||
: {}),
|
||||
applicationId: params?.applicationId ?? "123456789012345678",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createActivityTestRuntime(
|
||||
cfg = createActivityTestConfig(),
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
): DiscordActivitiesRuntime {
|
||||
return new DiscordActivitiesRuntime(createMemoryActivityStore(), cfg, undefined, env);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildDiscordActivityCustomId } from "../component-custom-id.js";
|
||||
import type { sendMessageDiscord } from "../send.js";
|
||||
import { createActivityTestRuntime } from "./test-helpers.test-support.js";
|
||||
import { createDiscordWidgetTool } from "./tool.js";
|
||||
|
||||
function discordContext(overrides: Partial<OpenClawPluginToolContext> = {}) {
|
||||
return {
|
||||
messageChannel: "discord",
|
||||
nativeChannelId: "987654321",
|
||||
agentAccountId: "default",
|
||||
...overrides,
|
||||
} satisfies OpenClawPluginToolContext;
|
||||
}
|
||||
|
||||
describe("discord_widget", () => {
|
||||
it("is absent outside Discord sessions", () => {
|
||||
expect(
|
||||
createDiscordWidgetTool(discordContext({ messageChannel: "slack" }), {
|
||||
runtime: createActivityTestRuntime(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("stores a wrapped widget and posts its launch button", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const send = vi.fn(async (..._args: Parameters<typeof sendMessageDiscord>) => ({
|
||||
messageId: "message-1",
|
||||
channelId: "987654321",
|
||||
receipt: {},
|
||||
}));
|
||||
const tool = createDiscordWidgetTool(discordContext(), {
|
||||
runtime,
|
||||
sendMessage: send as unknown as typeof sendMessageDiscord,
|
||||
now: () => 7,
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("expected Discord widget tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("widget-call", {
|
||||
html: "<button onclick=\"document.body.dataset.clicked='yes'\">Click</button>",
|
||||
title: "Status",
|
||||
});
|
||||
const details = result.details as { widgetId: string; messageId: string };
|
||||
const stored = await runtime.store.lookupWidget(details.widgetId);
|
||||
|
||||
expect(details.messageId).toBe("message-1");
|
||||
expect(details.widgetId).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
expect(stored).toMatchObject({
|
||||
title: "Status",
|
||||
channelId: "987654321",
|
||||
accountId: "default",
|
||||
createdAt: 7,
|
||||
});
|
||||
expect(stored?.html).toContain("<!doctype html>");
|
||||
expect(stored?.html).toContain("<button");
|
||||
const options = send.mock.calls[0]?.[2] as { components?: Array<{ serialize(): unknown }> };
|
||||
expect(send).toHaveBeenCalledWith("channel:987654321", "Status", expect.any(Object));
|
||||
expect(options.components?.[0]?.serialize()).toEqual({
|
||||
type: 1,
|
||||
components: [
|
||||
{
|
||||
type: 2,
|
||||
style: 1,
|
||||
custom_id: buildDiscordActivityCustomId(details.widgetId),
|
||||
label: "Open widget",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps full documents unchanged and rejects oversized HTML", async () => {
|
||||
const document = "<!doctype html><html><body>full</body></html>";
|
||||
const runtime = createActivityTestRuntime();
|
||||
const send = vi.fn(async (..._args: Parameters<typeof sendMessageDiscord>) => ({
|
||||
messageId: "message-1",
|
||||
channelId: "987654321",
|
||||
receipt: {},
|
||||
}));
|
||||
const tool = createDiscordWidgetTool(discordContext(), {
|
||||
runtime,
|
||||
sendMessage: send as unknown as typeof sendMessageDiscord,
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("expected Discord widget tool");
|
||||
}
|
||||
const result = await tool.execute("full-document", { html: document, title: "Full" });
|
||||
const details = result.details as { widgetId: string };
|
||||
await expect(runtime.store.lookupWidget(details.widgetId)).resolves.toMatchObject({
|
||||
html: document,
|
||||
});
|
||||
|
||||
// 49152 bytes is the 48 KiB cap mirrored from tool.ts.
|
||||
await expect(
|
||||
tool.execute("oversized", {
|
||||
html: "x".repeat(49_153),
|
||||
title: "Too large",
|
||||
}),
|
||||
).rejects.toThrow("html exceeds maximum size (49152 bytes)");
|
||||
});
|
||||
|
||||
it("leaves the widget store unchanged when posting the launch button fails", async () => {
|
||||
const runtime = createActivityTestRuntime();
|
||||
const existingId = await runtime.store.createWidget({
|
||||
html: "<p>existing</p>",
|
||||
title: "Existing",
|
||||
channelId: "987654321",
|
||||
accountId: "default",
|
||||
createdAt: 1,
|
||||
});
|
||||
const failure = new Error("send failed");
|
||||
const send = vi.fn(async () => {
|
||||
throw failure;
|
||||
}) as unknown as typeof sendMessageDiscord;
|
||||
const tool = createDiscordWidgetTool(discordContext(), { runtime, sendMessage: send });
|
||||
if (!tool) {
|
||||
throw new Error("expected Discord widget tool");
|
||||
}
|
||||
|
||||
await expect(
|
||||
tool.execute("failed-send", { html: "<p>temporary</p>", title: "Temporary" }),
|
||||
).rejects.toBe(failure);
|
||||
await expect(
|
||||
runtime.store.singleWidgetForChannel("default", "987654321"),
|
||||
).resolves.toMatchObject({ id: existingId, widget: { title: "Existing" } });
|
||||
});
|
||||
|
||||
it("requires a concrete channel target", async () => {
|
||||
const tool = createDiscordWidgetTool(discordContext({ nativeChannelId: undefined }), {
|
||||
runtime: createActivityTestRuntime(),
|
||||
sendMessage: vi.fn() as unknown as typeof sendMessageDiscord,
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("expected Discord widget tool");
|
||||
}
|
||||
await expect(
|
||||
tool.execute("missing-channel", { html: "hello", title: "No channel" }),
|
||||
).rejects.toThrow("requires a concrete Discord channel");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { AnyAgentTool, OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { Type } from "typebox";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { buildDiscordActivityCustomId } from "../component-custom-id.js";
|
||||
import { Button, Row } from "../internal/discord.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
import type { DiscordActivitiesRuntime } from "./runtime.js";
|
||||
|
||||
const DISCORD_WIDGET_HTML_MAX_BYTES = 48 * 1024;
|
||||
|
||||
const DiscordWidgetParameters = Type.Object({
|
||||
html: Type.String({ description: "Self-contained HTML document or body fragment" }),
|
||||
title: Type.String({ minLength: 1, maxLength: 80 }),
|
||||
button_label: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
||||
});
|
||||
|
||||
class DiscordWidgetInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ToolInputError";
|
||||
}
|
||||
}
|
||||
|
||||
class DiscordWidgetLaunchButton extends Button {
|
||||
constructor(
|
||||
readonly customId: string,
|
||||
readonly label: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
function currentConfig(context: OpenClawPluginToolContext, runtime: DiscordActivitiesRuntime) {
|
||||
return (
|
||||
context.getRuntimeConfig?.() ??
|
||||
context.runtimeConfig ??
|
||||
context.config ??
|
||||
runtime.currentConfig()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDiscordChannelId(context: OpenClawPluginToolContext): string | undefined {
|
||||
const raw = context.nativeChannelId?.trim() || context.deliveryContext?.to?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = raw.replace(/^channel:/i, "");
|
||||
return /^\d+$/.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildDiscordWidgetDocument(title: string, html: string): string {
|
||||
if (/^(?:<!doctype\s+html\b|<html\b)/i.test(html.trimStart())) {
|
||||
return html;
|
||||
}
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title>
|
||||
<style>:root{color-scheme:dark;background:#111214;color:#dbdee1;font:14px system-ui,sans-serif}*{box-sizing:border-box}html,body{margin:0;min-height:100%}body{padding:16px}</style></head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
type DiscordWidgetToolDeps = {
|
||||
runtime: DiscordActivitiesRuntime;
|
||||
sendMessage?: typeof sendMessageDiscord;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export function createDiscordWidgetTool(
|
||||
context: OpenClawPluginToolContext,
|
||||
deps: DiscordWidgetToolDeps,
|
||||
): AnyAgentTool | null {
|
||||
if (context.messageChannel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const cfg = currentConfig(context, deps.runtime);
|
||||
const account = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: context.agentAccountId ?? context.deliveryContext?.accountId,
|
||||
});
|
||||
if (!deps.runtime.isAccountEnabled(account.accountId, cfg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Discord Widget",
|
||||
name: "discord_widget",
|
||||
description:
|
||||
"Show an interactive HTML widget to Discord users. Posts a message with an Open widget button; the widget opens inside Discord as an Activity. HTML must be fully self-contained with inline CSS and JavaScript and no external network access.",
|
||||
parameters: DiscordWidgetParameters,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
const params = rawParams as Record<string, unknown>;
|
||||
const html = readStringParam(params, "html", { required: true, trim: false });
|
||||
const title = readStringParam(params, "title", { required: true });
|
||||
const buttonLabel = readStringParam(params, "button_label") || "Open widget";
|
||||
if (!html.trim()) {
|
||||
throw new DiscordWidgetInputError("html is required");
|
||||
}
|
||||
if (Buffer.byteLength(html, "utf8") > DISCORD_WIDGET_HTML_MAX_BYTES) {
|
||||
throw new DiscordWidgetInputError(
|
||||
`html exceeds maximum size (${DISCORD_WIDGET_HTML_MAX_BYTES} bytes)`,
|
||||
);
|
||||
}
|
||||
if (title.length > 80) {
|
||||
throw new DiscordWidgetInputError("title must be 80 characters or fewer");
|
||||
}
|
||||
if (!buttonLabel.trim() || buttonLabel.length > 80) {
|
||||
throw new DiscordWidgetInputError("button_label must be 1 to 80 characters");
|
||||
}
|
||||
const channelId = resolveDiscordChannelId(context);
|
||||
if (!channelId) {
|
||||
throw new DiscordWidgetInputError(
|
||||
"discord_widget requires a concrete Discord channel in the current session",
|
||||
);
|
||||
}
|
||||
// Persist before the button can be delivered so a launch never races an absent record;
|
||||
// roll the record back if the post fails so a failed send leaves no unreachable widget.
|
||||
const widgetId = await deps.runtime.store.createWidget({
|
||||
html: buildDiscordWidgetDocument(title, html),
|
||||
title,
|
||||
channelId,
|
||||
accountId: account.accountId,
|
||||
createdAt: (deps.now ?? Date.now)(),
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof sendMessageDiscord>>;
|
||||
try {
|
||||
result = await (deps.sendMessage ?? sendMessageDiscord)(`channel:${channelId}`, title, {
|
||||
cfg: cfg as OpenClawConfig,
|
||||
accountId: account.accountId,
|
||||
components: [
|
||||
new Row([
|
||||
new DiscordWidgetLaunchButton(
|
||||
buildDiscordActivityCustomId(widgetId),
|
||||
buttonLabel.trim(),
|
||||
),
|
||||
]),
|
||||
],
|
||||
allowedMentions: { parse: [] },
|
||||
});
|
||||
} catch (error) {
|
||||
await deps.runtime.store.deleteWidget(widgetId);
|
||||
throw error;
|
||||
}
|
||||
return jsonResult({ widgetId, messageId: result.messageId, channelId: result.channelId });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,8 +8,33 @@ import { parseCustomId, type ComponentParserResult } from "./internal/discord.js
|
||||
|
||||
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
|
||||
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
|
||||
const DISCORD_ACTIVITY_CUSTOM_ID_KEY = "ocactivity";
|
||||
const ENCODED_CUSTOM_ID_VERSION = "1";
|
||||
|
||||
export function buildDiscordActivityCustomId(widgetId: string): string {
|
||||
return `${DISCORD_ACTIVITY_CUSTOM_ID_KEY}:v=${ENCODED_CUSTOM_ID_VERSION};wid=${widgetId}`;
|
||||
}
|
||||
|
||||
export function parseDiscordActivityCustomId(id: string): { widgetId: string } | null {
|
||||
const parsed = parseCustomId(id);
|
||||
if (
|
||||
parsed.key !== DISCORD_ACTIVITY_CUSTOM_ID_KEY ||
|
||||
parsed.data.v !== ENCODED_CUSTOM_ID_VERSION ||
|
||||
typeof parsed.data.wid !== "string" ||
|
||||
!/^[A-Za-z0-9_-]{22}$/.test(parsed.data.wid)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { widgetId: parsed.data.wid };
|
||||
}
|
||||
|
||||
export function parseDiscordActivityCustomIdForInteraction(id: string): ComponentParserResult {
|
||||
const parsed = parseDiscordActivityCustomId(id);
|
||||
return parsed
|
||||
? { key: DISCORD_ACTIVITY_CUSTOM_ID_KEY, data: { widgetId: parsed.widgetId } }
|
||||
: parseCustomId(id);
|
||||
}
|
||||
|
||||
function decodeParsedCustomIdData(
|
||||
data: ComponentParserResult["data"],
|
||||
): ComponentParserResult["data"] {
|
||||
|
||||
@@ -373,4 +373,17 @@ export const discordChannelConfigUiHints = {
|
||||
label: "Discord Application ID",
|
||||
help: "Optional Discord application/client ID. Set this when hosted environments cannot reach Discord's application lookup endpoint during startup.",
|
||||
},
|
||||
activities: {
|
||||
label: "Discord Activities",
|
||||
help: "Enable Discord Activity widgets for this account. Routes, the agent tool, and the launch handler remain disabled when this block is absent.",
|
||||
},
|
||||
"activities.clientSecret": {
|
||||
label: "Discord Activities Client Secret",
|
||||
help: "OAuth2 client secret for the Discord application. DISCORD_CLIENT_SECRET is used when this field is unset.",
|
||||
sensitive: true,
|
||||
},
|
||||
"activities.applicationId": {
|
||||
label: "Discord Activities Application ID",
|
||||
help: "Optional Activity application ID. Defaults to the bot application ID learned at gateway startup.",
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
|
||||
@@ -280,6 +280,9 @@ export class BaseComponentInteraction extends BaseInteraction {
|
||||
async showModal(modal: Modal): Promise<unknown> {
|
||||
return await this.callback(InteractionResponseType.Modal, modal.serialize());
|
||||
}
|
||||
async launchActivity(): Promise<unknown> {
|
||||
return await this.callback(InteractionResponseType.LaunchActivity);
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonInteraction extends BaseComponentInteraction {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runti
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createDiscordActivityButton } from "../activities/interaction.js";
|
||||
import {
|
||||
getDiscordExecApprovalApprovers,
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
@@ -34,6 +35,7 @@ export function createDiscordProviderInteractionSurface(params: {
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordAccountConfig;
|
||||
accountId: string;
|
||||
applicationId?: string;
|
||||
token: string;
|
||||
commandSpecs: NativeCommandSpec[];
|
||||
nativeEnabled: boolean;
|
||||
@@ -131,6 +133,22 @@ export function createDiscordProviderInteractionSurface(params: {
|
||||
threadBindings: params.threadBindings,
|
||||
}),
|
||||
];
|
||||
const activityButton = createDiscordActivityButton(
|
||||
{
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
guildEntries: params.guildEntries,
|
||||
allowFrom: params.allowFrom,
|
||||
dmPolicy: params.dmPolicy,
|
||||
runtime: params.runtime,
|
||||
token: params.token,
|
||||
},
|
||||
params.applicationId,
|
||||
);
|
||||
if (activityButton) {
|
||||
components.push(activityButton);
|
||||
}
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (approvalActionsEnabled) {
|
||||
|
||||
@@ -301,6 +301,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
applicationId,
|
||||
token,
|
||||
commandSpecs,
|
||||
nativeEnabled,
|
||||
@@ -319,7 +320,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
abortSignal: opts.abortSignal,
|
||||
createNativeCommand: discordProviderRuntime.createDiscordNativeCommand,
|
||||
});
|
||||
|
||||
const {
|
||||
client,
|
||||
gateway,
|
||||
|
||||
Generated
+56
@@ -708,6 +708,9 @@ importers:
|
||||
|
||||
extensions/discord:
|
||||
dependencies:
|
||||
'@discord/embedded-app-sdk':
|
||||
specifier: 2.5.0
|
||||
version: 2.5.0
|
||||
'@discordjs/voice':
|
||||
specifier: 0.19.2
|
||||
version: 0.19.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
|
||||
@@ -2806,6 +2809,9 @@ packages:
|
||||
'@d-fischer/typed-event-emitter@3.3.3':
|
||||
resolution: {integrity: sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==}
|
||||
|
||||
'@discord/embedded-app-sdk@2.5.0':
|
||||
resolution: {integrity: sha512-FNoe5PbSkoKEbqPubaBmq6tlEMOftMjR3gu55YrdrrKmBfKKM4i9P/Z+Zxl0RZdJcKviBwVcTyYMMPM/aGex/w==}
|
||||
|
||||
'@discordjs/voice@0.19.2':
|
||||
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
@@ -4715,6 +4721,12 @@ packages:
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/lodash.transform@4.6.9':
|
||||
resolution: {integrity: sha512-1iIn+l7Vrj8hsr2iZLtxRkcV9AtjTafIyxKO9DX2EEcdOgz3Op5dhwKQFhMJgdfIRbYHBUF+SU97Y6P+zyLXNg==}
|
||||
|
||||
'@types/lodash@4.17.24':
|
||||
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
@@ -4781,6 +4793,9 @@ packages:
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/uuid@10.0.0':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
@@ -5184,6 +5199,10 @@ packages:
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
@@ -5483,6 +5502,9 @@ packages:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -6443,6 +6465,9 @@ packages:
|
||||
lodash.pickby@4.6.0:
|
||||
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
|
||||
|
||||
lodash.transform@4.6.0:
|
||||
resolution: {integrity: sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7986,6 +8011,10 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@14.0.0:
|
||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-name@7.0.2:
|
||||
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
@@ -9171,6 +9200,17 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@discord/embedded-app-sdk@2.5.0':
|
||||
dependencies:
|
||||
'@types/lodash.transform': 4.6.9
|
||||
'@types/uuid': 10.0.0
|
||||
big-integer: 1.6.52
|
||||
decimal.js-light: 2.5.1
|
||||
eventemitter3: 5.0.4
|
||||
lodash.transform: 4.6.0
|
||||
uuid: 14.0.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@discordjs/voice@0.19.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
dependencies:
|
||||
'@snazzah/davey': 0.1.12(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
|
||||
@@ -10970,6 +11010,12 @@ snapshots:
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/lodash.transform@4.6.9':
|
||||
dependencies:
|
||||
'@types/lodash': 4.17.24
|
||||
|
||||
'@types/lodash@4.17.24': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
@@ -11036,6 +11082,8 @@ snapshots:
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/uuid@10.0.0': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 26.1.0
|
||||
@@ -11479,6 +11527,8 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
birpc@4.0.0: {}
|
||||
@@ -11771,6 +11821,8 @@ snapshots:
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
@@ -12846,6 +12898,8 @@ snapshots:
|
||||
|
||||
lodash.pickby@4.6.0: {}
|
||||
|
||||
lodash.transform@4.6.0: {}
|
||||
|
||||
log-symbols@7.0.1:
|
||||
dependencies:
|
||||
is-unicode-supported: 2.1.0
|
||||
@@ -14731,6 +14785,8 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@14.0.0: {}
|
||||
|
||||
validate-npm-package-name@7.0.2:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
const repoRoot = path.resolve(path.dirname(modulePath), "..");
|
||||
const discordDir = path.join(repoRoot, "extensions/discord");
|
||||
const outputPath = path.join(repoRoot, "extensions/discord/assets/embedded-app-sdk.mjs");
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await build({
|
||||
entryPoints: ["@discord/embedded-app-sdk"],
|
||||
absWorkingDir: discordDir,
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
target: "es2020",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
legalComments: "none",
|
||||
outfile: outputPath,
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -67,4 +67,24 @@ describe("realredactConfigSnapshot_real", () => {
|
||||
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts Discord Activity client secrets registered on plain string schemas", () => {
|
||||
const hints = buildConfigSchema().uiHints;
|
||||
expect(hints["channels.discord.activities.clientSecret"]?.sensitive).toBe(true);
|
||||
const snapshot = makeSnapshot({
|
||||
channels: {
|
||||
discord: {
|
||||
activities: {
|
||||
clientSecret: "cfgsec",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = redactConfigSnapshot(snapshot, hints);
|
||||
const channels = result.config.channels as Record<string, Record<string, unknown>>;
|
||||
const discord = expectDefined(channels.discord, "channels.discord test invariant");
|
||||
const activities = discord.activities as Record<string, unknown>;
|
||||
expect(activities.clientSecret).toBe(REDACTED_SENTINEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js";
|
||||
import { NODE_CAPABILITY_FIELD_HELP } from "./schema.node-capabilities.js";
|
||||
import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js";
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
"channels.discord.activities":
|
||||
"Discord Activities configuration for launching interactive HTML widgets inside Discord. Leave unset to keep all Activity routes, tools, and handlers disabled.",
|
||||
"channels.discord.activities.clientSecret":
|
||||
"OAuth2 client secret for the Discord application that hosts Activities. Keep this value secret; DISCORD_CLIENT_SECRET is used when this field is unset.",
|
||||
"channels.discord.activities.applicationId":
|
||||
"Optional Discord application ID for Activities. Defaults to the bot application ID learned from Discord at gateway startup.",
|
||||
meta: "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.",
|
||||
"meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.",
|
||||
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
|
||||
|
||||
@@ -3,6 +3,9 @@ import { MEDIA_AUDIO_FIELD_LABELS } from "./media-audio-field-metadata.js";
|
||||
import { NODE_CAPABILITY_FIELD_LABELS } from "./schema.node-capabilities.js";
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.activities": "Discord Activities",
|
||||
"channels.discord.activities.clientSecret": "Discord Activities Client Secret",
|
||||
"channels.discord.activities.applicationId": "Discord Activities Application ID",
|
||||
meta: "Metadata",
|
||||
"meta.lastTouchedVersion": "Config Last Touched Version",
|
||||
"meta.lastTouchedAt": "Config Last Touched At",
|
||||
|
||||
@@ -338,6 +338,7 @@ export type DiscordAccountConfig = {
|
||||
token?: SecretInput;
|
||||
/** Optional Discord application/client ID. Set this when REST application lookup is blocked. */
|
||||
applicationId?: string;
|
||||
activities?: { clientSecret?: string; applicationId?: string };
|
||||
/** HTTP(S) proxy URL for Discord gateway WebSocket connections. */
|
||||
proxy?: string;
|
||||
/** Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default: 30000. */
|
||||
|
||||
@@ -617,6 +617,13 @@ const DiscordAccountSchema = z
|
||||
configWrites: z.boolean().optional(),
|
||||
token: SecretInputSchema.optional().register(sensitive),
|
||||
applicationId: DiscordIdSchema.optional(),
|
||||
activities: z
|
||||
.object({
|
||||
clientSecret: z.string().min(1).optional().register(sensitive),
|
||||
applicationId: DiscordSnowflakeStringSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
proxy: z.string().optional(),
|
||||
gatewayInfoTimeoutMs: z.number().int().positive().max(120_000).optional(),
|
||||
gatewayReadyTimeoutMs: z.number().int().positive().max(120_000).optional(),
|
||||
|
||||
@@ -55,6 +55,12 @@ const INDIRECT_RUNTIME_DEPENDENCIES = new Map<string, Set<string>>([
|
||||
],
|
||||
]);
|
||||
const COMPUTED_RUNTIME_DEPENDENCIES = new Map<string, Set<string>>([
|
||||
[
|
||||
"extensions/discord",
|
||||
// Bundled at build time into the served Discord Activity shell asset rather than
|
||||
// imported by plugin runtime code; see scripts/build-discord-activity-sdk.mjs.
|
||||
new Set(["@discord/embedded-app-sdk"]),
|
||||
],
|
||||
[
|
||||
"extensions/lobster",
|
||||
// Keep Lobster external to the plugin bundle; its computed core import is resolved at runtime.
|
||||
|
||||
@@ -39,6 +39,33 @@ async function withPluginAssetFixture(run: (rootDir: string) => Promise<void>) {
|
||||
}
|
||||
|
||||
describe("bundled plugin assets", () => {
|
||||
it("resolves the Discord SDK entry from the package that declares it", () => {
|
||||
const script = fs.readFileSync(
|
||||
path.join(process.cwd(), "scripts/build-discord-activity-sdk.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(script).toContain('const discordDir = path.join(repoRoot, "extensions/discord")');
|
||||
expect(script).toContain("absWorkingDir: discordDir");
|
||||
});
|
||||
|
||||
it("discovers the Discord Embedded App SDK build hook", async () => {
|
||||
const hooks = await readBundledPluginAssetHooks({
|
||||
phase: "build",
|
||||
plugins: ["discord"],
|
||||
rootDir: process.cwd(),
|
||||
});
|
||||
|
||||
expect(hooks).toMatchObject([
|
||||
{
|
||||
command: "node ../../scripts/build-discord-activity-sdk.mjs",
|
||||
packageName: "@openclaw/discord",
|
||||
phase: "build",
|
||||
pluginId: "discord",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("discovers plugin-owned asset scripts by manifest id", async () => {
|
||||
await withPluginAssetFixture(async (rootDir) => {
|
||||
const hooks = await readBundledPluginAssetHooks({
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("runtime postbuild static assets", () => {
|
||||
"dist/extensions/acpx/mcp-proxy.mjs",
|
||||
"dist/extensions/diffs-language-pack/assets/viewer-runtime.js",
|
||||
"dist/extensions/diffs/assets/viewer-runtime.js",
|
||||
"dist/extensions/discord/assets/embedded-app-sdk.mjs",
|
||||
"dist/extensions/vault/vault-secret-id.js",
|
||||
"dist/extensions/vault/vault-secret-ref-resolver.js",
|
||||
]);
|
||||
@@ -64,11 +65,13 @@ describe("runtime postbuild static assets", () => {
|
||||
"dist/extensions/acpx/mcp-proxy.mjs",
|
||||
"dist/extensions/diffs-language-pack/assets/viewer-runtime.js",
|
||||
"dist/extensions/diffs/assets/viewer-runtime.js",
|
||||
"dist/extensions/discord/assets/embedded-app-sdk.mjs",
|
||||
"dist/extensions/vault/vault-secret-id.js",
|
||||
"dist/extensions/vault/vault-secret-ref-resolver.js",
|
||||
]);
|
||||
expect(payload.sources).toContain("extensions/diffs-language-pack/assets/viewer-runtime.js");
|
||||
expect(payload.sources).toContain("extensions/diffs/assets/viewer-runtime.js");
|
||||
expect(payload.sources).toContain("extensions/discord/assets/embedded-app-sdk.mjs");
|
||||
});
|
||||
|
||||
it("discovers static assets from plugin package metadata", async () => {
|
||||
|
||||
Reference in New Issue
Block a user