mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 23:47:20 +00:00
Plugin SDK: centralize message tool discovery and context
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
161
src/channels/plugins/message-tool-schema.ts
Normal file
161
src/channels/plugins/message-tool-schema.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -56,9 +56,11 @@ export type {
|
||||
ChannelLogSink,
|
||||
ChannelMentionAdapter,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionDiscoveryContext,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessagingAdapter,
|
||||
ChannelMeta,
|
||||
ChannelMessageToolSchemaContribution,
|
||||
ChannelOutboundTargetMode,
|
||||
ChannelPollContext,
|
||||
ChannelPollResult,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user