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:
Peter Steinberger
2026-07-15 04:07:46 -07:00
committed by GitHub
parent 968c6581b6
commit ddc9ec3f36
47 changed files with 2856 additions and 32 deletions
+1
View File
@@ -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/
+1
View File
@@ -55,6 +55,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
"@a2ui/lit",
"@azure/identity",
"@clawdbot/lobster",
"@discord/embedded-app-sdk",
"@discordjs/opus",
"@homebridge/ciao",
"@lit/context",
+4 -4
View File
@@ -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
+124
View File
@@ -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.
+11 -1
View File
@@ -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>
+1
View File
@@ -1143,6 +1143,7 @@
"group": "Mainstream messaging",
"pages": [
"channels/discord",
"channels/discord-activities",
"channels/slack",
"channels/telegram",
"channels/whatsapp",
+14
View File
@@ -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
+9
View File
@@ -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);
}
+2
View File
@@ -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);
},
+87
View File
@@ -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"
}
}
}
}
+15 -4
View File
@@ -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",
+11 -1
View File
@@ -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,
+1 -1
View File
@@ -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);
});
});
+364
View File
@@ -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;
}
+103
View File
@@ -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.");
}
});
`;
+124
View File
@@ -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");
});
});
+147
View File
@@ -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"] {
+13
View File
@@ -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) {
+1 -1
View File
@@ -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,
+56
View File
@@ -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
+24
View File
@@ -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
+20
View File
@@ -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);
});
});
+6
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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. */
+7
View File
@@ -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({
+3
View File
@@ -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 () => {