Files
clawdbot/src/agents/tools/message-tool.ts
Nicholas Spisak d63eae528c feat(discord): expose channel management actions via message tool
Add channel-create, channel-edit, channel-delete, channel-move,
category-create, category-edit, and category-delete actions to the
unified message tool. These actions were already implemented in the
Discord-specific handler but weren't accessible via the pi_message tool.

Changes:
- Add 7 new channel/category management actions to MessageActionSchema
- Add parameters: name, type, parentId, topic, position, nsfw,
  rateLimitPerUser, categoryId
- Gate actions behind discord.actions.channels (disabled by default)
- Add execute handlers routing to existing Discord action handlers
- Update Discord skill SKILL.md with documentation

Channel types: 0=text, 2=voice, 4=category
2026-01-11 10:01:32 -06:00

402 lines
12 KiB
TypeScript

import { Type } from "@sinclair/typebox";
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
type MessagePollResult,
type MessageSendResult,
sendMessage,
sendPoll,
} from "../../infra/outbound/message.js";
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
import {
dispatchProviderMessageAction,
listProviderMessageActions,
supportsProviderMessageButtons,
} from "../../providers/plugins/message-actions.js";
import type { ProviderMessageActionName } from "../../providers/plugins/types.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../utils/message-provider.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
const AllMessageActions = [
"send",
"poll",
"react",
"reactions",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"permissions",
"thread-create",
"thread-list",
"thread-reply",
"search",
"sticker",
"member-info",
"role-info",
"emoji-list",
"emoji-upload",
"sticker-upload",
"role-add",
"role-remove",
"channel-info",
"channel-list",
"channel-create",
"channel-edit",
"channel-delete",
"channel-move",
"category-create",
"category-edit",
"category-delete",
"voice-status",
"event-list",
"event-create",
"timeout",
"kick",
"ban",
];
const MessageToolCommonSchema = {
provider: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
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)",
},
),
),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
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())),
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()),
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
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()),
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
includeArchived: Type.Optional(Type.Boolean()),
participant: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
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()),
};
function buildMessageToolSchemaFromActions(
actions: string[],
options: { includeButtons: boolean },
) {
const props: Record<string, unknown> = { ...MessageToolCommonSchema };
if (!options.includeButtons) delete props.buttons;
const schemas: Array<ReturnType<typeof Type.Object>> = [];
if (actions.includes("send")) {
schemas.push(
Type.Object({
action: Type.Literal("send"),
to: Type.String(),
message: Type.String(),
...props,
}),
);
}
const nonSendActions = actions.filter((action) => action !== "send");
if (nonSendActions.length > 0) {
schemas.push(
Type.Object({
action: Type.Union(
nonSendActions.map((action) => Type.Literal(action)),
),
...props,
}),
);
}
return schemas.length === 1 ? schemas[0] : Type.Union(schemas);
}
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
});
type MessageToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
};
function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listProviderMessageActions(cfg);
const includeButtons = supportsProviderMessageButtons(cfg);
return buildMessageToolSchemaFromActions(
actions.length > 0 ? actions : ["send"],
{ includeButtons },
);
}
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const schema = options?.config
? buildMessageToolSchema(options.config)
: MessageToolSchema;
return {
label: "Message",
name: "message",
description:
"Send messages and provider actions (polls, reactions, pins, threads, etc.) via configured provider plugins.",
parameters: schema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", {
required: true,
}) as ProviderMessageActionName;
const providerSelection = await resolveMessageProviderSelection({
cfg,
provider: readStringParam(params, "provider"),
});
const provider = providerSelection.provider;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const dryRun = Boolean(params.dryRun);
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs: readNumberParam(params, "timeoutMs"),
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "agent",
mode: GATEWAY_CLIENT_MODES.BACKEND,
};
const toolContext =
options?.currentChannelId ||
options?.currentThreadTs ||
options?.replyToMode ||
options?.hasRepliedRef
? {
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
}
: undefined;
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
let message = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
// Let send accept the same inline directives we use elsewhere.
// Provider plugins consume `replyTo` / `media` / `buttons` from params.
const parsed = parseReplyDirectives(message);
message = parsed.text;
params.message = message;
if (!params.replyTo && parsed.replyToId)
params.replyTo = parsed.replyToId;
if (!params.media) {
params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
}
const mediaUrl = readStringParam(params, "media", { trim: false });
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
typeof params.bestEffort === "boolean"
? params.bestEffort
: undefined;
if (dryRun) {
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
const handled = await dispatchProviderMessageAction({
provider,
action,
cfg,
params,
accountId,
gateway,
toolContext,
dryRun,
});
if (handled) return handled;
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect =
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
if (dryRun) {
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
const handled = await dispatchProviderMessageAction({
provider,
action,
cfg,
params,
accountId,
gateway,
toolContext,
dryRun,
});
if (handled) return handled;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
const handled = await dispatchProviderMessageAction({
provider,
action,
cfg,
params,
accountId,
gateway,
toolContext,
dryRun,
});
if (handled) return handled;
throw new Error(
`Message action ${action} not supported for provider ${provider}.`,
);
},
};
}