import { inspect } from "node:util"; import { Client, ReadyListener, type BaseCommand, type BaseMessageInteractiveComponent, type Modal, type Plugin, } from "@buape/carbon"; import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway"; import { VoicePlugin } from "@buape/carbon/voice"; import { Routes } from "discord-api-types/v10"; import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { listNativeCommandSpecsForConfig } from "../../../../src/auto-reply/commands-registry.js"; import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js"; import { resolveThreadBindingIdleTimeoutMs, resolveThreadBindingMaxAgeMs, resolveThreadBindingsEnabled, } from "../../../../src/channels/thread-bindings-policy.js"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, } from "../../../../src/config/commands.js"; import type { RemoteClawConfig, ReplyToMode } from "../../../../src/config/config.js"; import { loadConfig } from "../../../../src/config/config.js"; import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; import { GROUP_POLICY_BLOCKED_LABEL, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "../../../../src/config/runtime-group-policy.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js"; import { formatErrorMessage } from "../../../../src/infra/errors.js"; import { createDiscordRetryRunner } from "../../../../src/infra/retry-policy.js"; import { createSubsystemLogger } from "../../../../src/logging/subsystem.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; import { resolveDiscordAccount } from "../accounts.js"; import { fetchDiscordApplicationId } from "../probe.js"; import { normalizeDiscordToken } from "../token.js"; import { createDiscordVoiceCommand } from "../voice/command.js"; import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js"; import { createAgentComponentButton, createAgentSelectMenu, createDiscordComponentButton, createDiscordComponentChannelSelect, createDiscordComponentMentionableSelect, createDiscordComponentModal, createDiscordComponentRoleSelect, createDiscordComponentStringSelect, createDiscordComponentUserSelect, } from "./agent-components.js"; import { resolveDiscordSlashCommandConfig } from "./commands.js"; import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js"; import { createDiscordGatewayPlugin } from "./gateway-plugin.js"; import { DiscordMessageListener, DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, registerDiscordListener, } from "./listeners.js"; import { createDiscordMessageHandler } from "./message-handler.js"; import { createDiscordCommandArgFallbackButton, createDiscordNativeCommand, } from "./native-command.js"; import { resolveDiscordPresenceUpdate } from "./presence.js"; import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js"; import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js"; import { resolveDiscordRestFetch } from "./rest-fetch.js"; import type { DiscordMonitorStatusSink } from "./status.js"; import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js"; import { formatThreadBindingDurationLabel } from "./thread-bindings.messages.js"; export type MonitorDiscordOpts = { token?: string; accountId?: string; config?: RemoteClawConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; mediaMaxMb?: number; historyLimit?: number; replyToMode?: ReplyToMode; setStatus?: DiscordMonitorStatusSink; }; function summarizeAllowList(list?: string[]) { if (!list || list.length === 0) { return "any"; } const sample = list.slice(0, 4).map((entry) => String(entry)); const suffix = list.length > sample.length ? ` (+${list.length - sample.length})` : ""; return `${sample.join(", ")}${suffix}`; } function summarizeGuilds(entries?: Record) { if (!entries || Object.keys(entries).length === 0) { return "any"; } const keys = Object.keys(entries); const sample = keys.slice(0, 4); const suffix = keys.length > sample.length ? ` (+${keys.length - sample.length})` : ""; return `${sample.join(", ")}${suffix}`; } function formatThreadBindingDurationForConfigLabel(durationMs: number): string { const label = formatThreadBindingDurationLabel(durationMs); return label === "disabled" ? "off" : label; } async function deployDiscordCommands(params: { client: Client; runtime: RuntimeEnv; enabled: boolean; }) { if (!params.enabled) { return; } const runWithRetry = createDiscordRetryRunner({ verbose: shouldLogVerbose() }); try { await runWithRetry(() => params.client.handleDeployRequest(), "command deploy"); } catch (err) { const details = formatDiscordDeployErrorDetails(err); params.runtime.error?.( danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}${details}`), ); } } function formatDiscordDeployErrorDetails(err: unknown): string { if (!err || typeof err !== "object") { return ""; } const status = (err as { status?: unknown }).status; const discordCode = (err as { discordCode?: unknown }).discordCode; const rawBody = (err as { rawBody?: unknown }).rawBody; const details: string[] = []; if (typeof status === "number") { details.push(`status=${status}`); } if (typeof discordCode === "number" || typeof discordCode === "string") { details.push(`code=${discordCode}`); } if (rawBody !== undefined) { let bodyText = ""; try { bodyText = JSON.stringify(rawBody); } catch { bodyText = typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 }); } if (bodyText) { const maxLen = 800; const trimmed = bodyText.length > maxLen ? `${bodyText.slice(0, maxLen)}...` : bodyText; details.push(`body=${trimmed}`); } } return details.length > 0 ? ` (${details.join(", ")})` : ""; } const DISCORD_DISALLOWED_INTENTS_CODE = GatewayCloseCodes.DisallowedIntents; function isDiscordDisallowedIntentsError(err: unknown): boolean { if (!err) { return false; } const message = formatErrorMessage(err); return message.includes(String(DISCORD_DISALLOWED_INTENTS_CODE)); } export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveDiscordAccount({ cfg, accountId: opts.accountId, }); const token = normalizeDiscordToken(opts.token ?? undefined, "channels.discord.token") ?? account.token; if (!token) { throw new Error( `Discord bot token missing for account "${account.accountId}" (set discord.accounts.${account.accountId}.token or DISCORD_BOT_TOKEN for default).`, ); } const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); const rawDiscordCfg = account.config; const discordRootThreadBindings = cfg.channels?.discord?.threadBindings; const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); const dmConfig = rawDiscordCfg.dm; let guildEntries = rawDiscordCfg.guilds; const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const providerConfigPresent = cfg.channels?.discord !== undefined; const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent, groupPolicy: rawDiscordCfg.groupPolicy, defaultGroupPolicy, }); const discordCfg = rawDiscordCfg.groupPolicy === groupPolicy ? rawDiscordCfg : { ...rawDiscordCfg, groupPolicy }; warnMissingProviderGroupPolicyFallbackOnce({ providerMissingFallbackApplied, providerKey: "discord", accountId: account.accountId, blockedLabel: GROUP_POLICY_BLOCKED_LABEL.guild, log: (message) => runtime.log?.(warn(message)), }); let allowFrom = discordCfg.allowFrom ?? dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { fallbackLimit: 2000, }); const historyLimit = Math.max( 0, opts.historyLimit ?? discordCfg.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? 20, ); const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off"; const dmEnabled = dmConfig?.enabled ?? true; const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing"; const threadBindingIdleTimeoutMs = resolveThreadBindingIdleTimeoutMs({ channelIdleHoursRaw: discordAccountThreadBindings?.idleHours ?? discordRootThreadBindings?.idleHours, sessionIdleHoursRaw: cfg.session?.threadBindings?.idleHours, }); const threadBindingMaxAgeMs = resolveThreadBindingMaxAgeMs({ channelMaxAgeHoursRaw: discordAccountThreadBindings?.maxAgeHours ?? discordRootThreadBindings?.maxAgeHours, sessionMaxAgeHoursRaw: cfg.session?.threadBindings?.maxAgeHours, }); const threadBindingsEnabled = resolveThreadBindingsEnabled({ channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled, sessionEnabledRaw: cfg.session?.threadBindings?.enabled, }); const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; const nativeEnabled = resolveNativeCommandsEnabled({ providerId: "discord", providerSetting: discordCfg.commands?.native, globalSetting: cfg.commands?.native, }); const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ providerSetting: discordCfg.commands?.native, globalSetting: cfg.commands?.native, }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; const slashCommand = resolveDiscordSlashCommandConfig(discordCfg.slashCommand); const sessionPrefix = "discord:slash"; const ephemeralDefault = slashCommand.ephemeral; const voiceEnabled = discordCfg.voice?.enabled !== false; const allowlistResolved = await resolveDiscordAllowlistConfig({ token, guildEntries, allowFrom, fetcher: discordRestFetch, runtime, }); guildEntries = allowlistResolved.guildEntries; allowFrom = allowlistResolved.allowFrom; if (shouldLogVerbose()) { logVerbose( `discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadIdleTimeout=${formatThreadBindingDurationForConfigLabel(threadBindingIdleTimeoutMs)} threadMaxAge=${formatThreadBindingDurationForConfigLabel(threadBindingMaxAgeMs)}`, ); } const applicationId = await fetchDiscordApplicationId(token, 4000, discordRestFetch); if (!applicationId) { throw new Error("Failed to resolve Discord application id"); } const maxDiscordCommands = 100; const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { provider: "discord" }) : []; if (nativeEnabled && commandSpecs.length > maxDiscordCommands) { runtime.log?.( warn( `discord: ${commandSpecs.length} commands exceeds limit; some commands may fail to deploy.`, ), ); } const voiceManagerRef: { current: DiscordVoiceManager | null } = { current: null }; const threadBindings = threadBindingsEnabled ? createThreadBindingManager({ accountId: account.accountId, token, idleTimeoutMs: threadBindingIdleTimeoutMs, maxAgeMs: threadBindingMaxAgeMs, }) : createNoopThreadBindingManager(account.accountId); let lifecycleStarted = false; let releaseEarlyGatewayErrorGuard = () => {}; try { const commands: BaseCommand[] = commandSpecs.map((spec) => createDiscordNativeCommand({ command: spec, cfg, discordConfig: discordCfg, accountId: account.accountId, sessionPrefix, ephemeralDefault, threadBindings, }), ); if (nativeEnabled && voiceEnabled) { commands.push( createDiscordVoiceCommand({ cfg, discordConfig: discordCfg, accountId: account.accountId, groupPolicy, useAccessGroups, getManager: () => voiceManagerRef.current, ephemeralDefault, }), ); } const agentComponentsConfig = discordCfg.agentComponents ?? {}; const agentComponentsEnabled = agentComponentsConfig.enabled ?? true; const components: BaseMessageInteractiveComponent[] = [ createDiscordCommandArgFallbackButton({ cfg, discordConfig: discordCfg, accountId: account.accountId, sessionPrefix, threadBindings, }), ]; const modals: Modal[] = []; if (agentComponentsEnabled) { const componentContext = { cfg, discordConfig: discordCfg, accountId: account.accountId, guildEntries, allowFrom, dmPolicy, runtime, token, }; components.push(createAgentComponentButton(componentContext)); components.push(createAgentSelectMenu(componentContext)); components.push(createDiscordComponentButton(componentContext)); components.push(createDiscordComponentStringSelect(componentContext)); components.push(createDiscordComponentUserSelect(componentContext)); components.push(createDiscordComponentRoleSelect(componentContext)); components.push(createDiscordComponentMentionableSelect(componentContext)); components.push(createDiscordComponentChannelSelect(componentContext)); modals.push(createDiscordComponentModal(componentContext)); } class DiscordStatusReadyListener extends ReadyListener { async handle(_data: unknown, client: Client) { const gateway = client.getPlugin("gateway"); if (!gateway) { return; } const presence = resolveDiscordPresenceUpdate(discordCfg); if (!presence) { return; } gateway.updatePresence(presence); } } const clientPlugins: Plugin[] = [ createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }), ]; if (voiceEnabled) { clientPlugins.push(new VoicePlugin()); } const client = new Client( { baseUrl: "http://localhost", deploySecret: "a", clientId: applicationId, publicKey: "a", token, autoDeploy: false, }, { commands, listeners: [new DiscordStatusReadyListener()], components, modals, }, clientPlugins, ); const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client); releaseEarlyGatewayErrorGuard = earlyGatewayErrorGuard.release; await deployDiscordCommands({ client, runtime, enabled: nativeEnabled }); const logger = createSubsystemLogger("discord/monitor"); const guildHistories = new Map(); let botUserId: string | undefined; let voiceManager: DiscordVoiceManager | null = null; if (nativeDisabledExplicit) { await clearDiscordNativeCommands({ client, applicationId, runtime, }); } try { const botUser = await client.fetchUser("@me"); botUserId = botUser?.id; } catch (err) { runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`)); } if (voiceEnabled) { voiceManager = new DiscordVoiceManager({ client, cfg, discordConfig: discordCfg, accountId: account.accountId, runtime, botUserId, }); voiceManagerRef.current = voiceManager; registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager)); } const messageHandler = createDiscordMessageHandler({ cfg, discordConfig: discordCfg, accountId: account.accountId, token, runtime, botUserId, guildHistories, historyLimit, mediaMaxBytes, textLimit, replyToMode, dmEnabled, groupDmEnabled, groupDmChannels, allowFrom, guildEntries, threadBindings, discordRestFetch, }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger)); const reactionListenerOptions = { cfg, accountId: account.accountId, runtime, botUserId, dmEnabled, groupDmEnabled, groupDmChannels: groupDmChannels ?? [], dmPolicy, allowFrom: allowFrom ?? [], groupPolicy, allowNameMatching: isDangerousNameMatchingEnabled(discordCfg), guildEntries, logger, }; registerDiscordListener(client.listeners, new DiscordReactionListener(reactionListenerOptions)); registerDiscordListener( client.listeners, new DiscordReactionRemoveListener(reactionListenerOptions), ); if (discordCfg.intents?.presence) { registerDiscordListener( client.listeners, new DiscordPresenceListener({ logger, accountId: account.accountId }), ); runtime.log?.("discord: GuildPresences intent enabled — presence listener registered"); } runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); lifecycleStarted = true; await runDiscordGatewayLifecycle({ accountId: account.accountId, client, runtime, abortSignal: opts.abortSignal, isDisallowedIntentsError: isDiscordDisallowedIntentsError, voiceManager, voiceManagerRef, threadBindings, pendingGatewayErrors: earlyGatewayErrorGuard.pendingErrors, releaseEarlyGatewayErrorGuard, }); } finally { releaseEarlyGatewayErrorGuard(); if (!lifecycleStarted) { threadBindings.stop(); } } } async function clearDiscordNativeCommands(params: { client: Client; applicationId: string; runtime: RuntimeEnv; }) { try { await params.client.rest.put(Routes.applicationCommands(params.applicationId), { body: [], }); logVerbose("discord: cleared native commands (commands.native=false)"); } catch (err) { params.runtime.error?.(danger(`discord: failed to clear native commands: ${String(err)}`)); } } export const __testing = { createDiscordGatewayPlugin, resolveDiscordRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveDiscordRestFetch, resolveThreadBindingsEnabled, };