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:
Bob
2026-03-17 17:27:52 +01:00
committed by GitHub
parent 8139f83175
commit ea15819ecf
102 changed files with 6606 additions and 1199 deletions

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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,
});
}
}

View File

@@ -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);
});
});

View File

@@ -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(

View File

@@ -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,

View File

@@ -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.",
}),
);
});
});

View File

@@ -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;

View File

@@ -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(

View File

@@ -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
? {

View File

@@ -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,
});
});
});

View File

@@ -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();

View File

@@ -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();
}
},
};

View File

@@ -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");