Bob ea15819ecf
ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist

* Gateway: harden ACP startup and service PATH

* ACP: reinitialize error-state configured bindings

* ACP: classify pre-turn runtime failures as session init failures

* Plugins: move configured ACP routing behind channel seams

* Telegram tests: align startup probe assertions after rebase

* Discord: harden ACP configured binding recovery

* ACP: recover Discord bindings after stale runtime exits

* ACPX: replace dead sessions during ensure

* Discord: harden ACP binding recovery

* Discord: fix review follow-ups

* ACP bindings: load channel snapshots across workspaces

* ACP bindings: cache snapshot channel plugin resolution

* Experiments: add ACP pluginification holy grail plan

* Experiments: rename ACP pluginification plan doc

* Experiments: drop old ACP pluginification doc path

* ACP: move configured bindings behind plugin services

* Experiments: update bindings capability architecture plan

* Bindings: isolate configured binding routing and targets

* Discord tests: fix runtime env helper path

* Tests: fix channel binding CI regressions

* Tests: normalize ACP workspace assertion on Windows

* Bindings: isolate configured binding registry

* Bindings: finish configured binding cleanup

* Bindings: finish generic cleanup

* Bindings: align runtime approval callbacks

* ACP: delete residual bindings barrel

* Bindings: restore legacy compatibility

* Revert "Bindings: restore legacy compatibility"

This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe.

* Tests: drop ACP route legacy helper names

* Discord/ACP: fix binding regressions

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-17 17:27:52 +01:00

710 lines
24 KiB
TypeScript

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,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
DiscordConfigSchema,
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 = {
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
getCapabilities: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [],
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<TextDisplay | Separator> = [];
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<typeof resolveDiscordAccount>[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<ResolvedDiscordAccount> = {
...createDiscordPluginBase({
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
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.<id>.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.<id>.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: "<channelId|user:ID|channel:ID>",
},
},
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<DiscordSendFn>(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<DiscordSendFn>(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<string, unknown> = {
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:<id> 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 }),
});
},
},
};