mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 01:10:22 +00:00
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>
This commit is contained in:
@@ -1,8 +1,87 @@
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import { discordPlugin } from "./channel.js";
|
||||
import { setDiscordRuntime } from "./runtime.js";
|
||||
|
||||
const probeDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
|
||||
const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./probe.js")>();
|
||||
return {
|
||||
...actual,
|
||||
probeDiscord: probeDiscordMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./monitor.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./monitor.js")>();
|
||||
return {
|
||||
...actual,
|
||||
monitorDiscordProvider: monitorDiscordProviderMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./audit.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./audit.js")>();
|
||||
return {
|
||||
...actual,
|
||||
auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "discord-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedDiscordAccount> {
|
||||
const account = discordPlugin.config.resolveAccount(
|
||||
params.cfg,
|
||||
params.accountId,
|
||||
) as ResolvedDiscordAccount;
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
abortSignal: new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
probeDiscordMock.mockReset();
|
||||
monitorDiscordProviderMock.mockReset();
|
||||
auditDiscordChannelPermissionsMock.mockReset();
|
||||
});
|
||||
|
||||
describe("discordPlugin outbound", () => {
|
||||
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
||||
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
||||
@@ -33,4 +112,100 @@ describe("discordPlugin outbound", () => {
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
|
||||
});
|
||||
|
||||
it("uses direct Discord probe helpers for status probes", async () => {
|
||||
const runtimeProbeDiscord = vi.fn(async () => {
|
||||
throw new Error("runtime Discord probe should not be used");
|
||||
});
|
||||
setDiscordRuntime({
|
||||
channel: {
|
||||
discord: {
|
||||
probeDiscord: runtimeProbeDiscord,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
probeDiscordMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { username: "Bob" },
|
||||
application: {
|
||||
intents: {
|
||||
messageContent: "limited",
|
||||
guildMembers: "disabled",
|
||||
presence: "disabled",
|
||||
},
|
||||
},
|
||||
elapsedMs: 1,
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
const account = discordPlugin.config.resolveAccount(cfg, "default");
|
||||
|
||||
await discordPlugin.status!.probeAccount!({
|
||||
account,
|
||||
timeoutMs: 5000,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, {
|
||||
includeApplication: true,
|
||||
});
|
||||
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses direct Discord startup helpers before monitoring", async () => {
|
||||
const runtimeProbeDiscord = vi.fn(async () => {
|
||||
throw new Error("runtime Discord probe should not be used");
|
||||
});
|
||||
const runtimeMonitorDiscordProvider = vi.fn(async () => {
|
||||
throw new Error("runtime Discord monitor should not be used");
|
||||
});
|
||||
setDiscordRuntime({
|
||||
channel: {
|
||||
discord: {
|
||||
probeDiscord: runtimeProbeDiscord,
|
||||
monitorDiscordProvider: runtimeMonitorDiscordProvider,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
probeDiscordMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { username: "Bob" },
|
||||
application: {
|
||||
intents: {
|
||||
messageContent: "limited",
|
||||
guildMembers: "disabled",
|
||||
presence: "disabled",
|
||||
},
|
||||
},
|
||||
elapsedMs: 1,
|
||||
});
|
||||
monitorDiscordProviderMock.mockResolvedValue(undefined);
|
||||
|
||||
const cfg = createCfg();
|
||||
await discordPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
|
||||
includeApplication: true,
|
||||
});
|
||||
expect(monitorDiscordProviderMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: "discord-token",
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
||||
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,17 +35,18 @@ import {
|
||||
resolveDiscordAccount,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { collectDiscordAuditChannelIds } from "./audit.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 type { DiscordProbe } from "./probe.js";
|
||||
import { probeDiscord, type DiscordProbe } from "./probe.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
@@ -491,11 +492,15 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
silent: silent ?? undefined,
|
||||
}),
|
||||
},
|
||||
acpBindings: {
|
||||
normalizeConfiguredBindingTarget: ({ conversationId }) =>
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) =>
|
||||
normalizeDiscordAcpConversationId(conversationId),
|
||||
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
|
||||
matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
|
||||
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||
matchDiscordAcpConversation({
|
||||
bindingConversationId: compiledBinding.conversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
@@ -514,7 +519,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
|
||||
probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
}),
|
||||
formatCapabilitiesProbe: ({ probe }) => {
|
||||
@@ -620,7 +625,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
|
||||
const audit = await auditDiscordChannelPermissions({
|
||||
token: botToken,
|
||||
accountId: account.accountId,
|
||||
channelIds,
|
||||
@@ -661,7 +666,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
const token = account.token.trim();
|
||||
let discordBotLabel = "";
|
||||
try {
|
||||
const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
|
||||
const probe = await probeDiscord(token, 2500, {
|
||||
includeApplication: true,
|
||||
});
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
@@ -689,7 +694,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
}
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
|
||||
return getDiscordRuntime().channel.discord.monitorDiscordProvider({
|
||||
return monitorDiscordProvider({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
|
||||
@@ -9,6 +9,7 @@ import WebSocket from "ws";
|
||||
|
||||
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
|
||||
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
|
||||
const DISCORD_GATEWAY_INFO_TIMEOUT_MS = 10_000;
|
||||
|
||||
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
|
||||
type DiscordGatewayFetchInit = Record<string, unknown> & {
|
||||
@@ -19,6 +20,8 @@ type DiscordGatewayFetch = (
|
||||
init?: DiscordGatewayFetchInit,
|
||||
) => Promise<DiscordGatewayMetadataResponse>;
|
||||
|
||||
type DiscordGatewayMetadataError = Error & { transient?: boolean };
|
||||
|
||||
export function resolveDiscordGatewayIntents(
|
||||
intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig,
|
||||
): number {
|
||||
@@ -64,14 +67,36 @@ function createGatewayMetadataError(params: {
|
||||
transient: boolean;
|
||||
cause?: unknown;
|
||||
}): Error {
|
||||
if (params.transient) {
|
||||
return new Error("Failed to get gateway information from Discord: fetch failed", {
|
||||
cause: params.cause ?? new Error(params.detail),
|
||||
});
|
||||
}
|
||||
return new Error(`Failed to get gateway information from Discord: ${params.detail}`, {
|
||||
cause: params.cause,
|
||||
const error = new Error(
|
||||
params.transient
|
||||
? "Failed to get gateway information from Discord: fetch failed"
|
||||
: `Failed to get gateway information from Discord: ${params.detail}`,
|
||||
{
|
||||
cause: params.cause ?? (params.transient ? new Error(params.detail) : undefined),
|
||||
},
|
||||
) as DiscordGatewayMetadataError;
|
||||
Object.defineProperty(error, "transient", {
|
||||
value: params.transient,
|
||||
enumerable: false,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
function isTransientGatewayMetadataError(error: unknown): boolean {
|
||||
return Boolean((error as DiscordGatewayMetadataError | undefined)?.transient);
|
||||
}
|
||||
|
||||
function createDefaultGatewayInfo(): APIGatewayBotInfo {
|
||||
return {
|
||||
url: DEFAULT_DISCORD_GATEWAY_URL,
|
||||
shards: 1,
|
||||
session_start_limit: {
|
||||
total: 1,
|
||||
remaining: 1,
|
||||
reset_after: 0,
|
||||
max_concurrency: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDiscordGatewayInfo(params: {
|
||||
@@ -134,6 +159,65 @@ async function fetchDiscordGatewayInfo(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDiscordGatewayInfoWithTimeout(params: {
|
||||
token: string;
|
||||
fetchImpl: DiscordGatewayFetch;
|
||||
fetchInit?: DiscordGatewayFetchInit;
|
||||
timeoutMs?: number;
|
||||
}): Promise<APIGatewayBotInfo> {
|
||||
const timeoutMs = Math.max(1, params.timeoutMs ?? DISCORD_GATEWAY_INFO_TIMEOUT_MS);
|
||||
const abortController = new AbortController();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
reject(
|
||||
createGatewayMetadataError({
|
||||
detail: `Discord API /gateway/bot timed out after ${timeoutMs}ms`,
|
||||
transient: true,
|
||||
cause: new Error("gateway metadata timeout"),
|
||||
}),
|
||||
);
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
fetchDiscordGatewayInfo({
|
||||
token: params.token,
|
||||
fetchImpl: params.fetchImpl,
|
||||
fetchInit: {
|
||||
...params.fetchInit,
|
||||
signal: abortController.signal,
|
||||
},
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: unknown }): {
|
||||
info: APIGatewayBotInfo;
|
||||
usedFallback: boolean;
|
||||
} {
|
||||
if (!isTransientGatewayMetadataError(params.error)) {
|
||||
throw params.error;
|
||||
}
|
||||
const message = params.error instanceof Error ? params.error.message : String(params.error);
|
||||
params.runtime?.log?.(
|
||||
`discord: gateway metadata lookup failed transiently; using default gateway url (${message})`,
|
||||
);
|
||||
return {
|
||||
info: createDefaultGatewayInfo(),
|
||||
usedFallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayPlugin(params: {
|
||||
options: {
|
||||
reconnect: { maxAttempts: number };
|
||||
@@ -143,19 +227,29 @@ function createGatewayPlugin(params: {
|
||||
fetchImpl: DiscordGatewayFetch;
|
||||
fetchInit?: DiscordGatewayFetchInit;
|
||||
wsAgent?: HttpsProxyAgent<string>;
|
||||
runtime?: RuntimeEnv;
|
||||
}): GatewayPlugin {
|
||||
class SafeGatewayPlugin extends GatewayPlugin {
|
||||
private gatewayInfoUsedFallback = false;
|
||||
|
||||
constructor() {
|
||||
super(params.options);
|
||||
}
|
||||
|
||||
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
|
||||
if (!this.gatewayInfo) {
|
||||
this.gatewayInfo = await fetchDiscordGatewayInfo({
|
||||
if (!this.gatewayInfo || this.gatewayInfoUsedFallback) {
|
||||
const resolved = await fetchDiscordGatewayInfoWithTimeout({
|
||||
token: client.options.token,
|
||||
fetchImpl: params.fetchImpl,
|
||||
fetchInit: params.fetchInit,
|
||||
});
|
||||
})
|
||||
.then((info) => ({
|
||||
info,
|
||||
usedFallback: false,
|
||||
}))
|
||||
.catch((error) => resolveGatewayInfoWithFallback({ runtime: params.runtime, error }));
|
||||
this.gatewayInfo = resolved.info;
|
||||
this.gatewayInfoUsedFallback = resolved.usedFallback;
|
||||
}
|
||||
return super.registerClient(client);
|
||||
}
|
||||
@@ -187,6 +281,7 @@ export function createDiscordGatewayPlugin(params: {
|
||||
return createGatewayPlugin({
|
||||
options,
|
||||
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
||||
runtime: params.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,12 +296,14 @@ export function createDiscordGatewayPlugin(params: {
|
||||
fetchImpl: (input, init) => undiciFetch(input, init),
|
||||
fetchInit: { dispatcher: fetchAgent },
|
||||
wsAgent,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
} catch (err) {
|
||||
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
|
||||
return createGatewayPlugin({
|
||||
options,
|
||||
fetchImpl: (input, init) => fetch(input, init as RequestInit),
|
||||
runtime: params.runtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../src/acp/persistent-bindings.js", () => ({
|
||||
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
|
||||
ensureConfiguredAcpBindingSessionMock(...args),
|
||||
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
|
||||
resolveConfiguredAcpBindingRecordMock(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||
resolveConfiguredBindingRouteMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
||||
@@ -52,6 +56,77 @@ function createConfiguredDiscordBinding() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createConfiguredDiscordRoute() {
|
||||
const configuredBinding = createConfiguredDiscordBinding();
|
||||
return {
|
||||
bindingResolution: {
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: CHANNEL_ID,
|
||||
},
|
||||
compiledBinding: {
|
||||
channel: "discord",
|
||||
accountPattern: "default",
|
||||
binding: {
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: {
|
||||
kind: "channel",
|
||||
id: CHANNEL_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
bindingConversationId: CHANNEL_ID,
|
||||
target: {
|
||||
conversationId: CHANNEL_ID,
|
||||
},
|
||||
agentId: "codex",
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({ conversationId: CHANNEL_ID }),
|
||||
matchInboundConversation: () => ({ conversationId: CHANNEL_ID }),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId: "acp",
|
||||
materialize: () => ({
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: configuredBinding.record.targetSessionKey,
|
||||
agentId: configuredBinding.spec.agentId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: CHANNEL_ID,
|
||||
},
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: configuredBinding.record.targetSessionKey,
|
||||
agentId: configuredBinding.spec.agentId,
|
||||
},
|
||||
},
|
||||
configuredBinding,
|
||||
boundSessionKey: configuredBinding.record.targetSessionKey,
|
||||
route: {
|
||||
agentId: "codex",
|
||||
accountId: "default",
|
||||
channel: "discord",
|
||||
sessionKey: configuredBinding.record.targetSessionKey,
|
||||
mainSessionKey: "agent:codex:main",
|
||||
matchedBy: "binding.channel",
|
||||
lastRoutePolicy: "bound",
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-1",
|
||||
@@ -94,13 +169,10 @@ function createBasePreflightParams(overrides?: Record<string, unknown>) {
|
||||
describe("preflightDiscordMessage configured ACP bindings", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
ensureConfiguredAcpBindingSessionMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReset();
|
||||
resolveConfiguredAcpBindingRecordMock.mockReturnValue(createConfiguredDiscordBinding());
|
||||
ensureConfiguredAcpBindingSessionMock.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:acp:binding:discord:default:abc123",
|
||||
});
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredDiscordRoute());
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("does not initialize configured ACP bindings for rejected messages", async () => {
|
||||
@@ -121,8 +193,8 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).not.toHaveBeenCalled();
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initializes configured ACP bindings only after preflight accepts the message", async () => {
|
||||
@@ -144,8 +216,176 @@ describe("preflightDiscordMessage configured ACP bindings", () => {
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(resolveConfiguredAcpBindingRecordMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredAcpBindingSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
|
||||
});
|
||||
|
||||
it("accepts plain messages in configured ACP-bound channels without a mention", async () => {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-no-mention",
|
||||
channelId: CHANNEL_ID,
|
||||
content: "hello",
|
||||
mentionedUsers: [],
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage(
|
||||
createBasePreflightParams({
|
||||
data: createGuildEvent({
|
||||
channelId: CHANNEL_ID,
|
||||
guildId: GUILD_ID,
|
||||
author: message.author,
|
||||
message,
|
||||
}),
|
||||
guildEntries: {
|
||||
[GUILD_ID]: {
|
||||
id: GUILD_ID,
|
||||
channels: {
|
||||
[CHANNEL_ID]: {
|
||||
allow: true,
|
||||
enabled: true,
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
expect(result?.boundSessionKey).toBe("agent:codex:acp:binding:discord:default:abc123");
|
||||
});
|
||||
|
||||
it("hydrates empty guild message payloads from REST before ensuring configured ACP bindings", async () => {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-rest",
|
||||
channelId: CHANNEL_ID,
|
||||
content: "",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
});
|
||||
const restGet = vi.fn(async () => ({
|
||||
id: "m-rest",
|
||||
content: "hello from rest",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
author: {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
},
|
||||
}));
|
||||
const client = {
|
||||
...createGuildTextClient(CHANNEL_ID),
|
||||
rest: {
|
||||
get: restGet,
|
||||
},
|
||||
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
|
||||
|
||||
const result = await preflightDiscordMessage(
|
||||
createBasePreflightParams({
|
||||
client,
|
||||
data: createGuildEvent({
|
||||
channelId: CHANNEL_ID,
|
||||
guildId: GUILD_ID,
|
||||
author: message.author,
|
||||
message,
|
||||
}),
|
||||
guildEntries: {
|
||||
[GUILD_ID]: {
|
||||
id: GUILD_ID,
|
||||
channels: {
|
||||
[CHANNEL_ID]: {
|
||||
allow: true,
|
||||
enabled: true,
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(restGet).toHaveBeenCalledTimes(1);
|
||||
expect(result?.messageText).toBe("hello from rest");
|
||||
expect(result?.data.message.content).toBe("hello from rest");
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hydrates sticker-only guild message payloads from REST before ensuring configured ACP bindings", async () => {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-rest-sticker",
|
||||
channelId: CHANNEL_ID,
|
||||
content: "",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
});
|
||||
const restGet = vi.fn(async () => ({
|
||||
id: "m-rest-sticker",
|
||||
content: "",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
sticker_items: [
|
||||
{
|
||||
id: "sticker-1",
|
||||
name: "wave",
|
||||
},
|
||||
],
|
||||
author: {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
},
|
||||
}));
|
||||
const client = {
|
||||
...createGuildTextClient(CHANNEL_ID),
|
||||
rest: {
|
||||
get: restGet,
|
||||
},
|
||||
} as unknown as Parameters<typeof preflightDiscordMessage>[0]["client"];
|
||||
|
||||
const result = await preflightDiscordMessage(
|
||||
createBasePreflightParams({
|
||||
client,
|
||||
data: createGuildEvent({
|
||||
channelId: CHANNEL_ID,
|
||||
guildId: GUILD_ID,
|
||||
author: message.author,
|
||||
message,
|
||||
}),
|
||||
guildEntries: {
|
||||
[GUILD_ID]: {
|
||||
id: GUILD_ID,
|
||||
channels: {
|
||||
[CHANNEL_ID]: {
|
||||
allow: true,
|
||||
enabled: true,
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(restGet).toHaveBeenCalledTimes(1);
|
||||
expect(result?.messageText).toBe("<media:sticker> (1 sticker)");
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({
|
||||
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
}));
|
||||
import {
|
||||
@@ -229,16 +229,16 @@ describe("resolvePreflightMentionRequirement", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: true,
|
||||
isBoundThreadSession: false,
|
||||
bypassMentionRequirement: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disables mention requirement for bound thread sessions", () => {
|
||||
it("disables mention requirement when the route explicitly bypasses mentions", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: true,
|
||||
isBoundThreadSession: true,
|
||||
bypassMentionRequirement: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -247,7 +247,7 @@ describe("resolvePreflightMentionRequirement", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: false,
|
||||
isBoundThreadSession: false,
|
||||
bypassMentionRequirement: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -378,6 +378,69 @@ describe("preflightDiscordMessage", () => {
|
||||
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
|
||||
});
|
||||
|
||||
it("drops hydrated bound-thread webhook echoes after fetching an empty payload", async () => {
|
||||
const threadBinding = createThreadBinding({
|
||||
targetKind: "session",
|
||||
targetSessionKey: "agent:main:acp:discord-thread-1",
|
||||
});
|
||||
const threadId = "thread-webhook-hydrated-1";
|
||||
const parentId = "channel-parent-webhook-hydrated-1";
|
||||
const message = createDiscordMessage({
|
||||
id: "m-webhook-hydrated-1",
|
||||
channelId: threadId,
|
||||
content: "",
|
||||
webhookId: undefined,
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
});
|
||||
const restGet = vi.fn(async () => ({
|
||||
id: message.id,
|
||||
content: "webhook relay",
|
||||
webhook_id: "wh-1",
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
mention_everyone: false,
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
username: "Relay",
|
||||
bot: true,
|
||||
},
|
||||
}));
|
||||
const client = {
|
||||
...createThreadClient({ threadId, parentId }),
|
||||
rest: {
|
||||
get: restGet,
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as DiscordConfig,
|
||||
data: createGuildEvent({
|
||||
channelId: threadId,
|
||||
guildId: "guild-1",
|
||||
author: message.author,
|
||||
message,
|
||||
}),
|
||||
client,
|
||||
}),
|
||||
threadBindings: {
|
||||
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
|
||||
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||
});
|
||||
|
||||
expect(restGet).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
|
||||
const threadBinding = createThreadBinding();
|
||||
const threadId = "thread-bot-focus";
|
||||
@@ -655,8 +718,8 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage(
|
||||
createPreflightArgs({
|
||||
const result = await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
messages: {
|
||||
@@ -674,7 +737,17 @@ describe("preflightDiscordMessage", () => {
|
||||
}),
|
||||
client,
|
||||
}),
|
||||
);
|
||||
guildEntries: {
|
||||
"guild-1": {
|
||||
channels: {
|
||||
[channelId]: {
|
||||
allow: true,
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||
expect(transcribeFirstAudioMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChannelType, MessageType, type User } from "@buape/carbon";
|
||||
import { ChannelType, MessageType, type Message, type User } from "@buape/carbon";
|
||||
import { Routes, type APIMessage } from "discord-api-types/v10";
|
||||
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
|
||||
@@ -6,8 +7,8 @@ import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runt
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
@@ -95,12 +96,12 @@ function isBoundThreadBotSystemMessage(params: {
|
||||
|
||||
export function resolvePreflightMentionRequirement(params: {
|
||||
shouldRequireMention: boolean;
|
||||
isBoundThreadSession: boolean;
|
||||
bypassMentionRequirement: boolean;
|
||||
}): boolean {
|
||||
if (!params.shouldRequireMention) {
|
||||
return false;
|
||||
}
|
||||
return !params.isBoundThreadSession;
|
||||
return !params.bypassMentionRequirement;
|
||||
}
|
||||
|
||||
export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
||||
@@ -131,6 +132,95 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
||||
return webhookId === boundWebhookId;
|
||||
}
|
||||
|
||||
function mergeFetchedDiscordMessage(base: Message, fetched: APIMessage): Message {
|
||||
const baseReferenced = (
|
||||
base as unknown as {
|
||||
referencedMessage?: {
|
||||
mentionedUsers?: unknown[];
|
||||
mentionedRoles?: unknown[];
|
||||
mentionedEveryone?: boolean;
|
||||
};
|
||||
}
|
||||
).referencedMessage;
|
||||
const fetchedMentions = Array.isArray(fetched.mentions)
|
||||
? fetched.mentions.map((mention) => ({
|
||||
...mention,
|
||||
globalName: mention.global_name ?? undefined,
|
||||
}))
|
||||
: undefined;
|
||||
const referencedMessage = fetched.referenced_message
|
||||
? ({
|
||||
...((base as { referencedMessage?: object }).referencedMessage ?? {}),
|
||||
...fetched.referenced_message,
|
||||
mentionedUsers: Array.isArray(fetched.referenced_message.mentions)
|
||||
? fetched.referenced_message.mentions.map((mention) => ({
|
||||
...mention,
|
||||
globalName: mention.global_name ?? undefined,
|
||||
}))
|
||||
: (baseReferenced?.mentionedUsers ?? []),
|
||||
mentionedRoles:
|
||||
fetched.referenced_message.mention_roles ?? baseReferenced?.mentionedRoles ?? [],
|
||||
mentionedEveryone:
|
||||
fetched.referenced_message.mention_everyone ?? baseReferenced?.mentionedEveryone ?? false,
|
||||
} satisfies Record<string, unknown>)
|
||||
: (base as { referencedMessage?: Message }).referencedMessage;
|
||||
const rawData = {
|
||||
...((base as { rawData?: Record<string, unknown> }).rawData ?? {}),
|
||||
message_snapshots:
|
||||
fetched.message_snapshots ??
|
||||
(base as { rawData?: { message_snapshots?: unknown } }).rawData?.message_snapshots,
|
||||
sticker_items:
|
||||
(fetched as { sticker_items?: unknown }).sticker_items ??
|
||||
(base as { rawData?: { sticker_items?: unknown } }).rawData?.sticker_items,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...fetched,
|
||||
content: fetched.content ?? base.content,
|
||||
attachments: fetched.attachments ?? base.attachments,
|
||||
embeds: fetched.embeds ?? base.embeds,
|
||||
stickers:
|
||||
(fetched as { stickers?: unknown }).stickers ??
|
||||
(fetched as { sticker_items?: unknown }).sticker_items ??
|
||||
base.stickers,
|
||||
mentionedUsers: fetchedMentions ?? base.mentionedUsers,
|
||||
mentionedRoles: fetched.mention_roles ?? base.mentionedRoles,
|
||||
mentionedEveryone: fetched.mention_everyone ?? base.mentionedEveryone,
|
||||
referencedMessage,
|
||||
rawData,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
async function hydrateDiscordMessageIfEmpty(params: {
|
||||
client: DiscordMessagePreflightParams["client"];
|
||||
message: Message;
|
||||
messageChannelId: string;
|
||||
}): Promise<Message> {
|
||||
const currentText = resolveDiscordMessageText(params.message, {
|
||||
includeForwarded: true,
|
||||
});
|
||||
if (currentText) {
|
||||
return params.message;
|
||||
}
|
||||
const rest = params.client.rest as { get?: (route: string) => Promise<unknown> } | undefined;
|
||||
if (typeof rest?.get !== "function") {
|
||||
return params.message;
|
||||
}
|
||||
try {
|
||||
const fetched = (await rest.get(
|
||||
Routes.channelMessage(params.messageChannelId, params.message.id),
|
||||
)) as APIMessage | null | undefined;
|
||||
if (!fetched) {
|
||||
return params.message;
|
||||
}
|
||||
logVerbose(`discord: hydrated empty inbound payload via REST for ${params.message.id}`);
|
||||
return mergeFetchedDiscordMessage(params.message, fetched);
|
||||
} catch (err) {
|
||||
logVerbose(`discord: failed to hydrate message ${params.message.id}: ${String(err)}`);
|
||||
return params.message;
|
||||
}
|
||||
}
|
||||
|
||||
export async function preflightDiscordMessage(
|
||||
params: DiscordMessagePreflightParams,
|
||||
): Promise<DiscordMessagePreflightContext | null> {
|
||||
@@ -138,7 +228,7 @@ export async function preflightDiscordMessage(
|
||||
return null;
|
||||
}
|
||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||
const message = params.data.message;
|
||||
let message = params.data.message;
|
||||
const author = params.data.author;
|
||||
if (!author) {
|
||||
return null;
|
||||
@@ -160,6 +250,15 @@ export async function preflightDiscordMessage(
|
||||
return null;
|
||||
}
|
||||
|
||||
message = await hydrateDiscordMessageIfEmpty({
|
||||
client: params.client,
|
||||
message,
|
||||
messageChannelId,
|
||||
});
|
||||
if (isPreflightAborted(params.abortSignal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluralkitConfig = params.discordConfig?.pluralkit;
|
||||
const webhookId = resolveDiscordWebhookId(message);
|
||||
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
|
||||
@@ -197,6 +296,7 @@ export async function preflightDiscordMessage(
|
||||
}
|
||||
const isDirectMessage = channelInfo?.type === ChannelType.DM;
|
||||
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
|
||||
const data = message === params.data.message ? params.data : { ...params.data, message };
|
||||
logDebug(
|
||||
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
|
||||
);
|
||||
@@ -359,16 +459,18 @@ export async function preflightDiscordMessage(
|
||||
}) ?? undefined;
|
||||
const configuredRoute =
|
||||
threadBinding == null
|
||||
? resolveConfiguredAcpRoute({
|
||||
? resolveConfiguredBindingRoute({
|
||||
cfg: freshCfg,
|
||||
route,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: messageChannelId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: messageChannelId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const configuredBinding = configuredRoute?.configuredBinding ?? null;
|
||||
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
||||
if (!threadBinding && configuredBinding) {
|
||||
threadBinding = configuredBinding.record;
|
||||
}
|
||||
@@ -394,6 +496,7 @@ export async function preflightDiscordMessage(
|
||||
});
|
||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||
const bypassMentionRequirement = isBoundThreadSession || Boolean(configuredBinding);
|
||||
if (
|
||||
isBoundThreadBotSystemMessage({
|
||||
isBoundThreadSession,
|
||||
@@ -579,7 +682,7 @@ export async function preflightDiscordMessage(
|
||||
});
|
||||
const shouldRequireMention = resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: shouldRequireMentionByConfig,
|
||||
isBoundThreadSession,
|
||||
bypassMentionRequirement,
|
||||
});
|
||||
|
||||
// Preflight audio transcription for mention detection in guilds.
|
||||
@@ -764,13 +867,13 @@ export async function preflightDiscordMessage(
|
||||
return null;
|
||||
}
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: freshCfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`discord: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
`discord: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -794,7 +897,7 @@ export async function preflightDiscordMessage(
|
||||
replyToMode: params.replyToMode,
|
||||
ackReactionScope: params.ackReactionScope,
|
||||
groupPolicy: params.groupPolicy,
|
||||
data: params.data,
|
||||
data,
|
||||
client: params.client,
|
||||
message,
|
||||
messageChannelId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
|
||||
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
|
||||
import type { ChatType } from "../../../../src/channels/chat-type.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
||||
@@ -11,17 +12,17 @@ import {
|
||||
} from "./native-command.test-helpers.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ResolveConfiguredAcpBindingRecordFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredAcpRoute;
|
||||
type EnsureConfiguredAcpBindingSessionFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredAcpRouteReady;
|
||||
type ResolveConfiguredBindingRouteFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>((params) => ({
|
||||
configuredBinding: null,
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
})),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredAcpBindingSessionFn>(async () => ({
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
ok: true,
|
||||
})),
|
||||
}));
|
||||
@@ -30,8 +31,8 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredAcpRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredAcpRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -65,12 +66,7 @@ function createConfig(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStatusCommand(cfg: OpenClawConfig) {
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
|
||||
return createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
@@ -147,39 +143,145 @@ async function expectPairCommandReply(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
||||
configuredBinding: {
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: channelId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: `config:acp:discord:${params.accountId}:${channelId}`,
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
},
|
||||
boundSessionKey,
|
||||
boundAgentId: "codex",
|
||||
route: {
|
||||
...params.route,
|
||||
function createStatusCommand(cfg: OpenClawConfig) {
|
||||
return createNativeCommand(cfg, {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConversationFromParams(params: Parameters<ResolveConfiguredBindingRouteFn>[0]) {
|
||||
if ("conversation" in params) {
|
||||
return params.conversation;
|
||||
}
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createConfiguredBindingResolution(params: {
|
||||
conversation: ReturnType<typeof resolveConversationFromParams>;
|
||||
boundSessionKey: string;
|
||||
}) {
|
||||
const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-")
|
||||
? "direct"
|
||||
: "channel";
|
||||
const configuredBinding = {
|
||||
spec: {
|
||||
channel: "discord" as const,
|
||||
accountId: params.conversation.accountId,
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
agentId: "codex",
|
||||
sessionKey: boundSessionKey,
|
||||
matchedBy: "binding.channel",
|
||||
mode: "persistent" as const,
|
||||
},
|
||||
}));
|
||||
record: {
|
||||
bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`,
|
||||
targetSessionKey: params.boundSessionKey,
|
||||
targetKind: "session" as const,
|
||||
conversation: params.conversation,
|
||||
status: "active" as const,
|
||||
boundAt: 0,
|
||||
},
|
||||
};
|
||||
return {
|
||||
conversation: params.conversation,
|
||||
compiledBinding: {
|
||||
channel: "discord" as const,
|
||||
binding: {
|
||||
type: "acp" as const,
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: params.conversation.accountId,
|
||||
peer: {
|
||||
kind: peerKind,
|
||||
id: params.conversation.conversationId,
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent" as const,
|
||||
},
|
||||
},
|
||||
bindingConversationId: params.conversation.conversationId,
|
||||
target: {
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
agentId: "codex",
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
matchInboundConversation: () => ({
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId: "acp" as const,
|
||||
materialize: () => ({
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp",
|
||||
sessionKey: params.boundSessionKey,
|
||||
agentId: "codex",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp",
|
||||
sessionKey: params.boundSessionKey,
|
||||
agentId: "codex",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => {
|
||||
const conversation = resolveConversationFromParams(params);
|
||||
const bindingResolution = createConfiguredBindingResolution({
|
||||
conversation: {
|
||||
...conversation,
|
||||
conversationId: channelId,
|
||||
},
|
||||
boundSessionKey,
|
||||
});
|
||||
return {
|
||||
bindingResolution,
|
||||
boundSessionKey,
|
||||
boundAgentId: "codex",
|
||||
route: {
|
||||
...params.route,
|
||||
agentId: "codex",
|
||||
sessionKey: boundSessionKey,
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
@@ -234,7 +336,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
clearPluginCommands();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
||||
configuredBinding: null,
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
}));
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
||||
@@ -519,4 +621,64 @@ describe("Discord native plugin command dispatch", () => {
|
||||
boundSessionKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
|
||||
const guildId = "1459246755253325866";
|
||||
const channelId = "1479098716916023408";
|
||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const cfg = {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: channelId },
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as OpenClawConfig;
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
guildId,
|
||||
guildName: "Ops",
|
||||
});
|
||||
const command = createNativeCommand(cfg, {
|
||||
name: "new",
|
||||
description: "Start a new session.",
|
||||
acceptsArgs: true,
|
||||
});
|
||||
|
||||
setConfiguredBinding(channelId, boundSessionKey);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "acpx exited with code 1",
|
||||
});
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "Configured ACP binding is unavailable right now. Please try again.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,8 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredAcpRouteReady,
|
||||
resolveConfiguredAcpRoute,
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -194,6 +194,11 @@ function buildDiscordCommandOptions(params: {
|
||||
}) satisfies CommandOptions;
|
||||
}
|
||||
|
||||
function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
|
||||
const normalized = commandName.trim().toLowerCase();
|
||||
return normalized === "acp" || normalized === "new" || normalized === "reset";
|
||||
}
|
||||
|
||||
function readDiscordCommandArgs(
|
||||
interaction: CommandInteraction,
|
||||
definitions?: CommandArgDefinition[],
|
||||
@@ -1617,24 +1622,27 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
||||
const configuredRoute =
|
||||
threadBinding == null
|
||||
? resolveConfiguredAcpRoute({
|
||||
? resolveConfiguredBindingRoute({
|
||||
cfg,
|
||||
route,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: channelId,
|
||||
parentConversationId: threadParentId,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: channelId,
|
||||
parentConversationId: threadParentId,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const configuredBinding = configuredRoute?.configuredBinding ?? null;
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpRouteReady({
|
||||
const configuredBinding = configuredRoute?.bindingResolution ?? null;
|
||||
const commandName = command.nativeName ?? command.key;
|
||||
if (configuredBinding && !shouldBypassConfiguredAcpEnsure(commandName)) {
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg,
|
||||
configuredBinding,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
|
||||
`discord native command: configured ACP binding unavailable for channel ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
|
||||
);
|
||||
await respond("Configured ACP binding is unavailable right now. Please try again.");
|
||||
return;
|
||||
|
||||
@@ -228,6 +228,65 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
expect(connectedCall![0].lastConnectedAt).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("forces a fresh reconnect when startup never reaches READY, then recovers", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
gateway.connect.mockImplementation((_resume?: boolean) => {
|
||||
setTimeout(() => {
|
||||
gateway.isConnected = true;
|
||||
}, 1_000);
|
||||
});
|
||||
|
||||
const { lifecycleParams, runtimeError } = createLifecycleHarness({ gateway });
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
await vi.advanceTimersByTimeAsync(15_000 + 1_000);
|
||||
await expect(lifecyclePromise).resolves.toBeUndefined();
|
||||
|
||||
expect(runtimeError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("gateway was not ready after 15000ms"),
|
||||
);
|
||||
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.connect).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.connect).toHaveBeenCalledWith(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast when startup never reaches READY after a forced reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||
createLifecycleHarness({ gateway });
|
||||
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
lifecyclePromise.catch(() => {});
|
||||
await vi.advanceTimersByTimeAsync(15_000 * 2 + 1_000);
|
||||
await expect(lifecyclePromise).rejects.toThrow(
|
||||
"discord gateway did not reach READY within 15000ms after a forced reconnect",
|
||||
);
|
||||
|
||||
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.connect).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.connect).toHaveBeenCalledWith(false);
|
||||
expectLifecycleCleanup({
|
||||
start,
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const {
|
||||
@@ -276,6 +335,51 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces fatal startup gateway errors while waiting for READY", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const pendingGatewayErrors: unknown[] = [];
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
const {
|
||||
lifecycleParams,
|
||||
start,
|
||||
stop,
|
||||
threadStop,
|
||||
runtimeError,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
} = createLifecycleHarness({
|
||||
gateway,
|
||||
pendingGatewayErrors,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
pendingGatewayErrors.push(new Error("Fatal Gateway error: 4001"));
|
||||
}, 1_000);
|
||||
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
lifecyclePromise.catch(() => {});
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
await expect(lifecyclePromise).rejects.toThrow("Fatal Gateway error: 4001");
|
||||
|
||||
expect(runtimeError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("discord gateway error: Error: Fatal Gateway error: 4001"),
|
||||
);
|
||||
expect(gateway.disconnect).not.toHaveBeenCalled();
|
||||
expect(gateway.connect).not.toHaveBeenCalled();
|
||||
expectLifecycleCleanup({
|
||||
start,
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries stalled HELLO with resume before forcing fresh identify", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -288,8 +392,11 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
},
|
||||
sequence: 123,
|
||||
});
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
||||
emitter.emit("debug", "WebSocket connection closed with code 1006");
|
||||
gateway.isConnected = false;
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
@@ -324,8 +431,13 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
},
|
||||
sequence: 456,
|
||||
});
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
|
||||
emitter.emit("debug", "WebSocket connection closed with code 1006");
|
||||
gateway.isConnected = false;
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
|
||||
await emitGatewayOpenAndWait(emitter);
|
||||
|
||||
// Successful reconnect (READY/RESUMED sets isConnected=true), then
|
||||
@@ -342,10 +454,11 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
const { lifecycleParams } = createLifecycleHarness({ gateway });
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
||||
|
||||
expect(gateway.connect).toHaveBeenCalledTimes(3);
|
||||
expect(gateway.connect).toHaveBeenCalledTimes(4);
|
||||
expect(gateway.connect).toHaveBeenNthCalledWith(1, true);
|
||||
expect(gateway.connect).toHaveBeenNthCalledWith(2, true);
|
||||
expect(gateway.connect).toHaveBeenNthCalledWith(3, true);
|
||||
expect(gateway.connect).toHaveBeenNthCalledWith(4, true);
|
||||
expect(gateway.connect).not.toHaveBeenCalledWith(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -357,6 +470,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
||||
(waitParams: WaitForDiscordGatewayStopParams) =>
|
||||
@@ -382,6 +496,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
gateway.isConnected = true;
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
let resolveWait: (() => void) | undefined;
|
||||
waitForDiscordGatewayStopMock.mockImplementationOnce(
|
||||
|
||||
@@ -15,6 +15,37 @@ type ExecApprovalsHandler = {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000;
|
||||
const DISCORD_GATEWAY_READY_POLL_MS = 250;
|
||||
|
||||
type GatewayReadyWaitResult = "ready" | "timeout" | "stopped";
|
||||
|
||||
async function waitForDiscordGatewayReady(params: {
|
||||
gateway?: Pick<GatewayPlugin, "isConnected">;
|
||||
abortSignal?: AbortSignal;
|
||||
timeoutMs: number;
|
||||
beforePoll?: () => Promise<"continue" | "stop"> | "continue" | "stop";
|
||||
}): Promise<GatewayReadyWaitResult> {
|
||||
const deadlineAt = Date.now() + params.timeoutMs;
|
||||
while (!params.abortSignal?.aborted) {
|
||||
const pollDecision = await params.beforePoll?.();
|
||||
if (pollDecision === "stop") {
|
||||
return "stopped";
|
||||
}
|
||||
if (params.gateway?.isConnected) {
|
||||
return "ready";
|
||||
}
|
||||
if (Date.now() >= deadlineAt) {
|
||||
return "timeout";
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, DISCORD_GATEWAY_READY_POLL_MS);
|
||||
timeout.unref?.();
|
||||
});
|
||||
}
|
||||
return "stopped";
|
||||
}
|
||||
|
||||
export async function runDiscordGatewayLifecycle(params: {
|
||||
accountId: string;
|
||||
client: Client;
|
||||
@@ -242,20 +273,6 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
|
||||
// If the gateway is already connected when the lifecycle starts (the
|
||||
// "WebSocket connection opened" debug event was emitted before we
|
||||
// registered the listener above), push the initial connected status now.
|
||||
// Guard against lifecycleStopping: if the abortSignal was already aborted,
|
||||
// onAbort() ran synchronously above and pushed connected: false — don't
|
||||
// contradict it with a spurious connected: true.
|
||||
if (gateway?.isConnected && !lifecycleStopping) {
|
||||
const at = Date.now();
|
||||
pushStatus({
|
||||
...createConnectedChannelStatusPatch(at),
|
||||
lastDisconnect: null,
|
||||
});
|
||||
}
|
||||
|
||||
let sawDisallowedIntents = false;
|
||||
const logGatewayError = (err: unknown) => {
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
@@ -277,28 +294,107 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
params.isDisallowedIntentsError(err)
|
||||
);
|
||||
};
|
||||
const drainPendingGatewayErrors = (): "continue" | "stop" => {
|
||||
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
|
||||
if (pendingGatewayErrors.length === 0) {
|
||||
return "continue";
|
||||
}
|
||||
const queuedErrors = [...pendingGatewayErrors];
|
||||
pendingGatewayErrors.length = 0;
|
||||
for (const err of queuedErrors) {
|
||||
logGatewayError(err);
|
||||
if (!shouldStopOnGatewayError(err)) {
|
||||
continue;
|
||||
}
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
return "stop";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return "continue";
|
||||
};
|
||||
try {
|
||||
if (params.execApprovalsHandler) {
|
||||
await params.execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
// Drain gateway errors emitted before lifecycle listeners were attached.
|
||||
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
|
||||
if (pendingGatewayErrors.length > 0) {
|
||||
const queuedErrors = [...pendingGatewayErrors];
|
||||
pendingGatewayErrors.length = 0;
|
||||
for (const err of queuedErrors) {
|
||||
logGatewayError(err);
|
||||
if (!shouldStopOnGatewayError(err)) {
|
||||
continue;
|
||||
}
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
if (drainPendingGatewayErrors() === "stop") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Carbon starts the gateway during client construction, before OpenClaw can
|
||||
// attach lifecycle listeners. Require a READY/RESUMED-connected gateway
|
||||
// before continuing so the monitor does not look healthy while silently
|
||||
// missing inbound events.
|
||||
if (gateway && !gateway.isConnected && !lifecycleStopping) {
|
||||
const initialReady = await waitForDiscordGatewayReady({
|
||||
gateway,
|
||||
abortSignal: params.abortSignal,
|
||||
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
|
||||
beforePoll: drainPendingGatewayErrors,
|
||||
});
|
||||
if (initialReady === "stopped" || lifecycleStopping) {
|
||||
return;
|
||||
}
|
||||
if (initialReady === "timeout" && !lifecycleStopping) {
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
`discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; forcing a fresh reconnect`,
|
||||
),
|
||||
);
|
||||
const startupRetryAt = Date.now();
|
||||
pushStatus({
|
||||
connected: false,
|
||||
lastEventAt: startupRetryAt,
|
||||
lastDisconnect: {
|
||||
at: startupRetryAt,
|
||||
error: "startup-not-ready",
|
||||
},
|
||||
});
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
const reconnected = await waitForDiscordGatewayReady({
|
||||
gateway,
|
||||
abortSignal: params.abortSignal,
|
||||
timeoutMs: DISCORD_GATEWAY_READY_TIMEOUT_MS,
|
||||
beforePoll: drainPendingGatewayErrors,
|
||||
});
|
||||
if (reconnected === "stopped" || lifecycleStopping) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
if (reconnected === "timeout" && !lifecycleStopping) {
|
||||
const error = new Error(
|
||||
`discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after a forced reconnect`,
|
||||
);
|
||||
const startupFailureAt = Date.now();
|
||||
pushStatus({
|
||||
connected: false,
|
||||
lastEventAt: startupFailureAt,
|
||||
lastDisconnect: {
|
||||
at: startupFailureAt,
|
||||
error: "startup-reconnect-timeout",
|
||||
},
|
||||
lastError: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the gateway is already connected when the lifecycle starts (or becomes
|
||||
// connected during the startup readiness guard), push the initial connected
|
||||
// status now. Guard against lifecycleStopping: if the abortSignal was
|
||||
// already aborted, onAbort() ran synchronously above and pushed connected:
|
||||
// false, so don't contradict it with a spurious connected: true.
|
||||
if (gateway?.isConnected && !lifecycleStopping) {
|
||||
const at = Date.now();
|
||||
pushStatus({
|
||||
...createConnectedChannelStatusPatch(at),
|
||||
lastDisconnect: null,
|
||||
});
|
||||
}
|
||||
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
|
||||
@@ -142,11 +142,30 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
});
|
||||
|
||||
await expect(registerGatewayClient(plugin)).rejects.toThrow(
|
||||
"Failed to get gateway information from Discord: fetch failed",
|
||||
"Failed to get gateway information from Discord",
|
||||
);
|
||||
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectGatewayRegisterFallback(response: Response) {
|
||||
const runtime = createRuntime();
|
||||
globalFetchMock.mockResolvedValue(response);
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
discordConfig: {},
|
||||
runtime,
|
||||
});
|
||||
|
||||
await registerGatewayClient(plugin);
|
||||
|
||||
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
||||
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
||||
"wss://gateway.discord.gg/",
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("discord: gateway metadata lookup failed transiently"),
|
||||
);
|
||||
}
|
||||
|
||||
async function registerGatewayClientWithMetadata(params: {
|
||||
plugin: unknown;
|
||||
fetchMock: typeof globalFetchMock;
|
||||
@@ -161,6 +180,7 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", globalFetchMock);
|
||||
vi.useRealTimers();
|
||||
baseRegisterClientSpy.mockClear();
|
||||
globalFetchMock.mockClear();
|
||||
restProxyAgentSpy.mockClear();
|
||||
@@ -190,7 +210,7 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
});
|
||||
|
||||
it("maps plain-text Discord 503 responses to fetch failed", async () => {
|
||||
await expectGatewayRegisterFetchFailure({
|
||||
await expectGatewayRegisterFallback({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () =>
|
||||
@@ -198,6 +218,14 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it("keeps fatal Discord metadata failures fatal", async () => {
|
||||
await expectGatewayRegisterFetchFailure({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => "401: Unauthorized",
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
@@ -255,7 +283,7 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
});
|
||||
|
||||
it("maps body read failures to fetch failed", async () => {
|
||||
await expectGatewayRegisterFetchFailure({
|
||||
await expectGatewayRegisterFallback({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => {
|
||||
@@ -263,4 +291,68 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
},
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
it("falls back to the default gateway url when metadata lookup times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runtime = createRuntime();
|
||||
globalFetchMock.mockImplementation(() => new Promise(() => {}));
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
discordConfig: {},
|
||||
runtime,
|
||||
});
|
||||
|
||||
const registerPromise = registerGatewayClient(plugin);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await registerPromise;
|
||||
|
||||
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
||||
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
||||
"wss://gateway.discord.gg/",
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("discord: gateway metadata lookup failed transiently"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes fallback gateway metadata on the next register attempt", async () => {
|
||||
const runtime = createRuntime();
|
||||
globalFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () =>
|
||||
"upstream connect error or disconnect/reset before headers. reset reason: overflow",
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
url: "wss://gateway.discord.gg/?v=10",
|
||||
shards: 8,
|
||||
session_start_limit: {
|
||||
total: 1000,
|
||||
remaining: 999,
|
||||
reset_after: 120_000,
|
||||
max_concurrency: 16,
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const plugin = createDiscordGatewayPlugin({
|
||||
discordConfig: {},
|
||||
runtime,
|
||||
});
|
||||
|
||||
await registerGatewayClient(plugin);
|
||||
await registerGatewayClient(plugin);
|
||||
|
||||
expect(globalFetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(plugin as unknown as { gatewayInfo?: { url?: string; shards?: number } }).gatewayInfo,
|
||||
).toMatchObject({
|
||||
url: "wss://gateway.discord.gg/?v=10",
|
||||
shards: 8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
@@ -12,12 +13,12 @@ import { getSessionBindingService } from "../../../../src/infra/outbound/session
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||
const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
|
||||
const restGet = vi.fn(async () => ({
|
||||
const restGet = vi.fn(async (..._args: unknown[]) => ({
|
||||
id: "thread-1",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
}));
|
||||
const restPost = vi.fn(async () => ({
|
||||
const restPost = vi.fn(async (..._args: unknown[]) => ({
|
||||
id: "wh-created",
|
||||
token: "tok-created",
|
||||
}));
|
||||
@@ -45,47 +46,151 @@ vi.mock("../send.js", () => ({
|
||||
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
vi.mock("../send.messages.js", () => ({
|
||||
createThreadDiscord: hoisted.createThreadDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/acp/runtime/session-meta.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/acp/runtime/session-meta.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readAcpSessionEntry: hoisted.readAcpSessionEntry,
|
||||
};
|
||||
});
|
||||
|
||||
const { __testing, createThreadBindingManager } = await import("./thread-bindings.manager.js");
|
||||
const {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createThreadBindingManager,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} = await import("./thread-bindings.js");
|
||||
} = await import("./thread-bindings.lifecycle.js");
|
||||
const { resolveThreadBindingInactivityExpiresAt, resolveThreadBindingMaxAgeExpiresAt } =
|
||||
await import("./thread-bindings.state.js");
|
||||
const { resolveThreadBindingIntroText } = await import("./thread-bindings.messages.js");
|
||||
const discordClientModule = await import("../client.js");
|
||||
const discordThreadBindingApi = await import("./thread-bindings.discord-api.js");
|
||||
const acpRuntime = await import("openclaw/plugin-sdk/acp-runtime");
|
||||
|
||||
describe("thread binding lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
clearRuntimeConfigSnapshot();
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restPost.mockClear();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
hoisted.sendMessageDiscord.mockReset().mockResolvedValue({});
|
||||
hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({});
|
||||
hoisted.restGet.mockReset().mockResolvedValue({
|
||||
id: "thread-1",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
});
|
||||
hoisted.restPost.mockReset().mockResolvedValue({
|
||||
id: "wh-created",
|
||||
token: "tok-created",
|
||||
});
|
||||
hoisted.createDiscordRestClient.mockReset().mockImplementation((..._args: unknown[]) => ({
|
||||
rest: {
|
||||
get: hoisted.restGet,
|
||||
post: hoisted.restPost,
|
||||
},
|
||||
}));
|
||||
hoisted.createThreadDiscord.mockReset().mockResolvedValue({ id: "thread-created" });
|
||||
hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null);
|
||||
vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation(
|
||||
(...args) =>
|
||||
hoisted.createDiscordRestClient(...args) as unknown as ReturnType<
|
||||
typeof discordClientModule.createDiscordRestClient
|
||||
>,
|
||||
);
|
||||
vi.spyOn(discordThreadBindingApi, "createWebhookForChannel").mockImplementation(
|
||||
async (params) => {
|
||||
const rest = hoisted.createDiscordRestClient(
|
||||
{
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
},
|
||||
params.cfg,
|
||||
).rest;
|
||||
const created = (await rest.post("mock:channel-webhook")) as {
|
||||
id?: string;
|
||||
token?: string;
|
||||
};
|
||||
return {
|
||||
webhookId: typeof created?.id === "string" ? created.id.trim() || undefined : undefined,
|
||||
webhookToken:
|
||||
typeof created?.token === "string" ? created.token.trim() || undefined : undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.spyOn(discordThreadBindingApi, "resolveChannelIdForBinding").mockImplementation(
|
||||
async (params) => {
|
||||
const explicit = params.channelId?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
const rest = hoisted.createDiscordRestClient(
|
||||
{
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
},
|
||||
params.cfg,
|
||||
).rest;
|
||||
const channel = (await rest.get("mock:channel-resolve")) as {
|
||||
id?: string;
|
||||
type?: number;
|
||||
parent_id?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
|
||||
const parentId =
|
||||
typeof channel?.parent_id === "string"
|
||||
? channel.parent_id.trim()
|
||||
: typeof channel?.parentId === "string"
|
||||
? channel.parentId.trim()
|
||||
: "";
|
||||
const isThreadType =
|
||||
channel?.type === ChannelType.PublicThread ||
|
||||
channel?.type === ChannelType.PrivateThread ||
|
||||
channel?.type === ChannelType.AnnouncementThread;
|
||||
if (parentId && isThreadType) {
|
||||
return parentId;
|
||||
}
|
||||
return channelId || null;
|
||||
},
|
||||
);
|
||||
vi.spyOn(discordThreadBindingApi, "createThreadForBinding").mockImplementation(
|
||||
async (params) => {
|
||||
const created = await hoisted.createThreadDiscord(
|
||||
params.channelId,
|
||||
{
|
||||
name: params.threadName,
|
||||
autoArchiveMinutes: 60,
|
||||
},
|
||||
{
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
cfg: params.cfg,
|
||||
},
|
||||
);
|
||||
return typeof created?.id === "string" ? created.id.trim() || null : null;
|
||||
},
|
||||
);
|
||||
vi.spyOn(discordThreadBindingApi, "maybeSendBindingMessage").mockImplementation(
|
||||
async (params) => {
|
||||
if (
|
||||
params.preferWebhook !== false &&
|
||||
params.record.webhookId &&
|
||||
params.record.webhookToken
|
||||
) {
|
||||
await hoisted.sendWebhookMessageDiscord(params.text, {
|
||||
cfg: params.cfg,
|
||||
webhookId: params.record.webhookId,
|
||||
webhookToken: params.record.webhookToken,
|
||||
accountId: params.record.accountId,
|
||||
threadId: params.record.threadId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await hoisted.sendMessageDiscord(`channel:${params.record.threadId}`, params.text, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.record.accountId,
|
||||
});
|
||||
},
|
||||
);
|
||||
vi.spyOn(acpRuntime, "readAcpSessionEntry").mockImplementation(hoisted.readAcpSessionEntry);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -93,7 +198,7 @@ describe("thread binding lifecycle", () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
@@ -139,7 +244,7 @@ describe("thread binding lifecycle", () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
@@ -159,6 +264,7 @@ describe("thread binding lifecycle", () => {
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
@@ -177,7 +283,7 @@ describe("thread binding lifecycle", () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 0,
|
||||
maxAgeMs: 60_000,
|
||||
});
|
||||
@@ -195,6 +301,7 @@ describe("thread binding lifecycle", () => {
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
|
||||
@@ -214,6 +321,7 @@ describe("thread binding lifecycle", () => {
|
||||
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
@@ -234,6 +342,7 @@ describe("thread binding lifecycle", () => {
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
@@ -334,7 +443,7 @@ describe("thread binding lifecycle", () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
@@ -358,6 +467,7 @@ describe("thread binding lifecycle", () => {
|
||||
expect(updated[0]?.idleTimeoutMs).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
} finally {
|
||||
@@ -371,7 +481,7 @@ describe("thread binding lifecycle", () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
@@ -417,6 +527,7 @@ describe("thread binding lifecycle", () => {
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await __testing.runThreadBindingSweepForAccount("default");
|
||||
|
||||
expect(manager.getByThreadId("thread-2")).toBeDefined();
|
||||
expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
|
||||
|
||||
@@ -69,6 +69,8 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) {
|
||||
}
|
||||
}
|
||||
|
||||
const SWEEPERS_BY_ACCOUNT_ID = new Map<string, () => Promise<void>>();
|
||||
|
||||
function resolveEffectiveBindingExpiresAt(params: {
|
||||
record: ThreadBindingRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
@@ -200,6 +202,111 @@ export function createThreadBindingManager(
|
||||
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
|
||||
|
||||
let sweepTimer: NodeJS.Timeout | null = null;
|
||||
const runSweepOnce = async () => {
|
||||
const bindings = manager.listBindings();
|
||||
if (bindings.length === 0) {
|
||||
return;
|
||||
}
|
||||
let rest: ReturnType<typeof createDiscordRestClient>["rest"] | null = null;
|
||||
for (const snapshotBinding of bindings) {
|
||||
// Re-read live state after any awaited work from earlier iterations.
|
||||
// This avoids unbinding based on stale snapshot data when activity touches
|
||||
// happen while the sweeper loop is in-flight.
|
||||
const binding = manager.getByThreadId(snapshotBinding.threadId);
|
||||
if (!binding) {
|
||||
continue;
|
||||
}
|
||||
const now = Date.now();
|
||||
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
||||
record: binding,
|
||||
defaultIdleTimeoutMs: idleTimeoutMs,
|
||||
});
|
||||
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
||||
record: binding,
|
||||
defaultMaxAgeMs: maxAgeMs,
|
||||
});
|
||||
const expirationCandidates: Array<{
|
||||
reason: "idle-expired" | "max-age-expired";
|
||||
at: number;
|
||||
}> = [];
|
||||
if (inactivityExpiresAt != null && now >= inactivityExpiresAt) {
|
||||
expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt });
|
||||
}
|
||||
if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) {
|
||||
expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt });
|
||||
}
|
||||
if (expirationCandidates.length > 0) {
|
||||
expirationCandidates.sort((a, b) => a.at - b.at);
|
||||
const reason = expirationCandidates[0]?.reason ?? "idle-expired";
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason,
|
||||
sendFarewell: true,
|
||||
farewellText: resolveThreadBindingFarewellText({
|
||||
reason,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
|
||||
record: binding,
|
||||
defaultIdleTimeoutMs: idleTimeoutMs,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMs({
|
||||
record: binding,
|
||||
defaultMaxAgeMs: maxAgeMs,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isDirectConversationBindingId(binding.threadId)) {
|
||||
continue;
|
||||
}
|
||||
if (!rest) {
|
||||
try {
|
||||
const cfg = resolveCurrentCfg();
|
||||
rest = createDiscordRestClient(
|
||||
{
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
},
|
||||
cfg,
|
||||
).rest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||
if (!channel || typeof channel !== "object") {
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isThreadArchived(channel)) {
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-archived",
|
||||
sendFarewell: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDiscordThreadGoneError(err)) {
|
||||
logVerbose(
|
||||
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-delete",
|
||||
sendFarewell: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
SWEEPERS_BY_ACCOUNT_ID.set(accountId, runSweepOnce);
|
||||
|
||||
const manager: ThreadBindingManager = {
|
||||
accountId,
|
||||
@@ -444,6 +551,7 @@ export function createThreadBindingManager(
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
SWEEPERS_BY_ACCOUNT_ID.delete(accountId);
|
||||
unregisterManager(accountId, manager);
|
||||
unregisterSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
@@ -455,110 +563,13 @@ export function createThreadBindingManager(
|
||||
|
||||
if (params.enableSweeper !== false) {
|
||||
sweepTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
const bindings = manager.listBindings();
|
||||
if (bindings.length === 0) {
|
||||
return;
|
||||
}
|
||||
let rest;
|
||||
try {
|
||||
const cfg = resolveCurrentCfg();
|
||||
rest = createDiscordRestClient(
|
||||
{
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
},
|
||||
cfg,
|
||||
).rest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const snapshotBinding of bindings) {
|
||||
// Re-read live state after any awaited work from earlier iterations.
|
||||
// This avoids unbinding based on stale snapshot data when activity touches
|
||||
// happen while the sweeper loop is in-flight.
|
||||
const binding = manager.getByThreadId(snapshotBinding.threadId);
|
||||
if (!binding) {
|
||||
continue;
|
||||
}
|
||||
const now = Date.now();
|
||||
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
||||
record: binding,
|
||||
defaultIdleTimeoutMs: idleTimeoutMs,
|
||||
});
|
||||
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
||||
record: binding,
|
||||
defaultMaxAgeMs: maxAgeMs,
|
||||
});
|
||||
const expirationCandidates: Array<{
|
||||
reason: "idle-expired" | "max-age-expired";
|
||||
at: number;
|
||||
}> = [];
|
||||
if (inactivityExpiresAt != null && now >= inactivityExpiresAt) {
|
||||
expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt });
|
||||
}
|
||||
if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) {
|
||||
expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt });
|
||||
}
|
||||
if (expirationCandidates.length > 0) {
|
||||
expirationCandidates.sort((a, b) => a.at - b.at);
|
||||
const reason = expirationCandidates[0]?.reason ?? "idle-expired";
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason,
|
||||
sendFarewell: true,
|
||||
farewellText: resolveThreadBindingFarewellText({
|
||||
reason,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
|
||||
record: binding,
|
||||
defaultIdleTimeoutMs: idleTimeoutMs,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMs({
|
||||
record: binding,
|
||||
defaultMaxAgeMs: maxAgeMs,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isDirectConversationBindingId(binding.threadId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||
if (!channel || typeof channel !== "object") {
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isThreadArchived(channel)) {
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-archived",
|
||||
sendFarewell: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDiscordThreadGoneError(err)) {
|
||||
logVerbose(
|
||||
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-delete",
|
||||
sendFarewell: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void runSweepOnce();
|
||||
}, THREAD_BINDINGS_SWEEP_INTERVAL_MS);
|
||||
sweepTimer.unref?.();
|
||||
// Keep the production process free to exit, but avoid breaking fake-timer
|
||||
// sweeper tests where unref'd intervals may never fire.
|
||||
if (!(process.env.VITEST || process.env.NODE_ENV === "test")) {
|
||||
sweepTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
@@ -690,4 +701,10 @@ export const __testing = {
|
||||
resolveThreadBindingsPath,
|
||||
resolveThreadBindingThreadName,
|
||||
resetThreadBindingsForTests,
|
||||
runThreadBindingSweepForAccount: async (accountId?: string) => {
|
||||
const sweep = SWEEPERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId));
|
||||
if (sweep) {
|
||||
await sweep();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,10 +6,14 @@ const hoisted = vi.hoisted(() => {
|
||||
return { updateSessionStore, resolveStorePath };
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/sessions.js", () => ({
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
resolveStorePath: hoisted.resolveStorePath,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
resolveStorePath: hoisted.resolveStorePath,
|
||||
};
|
||||
});
|
||||
|
||||
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user