Fix Discord /codex_resume picker expiration (#51260)

Merged via squash.

Prepared head SHA: 76eb184dbe
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-21 12:59:21 -04:00
committed by GitHub
parent f4227e2787
commit e24bf22f98
15 changed files with 534 additions and 104 deletions

View File

@@ -211,6 +211,7 @@ Docs: https://docs.openclaw.ai
- Gateway/chat.send: persist uploaded image references across reloads and compaction without delaying first-turn dispatch or double-submitting the same image to vision models. (#51324) Thanks @fuller-stack-dev.
- Plugins/runtime state: share plugin-facing infra singleton state across duplicate module graphs and keep session-binding adapter ownership stable until the active owner unregisters. (#50725) thanks @huntharo.
- Agents/compaction safeguard: preserve split-turn context and preserved recent turns when capped retry fallback reuses the last successful summary. (#27727) thanks @Pandadadadazxf.
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
### Breaking

View File

@@ -1,9 +1,23 @@
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries");
const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries");
const componentEntries = new Map<string, DiscordComponentEntry>();
const modalEntries = new Map<string, DiscordModalEntry>();
function resolveGlobalMap<TKey, TValue>(key: symbol): Map<TKey, TValue> {
const globalStore = globalThis as Record<PropertyKey, unknown>;
if (globalStore[key] instanceof Map) {
return globalStore[key] as Map<TKey, TValue>;
}
const created = new Map<TKey, TValue>();
globalStore[key] = created;
return created;
}
const componentEntries = resolveGlobalMap<string, DiscordComponentEntry>(
DISCORD_COMPONENT_ENTRIES_KEY,
);
const modalEntries = resolveGlobalMap<string, DiscordModalEntry>(DISCORD_MODAL_ENTRIES_KEY);
function isExpired(entry: { expiresAt?: number }, now: number) {
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;

View File

@@ -1,5 +1,5 @@
import { MessageFlags } from "discord-api-types/v10";
import { describe, expect, it, beforeEach } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@@ -78,6 +78,8 @@ describe("discord component registry", () => {
clearDiscordComponentEntries();
});
const componentsRegistryModuleUrl = new URL("./components-registry.ts", import.meta.url).href;
it("registers and consumes component entries", () => {
registerDiscordComponentEntries({
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
@@ -102,4 +104,28 @@ describe("discord component registry", () => {
expect(consumed?.id).toBe("btn_1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
it("shares registry state across duplicate module instances", async () => {
const first = (await import(
`${componentsRegistryModuleUrl}?t=first-${Date.now()}`
)) as typeof import("./components-registry.js");
const second = (await import(
`${componentsRegistryModuleUrl}?t=second-${Date.now()}`
)) as typeof import("./components-registry.js");
first.clearDiscordComponentEntries();
first.registerDiscordComponentEntries({
entries: [{ id: "btn_shared", kind: "button", label: "Shared" }],
modals: [],
});
expect(second.resolveDiscordComponentEntry({ id: "btn_shared", consume: false })).toMatchObject(
{
id: "btn_shared",
label: "Shared",
},
);
second.clearDiscordComponentEntries();
});
});

View File

@@ -59,6 +59,7 @@ import {
type DiscordComponentEntry,
type DiscordModalEntry,
} from "../components.js";
import { editDiscordComponentMessage } from "../send.components.js";
import {
AGENT_BUTTON_KEY,
AGENT_SELECT_KEY,
@@ -120,10 +121,34 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
? `channel:${params.interactionCtx.channelId}`
: `user:${params.interactionCtx.userId}`;
let responded = false;
let acknowledged = false;
const updateOriginalMessage = async (input: {
text?: string;
components?: TopLevelComponents[];
}) => {
const payload = {
...(input.text !== undefined ? { content: input.text } : {}),
...(input.components !== undefined ? { components: input.components } : {}),
};
if (acknowledged) {
// Carbon edits @original on reply() after acknowledge(), which preserves
// plugin edit/clear flows without consuming a second interaction callback.
await params.interaction.reply(payload);
return;
}
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot update the source message");
}
await params.interaction.update(payload);
};
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
acknowledge: async () => {
responded = true;
if (responded) {
return;
}
await params.interaction.acknowledge();
acknowledged = true;
responded = true;
},
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
responded = true;
@@ -140,61 +165,58 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
});
},
editMessage: async (input) => {
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot update the source message");
}
const { text, components } = input;
responded = true;
await params.interaction.update({
...(text !== undefined ? { content: text } : {}),
...(components !== undefined ? { components: components as TopLevelComponents[] } : {}),
await updateOriginalMessage({
text,
components: components as TopLevelComponents[] | undefined,
});
},
clearComponents: async (input?: { text?: string }) => {
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot clear components on the source message");
}
responded = true;
await params.interaction.update({
...(input?.text !== undefined ? { content: input.text } : {}),
await updateOriginalMessage({
text: input?.text,
components: [],
});
},
};
const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.data);
if (pluginBindingApproval) {
try {
await respond.acknowledge();
} catch {
// Interaction may have expired; try to continue anyway.
}
const resolved = await resolvePluginConversationBindingApproval({
approvalId: pluginBindingApproval.approvalId,
decision: pluginBindingApproval.decision,
senderId: params.interactionCtx.userId,
});
let cleared = false;
try {
await respond.clearComponents();
cleared = true;
} catch {
const approvalMessageId = params.messageId?.trim() || params.interaction.message?.id?.trim();
if (approvalMessageId) {
try {
await respond.acknowledge();
} catch {
// Interaction may already be acknowledged; continue with best-effort follow-up.
await editDiscordComponentMessage(
normalizedConversationId,
approvalMessageId,
{
text: buildPluginBindingResolvedText(resolved),
},
{
accountId: params.ctx.accountId,
},
);
} catch (err) {
logError(`discord plugin binding approval: failed to clear prompt: ${String(err)}`);
}
}
try {
await respond.followUp({
text: buildPluginBindingResolvedText(resolved),
ephemeral: true,
});
} catch (err) {
logError(`discord plugin binding approval: failed to follow up: ${String(err)}`);
if (!cleared) {
try {
await respond.reply({
text: buildPluginBindingResolvedText(resolved),
ephemeral: true,
});
} catch {
// Interaction may no longer accept a direct reply.
}
if (resolved.status !== "approved") {
try {
await respond.followUp({
text: buildPluginBindingResolvedText(resolved),
ephemeral: true,
});
} catch (err) {
logError(`discord plugin binding approval: failed to follow up: ${String(err)}`);
}
}
return "handled";
@@ -220,6 +242,13 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
},
},
respond,
onMatched: async () => {
try {
await respond.acknowledge();
} catch {
// Interaction may have expired before the plugin handler ran.
}
},
});
if (!dispatched.matched) {
return "unmatched";
@@ -509,6 +538,7 @@ async function handleDiscordComponentEvent(params: {
interaction: params.interaction,
label: params.label,
componentLabel: params.componentLabel,
defer: false,
});
if (!interactionCtx) {
return;
@@ -614,11 +644,16 @@ async function handleDiscordComponentEvent(params: {
return;
}
}
const eventText = formatDiscordComponentEventText({
kind: consumed.kind === "select" ? "select" : "button",
label: consumed.label,
values,
});
// Preserve explicit callback payloads for button fallbacks so Discord
// behaves like Telegram when buttons carry synthetic command text. Select
// fallbacks still need their chosen values in the synthesized event text.
const eventText =
(consumed.kind === "button" ? consumed.callbackData?.trim() : undefined) ||
formatDiscordComponentEventText({
kind: consumed.kind === "select" ? "select" : "button",
label: consumed.label,
values,
});
try {
await params.interaction.reply({ content: "✓", ...replyOpts });
@@ -809,6 +844,7 @@ export class AgentComponentButton extends Button {
interaction,
label: "agent button",
componentLabel: "button",
defer: false,
});
if (!interactionCtx) {
return;
@@ -898,6 +934,7 @@ export class AgentSelectMenu extends StringSelectMenu {
interaction,
label: "agent select",
componentLabel: "select menu",
defer: false,
});
if (!interactionCtx) {
return;

View File

@@ -19,10 +19,12 @@ import {
resolveDiscordModalEntry,
} from "../components-registry.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
import * as sendComponents from "../send.components.js";
import {
createAgentComponentButton,
createAgentSelectMenu,
createDiscordComponentButton,
createDiscordComponentStringSelect,
createDiscordComponentModal,
} from "./agent-components.js";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
@@ -198,7 +200,7 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledTimes(1);
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
expect(pairingText).toContain("Pairing code:");
@@ -219,8 +221,11 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({
content: "You are not authorized to use this button.",
ephemeral: true,
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -236,8 +241,8 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
@@ -254,8 +259,8 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -271,8 +276,11 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "DM interactions are disabled." });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({
content: "DM interactions are disabled.",
ephemeral: true,
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -289,8 +297,8 @@ describe("agent components", () => {
await select.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -306,8 +314,8 @@ describe("agent components", () => {
await button.run(interaction, { cid: "hello_cid" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("hello_cid"),
expect.any(Object),
@@ -326,8 +334,8 @@ describe("agent components", () => {
await button.run(interaction, { cid: "hello%2G" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("hello%2G"),
expect.any(Object),
@@ -337,6 +345,7 @@ describe("agent components", () => {
});
describe("discord component interactions", () => {
let editDiscordComponentMessageMock: ReturnType<typeof vi.spyOn>;
const createCfg = (): OpenClawConfig =>
({
channels: {
@@ -394,6 +403,31 @@ describe("discord component interactions", () => {
return { interaction, defer, reply };
};
const createComponentSelectInteraction = (
overrides: Partial<StringSelectMenuInteraction> = {},
) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const rest = {
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
post: vi.fn().mockResolvedValue({}),
patch: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue(undefined),
};
const interaction = {
rawData: { channel_id: "dm-channel", id: "interaction-select-1" },
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
customId: "occomp:cid=sel_1",
message: { id: "msg-1" },
values: ["alpha"],
client: { rest },
defer,
reply,
...overrides,
} as unknown as StringSelectMenuInteraction;
return { interaction, defer, reply };
};
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
@@ -454,6 +488,12 @@ describe("discord component interactions", () => {
});
beforeEach(() => {
editDiscordComponentMessageMock = vi
.spyOn(sendComponents, "editDiscordComponentMessage")
.mockResolvedValue({
messageId: "msg-1",
channelId: "dm-channel",
});
clearDiscordComponentEntries();
lastDispatchCtx = undefined;
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
@@ -511,12 +551,57 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
it("uses raw callbackData for built-in fallback when no plugin handler matches", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "/codex_resume --browse-projects" })],
modals: [],
});
const button = createDiscordComponentButton(createComponentContext());
const { interaction, reply } = createComponentButtonInteraction();
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe("/codex_resume --browse-projects");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
it("preserves selected values for select fallback when no plugin handler matches", async () => {
registerDiscordComponentEntries({
entries: [
{
id: "sel_1",
kind: "select",
label: "Pick",
messageId: "msg-1",
sessionKey: "session-1",
agentId: "agent-1",
accountId: "default",
callbackData: "/codex_resume",
selectType: "string",
options: [{ value: "alpha", label: "Alpha" }],
},
],
modals: [],
});
const select = createDiscordComponentStringSelect(createComponentContext());
const { interaction, reply } = createComponentSelectInteraction();
await select.run(interaction, { cid: "sel_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe('Selected Alpha from "Pick".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
it("keeps reusable buttons active after use", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ reusable: true })],
@@ -550,7 +635,10 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." });
expect(reply).toHaveBeenCalledWith({
content: "You are not authorized to use this button.",
ephemeral: true,
});
expect(dispatchReplyMock).not.toHaveBeenCalled();
expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull();
});
@@ -797,6 +885,43 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("lets plugin Discord interactions clear components after acknowledging", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
modals: [],
});
dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => {
await params.respond.acknowledge();
await params.respond.clearComponents({ text: "Handled" });
return {
matched: true,
handled: true,
duplicate: false,
};
});
const button = createDiscordComponentButton(createComponentContext());
const acknowledge = vi.fn().mockResolvedValue(undefined);
const reply = vi.fn().mockResolvedValue(undefined);
const update = vi.fn().mockResolvedValue(undefined);
const interaction = {
...(createComponentButtonInteraction().interaction as any),
acknowledge,
reply,
update,
} as ButtonInteraction;
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledWith({
content: "Handled",
components: [],
});
expect(update).not.toHaveBeenCalled();
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
it("falls through to built-in Discord component routing when a plugin declines handling", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ callbackData: "codex:approve" })],
@@ -814,7 +939,7 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
@@ -828,21 +953,23 @@ describe("discord component interactions", () => {
modals: [],
});
const button = createDiscordComponentButton(createComponentContext());
const update = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const followUp = vi.fn().mockResolvedValue(undefined);
const interaction = {
...(createComponentButtonInteraction().interaction as any),
update,
acknowledge,
followUp,
} as ButtonInteraction;
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(update).toHaveBeenCalledWith({ components: [] });
expect(followUp).toHaveBeenCalledWith({
content: expect.stringContaining("bind approval"),
ephemeral: true,
});
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(editDiscordComponentMessageMock).toHaveBeenCalledWith(
"user:123456789",
"msg-1",
{ text: expect.any(String) },
{ accountId: "default" },
);
expect(dispatchReplyMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,11 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordComponentEntries } from "./components-registry.js";
import { sendDiscordComponentMessage } from "./send.components.js";
import {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./send.components.js";
import { makeDiscordRest } from "./send.test-harness.js";
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
@@ -52,4 +56,56 @@ describe("sendDiscordComponentMessage", () => {
const args = registerMock.mock.calls[0]?.[0];
expect(args?.entries[0]?.sessionKey).toBe("agent:main:discord:channel:dm-1");
});
it("edits component messages and refreshes component registry entries", async () => {
const { rest, patchMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({
type: ChannelType.GuildText,
id: "chan-1",
});
patchMock.mockResolvedValueOnce({ id: "msg1", channel_id: "chan-1" });
await editDiscordComponentMessage(
"channel:chan-1",
"msg1",
{
text: "Updated picker",
blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }],
},
{
rest,
token: "t",
sessionKey: "agent:main:discord:channel:chan-1",
agentId: "main",
},
);
expect(patchMock).toHaveBeenCalledWith(
expect.stringContaining("/channels/chan-1/messages/msg1"),
expect.objectContaining({
body: expect.any(Object),
}),
);
expect(registerMock).toHaveBeenCalledTimes(1);
const args = registerMock.mock.calls[0]?.[0];
expect(args?.messageId).toBe("msg1");
expect(args?.entries[0]?.sessionKey).toBe("agent:main:discord:channel:chan-1");
});
it("registers a prebuilt component message against an edited message id", () => {
registerBuiltDiscordComponentMessage({
messageId: "msg1",
buildResult: {
components: [],
entries: [{ id: "entry-1", kind: "button", label: "Tap" }],
modals: [{ id: "modal-1", title: "Modal", fields: [] }],
},
});
expect(registerMock).toHaveBeenCalledWith({
entries: [{ id: "entry-1", kind: "button", label: "Tap" }],
modals: [{ id: "modal-1", title: "Modal", fields: [] }],
messageId: "msg1",
});
});
});

View File

@@ -14,6 +14,7 @@ import {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
resolveDiscordComponentAttachmentName,
type DiscordComponentBuildResult,
type DiscordComponentMessageSpec,
} from "./components.js";
import {
@@ -54,38 +55,40 @@ type DiscordComponentSendOpts = {
filename?: string;
};
export async function sendDiscordComponentMessage(
to: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { channelId } = await resolveChannelId(rest, recipient, request);
const channelType = await resolveDiscordChannelType(rest, channelId);
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
throw new Error("Discord components are not supported in forum-style channels");
}
export function registerBuiltDiscordComponentMessage(params: {
buildResult: DiscordComponentBuildResult;
messageId: string;
}): void {
registerDiscordComponentEntries({
entries: params.buildResult.entries,
modals: params.buildResult.modals,
messageId: params.messageId,
});
}
async function buildDiscordComponentPayload(params: {
spec: DiscordComponentMessageSpec;
opts: DiscordComponentSendOpts;
accountId: string;
}): Promise<{
body: ReturnType<typeof stripUndefinedFields>;
buildResult: ReturnType<typeof buildDiscordComponentMessage>;
}> {
const buildResult = buildDiscordComponentMessage({
spec,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
accountId: accountInfo.accountId,
spec: params.spec,
sessionKey: params.opts.sessionKey,
agentId: params.opts.agentId,
accountId: params.accountId,
});
const flags = buildDiscordComponentMessageFlags(buildResult.components);
const finalFlags = opts.silent
const finalFlags = params.opts.silent
? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG
: (flags ?? undefined);
const messageReference = opts.replyTo
? { message_id: opts.replyTo, fail_if_not_exists: false }
const messageReference = params.opts.replyTo
? { message_id: params.opts.replyTo, fail_if_not_exists: false }
: undefined;
const attachmentNames = extractComponentAttachmentNames(spec);
const attachmentNames = extractComponentAttachmentNames(params.spec);
const uniqueAttachmentNames = [...new Set(attachmentNames)];
if (uniqueAttachmentNames.length > 1) {
throw new Error(
@@ -94,9 +97,11 @@ export async function sendDiscordComponentMessage(
}
const expectedAttachmentName = uniqueAttachmentNames[0];
let files: MessagePayloadFile[] | undefined;
if (opts.mediaUrl) {
const media = await loadWebMedia(opts.mediaUrl, { localRoots: opts.mediaLocalRoots });
const filenameOverride = opts.filename?.trim();
if (params.opts.mediaUrl) {
const media = await loadWebMedia(params.opts.mediaUrl, {
localRoots: params.opts.mediaLocalRoots,
});
const filenameOverride = params.opts.filename?.trim();
const fileName = filenameOverride || media.fileName || "upload";
if (expectedAttachmentName && expectedAttachmentName !== fileName) {
throw new Error(
@@ -121,6 +126,32 @@ export async function sendDiscordComponentMessage(
...(messageReference ? { message_reference: messageReference } : {}),
});
return { body, buildResult };
}
export async function sendDiscordComponentMessage(
to: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { channelId } = await resolveChannelId(rest, recipient, request);
const channelType = await resolveDiscordChannelType(rest, channelId);
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
throw new Error("Discord components are not supported in forum-style channels");
}
const { body, buildResult } = await buildDiscordComponentPayload({
spec,
opts,
accountId: accountInfo.accountId,
});
let result: { id: string; channel_id: string };
try {
result = (await request(
@@ -135,13 +166,12 @@ export async function sendDiscordComponentMessage(
channelId,
rest,
token,
hasMedia: Boolean(files?.length),
hasMedia: Boolean(opts.mediaUrl),
});
}
registerDiscordComponentEntries({
entries: buildResult.entries,
modals: buildResult.modals,
registerBuiltDiscordComponentMessage({
buildResult,
messageId: result.id,
});
@@ -156,3 +186,55 @@ export async function sendDiscordComponentMessage(
channelId: result.channel_id ?? channelId,
};
}
export async function editDiscordComponentMessage(
to: string,
messageId: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { channelId } = await resolveChannelId(rest, recipient, request);
const { body, buildResult } = await buildDiscordComponentPayload({
spec,
opts,
accountId: accountInfo.accountId,
});
let result: { id: string; channel_id: string };
try {
result = (await request(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"components",
)) as { id: string; channel_id: string };
} catch (err) {
throw await buildDiscordSendError(err, {
channelId,
rest,
token,
hasMedia: Boolean(opts.mediaUrl),
});
}
registerBuiltDiscordComponentMessage({
buildResult,
messageId: result.id ?? messageId,
});
recordChannelActivity({
channel: "discord",
accountId: accountInfo.accountId,
direction: "outbound",
});
return {
messageId: result.id ?? messageId,
channelId: result.channel_id ?? channelId,
};
}

View File

@@ -44,7 +44,11 @@ export {
sendWebhookMessageDiscord,
sendVoiceMessageDiscord,
} from "./send.outbound.js";
export { sendDiscordComponentMessage } from "./send.components.js";
export {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./send.components.js";
export { sendTypingDiscord } from "./send.typing.js";
export {
fetchChannelPermissionsDiscord,

View File

@@ -241,6 +241,10 @@
"types": "./dist/plugin-sdk/diffs.d.ts",
"default": "./dist/plugin-sdk/diffs.js"
},
"./plugin-sdk/discord": {
"types": "./dist/plugin-sdk/discord.d.ts",
"default": "./dist/plugin-sdk/discord.js"
},
"./plugin-sdk/extension-shared": {
"types": "./dist/plugin-sdk/extension-shared.d.ts",
"default": "./dist/plugin-sdk/extension-shared.js"

View File

@@ -50,6 +50,7 @@
"device-bootstrap",
"diagnostics-otel",
"diffs",
"discord",
"extension-shared",
"channel-config-helpers",
"channel-config-schema",

View File

@@ -9,6 +9,7 @@ export type { DiscordConfig, DiscordPluralKitConfig } from "../config/types.disc
export type { InspectedDiscordAccount } from "../../extensions/discord/api.js";
export type { ResolvedDiscordAccount } from "../../extensions/discord/api.js";
export type { DiscordSendComponents, DiscordSendEmbeds } from "../../extensions/discord/api.js";
export type { DiscordComponentMessageSpec } from "../../extensions/discord/api.js";
export type {
ThreadBindingManager,
ThreadBindingRecord,
@@ -64,8 +65,10 @@ export {
} from "./status-helpers.js";
export {
buildDiscordComponentMessage,
createDiscordActionGate,
listDiscordAccountIds,
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
} from "../../extensions/discord/api.js";
export { inspectDiscordAccount } from "../../extensions/discord/api.js";
@@ -105,6 +108,8 @@ export {
createScheduledEventDiscord,
createThreadDiscord,
deleteChannelDiscord,
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,

View File

@@ -9,6 +9,7 @@ const require = createRequire(import.meta.url);
const rootSdk = require("./root-alias.cjs") as Record<string, unknown>;
const rootAliasPath = fileURLToPath(new URL("./root-alias.cjs", import.meta.url));
const rootAliasSource = fs.readFileSync(rootAliasPath, "utf-8");
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
type EmptySchema = {
safeParse: (value: unknown) =>
@@ -196,6 +197,14 @@ describe("plugin-sdk root alias", () => {
expect(rootSdk.__esModule).toBe(true);
});
it("publishes the Discord plugin-sdk subpath", () => {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as {
exports?: Record<string, unknown>;
};
expect(packageJson.exports?.["./plugin-sdk/discord"]).toBeDefined();
});
it("preserves reflection semantics for lazily resolved exports", { timeout: 240_000 }, () => {
expect("resolveControlCommandGate" in rootSdk).toBe(true);
expect("onDiagnosticEvent" in rootSdk).toBe(true);

View File

@@ -97,7 +97,6 @@ describe("plugin-sdk subpath exports", () => {
expect(pluginSdkSubpaths).not.toContain("bluebubbles");
expect(pluginSdkSubpaths).not.toContain("compat");
expect(pluginSdkSubpaths).not.toContain("device-pair");
expect(pluginSdkSubpaths).not.toContain("discord");
expect(pluginSdkSubpaths).not.toContain("feishu");
expect(pluginSdkSubpaths).not.toContain("google");
expect(pluginSdkSubpaths).not.toContain("googlechat");
@@ -132,7 +131,6 @@ describe("plugin-sdk subpath exports", () => {
expect(pluginSdkSubpaths).not.toContain("secret-input-runtime");
expect(pluginSdkSubpaths).not.toContain("secret-input-schema");
expect(pluginSdkSubpaths).not.toContain("zai");
expect(pluginSdkSubpaths).not.toContain("discord-core");
expect(pluginSdkSubpaths).not.toContain("slack-core");
expect(pluginSdkSubpaths).not.toContain("provider-model-definitions");
});
@@ -220,6 +218,14 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof runtimeSdk.createLoggerBackedRuntime).toBe("function");
});
it("exports Discord component helpers from the dedicated subpath", async () => {
const discordSdk = await import("openclaw/plugin-sdk/discord");
expect(typeof discordSdk.buildDiscordComponentMessage).toBe("function");
expect(typeof discordSdk.editDiscordComponentMessage).toBe("function");
expect(typeof discordSdk.registerBuiltDiscordComponentMessage).toBe("function");
expect(typeof discordSdk.resolveDiscordAccount).toBe("function");
});
it("exports channel identity and session helpers from stronger existing homes", () => {
expect(typeof routingSdk.normalizeMessageChannel).toBe("function");
expect(typeof routingSdk.resolveGatewayMessageChannel).toBe("function");

View File

@@ -313,6 +313,58 @@ describe("plugin interactive handlers", () => {
});
});
it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => {
const callOrder: string[] = [];
const handler = vi.fn(async () => {
callOrder.push("handler");
expect(callOrder).toEqual(["ack", "handler"]);
return { handled: true };
});
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "discord",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler({
channel: "discord",
data: "codex:approve:thread-1",
interactionId: "ix-ack-1",
ctx: {
accountId: "default",
interactionId: "ix-ack-1",
conversationId: "channel-1",
parentConversationId: "parent-1",
guildId: "guild-1",
senderId: "user-1",
senderUsername: "ada",
auth: { isAuthorizedSender: true },
interaction: {
kind: "button",
messageId: "message-1",
},
},
respond: {
acknowledge: vi.fn(async () => {}),
reply: vi.fn(async () => {}),
followUp: vi.fn(async () => {}),
editMessage: vi.fn(async () => {}),
clearComponents: vi.fn(async () => {}),
},
onMatched: async () => {
callOrder.push("ack");
},
}),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
});
it("routes Slack interactions by namespace and dedupes interaction ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(

View File

@@ -168,6 +168,7 @@ export async function dispatchPluginInteractiveHandler(params: {
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
};
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "discord";
@@ -175,6 +176,7 @@ export async function dispatchPluginInteractiveHandler(params: {
interactionId: string;
ctx: DiscordInteractiveDispatchContext;
respond: PluginInteractiveDiscordHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "slack";
@@ -182,6 +184,7 @@ export async function dispatchPluginInteractiveHandler(params: {
interactionId: string;
ctx: SlackInteractiveDispatchContext;
respond: PluginInteractiveSlackHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "telegram" | "discord" | "slack";
@@ -205,6 +208,7 @@ export async function dispatchPluginInteractiveHandler(params: {
}
| PluginInteractiveDiscordHandlerContext["respond"]
| PluginInteractiveSlackHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}): Promise<InteractiveDispatchResult> {
const match = resolveNamespaceMatch(params.channel, params.data);
if (!match) {
@@ -217,6 +221,8 @@ export async function dispatchPluginInteractiveHandler(params: {
return { matched: true, handled: true, duplicate: true };
}
await params.onMatched?.();
let result:
| ReturnType<PluginInteractiveTelegramHandlerRegistration["handler"]>
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>