import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; import { readBooleanParam } from "../../plugin-sdk/boolean-param.js"; import { createThreadDiscord, deleteMessageDiscord, editMessageDiscord, fetchChannelPermissionsDiscord, fetchMessageDiscord, fetchReactionsDiscord, listPinsDiscord, listThreadsDiscord, pinMessageDiscord, reactMessageDiscord, readMessagesDiscord, removeOwnReactionsDiscord, removeReactionDiscord, searchMessagesDiscord, sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, sendStickerDiscord, sendVoiceMessageDiscord, unpinMessageDiscord, } from "../../plugin-sdk/discord.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js"; import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js"; import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { assertMediaNotDataUrl } from "../sandbox-paths.js"; import { type ActionGate, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringParam, } from "./common.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///.", ); } return { guildId: match[1], channelId: match[2], messageId: match[3], }; } export async function handleDiscordMessagingAction( action: string, params: Record, isActionEnabled: ActionGate, options?: { mediaLocalRoots?: readonly string[]; }, cfg?: OpenClawConfig, ): Promise> { const resolveChannelId = () => resolveDiscordChannelId( readStringParam(params, "channelId", { required: true, }), ); const accountId = readStringParam(params, "accountId"); const cfgOptions = cfg ? { cfg } : {}; const normalizeMessage = (message: unknown) => { if (!message || typeof message !== "object") { return message; } return withNormalizedTimestamp( message as Record, (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, { ...cfgOptions, accountId, }); } else { await removeReactionDiscord(channelId, messageId, emoji, cfgOptions); } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { const removed = accountId ? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId }) : await removeOwnReactionsDiscord(channelId, messageId, cfgOptions); return jsonResult({ ok: true, removed: removed.removed }); } if (accountId) { await reactMessageDiscord(channelId, messageId, emoji, { ...cfgOptions, accountId, }); } else { await reactMessageDiscord(channelId, messageId, emoji, cfgOptions); } 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 limit = readNumberParam(params, "limit"); const reactions = await fetchReactionsDiscord(channelId, messageId, { ...cfgOptions, ...(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, { ...cfgOptions, ...(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 allowMultiselect = readBooleanParam(params, "allowMultiselect"); const durationHours = readNumberParam(params, "durationHours"); const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect); await sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, { ...cfgOptions, ...(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, { ...cfgOptions, accountId }) : await fetchChannelPermissionsDiscord(channelId, cfgOptions); 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, { ...cfgOptions, accountId }) : await fetchMessageDiscord(channelId, messageId, cfgOptions); 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: readNumberParam(params, "limit"), before: readStringParam(params, "before"), after: readStringParam(params, "after"), around: readStringParam(params, "around"), }; const messages = accountId ? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId }) : await readMessagesDiscord(channelId, query, cfgOptions); 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 asVoice = params.asVoice === true; const silent = params.silent === true; const rawComponents = params.components; const componentSpec = rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) ? readDiscordComponentSpec(rawComponents) : null; const components: DiscordSendComponents | undefined = Array.isArray(rawComponents) || typeof rawComponents === "function" ? (rawComponents as DiscordSendComponents) : undefined; const content = readStringParam(params, "content", { required: !asVoice && !componentSpec && !components, allowEmpty: true, }); const mediaUrl = readStringParam(params, "mediaUrl", { trim: false }) ?? readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); const rawEmbeds = params.embeds; const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds) ? (rawEmbeds as DiscordSendEmbeds) : undefined; const sessionKey = readStringParam(params, "__sessionKey"); const agentId = readStringParam(params, "__agentId"); if (componentSpec) { if (asVoice) { throw new Error("Discord components cannot be sent as voice messages."); } if (embeds?.length) { throw new Error("Discord components cannot include embeds."); } const normalizedContent = content?.trim() ? content : undefined; const payload = componentSpec.text ? componentSpec : { ...componentSpec, text: normalizedContent }; const result = await sendDiscordComponentMessage(to, payload, { ...cfgOptions, ...(accountId ? { accountId } : {}), silent, replyTo: replyTo ?? undefined, sessionKey: sessionKey ?? undefined, agentId: agentId ?? undefined, mediaUrl: mediaUrl ?? undefined, filename: filename ?? undefined, }); return jsonResult({ ok: true, result, components: true }); } // Handle voice message sending if (asVoice) { if (!mediaUrl) { throw new Error( "Voice messages require a media file reference (mediaUrl, path, or filePath).", ); } if (content && content.trim()) { throw new Error( "Voice messages cannot include text content (Discord limitation). Remove the content parameter.", ); } assertMediaNotDataUrl(mediaUrl); const result = await sendVoiceMessageDiscord(to, mediaUrl, { ...cfgOptions, ...(accountId ? { accountId } : {}), replyTo, silent, }); return jsonResult({ ok: true, result, voiceMessage: true }); } const result = await sendMessageDiscord(to, content ?? "", { ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, mediaLocalRoots: options?.mediaLocalRoots, replyTo, components, embeds, silent, }); 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 }, { ...cfgOptions, accountId }) : await editMessageDiscord(channelId, messageId, { content }, cfgOptions); 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, { ...cfgOptions, accountId }); } else { await deleteMessageDiscord(channelId, messageId, cfgOptions); } 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 content = readStringParam(params, "content"); const autoArchiveMinutes = readNumberParam(params, "autoArchiveMinutes"); const appliedTags = readStringArrayParam(params, "appliedTags"); const payload = { name, messageId, autoArchiveMinutes, content, appliedTags: appliedTags ?? undefined, }; const thread = accountId ? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId }) : await createThreadDiscord(channelId, payload, cfgOptions); 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 = readBooleanParam(params, "includeArchived"); const before = readStringParam(params, "before"); const limit = readNumberParam(params, "limit"); const threads = accountId ? await listThreadsDiscord( { guildId, channelId, includeArchived, before, limit, }, { ...cfgOptions, accountId }, ) : await listThreadsDiscord( { guildId, channelId, includeArchived, before, limit, }, cfgOptions, ); 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, { ...cfgOptions, ...(accountId ? { accountId } : {}), mediaUrl, mediaLocalRoots: options?.mediaLocalRoots, 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, { ...cfgOptions, accountId }); } else { await pinMessageDiscord(channelId, messageId, cfgOptions); } 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, { ...cfgOptions, accountId }); } else { await unpinMessageDiscord(channelId, messageId, cfgOptions); } 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, { ...cfgOptions, accountId }) : await listPinsDiscord(channelId, cfgOptions); 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 = readNumberParam(params, "limit"); 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, }, { ...cfgOptions, accountId }, ) : await searchMessagesDiscord( { guildId, content, channelIds: channelIdList.length ? channelIdList : undefined, authorIds: authorIdList.length ? authorIdList : undefined, limit, }, cfgOptions, ); if (!results || typeof results !== "object") { return jsonResult({ ok: true, results }); } const resultsRecord = results as Record; 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}`); } }