import { Separator, TextDisplay } from "@buape/carbon"; import { buildAccountScopedAllowlistConfigEditor, resolveLegacyDmAllowlistConfigPaths, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, type ChannelMessageActionAdapter, type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/discord"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { listDiscordAccountIds, resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js"; import { isDiscordExecApprovalClientEnabled, shouldSuppressLocalDiscordExecApprovalPrompt, } from "./exec-approvals.js"; import { monitorDiscordProvider } from "./monitor.js"; import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, normalizeDiscordOutboundTarget, } from "./normalize.js"; import { probeDiscord, type DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { discordSetupAdapter } from "./setup-core.js"; import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; type DiscordSendFn = ReturnType< typeof getDiscordRuntime >["channel"]["discord"]["sendMessageDiscord"]; const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; function formatDiscordIntents(intents?: { messageContent?: string; guildMembers?: string; presence?: string; }) { if (!intents) { return "unknown"; } return [ `messageContent=${intents.messageContent ?? "unknown"}`, `guildMembers=${intents.guildMembers ?? "unknown"}`, `presence=${intents.presence ?? "unknown"}`, ].join(" "); } const discordMessageActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null, listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [], getCapabilities: (ctx) => getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [], getToolSchema: (ctx) => getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null, extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null, handleAction: async (ctx) => { const ma = getDiscordRuntime().channel.discord.messageActions; if (!ma?.handleAction) { throw new Error("Discord message actions not available"); } return ma.handleAction(ctx); }, requiresTrustedRequesterSender: ({ action, toolContext }) => Boolean(toolContext && (action === "timeout" || action === "kick" || action === "ban")), }; function buildDiscordCrossContextComponents(params: { originLabel: string; message: string; cfg: OpenClawConfig; accountId?: string | null; }) { const trimmed = params.message.trim(); const components: Array = []; if (trimmed) { components.push(new TextDisplay(params.message)); components.push(new Separator({ divider: true, spacing: "small" })); } components.push(new TextDisplay(`*From ${params.originLabel}*`)); return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })]; } function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { return listDiscordAccountIds(cfg).some((accountId) => { const execApprovals = resolveDiscordAccount({ cfg, accountId }).config.execApprovals; if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { return false; } const target = execApprovals.target ?? "dm"; return target === "dm" || target === "both"; }); } function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) { const groupOverrides: Array<{ label: string; entries: string[] }> = []; for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) { const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); if (entries.length > 0) { groupOverrides.push({ label: `guild ${guildKey}`, entries }); } for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) { const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); if (channelEntries.length > 0) { groupOverrides.push({ label: `guild ${guildKey} / channel ${channelKey}`, entries: channelEntries, }); } } } return { dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), groupPolicy: account.config.groupPolicy, groupOverrides, }; } async function resolveDiscordAllowlistNames(params: { cfg: Parameters[0]["cfg"]; accountId?: string | null; entries: string[]; }) { const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const token = account.token?.trim(); if (!token) { return []; } return await resolveDiscordUserAllowlist({ token, entries: params.entries }); } function normalizeDiscordAcpConversationId(conversationId: string) { const normalized = conversationId.trim(); return normalized ? { conversationId: normalized } : null; } function matchDiscordAcpConversation(params: { bindingConversationId: string; conversationId: string; parentConversationId?: string; }) { if (params.bindingConversationId === params.conversationId) { return { conversationId: params.conversationId, matchPriority: 2 }; } if ( params.parentConversationId && params.parentConversationId !== params.conversationId && params.bindingConversationId === params.parentConversationId ) { return { conversationId: params.parentConversationId, matchPriority: 1, }; } return null; } function parseDiscordExplicitTarget(raw: string) { try { const target = parseDiscordTarget(raw, { defaultKind: "channel" }); if (!target) { return null; } return { to: target.id, chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), }; } catch { return null; } } function buildDiscordBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; accountId?: string | null; peer: RoutePeer; }) { return buildOutboundBaseSessionKey({ ...params, channel: "discord" }); } function resolveDiscordOutboundTargetKindHint(params: { target: string; resolvedTarget?: { kind: string }; }): "user" | "channel" | undefined { const resolvedKind = params.resolvedTarget?.kind; if (resolvedKind === "user") { return "user"; } if (resolvedKind === "group" || resolvedKind === "channel") { return "channel"; } const target = params.target.trim(); if (/^channel:/i.test(target)) { return "channel"; } if (/^(user:|discord:|@|<@!?)/i.test(target)) { return "user"; } return undefined; } function resolveDiscordOutboundSessionRoute(params: { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; resolvedTarget?: { kind: string }; replyToId?: string | null; threadId?: string | number | null; }) { const parsed = parseDiscordTarget(params.target, { defaultKind: resolveDiscordOutboundTargetKindHint(params), }); if (!parsed) { return null; } const isDm = parsed.kind === "user"; const peer: RoutePeer = { kind: isDm ? "direct" : "channel", id: parsed.id, }; const baseSessionKey = buildDiscordBaseSessionKey({ cfg: params.cfg, agentId: params.agentId, accountId: params.accountId, peer, }); const explicitThreadId = normalizeOutboundThreadId(params.threadId); const threadCandidate = explicitThreadId ?? normalizeOutboundThreadId(params.replyToId); const threadKeys = resolveThreadSessionKeys({ baseSessionKey, threadId: threadCandidate, useSuffix: false, }); return { sessionKey: threadKeys.sessionKey, baseSessionKey, peer, chatType: isDm ? ("direct" as const) : ("channel" as const), from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`, to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`, threadId: explicitThreadId ?? undefined, }; } export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ setup: discordSetupAdapter, }), pairing: { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), notifyApproval: async ({ id }) => { await getDiscordRuntime().channel.discord.sendMessageDiscord( `user:${id}`, PAIRING_APPROVED_MESSAGE, ); }, }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })), resolveNames: async ({ cfg, accountId, entries }) => await resolveDiscordAllowlistNames({ cfg, accountId, entries }), applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ channelId: "discord", normalize: ({ cfg, accountId, values }) => discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }), resolvePaths: resolveLegacyDmAllowlistConfigPaths, }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ cfg, channelKey: "discord", accountId, fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, policy: account.config.dm?.policy, allowFrom: account.config.dm?.allowFrom ?? [], allowFromPathSuffix: "dm.", normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), }); }, collectWarnings: ({ account, cfg }) => { const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const channelAllowlistConfigured = guildsConfigured; return collectOpenProviderGroupPolicyWarnings({ cfg, providerConfigPresent: cfg.channels?.discord !== undefined, configuredGroupPolicy: account.config.groupPolicy, collect: (groupPolicy) => collectOpenGroupPolicyConfiguredRouteWarnings({ groupPolicy, routeAllowlistConfigured: channelAllowlistConfigured, configureRouteAllowlist: { surface: "Discord guilds", openScope: "any channel not explicitly denied", groupPolicyPath: "channels.discord.groupPolicy", routeAllowlistPath: "channels.discord.guilds..channels", }, missingRouteAllowlist: { surface: "Discord guilds", openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)", remediation: 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', }, }), }); }, }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, resolveToolPolicy: resolveDiscordGroupToolPolicy, }, mentions: { stripPatterns: () => ["<@!?\\d+>"], }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", }, agentPrompt: { messageToolHints: () => [ "- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.", "- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.", ], }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw), inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType, buildCrossContextComponents: buildDiscordCrossContextComponents, resolveOutboundSessionRoute: (params) => resolveDiscordOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeDiscordTargetId, hint: "", }, }, execApprovals: { getInitiatingSurfaceState: ({ cfg, accountId }) => isDiscordExecApprovalClientEnabled({ cfg, accountId }) ? { kind: "enabled" } : { kind: "disabled" }, shouldSuppressLocalPrompt: ({ cfg, accountId, payload }) => shouldSuppressLocalDiscordExecApprovalPrompt({ cfg, accountId, payload, }), hasConfiguredDmRoute: ({ cfg }) => hasDiscordExecApprovalDmRoute(cfg), shouldSuppressForwardingFallback: ({ cfg, target }) => (normalizeMessageChannel(target.channel) ?? target.channel) === "discord" && isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), }, directory: { self: async () => null, listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), listPeersLive: async (params) => getDiscordRuntime().channel.discord.listDirectoryPeersLive(params), listGroupsLive: async (params) => getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params), }, resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const account = resolveDiscordAccount({ cfg, accountId }); const token = account.token?.trim(); if (!token) { return inputs.map((input) => ({ input, resolved: false, note: "missing Discord token", })); } if (kind === "group") { const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({ token, entries: inputs, }); return resolved.map((entry) => ({ input: entry.input, resolved: entry.resolved, id: entry.channelId ?? entry.guildId, name: entry.channelName ?? entry.guildName ?? (entry.guildId && !entry.channelId ? entry.guildId : undefined), note: entry.note, })); } const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({ token, entries: inputs, }); return resolved.map((entry) => ({ input: entry.input, resolved: entry.resolved, id: entry.id, name: entry.name, note: entry.note, })); }, }, actions: discordMessageActions, setup: discordSetupAdapter, outbound: { deliveryMode: "direct", chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { const send = resolveOutboundSendDep(deps, "discord") ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, }); return { channel: "discord", ...result }; }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId, silent, }) => { const send = resolveOutboundSendDep(deps, "discord") ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, mediaUrl, mediaLocalRoots, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, }); return { channel: "discord", ...result }; }, sendPoll: async ({ cfg, to, poll, accountId, silent }) => await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { cfg, accountId: accountId ?? undefined, silent: silent ?? undefined, }), }, bindings: { compileConfiguredBinding: ({ conversationId }) => normalizeDiscordAcpConversationId(conversationId), matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => matchDiscordAcpConversation({ bindingConversationId: compiledBinding.conversationId, conversationId, parentConversationId, }), }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, connected: false, reconnectAttempts: 0, lastConnectedAt: null, lastDisconnect: null, lastEventAt: null, lastStartAt: null, lastStopAt: null, lastError: null, }, collectStatusIssues: collectDiscordStatusIssues, buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot, { includeMode: false }), probeAccount: async ({ account, timeoutMs }) => probeDiscord(account.token, timeoutMs, { includeApplication: true, }), formatCapabilitiesProbe: ({ probe }) => { const discordProbe = probe as DiscordProbe | undefined; const lines = []; if (discordProbe?.bot?.username) { const botId = discordProbe.bot.id ? ` (${discordProbe.bot.id})` : ""; lines.push({ text: `Bot: @${discordProbe.bot.username}${botId}` }); } if (discordProbe?.application?.intents) { lines.push({ text: `Intents: ${formatDiscordIntents(discordProbe.application.intents)}` }); } return lines; }, buildCapabilitiesDiagnostics: async ({ account, timeoutMs, target }) => { if (!target?.trim()) { return undefined; } const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" }); const details: Record = { target: { raw: target, normalized: parsedTarget?.normalized, kind: parsedTarget?.kind, channelId: parsedTarget?.kind === "channel" ? parsedTarget.id : undefined, }, }; if (!parsedTarget || parsedTarget.kind !== "channel") { return { details, lines: [ { text: "Permissions: Target looks like a DM user; pass channel: to audit channel permissions.", tone: "error", }, ], }; } const token = account.token?.trim(); if (!token) { return { details, lines: [ { text: "Permissions: Discord bot token missing for permission audit.", tone: "error", }, ], }; } try { const perms = await fetchChannelPermissionsDiscord(parsedTarget.id, { token, accountId: account.accountId ?? undefined, }); const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter( (permission) => !perms.permissions.includes(permission), ); details.permissions = { channelId: perms.channelId, guildId: perms.guildId, isDm: perms.isDm, channelType: perms.channelType, permissions: perms.permissions, missingRequired, raw: perms.raw, }; return { details, lines: [ { text: `Permissions (${perms.channelId}): ${perms.permissions.length ? perms.permissions.join(", ") : "none"}`, }, missingRequired.length > 0 ? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" } : { text: "Missing required: none", tone: "success" }, ], }; } catch (err) { const message = err instanceof Error ? err.message : String(err); details.permissions = { channelId: parsedTarget.id, error: message }; return { details, lines: [{ text: `Permissions: ${message}`, tone: "error" }], }; } }, auditAccount: async ({ account, timeoutMs, cfg }) => { const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ cfg, accountId: account.accountId, }); if (!channelIds.length && unresolvedChannels === 0) { return undefined; } const botToken = account.token?.trim(); if (!botToken) { return { ok: unresolvedChannels === 0, checkedChannels: 0, unresolvedChannels, channels: [], elapsedMs: 0, }; } const audit = await auditDiscordChannelPermissions({ token: botToken, accountId: account.accountId, channelIds, timeoutMs, }); return { ...audit, unresolvedChannels }; }, buildAccountSnapshot: ({ account, runtime, probe, audit }) => { const configured = resolveConfiguredFromCredentialStatuses(account) ?? Boolean(account.token?.trim()); const app = runtime?.application ?? (probe as { application?: unknown })?.application; const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot; const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured, runtime, probe, }); return { ...base, ...projectCredentialSnapshotFields(account), connected: runtime?.connected ?? false, reconnectAttempts: runtime?.reconnectAttempts, lastConnectedAt: runtime?.lastConnectedAt ?? null, lastDisconnect: runtime?.lastDisconnect ?? null, lastEventAt: runtime?.lastEventAt ?? null, application: app ?? undefined, bot: bot ?? undefined, audit, }; }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; const token = account.token.trim(); let discordBotLabel = ""; try { const probe = await probeDiscord(token, 2500, { includeApplication: true, }); const username = probe.ok ? probe.bot?.username?.trim() : null; if (username) { discordBotLabel = ` (@${username})`; } ctx.setStatus({ accountId: account.accountId, bot: probe.bot, application: probe.application, }); const messageContent = probe.application?.intents?.messageContent; if (messageContent === "disabled") { ctx.log?.warn( `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`, ); } else if (messageContent === "limited") { ctx.log?.info( `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`, ); } } catch (err) { if (getDiscordRuntime().logging.shouldLogVerbose()) { ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); } } ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); return monitorDiscordProvider({ token, accountId: account.accountId, config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, mediaMaxMb: account.config.mediaMaxMb, historyLimit: account.config.historyLimit, setStatus: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), }); }, }, };