Files
clawdbot/src/agents/tools/discord-actions-messaging.ts
2026-01-23 09:06:04 +00:00

448 lines
16 KiB
TypeScript

import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchMessageDiscord,
fetchReactionsDiscord,
listPinsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
unpinMessageDiscord,
} from "../../discord/send.js";
import {
type ActionGate,
jsonResult,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "./common.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { resolveDiscordChannelId } from "../../discord/targets.js";
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
const match = normalized.match(
/^(?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d+)\/(\d+)\/(\d+)(?:\/?|\?.*)$/i,
);
if (!match) {
throw new Error(
"Invalid Discord message link. Expected https://discord.com/channels/<guildId>/<channelId>/<messageId>.",
);
}
return {
guildId: match[1],
channelId: match[2],
messageId: match[3],
};
}
export async function handleDiscordMessagingAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveDiscordChannelId(
readStringParam(params, "channelId", {
required: true,
}),
);
const accountId = readStringParam(params, "accountId");
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { timestamp?: unknown }).timestamp,
);
};
switch (action) {
case "react": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
});
if (remove) {
if (accountId) {
await removeReactionDiscord(channelId, messageId, emoji, { accountId });
} else {
await removeReactionDiscord(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = accountId
? await removeOwnReactionsDiscord(channelId, messageId, { accountId })
: await removeOwnReactionsDiscord(channelId, messageId);
return jsonResult({ ok: true, removed: removed.removed });
}
if (accountId) {
await reactMessageDiscord(channelId, messageId, emoji, { accountId });
} else {
await reactMessageDiscord(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
case "reactions": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const reactions = await fetchReactionsDiscord(channelId, messageId, {
...(accountId ? { accountId } : {}),
limit,
});
return jsonResult({ ok: true, reactions });
}
case "sticker": {
if (!isActionEnabled("stickers")) {
throw new Error("Discord stickers are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content");
const stickerIds = readStringArrayParam(params, "stickerIds", {
required: true,
label: "stickerIds",
});
await sendStickerDiscord(to, stickerIds, {
...(accountId ? { accountId } : {}),
content,
});
return jsonResult({ ok: true });
}
case "poll": {
if (!isActionEnabled("polls")) {
throw new Error("Discord polls are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content");
const question = readStringParam(params, "question", {
required: true,
});
const answers = readStringArrayParam(params, "answers", {
required: true,
label: "answers",
});
const allowMultiselectRaw = params.allowMultiselect;
const allowMultiselect =
typeof allowMultiselectRaw === "boolean" ? allowMultiselectRaw : undefined;
const durationRaw = params.durationHours;
const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
await sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
{ ...(accountId ? { accountId } : {}), content },
);
return jsonResult({ ok: true });
}
case "permissions": {
if (!isActionEnabled("permissions")) {
throw new Error("Discord permissions are disabled.");
}
const channelId = resolveChannelId();
const permissions = accountId
? await fetchChannelPermissionsDiscord(channelId, { accountId })
: await fetchChannelPermissionsDiscord(channelId);
return jsonResult({ ok: true, permissions });
}
case "fetchMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const messageLink = readStringParam(params, "messageLink");
let guildId = readStringParam(params, "guildId");
let channelId = readStringParam(params, "channelId");
let messageId = readStringParam(params, "messageId");
if (messageLink) {
const parsed = parseDiscordMessageLink(messageLink);
guildId = parsed.guildId;
channelId = parsed.channelId;
messageId = parsed.messageId;
}
if (!guildId || !channelId || !messageId) {
throw new Error(
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
);
}
const message = accountId
? await fetchMessageDiscord(channelId, messageId, { accountId })
: await fetchMessageDiscord(channelId, messageId);
return jsonResult({
ok: true,
message: normalizeMessage(message),
guildId,
channelId,
messageId,
});
}
case "readMessages": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const channelId = resolveChannelId();
const query = {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
};
const messages = accountId
? await readMessagesDiscord(channelId, query, { accountId })
: await readMessagesDiscord(channelId, query);
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
});
}
case "sendMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message sends are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
required: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const embeds =
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
const result = await sendMessageDiscord(to, content, {
...(accountId ? { accountId } : {}),
mediaUrl,
replyTo,
embeds,
});
return jsonResult({ ok: true, result });
}
case "editMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message edits are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const message = accountId
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
: await editMessageDiscord(channelId, messageId, { content });
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message deletes are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (accountId) {
await deleteMessageDiscord(channelId, messageId, { accountId });
} else {
await deleteMessageDiscord(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "threadCreate": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = resolveChannelId();
const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const thread = accountId
? await createThreadDiscord(
channelId,
{ name, messageId, autoArchiveMinutes },
{ accountId },
)
: await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes });
return jsonResult({ ok: true, thread });
}
case "threadList": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean" ? params.includeArchived : undefined;
const before = readStringParam(params, "before");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const threads = accountId
? await listThreadsDiscord(
{
guildId,
channelId,
includeArchived,
before,
limit,
},
{ accountId },
)
: await listThreadsDiscord({
guildId,
channelId,
includeArchived,
before,
limit,
});
return jsonResult({ ok: true, threads });
}
case "threadReply": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = resolveChannelId();
const content = readStringParam(params, "content", {
required: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
...(accountId ? { accountId } : {}),
mediaUrl,
replyTo,
});
return jsonResult({ ok: true, result });
}
case "pinMessage": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (accountId) {
await pinMessageDiscord(channelId, messageId, { accountId });
} else {
await pinMessageDiscord(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "unpinMessage": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (accountId) {
await unpinMessageDiscord(channelId, messageId, { accountId });
} else {
await unpinMessageDiscord(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "listPins": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = resolveChannelId();
const pins = accountId
? await listPinsDiscord(channelId, { accountId })
: await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
if (!isActionEnabled("search")) {
throw new Error("Discord search is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = accountId
? await searchMessagesDiscord(
{
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
},
{ accountId },
)
: await searchMessagesDiscord({
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}
const resultsRecord = results as Record<string, unknown>;
const messages = resultsRecord.messages;
const normalizedMessages = Array.isArray(messages)
? messages.map((group) =>
Array.isArray(group) ? group.map((msg) => normalizeMessage(msg)) : group,
)
: messages;
return jsonResult({
ok: true,
results: {
...resultsRecord,
messages: normalizedMessages,
},
});
}
default:
throw new Error(`Unknown action: ${action}`);
}
}