mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"device-bootstrap",
|
||||
"diagnostics-otel",
|
||||
"diffs",
|
||||
"discord",
|
||||
"extension-shared",
|
||||
"channel-config-helpers",
|
||||
"channel-config-schema",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]>
|
||||
|
||||
Reference in New Issue
Block a user