Files
openclaw/extensions/discord/src/monitor/provider.startup.ts
2026-04-24 23:15:28 +01:00

301 lines
10 KiB
TypeScript

import {
Client,
ReadyListener,
type BaseCommand,
type BaseMessageInteractiveComponent,
type Modal,
type Plugin,
} from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { VoicePlugin } from "@buape/carbon/voice";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { createDiscordRequestClient } from "../proxy-request-client.js";
import type { DiscordGuildEntryResolved } from "./allow-list.js";
import { createDiscordAutoPresenceController } from "./auto-presence.js";
import type { DiscordDmPolicy } from "./dm-command-auth.js";
import type { MutableDiscordGateway } from "./gateway-handle.js";
import {
createDiscordGatewayPlugin,
waitForDiscordGatewayPluginRegistration,
} from "./gateway-plugin.js";
import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js";
import {
DiscordMessageListener,
DiscordPresenceListener,
DiscordReactionListener,
DiscordReactionRemoveListener,
DiscordThreadUpdateListener,
registerDiscordListener,
} from "./listeners.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
type DiscordAutoPresenceController = ReturnType<typeof createDiscordAutoPresenceController>;
type DiscordListenerConfig = {
dangerouslyAllowNameMatching?: boolean;
intents?: { presence?: boolean };
};
type CreateClientFn = (
options: ConstructorParameters<typeof Client>[0],
handlers: ConstructorParameters<typeof Client>[1],
plugins: ConstructorParameters<typeof Client>[2],
) => Client;
type CarbonEventQueueOptions = NonNullable<ConstructorParameters<typeof Client>[0]["eventQueue"]>;
type ListenerCompatClient = Client & {
plugins?: Array<{ id: string; plugin: Plugin }>;
registerListener?: (listener: object) => object;
unregisterListener?: (listener: object) => boolean;
};
function withLegacyListenerCompat(client: Client): ListenerCompatClient {
const compatClient = client as ListenerCompatClient;
if (!compatClient.registerListener) {
compatClient.registerListener = (listener: object) => {
if (!compatClient.listeners.includes(listener as never)) {
compatClient.listeners.push(listener as never);
}
return listener;
};
}
if (!compatClient.unregisterListener) {
compatClient.unregisterListener = (listener: object) => {
const index = compatClient.listeners.indexOf(listener as never);
if (index < 0) {
return false;
}
compatClient.listeners.splice(index, 1);
return true;
};
}
return compatClient;
}
function registerLatePlugin(client: Client, plugin: Plugin) {
const compatClient = withLegacyListenerCompat(client);
void plugin.registerClient?.(compatClient);
void plugin.registerRoutes?.(compatClient);
if (!compatClient.plugins?.some((entry) => entry.id === plugin.id)) {
compatClient.plugins?.push({ id: plugin.id, plugin });
}
}
export function createDiscordStatusReadyListener(params: {
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0];
getAutoPresenceController: () => DiscordAutoPresenceController | null;
}): ReadyListener {
return new (class DiscordStatusReadyListener extends ReadyListener {
async handle(_data: unknown, client: Client) {
const autoPresenceController = params.getAutoPresenceController();
if (autoPresenceController?.enabled) {
autoPresenceController.refresh();
return;
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
if (!gateway) {
return;
}
const presence = resolveDiscordPresenceUpdate(params.discordConfig);
if (!presence) {
return;
}
gateway.updatePresence(presence);
}
})();
}
export async function createDiscordMonitorClient(params: {
accountId: string;
applicationId: string;
token: string;
proxyFetch?: typeof fetch;
commands: BaseCommand[];
components: BaseMessageInteractiveComponent[];
modals: Modal[];
voiceEnabled: boolean;
discordConfig: Parameters<typeof resolveDiscordPresenceUpdate>[0] & {
eventQueue?: Pick<
CarbonEventQueueOptions,
"listenerTimeout" | "maxQueueSize" | "maxConcurrency"
>;
};
runtime: RuntimeEnv;
createClient: CreateClientFn;
createGatewayPlugin: typeof createDiscordGatewayPlugin;
createGatewaySupervisor: typeof createDiscordGatewaySupervisor;
createAutoPresenceController: typeof createDiscordAutoPresenceController;
isDisallowedIntentsError: (err: unknown) => boolean;
}) {
let autoPresenceController: DiscordAutoPresenceController | null = null;
const clientPlugins: Plugin[] = [
params.createGatewayPlugin({
discordConfig: params.discordConfig,
runtime: params.runtime,
}),
];
if (params.voiceEnabled) {
clientPlugins.push(new VoicePlugin());
}
const voicePlugin = clientPlugins.find((plugin) => plugin.id === "voice");
const constructorPlugins = voicePlugin
? clientPlugins.filter((plugin) => plugin !== voicePlugin)
: clientPlugins;
// Pass eventQueue config to Carbon so the gateway listener budget can be tuned.
// Default listenerTimeout is 120s (Carbon defaults to 30s, which is too short for some
// Discord normalization/enqueue work).
const eventQueueOpts = {
listenerTimeout: 120_000,
slowListenerThreshold: 30_000,
...params.discordConfig.eventQueue,
} satisfies CarbonEventQueueOptions;
const readyListener = createDiscordStatusReadyListener({
discordConfig: params.discordConfig,
getAutoPresenceController: () => autoPresenceController,
});
const client = params.createClient(
{
baseUrl: "http://localhost",
deploySecret: "a",
clientId: params.applicationId,
publicKey: "a",
token: params.token,
autoDeploy: false,
eventQueue: eventQueueOpts,
},
{
commands: params.commands,
listeners: [readyListener],
components: params.components,
modals: params.modals,
},
constructorPlugins,
);
if (voicePlugin) {
registerLatePlugin(client, voicePlugin);
}
if (params.proxyFetch) {
client.rest = createDiscordRequestClient(params.token, {
fetch: params.proxyFetch,
});
}
const gateway = client.getPlugin<GatewayPlugin>("gateway") as MutableDiscordGateway | undefined;
await waitForDiscordGatewayPluginRegistration(gateway);
const gatewaySupervisor = params.createGatewaySupervisor({
gateway,
isDisallowedIntentsError: params.isDisallowedIntentsError,
runtime: params.runtime,
});
if (gateway) {
autoPresenceController = params.createAutoPresenceController({
accountId: params.accountId,
discordConfig: params.discordConfig,
gateway,
log: (message) => params.runtime.log?.(message),
});
autoPresenceController.start();
}
return {
client,
gateway,
gatewaySupervisor,
autoPresenceController,
eventQueueOpts,
};
}
export async function fetchDiscordBotIdentity(params: {
client: Pick<Client, "fetchUser">;
runtime: RuntimeEnv;
logStartupPhase: (phase: string, details?: string) => void;
}) {
params.logStartupPhase("fetch-bot-identity:start");
try {
const botUser = await params.client.fetchUser("@me");
const botUserId = botUser?.id;
const botUserName =
normalizeOptionalString(botUser?.username) ?? normalizeOptionalString(botUser?.globalName);
params.logStartupPhase(
"fetch-bot-identity:done",
`botUserId=${botUserId ?? "<missing>"} botUserName=${botUserName ?? "<missing>"}`,
);
return { botUserId, botUserName };
} catch (err) {
params.runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
params.logStartupPhase("fetch-bot-identity:error", String(err));
return { botUserId: undefined, botUserName: undefined };
}
}
export function registerDiscordMonitorListeners(params: {
cfg: OpenClawConfig;
client: Pick<Client, "listeners">;
accountId: string;
discordConfig: DiscordListenerConfig;
runtime: RuntimeEnv;
botUserId?: string;
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels?: string[];
dmPolicy: DiscordDmPolicy;
allowFrom?: string[];
groupPolicy: "open" | "allowlist" | "disabled";
guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: NonNullable<ConstructorParameters<typeof DiscordMessageListener>[1]>;
messageHandler: ConstructorParameters<typeof DiscordMessageListener>[0];
trackInboundEvent?: () => void;
eventQueueListenerTimeoutMs?: number;
}) {
registerDiscordListener(
params.client.listeners,
new DiscordMessageListener(params.messageHandler, params.logger, params.trackInboundEvent, {
timeoutMs: params.eventQueueListenerTimeoutMs,
}),
);
const reactionListenerOptions: ConstructorParameters<typeof DiscordReactionListener>[0] = {
cfg: params.cfg,
accountId: params.accountId,
runtime: params.runtime,
botUserId: params.botUserId,
dmEnabled: params.dmEnabled,
groupDmEnabled: params.groupDmEnabled,
groupDmChannels: params.groupDmChannels ?? [],
dmPolicy: params.dmPolicy,
allowFrom: params.allowFrom ?? [],
groupPolicy: params.groupPolicy,
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
guildEntries: params.guildEntries,
logger: params.logger,
onEvent: params.trackInboundEvent,
};
registerDiscordListener(
params.client.listeners,
new DiscordReactionListener(reactionListenerOptions),
);
registerDiscordListener(
params.client.listeners,
new DiscordReactionRemoveListener(reactionListenerOptions),
);
registerDiscordListener(
params.client.listeners,
new DiscordThreadUpdateListener(params.cfg, params.accountId, params.logger),
);
if (params.discordConfig.intents?.presence) {
registerDiscordListener(
params.client.listeners,
new DiscordPresenceListener({ logger: params.logger, accountId: params.accountId }),
);
params.runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
}
}