mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 13:27:39 +00:00
feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -14,14 +14,6 @@ import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||
import {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
mergeAllowlist,
|
||||
resolveAllowlistIdAdditions,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../../channels/allowlists/resolve-utils.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
@@ -35,11 +27,7 @@ import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
|
||||
import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js";
|
||||
@@ -57,7 +45,6 @@ import {
|
||||
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
@@ -73,7 +60,10 @@ import {
|
||||
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 { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
@@ -105,6 +95,61 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
const DEFAULT_THREAD_BINDING_TTL_HOURS = 24;
|
||||
|
||||
function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
if (raw < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingSessionTtlMs(params: {
|
||||
channelTtlHoursRaw: unknown;
|
||||
sessionTtlHoursRaw: unknown;
|
||||
}): number {
|
||||
const ttlHours =
|
||||
normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ??
|
||||
normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ??
|
||||
DEFAULT_THREAD_BINDING_TTL_HOURS;
|
||||
return Math.floor(ttlHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined {
|
||||
if (typeof raw !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingsEnabled(params: {
|
||||
channelEnabledRaw: unknown;
|
||||
sessionEnabledRaw: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
normalizeThreadBindingsEnabled(params.channelEnabledRaw) ??
|
||||
normalizeThreadBindingsEnabled(params.sessionEnabledRaw) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "off";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
function dedupeSkillCommandsForDiscord(
|
||||
skillCommands: ReturnType<typeof listSkillCommandsForAgents>,
|
||||
) {
|
||||
@@ -201,6 +246,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
|
||||
const discordCfg = account.config;
|
||||
const discordRootThreadBindings = cfg.channels?.discord?.threadBindings;
|
||||
const discordAccountThreadBindings =
|
||||
cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
|
||||
const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime);
|
||||
const dmConfig = discordCfg.dm;
|
||||
let guildEntries = discordCfg.guilds;
|
||||
@@ -230,6 +278,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing";
|
||||
const threadBindingSessionTtlMs = resolveThreadBindingSessionTtlMs({
|
||||
channelTtlHoursRaw:
|
||||
discordAccountThreadBindings?.ttlHours ?? discordRootThreadBindings?.ttlHours,
|
||||
sessionTtlHoursRaw: cfg.session?.threadBindings?.ttlHours,
|
||||
});
|
||||
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({
|
||||
@@ -252,159 +309,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const ephemeralDefault = slashCommand.ephemeral;
|
||||
const voiceEnabled = discordCfg.voice?.enabled !== false;
|
||||
|
||||
if (token) {
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0) {
|
||||
try {
|
||||
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
|
||||
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
|
||||
if (guildKey === "*") {
|
||||
continue;
|
||||
}
|
||||
const channels = guildCfg?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
|
||||
if (channelKeys.length === 0) {
|
||||
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
|
||||
entries.push({ input, guildKey });
|
||||
continue;
|
||||
}
|
||||
for (const channelKey of channelKeys) {
|
||||
entries.push({
|
||||
input: `${guildKey}/${channelKey}`,
|
||||
guildKey,
|
||||
channelKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveDiscordChannelAllowlist({
|
||||
token,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const nextGuilds = { ...guildEntries };
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
const source = entries.find((item) => item.input === entry.input);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const sourceGuild = guildEntries?.[source.guildKey] ?? {};
|
||||
if (!entry.resolved || !entry.guildId) {
|
||||
unresolved.push(entry.input);
|
||||
continue;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.guildId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.guildId}`,
|
||||
);
|
||||
const existing = nextGuilds[entry.guildId] ?? {};
|
||||
const mergedChannels = { ...sourceGuild.channels, ...existing.channels };
|
||||
const mergedGuild = { ...sourceGuild, ...existing, channels: mergedChannels };
|
||||
nextGuilds[entry.guildId] = mergedGuild;
|
||||
if (source.channelKey && entry.channelId) {
|
||||
const sourceChannel = sourceGuild.channels?.[source.channelKey];
|
||||
if (sourceChannel) {
|
||||
nextGuilds[entry.guildId] = {
|
||||
...mergedGuild,
|
||||
channels: {
|
||||
...mergedChannels,
|
||||
[entry.channelId]: {
|
||||
...sourceChannel,
|
||||
...mergedChannels?.[entry.channelId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channels", mapping, unresolved, runtime);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowEntries =
|
||||
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: allowEntries.map((entry) => String(entry)),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
summarizeMapping("discord users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0) {
|
||||
const userEntries = new Set<string>();
|
||||
for (const guild of Object.values(guildEntries)) {
|
||||
if (!guild || typeof guild !== "object") {
|
||||
continue;
|
||||
}
|
||||
addAllowlistUserEntriesFromConfigEntry(userEntries, guild);
|
||||
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
for (const channel of Object.values(channels)) {
|
||||
addAllowlistUserEntriesFromConfigEntry(userEntries, channel);
|
||||
}
|
||||
}
|
||||
|
||||
if (userEntries.size > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: Array.from(userEntries),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } =
|
||||
buildAllowlistResolutionSummary(resolvedUsers);
|
||||
|
||||
const nextGuilds = { ...guildEntries };
|
||||
for (const [guildKey, guildConfig] of Object.entries(guildEntries ?? {})) {
|
||||
if (!guildConfig || typeof guildConfig !== "object") {
|
||||
continue;
|
||||
}
|
||||
const nextGuild = { ...guildConfig } as Record<string, unknown>;
|
||||
const users = (guildConfig as { users?: string[] }).users;
|
||||
if (Array.isArray(users) && users.length > 0) {
|
||||
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
|
||||
nextGuild.users = mergeAllowlist({ existing: users, additions });
|
||||
}
|
||||
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
if (channels && typeof channels === "object") {
|
||||
nextGuild.channels = patchAllowlistUsersInConfigEntries({
|
||||
entries: channels,
|
||||
resolvedMap,
|
||||
});
|
||||
}
|
||||
nextGuilds[guildKey] = nextGuild;
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channel users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
|
||||
`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"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadSessionTtl=${formatThreadBindingSessionTtlLabel(threadBindingSessionTtlMs)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,326 +356,250 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
);
|
||||
}
|
||||
const voiceManagerRef: { current: DiscordVoiceManager | null } = { current: null };
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
if (nativeEnabled && voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
const threadBindings = threadBindingsEnabled
|
||||
? createThreadBindingManager({
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
sessionTtlMs: threadBindingSessionTtlMs,
|
||||
})
|
||||
: createNoopThreadBindingManager(account.accountId);
|
||||
let lifecycleStarted = false;
|
||||
try {
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
getManager: () => voiceManagerRef.current,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
threadBindings,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (nativeEnabled && voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
getManager: () => voiceManagerRef.current,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
cfg,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const agentComponentsConfig = discordCfg.agentComponents ?? {};
|
||||
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const agentComponentsConfig = discordCfg.agentComponents ?? {};
|
||||
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
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<GatewayPlugin>("gateway");
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
const presence = resolveDiscordPresenceUpdate(discordCfg);
|
||||
if (!presence) {
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.updatePresence(presence);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
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,
|
||||
});
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
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}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (gateway) {
|
||||
registerGateway(account.accountId, gateway);
|
||||
}
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
runtime,
|
||||
});
|
||||
const abortSignal = opts.abortSignal;
|
||||
const onAbort = () => {
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
// Carbon emits an error when maxAttempts is 0; keep a one-shot listener to avoid
|
||||
// an unhandled error after we tear down listeners during abort.
|
||||
gatewayEmitter?.once("error", () => {});
|
||||
gateway.options.reconnect = { maxAttempts: 0 };
|
||||
gateway.disconnect();
|
||||
};
|
||||
if (abortSignal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Timeout to detect zombie connections where HELLO is never received.
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const onGatewayDebug = (msg: unknown) => {
|
||||
const message = String(msg);
|
||||
if (!message.includes("WebSocket connection opened")) {
|
||||
return;
|
||||
}
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
helloTimeoutId = setTimeout(() => {
|
||||
if (!gateway?.isConnected) {
|
||||
runtime.log?.(
|
||||
danger(
|
||||
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
|
||||
),
|
||||
);
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
}
|
||||
helloTimeoutId = undefined;
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
// Disallowed intents (4014) should stop the provider without crashing the gateway.
|
||||
let sawDisallowedIntents = false;
|
||||
try {
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
if (isDiscordDisallowedIntentsError(err)) {
|
||||
sawDisallowedIntents = true;
|
||||
runtime.error?.(
|
||||
danger(
|
||||
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
|
||||
),
|
||||
);
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
async handle(_data: unknown, client: Client) {
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
|
||||
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,
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error") ||
|
||||
isDiscordDisallowedIntentsError(err)
|
||||
);
|
||||
{
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
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,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!sawDisallowedIntents && !isDiscordDisallowedIntentsError(err)) {
|
||||
throw err;
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
execApprovalsHandler,
|
||||
threadBindings,
|
||||
});
|
||||
} finally {
|
||||
unregisterGateway(account.accountId);
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (voiceManager) {
|
||||
await voiceManager.destroy();
|
||||
voiceManagerRef.current = null;
|
||||
}
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.stop();
|
||||
if (!lifecycleStarted) {
|
||||
threadBindings.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -782,4 +623,5 @@ export const __testing = {
|
||||
createDiscordGatewayPlugin,
|
||||
dedupeSkillCommandsForDiscord,
|
||||
resolveDiscordRestFetch,
|
||||
resolveThreadBindingsEnabled,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user