diff --git a/src/discord/account-inspect.test.ts b/extensions/discord/src/account-inspect.test.ts similarity index 98% rename from src/discord/account-inspect.test.ts rename to extensions/discord/src/account-inspect.test.ts index 0e8303635f9..eda0b6cc0e0 100644 --- a/src/discord/account-inspect.test.ts +++ b/extensions/discord/src/account-inspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { inspectDiscordAccount } from "./account-inspect.js"; function asConfig(value: unknown): OpenClawConfig { diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts new file mode 100644 index 00000000000..d99f87aeb56 --- /dev/null +++ b/extensions/discord/src/account-inspect.ts @@ -0,0 +1,132 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig } from "../../../src/config/types.discord.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + mergeDiscordAccountConfig, + resolveDefaultDiscordAccountId, + resolveDiscordAccountConfig, +} from "./accounts.js"; + +export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing"; + +export type InspectedDiscordAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "config" | "none"; + tokenStatus: DiscordCredentialStatus; + configured: boolean; + config: DiscordAccountConfig; +}; + +function inspectDiscordTokenValue(value: unknown): { + token: string; + tokenSource: "config"; + tokenStatus: Exclude; +} | null { + const normalized = normalizeSecretInputString(value); + if (normalized) { + return { + token: normalized.replace(/^Bot\s+/i, ""), + tokenSource: "config", + tokenStatus: "available", + }; + } + if (hasConfiguredSecretInput(value)) { + return { + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }; + } + return null; +} + +export function inspectDiscordAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + envToken?: string | null; +}): InspectedDiscordAccount { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultDiscordAccountId(params.cfg), + ); + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const enabled = params.cfg.channels?.discord?.enabled !== false && merged.enabled !== false; + const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId); + const hasAccountToken = Boolean( + accountConfig && + Object.prototype.hasOwnProperty.call(accountConfig as Record, "token"), + ); + const accountToken = inspectDiscordTokenValue(accountConfig?.token); + if (accountToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: accountToken.token, + tokenSource: accountToken.tokenSource, + tokenStatus: accountToken.tokenStatus, + configured: true, + config: merged, + }; + } + if (hasAccountToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config: merged, + }; + } + + const channelToken = inspectDiscordTokenValue(params.cfg.channels?.discord?.token); + if (channelToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: channelToken.token, + tokenSource: channelToken.tokenSource, + tokenStatus: channelToken.tokenStatus, + configured: true, + config: merged, + }; + } + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv + ? normalizeSecretInputString(params.envToken ?? process.env.DISCORD_BOT_TOKEN) + : undefined; + if (envToken) { + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: envToken.replace(/^Bot\s+/i, ""), + tokenSource: "env", + tokenStatus: "available", + configured: true, + config: merged, + }; + } + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + tokenStatus: "missing", + configured: false, + config: merged, + }; +} diff --git a/src/discord/accounts.test.ts b/extensions/discord/src/accounts.test.ts similarity index 100% rename from src/discord/accounts.test.ts rename to extensions/discord/src/accounts.test.ts diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts new file mode 100644 index 00000000000..6cd1699f192 --- /dev/null +++ b/extensions/discord/src/accounts.ts @@ -0,0 +1,89 @@ +import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { resolveDiscordToken } from "./token.js"; + +export type ResolvedDiscordAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "config" | "none"; + config: DiscordAccountConfig; +}; + +const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("discord"); +export const listDiscordAccountIds = listAccountIds; +export const resolveDefaultDiscordAccountId = resolveDefaultAccountId; + +export function resolveDiscordAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): DiscordAccountConfig | undefined { + return resolveAccountEntry(cfg.channels?.discord?.accounts, accountId); +} + +export function mergeDiscordAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): DiscordAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.discord ?? {}) as DiscordAccountConfig & { + accounts?: unknown; + }; + const account = resolveDiscordAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function createDiscordActionGate(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean { + const accountId = normalizeAccountId(params.accountId); + return createAccountActionGate({ + baseActions: params.cfg.channels?.discord?.actions, + accountActions: resolveDiscordAccountConfig(params.cfg, accountId)?.actions, + }); +} + +export function resolveDiscordAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedDiscordAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.discord?.enabled !== false; + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const tokenResolution = resolveDiscordToken(params.cfg, { accountId }); + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + token: tokenResolution.token, + tokenSource: tokenResolution.source, + config: merged, + }; +} + +export function resolveDiscordMaxLinesPerMessage(params: { + cfg: OpenClawConfig; + discordConfig?: DiscordAccountConfig | null; + accountId?: string | null; +}): number | undefined { + if (typeof params.discordConfig?.maxLinesPerMessage === "number") { + return params.discordConfig.maxLinesPerMessage; + } + return resolveDiscordAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).config.maxLinesPerMessage; +} + +export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] { + return listDiscordAccountIds(cfg) + .map((accountId) => resolveDiscordAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts new file mode 100644 index 00000000000..80cd97217ae --- /dev/null +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -0,0 +1,451 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../../../src/agents/tools/discord-actions-moderation-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; + +type Ctx = Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" +>; + +export async function tryHandleDiscordMessageActionGuildAdmin(params: { + ctx: Ctx; + resolveChannelId: () => string; + readParentIdParam: (params: Record) => string | null | undefined; +}): Promise | undefined> { + const { ctx, resolveChannelId, readParentIdParam } = params; + const { action, params: actionParams, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId"); + + if (action === "member-info") { + const userId = readStringParam(actionParams, "userId", { required: true }); + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "memberInfo", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "role-info") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "roleInfo", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "emojiList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "emojiName", { required: true }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + const roleIds = readStringArrayParam(actionParams, "roleIds"); + return await handleDiscordAction( + { + action: "emojiUpload", + accountId: accountId ?? undefined, + guildId, + name, + mediaUrl, + roleIds, + }, + cfg, + ); + } + + if (action === "sticker-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "stickerName", { + required: true, + }); + const description = readStringParam(actionParams, "stickerDesc", { + required: true, + }); + const tags = readStringParam(actionParams, "stickerTags", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + return await handleDiscordAction( + { + action: "stickerUpload", + accountId: accountId ?? undefined, + guildId, + name, + description, + tags, + mediaUrl, + }, + cfg, + ); + } + + if (action === "role-add" || action === "role-remove") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + const roleId = readStringParam(actionParams, "roleId", { required: true }); + return await handleDiscordAction( + { + action: action === "role-add" ? "roleAdd" : "roleRemove", + accountId: accountId ?? undefined, + guildId, + userId, + roleId, + }, + cfg, + ); + } + + if (action === "channel-info") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelInfo", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "channel-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const type = readNumberParam(actionParams, "type", { integer: true }); + const parentId = readParentIdParam(actionParams); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + return await handleDiscordAction( + { + action: "channelCreate", + accountId: accountId ?? undefined, + guildId, + name, + type: type ?? undefined, + parentId: parentId ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + nsfw, + }, + cfg, + ); + } + + if (action === "channel-edit") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const parentId = readParentIdParam(actionParams); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + const rateLimitPerUser = readNumberParam(actionParams, "rateLimitPerUser", { + integer: true, + }); + const archived = typeof actionParams.archived === "boolean" ? actionParams.archived : undefined; + const locked = typeof actionParams.locked === "boolean" ? actionParams.locked : undefined; + const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", { + integer: true, + }); + const availableTags = parseAvailableTags(actionParams.availableTags); + return await handleDiscordAction( + { + action: "channelEdit", + accountId: accountId ?? undefined, + channelId, + name: name ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + parentId: parentId === undefined ? undefined : parentId, + nsfw, + rateLimitPerUser: rateLimitPerUser ?? undefined, + archived, + locked, + autoArchiveDuration: autoArchiveDuration ?? undefined, + availableTags, + }, + cfg, + ); + } + + if (action === "channel-delete") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelDelete", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-move") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const parentId = readParentIdParam(actionParams); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "channelMove", + accountId: accountId ?? undefined, + guildId, + channelId, + parentId: parentId === undefined ? undefined : parentId, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryCreate", + accountId: accountId ?? undefined, + guildId, + name, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-edit") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryEdit", + accountId: accountId ?? undefined, + categoryId, + name: name ?? undefined, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-delete") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + return await handleDiscordAction( + { action: "categoryDelete", accountId: accountId ?? undefined, categoryId }, + cfg, + ); + } + + if (action === "voice-status") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + return await handleDiscordAction( + { action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "event-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "eventList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "event-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "eventName", { required: true }); + const startTime = readStringParam(actionParams, "startTime", { + required: true, + }); + const endTime = readStringParam(actionParams, "endTime"); + const description = readStringParam(actionParams, "desc"); + const channelId = readStringParam(actionParams, "channelId"); + const location = readStringParam(actionParams, "location"); + const entityType = readStringParam(actionParams, "eventType"); + return await handleDiscordAction( + { + action: "eventCreate", + accountId: accountId ?? undefined, + guildId, + name, + startTime, + endTime, + description, + channelId, + location, + entityType, + }, + cfg, + ); + } + + if (isDiscordModerationAction(action)) { + const moderation = readDiscordModerationCommand(action, { + ...actionParams, + durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }), + deleteMessageDays: readNumberParam(actionParams, "deleteDays", { + integer: true, + }), + }); + const senderUserId = ctx.requesterSenderId?.trim() || undefined; + return await handleDiscordAction( + { + action: moderation.action, + accountId: accountId ?? undefined, + guildId: moderation.guildId, + userId: moderation.userId, + durationMinutes: moderation.durationMinutes, + until: moderation.until, + reason: moderation.reason, + deleteMessageDays: moderation.deleteMessageDays, + senderUserId, + }, + cfg, + ); + } + + // Some actions are conceptually "admin", but still act on a resolved channel. + if (action === "thread-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId"); + const includeArchived = + typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined; + const before = readStringParam(actionParams, "before"); + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "threadList", + accountId: accountId ?? undefined, + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfg, + ); + } + + if (action === "thread-reply") { + const content = readStringParam(actionParams, "message", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { trim: false }); + const replyTo = readStringParam(actionParams, "replyTo"); + + // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`. + // Prefer `threadId` when present to avoid accidentally replying in the parent channel. + const threadId = readStringParam(actionParams, "threadId"); + const channelId = threadId ?? resolveChannelId(); + + return await handleDiscordAction( + { + action: "threadReply", + accountId: accountId ?? undefined, + channelId, + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "search") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const query = readStringParam(actionParams, "query", { required: true }); + return await handleDiscordAction( + { + action: "searchMessages", + accountId: accountId ?? undefined, + guildId, + content: query, + channelId: readStringParam(actionParams, "channelId"), + channelIds: readStringArrayParam(actionParams, "channelIds"), + authorId: readStringParam(actionParams, "authorId"), + authorIds: readStringArrayParam(actionParams, "authorIds"), + limit: readNumberParam(actionParams, "limit", { integer: true }), + }, + cfg, + ); + } + + return undefined; +} diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts new file mode 100644 index 00000000000..b0842ce25b2 --- /dev/null +++ b/extensions/discord/src/actions/handle-action.ts @@ -0,0 +1,295 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-actions-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; +import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; +import { resolveDiscordChannelId } from "../targets.js"; +import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; + +const providerId = "discord"; + +export async function handleDiscordMessageAction( + ctx: Pick< + ChannelMessageActionContext, + | "action" + | "params" + | "cfg" + | "accountId" + | "requesterSenderId" + | "toolContext" + | "mediaLocalRoots" + >, +): Promise> { + const { action, params, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(params, "accountId"); + const actionOptions = { + mediaLocalRoots: ctx.mediaLocalRoots, + } as const; + + const resolveChannelId = () => + resolveDiscordChannelId( + readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }), + ); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const asVoice = readBooleanParam(params, "asVoice") === true; + const rawComponents = params.components; + const hasComponents = + Boolean(rawComponents) && + (typeof rawComponents === "function" || typeof rawComponents === "object"); + const components = hasComponents ? rawComponents : undefined; + const content = readStringParam(params, "message", { + required: !asVoice && !hasComponents, + allowEmpty: true, + }); + // Support media, path, and filePath for media URL + const mediaUrl = + readStringParam(params, "media", { 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 = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; + const silent = readBooleanParam(params, "silent") === true; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); + return await handleDiscordAction( + { + action: "sendMessage", + accountId: accountId ?? undefined, + to, + content, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + replyTo: replyTo ?? undefined, + components, + embeds, + asVoice, + silent, + __sessionKey: sessionKey ?? undefined, + __agentId: agentId ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + return await handleDiscordAction( + { + action: "poll", + accountId: accountId ?? undefined, + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "react") { + const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext }); + const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : ""; + if (!messageId) { + throw new Error( + "messageId required. Provide messageId explicitly or react to the current inbound message.", + ); + } + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = readBooleanParam(params, "remove"); + return await handleDiscordAction( + { + action: "react", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + emoji, + remove, + }, + cfg, + actionOptions, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "reactions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + limit, + }, + cfg, + actionOptions, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "readMessages", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + around: readStringParam(params, "around"), + }, + cfg, + actionOptions, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "message", { required: true }); + return await handleDiscordAction( + { + action: "editMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + content, + }, + cfg, + actionOptions, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: "deleteMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "permissions") { + return await handleDiscordAction( + { + action: "permissions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + }, + cfg, + actionOptions, + ); + } + + if (action === "thread-create") { + const name = readStringParam(params, "threadName", { required: true }); + const messageId = readStringParam(params, "messageId"); + const content = readStringParam(params, "message"); + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { + integer: true, + }); + const appliedTags = readStringArrayParam(params, "appliedTags"); + return await handleDiscordAction( + { + action: "threadCreate", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + name, + messageId, + content, + autoArchiveMinutes, + appliedTags: appliedTags ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "sticker") { + const stickerIds = + readStringArrayParam(params, "stickerId", { + required: true, + label: "sticker-id", + }) ?? []; + return await handleDiscordAction( + { + action: "sticker", + accountId: accountId ?? undefined, + to: readStringParam(params, "to", { required: true }), + stickerIds, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "set-presence") { + return await handleDiscordAction( + { + action: "setPresence", + accountId: accountId ?? undefined, + status: readStringParam(params, "status"), + activityType: readStringParam(params, "activityType"), + activityName: readStringParam(params, "activityName"), + activityUrl: readStringParam(params, "activityUrl"), + activityState: readStringParam(params, "activityState"), + }, + cfg, + actionOptions, + ); + } + + const adminResult = await tryHandleDiscordMessageActionGuildAdmin({ + ctx, + resolveChannelId, + readParentIdParam: readDiscordParentIdParam, + }); + if (adminResult !== undefined) { + return adminResult; + } + + throw new Error(`Action ${String(action)} is not supported for provider ${providerId}.`); +} diff --git a/src/discord/api.test.ts b/extensions/discord/src/api.test.ts similarity index 96% rename from src/discord/api.test.ts rename to extensions/discord/src/api.test.ts index 4c9f1a9c0c1..5b0e648aa1d 100644 --- a/src/discord/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { fetchDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; diff --git a/extensions/discord/src/api.ts b/extensions/discord/src/api.ts new file mode 100644 index 00000000000..cead5eb8cea --- /dev/null +++ b/extensions/discord/src/api.ts @@ -0,0 +1,136 @@ +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../src/infra/retry.js"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; +const DISCORD_API_RETRY_DEFAULTS = { + attempts: 3, + minDelayMs: 500, + maxDelayMs: 30_000, + jitter: 0.1, +}; + +type DiscordApiErrorPayload = { + message?: string; + retry_after?: number; + code?: number; + global?: boolean; +}; + +function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return null; + } + try { + const payload = JSON.parse(trimmed); + if (payload && typeof payload === "object") { + return payload as DiscordApiErrorPayload; + } + } catch { + return null; + } + return null; +} + +function parseRetryAfterSeconds(text: string, response: Response): number | undefined { + const payload = parseDiscordApiErrorPayload(text); + const retryAfter = + payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after) + ? payload.retry_after + : undefined; + if (retryAfter !== undefined) { + return retryAfter; + } + const header = response.headers.get("Retry-After"); + if (!header) { + return undefined; + } + const parsed = Number(header); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function formatRetryAfterSeconds(value: number | undefined): string | undefined { + if (value === undefined || !Number.isFinite(value) || value < 0) { + return undefined; + } + const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString(); + return `${rounded}s`; +} + +function formatDiscordApiErrorText(text: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed) { + return undefined; + } + const payload = parseDiscordApiErrorPayload(trimmed); + if (!payload) { + const looksJson = trimmed.startsWith("{") && trimmed.endsWith("}"); + return looksJson ? "unknown error" : trimmed; + } + const message = + typeof payload.message === "string" && payload.message.trim() + ? payload.message.trim() + : "unknown error"; + const retryAfter = formatRetryAfterSeconds( + typeof payload.retry_after === "number" ? payload.retry_after : undefined, + ); + return retryAfter ? `${message} (retry after ${retryAfter})` : message; +} + +export class DiscordApiError extends Error { + status: number; + retryAfter?: number; + + constructor(message: string, status: number, retryAfter?: number) { + super(message); + this.status = status; + this.retryAfter = retryAfter; + } +} + +export type DiscordFetchOptions = { + retry?: RetryConfig; + label?: string; +}; + +export async function fetchDiscord( + path: string, + token: string, + fetcher: typeof fetch = fetch, + options?: DiscordFetchOptions, +): Promise { + const fetchImpl = resolveFetch(fetcher); + if (!fetchImpl) { + throw new Error("fetch is not available"); + } + + const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry); + return retryAsync( + async () => { + const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const detail = formatDiscordApiErrorText(text); + const suffix = detail ? `: ${detail}` : ""; + const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined; + throw new DiscordApiError( + `Discord API ${path} failed (${res.status})${suffix}`, + res.status, + retryAfter, + ); + } + return (await res.json()) as T; + }, + { + ...retryConfig, + label: options?.label ?? path, + shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429, + retryAfterMs: (err) => + err instanceof DiscordApiError && typeof err.retryAfter === "number" + ? err.retryAfter * 1000 + : undefined, + }, + ); +} diff --git a/src/discord/audit.test.ts b/extensions/discord/src/audit.test.ts similarity index 92% rename from src/discord/audit.test.ts rename to extensions/discord/src/audit.test.ts index 55339b03381..c1b276f320b 100644 --- a/src/discord/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -27,7 +27,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, @@ -73,7 +73,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); @@ -98,7 +98,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual([]); @@ -127,7 +127,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); diff --git a/extensions/discord/src/audit.ts b/extensions/discord/src/audit.ts new file mode 100644 index 00000000000..a5a226c5550 --- /dev/null +++ b/extensions/discord/src/audit.ts @@ -0,0 +1,141 @@ +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../../../src/config/types.js"; +import { isRecord } from "../../../src/utils.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { fetchChannelPermissionsDiscord } from "./send.js"; + +export type DiscordChannelPermissionsAuditEntry = { + channelId: string; + ok: boolean; + missing?: string[]; + error?: string | null; + matchKey?: string; + matchSource?: "id"; +}; + +export type DiscordChannelPermissionsAudit = { + ok: boolean; + checkedChannels: number; + unresolvedChannels: number; + channels: DiscordChannelPermissionsAuditEntry[]; + elapsedMs: number; +}; + +const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; + +function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) { + if (!config) { + return true; + } + if (config.allow === false) { + return false; + } + if (config.enabled === false) { + return false; + } + return true; +} + +function listConfiguredGuildChannelKeys( + guilds: Record | undefined, +): string[] { + if (!guilds) { + return []; + } + const ids = new Set(); + for (const entry of Object.values(guilds)) { + if (!entry || typeof entry !== "object") { + continue; + } + const channelsRaw = (entry as { channels?: unknown }).channels; + if (!isRecord(channelsRaw)) { + continue; + } + for (const [key, value] of Object.entries(channelsRaw)) { + const channelId = String(key).trim(); + if (!channelId) { + continue; + } + // Skip wildcard keys (e.g. "*" meaning "all channels") — they are valid + // config but are not real channel IDs and should not be audited. + if (channelId === "*") { + continue; + } + if (!shouldAuditChannelConfig(value as DiscordGuildChannelConfig | undefined)) { + continue; + } + ids.add(channelId); + } + } + return [...ids].toSorted((a, b) => a.localeCompare(b)); +} + +export function collectDiscordAuditChannelIds(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}) { + const account = inspectDiscordAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const keys = listConfiguredGuildChannelKeys(account.config.guilds); + const channelIds = keys.filter((key) => /^\d+$/.test(key)); + const unresolvedChannels = keys.length - channelIds.length; + return { channelIds, unresolvedChannels }; +} + +export async function auditDiscordChannelPermissions(params: { + token: string; + accountId?: string | null; + channelIds: string[]; + timeoutMs: number; +}): Promise { + const started = Date.now(); + const token = params.token?.trim() ?? ""; + if (!token || params.channelIds.length === 0) { + return { + ok: true, + checkedChannels: 0, + unresolvedChannels: 0, + channels: [], + elapsedMs: Date.now() - started, + }; + } + + const required = [...REQUIRED_CHANNEL_PERMISSIONS]; + const channels: DiscordChannelPermissionsAuditEntry[] = []; + + for (const channelId of params.channelIds) { + try { + const perms = await fetchChannelPermissionsDiscord(channelId, { + token, + accountId: params.accountId ?? undefined, + }); + const missing = required.filter((p) => !perms.permissions.includes(p)); + channels.push({ + channelId, + ok: missing.length === 0, + missing: missing.length ? missing : undefined, + error: null, + matchKey: channelId, + matchSource: "id", + }); + } catch (err) { + channels.push({ + channelId, + ok: false, + error: err instanceof Error ? err.message : String(err), + matchKey: channelId, + matchSource: "id", + }); + } + } + + return { + ok: channels.every((c) => c.ok), + checkedChannels: channels.length, + unresolvedChannels: 0, + channels, + elapsedMs: Date.now() - started, + }; +} diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts new file mode 100644 index 00000000000..bf35b788e3e --- /dev/null +++ b/extensions/discord/src/channel-actions.ts @@ -0,0 +1,140 @@ +import { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../../../src/channels/plugins/actions/shared.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "../../../src/channels/plugins/types.js"; +import type { DiscordActionConfig } from "../../../src/config/types.discord.js"; +import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; +import { handleDiscordMessageAction } from "./actions/handle-action.js"; + +export const discordMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); + if (accounts.length === 0) { + return []; + } + // Union of all accounts' action gates (any account enabling an action makes it available) + const gate = createUnionActionGate(accounts, (account) => + createDiscordActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => + gate(key, defaultValue); + const actions = new Set(["send"]); + if (isEnabled("polls")) { + actions.add("poll"); + } + if (isEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (isEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (isEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (isEnabled("permissions")) { + actions.add("permissions"); + } + if (isEnabled("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (isEnabled("search")) { + actions.add("search"); + } + if (isEnabled("stickers")) { + actions.add("sticker"); + } + if (isEnabled("memberInfo")) { + actions.add("member-info"); + } + if (isEnabled("roleInfo")) { + actions.add("role-info"); + } + if (isEnabled("reactions")) { + actions.add("emoji-list"); + } + if (isEnabled("emojiUploads")) { + actions.add("emoji-upload"); + } + if (isEnabled("stickerUploads")) { + actions.add("sticker-upload"); + } + if (isEnabled("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (isEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (isEnabled("channels")) { + actions.add("channel-create"); + actions.add("channel-edit"); + actions.add("channel-delete"); + actions.add("channel-move"); + actions.add("category-create"); + actions.add("category-edit"); + actions.add("category-delete"); + } + if (isEnabled("voiceStatus")) { + actions.add("voice-status"); + } + if (isEnabled("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (isEnabled("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + if (isEnabled("presence", false)) { + actions.add("set-presence"); + } + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action === "sendMessage") { + const to = typeof args.to === "string" ? args.to : undefined; + return to ? { to } : null; + } + if (action === "threadReply") { + const channelId = typeof args.channelId === "string" ? args.channelId.trim() : ""; + return channelId ? { to: `channel:${channelId}` } : null; + } + return null; + }, + handleAction: async ({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }) => { + return await handleDiscordMessageAction({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }); + }, +}; diff --git a/src/discord/chunk.test.ts b/extensions/discord/src/chunk.test.ts similarity index 98% rename from src/discord/chunk.test.ts rename to extensions/discord/src/chunk.test.ts index d33262c4767..3c667c0fc9f 100644 --- a/src/discord/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { countLines, hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; +import { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js"; import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; describe("chunkDiscordText", () => { diff --git a/extensions/discord/src/chunk.ts b/extensions/discord/src/chunk.ts new file mode 100644 index 00000000000..a814c10d2c8 --- /dev/null +++ b/extensions/discord/src/chunk.ts @@ -0,0 +1,277 @@ +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../src/auto-reply/chunk.js"; + +export type ChunkDiscordTextOpts = { + /** Max characters per Discord message. Default: 2000. */ + maxChars?: number; + /** + * Soft max line count per message. Default: 17. + * + * Discord clients can clip/collapse very tall messages in the UI; splitting + * by lines keeps long multi-paragraph replies readable. + */ + maxLines?: number; +}; + +type OpenFence = { + indent: string; + markerChar: string; + markerLen: number; + openLine: string; +}; + +const DEFAULT_MAX_CHARS = 2000; +const DEFAULT_MAX_LINES = 17; +const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/; + +function countLines(text: string) { + if (!text) { + return 0; + } + return text.split("\n").length; +} + +function parseFenceLine(line: string): OpenFence | null { + const match = line.match(FENCE_RE); + if (!match) { + return null; + } + const indent = match[1] ?? ""; + const marker = match[2] ?? ""; + return { + indent, + markerChar: marker[0] ?? "`", + markerLen: marker.length, + openLine: line, + }; +} + +function closeFenceLine(openFence: OpenFence) { + return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`; +} + +function closeFenceIfNeeded(text: string, openFence: OpenFence | null) { + if (!openFence) { + return text; + } + const closeLine = closeFenceLine(openFence); + if (!text) { + return closeLine; + } + if (!text.endsWith("\n")) { + return `${text}\n${closeLine}`; + } + return `${text}${closeLine}`; +} + +function splitLongLine( + line: string, + maxChars: number, + opts: { preserveWhitespace: boolean }, +): string[] { + const limit = Math.max(1, Math.floor(maxChars)); + if (line.length <= limit) { + return [line]; + } + const out: string[] = []; + let remaining = line; + while (remaining.length > limit) { + if (opts.preserveWhitespace) { + out.push(remaining.slice(0, limit)); + remaining = remaining.slice(limit); + continue; + } + const window = remaining.slice(0, limit); + let breakIdx = -1; + for (let i = window.length - 1; i >= 0; i--) { + if (/\s/.test(window[i])) { + breakIdx = i; + break; + } + } + if (breakIdx <= 0) { + breakIdx = limit; + } + out.push(remaining.slice(0, breakIdx)); + // Keep the separator for the next segment so words don't get glued together. + remaining = remaining.slice(breakIdx); + } + if (remaining.length) { + out.push(remaining); + } + return out; +} + +/** + * Chunks outbound Discord text by both character count and (soft) line count, + * while keeping fenced code blocks balanced across chunks. + */ +export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}): string[] { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)); + const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DEFAULT_MAX_LINES)); + + const body = text ?? ""; + if (!body) { + return []; + } + + const alreadyOk = body.length <= maxChars && countLines(body) <= maxLines; + if (alreadyOk) { + return [body]; + } + + const lines = body.split("\n"); + const chunks: string[] = []; + + let current = ""; + let currentLines = 0; + let openFence: OpenFence | null = null; + + const flush = () => { + if (!current) { + return; + } + const payload = closeFenceIfNeeded(current, openFence); + if (payload.trim().length) { + chunks.push(payload); + } + current = ""; + currentLines = 0; + if (openFence) { + current = openFence.openLine; + currentLines = 1; + } + }; + + for (const originalLine of lines) { + const fenceInfo = parseFenceLine(originalLine); + const wasInsideFence = openFence !== null; + let nextOpenFence: OpenFence | null = openFence; + if (fenceInfo) { + if (!openFence) { + nextOpenFence = fenceInfo; + } else if ( + openFence.markerChar === fenceInfo.markerChar && + fenceInfo.markerLen >= openFence.markerLen + ) { + nextOpenFence = null; + } + } + + const reserveChars = nextOpenFence ? closeFenceLine(nextOpenFence).length + 1 : 0; + const reserveLines = nextOpenFence ? 1 : 0; + const effectiveMaxChars = maxChars - reserveChars; + const effectiveMaxLines = maxLines - reserveLines; + const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars; + const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines; + const prefixLen = current.length > 0 ? current.length + 1 : 0; + const segmentLimit = Math.max(1, charLimit - prefixLen); + const segments = splitLongLine(originalLine, segmentLimit, { + preserveWhitespace: wasInsideFence, + }); + + for (let segIndex = 0; segIndex < segments.length; segIndex++) { + const segment = segments[segIndex]; + const isLineContinuation = segIndex > 0; + const delimiter = isLineContinuation ? "" : current.length > 0 ? "\n" : ""; + const addition = `${delimiter}${segment}`; + const nextLen = current.length + addition.length; + const nextLines = currentLines + (isLineContinuation ? 0 : 1); + + const wouldExceedChars = nextLen > charLimit; + const wouldExceedLines = nextLines > lineLimit; + + if ((wouldExceedChars || wouldExceedLines) && current.length > 0) { + flush(); + } + + if (current.length > 0) { + current += addition; + if (!isLineContinuation) { + currentLines += 1; + } + } else { + current = segment; + currentLines = 1; + } + } + + openFence = nextOpenFence; + } + + if (current.length) { + const payload = closeFenceIfNeeded(current, openFence); + if (payload.trim().length) { + chunks.push(payload); + } + } + + return rebalanceReasoningItalics(text, chunks); +} + +export function chunkDiscordTextWithMode( + text: string, + opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode }, +): string[] { + const chunkMode = opts.chunkMode ?? "length"; + if (chunkMode !== "newline") { + return chunkDiscordText(text, opts); + } + const lineChunks = chunkMarkdownTextWithMode( + text, + Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)), + "newline", + ); + const chunks: string[] = []; + for (const line of lineChunks) { + const nested = chunkDiscordText(line, opts); + if (!nested.length && line) { + chunks.push(line); + continue; + } + chunks.push(...nested); + } + return chunks; +} + +// Keep italics intact for reasoning payloads that are wrapped once with `_…_`. +// When Discord chunking splits the message, we close italics at the end of +// each chunk and reopen at the start of the next so every chunk renders +// consistently. +function rebalanceReasoningItalics(source: string, chunks: string[]): string[] { + if (chunks.length <= 1) { + return chunks; + } + + const opensWithReasoningItalics = + source.startsWith("Reasoning:\n_") && source.trimEnd().endsWith("_"); + if (!opensWithReasoningItalics) { + return chunks; + } + + const adjusted = [...chunks]; + for (let i = 0; i < adjusted.length; i++) { + const isLast = i === adjusted.length - 1; + const current = adjusted[i]; + + // Ensure current chunk closes italics so Discord renders it italicized. + const needsClosing = !current.trimEnd().endsWith("_"); + if (needsClosing) { + adjusted[i] = `${current}_`; + } + + if (isLast) { + break; + } + + // Re-open italics on the next chunk if needed. + const next = adjusted[i + 1]; + const leadingWhitespaceLen = next.length - next.trimStart().length; + const leadingWhitespace = next.slice(0, leadingWhitespaceLen); + const nextBody = next.slice(leadingWhitespaceLen); + if (!nextBody.startsWith("_")) { + adjusted[i + 1] = `${leadingWhitespace}_${nextBody}`; + } + } + + return adjusted; +} diff --git a/src/discord/client.test.ts b/extensions/discord/src/client.test.ts similarity index 96% rename from src/discord/client.test.ts rename to extensions/discord/src/client.test.ts index 3dc156670e7..416fa7c903a 100644 --- a/src/discord/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { createDiscordRestClient } from "./client.js"; describe("createDiscordRestClient", () => { diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts new file mode 100644 index 00000000000..2e8d53799a6 --- /dev/null +++ b/extensions/discord/src/client.ts @@ -0,0 +1,88 @@ +import { RequestClient } from "@buape/carbon"; +import { loadConfig } from "../../../src/config/config.js"; +import { createDiscordRetryRunner, type RetryRunner } from "../../../src/infra/retry-policy.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + mergeDiscordAccountConfig, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; +import { normalizeDiscordToken } from "./token.js"; + +export type DiscordClientOpts = { + cfg?: ReturnType; + token?: string; + accountId?: string; + rest?: RequestClient; + retry?: RetryConfig; + verbose?: boolean; +}; + +function resolveToken(params: { accountId: string; fallbackToken?: string }) { + const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token"); + if (!fallback) { + throw new Error( + `Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`, + ); + } + return fallback; +} + +function resolveRest(token: string, rest?: RequestClient) { + return rest ?? new RequestClient(token); +} + +function resolveAccountWithoutToken(params: { + cfg: ReturnType; + accountId?: string; +}): ResolvedDiscordAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const baseEnabled = params.cfg.channels?.discord?.enabled !== false; + const accountEnabled = merged.enabled !== false; + return { + accountId, + enabled: baseEnabled && accountEnabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + config: merged, + }; +} + +export function createDiscordRestClient( + opts: DiscordClientOpts, + cfg?: ReturnType, +) { + const resolvedCfg = opts.cfg ?? cfg ?? loadConfig(); + const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token"); + const account = explicitToken + ? resolveAccountWithoutToken({ cfg: resolvedCfg, accountId: opts.accountId }) + : resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId }); + const token = + explicitToken ?? + resolveToken({ + accountId: account.accountId, + fallbackToken: account.token, + }); + const rest = resolveRest(token, opts.rest); + return { token, rest, account }; +} + +export function createDiscordClient( + opts: DiscordClientOpts, + cfg?: ReturnType, +): { token: string; rest: RequestClient; request: RetryRunner } { + const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg); + const request = createDiscordRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + }); + return { token, rest, request }; +} + +export function resolveDiscordRest(opts: DiscordClientOpts) { + return createDiscordRestClient(opts, opts.cfg).rest; +} diff --git a/extensions/discord/src/components-registry.ts b/extensions/discord/src/components-registry.ts new file mode 100644 index 00000000000..ce7014aba75 --- /dev/null +++ b/extensions/discord/src/components-registry.ts @@ -0,0 +1,89 @@ +import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js"; + +const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000; + +const componentEntries = new Map(); +const modalEntries = new Map(); + +function isExpired(entry: { expiresAt?: number }, now: number) { + return typeof entry.expiresAt === "number" && entry.expiresAt <= now; +} + +function normalizeEntryTimestamps( + entry: T, + now: number, + ttlMs: number, +): T { + const createdAt = entry.createdAt ?? now; + const expiresAt = entry.expiresAt ?? createdAt + ttlMs; + return { ...entry, createdAt, expiresAt }; +} + +export function registerDiscordComponentEntries(params: { + entries: DiscordComponentEntry[]; + modals: DiscordModalEntry[]; + ttlMs?: number; + messageId?: string; +}): void { + const now = Date.now(); + const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS; + for (const entry of params.entries) { + const normalized = normalizeEntryTimestamps( + { ...entry, messageId: params.messageId ?? entry.messageId }, + now, + ttlMs, + ); + componentEntries.set(entry.id, normalized); + } + for (const modal of params.modals) { + const normalized = normalizeEntryTimestamps( + { ...modal, messageId: params.messageId ?? modal.messageId }, + now, + ttlMs, + ); + modalEntries.set(modal.id, normalized); + } +} + +export function resolveDiscordComponentEntry(params: { + id: string; + consume?: boolean; +}): DiscordComponentEntry | null { + const entry = componentEntries.get(params.id); + if (!entry) { + return null; + } + const now = Date.now(); + if (isExpired(entry, now)) { + componentEntries.delete(params.id); + return null; + } + if (params.consume !== false) { + componentEntries.delete(params.id); + } + return entry; +} + +export function resolveDiscordModalEntry(params: { + id: string; + consume?: boolean; +}): DiscordModalEntry | null { + const entry = modalEntries.get(params.id); + if (!entry) { + return null; + } + const now = Date.now(); + if (isExpired(entry, now)) { + modalEntries.delete(params.id); + return null; + } + if (params.consume !== false) { + modalEntries.delete(params.id); + } + return entry; +} + +export function clearDiscordComponentEntries(): void { + componentEntries.clear(); + modalEntries.clear(); +} diff --git a/src/discord/components.test.ts b/extensions/discord/src/components.test.ts similarity index 100% rename from src/discord/components.test.ts rename to extensions/discord/src/components.test.ts diff --git a/extensions/discord/src/components.ts b/extensions/discord/src/components.ts new file mode 100644 index 00000000000..2052c5baf69 --- /dev/null +++ b/extensions/discord/src/components.ts @@ -0,0 +1,1149 @@ +import crypto from "node:crypto"; +import { + Button, + ChannelSelectMenu, + CheckboxGroup, + Container, + File, + Label, + LinkButton, + MediaGallery, + MentionableSelectMenu, + Modal, + RadioGroup, + RoleSelectMenu, + Row, + Section, + Separator, + StringSelectMenu, + TextDisplay, + TextInput, + Thumbnail, + UserSelectMenu, + parseCustomId, + type ComponentParserResult, + type TopLevelComponents, +} from "@buape/carbon"; +import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10"; + +export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp"; +export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal"; +export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://"; + +export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link"; + +export type DiscordComponentSelectType = "string" | "user" | "role" | "mentionable" | "channel"; + +export type DiscordComponentModalFieldType = + | "text" + | "checkbox" + | "radio" + | "select" + | "role-select" + | "user-select"; + +export type DiscordComponentButtonSpec = { + label: string; + style?: DiscordComponentButtonStyle; + url?: string; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + disabled?: boolean; + /** Optional allowlist of users who can interact with this button (ids or names). */ + allowedUsers?: string[]; +}; + +export type DiscordComponentSelectOption = { + label: string; + value: string; + description?: string; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + default?: boolean; +}; + +export type DiscordComponentSelectSpec = { + type?: DiscordComponentSelectType; + placeholder?: string; + minValues?: number; + maxValues?: number; + options?: DiscordComponentSelectOption[]; +}; + +export type DiscordComponentSectionAccessory = + | { + type: "thumbnail"; + url: string; + } + | { + type: "button"; + button: DiscordComponentButtonSpec; + }; + +type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2; + +export type DiscordComponentBlock = + | { + type: "text"; + text: string; + } + | { + type: "section"; + text?: string; + texts?: string[]; + accessory?: DiscordComponentSectionAccessory; + } + | { + type: "separator"; + spacing?: DiscordComponentSeparatorSpacing; + divider?: boolean; + } + | { + type: "actions"; + buttons?: DiscordComponentButtonSpec[]; + select?: DiscordComponentSelectSpec; + } + | { + type: "media-gallery"; + items: Array<{ url: string; description?: string; spoiler?: boolean }>; + } + | { + type: "file"; + file: `attachment://${string}`; + spoiler?: boolean; + }; + +export type DiscordModalFieldSpec = { + type: DiscordComponentModalFieldType; + name?: string; + label: string; + description?: string; + placeholder?: string; + required?: boolean; + options?: DiscordComponentSelectOption[]; + minValues?: number; + maxValues?: number; + minLength?: number; + maxLength?: number; + style?: "short" | "paragraph"; +}; + +export type DiscordModalSpec = { + title: string; + triggerLabel?: string; + triggerStyle?: DiscordComponentButtonStyle; + fields: DiscordModalFieldSpec[]; +}; + +export type DiscordComponentMessageSpec = { + text?: string; + reusable?: boolean; + container?: { + accentColor?: string | number; + spoiler?: boolean; + }; + blocks?: DiscordComponentBlock[]; + modal?: DiscordModalSpec; +}; + +export type DiscordComponentEntry = { + id: string; + kind: "button" | "select" | "modal-trigger"; + label: string; + selectType?: DiscordComponentSelectType; + options?: Array<{ value: string; label: string }>; + modalId?: string; + sessionKey?: string; + agentId?: string; + accountId?: string; + reusable?: boolean; + allowedUsers?: string[]; + messageId?: string; + createdAt?: number; + expiresAt?: number; +}; + +export type DiscordModalFieldDefinition = { + id: string; + name: string; + label: string; + type: DiscordComponentModalFieldType; + description?: string; + placeholder?: string; + required?: boolean; + options?: DiscordComponentSelectOption[]; + minValues?: number; + maxValues?: number; + minLength?: number; + maxLength?: number; + style?: "short" | "paragraph"; +}; + +export type DiscordModalEntry = { + id: string; + title: string; + fields: DiscordModalFieldDefinition[]; + sessionKey?: string; + agentId?: string; + accountId?: string; + reusable?: boolean; + messageId?: string; + createdAt?: number; + expiresAt?: number; +}; + +export type DiscordComponentBuildResult = { + components: TopLevelComponents[]; + entries: DiscordComponentEntry[]; + modals: DiscordModalEntry[]; +}; + +const BLOCK_ALIASES = new Map([ + ["row", "actions"], + ["action-row", "actions"], +]); + +function createShortId(prefix: string) { + return `${prefix}${crypto.randomBytes(6).toString("base64url")}`; +} + +function requireObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + const trimmed = value.trim(); + if (!opts?.allowEmpty && !trimmed) { + throw new Error(`${label} cannot be empty`); + } + return opts?.allowEmpty ? value : trimmed; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function readOptionalStringArray(value: unknown, label: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array`); + } + if (value.length === 0) { + return undefined; + } + return value.map((entry, index) => readString(entry, `${label}[${index}]`)); +} + +function readOptionalNumber(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return value; +} + +function normalizeModalFieldName(value: string | undefined, index: number) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + return `field_${index + 1}`; +} + +function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` { + const trimmed = value.trim(); + if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) { + throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`); + } + const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim(); + if (!attachmentName) { + throw new Error(`${label} must include an attachment filename`); + } + return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`; +} + +export function resolveDiscordComponentAttachmentName(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) { + throw new Error( + `Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`, + ); + } + const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim(); + if (!attachmentName) { + throw new Error("Attachment reference must include a filename"); + } + return attachmentName; +} + +function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle { + switch ((style ?? "primary").toLowerCase()) { + case "secondary": + return ButtonStyle.Secondary; + case "success": + return ButtonStyle.Success; + case "danger": + return ButtonStyle.Danger; + case "link": + return ButtonStyle.Link; + case "primary": + default: + return ButtonStyle.Primary; + } +} + +function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) { + return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short; +} + +function normalizeBlockType(raw: string) { + const lowered = raw.trim().toLowerCase(); + return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]); +} + +function parseSelectOptions( + raw: unknown, + label: string, +): DiscordComponentSelectOption[] | undefined { + if (raw === undefined) { + return undefined; + } + if (!Array.isArray(raw)) { + throw new Error(`${label} must be an array`); + } + return raw.map((entry, index) => { + const obj = requireObject(entry, `${label}[${index}]`); + return { + label: readString(obj.label, `${label}[${index}].label`), + value: readString(obj.value, `${label}[${index}].value`), + description: readOptionalString(obj.description), + emoji: + typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) + ? { + name: readString( + (obj.emoji as { name?: unknown }).name, + `${label}[${index}].emoji.name`, + ), + id: readOptionalString((obj.emoji as { id?: unknown }).id), + animated: + typeof (obj.emoji as { animated?: unknown }).animated === "boolean" + ? (obj.emoji as { animated?: boolean }).animated + : undefined, + } + : undefined, + default: typeof obj.default === "boolean" ? obj.default : undefined, + }; + }); +} + +function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec { + const obj = requireObject(raw, label); + const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; + const url = readOptionalString(obj.url); + if ((style === "link" || url) && !url) { + throw new Error(`${label}.url is required for link buttons`); + } + return { + label: readString(obj.label, `${label}.label`), + style, + url, + emoji: + typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) + ? { + name: readString((obj.emoji as { name?: unknown }).name, `${label}.emoji.name`), + id: readOptionalString((obj.emoji as { id?: unknown }).id), + animated: + typeof (obj.emoji as { animated?: unknown }).animated === "boolean" + ? (obj.emoji as { animated?: boolean }).animated + : undefined, + } + : undefined, + disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined, + allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`), + }; +} + +function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec { + const obj = requireObject(raw, label); + const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined; + const allowedTypes: DiscordComponentSelectType[] = [ + "string", + "user", + "role", + "mentionable", + "channel", + ]; + if (type && !allowedTypes.includes(type)) { + throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`); + } + return { + type, + placeholder: readOptionalString(obj.placeholder), + minValues: readOptionalNumber(obj.minValues), + maxValues: readOptionalNumber(obj.maxValues), + options: parseSelectOptions(obj.options, `${label}.options`), + }; +} + +function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec { + const obj = requireObject(raw, label); + const type = readString( + obj.type, + `${label}.type`, + ).toLowerCase() as DiscordComponentModalFieldType; + const supported: DiscordComponentModalFieldType[] = [ + "text", + "checkbox", + "radio", + "select", + "role-select", + "user-select", + ]; + if (!supported.includes(type)) { + throw new Error(`${label}.type must be one of ${supported.join(", ")}`); + } + const options = parseSelectOptions(obj.options, `${label}.options`); + if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) { + throw new Error(`${label}.options is required for ${type} fields`); + } + return { + type, + name: normalizeModalFieldName(readOptionalString(obj.name), index), + label: readString(obj.label, `${label}.label`), + description: readOptionalString(obj.description), + placeholder: readOptionalString(obj.placeholder), + required: typeof obj.required === "boolean" ? obj.required : undefined, + options, + minValues: readOptionalNumber(obj.minValues), + maxValues: readOptionalNumber(obj.maxValues), + minLength: readOptionalNumber(obj.minLength), + maxLength: readOptionalNumber(obj.maxLength), + style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"], + }; +} + +function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock { + const obj = requireObject(raw, label); + const typeRaw = readString(obj.type, `${label}.type`).toLowerCase(); + const type = normalizeBlockType(typeRaw); + switch (type) { + case "text": + return { + type: "text", + text: readString(obj.text, `${label}.text`), + }; + case "section": { + const text = readOptionalString(obj.text); + const textsRaw = obj.texts; + const texts = Array.isArray(textsRaw) + ? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`)) + : undefined; + if (!text && (!texts || texts.length === 0)) { + throw new Error(`${label}.text or ${label}.texts is required for section blocks`); + } + let accessory: DiscordComponentSectionAccessory | undefined; + if (obj.accessory !== undefined) { + const accessoryObj = requireObject(obj.accessory, `${label}.accessory`); + const accessoryType = readString( + accessoryObj.type, + `${label}.accessory.type`, + ).toLowerCase(); + if (accessoryType === "thumbnail") { + accessory = { + type: "thumbnail", + url: readString(accessoryObj.url, `${label}.accessory.url`), + }; + } else if (accessoryType === "button") { + accessory = { + type: "button", + button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`), + }; + } else { + throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`); + } + } + return { + type: "section", + text, + texts, + accessory, + }; + } + case "separator": { + const spacingRaw = obj.spacing; + let spacing: DiscordComponentSeparatorSpacing | undefined; + if (spacingRaw === "small" || spacingRaw === "large") { + spacing = spacingRaw; + } else if (spacingRaw === 1 || spacingRaw === 2) { + spacing = spacingRaw; + } else if (spacingRaw !== undefined) { + throw new Error(`${label}.spacing must be "small", "large", 1, or 2`); + } + const divider = typeof obj.divider === "boolean" ? obj.divider : undefined; + return { + type: "separator", + spacing, + divider, + }; + } + case "actions": { + const buttonsRaw = obj.buttons; + const buttons = Array.isArray(buttonsRaw) + ? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`)) + : undefined; + const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined; + if ((!buttons || buttons.length === 0) && !select) { + throw new Error(`${label} requires buttons or select`); + } + if (buttons && select) { + throw new Error(`${label} cannot include both buttons and select`); + } + return { + type: "actions", + buttons, + select, + }; + } + case "media-gallery": { + const itemsRaw = obj.items; + if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { + throw new Error(`${label}.items must be a non-empty array`); + } + const items = itemsRaw.map((entry, idx) => { + const itemObj = requireObject(entry, `${label}.items[${idx}]`); + return { + url: readString(itemObj.url, `${label}.items[${idx}].url`), + description: readOptionalString(itemObj.description), + spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined, + }; + }); + return { + type: "media-gallery", + items, + }; + } + case "file": { + const file = readString(obj.file, `${label}.file`); + return { + type: "file", + file: normalizeAttachmentRef(file, `${label}.file`), + spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined, + }; + } + default: + throw new Error(`${label}.type must be a supported component block`); + } +} + +export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null { + if (raw === undefined || raw === null) { + return null; + } + const obj = requireObject(raw, "components"); + const blocksRaw = obj.blocks; + const blocks = Array.isArray(blocksRaw) + ? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`)) + : undefined; + const modalRaw = obj.modal; + const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined; + let modal: DiscordModalSpec | undefined; + if (modalRaw !== undefined) { + const modalObj = requireObject(modalRaw, "components.modal"); + const fieldsRaw = modalObj.fields; + if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) { + throw new Error("components.modal.fields must be a non-empty array"); + } + if (fieldsRaw.length > 5) { + throw new Error("components.modal.fields supports up to 5 inputs"); + } + const fields = fieldsRaw.map((entry, idx) => + parseModalField(entry, `components.modal.fields[${idx}]`, idx), + ); + modal = { + title: readString(modalObj.title, "components.modal.title"), + triggerLabel: readOptionalString(modalObj.triggerLabel), + triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, + fields, + }; + } + return { + text: readOptionalString(obj.text), + reusable, + container: + typeof obj.container === "object" && obj.container && !Array.isArray(obj.container) + ? { + accentColor: (obj.container as { accentColor?: unknown }).accentColor as + | string + | number + | undefined, + spoiler: + typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean" + ? ((obj.container as { spoiler?: boolean }).spoiler as boolean) + : undefined, + } + : undefined, + blocks, + modal, + }; +} + +export function buildDiscordComponentCustomId(params: { + componentId: string; + modalId?: string; +}): string { + const base = `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${params.componentId}`; + return params.modalId ? `${base};mid=${params.modalId}` : base; +} + +export function buildDiscordModalCustomId(modalId: string): string { + return `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`; +} + +export function parseDiscordComponentCustomId( + id: string, +): { componentId: string; modalId?: string } | null { + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) { + return null; + } + const componentId = parsed.data.cid; + if (typeof componentId !== "string" || !componentId.trim()) { + return null; + } + const modalId = parsed.data.mid; + return { + componentId, + modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined, + }; +} + +export function parseDiscordModalCustomId(id: string): string | null { + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) { + return null; + } + const modalId = parsed.data.mid; + if (typeof modalId !== "string" || !modalId.trim()) { + return null; + } + return modalId; +} + +function isDiscordComponentWildcardRegistrationId(id: string): boolean { + return /^__openclaw_discord_component_[a-z_]+_wildcard__$/.test(id); +} + +export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentParserResult { + if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) { + return { key: "*", data: {} }; + } + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) { + return parsed; + } + return { key: "*", data: parsed.data }; +} + +export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserResult { + if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) { + return { key: "*", data: {} }; + } + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) { + return parsed; + } + return { key: "*", data: parsed.data }; +} + +function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] { + if (texts && texts.length > 0) { + return texts.map((entry) => new TextDisplay(entry)); + } + if (text) { + return [new TextDisplay(text)]; + } + return []; +} + +function createButtonComponent(params: { + spec: DiscordComponentButtonSpec; + componentId?: string; + modalId?: string; +}): { component: Button | LinkButton; entry?: DiscordComponentEntry } { + const style = mapButtonStyle(params.spec.style); + const isLink = style === ButtonStyle.Link || Boolean(params.spec.url); + if (isLink) { + if (!params.spec.url) { + throw new Error("Link buttons require a url"); + } + const linkUrl = params.spec.url; + class DynamicLinkButton extends LinkButton { + label = params.spec.label; + url = linkUrl; + } + return { component: new DynamicLinkButton() }; + } + const componentId = params.componentId ?? createShortId("btn_"); + const customId = buildDiscordComponentCustomId({ + componentId, + modalId: params.modalId, + }); + class DynamicButton extends Button { + label = params.spec.label; + customId = customId; + style = style; + emoji = params.spec.emoji; + disabled = params.spec.disabled ?? false; + } + return { + component: new DynamicButton(), + entry: { + id: componentId, + kind: params.modalId ? "modal-trigger" : "button", + label: params.spec.label, + modalId: params.modalId, + allowedUsers: params.spec.allowedUsers, + }, + }; +} + +function createSelectComponent(params: { + spec: DiscordComponentSelectSpec; + componentId?: string; +}): { + component: + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu; + entry: DiscordComponentEntry; +} { + const type = (params.spec.type ?? "string").toLowerCase() as DiscordComponentSelectType; + const componentId = params.componentId ?? createShortId("sel_"); + const customId = buildDiscordComponentCustomId({ componentId }); + if (type === "string") { + const options = params.spec.options ?? []; + if (options.length === 0) { + throw new Error("String select menus require options"); + } + class DynamicStringSelect extends StringSelectMenu { + customId = customId; + options = options; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicStringSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "select", + selectType: "string", + options: options.map((option) => ({ value: option.value, label: option.label })), + }, + }; + } + if (type === "user") { + class DynamicUserSelect extends UserSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicUserSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "user select", + selectType: "user", + }, + }; + } + if (type === "role") { + class DynamicRoleSelect extends RoleSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicRoleSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "role select", + selectType: "role", + }, + }; + } + if (type === "mentionable") { + class DynamicMentionableSelect extends MentionableSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicMentionableSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "mentionable select", + selectType: "mentionable", + }, + }; + } + class DynamicChannelSelect extends ChannelSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicChannelSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "channel select", + selectType: "channel", + }, + }; +} + +function isSelectComponent( + component: unknown, +): component is + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu { + return ( + component instanceof StringSelectMenu || + component instanceof UserSelectMenu || + component instanceof RoleSelectMenu || + component instanceof MentionableSelectMenu || + component instanceof ChannelSelectMenu + ); +} + +function createModalFieldComponent( + field: DiscordModalFieldDefinition, +): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup { + if (field.type === "text") { + class DynamicTextInput extends TextInput { + customId = field.id; + style = mapTextInputStyle(field.style); + placeholder = field.placeholder; + required = field.required; + minLength = field.minLength; + maxLength = field.maxLength; + } + return new DynamicTextInput(); + } + if (field.type === "select") { + const options = field.options ?? []; + class DynamicModalSelect extends StringSelectMenu { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalSelect(); + } + if (field.type === "role-select") { + class DynamicModalRoleSelect extends RoleSelectMenu { + customId = field.id; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalRoleSelect(); + } + if (field.type === "user-select") { + class DynamicModalUserSelect extends UserSelectMenu { + customId = field.id; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalUserSelect(); + } + if (field.type === "checkbox") { + const options = field.options ?? []; + class DynamicCheckboxGroup extends CheckboxGroup { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + } + return new DynamicCheckboxGroup(); + } + const options = field.options ?? []; + class DynamicRadioGroup extends RadioGroup { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + } + return new DynamicRadioGroup(); +} + +export function buildDiscordComponentMessage(params: { + spec: DiscordComponentMessageSpec; + fallbackText?: string; + sessionKey?: string; + agentId?: string; + accountId?: string; +}): DiscordComponentBuildResult { + const entries: DiscordComponentEntry[] = []; + const modals: DiscordModalEntry[] = []; + const components: TopLevelComponents[] = []; + const containerChildren: Array< + | Row< + | Button + | LinkButton + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu + > + | TextDisplay + | Section + | MediaGallery + | Separator + | File + > = []; + + const addEntry = (entry: DiscordComponentEntry) => { + entries.push({ + ...entry, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + reusable: entry.reusable ?? params.spec.reusable, + }); + }; + + const text = params.spec.text ?? params.fallbackText; + if (text) { + containerChildren.push(new TextDisplay(text)); + } + + for (const block of params.spec.blocks ?? []) { + if (block.type === "text") { + containerChildren.push(new TextDisplay(block.text)); + continue; + } + if (block.type === "section") { + const displays = buildTextDisplays(block.text, block.texts); + if (displays.length > 3) { + throw new Error("Section blocks support up to 3 text displays"); + } + let accessory: Thumbnail | Button | LinkButton | undefined; + if (block.accessory?.type === "thumbnail") { + accessory = new Thumbnail(block.accessory.url); + } else if (block.accessory?.type === "button") { + const { component, entry } = createButtonComponent({ spec: block.accessory.button }); + accessory = component; + if (entry) { + addEntry(entry); + } + } + containerChildren.push(new Section(displays, accessory)); + continue; + } + if (block.type === "separator") { + containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider })); + continue; + } + if (block.type === "media-gallery") { + containerChildren.push(new MediaGallery(block.items)); + continue; + } + if (block.type === "file") { + containerChildren.push(new File(block.file, block.spoiler)); + continue; + } + if (block.type === "actions") { + const rowComponents: Array< + | Button + | LinkButton + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu + > = []; + if (block.buttons) { + if (block.buttons.length > 5) { + throw new Error("Action rows support up to 5 buttons"); + } + for (const button of block.buttons) { + const { component, entry } = createButtonComponent({ spec: button }); + rowComponents.push(component); + if (entry) { + addEntry(entry); + } + } + } else if (block.select) { + const { component, entry } = createSelectComponent({ spec: block.select }); + rowComponents.push(component); + addEntry(entry); + } + containerChildren.push(new Row(rowComponents)); + } + } + + if (params.spec.modal) { + const modalId = createShortId("mdl_"); + const fields = params.spec.modal.fields.map((field, index) => ({ + id: createShortId("fld_"), + name: normalizeModalFieldName(field.name, index), + label: field.label, + type: field.type, + description: field.description, + placeholder: field.placeholder, + required: field.required, + options: field.options, + minValues: field.minValues, + maxValues: field.maxValues, + minLength: field.minLength, + maxLength: field.maxLength, + style: field.style, + })); + modals.push({ + id: modalId, + title: params.spec.modal.title, + fields, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + reusable: params.spec.reusable, + }); + + const triggerSpec: DiscordComponentButtonSpec = { + label: params.spec.modal.triggerLabel ?? "Open form", + style: params.spec.modal.triggerStyle ?? "primary", + }; + + const { component, entry } = createButtonComponent({ + spec: triggerSpec, + modalId, + }); + + if (entry) { + addEntry(entry); + } + + const lastChild = containerChildren.at(-1); + if (lastChild instanceof Row) { + const row = lastChild; + const hasSelect = row.components.some((entry) => isSelectComponent(entry)); + if (row.components.length < 5 && !hasSelect) { + row.addComponent(component as Button); + } else { + containerChildren.push(new Row([component as Button])); + } + } else { + containerChildren.push(new Row([component as Button])); + } + } + + if (containerChildren.length === 0) { + throw new Error("components must include at least one block, text, or modal trigger"); + } + + const container = new Container(containerChildren, params.spec.container); + components.push(container); + return { components, entries, modals }; +} + +export function buildDiscordComponentMessageFlags( + components: TopLevelComponents[], +): number | undefined { + const hasV2 = components.some((component) => component.isV2); + return hasV2 ? MessageFlags.IsComponentsV2 : undefined; +} + +export class DiscordFormModal extends Modal { + title: string; + customId: string; + components: Array