openclaw/src/agents/tools/message-tool.ts
Michelle Tilley 5af322f710 feat(discord): add set-presence action for bot activity and status
Bridge the agent tools layer to the Discord gateway WebSocket via a new
gateway registry, allowing agents to set the bot's activity and online
status. Supports playing, streaming, listening, watching, custom, and
competing activity types. Custom type uses activityState as the sidebar
text; other types show activityName in the sidebar and activityState in
the flyout. Opt-in via channels.discord.actions.presence (default false).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 04:02:38 +00:00

462 lines
15 KiB
TypeScript

import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import type { AnyAgentTool } from "./common.js";
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import {
listChannelMessageActions,
supportsChannelMessageButtons,
supportsChannelMessageCards,
} from "../../channels/plugins/message-actions.js";
import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../../channels/plugins/types.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 { normalizeAccountId } from "../../routing/session-key.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { listChannelSupportedActions } from "../channel-tools.js";
import { assertSandboxPath } from "../sandbox-paths.js";
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
function buildRoutingSchema() {
return {
channel: Type.Optional(Type.String()),
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })),
targets: Type.Optional(channelTargetsSchema()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
};
}
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
Type.String({
description: "Message effect name/id for sendWithEffect (e.g., invisible ink).",
}),
),
effect: Type.Optional(
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
),
media: Type.Optional(Type.String()),
filename: Type.Optional(Type.String()),
buffer: Type.Optional(
Type.String({
description: "Base64 payload for attachments (optionally a data: URL).",
}),
),
contentType: Type.Optional(Type.String()),
mimeType: Type.Optional(Type.String()),
caption: Type.Optional(Type.String()),
path: Type.Optional(Type.String()),
filePath: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
quoteText: Type.Optional(
Type.String({ description: "Quote text for Telegram reply_parameters" }),
),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(
Type.Array(
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
}),
),
{
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)",
},
),
),
};
if (!options.includeButtons) {
delete props.buttons;
}
if (!options.includeCards) {
delete props.card;
}
return props;
}
function buildReactionSchema() {
return {
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
};
}
function buildFetchSchema() {
return {
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
includeArchived: Type.Optional(Type.Boolean()),
};
}
function buildPollSchema() {
return {
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
};
}
function buildChannelTargetSchema() {
return {
channelId: Type.Optional(
Type.String({ description: "Channel id filter (search/thread list/event create)." }),
),
channelIds: Type.Optional(
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })),
),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
participant: Type.Optional(Type.String()),
};
}
function buildStickerSchema() {
return {
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
};
}
function buildThreadSchema() {
return {
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
};
}
function buildEventSchema() {
return {
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
};
}
function buildModerationSchema() {
return {
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
};
}
function buildGatewaySchema() {
return {
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
};
}
function buildPresenceSchema() {
return {
activityType: Type.Optional(
Type.String({
description: "Activity type: playing, streaming, listening, watching, competing, custom.",
}),
),
activityName: Type.Optional(
Type.String({
description: "Activity name shown in sidebar (e.g. 'with fire'). Ignored for custom type.",
}),
),
activityUrl: Type.Optional(
Type.String({
description:
"Streaming URL (Twitch or YouTube). Only used with streaming type; may not render for bots.",
}),
),
activityState: Type.Optional(
Type.String({
description:
"State text. For custom type this is the status text; for others it shows in the flyout.",
}),
),
status: Type.Optional(
Type.String({ description: "Bot status: online, dnd, idle, invisible." }),
),
};
}
function buildChannelManagementSchema() {
return {
name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",
}),
),
};
}
function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
...buildReactionSchema(),
...buildFetchSchema(),
...buildPollSchema(),
...buildChannelTargetSchema(),
...buildStickerSchema(),
...buildThreadSchema(),
...buildEventSchema(),
...buildModerationSchema(),
...buildGatewaySchema(),
...buildChannelManagementSchema(),
...buildPresenceSchema(),
};
}
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: { includeButtons: boolean; includeCards: boolean },
) {
const props = buildMessageToolSchemaProps(options);
return Type.Object({
action: stringEnum(actions),
...props,
});
}
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
includeCards: true,
});
type MessageToolOptions = {
agentAccountId?: string;
agentSessionKey?: string;
config?: OpenClawConfig;
currentChannelId?: string;
currentChannelProvider?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
sandboxRoot?: string;
};
function buildMessageToolSchema(cfg: OpenClawConfig) {
const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg);
const includeCards = supportsChannelMessageCards(cfg);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons,
includeCards,
});
}
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return 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;
}): string {
const baseDescription = "Send, delete, and manage messages via channel plugins.";
// If we have a current channel, show only its supported actions
if (options?.currentChannel) {
const channelActions = filterActionsForContext({
actions: listChannelSupportedActions({
cfg: options.config,
channel: options.currentChannel,
}),
channel: options.currentChannel,
currentChannelId: options.currentChannelId,
});
if (channelActions.length > 0) {
// Always include "send" as a base action
const allActions = new Set(["send", ...channelActions]);
const actionList = Array.from(allActions).toSorted().join(", ");
return `${baseDescription} Current channel (${options.currentChannel}) supports: ${actionList}.`;
}
}
// Fallback to generic description with all configured actions
if (options?.config) {
const actions = listChannelMessageActions(options.config);
if (actions.length > 0) {
return `${baseDescription} Supports actions: ${actions.join(", ")}.`;
}
}
return `${baseDescription} Supports actions: send, delete, react, poll, pin, threads, and more.`;
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema;
const description = buildMessageToolDescription({
config: options?.config,
currentChannel: options?.currentChannelProvider,
currentChannelId: options?.currentChannelId,
});
return {
label: "Message",
name: "message",
description,
parameters: schema,
execute: async (_toolCallId, args, signal) => {
// Check if already aborted before doing any work
if (signal?.aborted) {
const err = new Error("Message send aborted");
err.name = "AbortError";
throw err;
}
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;
// Validate file paths against sandbox root to prevent host file access.
const sandboxRoot = options?.sandboxRoot;
if (sandboxRoot) {
for (const key of ["filePath", "path"] as const) {
const raw = readStringParam(params, key, { trim: false });
if (raw) {
await assertSandboxPath({ filePath: raw, cwd: sandboxRoot, root: sandboxRoot });
}
}
}
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
if (accountId) {
params.accountId = accountId;
}
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs: readNumberParam(params, "timeoutMs"),
clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
};
const toolContext =
options?.currentChannelId ||
options?.currentChannelProvider ||
options?.currentThreadTs ||
options?.replyToMode ||
options?.hasRepliedRef
? {
currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.currentChannelProvider,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
// Direct tool invocations should not add cross-context decoration.
// The agent is composing a message, not forwarding from another chat.
skipCrossContextDecoration: true,
}
: undefined;
const result = await runMessageAction({
cfg,
action,
params,
defaultAccountId: accountId ?? undefined,
gateway,
toolContext,
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
abortSignal: signal,
});
const toolResult = getToolResult(result);
if (toolResult) {
return toolResult;
}
return jsonResult(result.payload);
},
};
}