Plugin SDK: centralize message tool discovery and context

This commit is contained in:
Gustavo Madeira Santana
2026-03-17 23:47:45 +00:00
parent 4e912bffd8
commit a14ad01d66
13 changed files with 737 additions and 297 deletions

View File

@@ -15,6 +15,14 @@ import { defaultRuntime } from "../runtime.js";
export function listChannelSupportedActions(params: {
cfg?: OpenClawConfig;
channel?: string;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
}): ChannelMessageActionName[] {
if (!params.channel) {
return [];
@@ -24,7 +32,18 @@ export function listChannelSupportedActions(params: {
return [];
}
const cfg = params.cfg ?? ({} as OpenClawConfig);
return runPluginListActions(plugin, cfg);
return runPluginListActions(plugin, {
cfg,
currentChannelId: params.currentChannelId,
currentChannelProvider: params.channel,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.accountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
});
}
/**
@@ -32,6 +51,14 @@ export function listChannelSupportedActions(params: {
*/
export function listAllChannelSupportedActions(params: {
cfg?: OpenClawConfig;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
}): ChannelMessageActionName[] {
const actions = new Set<ChannelMessageActionName>();
for (const plugin of listChannelPlugins()) {
@@ -39,7 +66,18 @@ export function listAllChannelSupportedActions(params: {
continue;
}
const cfg = params.cfg ?? ({} as OpenClawConfig);
const channelActions = runPluginListActions(plugin, cfg);
const channelActions = runPluginListActions(plugin, {
cfg,
currentChannelId: params.currentChannelId,
currentChannelProvider: plugin.id,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.accountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
});
for (const action of channelActions) {
actions.add(action);
}
@@ -86,13 +124,13 @@ const loggedListActionErrors = new Set<string>();
function runPluginListActions(
plugin: ChannelPlugin,
cfg: OpenClawConfig,
context: Parameters<NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>>[0],
): ChannelMessageActionName[] {
if (!plugin.actions?.listActions) {
return [];
}
try {
const listed = plugin.actions.listActions({ cfg });
const listed = plugin.actions.listActions(context);
return Array.isArray(listed) ? listed : [];
} catch (err) {
logListActionsError(plugin.id, err);

View File

@@ -135,6 +135,7 @@ export function createOpenClawTools(
: createMessageTool({
agentAccountId: options?.agentAccountId,
agentSessionKey: options?.agentSessionKey,
sessionId: options?.sessionId,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.agentChannel,

View File

@@ -649,11 +649,19 @@ export async function compactEmbeddedPiSessionDirect(
return undefined;
})()
: undefined;
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: sessionAgentId,
})
: undefined;
const messageToolHints = runtimeChannel
@@ -680,10 +688,6 @@ export async function compactEmbeddedPiSessionDirect(
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode =
isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey)

View File

@@ -1623,6 +1623,13 @@ export async function runEmbeddedAttempt(
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.agentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: sessionAgentId,
})
: undefined;
const messageToolHints = runtimeChannel

View File

@@ -1,10 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
import {
createDiscordMessageToolComponentsSchema,
createMessageToolButtonsSchema,
createSlackMessageToolBlocksSchema,
createTelegramPollExtraToolSchemas,
} from "../../channels/plugins/message-tool-schema.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createMessageTool } from "./message-tool.js";
type CreateMessageTool = typeof import("./message-tool.js").createMessageTool;
type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry;
type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry;
let createMessageTool: CreateMessageTool;
let setActivePluginRegistry: SetActivePluginRegistry;
let createTestRegistry: CreateTestRegistry;
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
@@ -50,7 +60,7 @@ function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
} satisfies MessageActionRunResult);
}
function getToolProperties(tool: ReturnType<typeof createMessageTool>) {
function getToolProperties(tool: ReturnType<CreateMessageTool>) {
return (tool.parameters as { properties?: Record<string, unknown> }).properties ?? {};
}
@@ -58,13 +68,17 @@ function getActionEnum(properties: Record<string, unknown>) {
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
mocks.runMessageAction.mockReset();
mocks.loadConfig.mockReset().mockReturnValue({});
mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
resolvedConfig: config,
diagnostics: [],
}));
({ setActivePluginRegistry } = await import("../../plugins/runtime.js"));
({ createTestRegistry } = await import("../../test-utils/channel-plugins.js"));
({ createMessageTool } = await import("./message-tool.js"));
});
function createChannelPlugin(params: {
@@ -75,6 +89,7 @@ function createChannelPlugin(params: {
actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
capabilities?: readonly ChannelMessageCapability[];
toolSchema?: NonNullable<NonNullable<ChannelPlugin["actions"]>["getToolSchema"]>;
messaging?: ChannelPlugin["messaging"];
}): ChannelPlugin {
const actionCapabilities = params.capabilities;
@@ -102,6 +117,7 @@ function createChannelPlugin(params: {
...(actionCapabilities
? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities }
: {}),
...(params.toolSchema ? { getToolSchema: params.toolSchema } : {}),
},
};
}
@@ -219,6 +235,17 @@ describe("message tool schema scoping", () => {
blurb: "Telegram test plugin.",
actions: ["send", "react", "poll"],
capabilities: ["interactive", "buttons"],
toolSchema: () => [
{
properties: {
buttons: createMessageToolButtonsSchema(),
},
},
{
properties: createTelegramPollExtraToolSchemas(),
visibility: "all-configured",
},
],
});
const discordPlugin = createChannelPlugin({
@@ -228,6 +255,11 @@ describe("message tool schema scoping", () => {
blurb: "Discord test plugin.",
actions: ["send", "poll", "poll-vote"],
capabilities: ["interactive", "components"],
toolSchema: () => ({
properties: {
components: createDiscordMessageToolComponentsSchema(),
},
}),
});
const slackPlugin = createChannelPlugin({
@@ -237,6 +269,11 @@ describe("message tool schema scoping", () => {
blurb: "Slack test plugin.",
actions: ["send", "react"],
capabilities: ["interactive", "blocks"],
toolSchema: () => ({
properties: {
blocks: createSlackMessageToolBlocksSchema(),
},
}),
});
afterEach(() => {
@@ -365,6 +402,25 @@ describe("message tool schema scoping", () => {
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
},
capabilities: ["interactive", "buttons"],
toolSchema: ({ cfg }) => {
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
.channels?.telegram;
return [
{
properties: {
buttons: createMessageToolButtonsSchema(),
},
},
...(telegramCfg?.actions?.poll === false
? []
: [
{
properties: createTelegramPollExtraToolSchemas(),
visibility: "all-configured" as const,
},
]),
];
},
});
setActivePluginRegistry(
@@ -393,6 +449,95 @@ describe("message tool schema scoping", () => {
expect(properties.pollAnonymous).toBeUndefined();
expect(properties.pollPublic).toBeUndefined();
});
it("uses discovery account scope for capability-gated shared fields", () => {
const scopedInteractivePlugin = createChannelPlugin({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send"],
toolSchema: () => null,
});
scopedInteractivePlugin.actions = {
...scopedInteractivePlugin.actions,
getCapabilities: ({ accountId }) => (accountId === "ops" ? ["interactive"] : []),
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: scopedInteractivePlugin },
]),
);
const scopedTool = createMessageTool({
config: {} as never,
currentChannelProvider: "telegram",
agentAccountId: "ops",
});
const unscopedTool = createMessageTool({
config: {} as never,
currentChannelProvider: "telegram",
});
expect(getToolProperties(scopedTool).interactive).toBeDefined();
expect(getToolProperties(unscopedTool).interactive).toBeUndefined();
});
it("routes full discovery context into plugin action discovery", () => {
const seenContexts: Record<string, unknown>[] = [];
const contextPlugin = createChannelPlugin({
id: "discord",
label: "Discord",
docsPath: "/channels/discord",
blurb: "Discord context plugin.",
listActions: (ctx) => {
seenContexts.push({ phase: "listActions", ...ctx });
return ["send", "react"];
},
toolSchema: (ctx) => {
seenContexts.push({ phase: "getToolSchema", ...ctx });
return null;
},
});
contextPlugin.actions = {
...contextPlugin.actions,
getCapabilities: (ctx) => {
seenContexts.push({ phase: "getCapabilities", ...ctx });
return ["interactive"];
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]),
);
createMessageTool({
config: {} as never,
currentChannelProvider: "discord",
currentChannelId: "channel:123",
currentThreadTs: "thread-456",
currentMessageId: "msg-789",
agentAccountId: "ops",
agentSessionKey: "agent:alpha:main",
sessionId: "session-123",
requesterSenderId: "user-42",
});
expect(seenContexts).toContainEqual(
expect.objectContaining({
currentChannelProvider: "discord",
currentChannelId: "channel:123",
currentThreadTs: "thread-456",
currentMessageId: "msg-789",
accountId: "ops",
sessionKey: "agent:alpha:main",
sessionId: "session-123",
agentId: "alpha",
requesterSenderId: "user-42",
}),
);
});
});
describe("message tool description", () => {
@@ -405,7 +550,27 @@ describe("message tool description", () => {
label: "BlueBubbles",
docsPath: "/channels/bluebubbles",
blurb: "BlueBubbles test plugin.",
actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"],
listActions: ({ currentChannelId }) => {
const all: ChannelMessageActionName[] = [
"react",
"renameGroup",
"addParticipant",
"removeParticipant",
"leaveGroup",
];
const lowered = currentChannelId?.toLowerCase() ?? "";
const isDmTarget =
lowered.includes("chat_guid:imessage;-;") || lowered.includes("chat_guid:sms;-;");
return isDmTarget
? all.filter(
(action) =>
action !== "renameGroup" &&
action !== "addParticipant" &&
action !== "removeParticipant" &&
action !== "leaveGroup",
)
: all;
},
messaging: {
normalizeTarget: (raw) => {
const trimmed = raw.trim().replace(/^bluebubbles:/i, "");

View File

@@ -1,10 +1,10 @@
import { Type } from "@sinclair/typebox";
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import {
channelSupportsMessageCapability,
channelSupportsMessageCapabilityForChannel,
listChannelMessageActions,
resolveChannelMessageToolSchemaProperties,
} from "../../channels/plugins/message-actions.js";
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
import {
@@ -18,7 +18,6 @@ import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
@@ -53,116 +52,6 @@ function buildRoutingSchema() {
};
}
const discordComponentEmojiSchema = Type.Object({
name: Type.String(),
id: Type.Optional(Type.String()),
animated: Type.Optional(Type.Boolean()),
});
const discordComponentOptionSchema = Type.Object({
label: Type.String(),
value: Type.String(),
description: Type.Optional(Type.String()),
emoji: Type.Optional(discordComponentEmojiSchema),
default: Type.Optional(Type.Boolean()),
});
const discordComponentButtonSchema = Type.Object({
label: Type.String(),
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])),
url: Type.Optional(Type.String()),
emoji: Type.Optional(discordComponentEmojiSchema),
disabled: Type.Optional(Type.Boolean()),
allowedUsers: Type.Optional(
Type.Array(
Type.String({
description: "Discord user ids or names allowed to interact with this button.",
}),
),
),
});
const discordComponentSelectSchema = Type.Object({
type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])),
placeholder: Type.Optional(Type.String()),
minValues: Type.Optional(Type.Number()),
maxValues: Type.Optional(Type.Number()),
options: Type.Optional(Type.Array(discordComponentOptionSchema)),
});
const discordComponentBlockSchema = Type.Object({
type: Type.String(),
text: Type.Optional(Type.String()),
texts: Type.Optional(Type.Array(Type.String())),
accessory: Type.Optional(
Type.Object({
type: Type.String(),
url: Type.Optional(Type.String()),
button: Type.Optional(discordComponentButtonSchema),
}),
),
spacing: Type.Optional(stringEnum(["small", "large"])),
divider: Type.Optional(Type.Boolean()),
buttons: Type.Optional(Type.Array(discordComponentButtonSchema)),
select: Type.Optional(discordComponentSelectSchema),
items: Type.Optional(
Type.Array(
Type.Object({
url: Type.String(),
description: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
}),
),
),
file: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
});
const discordComponentModalFieldSchema = Type.Object({
type: Type.String(),
name: Type.Optional(Type.String()),
label: Type.String(),
description: Type.Optional(Type.String()),
placeholder: Type.Optional(Type.String()),
required: Type.Optional(Type.Boolean()),
options: Type.Optional(Type.Array(discordComponentOptionSchema)),
minValues: Type.Optional(Type.Number()),
maxValues: Type.Optional(Type.Number()),
minLength: Type.Optional(Type.Number()),
maxLength: Type.Optional(Type.Number()),
style: Type.Optional(stringEnum(["short", "paragraph"])),
});
const discordComponentModalSchema = Type.Object({
title: Type.String(),
triggerLabel: Type.Optional(Type.String()),
triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])),
fields: Type.Array(discordComponentModalFieldSchema),
});
const discordComponentMessageSchema = Type.Object(
{
text: Type.Optional(Type.String()),
reusable: Type.Optional(
Type.Boolean({
description: "Allow components to be used multiple times until they expire.",
}),
),
container: Type.Optional(
Type.Object({
accentColor: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
}),
),
blocks: Type.Optional(Type.Array(discordComponentBlockSchema)),
modal: Type.Optional(discordComponentModalSchema),
},
{
description:
"Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.",
},
);
const interactiveOptionSchema = Type.Object({
label: Type.String(),
value: Type.String(),
@@ -192,13 +81,7 @@ const interactiveMessageSchema = Type.Object(
},
);
function buildSendSchema(options: {
includeInteractive: boolean;
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeBlocks: boolean;
}) {
function buildSendSchema(options: { includeInteractive: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
@@ -240,57 +123,10 @@ function buildSendSchema(options: {
}),
),
interactive: Type.Optional(interactiveMessageSchema),
buttons: Type.Optional(
Type.Array(
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
style: Type.Optional(stringEnum(["danger", "success", "primary"])),
}),
),
{
description: "Telegram inline keyboard buttons (array of button rows)",
},
),
),
card: Type.Optional(
Type.Object(
{},
{
additionalProperties: true,
description: "Adaptive Card JSON object (when supported by the channel)",
},
),
),
components: Type.Optional(discordComponentMessageSchema),
blocks: Type.Optional(
Type.Array(
Type.Object(
{},
{
additionalProperties: true,
description: "Slack Block Kit payload blocks (Slack only).",
},
),
),
),
};
if (!options.includeButtons) {
delete props.buttons;
}
if (!options.includeInteractive) {
delete props.interactive;
}
if (!options.includeCards) {
delete props.card;
}
if (!options.includeComponents) {
delete props.components;
}
if (!options.includeBlocks) {
delete props.blocks;
}
return props;
}
@@ -330,7 +166,7 @@ function buildFetchSchema() {
};
}
function buildPollSchema(options?: { includeTelegramExtras?: boolean }) {
function buildPollSchema() {
const props: Record<string, unknown> = {
pollId: Type.Optional(Type.String()),
pollOptionId: Type.Optional(
@@ -363,7 +199,7 @@ function buildPollSchema(options?: { includeTelegramExtras?: boolean }) {
};
for (const name of POLL_CREATION_PARAM_NAMES) {
const def = POLL_CREATION_PARAM_DEFS[name];
if (def.telegramOnly && !options?.includeTelegramExtras) {
if (def.telegramOnly) {
continue;
}
switch (def.kind) {
@@ -510,18 +346,14 @@ function buildChannelManagementSchema() {
function buildMessageToolSchemaProps(options: {
includeInteractive: boolean;
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeBlocks: boolean;
includeTelegramPollExtras: boolean;
extraProperties?: Record<string, unknown>;
}) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
...buildReactionSchema(),
...buildFetchSchema(),
...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }),
...buildPollSchema(),
...buildChannelTargetSchema(),
...buildStickerSchema(),
...buildThreadSchema(),
@@ -530,6 +362,7 @@ function buildMessageToolSchemaProps(options: {
...buildGatewaySchema(),
...buildChannelManagementSchema(),
...buildPresenceSchema(),
...options.extraProperties,
};
}
@@ -537,11 +370,7 @@ function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: {
includeInteractive: boolean;
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeBlocks: boolean;
includeTelegramPollExtras: boolean;
extraProperties?: Record<string, unknown>;
},
) {
const props = buildMessageToolSchemaProps(options);
@@ -553,16 +382,12 @@ function buildMessageToolSchemaFromActions(
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeInteractive: true,
includeButtons: true,
includeCards: true,
includeComponents: true,
includeBlocks: true,
includeTelegramPollExtras: true,
});
type MessageToolOptions = {
agentAccountId?: string;
agentSessionKey?: string;
sessionId?: string;
config?: OpenClawConfig;
currentChannelId?: string;
currentChannelProvider?: string;
@@ -579,16 +404,27 @@ function resolveMessageToolSchemaActions(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
currentAccountId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
requesterSenderId?: string;
}): string[] {
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
if (currentChannel) {
const scopedActions = filterActionsForContext({
actions: listChannelSupportedActions({
cfg: params.cfg,
channel: currentChannel,
}),
const scopedActions = listChannelSupportedActions({
cfg: params.cfg,
channel: currentChannel,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.currentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
});
const allActions = new Set<string>(["send", ...scopedActions]);
// Include actions from other configured channels so isolated/cron agents
@@ -611,6 +447,14 @@ function resolveIncludeCapability(
params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
currentAccountId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
requesterSenderId?: string;
},
capability: ChannelMessageCapability,
): boolean {
@@ -620,6 +464,14 @@ function resolveIncludeCapability(
{
cfg: params.cfg,
channel: currentChannel,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.currentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
},
capability,
);
@@ -627,70 +479,50 @@ function resolveIncludeCapability(
return channelSupportsMessageCapability(params.cfg, capability);
}
function resolveIncludeComponents(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "components");
}
function resolveIncludeInteractive(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
currentAccountId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
requesterSenderId?: string;
}): boolean {
return resolveIncludeCapability(params, "interactive");
}
function resolveIncludeButtons(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "buttons");
}
function resolveIncludeCards(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "cards");
}
function resolveIncludeBlocks(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "blocks");
}
function resolveIncludeTelegramPollExtras(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return listChannelSupportedActions({
cfg: params.cfg,
channel: "telegram",
}).includes("poll");
}
function buildMessageToolSchema(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
currentAccountId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
requesterSenderId?: string;
}) {
const actions = resolveMessageToolSchemaActions(params);
const includeInteractive = resolveIncludeInteractive(params);
const includeButtons = resolveIncludeButtons(params);
const includeCards = resolveIncludeCards(params);
const includeComponents = resolveIncludeComponents(params);
const includeBlocks = resolveIncludeBlocks(params);
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
const extraProperties = resolveChannelMessageToolSchemaProperties({
cfg: params.cfg,
channel: normalizeMessageChannel(params.currentChannelProvider),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.currentAccountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
});
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeInteractive,
includeButtons,
includeCards,
includeComponents,
includeBlocks,
includeTelegramPollExtras,
extraProperties,
});
}
@@ -702,49 +534,33 @@ function resolveAgentAccountId(value?: string): string | undefined {
return normalizeAccountId(trimmed);
}
function filterActionsForContext(params: {
actions: ChannelMessageActionName[];
channel?: string;
currentChannelId?: string;
}): ChannelMessageActionName[] {
const channel = normalizeMessageChannel(params.channel);
if (!channel || channel !== "bluebubbles") {
return params.actions;
}
const currentChannelId = params.currentChannelId?.trim();
if (!currentChannelId) {
return params.actions;
}
const normalizedTarget =
normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId;
const lowered = normalizedTarget.trim().toLowerCase();
const isGroupTarget =
lowered.startsWith("chat_guid:") ||
lowered.startsWith("chat_id:") ||
lowered.startsWith("chat_identifier:") ||
lowered.startsWith("group:");
if (isGroupTarget) {
return params.actions;
}
return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action));
}
function buildMessageToolDescription(options?: {
config?: OpenClawConfig;
currentChannel?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
currentAccountId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
requesterSenderId?: string;
}): string {
const baseDescription = "Send, delete, and manage messages via channel plugins.";
// If we have a current channel, show its actions and list other configured channels
if (options?.currentChannel) {
const channelActions = filterActionsForContext({
actions: listChannelSupportedActions({
cfg: options.config,
channel: options.currentChannel,
}),
const channelActions = listChannelSupportedActions({
cfg: options.config,
channel: options.currentChannel,
currentChannelId: options.currentChannelId,
currentThreadTs: options.currentThreadTs,
currentMessageId: options.currentMessageId,
accountId: options.currentAccountId,
sessionKey: options.sessionKey,
sessionId: options.sessionId,
agentId: options.agentId,
requesterSenderId: options.requesterSenderId,
});
if (channelActions.length > 0) {
// Always include "send" as a base action
@@ -785,17 +601,37 @@ function buildMessageToolDescription(options?: {
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const resolvedAgentId = options?.agentSessionKey
? resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: options?.config,
})
: undefined;
const schema = options?.config
? buildMessageToolSchema({
cfg: options.config,
currentChannelProvider: options.currentChannelProvider,
currentChannelId: options.currentChannelId,
currentThreadTs: options.currentThreadTs,
currentMessageId: options.currentMessageId,
currentAccountId: agentAccountId,
sessionKey: options.agentSessionKey,
sessionId: options.sessionId,
agentId: resolvedAgentId,
requesterSenderId: options.requesterSenderId,
})
: MessageToolSchema;
const description = buildMessageToolDescription({
config: options?.config,
currentChannel: options?.currentChannelProvider,
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
currentMessageId: options?.currentMessageId,
currentAccountId: agentAccountId,
sessionKey: options?.agentSessionKey,
sessionId: options?.sessionId,
agentId: resolvedAgentId,
requesterSenderId: options?.requesterSenderId,
});
return {
@@ -917,9 +753,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
gateway,
toolContext,
sessionKey: options?.agentSessionKey,
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
sessionId: options?.sessionId,
agentId: resolvedAgentId,
sandboxRoot: options?.sandboxRoot,
abortSignal: signal,
});

View File

@@ -1,9 +1,15 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type { ChannelMessageCapability } from "./message-capabilities.js";
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
import type {
ChannelMessageActionContext,
ChannelMessageActionDiscoveryContext,
ChannelMessageActionName,
ChannelMessageToolSchemaContribution,
} from "./types.js";
type ChannelActions = NonNullable<NonNullable<ReturnType<typeof getChannelPlugin>>["actions"]>;
@@ -38,11 +44,11 @@ function logMessageActionError(params: {
function runListActionsSafely(params: {
pluginId: string;
cfg: OpenClawConfig;
context: ChannelMessageActionDiscoveryContext;
listActions: NonNullable<ChannelActions["listActions"]>;
}): ChannelMessageActionName[] {
try {
const listed = params.listActions({ cfg: params.cfg });
const listed = params.listActions(params.context);
return Array.isArray(listed) ? listed : [];
} catch (error) {
logMessageActionError({
@@ -62,7 +68,7 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
}
const list = runListActionsSafely({
pluginId: plugin.id,
cfg,
context: { cfg },
listActions: plugin.actions.listActions,
});
for (const action of list) {
@@ -75,10 +81,10 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
function listCapabilities(params: {
pluginId: string;
actions: ChannelActions;
cfg: OpenClawConfig;
context: ChannelMessageActionDiscoveryContext;
}): readonly ChannelMessageCapability[] {
try {
return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? [];
return params.actions.getCapabilities?.(params.context) ?? [];
} catch (error) {
logMessageActionError({
pluginId: params.pluginId,
@@ -98,7 +104,7 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess
for (const capability of listCapabilities({
pluginId: plugin.id,
actions: plugin.actions,
cfg,
context: { cfg },
})) {
capabilities.add(capability);
}
@@ -109,6 +115,14 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess
export function listChannelMessageCapabilitiesForChannel(params: {
cfg: OpenClawConfig;
channel?: string;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
}): ChannelMessageCapability[] {
if (!params.channel) {
return [];
@@ -119,12 +133,119 @@ export function listChannelMessageCapabilitiesForChannel(params: {
listCapabilities({
pluginId: plugin.id,
actions: plugin.actions,
cfg: params.cfg,
context: {
cfg: params.cfg,
currentChannelProvider: params.channel,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.accountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
},
}),
)
: [];
}
function logMessageActionSchemaError(params: { pluginId: string; error: unknown }) {
const message = params.error instanceof Error ? params.error.message : String(params.error);
const key = `${params.pluginId}:getToolSchema:${message}`;
if (loggedMessageActionErrors.has(key)) {
return;
}
loggedMessageActionErrors.add(key);
const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null;
defaultRuntime.error?.(
`[message-actions] ${params.pluginId}.actions.getToolSchema failed: ${stack ?? message}`,
);
}
function normalizeToolSchemaContributions(
value:
| ChannelMessageToolSchemaContribution
| ChannelMessageToolSchemaContribution[]
| null
| undefined,
): ChannelMessageToolSchemaContribution[] {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function mergeToolSchemaProperties(
target: Record<string, TSchema>,
source: Record<string, TSchema> | undefined,
) {
if (!source) {
return;
}
for (const [name, schema] of Object.entries(source)) {
if (!(name in target)) {
target[name] = schema;
}
}
}
export function resolveChannelMessageToolSchemaProperties(params: {
cfg: OpenClawConfig;
channel?: string;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
}): Record<string, TSchema> {
const properties: Record<string, TSchema> = {};
const plugins = listChannelPlugins();
const currentChannel = params.channel?.trim() || undefined;
const discoveryBase: ChannelMessageActionDiscoveryContext = {
cfg: params.cfg,
currentChannelId: params.currentChannelId,
currentChannelProvider: currentChannel,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
accountId: params.accountId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
agentId: params.agentId,
requesterSenderId: params.requesterSenderId,
};
for (const plugin of plugins) {
const getToolSchema = plugin?.actions?.getToolSchema;
if (!plugin || !getToolSchema) {
continue;
}
try {
const contributions = normalizeToolSchemaContributions(getToolSchema(discoveryBase));
for (const contribution of contributions) {
const visibility = contribution.visibility ?? "current-channel";
if (currentChannel) {
if (visibility === "all-configured" || plugin.id === currentChannel) {
mergeToolSchemaProperties(properties, contribution.properties);
}
continue;
}
mergeToolSchemaProperties(properties, contribution.properties);
}
} catch (error) {
logMessageActionSchemaError({
pluginId: plugin.id,
error,
});
}
}
return properties;
}
export function channelSupportsMessageCapability(
cfg: OpenClawConfig,
capability: ChannelMessageCapability,
@@ -136,6 +257,14 @@ export function channelSupportsMessageCapabilityForChannel(
params: {
cfg: OpenClawConfig;
channel?: string;
currentChannelId?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
},
capability: ChannelMessageCapability,
): boolean {

View File

@@ -0,0 +1,161 @@
import { Type } from "@sinclair/typebox";
import type { TSchema } from "@sinclair/typebox";
import { stringEnum } from "../../agents/schema/typebox.js";
const discordComponentEmojiSchema = Type.Object({
name: Type.String(),
id: Type.Optional(Type.String()),
animated: Type.Optional(Type.Boolean()),
});
const discordComponentOptionSchema = Type.Object({
label: Type.String(),
value: Type.String(),
description: Type.Optional(Type.String()),
emoji: Type.Optional(discordComponentEmojiSchema),
default: Type.Optional(Type.Boolean()),
});
const discordComponentButtonSchema = Type.Object({
label: Type.String(),
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])),
url: Type.Optional(Type.String()),
emoji: Type.Optional(discordComponentEmojiSchema),
disabled: Type.Optional(Type.Boolean()),
allowedUsers: Type.Optional(
Type.Array(
Type.String({
description: "Discord user ids or names allowed to interact with this button.",
}),
),
),
});
const discordComponentSelectSchema = Type.Object({
type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])),
placeholder: Type.Optional(Type.String()),
minValues: Type.Optional(Type.Number()),
maxValues: Type.Optional(Type.Number()),
options: Type.Optional(Type.Array(discordComponentOptionSchema)),
});
const discordComponentBlockSchema = Type.Object({
type: Type.String(),
text: Type.Optional(Type.String()),
texts: Type.Optional(Type.Array(Type.String())),
accessory: Type.Optional(
Type.Object({
type: Type.String(),
url: Type.Optional(Type.String()),
button: Type.Optional(discordComponentButtonSchema),
}),
),
spacing: Type.Optional(stringEnum(["small", "large"])),
divider: Type.Optional(Type.Boolean()),
buttons: Type.Optional(Type.Array(discordComponentButtonSchema)),
select: Type.Optional(discordComponentSelectSchema),
items: Type.Optional(
Type.Array(
Type.Object({
url: Type.String(),
description: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
}),
),
),
file: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
});
const discordComponentModalFieldSchema = Type.Object({
type: Type.String(),
name: Type.Optional(Type.String()),
label: Type.String(),
description: Type.Optional(Type.String()),
placeholder: Type.Optional(Type.String()),
required: Type.Optional(Type.Boolean()),
options: Type.Optional(Type.Array(discordComponentOptionSchema)),
minValues: Type.Optional(Type.Number()),
maxValues: Type.Optional(Type.Number()),
minLength: Type.Optional(Type.Number()),
maxLength: Type.Optional(Type.Number()),
style: Type.Optional(stringEnum(["short", "paragraph"])),
});
const discordComponentModalSchema = Type.Object({
title: Type.String(),
triggerLabel: Type.Optional(Type.String()),
triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])),
fields: Type.Array(discordComponentModalFieldSchema),
});
export function createMessageToolButtonsSchema(): TSchema {
return Type.Array(
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
style: Type.Optional(stringEnum(["danger", "success", "primary"])),
}),
),
{
description: "Button rows for channels that support button-style actions.",
},
);
}
export function createMessageToolCardSchema(): TSchema {
return Type.Object(
{},
{
additionalProperties: true,
description: "Structured card payload for channels that support card-style messages.",
},
);
}
export function createDiscordMessageToolComponentsSchema(): TSchema {
return Type.Object(
{
text: Type.Optional(Type.String()),
reusable: Type.Optional(
Type.Boolean({
description: "Allow components to be used multiple times until they expire.",
}),
),
container: Type.Optional(
Type.Object({
accentColor: Type.Optional(Type.String()),
spoiler: Type.Optional(Type.Boolean()),
}),
),
blocks: Type.Optional(Type.Array(discordComponentBlockSchema)),
modal: Type.Optional(discordComponentModalSchema),
},
{
description:
"Discord components v2 payload. Set reusable=true to keep buttons, selects, and forms active until expiry.",
},
);
}
export function createSlackMessageToolBlocksSchema(): TSchema {
return Type.Array(
Type.Object(
{},
{
additionalProperties: true,
description: "Slack Block Kit payload blocks (Slack only).",
},
),
);
}
export function createTelegramPollExtraToolSchemas(): Record<string, TSchema> {
return {
pollDurationHours: Type.Optional(Type.Number()),
pollDurationSeconds: Type.Optional(Type.Number()),
pollAnonymous: Type.Optional(Type.Boolean()),
pollPublic: Type.Optional(Type.Boolean()),
};
}

View File

@@ -21,6 +21,37 @@ export type ChannelAgentTool = AgentTool<TSchema, unknown> & {
export type ChannelAgentToolFactory = (params: { cfg?: OpenClawConfig }) => ChannelAgentTool[];
/**
* Discovery-time inputs passed to channel action adapters when the core is
* asking what an agent should be allowed to see. This is intentionally
* smaller than execution context: it carries routing/account scope, but no
* tool params or runtime handles.
*/
export type ChannelMessageActionDiscoveryContext = {
cfg: OpenClawConfig;
currentChannelId?: string | null;
currentChannelProvider?: string | null;
currentThreadTs?: string | null;
currentMessageId?: string | number | null;
accountId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
};
/**
* Plugin-owned schema fragments for the shared `message` tool.
* `current-channel` means expose the fields only when that provider is the
* active runtime channel. `all-configured` keeps the fields visible even while
* another configured channel is active, which is useful for cross-channel
* sends from cron or isolated agents.
*/
export type ChannelMessageToolSchemaContribution = {
properties: Record<string, TSchema>;
visibility?: "current-channel" | "all-configured";
};
export type ChannelSetupInput = {
name?: string;
token?: string;
@@ -424,6 +455,9 @@ export type ChannelMessageActionContext = {
* never be sourced from tool/model-controlled params.
*/
requesterSenderId?: string | null;
sessionKey?: string | null;
sessionId?: string | null;
agentId?: string | null;
gateway?: {
url?: string;
token?: string;
@@ -449,9 +483,23 @@ export type ChannelMessageActionAdapter = {
* not inferred from `outbound.sendPoll`, so channels that want agents to
* create polls should include `"poll"` here when enabled.
*/
listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
listActions?: (params: ChannelMessageActionDiscoveryContext) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[];
getCapabilities?: (
params: ChannelMessageActionDiscoveryContext,
) => readonly ChannelMessageCapability[];
/**
* Extend the shared `message` tool schema with channel-owned fields.
* Keep this aligned with `listActions` and `getCapabilities` so the exposed
* schema matches what the channel can actually execute in the current scope.
*/
getToolSchema?: (
params: ChannelMessageActionDiscoveryContext,
) =>
| ChannelMessageToolSchemaContribution
| ChannelMessageToolSchemaContribution[]
| null
| undefined;
requiresTrustedRequesterSender?: (params: {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;

View File

@@ -56,9 +56,11 @@ export type {
ChannelLogSink,
ChannelMentionAdapter,
ChannelMessageActionAdapter,
ChannelMessageActionDiscoveryContext,
ChannelMessageActionContext,
ChannelMessagingAdapter,
ChannelMeta,
ChannelMessageToolSchemaContribution,
ChannelOutboundTargetMode,
ChannelPollContext,
ChannelPollResult,

View File

@@ -112,6 +112,50 @@ describe("runMessageAction plugin dispatch", () => {
}),
);
});
it("routes execution context ids into plugin handleAction", async () => {
await runMessageAction({
cfg: {
channels: {
feishu: {
enabled: true,
},
},
} as OpenClawConfig,
action: "pin",
params: {
channel: "feishu",
messageId: "om_123",
},
defaultAccountId: "ops",
requesterSenderId: "trusted-user",
sessionKey: "agent:alpha:main",
sessionId: "session-123",
agentId: "alpha",
toolContext: {
currentChannelId: "chat:oc_123",
currentThreadTs: "thread-456",
currentMessageId: "msg-789",
},
dryRun: false,
});
expect(handleAction).toHaveBeenLastCalledWith(
expect.objectContaining({
action: "pin",
accountId: "ops",
requesterSenderId: "trusted-user",
sessionKey: "agent:alpha:main",
sessionId: "session-123",
agentId: "alpha",
toolContext: expect.objectContaining({
currentChannelId: "chat:oc_123",
currentThreadTs: "thread-456",
currentMessageId: "msg-789",
}),
}),
);
});
});
describe("media caption behavior", () => {

View File

@@ -96,6 +96,7 @@ export type RunMessageActionParams = {
params: Record<string, unknown>;
defaultAccountId?: string;
requesterSenderId?: string | null;
sessionId?: string;
toolContext?: ChannelThreadingToolContext;
gateway?: MessageActionRunnerGateway;
deps?: OutboundSendDeps;
@@ -675,7 +676,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
}
async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx;
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal, agentId } = ctx;
throwIfAborted(abortSignal);
const action = input.action as Exclude<ChannelMessageActionName, "send" | "poll" | "broadcast">;
if (dryRun) {
@@ -701,6 +702,9 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
params,
accountId: accountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
sessionKey: input.sessionKey,
sessionId: input.sessionId,
agentId,
gateway,
toolContext: input.toolContext,
dryRun,
@@ -836,6 +840,7 @@ export async function runMessageAction(
dryRun,
gateway,
input,
agentId: resolvedAgentId,
abortSignal: input.abortSignal,
});
}

View File

@@ -34,6 +34,7 @@ export type * from "../channels/plugins/types.js";
export * from "../channels/plugins/config-writes.js";
export * from "../channels/plugins/directory-config.js";
export * from "../channels/plugins/media-payload.js";
export * from "../channels/plugins/message-tool-schema.js";
export * from "../channels/plugins/normalize/signal.js";
export * from "../channels/plugins/normalize/whatsapp.js";
export * from "../channels/plugins/outbound/direct-text-media.js";