fix(widgets): route show_widget through Discord Activities (#126294)

* refactor(widgets): unify Discord presentation

* fix(discord): keep incomplete Activity routes private

* fix(discord): require usable Activity accounts

* docs(discord): clarify hidden Activity routes
This commit is contained in:
Peter Steinberger
2026-08-19 05:41:37 -07:00
committed by GitHub
parent 7a82d8b0f2
commit 97557ec3f5
51 changed files with 2008 additions and 1001 deletions
-1
View File
@@ -335,7 +335,6 @@ extensions/discord/src/activities/discord-api.ts 1
extensions/discord/src/activities/http.ts 1
extensions/discord/src/activities/register.ts 1
extensions/discord/src/activities/store.ts 1
extensions/discord/src/activities/tool.ts 2
extensions/discord/src/api.ts 3
extensions/discord/src/approval-handler.runtime.ts 4
extensions/discord/src/approval-native.ts 1
+6 -4
View File
@@ -7,7 +7,7 @@ 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, the `show_widget` agent tool, and the launch-button handler only when `channels.discord.activities` is present and a client secret resolves. The deprecated `discord_widget` alias remains available for one release.
The feature is off by default. `show_widget` remains one core-owned tool. When `channels.discord.activities` is present and a client secret resolves, the Discord Activity routes, launch-button handler, and current-channel presenter become available behind that tool. Without the block, requests to the public Activity prefix remain indistinguishable from an unregistered route. No Discord-specific widget tool or alias exists.
## Prerequisites
@@ -86,6 +86,8 @@ Keep normal gateway authentication enabled. Only the Activity prefix is public,
</Step>
</Steps>
Core validates and wraps the widget document before handing it to Discord. The presenter accepts HTML source up to 48 KiB, stores the canonical composed document, and always labels the Activity button **Open widget**. The standard `show_widget` pin, name, tab, size, frame, ordering, and capability fields remain available because dashboard state stays core-owned. Registered non-HTML widget kinds are not offered when Discord is the only available presentation route.
## Security model
- OAuth identifies the Discord user before widget metadata is returned.
@@ -95,7 +97,7 @@ Keep normal gateway authentication enabled. Only the Activity prefix is public,
- 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.
- When Activities is disabled or its required account credentials are unavailable, the route remains registered internally but public requests under `/discord/activity` are left unhandled and return the normal 404.
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.
@@ -106,7 +108,7 @@ The public Activity shell and token-exchange route become reachable through your
- 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
- confirm the Discord bot token and Activities client secret both resolve in the running gateway; incomplete credentials keep `/discord/activity` externally hidden behind the normal 404
### Discord opens a blank page or reports `blocked:csp`
@@ -122,4 +124,4 @@ Launch the button from the channel where the agent posted it. OpenClaw tracks la
### “You cannot launch Activities in this channel”
Discord does not launch Activities from forum-post threads. OpenClaw can post the widget message and button there, but launch the Activity from a regular text channel instead. This restriction comes from Discord, not OpenClaw.
Discord does not launch Activities from forum-style channels. OpenClaw rejects the Activity component delivery there instead of posting a button that cannot work. Ask for the widget from a regular text channel instead.
+1 -1
View File
@@ -1700,7 +1700,7 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
### 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.
Set `channels.discord.activities` to let the core `show_widget` tool post self-contained HTML widgets that open inside Discord. The block is opt-in. Discord registers the Activity plumbing statically, but the current-channel presenter stays unavailable and `/discord/activity` remains externally hidden behind the normal 404 until an enabled account has an available bot token, resolved client secret, and application ID. 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
+1 -1
View File
@@ -16,7 +16,7 @@ OpenClaw Discord channel plugin for channels, DMs, commands, and app events.
## Surface
channels: `discord`; contracts: `tools`, `transcriptSourceProviders`; skills
channels: `discord`; contracts: `transcriptSourceProviders`; skills
## Related docs
+4 -2
View File
@@ -164,9 +164,11 @@ or fully dynamic tool registration.
| `api.registerTool(tool, opts?)` | Agent tool (required or `{ optional: true }`) |
| `api.registerCommand(def)` | Custom command (bypasses the LLM) |
| `api.registerNodeHostCommand(command)` | Command handled by `openclaw node run`; optional `agentTool` metadata can expose it as an agent-visible tool while the node is connected |
| `api.registerWidgetPresenter(presenter)` | Destination that can present a hosted `show_widget` document |
| `api.registerWidgetPresenter(presenter)` | Explicit or current-channel destination behind the core `show_widget` tool |
Widget presenters declare a model-visible target, a short description, current availability, and a `present(...)` callback. Return the closed presentation result codes (`no_eligible_node` or `node_error`) instead of throwing for expected device failures; core then keeps the widget available inline and gives the agent a recovery step.
Explicit widget presenters declare a unique model-visible target such as `node_panel`. Current-channel presenters use `target: "current_channel"`, provide a synchronous `match(context)` predicate over trusted delivery facts, and declare supported source kinds and delivery limits. Multiple transport presenters may coexist, but core selects an implicit route only when exactly one matches.
Core validates the canonical `show_widget` schema, composes the bounded HTML document, and passes immutable HTML plus an optional hosted URL to `present(...)`. Presenters return either a generic message receipt or a node receipt. Expected availability and presentation failures use the closed error result instead of throwing; core falls back inline only for an actual `inline-widgets` client and otherwise surfaces the failure.
Computer Use providers use `registerComputerUseProvider(api, provider)` from
`openclaw/plugin-sdk/computer-use`. It registers the shared
+11 -11
View File
@@ -13,7 +13,9 @@ read_when:
## How widgets work
When the agent calls `show_widget`, OpenClaw core wraps `widget_code` in a minimal HTML document, stores it as a Canvas document, and returns a preview handle. The Control UI renders that handle in a sandboxed iframe, while iOS, Android, macOS, and Linux Quick Chat use isolated web views. Full chat clients restore the widget after history reload; Quick Chat keeps the widget for its active reply.
When the agent calls `show_widget`, OpenClaw core validates `widget_code` and wraps it once in the canonical HTML document. For an inline client, core stores that document as a Canvas document and returns a preview handle. The Control UI renders the handle in a sandboxed iframe, while iOS, Android, macOS, and Linux Quick Chat use isolated web views. Full chat clients restore the widget after history reload; Quick Chat keeps the widget for its active reply.
Channel plugins can register a contextual presenter behind the same core tool. In a configured Discord session, core hands the composed document to the Discord presenter, which stores it and posts the Activity button in the current channel. The model still makes one `show_widget` call; there is no transport-specific widget tool or content kind.
In Control UI sessions, a Canvas widget can also be pinned to the session dashboard. Set `pin: true` in the tool call, or use **Pin to dashboard** on an existing transcript widget. Pinned HTML runs behind the same dedicated-origin, double-iframe sandbox host used by MCP Apps; the browser never resolves a widget data binding inside the untrusted frame.
@@ -26,9 +28,9 @@ For browser embedding, the wrapper document injects four small host bridges arou
Everything else stays inside the frame: the document runs in an opaque origin with a strict Content Security Policy, so widget scripts cannot reach the Control UI, the Gateway, or the network.
The core implementation is available only when the originating Gateway client declares the `inline-widgets` capability. The Control UI and supported native apps declare this capability automatically. Linux Quick Chat stays text-only for Gateway connections that require a custom TLS leaf pin because its platform WebView cannot bind that pin. The Discord implementation is available only in Discord sessions with Activities configured. Other channel runs do not receive `show_widget`.
OpenClaw exposes `show_widget` only when the originating Gateway client declares the `inline-widgets` capability or exactly one registered current-channel presenter synchronously matches trusted run context. The Control UI and supported native apps declare the inline capability automatically. Linux Quick Chat stays text-only for Gateway connections that require a custom TLS leaf pin because its platform WebView cannot bind that pin. Discord matches only when Activities are configured for the current account and a concrete channel is available. Other channel runs without an inline client or matching presenter do not receive the tool.
Capability transport covers embedded, Codex app-server, and CLI-backed model backends. Grant-authenticated MCP callers and direct HTTP tool-invoke callers remain fail closed because they do not declare client capabilities.
Capability transport covers embedded, Codex app-server, and CLI-backed model backends. Grant-authenticated MCP callers without `inline-widgets` remain fail closed unless their trusted run context matches a presenter. Authenticated direct HTTP `tools/invoke` requests cannot request inline rendering, but a request carrying eligible current-channel context can use the matching presenter. Authentication never bypasses presenter or route eligibility.
## Design system
@@ -77,19 +79,17 @@ Author widgets with three rules:
## Use the tool
Both implementations use the same required fields:
The core tool uses these required fields on every destination:
<ParamField path="title" type="string" required>
Short title shown with the inline preview and in the hosted document title.
Short title shown with the inline preview and in the hosted document title. Discord accepts up to 80 characters.
</ParamField>
<ParamField path="widget_code" type="string" required>
Self-contained HTML or SVG. For inline-widget clients, input beginning with `<svg` after trimming is rendered in SVG mode; maximum length is 262,144 characters. Discord accepts a complete HTML document or body fragment up to 48 KiB.
Self-contained HTML or SVG. For inline-widget clients, input beginning with `<svg` after trimming is rendered in SVG mode; maximum length is 262,144 characters. The Discord presenter accepts HTML source up to 48 KiB. A Discord-only route does not advertise or accept registered non-HTML content kinds.
</ParamField>
Discord also accepts optional `button_label` text for the Activity launch button. The Canvas schema intentionally omits this Discord-only field.
The core Canvas tool accepts these optional dashboard placement fields:
The core tool also accepts these optional dashboard placement fields, including when Discord is the presentation destination:
- `pin`: also place the widget on the session dashboard.
- `name`: stable widget name; defaults to a slug of `title`.
@@ -99,9 +99,9 @@ The core Canvas tool accepts these optional dashboard placement fields:
- `after`: sibling widget name after which to place the widget.
- `capabilities`: access requested by a pinned widget. `netOrigins` contains exact HTTPS origins; `tools` contains `prompt`, an allowlisted read binding, or an exact `cron.trigger:<jobId>` action.
The core result includes a Canvas preview handle, so the Control UI and supported native apps render the widget directly from the tool call and restore it after history reload. Pinned results also retain the board widget name so the Control UI does not offer a duplicate pin after transcript reload. Discord returns the stored widget and posted-message identifiers.
An inline result includes a Canvas preview handle, so the Control UI and supported native apps render the widget directly from the tool call and restore it after history reload. A successful current-channel presentation returns a generic message receipt describing what became visible. Pinned results retain the board widget name so the Control UI does not offer a duplicate pin after transcript reload.
`discord_widget` remains registered as a deprecated alias for one release. New agent calls should use `show_widget`.
If current-channel presentation fails, core falls back inline only when the originating client actually supports inline widgets. Otherwise the tool fails visibly. When `pin: true` succeeded before presentation failed, the result is explicitly partial and names the durable board widget; presentation failure never rolls back that unrelated board state.
## Show on a device
+1 -1
View File
@@ -616,7 +616,7 @@ Web Push is independent of the iOS APNS relay path (see [Configuration](/gateway
Assistant messages can render hosted web content inline with the `[embed ...]` shortcode. The iframe sandbox policy is controlled by `gateway.controlUi.embedSandbox`:
The core [`show_widget`](/tools/show-widget) tool renders self-contained SVG or HTML directly from a tool call. The browser and supported native chat clients advertise the `inline-widgets` Gateway capability, and the resulting Canvas document remains available when chat history reloads. Discord Activities provide the same tool name on Discord; other channel-originated runs do not receive it.
The core [`show_widget`](/tools/show-widget) tool renders self-contained SVG or HTML directly from a tool call. The browser and supported native chat clients advertise the `inline-widgets` Gateway capability, and the resulting Canvas document remains available when chat history reloads. Channel plugins such as Discord Activities can register contextual presenters behind that same tool. Channel-originated runs without an eligible presenter or inline client do not receive it.
<Tabs>
<Tab title="strict">
+10 -7
View File
@@ -55,9 +55,11 @@ Principles:
## UX flows
- **Graduation:** agent calls `show_widget` in any chat → widget renders inline
in the transcript exactly as today → hover shows **Pin to dashboard** → widget
appears on the session's board. The agent can pass `pin: true` to do the same.
- **Graduation:** agent calls `show_widget` from an inline-capable chat → widget
renders in the transcript → hover shows **Pin to dashboard** → widget appears
on the session's board. The agent can pass `pin: true` to do the same. A
channel presenter can instead make the same core document visible on the
current transport.
- **Board view:** a session with a board gets a view switch (Chat / Split /
Dashboard). Split = tab strip (only when >1 tab) + fluid grid + docked chat
pane; Dashboard is the same without the chat. The chat dock is resizable and
@@ -335,11 +337,11 @@ Widget bytes are served over the authenticated HTTP surface, not the socket.
## Agent tools
Three tools total (core, always registered; rendering gated on the
`inline-widgets` client cap as today):
Three tools total (core; `show_widget` is exposed only for an `inline-widgets`
client or one unambiguous matching current-channel presenter):
- `show_widget { title, widget_code, kind?, name?, pin?, size?, tab?, after?,
capabilities? }` — create/update by name; `kind` defaults to `html` and its enum
presentation?, capabilities? }` — create/update by name; `kind` defaults to `html` and its enum
includes active registered kinds; `pin` places it on the board.
Without `name`/`pin` it behaves exactly like today (inline, ephemeral).
- `dashboard { action, ... }` — board management verbs: `read`, `tab_create`,
@@ -363,7 +365,8 @@ false`, never in a stable release (first appeared in 2026.7.2 betas). No
(`src/canvas/`); the plugin keeps the node-canvas control tool (`canvas`) and
A2UI. The `pluginSurfaceUrls["canvas"]` advertisement and
`/__openclaw__/canvas` paths are shipped native-client contracts and stay
stable. Discord sessions keep the Discord-owned `show_widget` variant.
stable. Discord Activities register a contextual presenter behind core's
canonical `show_widget` tool.
## Non-goals (this program)
+21 -9
View File
@@ -39,13 +39,17 @@ describe("Canvas widget presenter", () => {
await expect(
presenter.present({
documentUrlPath: "/__openclaw__/canvas/documents/cv_1/index.html",
document: {
kind: "html",
html: "<p>Status</p>",
hostedUrl: "/__openclaw__/canvas/documents/cv_1/index.html",
},
title: "Status",
sessionContext: { sessionKey: "agent:main:status" },
context: { sessionKey: "agent:main:status" },
}),
).resolves.toEqual({
ok: true,
value: { nodeId: "mac-local", nodeName: "Studio" },
value: { kind: "node", nodeId: "mac-local", nodeName: "Studio" },
});
expect(runtime.invoke).toHaveBeenNthCalledWith(
1,
@@ -90,9 +94,13 @@ describe("Canvas widget presenter", () => {
const presenter = createCanvasWidgetPresenter(runtime);
await expect(
presenter.present({
documentUrlPath: "/__openclaw__/canvas/documents/cv_2/index.html",
document: {
kind: "html",
html: "<p>Status</p>",
hostedUrl: "/__openclaw__/canvas/documents/cv_2/index.html",
},
title: "Status",
sessionContext: {},
context: {},
}),
).resolves.toEqual({
ok: false,
@@ -127,9 +135,9 @@ describe("Canvas widget presenter", () => {
});
const result = await createCanvasWidgetPresenter(runtime).present({
documentUrlPath,
document: { kind: "html", html: "<p>Status</p>", hostedUrl: documentUrlPath },
title: "Status",
sessionContext: {},
context: {},
});
expect(result).toEqual({
@@ -160,9 +168,13 @@ describe("Canvas widget presenter", () => {
});
await expect(
presenter.present({
documentUrlPath: "/__openclaw__/canvas/documents/cv_linux/index.html",
document: {
kind: "html",
html: "<p>Status</p>",
hostedUrl: "/__openclaw__/canvas/documents/cv_linux/index.html",
},
title: "Status",
sessionContext: {},
context: {},
}),
).resolves.toMatchObject({
ok: false,
+13 -3
View File
@@ -53,7 +53,16 @@ export function createCanvasWidgetPresenter(nodesRuntime: PluginRuntime["nodes"]
};
}
},
async present({ documentUrlPath, sessionContext }) {
async present({ document, context }) {
if (!document.hostedUrl) {
return {
ok: false,
error: {
code: "node_error",
message: "The widget document is not hosted for device presentation.",
},
};
}
let node: CanvasRuntimeNode | null;
try {
node = await selectCanvasNode(nodesRuntime);
@@ -76,14 +85,15 @@ export function createCanvasWidgetPresenter(nodesRuntime: PluginRuntime["nodes"]
await nodesRuntime.invoke({
nodeId: node.nodeId,
command: "canvas.present",
params: { url: documentUrlPath },
params: { url: document.hostedUrl },
timeoutMs: DEFAULT_CANVAS_NODE_INVOKE_TIMEOUT_MS,
idempotencyKey: randomUUID(),
...(sessionContext.sessionKey ? { sessionKey: sessionContext.sessionKey } : {}),
...(context.sessionKey ? { sessionKey: context.sessionKey } : {}),
});
return {
ok: true,
value: {
kind: "node",
nodeId: node.nodeId,
...(node.displayName ? { nodeName: node.displayName } : {}),
},
-4
View File
@@ -17,10 +17,6 @@
"discord"
],
"contracts": {
"tools": [
"show_widget",
"discord_widget"
],
"transcriptSourceProviders": [
"discord-voice"
]
+32 -9
View File
@@ -3,6 +3,7 @@ import { createServer, request as createHttpRequest, type Server } from "node:ht
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildDiscordActivityCustomId } from "../component-custom-id.js";
@@ -11,7 +12,6 @@ import { DiscordActivitiesRuntime } from "./runtime.js";
import {
createActivityTestConfig,
createActivityTestRuntime,
createMemoryActivityStore,
} from "./test-helpers.test-support.js";
const servers: Server[] = [];
@@ -244,6 +244,37 @@ function createWidgetFixture(
}
describe("Discord Activity HTTP OAuth", () => {
it.each([
{
name: "Activities are unconfigured",
config: { channels: { discord: { token: "testtok" } } },
},
{
name: "the client secret is unresolved",
config: createActivityTestConfig({ clientSecret: "" }),
},
{
name: "the bot token is unresolved",
config: {
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
activities: {
clientSecret: "testsec",
applicationId: "123456789012345678",
},
},
},
} as unknown as OpenClawConfig,
},
])("leaves the public prefix externally absent when $name", async ({ config }) => {
const base = await startServer(createActivityTestRuntime(config));
const response = await fetch(`${base}/discord/activity/`);
await expect(response.text()).resolves.toBe("not found");
expect(response.status).toBe(404);
});
it("terminates stalled token request bodies within the read timeout", async () => {
const base = await startServer(createActivityTestRuntime(), { bodyTimeoutMs: 25 });
@@ -355,14 +386,6 @@ describe("Discord Activity HTTP OAuth", () => {
await expect(widgetResponse.json()).resolves.toMatchObject({ id: widgetId });
});
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 requestToken(base);
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 });
await requestTokens(
@@ -357,6 +357,11 @@ export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps):
) {
return false;
}
// Keep the public prefix indistinguishable from an unregistered route until
// at least one current account enables Activities.
if (!deps.runtime.hasEnabledAccounts()) {
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", {
@@ -0,0 +1,231 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { buildDiscordActivityCustomId } from "../component-custom-id.js";
import type { sendDiscordComponentMessage } from "../send.components.js";
import { createDiscordSendReceipt } from "../send.receipt.js";
import { createDiscordWidgetPresenter } from "./presenter.js";
import {
createActivityTestConfig,
createActivityTestRuntime,
} from "./test-helpers.test-support.js";
type WidgetPresenter = Parameters<OpenClawPluginApi["registerWidgetPresenter"]>[0];
type WidgetPresenterContext = Parameters<WidgetPresenter["availability"]>[0];
type SendResult = Awaited<ReturnType<typeof sendDiscordComponentMessage>>;
function discordContext(overrides: Partial<WidgetPresenterContext> = {}): WidgetPresenterContext {
return {
messageChannel: "discord",
nativeChannelId: "987654321",
accountId: "default",
sessionKey: "agent:main:discord",
...overrides,
};
}
function sendResult(messageId = "1000000000000000001"): SendResult {
return {
messageId,
channelId: "987654321",
receipt: createDiscordSendReceipt({
platformMessageIds: [messageId],
channelId: "987654321",
kind: "card",
}),
};
}
async function present(
presenter: WidgetPresenter,
context: WidgetPresenterContext = discordContext(),
html = "<!doctype html><html><body><p>Canonical</p></body></html>",
) {
return await presenter.present({
context,
document: { kind: "html", html },
title: "Status",
});
}
describe("Discord Activity widget presenter", () => {
it("matches only configured Discord channel routes", async () => {
const presenter = createDiscordWidgetPresenter(createActivityTestRuntime());
expect(presenter.target).toBe("current_channel");
if (presenter.target !== "current_channel") {
throw new Error("expected current-channel presenter");
}
expect(presenter.match(discordContext())).toBe(true);
expect(presenter.match(discordContext({ messageChannel: "slack" }))).toBe(false);
expect(
presenter.match(
discordContext({
nativeChannelId: undefined,
deliveryContext: { channel: "discord", to: "discord:user:987654321" },
}),
),
).toBe(false);
const unconfigured = createDiscordWidgetPresenter(
createActivityTestRuntime(createActivityTestConfig({ clientSecret: "" })),
);
expect(unconfigured.target).toBe("current_channel");
expect(unconfigured.target === "current_channel" && unconfigured.match(discordContext())).toBe(
false,
);
await expect(unconfigured.availability(discordContext())).resolves.toMatchObject({
ok: false,
error: { code: "unavailable" },
});
await expect(
presenter.present({
context: discordContext(),
document: { kind: "html", html: "<p>too long</p>" },
title: "x".repeat(81),
}),
).resolves.toMatchObject({
ok: false,
error: { code: "presentation_error", message: "title must be 80 characters or fewer" },
});
});
it("stores the canonical document before posting a fixed launch button", async () => {
const runtime = createActivityTestRuntime();
const createWidget = vi.spyOn(runtime.store, "createWidget");
const send = vi.fn(async (..._args: Parameters<typeof sendDiscordComponentMessage>) =>
sendResult(),
);
const canonicalHtml = '<!doctype html><html><body data-owner="core">Canonical</body></html>';
const presenter = createDiscordWidgetPresenter(runtime, {
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
now: () => 7,
});
const result = await present(presenter, discordContext(), canonicalHtml);
expect(result).toMatchObject({
ok: true,
value: {
kind: "message",
receipt: { primaryPlatformMessageId: "1000000000000000001" },
},
});
const widgetIdPromise = createWidget.mock.results[0]?.value;
if (!widgetIdPromise) {
throw new Error("expected widget creation");
}
const widgetId = await widgetIdPromise;
const stored = await runtime.store.lookupWidget(widgetId);
expect(stored).toMatchObject({
html: canonicalHtml,
title: "Status",
channelId: "987654321",
accountId: "default",
createdAt: 7,
deliveredMessageId: "1000000000000000001",
});
expect(send.mock.calls[0]?.[1]).toEqual({
text: "Status",
blocks: [
{
type: "actions",
buttons: [
{
label: "Open widget",
style: "secondary",
internalCustomId: buildDiscordActivityCustomId(widgetId ?? ""),
},
],
},
],
});
expect(send.mock.calls[0]?.[2]).toMatchObject({
accountId: "default",
allowedMentions: { parse: [] },
});
});
it("resolves provider-prefixed current-channel targets", async () => {
const send = vi.fn(async () => sendResult());
const presenter = createDiscordWidgetPresenter(createActivityTestRuntime(), {
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
});
const context = discordContext({
nativeChannelId: undefined,
currentMessagingTarget: "discord:channel:987654321",
});
expect(presenter.target === "current_channel" && presenter.match(context)).toBe(true);
await expect(present(presenter, context)).resolves.toMatchObject({ ok: true });
expect(send).toHaveBeenCalledWith(
"channel:987654321",
expect.objectContaining({ text: "Status" }),
expect.any(Object),
);
});
it("rolls back only the undelivered widget when component delivery fails", async () => {
const runtime = createActivityTestRuntime();
const existingId = await runtime.store.createWidget({
html: "<p>existing</p>",
title: "Existing",
channelId: "987654321",
accountId: "default",
createdAt: 1,
});
await runtime.store.markWidgetDelivered(existingId, "1000000000000000000");
const failure = new Error("send failed");
const presenter = createDiscordWidgetPresenter(runtime, {
sendComponentMessage: vi.fn(async () => {
throw failure;
}) as unknown as typeof sendDiscordComponentMessage,
});
await expect(present(presenter)).rejects.toBe(failure);
await expect(
runtime.store.latestPostedWidgetForChannel("default", "987654321"),
).resolves.toMatchObject({ id: existingId, widget: { title: "Existing" } });
});
it("keeps a delivered widget when later component bookkeeping fails", async () => {
const runtime = createActivityTestRuntime();
const delivery = sendResult();
const presenter = createDiscordWidgetPresenter(runtime, {
sendComponentMessage: vi.fn(
async (...args: Parameters<typeof sendDiscordComponentMessage>) => {
await args[2].onDeliveryResult?.(delivery);
throw new Error("component registry failed");
},
) as unknown as typeof sendDiscordComponentMessage,
});
await expect(present(presenter)).resolves.toMatchObject({ ok: true });
await expect(
runtime.store.latestPostedWidgetForChannel("default", "987654321"),
).resolves.toMatchObject({ widget: { deliveredMessageId: delivery.messageId } });
});
it("surfaces delivery-record failures without deleting the delivered widget", async () => {
const runtime = createActivityTestRuntime();
const createWidget = vi.spyOn(runtime.store, "createWidget");
vi.spyOn(runtime.store, "markWidgetDelivered").mockRejectedValueOnce(
new Error("state unavailable"),
);
const presenter = createDiscordWidgetPresenter(runtime, {
sendComponentMessage: vi.fn(async () =>
sendResult(),
) as unknown as typeof sendDiscordComponentMessage,
});
await expect(present(presenter)).rejects.toThrow(
"Discord widget was delivered, but its delivery state could not be saved",
);
const widgetIdPromise = createWidget.mock.results[0]?.value;
if (!widgetIdPromise) {
throw new Error("expected widget creation");
}
await expect(runtime.store.lookupWidget(await widgetIdPromise)).resolves.toMatchObject({
deliveredMessageId: null,
});
});
});
@@ -0,0 +1,167 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { resolveDiscordAccount } from "../accounts.js";
import { sendDiscordComponentMessage } from "../send.components.js";
import { buildDiscordPresentationComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId as resolveDiscordTargetChannelId } from "../target-parsing.js";
import type { DiscordActivitiesRuntime } from "./runtime.js";
const DISCORD_WIDGET_HTML_MAX_BYTES = 48 * 1024;
type WidgetPresenter = Parameters<OpenClawPluginApi["registerWidgetPresenter"]>[0];
type WidgetPresenterContext = Parameters<WidgetPresenter["availability"]>[0];
type DiscordWidgetPresenterDeps = {
sendComponentMessage?: typeof sendDiscordComponentMessage;
now?: () => number;
};
function resolveDiscordChannelId(context: WidgetPresenterContext): string | undefined {
const raw =
context.nativeChannelId?.trim() ||
context.currentMessagingTarget?.trim() ||
context.currentChannelId?.trim() ||
context.deliveryContext?.to?.trim();
if (!raw) {
return undefined;
}
try {
return resolveDiscordTargetChannelId(raw);
} catch {
return undefined;
}
}
function resolveDiscordPresentationRoute(
context: WidgetPresenterContext,
runtime: DiscordActivitiesRuntime,
) {
if (context.messageChannel !== "discord") {
return undefined;
}
const cfg = runtime.currentConfig();
const account = resolveDiscordAccount({
cfg,
accountId: context.accountId ?? context.deliveryContext?.accountId,
});
const channelId = resolveDiscordChannelId(context);
const activityAccount = runtime.resolveAccount(account.accountId, cfg);
if (!channelId || !activityAccount) {
return undefined;
}
return { account: activityAccount, cfg, channelId };
}
/** Presents a canonical core widget document in the active Discord channel. */
export function createDiscordWidgetPresenter(
runtime: DiscordActivitiesRuntime,
deps: DiscordWidgetPresenterDeps = {},
): WidgetPresenter {
return {
target: "current_channel",
description: "Post an Activity launch button in the current Discord channel",
capabilities: {
sourceKinds: ["html"],
maxSourceBytes: DISCORD_WIDGET_HTML_MAX_BYTES,
},
match: (context) => resolveDiscordPresentationRoute(context, runtime) !== undefined,
async availability(context) {
return resolveDiscordPresentationRoute(context, runtime)
? { ok: true, value: { available: true } }
: {
ok: false,
error: {
code: "unavailable",
message: "Discord Activities are unavailable for the current channel and account.",
},
};
},
async present({ context, document, title }) {
const route = resolveDiscordPresentationRoute(context, runtime);
if (!route) {
return {
ok: false,
error: {
code: "unavailable",
message: "Discord Activities are unavailable for the current channel and account.",
},
};
}
if (title.length > 80) {
return {
ok: false,
error: { code: "presentation_error", message: "title must be 80 characters or fewer" },
};
}
// 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 runtime.store.createWidget({
html: document.html,
title,
channelId: route.channelId,
accountId: route.account.accountId,
createdAt: (deps.now ?? Date.now)(),
});
let result: Awaited<ReturnType<typeof sendDiscordComponentMessage>>;
let deliveredResult: Awaited<ReturnType<typeof sendDiscordComponentMessage>> | undefined;
let deliveryRecord: Promise<void> | undefined;
let deliveryRecordError: Error | undefined;
const recordDelivery = async (
deliveryResult: Awaited<ReturnType<typeof sendDiscordComponentMessage>>,
) => {
deliveredResult = deliveryResult;
deliveryRecord ??= runtime.store.markWidgetDelivered(widgetId, deliveryResult.messageId);
try {
await deliveryRecord;
} catch (error) {
deliveryRecordError ??= new Error(
"Discord widget was delivered, but its delivery state could not be saved",
{ cause: error },
);
throw deliveryRecordError;
}
};
try {
const components = buildDiscordPresentationComponents({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Open widget",
action: { type: "web-app", widgetId },
},
],
},
],
});
if (!components) {
throw new Error("Discord widget launch button could not be rendered");
}
result = await (deps.sendComponentMessage ?? sendDiscordComponentMessage)(
`channel:${route.channelId}`,
{ ...components, text: title },
{
cfg: route.cfg,
accountId: route.account.accountId,
allowedMentions: { parse: [] },
onDeliveryResult: recordDelivery,
},
);
await recordDelivery(result);
} catch (error) {
if (deliveryRecordError) {
throw deliveryRecordError;
}
if (!deliveredResult) {
await runtime.store.deleteWidget(widgetId);
throw error;
}
// sendDiscordComponentMessage awaits onDeliveryResult before later bookkeeping. Marker
// failures were surfaced above, so only post-delivery bookkeeping can reach this recovery.
result = deliveredResult;
}
return { ok: true, value: { kind: "message", receipt: result.receipt } };
},
};
}
@@ -11,23 +11,25 @@ afterEach(() => {
vi.unstubAllEnvs();
});
function createApi(config: Record<string, unknown>) {
const routes: unknown[] = [];
const tools: Array<{ tool: unknown; opts?: { name?: string } }> = [];
const warn = vi.fn();
function createApi(
config: Record<string, unknown>,
runtimeConfig: Record<string, unknown> = config,
) {
const routes: Array<Parameters<OpenClawPluginApi["registerHttpRoute"]>[0]> = [];
const widgetPresenters: Array<Parameters<OpenClawPluginApi["registerWidgetPresenter"]>[0]> = [];
const resolvePath = vi.fn((input: string) => `/plugin-root/${input}`);
const api = {
config,
logger: { warn },
logger: { warn: vi.fn() },
runtime: {
state: { openKeyedStore: vi.fn(() => createMemoryKeyedStore()) },
config: { current: () => config },
config: { current: () => runtimeConfig },
},
registerHttpRoute: vi.fn((route) => routes.push(route)),
registerTool: vi.fn((tool, opts) => tools.push({ tool, opts })),
registerWidgetPresenter: vi.fn((presenter) => widgetPresenters.push(presenter)),
resolvePath,
} as unknown as OpenClawPluginApi;
return { api, routes, tools, warn, resolvePath };
return { api, routes, widgetPresenters, resolvePath };
}
describe("Discord Activities registration", () => {
@@ -43,63 +45,76 @@ 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 route and both Discord-only widget tool factories", () => {
const test = createApi({
it("registers static transport surfaces before runtime config is published", () => {
const runtimeConfig = {
channels: {
discord: {
token: "test",
activities: { clientSecret: "secret", applicationId: "123" },
},
},
});
};
const test = createApi({ channels: { discord: { token: "test" } } }, runtimeConfig);
registerDiscordActivities(test.api);
expect(test.routes).toHaveLength(1);
expect(test.routes[0]).toMatchObject({
path: "/discord/activity",
auth: "plugin",
match: "prefix",
});
expect(test.routes).toEqual([
expect.objectContaining({ path: "/discord/activity", auth: "plugin", match: "prefix" }),
]);
expect(test.resolvePath).toHaveBeenCalledWith("assets/embedded-app-sdk.mjs");
expect(test.tools.map(({ opts }) => opts?.name)).toEqual(["show_widget", "discord_widget"]);
for (const { tool } of test.tools) {
const factory = tool as (context: { messageChannel?: string }) => unknown;
expect(factory({ messageChannel: "slack" })).toBeNull();
expect(factory({ messageChannel: "discord" })).not.toBeNull();
}
expect(test.widgetPresenters).toEqual([
expect.objectContaining({
target: "current_channel",
capabilities: { sourceKinds: ["html"], maxSourceBytes: 48 * 1024 },
}),
]);
const presenter = test.widgetPresenters[0];
expect(
presenter?.target === "current_channel" &&
presenter.match({
messageChannel: "discord",
accountId: "default",
nativeChannelId: "987654321",
}),
).toBe(true);
expect(getDiscordActivitiesRuntime()).toBeDefined();
});
it.each([
{
name: "Activities are unconfigured",
config: { channels: { discord: { token: "test" } } },
},
{
name: "the client secret is missing",
config: {
channels: { discord: { token: "test", activities: { applicationId: "123" } } },
},
},
{
name: "the Discord account is disabled",
config: {
channels: {
discord: {
enabled: false,
token: "test",
activities: { clientSecret: "secret", applicationId: "123" },
},
},
},
},
])("keeps the static presenter unavailable when $name", ({ config }) => {
const test = createApi({}, config);
registerDiscordActivities(test.api);
const presenter = test.widgetPresenters[0];
expect(
presenter?.target === "current_channel" &&
presenter.match({
messageChannel: "discord",
accountId: "default",
nativeChannelId: "987654321",
}),
).toBe(false);
});
});
+4 -35
View File
@@ -1,40 +1,15 @@
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 { createDiscordWidgetPresenter } from "./presenter.js";
import { DiscordActivitiesRuntime, setDiscordActivitiesRuntime } from "./runtime.js";
import { DISCORD_ACTIVITY_ROUTE_PREFIX } from "./shell.js";
import { DiscordActivityStore, openDiscordActivityStores } from "./store.js";
import { createDiscordShowWidgetTool, 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;
}
// Registration precedes publication of secret-resolved channel config. Keep the
// transport static; runtime matching and HTTP dispatch gate on the current snapshot.
const store = new DiscordActivityStore(
openDiscordActivityStores(<T>(options: OpenKeyedStoreOptions) =>
api.runtime.state.openKeyedStore<T>(options),
@@ -58,11 +33,5 @@ export function registerDiscordActivities(api: OpenClawPluginApi): void {
match: "prefix",
handler: async (req, res) => await http.handleHttpRequest(req, res),
});
api.registerTool((context) => createDiscordShowWidgetTool(context, { runtime }), {
name: "show_widget",
});
// One-release deprecation window: remove this alias in the next release.
api.registerTool((context) => createDiscordWidgetTool(context, { runtime }), {
name: "discord_widget",
});
api.registerWidgetPresenter(createDiscordWidgetPresenter(runtime));
}
+14 -7
View File
@@ -5,6 +5,7 @@ import {
resolveDiscordAccount,
} from "../accounts.js";
import { resolveDiscordProxyFetchForAccount } from "../proxy-fetch.js";
import { selectDiscordActivitiesRuntimeConfig } from "../runtime-config.js";
import { resolveDiscordActivitiesConfig } from "./config.js";
import type { DiscordActivityStore } from "./store.js";
@@ -27,7 +28,7 @@ export class DiscordActivitiesRuntime {
) {}
currentConfig(): OpenClawConfig {
return this.getCurrentConfig?.() ?? this.startupConfig;
return selectDiscordActivitiesRuntimeConfig(this.getCurrentConfig?.() ?? this.startupConfig);
}
registerApplicationId(accountId: string, applicationId: string): void {
@@ -42,7 +43,11 @@ export class DiscordActivitiesRuntime {
cfg = this.currentConfig(),
): ResolvedDiscordActivityAccount | null {
const account = resolveDiscordAccount({ cfg, accountId });
if (!isDiscordAccountEnabledForRuntime(account, cfg)) {
if (
!listDiscordAccountIds(cfg).includes(account.accountId) ||
!isDiscordAccountEnabledForRuntime(account, cfg) ||
account.tokenStatus !== "available"
) {
return null;
}
const activities = resolveDiscordActivitiesConfig(account.config, this.env);
@@ -78,13 +83,15 @@ export class DiscordActivitiesRuntime {
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
hasEnabledAccounts(cfg = this.currentConfig()): boolean {
return listDiscordAccountIds(cfg).some(
(accountId) => this.resolveAccount(accountId, cfg) !== null,
);
}
isAccountEnabled(accountId: string, cfg = this.currentConfig()): boolean {
return this.resolveAccount(accountId, cfg) !== null;
}
}
let activeRuntime: DiscordActivitiesRuntime | undefined;
@@ -61,7 +61,7 @@ export function createMemoryKeyedStore<T>(): PluginStateKeyedStore<T> & {
};
}
export function createMemoryActivityStore(): DiscordActivityStore {
function createMemoryActivityStore(): DiscordActivityStore {
const stores: DiscordActivityStores = {
widgets: createMemoryKeyedStore<DiscordActivityWidget>(),
sessions: createMemoryKeyedStore<DiscordActivitySession>(),
@@ -1,394 +0,0 @@
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import {
buildDiscordActivityCustomId,
parseDiscordActivityCustomIdForInteraction,
} from "../component-custom-id.js";
import { buildDiscordComponentMessage } from "../components.js";
import type { sendDiscordComponentMessage } from "../send.components.js";
import { createDiscordSendReceipt } from "../send.receipt.js";
import { createActivityTestRuntime } from "./test-helpers.test-support.js";
import { createDiscordShowWidgetTool, 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("marks the legacy name deprecated", () => {
const tool = createDiscordWidgetTool(discordContext(), {
runtime: createActivityTestRuntime(),
});
if (!tool) {
throw new Error("expected deprecated Discord widget tool");
}
expect(tool.description).toMatch(/^Deprecated: use show_widget\./);
expect((tool.parameters as { properties?: Record<string, unknown> }).properties).toHaveProperty(
"html",
);
});
it("stores a wrapped widget and posts its launch button", async () => {
const runtime = createActivityTestRuntime();
const send = vi.fn(async (..._args: Parameters<typeof sendDiscordComponentMessage>) => ({
messageId: "1000000000000000001",
channelId: "987654321",
receipt: {},
}));
const tool = createDiscordWidgetTool(discordContext(), {
runtime,
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
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("1000000000000000001");
expect(details.widgetId).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(stored).toMatchObject({
title: "Status",
channelId: "987654321",
accountId: "default",
createdAt: 7,
deliveredMessageId: "1000000000000000001",
});
expect(stored?.html).toContain("<!doctype html>");
expect(stored?.html).toContain("<button");
const spec = send.mock.calls[0]?.[1];
const customId = buildDiscordActivityCustomId(details.widgetId);
expect(send).toHaveBeenCalledWith("channel:987654321", expect.any(Object), expect.any(Object));
expect(send.mock.calls[0]?.[2]).toMatchObject({ allowedMentions: { parse: [] } });
if (!spec) {
throw new Error("expected Discord component spec");
}
expect(spec).toEqual({
text: "Status",
blocks: [
{
type: "actions",
buttons: [
{
label: "Open widget",
style: "secondary",
internalCustomId: customId,
},
],
},
],
});
expect(JSON.stringify(buildDiscordComponentMessage({ spec }).components)).toContain(customId);
expect(parseDiscordActivityCustomIdForInteraction(customId)).toEqual({
key: "ocactivity",
data: { widgetId: details.widgetId },
});
});
it("resolves a provider-prefixed forum thread target", async () => {
const runtime = createActivityTestRuntime();
const send = vi.fn(async (..._args: Parameters<typeof sendDiscordComponentMessage>) => ({
messageId: "1000000000000000001",
channelId: "987654321",
receipt: {},
}));
const tool = createDiscordWidgetTool(
discordContext({
nativeChannelId: undefined,
deliveryContext: { channel: "discord", to: "discord:channel:987654321" },
}),
{
runtime,
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
},
);
if (!tool) {
throw new Error("expected Discord widget tool");
}
const result = await tool.execute("forum-widget", {
html: "<p>Forum widget</p>",
title: "Forum widget",
});
expect(result.details).toMatchObject({ channelId: "987654321" });
expect(send).toHaveBeenCalledWith(
"channel:987654321",
expect.objectContaining({ text: "Forum widget" }),
expect.any(Object),
);
});
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 sendDiscordComponentMessage>) => ({
messageId: "1000000000000000001",
channelId: "987654321",
receipt: {},
}));
const tool = createDiscordWidgetTool(discordContext(), {
runtime,
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
});
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,
});
await runtime.store.markWidgetDelivered(existingId, "1000000000000000000");
const failure = new Error("send failed");
const send = vi.fn(async () => {
throw failure;
}) as unknown as typeof sendDiscordComponentMessage;
const tool = createDiscordWidgetTool(discordContext(), { runtime, sendComponentMessage: 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.latestPostedWidgetForChannel("default", "987654321"),
).resolves.toMatchObject({ id: existingId, widget: { title: "Existing" } });
});
it("orders missing-ID fallback by Discord message snowflake", async () => {
const runtime = createActivityTestRuntime();
type SendResult = Awaited<ReturnType<typeof sendDiscordComponentMessage>>;
const sendResult = (messageId: string): SendResult => ({
messageId,
channelId: "987654321",
receipt: createDiscordSendReceipt({
platformMessageIds: [messageId],
channelId: "987654321",
kind: "text",
}),
});
const pending = new Map<string, (result: SendResult) => void>();
const send = vi.fn(
async (...args: Parameters<typeof sendDiscordComponentMessage>) =>
await new Promise<SendResult>((resolve) => {
pending.set(args[1].text ?? "", resolve);
}),
) as unknown as typeof sendDiscordComponentMessage;
let timestamp = 0;
const tool = createDiscordWidgetTool(discordContext(), {
runtime,
sendComponentMessage: send,
now: () => ++timestamp,
});
if (!tool) {
throw new Error("expected Discord widget tool");
}
const first = tool.execute("first", { html: "<p>first</p>", title: "First" });
await vi.waitFor(() => expect(pending.has("First")).toBe(true));
const second = tool.execute("second", { html: "<p>second</p>", title: "Second" });
await vi.waitFor(() => expect(pending.has("Second")).toBe(true));
pending.get("Second")?.(sendResult("1000000000000000002"));
const secondDetails = (await second).details as { widgetId: string };
await expect(
runtime.store.latestPostedWidgetForChannel("default", "987654321"),
).resolves.toMatchObject({ id: secondDetails.widgetId, widget: { title: "Second" } });
pending.get("First")?.(sendResult("1000000000000000001"));
const firstDetails = (await first).details as { widgetId: string };
expect(firstDetails.widgetId).not.toBe(secondDetails.widgetId);
await expect(
runtime.store.latestPostedWidgetForChannel("default", "987654321"),
).resolves.toMatchObject({ id: secondDetails.widgetId, widget: { title: "Second" } });
});
it("keeps a widget when component bookkeeping fails after delivery", async () => {
const runtime = createActivityTestRuntime();
const failure = new Error("registry failed");
const send = vi.fn(async (...args: Parameters<typeof sendDiscordComponentMessage>) => {
await args[2].onDeliveryResult?.({
messageId: "1000000000000000001",
channelId: "987654321",
receipt: createDiscordSendReceipt({
platformMessageIds: ["1000000000000000001"],
channelId: "987654321",
kind: "text",
}),
});
throw failure;
}) as unknown as typeof sendDiscordComponentMessage;
const tool = createDiscordWidgetTool(discordContext(), { runtime, sendComponentMessage: send });
if (!tool) {
throw new Error("expected Discord widget tool");
}
const result = await tool.execute("delivered-send", {
html: "<p>delivered</p>",
title: "Delivered",
});
const details = result.details as { widgetId: string; messageId: string };
expect(details.messageId).toBe("1000000000000000001");
await expect(runtime.store.lookupWidget(details.widgetId)).resolves.toMatchObject({
title: "Delivered",
deliveredMessageId: "1000000000000000001",
});
await expect(
runtime.store.latestPostedWidgetForChannel("default", "987654321"),
).resolves.toMatchObject({ id: details.widgetId });
});
it("surfaces delivery-state failures without deleting the delivered widget", async () => {
const runtime = createActivityTestRuntime();
const createWidget = vi.spyOn(runtime.store, "createWidget");
vi.spyOn(runtime.store, "markWidgetDelivered").mockRejectedValueOnce(
new Error("state unavailable"),
);
const send = vi.fn(async (..._args: Parameters<typeof sendDiscordComponentMessage>) => ({
messageId: "1000000000000000001",
channelId: "987654321",
receipt: {},
}));
const tool = createDiscordWidgetTool(discordContext(), {
runtime,
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
});
if (!tool) {
throw new Error("expected Discord widget tool");
}
await expect(
tool.execute("delivery-state-failure", {
html: "<p>delivered</p>",
title: "Delivered",
}),
).rejects.toThrow("Discord widget was delivered, but its delivery state could not be saved");
const widgetIdPromise = createWidget.mock.results[0]?.value;
if (!widgetIdPromise) {
throw new Error("expected widget creation");
}
const widgetId = await widgetIdPromise;
await expect(runtime.store.lookupWidget(widgetId)).resolves.toMatchObject({
title: "Delivered",
deliveredMessageId: null,
});
});
it("requires a concrete channel target", async () => {
const tool = createDiscordWidgetTool(discordContext({ nativeChannelId: undefined }), {
runtime: createActivityTestRuntime(),
sendComponentMessage: vi.fn() as unknown as typeof sendDiscordComponentMessage,
});
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");
});
it("rejects direct-message targets without a channel", async () => {
const tool = createDiscordWidgetTool(
discordContext({
nativeChannelId: undefined,
deliveryContext: { channel: "discord", to: "discord:user:987654321" },
}),
{
runtime: createActivityTestRuntime(),
sendComponentMessage: vi.fn() as unknown as typeof sendDiscordComponentMessage,
},
);
if (!tool) {
throw new Error("expected Discord widget tool");
}
await expect(tool.execute("dm-target", { html: "hello", title: "No channel" })).rejects.toThrow(
"requires a concrete Discord channel",
);
});
});
describe("show_widget", () => {
it("maps widget_code to the Discord Activity document", async () => {
const runtime = createActivityTestRuntime();
const send = vi.fn(async (..._args: Parameters<typeof sendDiscordComponentMessage>) => ({
messageId: "1000000000000000001",
channelId: "987654321",
receipt: {},
}));
const tool = createDiscordShowWidgetTool(discordContext(), {
runtime,
sendComponentMessage: send as unknown as typeof sendDiscordComponentMessage,
});
if (!tool) {
throw new Error("expected unified Discord widget tool");
}
expect(tool.name).toBe("show_widget");
expect(tool.description).toMatch(/^Visual helps\? Make widget\. Do not wait for ask\./);
expect((tool.parameters as { properties?: Record<string, unknown> }).properties).toMatchObject({
title: expect.any(Object),
widget_code: expect.any(Object),
button_label: expect.any(Object),
});
expect(
(tool.parameters as { properties?: Record<string, unknown> }).properties,
).not.toHaveProperty("html");
const result = await tool.execute("unified-widget", {
title: "Unified",
widget_code: "<p>Discord surface</p>",
button_label: "Launch",
});
const details = result.details as { widgetId: string };
await expect(runtime.store.lookupWidget(details.widgetId)).resolves.toMatchObject({
title: "Unified",
html: expect.stringContaining("<p>Discord surface</p>"),
});
expect(send.mock.calls[0]?.[1]).toMatchObject({
blocks: [{ buttons: [{ label: "Launch" }] }],
});
});
});
-226
View File
@@ -1,226 +0,0 @@
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 {
assertWidgetHtmlSize,
isCompleteHtmlDocument,
WidgetHtmlInputError,
} from "openclaw/plugin-sdk/widget-html";
import { Type } from "typebox";
import { resolveDiscordAccount } from "../accounts.js";
import { sendDiscordComponentMessage } from "../send.components.js";
import { buildDiscordPresentationComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId as resolveDiscordTargetChannelId } from "../target-parsing.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 })),
});
const ShowWidgetParameters = Type.Object({
title: Type.String({ minLength: 1, maxLength: 80 }),
widget_code: Type.String({ description: "Self-contained HTML document or body fragment" }),
button_label: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
});
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;
}
try {
return resolveDiscordTargetChannelId(raw);
} catch {
return undefined;
}
}
function buildDiscordWidgetDocument(title: string, html: string): string {
if (isCompleteHtmlDocument(html)) {
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;
sendComponentMessage?: typeof sendDiscordComponentMessage;
now?: () => number;
};
type DiscordWidgetToolVariant = {
name: "discord_widget" | "show_widget";
label: string;
description: string;
htmlParam: "html" | "widget_code";
parameters: typeof DiscordWidgetParameters | typeof ShowWidgetParameters;
};
const DISCORD_WIDGET_VARIANT: DiscordWidgetToolVariant = {
name: "discord_widget",
label: "Discord Widget",
description:
"Deprecated: use show_widget. Show an interactive, self-contained HTML widget to the user in Discord.",
htmlParam: "html",
parameters: DiscordWidgetParameters,
};
const SHOW_WIDGET_VARIANT: DiscordWidgetToolVariant = {
name: "show_widget",
label: "Show Widget",
description:
"Visual helps? Make widget. Do not wait for ask. Use for comparisons, trends, timelines, flows, hierarchies, dashboards, status, progress, layouts, and choices. Text clearer? Skip. Show an interactive, self-contained HTML widget to the user on their current surface. In Discord, posts an Activity launch button.",
htmlParam: "widget_code",
parameters: ShowWidgetParameters,
};
function createDiscordWidgetToolVariant(
context: OpenClawPluginToolContext,
deps: DiscordWidgetToolDeps,
variant: DiscordWidgetToolVariant,
): 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: variant.label,
name: variant.name,
description: variant.description,
parameters: variant.parameters,
execute: async (_toolCallId, rawParams) => {
const params = rawParams as Record<string, unknown>;
const html = readStringParam(params, variant.htmlParam, { required: true, trim: false });
const title = readStringParam(params, "title", { required: true });
const buttonLabel = readStringParam(params, "button_label") || "Open widget";
if (!html.trim()) {
throw new WidgetHtmlInputError(`${variant.htmlParam} is required`);
}
assertWidgetHtmlSize(html, DISCORD_WIDGET_HTML_MAX_BYTES, {
inputName: variant.htmlParam,
});
if (title.length > 80) {
throw new WidgetHtmlInputError("title must be 80 characters or fewer");
}
if (!buttonLabel.trim() || buttonLabel.length > 80) {
throw new WidgetHtmlInputError("button_label must be 1 to 80 characters");
}
const channelId = resolveDiscordChannelId(context);
if (!channelId) {
throw new WidgetHtmlInputError(
`${variant.name} 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 sendDiscordComponentMessage>>;
let deliveredResult: Awaited<ReturnType<typeof sendDiscordComponentMessage>> | undefined;
let deliveryRecord: Promise<void> | undefined;
let deliveryRecordError: Error | undefined;
const recordDelivery = async (
deliveryResult: Awaited<ReturnType<typeof sendDiscordComponentMessage>>,
) => {
deliveredResult = deliveryResult;
deliveryRecord ??= deps.runtime.store.markWidgetDelivered(
widgetId,
deliveryResult.messageId,
);
try {
await deliveryRecord;
} catch (error) {
deliveryRecordError ??= new Error(
"Discord widget was delivered, but its delivery state could not be saved",
{ cause: error },
);
throw deliveryRecordError;
}
};
try {
const components = buildDiscordPresentationComponents({
blocks: [
{
type: "buttons",
buttons: [
{
label: buttonLabel.trim(),
action: { type: "web-app", widgetId },
},
],
},
],
});
if (!components) {
throw new Error("Discord widget launch button could not be rendered");
}
result = await (deps.sendComponentMessage ?? sendDiscordComponentMessage)(
`channel:${channelId}`,
{ ...components, text: title },
{
cfg: cfg as OpenClawConfig,
accountId: account.accountId,
allowedMentions: { parse: [] },
onDeliveryResult: recordDelivery,
},
);
await recordDelivery(result);
} catch (error) {
if (deliveryRecordError) {
throw deliveryRecordError;
}
if (!deliveredResult) {
await deps.runtime.store.deleteWidget(widgetId);
throw error;
}
// sendDiscordComponentMessage awaits onDeliveryResult before later bookkeeping. Marker
// failures were surfaced above, so only post-delivery bookkeeping can reach this recovery.
result = deliveredResult;
}
return jsonResult({ widgetId, messageId: result.messageId, channelId: result.channelId });
},
};
}
export function createDiscordWidgetTool(
context: OpenClawPluginToolContext,
deps: DiscordWidgetToolDeps,
): AnyAgentTool | null {
return createDiscordWidgetToolVariant(context, deps, DISCORD_WIDGET_VARIANT);
}
export function createDiscordShowWidgetTool(
context: OpenClawPluginToolContext,
deps: DiscordWidgetToolDeps,
): AnyAgentTool | null {
return createDiscordWidgetToolVariant(context, deps, SHOW_WIDGET_VARIANT);
}
+1 -1
View File
@@ -309,7 +309,7 @@ export const discordChannelConfigUiHints = {
},
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.",
help: "Enable the Discord Activity presenter for the core show_widget tool on this account. Activity routes and the launch handler remain disabled when this block is absent.",
},
"activities.clientSecret": {
label: "Discord Activities Client Secret",
+46 -2
View File
@@ -1,10 +1,10 @@
// Discord helper module supports runtime config behavior.
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
getRuntimeConfigSnapshot,
selectApplicableRuntimeConfig,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import type { OpenClawConfig } from "./runtime-api.js";
import type { DiscordAccountConfig, OpenClawConfig } from "./runtime-api.js";
export function selectDiscordRuntimeConfig(inputConfig: OpenClawConfig): OpenClawConfig {
return (
@@ -15,3 +15,47 @@ export function selectDiscordRuntimeConfig(inputConfig: OpenClawConfig): OpenCla
}) ?? inputConfig
);
}
function withSourceActivities(
runtimeAccount: DiscordAccountConfig | undefined,
sourceAccount: DiscordAccountConfig | undefined,
): DiscordAccountConfig {
const { activities: _runtimeActivities, ...runtimeRest } = runtimeAccount ?? {};
return {
...runtimeRest,
...(sourceAccount?.activities ? { activities: sourceAccount.activities } : {}),
};
}
/** Restores plugin-owned sensitive Activity config onto the resolved runtime shape. */
export function selectDiscordActivitiesRuntimeConfig(inputConfig: OpenClawConfig): OpenClawConfig {
const runtimeConfig = selectDiscordRuntimeConfig(inputConfig);
const sourceDiscord = getRuntimeConfigSourceSnapshot()?.channels?.discord;
if (!sourceDiscord) {
return runtimeConfig;
}
const runtimeDiscord = runtimeConfig.channels?.discord;
const accountIds = new Set([
...Object.keys(runtimeDiscord?.accounts ?? {}),
...Object.keys(sourceDiscord.accounts ?? {}),
]);
const accounts = Object.fromEntries(
[...accountIds].map((accountId) => [
accountId,
withSourceActivities(
runtimeDiscord?.accounts?.[accountId],
sourceDiscord.accounts?.[accountId],
),
]),
);
return {
...runtimeConfig,
channels: {
...runtimeConfig.channels,
discord: {
...withSourceActivities(runtimeDiscord, sourceDiscord),
...(accountIds.size > 0 ? { accounts } : {}),
},
},
};
}
@@ -121,6 +121,20 @@ describe("sendDiscordComponentMessage", () => {
expect(readRecordArg(postMock, 0, 1).body).toMatchObject({ allowed_mentions: { parse: [] } });
});
it("rejects component delivery to forum-style channels before posting", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum, id: "forum-1" });
await expect(
sendDiscordComponentMessage(
"channel:forum-1",
{ blocks: [{ type: "actions", buttons: [{ label: "Open widget" }] }] },
{ cfg: DISCORD_TEST_CFG, rest, token: "t" },
),
).rejects.toThrow("Discord components are not supported in forum-style channels");
expect(postMock).not.toHaveBeenCalled();
});
it("keeps direct-channel DM session keys on component entries", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({
@@ -652,10 +652,6 @@
"kind": "channel",
"openclaw": {
"contracts": {
"tools": [
"show_widget",
"discord_widget"
],
"transcriptSourceProviders": [
"discord-voice"
]
@@ -298,6 +298,37 @@ export function attachEmbeddedMessageDeliveryFact(
return { ...result, details: { ...details, messageDelivery: fact } };
}
export function isDeliveredCoreCurrentChannelWidgetResult(params: {
coreBuiltinToolNames?: ReadonlySet<string>;
sourceReplyDeliveryMode?: string;
toolName: string;
result: unknown;
isToolError: boolean;
}): boolean {
if (
params.sourceReplyDeliveryMode !== "message_tool_only" ||
params.toolName !== "show_widget" ||
params.isToolError ||
params.coreBuiltinToolNames?.has("show_widget") !== true
) {
return false;
}
const details = asOptionalRecord(params.result)?.details;
const presentation = asOptionalRecord(asOptionalRecord(details)?.presentation);
const receipt = asOptionalRecord(presentation?.receipt);
if (asOptionalRecord(details)?.kind !== "widget" || presentation?.target !== "current_channel") {
return false;
}
const receiptIds = [
receipt?.primaryPlatformMessageId,
...(Array.isArray(receipt?.platformMessageIds) ? receipt.platformMessageIds : []),
...(Array.isArray(receipt?.parts)
? receipt.parts.map((part) => asOptionalRecord(part)?.platformMessageId)
: []),
];
return receiptIds.some((id) => hasNonEmptyString(id));
}
export function readEmbeddedMessageDeliveryFact(
value: unknown,
): EmbeddedMessageDeliveryFact | undefined {
@@ -176,6 +176,7 @@ export function prepareEmbeddedAttemptClientTools(params: {
return {
allCustomTools,
builtinToolNames,
coreBuiltinToolNames,
clientToolCallSlots,
clientToolDefs,
clientToolLoopDetection,
@@ -32,6 +32,7 @@ export async function runEmbeddedAttemptExecutionPhase(
activeSession,
allCustomTools,
builtinToolNames,
coreBuiltinToolNames,
clientToolCallSlots,
clientToolLoopDetection,
hasDeliveredSourceReply,
@@ -208,6 +209,7 @@ export async function runEmbeddedAttemptExecutionPhase(
markSourceReplyDelivered,
sandboxSessionKey: input.setup.sandboxSessionKey,
builtinToolNames,
coreBuiltinToolNames,
replaySafeToolNames,
sideEffectToolOwners,
diagnosticOwner,
@@ -94,6 +94,7 @@ export function prepareEmbeddedAttemptStream(input: {
onBlockReplyFlush: EmbeddedRunAttemptParams["onBlockReplyFlush"];
sandboxSessionKey: string;
builtinToolNames: ReadonlySet<string>;
coreBuiltinToolNames?: ReadonlySet<string>;
replaySafeToolNames: ReadonlySet<string>;
sideEffectToolOwners?: ReadonlyMap<string, string>;
diagnosticOwner: DiagnosticEmbeddedRunOwner;
@@ -324,6 +325,7 @@ export function prepareEmbeddedAttemptStream(input: {
sessionId: attempt.sessionId,
agentId: input.hookAgentId,
builtinToolNames: input.builtinToolNames,
coreBuiltinToolNames: input.coreBuiltinToolNames,
replaySafeToolNames: input.replaySafeToolNames,
...(input.sideEffectToolOwners ? { sideEffectToolOwners: input.sideEffectToolOwners } : {}),
internalEvents: attempt.internalEvents,
@@ -20,7 +20,10 @@ import {
consumeTrackedToolExecutionStarted,
} from "./agent-tools.before-tool-call.state.js";
import { normalizeTextForComparison } from "./embedded-agent-helpers.js";
import { readEmbeddedMessageDeliveryFact } from "./embedded-agent-message-delivery.js";
import {
isDeliveredCoreCurrentChannelWidgetResult,
readEmbeddedMessageDeliveryFact,
} from "./embedded-agent-message-delivery.js";
import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
@@ -290,7 +293,7 @@ export async function handleToolExecutionEnd(
didDeliverMessagingResult && isMessagingSend
? [...argumentMediaUrls, ...collectMessagingMediaUrlsFromToolResult(result)]
: [];
const deliveredCurrentSourceReply =
const deliveredMessageToolSourceReply =
didDeliverMessagingResult &&
isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
@@ -300,7 +303,16 @@ export async function handleToolExecutionEnd(
isError: isToolError,
deliveryConfirmed: didDeliverMessagingResult,
});
const sourceReplyFinal = deliveredCurrentSourceReply
const deliveredCurrentSourceReply =
deliveredMessageToolSourceReply ||
isDeliveredCoreCurrentChannelWidgetResult({
coreBuiltinToolNames: ctx.params.coreBuiltinToolNames,
sourceReplyDeliveryMode: ctx.params.sourceReplyDeliveryMode,
toolName,
result,
isToolError,
});
const sourceReplyFinal = deliveredMessageToolSourceReply
? resolveMessageToolSourceReplyFinal(startArgs)
: undefined;
ctx.state.pendingMessagingTexts.delete(toolCallId);
@@ -326,13 +338,15 @@ export async function handleToolExecutionEnd(
}
if (deliveredCurrentSourceReply) {
ctx.state.messageToolOnlySourceReplyDelivered = true;
const sourceReplyText = readMessageToolSourceReplyText(startArgs);
const normalizedSourceReplyText = sourceReplyText
? normalizeTextForComparison(sourceReplyText)
: "";
if (normalizedSourceReplyText) {
ctx.state.currentSourceMessagingToolSentTextsNormalized.push(normalizedSourceReplyText);
ctx.trimMessagingToolSent();
if (deliveredMessageToolSourceReply) {
const sourceReplyText = readMessageToolSourceReplyText(startArgs);
const normalizedSourceReplyText = sourceReplyText
? normalizeTextForComparison(sourceReplyText)
: "";
if (normalizedSourceReplyText) {
ctx.state.currentSourceMessagingToolSentTextsNormalized.push(normalizedSourceReplyText);
ctx.trimMessagingToolSent();
}
}
ctx.params.onDeliveredMessageToolOnlySourceReply?.();
}
@@ -3609,6 +3609,67 @@ describe("messaging tool media URL tracking", () => {
]);
});
it("commits trusted core current-channel widgets as message-tool-only source replies", async () => {
const { ctx } = createTestContext();
const onDeliveredMessageToolOnlySourceReply = vi.fn();
Object.assign(ctx.params, {
sourceReplyDeliveryMode: "message_tool_only",
coreBuiltinToolNames: new Set(["show_widget"]),
onDeliveredMessageToolOnlySourceReply,
});
await executeTool(ctx, {
toolName: "show_widget",
toolCallId: "tool-current-channel-widget",
args: { title: "Status", widget_code: "<p>ready</p>" },
isError: false,
result: {
details: {
kind: "widget",
presentation: {
target: "current_channel",
receipt: {
primaryPlatformMessageId: "discord-message-1",
platformMessageIds: ["discord-message-1"],
parts: [],
sentAt: 1,
},
},
},
},
});
expect(ctx.state.messageToolOnlySourceReplyDelivered).toBe(true);
expect(onDeliveredMessageToolOnlySourceReply).toHaveBeenCalledOnce();
});
it("does not commit inline Canvas widgets as message-tool-only source replies", async () => {
const { ctx } = createTestContext();
const onDeliveredMessageToolOnlySourceReply = vi.fn();
Object.assign(ctx.params, {
sourceReplyDeliveryMode: "message_tool_only",
coreBuiltinToolNames: new Set(["show_widget"]),
onDeliveredMessageToolOnlySourceReply,
});
await executeTool(ctx, {
toolName: "show_widget",
toolCallId: "tool-inline-widget",
args: { title: "Status", widget_code: "<p>ready</p>" },
isError: false,
result: {
details: {
kind: "canvas",
presentation: { target: "assistant_message", title: "Status", sandbox: "scripts" },
view: { id: "cv_1", url: "/__openclaw__/canvas/documents/cv_1/index.html" },
},
},
});
expect(ctx.state.messageToolOnlySourceReplyDelivered).toBe(false);
expect(onDeliveredMessageToolOnlySourceReply).not.toHaveBeenCalled();
});
it("commits projected payload-only delivery after middleware replaces details", async () => {
const { ctx } = createTestContext();
ctx.params.sourceReplyDeliveryMode = "message_tool_only";
@@ -331,6 +331,7 @@ type ToolHandlerParams = Pick<
| "hasRepliedRef"
| "sessionId"
| "agentId"
| "coreBuiltinToolNames"
| "replaySafeToolNames"
| "sideEffectToolOwners"
| "toolResultFormat"
@@ -129,6 +129,8 @@ export type SubscribeEmbeddedAgentSessionParams = {
* Exact raw names of OpenClaw tools registered for this run.
*/
builtinToolNames?: ReadonlySet<string>;
/** Exact raw names of core-owned tools registered for this run. */
coreBuiltinToolNames?: ReadonlySet<string>;
/** Exact registered tool names whose concrete instances are safe to replay. */
replaySafeToolNames?: ReadonlySet<string>;
/** Canonical owner keys for unique plugin tools that can change durable state. */
+120 -2
View File
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { setEmbeddedMode } from "../infra/embedded-mode.js";
import { createPluginBoardWidgetContentKindRegistrar } from "../plugins/board-widget-content-kinds.js";
import { createPluginRecord } from "../plugins/loader-records.js";
import type { WidgetPresenter } from "../plugins/plugin-registration.types.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { withEnv } from "../test-utils/env.js";
@@ -828,13 +829,130 @@ describe("gateway client capability tool filtering", () => {
).toBe(true);
});
it("keeps the core widget tool out of Discord sessions", () => {
it("keeps the core widget tool available to inline-capable Discord clients", () => {
expect(
hasTool(
createOpenClawTools({ agentChannel: "discord", clientCaps: ["inline-widgets"] }),
"show_widget",
),
).toBe(false);
).toBe(true);
});
it("exposes one core widget tool for a matching current-channel presenter", async () => {
const registry = createEmptyPluginRegistry();
const present = vi.fn(async () => ({
ok: true as const,
value: {
kind: "message" as const,
receipt: {
primaryPlatformMessageId: "discord-message-1",
platformMessageIds: ["discord-message-1"],
parts: [],
sentAt: 1,
},
},
}));
const presenter: WidgetPresenter = {
target: "current_channel",
description: "Post in the current Discord channel",
capabilities: { sourceKinds: ["html"] },
match: (context) =>
context.messageChannel === "discord" && context.accountId === "configured",
availability: async () => ({ ok: true, value: { available: true } }),
present,
};
registry.widgetPresenters.push({
pluginId: "discord",
pluginName: "Discord",
presenter,
source: "discord-fixture",
});
setActivePluginRegistry(registry);
try {
const tools = createOpenClawTools({
agentChannel: "discord",
agentAccountId: "configured",
nativeChannelId: "channel-1",
agentSessionKey: "agent:main:discord",
});
const widgetTools = tools.filter((tool) => tool.name === "show_widget");
expect(widgetTools).toHaveLength(1);
expect(widgetTools[0]?.requiredClientCaps).toBeUndefined();
const result = await widgetTools[0]?.execute("discord-widget", {
title: "Status",
widget_code: "<p>ready</p>",
});
expect(result?.details).toMatchObject({
kind: "widget",
presentation: {
target: "current_channel",
receipt: { primaryPlatformMessageId: "discord-message-1" },
},
});
expect(present).toHaveBeenCalledOnce();
} finally {
resetPluginRuntimeStateForTest();
}
});
it("hides current-channel widgets when no presenter matches the trusted run facts", () => {
const registry = createEmptyPluginRegistry();
const presenter: WidgetPresenter = {
target: "current_channel",
description: "Post in the current configured Discord channel",
capabilities: { sourceKinds: ["html"] },
match: (context) =>
context.messageChannel === "discord" && context.accountId === "configured",
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => {
throw new Error("present must not run");
},
};
registry.widgetPresenters.push({
pluginId: "discord",
presenter,
source: "discord-fixture",
});
setActivePluginRegistry(registry);
try {
expect(
hasTool(
createOpenClawTools({ agentChannel: "discord", agentAccountId: "unconfigured" }),
"show_widget",
),
).toBe(false);
expect(hasTool(createOpenClawTools({ agentChannel: "slack" }), "show_widget")).toBe(false);
} finally {
resetPluginRuntimeStateForTest();
}
});
it("fails closed when current-channel presenter matching is ambiguous", () => {
const registry = createEmptyPluginRegistry();
const presenter = (pluginId: string): WidgetPresenter => ({
target: "current_channel",
description: `Present through ${pluginId}`,
capabilities: { sourceKinds: ["html"] },
match: (context) => context.messageChannel === "discord",
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => {
throw new Error("present must not run");
},
});
registry.widgetPresenters.push(
{ pluginId: "first", presenter: presenter("first"), source: "first-fixture" },
{ pluginId: "second", presenter: presenter("second"), source: "second-fixture" },
);
setActivePluginRegistry(registry);
try {
expect(hasTool(createOpenClawTools({ agentChannel: "discord" }), "show_widget")).toBe(false);
} finally {
resetPluginRuntimeStateForTest();
}
});
it("keeps the core widget tool out when Canvas host config disables it", () => {
+9 -13
View File
@@ -12,11 +12,9 @@ import { selectApplicableRuntimeConfig } from "../config/config.js";
import { resolveControlUiSessionLinkBase } from "../config/control-ui-link-base.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isEmbeddedMode } from "../infra/embedded-mode.js";
import { resolveWidgetPresenters } from "../plugins/widget-presenters.js";
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
import { getActiveRuntimeWebToolsMetadataFromState } from "../secrets/runtime-web-tools-state.js";
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js";
import {
type HookContext,
@@ -43,6 +41,7 @@ import {
import { createRequesterYieldCallback } from "./openclaw-tools.requester-yield.js";
import { createOpenClawSwarmToolGroups } from "./openclaw-tools.swarm.js";
import { resolveTranscriptsTool } from "./openclaw-tools.transcripts.js";
import { resolveWidgetPresentationForRun } from "./openclaw-tools.widget-presentation.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import type { SpawnedToolContext } from "./spawned-context.js";
import type { ToolFsPolicy } from "./tool-fs-policy.js";
@@ -255,12 +254,7 @@ export function createOpenClawTools(
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir ?? inferredWorkspaceDir);
const spawnWorkspaceDir = resolveWorkspaceRoot(options?.spawnWorkspaceDir ?? workspaceDir);
options?.recordToolPrepStage?.("openclaw-tools:session-workspace");
const deliveryContext = normalizeDeliveryContext({
channel: options?.agentChannel,
to: options?.agentTo,
accountId: options?.agentAccountId,
threadId: options?.agentThreadId,
});
const widgetPresentation = resolveWidgetPresentationForRun(options);
const gatewayCallerAccountId = options?.gatewayCallerAccountId ?? options?.agentAccountId;
const runtimeWebTools = getActiveRuntimeWebToolsMetadataFromState();
const sandbox =
@@ -318,7 +312,7 @@ export function createOpenClawTools(
authProfileStore: options?.authProfileStore,
agentSessionKey: mediaGenerationAgentSessionKey,
requesterAgentId: sessionAgentId,
requesterOrigin: deliveryContext ?? undefined,
requesterOrigin: widgetPresentation.deliveryContext ?? undefined,
workspaceDir,
preparedModelRuntime: options?.preparedModelRuntime,
sandbox,
@@ -530,9 +524,9 @@ export function createOpenClawTools(
})
: []),
...(messageTool && includeMessageTool ? [messageTool] : []),
// Discord owns show_widget; registering the core tool would collide.
...(options?.agentChannel === "discord" ||
(!isCoreCanvasHostEnabled(resolvedConfig) && !hasRegisteredShowWidgetKinds())
...(!isCoreCanvasHostEnabled(resolvedConfig) &&
!hasRegisteredShowWidgetKinds() &&
!widgetPresentation.currentChannelPresenter
? []
: [
createShowWidgetTool({
@@ -540,7 +534,9 @@ export function createOpenClawTools(
agentId: sessionAgentId,
agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey,
inlineHostEnabled: isCoreCanvasHostEnabled(resolvedConfig),
presenters: resolveWidgetPresenters().map((registration) => registration.presenter),
inlineClientAvailable: options?.clientCaps?.includes("inline-widgets") === true,
presenters: widgetPresentation.presenters,
presenterContext: widgetPresentation.context,
}),
]),
...collectPresentOpenClawTools([heartbeatTool]),
@@ -0,0 +1,42 @@
import { resolveCurrentChannelWidgetPresenter } from "../canvas/widget-tool.js";
import { resolveWidgetPresenters } from "../plugins/widget-presenters.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
type WidgetPresentationRunOptions = {
agentSessionKey?: string;
runSessionKey?: string;
agentChannel?: string;
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
nativeChannelId?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
};
/** Resolves widget presenters against the trusted delivery facts prepared for this run. */
export function resolveWidgetPresentationForRun(options?: WidgetPresentationRunOptions) {
const deliveryContext = normalizeDeliveryContext({
channel: options?.agentChannel,
to: options?.agentTo ?? options?.currentMessagingTarget ?? options?.currentChannelId,
accountId: options?.agentAccountId,
threadId: options?.agentThreadId,
});
const sessionKey = options?.runSessionKey ?? options?.agentSessionKey;
const context = {
messageChannel: options?.agentChannel,
accountId: options?.agentAccountId,
deliveryContext,
nativeChannelId: options?.nativeChannelId,
currentChannelId: options?.currentChannelId,
currentMessagingTarget: options?.currentMessagingTarget,
sessionKey,
};
const presenters = resolveWidgetPresenters().map((registration) => registration.presenter);
return {
context,
deliveryContext,
presenters,
currentChannelPresenter: resolveCurrentChannelWidgetPresenter(presenters, context),
};
}
+18 -4
View File
@@ -6,6 +6,7 @@ const hoisted = vi.hoisted(() => ({
getActivePluginRegistry: vi.fn(),
loadPluginRegistryHandle: vi.fn(),
adoptRuntimeContextEngineRegistrations: vi.fn((target: unknown) => target),
adoptRuntimeWidgetPresenterRegistrations: vi.fn((target: unknown) => target),
resolveAgentRuntimePluginLoadPlan: vi.fn(),
resolveAgentRuntimePluginSelections: vi.fn(
(_config: unknown, selections: readonly unknown[]) => selections,
@@ -20,6 +21,10 @@ vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: hoisted.getActivePluginRegistry,
}));
vi.mock("../plugins/widget-presenters.js", () => ({
adoptRuntimeWidgetPresenterRegistrations: hoisted.adoptRuntimeWidgetPresenterRegistrations,
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: hoisted.loadPluginMetadataSnapshot,
}));
@@ -68,6 +73,9 @@ describe("agent runtime plugin registries", () => {
hoisted.adoptRuntimeContextEngineRegistrations
.mockReset()
.mockImplementation((target) => target);
hoisted.adoptRuntimeWidgetPresenterRegistrations
.mockReset()
.mockImplementation((target) => target);
hoisted.resolveAgentRuntimePluginLoadPlan.mockReset().mockImplementation(({ config }) => ({
config,
pluginIds: ["codex", "memory-core"],
@@ -77,19 +85,25 @@ describe("agent runtime plugin registries", () => {
.mockImplementation((_config, selections) => selections);
});
it("adopts runtime context engines from the active composition-root registry", () => {
it("adopts full-only runtime capabilities from the active composition-root registry", () => {
const activeRegistry = { active: true };
const adopted = { handle: "adopted" };
const contextEnginesAdopted = { handle: "context-engines" };
const presentersAdopted = { handle: "presenters" };
hoisted.getActivePluginRegistry.mockReturnValue(activeRegistry);
hoisted.adoptRuntimeContextEngineRegistrations.mockReturnValue(adopted);
hoisted.adoptRuntimeContextEngineRegistrations.mockReturnValue(contextEnginesAdopted);
hoisted.adoptRuntimeWidgetPresenterRegistrations.mockReturnValue(presentersAdopted);
expect(
loadAgentRuntimePluginRegistryHandle({ config: {} as never, workspaceDir: "/tmp/workspace" }),
).toBe(adopted);
).toBe(presentersAdopted);
expect(hoisted.adoptRuntimeContextEngineRegistrations).toHaveBeenCalledWith(
{ handle: true },
activeRegistry,
);
expect(hoisted.adoptRuntimeWidgetPresenterRegistrations).toHaveBeenCalledWith(
contextEnginesAdopted,
activeRegistry,
);
});
it("keeps direct no-current loads on the requested workspace", () => {
+9 -4
View File
@@ -12,6 +12,7 @@ import {
getPluginRuntimeGatewayRequestScope,
withPluginRuntimeRegistryScope,
} from "../plugins/runtime/gateway-request-scope.js";
import { adoptRuntimeWidgetPresenterRegistrations } from "../plugins/widget-presenters.js";
import { resolveUserPath } from "../utils.js";
import {
resolveAgentRuntimePluginLoadPlan,
@@ -104,12 +105,16 @@ export function loadAgentRuntimePluginRegistryHandle(
): PluginRegistry {
const load = resolveAgentRuntimePluginRegistryLoad(params);
// Discovery-only load: full mode can replace process-global sandbox backends.
// Copy runtime context engines from the composition-root registry instead.
// Adopt full-only runtime capabilities from the matching composition-root owners.
const pluginRegistry = loadPluginRegistryHandle({ ...load.loadOptions, activate: false });
const activeRegistry = getActivePluginRegistry();
return activeRegistry
? adoptRuntimeContextEngineRegistrations(pluginRegistry, activeRegistry)
: pluginRegistry;
if (!activeRegistry) {
return pluginRegistry;
}
return adoptRuntimeWidgetPresenterRegistrations(
adoptRuntimeContextEngineRegistrations(pluginRegistry, activeRegistry),
activeRegistry,
);
}
/** Binds a scoped plugin generation when a direct host has no Gateway owner. */
+3 -3
View File
@@ -123,10 +123,10 @@ describe("tool-policy-pipeline", () => {
});
test.each([
{ expected: ["exec", "show_widget"], policy: { deny: ["canvas"] } },
{ expected: ["canvas"], policy: { allow: ["canvas"] } },
{ expected: ["exec"], policy: { deny: ["canvas"] } },
{ expected: ["canvas", "show_widget"], policy: { allow: ["canvas"] } },
])(
"does not apply the Canvas core alias to Discord-owned show_widget ($policy)",
"applies the Canvas family uniformly even when stale metadata claims show_widget ($policy)",
({ expected, policy }) => {
const tools = [{ name: "exec" }, { name: "show_widget" }, { name: "canvas" }];
const filtered = applyToolPolicyPipeline({
+1 -4
View File
@@ -222,10 +222,7 @@ function expandPluginGroups(
continue;
}
const tools = groups.byPlugin.get(normalized) ?? [];
// Discord owns its own show_widget; only alias names absent from plugin ownership metadata.
const promotedCoreTools = (
SHIPPED_PLUGIN_POLICY_FAMILY_CORE_TOOLS.get(normalized) ?? []
).filter((toolName) => !groups.all.includes(toolName));
const promotedCoreTools = SHIPPED_PLUGIN_POLICY_FAMILY_CORE_TOOLS.get(normalized) ?? [];
if (tools.length > 0 || promotedCoreTools.length > 0) {
expanded.push(...tools, ...promotedCoreTools);
continue;
+76
View File
@@ -0,0 +1,76 @@
import { access } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { WidgetPresenter } from "../plugins/plugin-registration.types.js";
import { resolveCanvasDocumentsDir } from "./documents.js";
import { createShowWidgetTool } from "./widget-tool.js";
import { buildWidgetDocument } from "./wrap.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("show_widget current-channel presentation", () => {
it("presents once without materializing an inline view", async () => {
const stateDir = tempDirs.make("openclaw-widget-presenter-");
const present = vi.fn(async () => ({
ok: true as const,
value: {
kind: "message" as const,
receipt: {
primaryPlatformMessageId: "message-1",
platformMessageIds: ["message-1"],
parts: [],
sentAt: 1,
},
},
}));
const context = {
messageChannel: "discord",
accountId: "work",
nativeChannelId: "channel-1",
currentChannelId: "channel:channel-1",
currentMessagingTarget: "discord:channel:channel-1",
sessionKey: "agent:main:discord",
};
const presenter: WidgetPresenter = {
target: "current_channel",
description: "Post in the current channel",
capabilities: { sourceKinds: ["html"], maxSourceBytes: 48 * 1024 },
match: (candidate) => candidate.messageChannel === "discord",
availability: async () => ({ ok: true, value: { available: true } }),
present,
};
const tool = createShowWidgetTool({
stateDir,
sessionId: "current-channel",
inlineClientAvailable: false,
presenters: [presenter],
presenterContext: context,
});
expect(tool.requiredClientCaps).toBeUndefined();
expect(
(tool.parameters as { properties?: { kind?: { enum?: string[] } } }).properties?.kind?.enum,
).toEqual(["html"]);
const result = await tool.execute("current-channel", {
title: "Status",
widget_code: "<p>ready</p>",
});
const parsed = JSON.parse(result.content[0]?.type === "text" ? result.content[0].text : "null");
expect(parsed).toMatchObject({
kind: "widget",
presentation: {
target: "current_channel",
title: "Status",
receipt: { primaryPlatformMessageId: "message-1" },
},
text: "Widget presented in the current channel as message message-1",
});
expect(present).toHaveBeenCalledExactlyOnceWith({
document: { kind: "html", html: buildWidgetDocument("Status", "<p>ready</p>") },
title: "Status",
context,
});
await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
});
});
+129 -5
View File
@@ -291,7 +291,7 @@ describe("show_widget", () => {
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => ({
ok: true,
value: { nodeId: "mac-panel", nodeName: "Studio" },
value: { kind: "node", nodeId: "mac-panel", nodeName: "Studio" },
}),
};
const withPresenterTool = createShowWidgetTool({ presenters: [presenter] });
@@ -323,7 +323,7 @@ describe("show_widget", () => {
}));
const present = vi.fn(async () => ({
ok: true as const,
value: { nodeId: "mac-panel", nodeName: "Studio" },
value: { kind: "node" as const, nodeId: "mac-panel", nodeName: "Studio" },
}));
const result = await executeWidget({
stateDir,
@@ -345,9 +345,13 @@ describe("show_widget", () => {
expect(result.resultText).toContain("presented on Studio (mac-panel)");
expect(availability).toHaveBeenCalledWith({ sessionKey: "agent:main:status" });
expect(present).toHaveBeenCalledWith({
documentUrlPath: result.url,
document: {
kind: "html",
html: expect.stringContaining("<p>ready</p>"),
hostedUrl: result.url,
},
title: "Status",
sessionContext: { sessionKey: "agent:main:status" },
context: { sessionKey: "agent:main:status" },
});
});
@@ -407,13 +411,133 @@ describe("show_widget", () => {
expect(result.target).toBe("assistant_message");
expect(result.resultText).toContain(expected);
expect(result.resultText).toContain("available inline here");
expect(result.resultText).toMatch(/Pair a canvas-capable device|Open the OpenClaw app/u);
expect(result.resultText).toMatch(
/Pair a canvas-capable device|Retry the requested presentation destination/u,
);
await expect(
access(resolveCanvasDocumentDir(stateDir, result.viewId)),
).resolves.toBeUndefined();
},
);
it("enforces current-presenter source kinds and byte limits in core", async () => {
registerDiagramContentKind();
const presenter: WidgetPresenter = {
target: "current_channel",
description: "HTML-only current channel",
capabilities: { sourceKinds: ["html"], maxSourceBytes: 8 },
match: () => true,
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => {
throw new Error("present must not run");
},
};
const tool = createShowWidgetTool({
inlineClientAvailable: false,
presenters: [presenter],
presenterContext: {},
});
const kindSchema = (tool.parameters as { properties?: { kind?: { enum?: string[] } } })
.properties?.kind;
expect(kindSchema?.enum).toEqual(["html"]);
expect(tool.description).not.toContain("registered kinds are diagram");
await expect(
tool.execute("oversized-current", { title: "Large", widget_code: "123456789" }),
).rejects.toThrow("widget_code exceeds maximum size (8 bytes)");
await expect(
tool.execute("unsupported-current", {
title: "Diagram",
widget_code: "diagram:ready",
kind: "diagram",
}),
).rejects.toThrow("inline widget hosting is disabled");
});
it("fails visibly without inline fallback and uses a real inline route when available", async () => {
const presenter: WidgetPresenter = {
target: "current_channel",
description: "Failing current channel",
capabilities: { sourceKinds: ["html"] },
match: () => true,
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => ({
ok: false,
error: { code: "presentation_error", message: "delivery rejected" },
}),
};
const noInline = createShowWidgetTool({
inlineClientAvailable: false,
presenters: [presenter],
presenterContext: {},
});
await expect(
noInline.execute("no-inline", { title: "Status", widget_code: "<p>ready</p>" }),
).rejects.toThrow("Widget presentation failed: delivery rejected");
const stateDir = await createStateDir();
const withInline = createShowWidgetTool({
stateDir,
sessionId: "inline-fallback",
inlineClientAvailable: true,
presenters: [presenter],
presenterContext: {},
});
const fallback = await withInline.execute("with-inline", {
title: "Status",
widget_code: "<p>ready</p>",
});
const parsed = JSON.parse(
fallback.content[0]?.type === "text" ? fallback.content[0].text : "null",
);
expect(parsed).toMatchObject({
kind: "canvas",
presentation: { target: "assistant_message" },
text: expect.stringContaining("delivery rejected. The widget is available inline here."),
});
});
it("reports pin success and presentation failure as an explicit partial outcome", async () => {
const { callGateway } = createBoardPutCaller();
const presenter: WidgetPresenter = {
target: "current_channel",
description: "Failing current channel",
capabilities: { sourceKinds: ["html"] },
match: () => true,
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => ({
ok: false,
error: { code: "presentation_error", message: "delivery rejected" },
}),
};
const tool = createShowWidgetTool({
agentSessionKey: "agent:main:partial",
callGateway,
inlineClientAvailable: false,
presenters: [presenter],
presenterContext: {},
});
const result = await tool.execute("partial", {
title: "Status",
widget_code: "<p>ready</p>",
pin: true,
});
const parsed = JSON.parse(result.content[0]?.type === "text" ? result.content[0].text : "null");
expect(parsed).toMatchObject({
status: "partial",
boardWidgetName: "status",
presentation: {
target: "current_channel",
status: "failed",
error: { code: "presentation_error", message: "delivery rejected" },
},
text: expect.stringContaining(
"pinned to dashboard tab main as status, but presentation failed",
),
});
});
it("keeps the wrapped document bytes stable", () => {
const html = buildWidgetDocument(
"Status <live>",
+204 -105
View File
@@ -20,7 +20,8 @@ import type {
WidgetPresentationError,
WidgetPresentationSuccess,
WidgetPresenter,
WidgetPresenterTarget,
WidgetPresenterContext,
WidgetPresenterDocument,
} from "../plugins/plugin-registration.types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
@@ -44,10 +45,12 @@ function createShowWidgetToolSchema(
kinds: readonly string[],
presenters: readonly WidgetPresenter[],
) {
const presenterTargets = presenters.map((presenter) => presenter.target);
const presenterTargets = presenters.flatMap((presenter) =>
presenter.target === "current_channel" ? [] : [presenter.target],
);
const targets = ["assistant_message", ...presenterTargets] as const;
const presenterDescriptions = presenters.map(
(presenter) => `${presenter.target}: ${presenter.description}`,
const presenterDescriptions = presenters.flatMap((presenter) =>
presenter.target === "current_channel" ? [] : [`${presenter.target}: ${presenter.description}`],
);
return Type.Object({
title: Type.String(),
@@ -114,7 +117,9 @@ type ShowWidgetToolOptions = {
stateDir?: string;
callGateway?: InProcessGatewayCaller;
inlineHostEnabled?: boolean;
inlineClientAvailable?: boolean;
presenters?: readonly WidgetPresenter[];
presenterContext?: WidgetPresenterContext;
};
type WidgetPresentationAttempt =
@@ -122,55 +127,72 @@ type WidgetPresentationAttempt =
| { ok: false; error: WidgetPresentationError };
async function presentWidget(params: {
presenters: readonly WidgetPresenter[];
target: WidgetPresenterTarget;
documentUrlPath: string;
presenter?: WidgetPresenter;
document: WidgetPresenterDocument;
title: string;
sessionKey?: string;
context: WidgetPresenterContext;
}): Promise<WidgetPresentationAttempt> {
const presenters = params.presenters.filter((presenter) => presenter.target === params.target);
let lastError: WidgetPresentationError = {
code: "no_eligible_node",
message: "No widget presenter is registered for this target.",
};
for (const presenter of presenters) {
const sessionContext = params.sessionKey ? { sessionKey: params.sessionKey } : {};
let availability: Awaited<ReturnType<WidgetPresenter["availability"]>>;
try {
availability = await presenter.availability(sessionContext);
} catch (error) {
lastError = { code: "node_error", message: formatErrorMessage(error) };
continue;
}
if (!availability.ok) {
lastError = availability.error;
continue;
}
let result: Awaited<ReturnType<WidgetPresenter["present"]>>;
try {
result = await presenter.present({
documentUrlPath: params.documentUrlPath,
title: params.title,
sessionContext,
});
} catch (error) {
lastError = { code: "node_error", message: formatErrorMessage(error) };
continue;
}
if (result.ok) {
return result;
}
lastError = result.error;
const presenter = params.presenter;
if (!presenter) {
return {
ok: false,
error: {
code: "no_eligible_node",
message: "No widget presenter is registered for this target.",
},
};
}
const errorCode = presenter.target === "current_channel" ? "presentation_error" : "node_error";
try {
const availability = await presenter.availability(params.context);
if (!availability.ok) {
return availability;
}
return await presenter.present({
document: params.document,
title: params.title,
context: params.context,
});
} catch (error) {
return {
ok: false,
error: { code: errorCode, message: formatErrorMessage(error) },
};
}
return { ok: false, error: lastError };
}
function widgetPresentationFailureText(error: WidgetPresentationError): string {
export function resolveCurrentChannelWidgetPresenter(
presenters: readonly WidgetPresenter[],
context: WidgetPresenterContext,
): Extract<WidgetPresenter, { target: "current_channel" }> | undefined {
const matches = presenters.filter(
(presenter): presenter is Extract<WidgetPresenter, { target: "current_channel" }> => {
if (presenter.target !== "current_channel") {
return false;
}
try {
return presenter.match(context);
} catch {
return false;
}
},
);
return matches.length === 1 ? matches[0] : undefined;
}
function widgetPresentationFailureText(
error: WidgetPresentationError,
inlineAvailable: boolean,
): string {
const message = /[.!?]$/u.test(error.message) ? error.message : `${error.message}.`;
if (!inlineAvailable) {
return message;
}
const nextStep =
error.code === "no_eligible_node"
? "Pair a canvas-capable device or open the OpenClaw app, then retry."
: "Open the OpenClaw app and retry.";
return `${error.message} The widget is available inline here. ${nextStep}`;
: "Retry the requested presentation destination when it is available.";
return `${message} The widget is available inline here. ${nextStep}`;
}
function slugWidgetName(title: string): string {
@@ -222,17 +244,35 @@ function assertPinnedWidgetDocumentSize(html: string): void {
export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAgentTool {
const gatewayCall = options.callGateway ?? callInProcessGatewayTool;
const inlineHostEnabled = options.inlineHostEnabled !== false;
const inlineAvailable = inlineHostEnabled && options.inlineClientAvailable !== false;
const registeredKinds = listBoardWidgetContentKinds(currentPluginRegistry());
const kinds = ["html", ...registeredKinds] as const;
const allKinds = ["html", ...registeredKinds] as const;
const presenters = options.presenters ?? [];
const presenterContext =
options.presenterContext ??
(options.agentSessionKey ? { sessionKey: options.agentSessionKey } : {});
const currentChannelPresenter = resolveCurrentChannelWidgetPresenter(
presenters,
presenterContext,
);
const kinds =
currentChannelPresenter && !inlineAvailable
? allKinds.filter((kind) => currentChannelPresenter.capabilities.sourceKinds.includes(kind))
: allKinds;
const advertisedRegisteredKinds = kinds.filter((kind) => kind !== "html");
const explicitPresenters = presenters.filter(
(presenter) => presenter.target !== "current_channel",
);
const presenterPrompt =
presenters.length > 0 ? " Use presentation.target to choose a registered device surface." : "";
explicitPresenters.length > 0
? " Use presentation.target to choose a registered device surface."
: "";
return {
label: "Show Widget",
name: "show_widget",
description: `Visual helps? Make widget. Do not wait for ask. Use for comparisons, trends, timelines, flows, hierarchies, dashboards, status, progress, layouts, and choices. Text clearer? Skip. Show a widget on the user's current surface; kind defaults to html${registeredKinds.length ? ` and registered kinds are ${registeredKinds.join(", ")}` : ""}. ${inlineHostEnabled ? "Set pin=true to also place it on this session's dashboard" : "Inline hosting is disabled; set pin=true to place it on this session's dashboard"}; use name for a stable widget id, tab for a tab slug, size sm|md|lg|xl|full, presentation.frame card|full-bleed|frameless, and after for a sibling widget anchor. Pinned widgets may declare capabilities.netOrigins and capabilities.tools for operator approval. HTML widgets are self-contained HTML or SVG. Dashboard host APIs: openclaw.prompt.send(text), openclaw.state.emit(payload), openclaw.data.read(bindingId, params?), and openclaw.cron.trigger(jobId). \`title\` is host metadata. Start directly with content; do not repeat the title or recreate dashboard chrome. HTML is pre-themed with --surface --card --elevated --text --text-strong --muted --border --border-strong --accent --accent-fill --accent-fg --ok --warn --danger --info --radius --font-body --font-mono.${presenterPrompt}`,
parameters: createShowWidgetToolSchema(kinds, presenters),
requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS,
description: `Visual helps? Make widget. Do not wait for ask. Use for comparisons, trends, timelines, flows, hierarchies, dashboards, status, progress, layouts, and choices. Text clearer? Skip. Show a widget on the user's current surface; kind defaults to html${advertisedRegisteredKinds.length ? ` and registered kinds are ${advertisedRegisteredKinds.join(", ")}` : ""}. ${inlineHostEnabled ? "Set pin=true to also place it on this session's dashboard" : "Inline hosting is disabled; set pin=true to place it on this session's dashboard"}; use name for a stable widget id, tab for a tab slug, size sm|md|lg|xl|full, presentation.frame card|full-bleed|frameless, and after for a sibling widget anchor. Pinned widgets may declare capabilities.netOrigins and capabilities.tools for operator approval. HTML widgets are self-contained HTML or SVG. Dashboard host APIs: openclaw.prompt.send(text), openclaw.state.emit(payload), openclaw.data.read(bindingId, params?), and openclaw.cron.trigger(jobId). \`title\` is host metadata. Start directly with content; do not repeat the title or recreate dashboard chrome. HTML is pre-themed with --surface --card --elevated --text --text-strong --muted --border --border-strong --accent --accent-fill --accent-fg --ok --warn --danger --info --radius --font-body --font-mono.${presenterPrompt}`,
parameters: createShowWidgetToolSchema(kinds, explicitPresenters),
...(currentChannelPresenter ? {} : { requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS }),
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const kind = readToolStringParam(params, "kind") ?? "html";
@@ -277,30 +317,41 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
throw new WidgetHtmlInputError(`invalid ${kind} widget source: ${String(error)}`);
}
}
if (!inlineHostEnabled && !shouldPin) {
const currentPresenterSupportsKind =
currentChannelPresenter?.target === "current_channel" &&
currentChannelPresenter.capabilities.sourceKinds.includes(kind);
const wantsCurrentChannel =
requestedTarget === "assistant_message" && currentPresenterSupportsKind;
const wantsNodePanel = requestedTarget === "node_panel";
if (!inlineAvailable && !wantsCurrentChannel && !wantsNodePanel && !shouldPin) {
throw new WidgetHtmlInputError(
"inline widget hosting is disabled; set pin=true to place the widget on the session dashboard",
);
}
const wrappedDocument = inlineHostEnabled
? buildWidgetDocument(
if (wantsCurrentChannel && currentChannelPresenter?.target === "current_channel") {
const { maxSourceBytes } = currentChannelPresenter.capabilities;
if (maxSourceBytes !== undefined) {
assertWidgetHtmlSize(rawWidgetCode, maxSourceBytes, { inputName: "widget_code" });
}
}
const composedWidget = registration
? registration.definition.composeDocument({
source: widgetCode,
title,
registration
? registration.definition.composeDocument({
source: widgetCode,
title,
resourceUrls: Object.fromEntries(
registration.definition.resources.paths.map((resourcePath) => [
resourcePath,
resourcePath,
]),
),
promptGranted: false,
})
: widgetCode,
registration ? { scriptOrigins: ["'self'"] } : {},
)
: undefined;
resourceUrls: Object.fromEntries(
registration.definition.resources.paths.map((resourcePath) => [
resourcePath,
resourcePath,
]),
),
promptGranted: false,
})
: widgetCode;
const wrappedDocument = buildWidgetDocument(
title,
composedWidget,
registration ? { scriptOrigins: ["'self'"] } : {},
);
let pinnedText = "";
let pinnedWidgetName: string | undefined;
if (pinSessionKey) {
@@ -349,46 +400,92 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
snapshot.resolvedWidgetName
}${size ? ` (${size})` : ""}`;
}
if (!wrappedDocument) {
const hasPresentationRoute = inlineAvailable || wantsCurrentChannel || wantsNodePanel;
if (!hasPresentationRoute) {
return jsonResult({
status: "pinned",
boardWidgetName: pinnedWidgetName,
text: `Widget ${pinnedText}`,
});
}
// Pin first: placement validation can fail, and a rejected board write
// must not materialize or prune the bounded inline-document store.
const document = await createCanvasDocument(
{
kind: "html_bundle",
let document: Awaited<ReturnType<typeof createCanvasDocument>> | undefined;
const hostDocument = async () =>
(document ??= await createCanvasDocument(
{
kind: "html_bundle",
title,
entrypoint: { type: "html", value: wrappedDocument },
surface: "assistant_message",
retentionScope: resolveRetentionScope(options),
// Direct navigation must not run widget script as the Control UI origin.
cspSandbox: "scripts",
},
{
stateDir: options.stateDir,
maxDocumentsPerScope: WIDGET_MAX_PER_SCOPE,
},
));
let presentationAttempt: WidgetPresentationAttempt | undefined;
if (wantsCurrentChannel && currentChannelPresenter) {
presentationAttempt = await presentWidget({
presenter: currentChannelPresenter,
document: { kind: "html", html: wrappedDocument },
title,
entrypoint: { type: "html", value: wrappedDocument },
surface: "assistant_message",
retentionScope: resolveRetentionScope(options),
// Direct navigation must not run widget script as the Control UI origin.
cspSandbox: "scripts",
},
{
stateDir: options.stateDir,
maxDocumentsPerScope: WIDGET_MAX_PER_SCOPE,
},
);
const presentationAttempt =
requestedTarget === "node_panel"
? await presentWidget({
presenters,
target: requestedTarget,
documentUrlPath: document.entryUrl,
title,
sessionKey: options.agentSessionKey,
})
context: presenterContext,
});
} else if (wantsNodePanel) {
const hosted = await hostDocument();
presentationAttempt = await presentWidget({
presenter: explicitPresenters.find((presenter) => presenter.target === "node_panel"),
document: { kind: "html", html: wrappedDocument, hostedUrl: hosted.entryUrl },
title,
context: presenterContext,
});
}
if (presentationAttempt?.ok && presentationAttempt.value.kind === "message") {
const receipt = presentationAttempt.value.receipt;
const messageId = receipt.primaryPlatformMessageId ?? receipt.platformMessageIds[0];
return jsonResult({
kind: "widget",
presentation: {
target: "current_channel",
title,
receipt,
},
...(pinnedWidgetName ? { boardWidgetName: pinnedWidgetName } : {}),
text: `Widget presented in the current channel${messageId ? ` as message ${messageId}` : ""}${pinnedText ? `; ${pinnedText}` : ""}`,
});
}
if (presentationAttempt && !presentationAttempt.ok && !inlineAvailable) {
const failureText = widgetPresentationFailureText(presentationAttempt.error, false);
if (pinnedWidgetName) {
return jsonResult({
status: "partial",
boardWidgetName: pinnedWidgetName,
presentation: {
target: requestedTarget === "node_panel" ? "node_panel" : "current_channel",
status: "failed",
error: presentationAttempt.error,
},
text: `Widget ${pinnedText}, but presentation failed: ${failureText}`,
});
}
throw new WidgetHtmlInputError(`Widget presentation failed: ${failureText}`);
}
const hosted = await hostDocument();
const presentedNode =
presentationAttempt?.ok && presentationAttempt.value.kind === "node"
? presentationAttempt.value
: undefined;
const presented = presentationAttempt?.ok ? presentationAttempt.value : undefined;
const target = presented ? "node_panel" : "assistant_message";
const presentationText = presentationAttempt?.ok
? `; presented on ${presentationAttempt.value.nodeName ?? presentationAttempt.value.nodeId} (${presentationAttempt.value.nodeId})`
: presentationAttempt
? `; ${widgetPresentationFailureText(presentationAttempt.error)}`
const target = presentedNode ? "node_panel" : "assistant_message";
const presentationText = presentedNode
? `; presented on ${presentedNode.nodeName ?? presentedNode.nodeId} (${presentedNode.nodeId})`
: presentationAttempt && !presentationAttempt.ok
? `; ${widgetPresentationFailureText(presentationAttempt.error, true)}`
: "";
return jsonResult({
kind: "canvas",
@@ -396,14 +493,16 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
target,
title,
sandbox: "scripts",
...(presented ? { node: { id: presented.nodeId, name: presented.nodeName } } : {}),
...(presentedNode
? { node: { id: presentedNode.nodeId, name: presentedNode.nodeName } }
: {}),
},
view: {
id: document.id,
url: document.entryUrl,
id: hosted.id,
url: hosted.entryUrl,
...(pinnedWidgetName ? { boardWidgetName: pinnedWidgetName } : {}),
},
text: `Widget hosted at ${document.entryUrl}${pinnedText ? `; ${pinnedText}` : ""}${presentationText}`,
text: `Widget hosted at ${hosted.entryUrl}${pinnedText ? `; ${pinnedText}` : ""}${presentationText}`,
});
},
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@ import { DESKTOP_FIELD_HELP } from "./zod-schema.desktop.js";
export const CORE_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.",
"Discord Activities configuration for presenting core show_widget documents inside Discord. Leave unset to keep Activity routes, presentation, 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":
+43 -14
View File
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import type { Result } from "@openclaw/normalization-core/result";
import type { Command } from "commander";
import type { MessageReceipt } from "../channels/message/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type {
DiagnosticEventPrivateData,
@@ -11,6 +12,7 @@ import type {
} from "../infra/diagnostic-events.js";
import type { DiagnosticTracePropagationBridge as DiagnosticTracePropagationBridgeContract } from "../infra/diagnostic-trace-propagation.js";
import type { SecurityAuditFinding } from "../security/audit.types.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import type { PluginLogger } from "./logger-types.js";
type ChannelPlugin = import("../channels/plugins/types.plugin.js").ChannelPlugin;
@@ -68,34 +70,61 @@ export type OpenClawPluginHostedMediaResolver = (
mediaUrl: string,
) => string | null | undefined | Promise<string | null | undefined>;
export type WidgetPresenterTarget = "node_panel";
type WidgetPresenterSessionContext = {
export type WidgetPresenterContext = Readonly<{
messageChannel?: string;
accountId?: string;
deliveryContext?: Readonly<DeliveryContext>;
nativeChannelId?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
sessionKey?: string;
};
}>;
export type WidgetPresenterDocument = Readonly<{
kind: "html";
html: string;
hostedUrl?: string;
}>;
export type WidgetPresentationError =
| { code: "no_eligible_node"; message: string }
| { code: "node_error"; message: string; nodeId?: string };
| { code: "node_error"; message: string; nodeId?: string }
| { code: "unavailable"; message: string }
| { code: "presentation_error"; message: string };
export type WidgetPresentationSuccess = {
nodeId: string;
nodeName?: string;
};
export type WidgetPresentationSuccess =
| { kind: "node"; nodeId: string; nodeName?: string }
| { kind: "message"; receipt: MessageReceipt };
export type WidgetPresenter = {
target: WidgetPresenterTarget;
type WidgetPresenterBase = {
description: string;
availability: (
sessionContext: WidgetPresenterSessionContext,
context: WidgetPresenterContext,
) => Promise<Result<{ available: true }, WidgetPresentationError>>;
present: (params: {
documentUrlPath: string;
document: WidgetPresenterDocument;
title: string;
sessionContext: WidgetPresenterSessionContext;
context: WidgetPresenterContext;
}) => Promise<Result<WidgetPresentationSuccess, WidgetPresentationError>>;
};
export type WidgetPresenter = WidgetPresenterBase &
(
| {
target: "node_panel";
match?: never;
capabilities?: never;
}
| {
target: "current_channel";
match: (context: WidgetPresenterContext) => boolean;
capabilities: Readonly<{
sourceKinds: readonly string[];
maxSourceBytes?: number;
}>;
}
);
export type OpenClawPluginCliContext = {
/**
* Command object where this plugin should register its commands.
+21 -4
View File
@@ -62,8 +62,22 @@ export function createOperationRegistrars(state: PluginRegistryState) {
const registerWidgetPresenter = (record: PluginRecord, presenter: WidgetPresenter) => {
const description = normalizeOptionalString(presenter.description);
const currentCapabilities =
presenter.target === "current_channel" ? presenter.capabilities : undefined;
const currentChannelValid =
presenter.target === "current_channel" &&
typeof presenter.match === "function" &&
currentCapabilities !== undefined &&
Array.isArray(currentCapabilities.sourceKinds) &&
currentCapabilities.sourceKinds.length > 0 &&
currentCapabilities.sourceKinds.every(
(kind) => typeof kind === "string" && kind.trim().length > 0,
) &&
(currentCapabilities.maxSourceBytes === undefined ||
(Number.isInteger(currentCapabilities.maxSourceBytes) &&
currentCapabilities.maxSourceBytes > 0));
if (
presenter.target !== "node_panel" ||
(presenter.target !== "node_panel" && !currentChannelValid) ||
!description ||
description.length > 160 ||
typeof presenter.availability !== "function" ||
@@ -77,9 +91,12 @@ export function createOperationRegistrars(state: PluginRegistryState) {
});
return;
}
const existing = registry.widgetPresenters.find(
(registration) => registration.presenter.target === presenter.target,
);
const existing =
presenter.target === "current_channel"
? undefined
: registry.widgetPresenters.find(
(registration) => registration.presenter.target === presenter.target,
);
if (existing) {
pushDiagnostic({
level: "error",
+97 -1
View File
@@ -2,9 +2,34 @@ import {
createPluginRegistryFixture,
registerTestPlugin,
} from "openclaw/plugin-sdk/plugin-test-contracts";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import type { WidgetPresenter } from "./plugin-registration.types.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js";
import { createPluginRecord } from "./status.test-fixtures.js";
import {
adoptRuntimeWidgetPresenterRegistrations,
resolveWidgetPresenters,
} from "./widget-presenters.js";
afterEach(() => {
resetPluginRuntimeStateForTest();
});
function currentPresenter(description: string): WidgetPresenter {
return {
target: "current_channel",
description,
capabilities: { sourceKinds: ["html"] },
match: () => true,
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => ({
ok: false,
error: { code: "unavailable", message: "not used" },
}),
};
}
describe("plugin widget presenter registry", () => {
it("registers one presenter for a target and rejects a competing owner", () => {
@@ -45,4 +70,75 @@ describe("plugin widget presenter registry", () => {
}),
);
});
it("allows multiple contextual presenters while keeping explicit targets unique", () => {
const { config, registry } = createPluginRegistryFixture();
for (const id of ["discord-presenter", "slack-presenter"]) {
registerTestPlugin({
registry,
config,
record: createPluginRecord({ id }),
register(api) {
api.registerWidgetPresenter({
target: "current_channel",
description: `Present through ${id}`,
capabilities: { sourceKinds: ["html"] },
match: (context) => context.messageChannel === id.split("-")[0],
availability: async () => ({ ok: true, value: { available: true } }),
present: async () => ({
ok: false,
error: { code: "unavailable", message: "not used" },
}),
});
},
});
}
expect(registry.registry.widgetPresenters.map(({ pluginId }) => pluginId)).toEqual([
"discord-presenter",
"slack-presenter",
]);
expect(registry.registry.diagnostics).toEqual([]);
});
it("adopts full-only presenters only from the matching lifecycle owner", () => {
const source = "/tmp/discord/index.ts";
const target = createEmptyPluginRegistry();
const runtime = createEmptyPluginRegistry();
target.plugins.push(createPluginRecord({ id: "discord", source }));
runtime.plugins.push(createPluginRecord({ id: "discord", source }));
runtime.widgetPresenters.push({
pluginId: "discord",
presenter: currentPresenter("Runtime Discord"),
source,
});
const adopted = adoptRuntimeWidgetPresenterRegistrations(target, runtime);
expect(adopted.widgetPresenters).toEqual(runtime.widgetPresenters);
target.plugins[0] = createPluginRecord({ id: "discord", source: "/tmp/other/index.ts" });
expect(adoptRuntimeWidgetPresenterRegistrations(target, runtime)).toBe(target);
});
it("keeps request-scoped presenters ahead of the active lifecycle registry", () => {
const active = createEmptyPluginRegistry();
const scoped = createEmptyPluginRegistry();
active.widgetPresenters.push({
pluginId: "active",
presenter: currentPresenter("Active"),
source: "/tmp/active/index.ts",
});
scoped.widgetPresenters.push({
pluginId: "scoped",
presenter: currentPresenter("Scoped"),
source: "/tmp/scoped/index.ts",
});
setActivePluginRegistry(active);
expect(
withPluginRuntimeRegistryScope(scoped, () =>
resolveWidgetPresenters().map(({ pluginId }) => pluginId),
),
).toEqual(["scoped"]);
});
});
-34
View File
@@ -1986,40 +1986,6 @@ describe("resolvePluginTools optional tools", () => {
expect(registry.diagnostics).toHaveLength(0);
});
it("keeps the Discord-owned show_widget contextual to Discord sessions", () => {
const registry = setRegistry([
{
pluginId: "discord",
optional: false,
source: "/tmp/discord.js",
names: ["show_widget"],
factory: (context) =>
(context as { messageChannel?: string }).messageChannel === "discord"
? { ...makeTool("show_widget"), description: "discord implementation" }
: null,
},
]);
const discordTools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), messageChannel: "discord" },
clientCaps: ["inline-widgets"],
}),
);
expect(discordTools.map((tool) => [tool.name, tool.description])).toEqual([
["show_widget", "discord implementation"],
]);
const webTools = resolvePluginTools(
createResolveToolsParams({
context: { ...createContext(), messageChannel: "webchat" },
clientCaps: ["inline-widgets"],
}),
);
expect(webTools).toHaveLength(0);
expect(registry.diagnostics).toHaveLength(0);
});
it("isolates tools with malformed required client capabilities", () => {
const registry = setRegistry([
{
+41 -1
View File
@@ -1,7 +1,47 @@
import type { PluginWidgetPresenterRegistration } from "./registry-types.js";
import type { PluginWidgetPresenterRegistration, PluginRegistry } from "./registry-types.js";
import { getActivePluginRegistry } from "./runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js";
function hasMatchingLoadedOwner(
registration: PluginWidgetPresenterRegistration,
targetRegistry: PluginRegistry,
runtimeRegistry: PluginRegistry,
): boolean {
const target = targetRegistry.plugins.find((plugin) => plugin.id === registration.pluginId);
const runtime = runtimeRegistry.plugins.find((plugin) => plugin.id === registration.pluginId);
return (
target?.status === "loaded" &&
runtime?.status === "loaded" &&
target.source === runtime.source &&
registration.source === runtime.source
);
}
/** Copies full-only presenters into a matching discovery registry without rerunning plugin code. */
export function adoptRuntimeWidgetPresenterRegistrations(
targetRegistry: PluginRegistry,
runtimeRegistry: PluginRegistry,
): PluginRegistry {
const presenters = [...targetRegistry.widgetPresenters];
let changed = false;
for (const registration of runtimeRegistry.widgetPresenters) {
if (!hasMatchingLoadedOwner(registration, targetRegistry, runtimeRegistry)) {
continue;
}
const conflicts = presenters.some((candidate) =>
registration.presenter.target === "current_channel"
? candidate.pluginId === registration.pluginId &&
candidate.presenter.target === registration.presenter.target
: candidate.presenter.target === registration.presenter.target,
);
if (!conflicts) {
presenters.push(registration);
changed = true;
}
}
return changed ? { ...targetRegistry, widgetPresenters: presenters } : targetRegistry;
}
/** Returns presenter registrations from the exact request registry when available. */
export function resolveWidgetPresenters(): readonly PluginWidgetPresenterRegistration[] {
const registry =
@@ -0,0 +1,399 @@
import { writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
GatewayClient,
startGatewayClientWhenEventLoopReady,
} from "openclaw/plugin-sdk/gateway-runtime";
import { afterEach, describe, expect, it } from "vitest";
import {
type MockOpenAiRequestSnapshot,
startQaGatewayChild,
startQaMockOpenAiServer,
} from "../../../../extensions/qa-lab/api.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../../..");
const MODEL_REF = "mock-openai/gpt-5.6-luna";
const DISCORD_CHANNEL_ID = "789";
const DISCORD_MESSAGE_ID = "1000000000000000001";
const DISCORD_APPLICATION_ID = "123456789012345678";
const DISCORD_SESSION_KEY = `agent:qa:discord:channel:${DISCORD_CHANNEL_ID}`;
const INLINE_SESSION_KEY = "agent:qa:inline-widget-proof";
const INVENTORY_MARKER = "DISCORD_WIDGET_PRESENTER_INVENTORY";
type JsonRecord = Record<string, unknown>;
type DiscordRestRequest = { method: string; pathname: string; body?: JsonRecord };
type ToolsInvokeResult = {
ok: boolean;
source?: string;
output?: { details?: JsonRecord };
error?: { code?: string; message?: string };
};
async function readRequestBody(req: IncomingMessage): Promise<JsonRecord | undefined> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.from(chunk));
}
if (chunks.length === 0) {
return undefined;
}
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as JsonRecord)
: undefined;
}
function writeJson(res: ServerResponse, statusCode: number, value: unknown): void {
const body = JSON.stringify(value);
res.writeHead(statusCode, {
"content-length": Buffer.byteLength(body),
"content-type": "application/json",
});
res.end(body);
}
async function startDiscordRestLoopback() {
const requests: DiscordRestRequest[] = [];
const server = createServer(async (req, res) => {
try {
const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
const method = req.method ?? "GET";
const body = await readRequestBody(req);
requests.push({ method, pathname, ...(body ? { body } : {}) });
if (method === "GET" && pathname === `/api/v10/channels/${DISCORD_CHANNEL_ID}`) {
writeJson(res, 200, { id: DISCORD_CHANNEL_ID, type: 0 });
return;
}
if (method === "POST" && pathname === `/api/v10/channels/${DISCORD_CHANNEL_ID}/messages`) {
writeJson(res, 200, { id: DISCORD_MESSAGE_ID, channel_id: DISCORD_CHANNEL_ID });
return;
}
writeJson(res, 404, { message: `unexpected Discord REST request: ${method} ${pathname}` });
} catch (error) {
writeJson(res, 500, { message: error instanceof Error ? error.message : String(error) });
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Discord REST loopback did not bind a TCP port");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
stop: async () =>
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
),
};
}
function configureDiscordActivities(cfg: OpenClawConfig): OpenClawConfig {
return {
...cfg,
tools: {
...cfg.tools,
alsoAllow: [...(cfg.tools?.alsoAllow ?? []), "show_widget"],
},
};
}
const discordTransport = {
requiredPluginIds: ["discord"],
createGatewayConfig: () => ({
channels: {
discord: {
enabled: true,
token: "qa-activities-token",
applicationId: DISCORD_APPLICATION_ID,
activities: {
clientSecret: "qa-activities-client-secret",
applicationId: DISCORD_APPLICATION_ID,
},
},
},
}),
};
async function writeDiscordFetchPreload(root: string): Promise<string> {
const preloadPath = path.join(root, "discord-rest-preload.mjs");
await writeFile(
preloadPath,
`const originalFetch = globalThis.fetch.bind(globalThis);
const loopbackBase = process.env.OPENCLAW_QA_DISCORD_REST_BASE;
if (!loopbackBase) throw new Error("OPENCLAW_QA_DISCORD_REST_BASE is required");
globalThis.fetch = async (input, init) => {
const sourceUrl = new URL(input instanceof Request ? input.url : String(input));
if (sourceUrl.origin === "https://discord.com" && sourceUrl.pathname.startsWith("/api/")) {
const target = new URL(loopbackBase);
target.pathname = sourceUrl.pathname;
target.search = sourceUrl.search;
return input instanceof Request
? await originalFetch(new Request(target, input), init)
: await originalFetch(target, init);
}
return await originalFetch(input, init);
};
`,
"utf8",
);
return preloadPath;
}
async function readMockRequests(baseUrl: string): Promise<MockOpenAiRequestSnapshot[]> {
const response = await fetch(`${baseUrl}/debug/requests`);
if (!response.ok) {
throw new Error(`mock request log failed with HTTP ${response.status}`);
}
return (await response.json()) as MockOpenAiRequestSnapshot[];
}
function countToolDeclarations(value: unknown, name: string): number {
if (Array.isArray(value)) {
return value.reduce((sum, item) => sum + countToolDeclarations(item, name), 0);
}
if (!value || typeof value !== "object") {
return 0;
}
const record = value as JsonRecord;
const current = record.name === name && record.type === "function" ? 1 : 0;
return current + countToolDeclarations(record.tools, name);
}
function findOpenWidgetButton(value: unknown): JsonRecord | undefined {
if (Array.isArray(value)) {
return value.map(findOpenWidgetButton).find(Boolean);
}
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as JsonRecord;
if (record.label === "Open widget" && typeof record.custom_id === "string") {
return record;
}
return Object.values(record).map(findOpenWidgetButton).find(Boolean);
}
async function postShowWidget(params: {
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>;
accountId: string;
messageChannel: string;
messageTo: string;
}) {
const response = await fetch(`${params.gateway.baseUrl}/tools/invoke`, {
method: "POST",
headers: {
authorization: `Bearer ${params.gateway.token}`,
"content-type": "application/json",
"x-openclaw-account-id": params.accountId,
"x-openclaw-message-channel": params.messageChannel,
"x-openclaw-message-to": params.messageTo,
},
body: JSON.stringify({
tool: "show_widget",
sessionKey: DISCORD_SESSION_KEY,
args: { title: "Activity proof", widget_code: "<button>Proof</button>" },
}),
});
return { status: response.status, body: (await response.json()) as JsonRecord };
}
async function connectInlineClient(
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
): Promise<GatewayClient> {
let resolveConnected!: () => void;
let rejectConnected!: (error: Error) => void;
const connected = new Promise<void>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
});
const client = new GatewayClient({
url: gateway.wsUrl,
token: gateway.token,
clientName: "gateway-client",
deviceIdentity: null,
mode: "backend",
scopes: ["operator.admin"],
caps: ["inline-widgets"],
requestTimeoutMs: 20_000,
onHelloOk: resolveConnected,
onConnectError: rejectConnected,
});
client.start();
const readiness = await startGatewayClientWhenEventLoopReady(client, { timeoutMs: 20_000 });
if (!readiness.ready) {
await client.stopAndWait().catch(() => undefined);
throw new Error("inline Gateway client event loop did not become ready");
}
await connected;
return client;
}
describe("Discord show_widget contextual presenter process proof", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
const errors: unknown[] = [];
for (const cleanup of cleanups.splice(0).toReversed()) {
try {
await cleanup();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, "Discord show_widget process proof cleanup failed");
}
});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it(
"routes one core tool through Discord and keeps mismatched and inline paths honest",
{ timeout: 180_000 },
async () => {
process.stdout.write("[discord-widget-e2e] starting isolated Gateway proof\n");
const progress = setInterval(() => {
process.stdout.write("[discord-widget-e2e] Gateway proof still running\n");
}, 10_000);
progress.unref();
cleanups.push(async () => clearInterval(progress));
const scratch = tempDirs.make("openclaw-discord-widget-e2e-");
const discord = await startDiscordRestLoopback();
cleanups.push(() => discord.stop());
const preloadPath = await writeDiscordFetchPreload(scratch);
const mock = await startQaMockOpenAiServer();
cleanups.push(() => mock.stop());
const gateway = await startQaGatewayChild({
repoRoot: REPO_ROOT,
useRepoCli: true,
providerBaseUrl: `${mock.baseUrl}/v1`,
providerMode: "mock-openai",
primaryModel: MODEL_REF,
alternateModel: MODEL_REF,
transport: discordTransport,
transportBaseUrl: "http://127.0.0.1:9",
controlUiEnabled: false,
mutateConfig: configureDiscordActivities,
runtimeEnvPatch: {
DISCORD_BOT_TOKEN: "qa-activities-token",
NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`,
OPENCLAW_QA_DISCORD_REST_BASE: discord.baseUrl,
OPENCLAW_SKIP_CANVAS_HOST: undefined,
OPENCLAW_SKIP_CHANNELS: "1",
},
});
cleanups.push(() => gateway.stop());
const started = (await gateway.call("chat.send", {
sessionKey: DISCORD_SESSION_KEY,
message: `${INVENTORY_MARKER}: reply exactly INVENTORY_OK without calling tools.`,
originatingChannel: "discord",
originatingTo: `channel:${DISCORD_CHANNEL_ID}`,
originatingAccountId: "default",
deliver: false,
idempotencyKey: "discord-widget-inventory",
})) as { runId?: string; status?: string };
expect(started.status).toBe("started");
expect(started.runId).toBeTruthy();
await expect(
gateway.call(
"agent.wait",
{ runId: started.runId, timeoutMs: 60_000 },
{ timeoutMs: 65_000 },
),
).resolves.toMatchObject({ status: "ok" });
const request = (await readMockRequests(mock.baseUrl)).find((entry) =>
entry.allInputText.includes(INVENTORY_MARKER),
);
expect(request, gateway.logs()).toBeDefined();
expect(
countToolDeclarations([request?.body.tools, request?.body.dynamicTools], "show_widget"),
gateway.logs(),
).toBe(1);
const presented = await postShowWidget({
gateway,
accountId: "default",
messageChannel: "discord",
messageTo: `channel:${DISCORD_CHANNEL_ID}`,
});
expect(presented.status, JSON.stringify(presented.body)).toBe(200);
expect(presented.body).toMatchObject({
ok: true,
result: {
details: {
kind: "widget",
presentation: {
target: "current_channel",
receipt: {
primaryPlatformMessageId: DISCORD_MESSAGE_ID,
parts: [expect.objectContaining({ kind: "card" })],
},
},
},
},
});
const post = discord.requests.find((entry) => entry.method === "POST");
expect(discord.requests.map(({ method, pathname }) => ({ method, pathname }))).toEqual([
{ method: "GET", pathname: `/api/v10/channels/${DISCORD_CHANNEL_ID}` },
{ method: "POST", pathname: `/api/v10/channels/${DISCORD_CHANNEL_ID}/messages` },
]);
expect(post?.body).toMatchObject({ enforce_nonce: true });
const button = findOpenWidgetButton(post?.body);
expect(button).toMatchObject({ label: "Open widget" });
expect(button?.custom_id).toMatch(/^ocactivity1_[A-Za-z0-9_-]{22}$/u);
const postsAfterSuccess = discord.requests.filter((entry) => entry.method === "POST").length;
for (const mismatch of [
{
accountId: "missing",
messageChannel: "discord",
messageTo: `channel:${DISCORD_CHANNEL_ID}`,
},
{ accountId: "default", messageChannel: "discord", messageTo: "user:789" },
{
accountId: "default",
messageChannel: "slack",
messageTo: `channel:${DISCORD_CHANNEL_ID}`,
},
]) {
const hidden = await postShowWidget({ gateway, ...mismatch });
expect(hidden.status, JSON.stringify({ mismatch, hidden: hidden.body })).toBe(404);
}
expect(discord.requests.filter((entry) => entry.method === "POST")).toHaveLength(
postsAfterSuccess,
);
const inlineClient = await connectInlineClient(gateway);
cleanups.push(() => inlineClient.stopAndWait());
const inline = await inlineClient.request<ToolsInvokeResult>("tools.invoke", {
name: "show_widget",
sessionKey: INLINE_SESSION_KEY,
args: { title: "Inline proof", widget_code: "<p>inline</p>" },
});
expect(inline).toMatchObject({
ok: true,
source: "core",
output: {
details: {
kind: "canvas",
presentation: { target: "assistant_message" },
view: { url: expect.stringContaining("/__openclaw__/canvas/documents/") },
},
},
});
expect(discord.requests.filter((entry) => entry.method === "POST")).toHaveLength(
postsAfterSuccess,
);
},
);
});