mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 12:30:49 +00:00
Plugins: add Discord interaction surface
This commit is contained in:
@@ -19,11 +19,13 @@ describe("discord components", () => {
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Approve", style: "success" }],
|
||||
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
|
||||
},
|
||||
],
|
||||
modal: {
|
||||
title: "Details",
|
||||
callbackData: "codex:modal",
|
||||
allowedUsers: ["discord:user-1"],
|
||||
fields: [{ type: "text", label: "Requester" }],
|
||||
},
|
||||
});
|
||||
@@ -39,6 +41,11 @@ describe("discord components", () => {
|
||||
|
||||
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
|
||||
expect(trigger?.modalId).toBe(result.modals[0]?.id);
|
||||
expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe(
|
||||
"codex:approve",
|
||||
);
|
||||
expect(result.modals[0]?.callbackData).toBe("codex:modal");
|
||||
expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]);
|
||||
});
|
||||
|
||||
it("requires options for modal select fields", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ export type DiscordComponentButtonSpec = {
|
||||
label: string;
|
||||
style?: DiscordComponentButtonStyle;
|
||||
url?: string;
|
||||
callbackData?: string;
|
||||
emoji?: {
|
||||
name: string;
|
||||
id?: string;
|
||||
@@ -70,10 +71,12 @@ export type DiscordComponentSelectOption = {
|
||||
|
||||
export type DiscordComponentSelectSpec = {
|
||||
type?: DiscordComponentSelectType;
|
||||
callbackData?: string;
|
||||
placeholder?: string;
|
||||
minValues?: number;
|
||||
maxValues?: number;
|
||||
options?: DiscordComponentSelectOption[];
|
||||
allowedUsers?: string[];
|
||||
};
|
||||
|
||||
export type DiscordComponentSectionAccessory =
|
||||
@@ -136,8 +139,10 @@ export type DiscordModalFieldSpec = {
|
||||
|
||||
export type DiscordModalSpec = {
|
||||
title: string;
|
||||
callbackData?: string;
|
||||
triggerLabel?: string;
|
||||
triggerStyle?: DiscordComponentButtonStyle;
|
||||
allowedUsers?: string[];
|
||||
fields: DiscordModalFieldSpec[];
|
||||
};
|
||||
|
||||
@@ -156,6 +161,7 @@ export type DiscordComponentEntry = {
|
||||
id: string;
|
||||
kind: "button" | "select" | "modal-trigger";
|
||||
label: string;
|
||||
callbackData?: string;
|
||||
selectType?: DiscordComponentSelectType;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
modalId?: string;
|
||||
@@ -188,6 +194,7 @@ export type DiscordModalFieldDefinition = {
|
||||
export type DiscordModalEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
callbackData?: string;
|
||||
fields: DiscordModalFieldDefinition[];
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
@@ -196,6 +203,7 @@ export type DiscordModalEntry = {
|
||||
messageId?: string;
|
||||
createdAt?: number;
|
||||
expiresAt?: number;
|
||||
allowedUsers?: string[];
|
||||
};
|
||||
|
||||
export type DiscordComponentBuildResult = {
|
||||
@@ -364,6 +372,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe
|
||||
label: readString(obj.label, `${label}.label`),
|
||||
style,
|
||||
url,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
emoji:
|
||||
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
|
||||
? {
|
||||
@@ -395,10 +404,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
|
||||
}
|
||||
return {
|
||||
type,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
placeholder: readOptionalString(obj.placeholder),
|
||||
minValues: readOptionalNumber(obj.minValues),
|
||||
maxValues: readOptionalNumber(obj.maxValues),
|
||||
options: parseSelectOptions(obj.options, `${label}.options`),
|
||||
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -578,8 +589,10 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
|
||||
);
|
||||
modal = {
|
||||
title: readString(modalObj.title, "components.modal.title"),
|
||||
callbackData: readOptionalString(modalObj.callbackData),
|
||||
triggerLabel: readOptionalString(modalObj.triggerLabel),
|
||||
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
|
||||
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
|
||||
fields,
|
||||
};
|
||||
}
|
||||
@@ -718,6 +731,7 @@ function createButtonComponent(params: {
|
||||
id: componentId,
|
||||
kind: params.modalId ? "modal-trigger" : "button",
|
||||
label: params.spec.label,
|
||||
callbackData: params.spec.callbackData,
|
||||
modalId: params.modalId,
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
@@ -758,8 +772,10 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "string",
|
||||
options: options.map((option) => ({ value: option.value, label: option.label })),
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -777,7 +793,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "user select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "user",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -795,7 +813,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "role select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "role",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -813,7 +833,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "mentionable select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "mentionable",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -830,7 +852,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "channel select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "channel",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1047,16 +1071,19 @@ export function buildDiscordComponentMessage(params: {
|
||||
modals.push({
|
||||
id: modalId,
|
||||
title: params.spec.modal.title,
|
||||
callbackData: params.spec.modal.callbackData,
|
||||
fields,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
reusable: params.spec.reusable,
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
});
|
||||
|
||||
const triggerSpec: DiscordComponentButtonSpec = {
|
||||
label: params.spec.modal.triggerLabel ?? "Open form",
|
||||
style: params.spec.modal.triggerStyle ?? "primary",
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
};
|
||||
|
||||
const { component, entry } = createButtonComponent({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ModalInteraction,
|
||||
type RoleSelectMenuInteraction,
|
||||
type StringSelectMenuInteraction,
|
||||
type TopLevelComponents,
|
||||
type UserSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
||||
@@ -40,6 +41,7 @@ import { logDebug, logError } from "../../../../src/logger.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js";
|
||||
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
||||
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js";
|
||||
import { dispatchPluginInteractiveHandler } from "../../../../src/plugins/interactive.js";
|
||||
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import {
|
||||
@@ -771,6 +773,113 @@ function formatModalSubmissionText(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
|
||||
const rawId =
|
||||
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
|
||||
? (interaction.rawData as { id?: unknown }).id
|
||||
: undefined;
|
||||
if (typeof rawId === "string" && rawId.trim()) {
|
||||
return rawId.trim();
|
||||
}
|
||||
if (typeof rawId === "number" && Number.isFinite(rawId)) {
|
||||
return String(rawId);
|
||||
}
|
||||
return `discord-interaction:${Date.now()}`;
|
||||
}
|
||||
|
||||
async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
channelCtx: DiscordChannelContext;
|
||||
data: string;
|
||||
kind: "button" | "select" | "modal";
|
||||
values?: string[];
|
||||
fields?: Array<{ id: string; name: string; values: string[] }>;
|
||||
messageId?: string;
|
||||
}): Promise<"handled" | "unmatched"> {
|
||||
let responded = false;
|
||||
const respond = {
|
||||
acknowledge: async () => {
|
||||
responded = true;
|
||||
await params.interaction.acknowledge();
|
||||
},
|
||||
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
||||
responded = true;
|
||||
await params.interaction.reply({
|
||||
content: text,
|
||||
ephemeral,
|
||||
});
|
||||
},
|
||||
followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
||||
responded = true;
|
||||
await params.interaction.followUp({
|
||||
content: text,
|
||||
ephemeral,
|
||||
});
|
||||
},
|
||||
editMessage: async ({
|
||||
text,
|
||||
components,
|
||||
}: {
|
||||
text?: string;
|
||||
components?: TopLevelComponents[];
|
||||
}) => {
|
||||
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
||||
throw new Error("Discord interaction cannot update the source message");
|
||||
}
|
||||
responded = true;
|
||||
await params.interaction.update({
|
||||
...(text !== undefined ? { content: text } : {}),
|
||||
...(components !== undefined ? { components } : {}),
|
||||
});
|
||||
},
|
||||
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 } : {}),
|
||||
components: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
const dispatched = await dispatchPluginInteractiveHandler({
|
||||
channel: "discord",
|
||||
data: params.data,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
ctx: {
|
||||
accountId: params.ctx.accountId,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
conversationId: params.interactionCtx.channelId,
|
||||
parentConversationId: params.channelCtx.parentId,
|
||||
guildId: params.interactionCtx.rawGuildId,
|
||||
senderId: params.interactionCtx.userId,
|
||||
senderUsername: params.interactionCtx.username,
|
||||
auth: { isAuthorizedSender: true },
|
||||
interaction: {
|
||||
kind: params.kind,
|
||||
messageId: params.messageId,
|
||||
values: params.values,
|
||||
fields: params.fields,
|
||||
},
|
||||
},
|
||||
respond,
|
||||
});
|
||||
if (!dispatched.matched) {
|
||||
return "unmatched";
|
||||
}
|
||||
if (dispatched.handled && !responded) {
|
||||
try {
|
||||
await respond.acknowledge();
|
||||
} catch {
|
||||
// Interaction may have expired after the handler finished.
|
||||
}
|
||||
}
|
||||
return "handled";
|
||||
}
|
||||
|
||||
function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
@@ -1162,6 +1271,21 @@ async function handleDiscordComponentEvent(params: {
|
||||
}
|
||||
|
||||
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
|
||||
if (consumed.callbackData) {
|
||||
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
data: consumed.callbackData,
|
||||
kind: consumed.kind === "select" ? "select" : "button",
|
||||
values,
|
||||
messageId: consumed.messageId ?? params.interaction.message?.id,
|
||||
});
|
||||
if (pluginDispatch === "handled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const eventText = formatDiscordComponentEventText({
|
||||
kind: consumed.kind === "select" ? "select" : "button",
|
||||
label: consumed.label,
|
||||
@@ -1723,6 +1847,24 @@ class DiscordComponentModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalAllowed = await ensureComponentUserAllowed({
|
||||
entry: {
|
||||
id: modalEntry.id,
|
||||
kind: "button",
|
||||
label: modalEntry.title,
|
||||
allowedUsers: modalEntry.allowedUsers,
|
||||
},
|
||||
interaction,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig),
|
||||
});
|
||||
if (!modalAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumed = resolveDiscordModalEntry({
|
||||
id: modalId,
|
||||
consume: !modalEntry.reusable,
|
||||
@@ -1739,6 +1881,27 @@ class DiscordComponentModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumed.callbackData) {
|
||||
const fields = consumed.fields.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
values: resolveModalFieldValues(field, interaction),
|
||||
}));
|
||||
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
data: consumed.callbackData,
|
||||
kind: "modal",
|
||||
fields,
|
||||
messageId: consumed.messageId,
|
||||
});
|
||||
if (pluginDispatch === "handled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.acknowledge();
|
||||
} catch (err) {
|
||||
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
sendVoiceMessageDiscord,
|
||||
} from "./send.outbound.js";
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
export { sendTypingDiscord } from "./send.typing.js";
|
||||
export {
|
||||
fetchChannelPermissionsDiscord,
|
||||
hasAllGuildPermissionsDiscord,
|
||||
|
||||
9
extensions/discord/src/send.typing.ts
Normal file
9
extensions/discord/src/send.typing.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveDiscordRest } from "./client.js";
|
||||
import type { DiscordReactOpts } from "./send.types.js";
|
||||
|
||||
export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts = {}) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.post(Routes.channelTyping(channelId));
|
||||
return { ok: true, channelId };
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export type {
|
||||
PluginHookInboundClaimContext,
|
||||
PluginHookInboundClaimEvent,
|
||||
PluginHookInboundClaimResult,
|
||||
PluginInteractiveDiscordHandlerContext,
|
||||
PluginInteractiveHandlerRegistration,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
PluginLogger,
|
||||
|
||||
@@ -88,4 +88,62 @@ describe("plugin interactive handlers", () => {
|
||||
error: 'Interactive handler namespace "codex" already registered by plugin "plugin-a"',
|
||||
});
|
||||
});
|
||||
|
||||
it("routes Discord interactions by namespace and dedupes interaction ids", async () => {
|
||||
const handler = vi.fn(async () => ({ handled: true }));
|
||||
expect(
|
||||
registerPluginInteractiveHandler("codex-plugin", {
|
||||
channel: "discord",
|
||||
namespace: "codex",
|
||||
handler,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const baseParams = {
|
||||
channel: "discord" as const,
|
||||
data: "codex:approve:thread-1",
|
||||
interactionId: "ix-1",
|
||||
ctx: {
|
||||
accountId: "default",
|
||||
interactionId: "ix-1",
|
||||
conversationId: "channel-1",
|
||||
parentConversationId: "parent-1",
|
||||
guildId: "guild-1",
|
||||
senderId: "user-1",
|
||||
senderUsername: "ada",
|
||||
auth: { isAuthorizedSender: true },
|
||||
interaction: {
|
||||
kind: "button" as const,
|
||||
messageId: "message-1",
|
||||
values: ["allow"],
|
||||
},
|
||||
},
|
||||
respond: {
|
||||
acknowledge: vi.fn(async () => {}),
|
||||
reply: vi.fn(async () => {}),
|
||||
followUp: vi.fn(async () => {}),
|
||||
editMessage: vi.fn(async () => {}),
|
||||
clearComponents: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
|
||||
const first = await dispatchPluginInteractiveHandler(baseParams);
|
||||
const duplicate = await dispatchPluginInteractiveHandler(baseParams);
|
||||
|
||||
expect(first).toEqual({ matched: true, handled: true, duplicate: false });
|
||||
expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "discord",
|
||||
conversationId: "channel-1",
|
||||
interaction: expect.objectContaining({
|
||||
namespace: "codex",
|
||||
payload: "approve:thread-1",
|
||||
messageId: "message-1",
|
||||
values: ["allow"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createDedupeCache } from "../infra/dedupe.js";
|
||||
import type {
|
||||
PluginInteractiveDiscordHandlerContext,
|
||||
PluginInteractiveButtons,
|
||||
PluginInteractiveDiscordHandlerRegistration,
|
||||
PluginInteractiveHandlerRegistration,
|
||||
PluginInteractiveTelegramHandlerRegistration,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -83,12 +86,21 @@ export function registerPluginInteractiveHandler(
|
||||
error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`,
|
||||
};
|
||||
}
|
||||
interactiveHandlers.set(key, {
|
||||
...registration,
|
||||
namespace,
|
||||
channel: registration.channel,
|
||||
pluginId,
|
||||
});
|
||||
if (registration.channel === "telegram") {
|
||||
interactiveHandlers.set(key, {
|
||||
...registration,
|
||||
namespace,
|
||||
channel: "telegram",
|
||||
pluginId,
|
||||
});
|
||||
} else {
|
||||
interactiveHandlers.set(key, {
|
||||
...registration,
|
||||
namespace,
|
||||
channel: "discord",
|
||||
pluginId,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -123,34 +135,128 @@ export async function dispatchPluginInteractiveHandler(params: {
|
||||
clearButtons: () => Promise<void>;
|
||||
deleteMessage: () => Promise<void>;
|
||||
};
|
||||
}): Promise<InteractiveDispatchResult>;
|
||||
export async function dispatchPluginInteractiveHandler(params: {
|
||||
channel: "discord";
|
||||
data: string;
|
||||
interactionId: string;
|
||||
ctx: Omit<PluginInteractiveDiscordHandlerContext, "interaction" | "respond" | "channel"> & {
|
||||
interaction: Omit<
|
||||
PluginInteractiveDiscordHandlerContext["interaction"],
|
||||
"data" | "namespace" | "payload"
|
||||
>;
|
||||
};
|
||||
respond: PluginInteractiveDiscordHandlerContext["respond"];
|
||||
}): Promise<InteractiveDispatchResult>;
|
||||
export async function dispatchPluginInteractiveHandler(params: {
|
||||
channel: "telegram" | "discord";
|
||||
data: string;
|
||||
callbackId?: string;
|
||||
interactionId?: string;
|
||||
ctx:
|
||||
| (Omit<PluginInteractiveTelegramHandlerContext, "callback" | "respond" | "channel"> & {
|
||||
callbackMessage: {
|
||||
messageId: number;
|
||||
chatId: string;
|
||||
messageText?: string;
|
||||
};
|
||||
})
|
||||
| (Omit<PluginInteractiveDiscordHandlerContext, "interaction" | "respond" | "channel"> & {
|
||||
interaction: Omit<
|
||||
PluginInteractiveDiscordHandlerContext["interaction"],
|
||||
"data" | "namespace" | "payload"
|
||||
>;
|
||||
});
|
||||
respond:
|
||||
| {
|
||||
reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise<void>;
|
||||
editMessage: (params: {
|
||||
text: string;
|
||||
buttons?: PluginInteractiveButtons;
|
||||
}) => Promise<void>;
|
||||
editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise<void>;
|
||||
clearButtons: () => Promise<void>;
|
||||
deleteMessage: () => Promise<void>;
|
||||
}
|
||||
| PluginInteractiveDiscordHandlerContext["respond"];
|
||||
}): Promise<InteractiveDispatchResult> {
|
||||
const match = resolveNamespaceMatch(params.channel, params.data);
|
||||
if (!match) {
|
||||
return { matched: false, handled: false, duplicate: false };
|
||||
}
|
||||
|
||||
if (callbackDedupe.check(params.callbackId)) {
|
||||
const dedupeKey =
|
||||
params.channel === "telegram" ? params.callbackId?.trim() : params.interactionId?.trim();
|
||||
if (dedupeKey && callbackDedupe.check(dedupeKey)) {
|
||||
return { matched: true, handled: true, duplicate: true };
|
||||
}
|
||||
|
||||
const { callbackMessage, ...handlerContext } = params.ctx;
|
||||
const result = await match.registration.handler({
|
||||
...handlerContext,
|
||||
channel: "telegram",
|
||||
callback: {
|
||||
data: params.data,
|
||||
namespace: match.namespace,
|
||||
payload: match.payload,
|
||||
messageId: callbackMessage.messageId,
|
||||
chatId: callbackMessage.chatId,
|
||||
messageText: callbackMessage.messageText,
|
||||
},
|
||||
respond: params.respond,
|
||||
});
|
||||
let result:
|
||||
| ReturnType<PluginInteractiveTelegramHandlerRegistration["handler"]>
|
||||
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>;
|
||||
if (params.channel === "telegram") {
|
||||
const { callbackMessage, ...handlerContext } = params.ctx as Omit<
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
"callback" | "respond" | "channel"
|
||||
> & {
|
||||
callbackMessage: {
|
||||
messageId: number;
|
||||
chatId: string;
|
||||
messageText?: string;
|
||||
};
|
||||
};
|
||||
result = (
|
||||
match.registration as RegisteredInteractiveHandler &
|
||||
PluginInteractiveTelegramHandlerRegistration
|
||||
).handler({
|
||||
...handlerContext,
|
||||
channel: "telegram",
|
||||
callback: {
|
||||
data: params.data,
|
||||
namespace: match.namespace,
|
||||
payload: match.payload,
|
||||
messageId: callbackMessage.messageId,
|
||||
chatId: callbackMessage.chatId,
|
||||
messageText: callbackMessage.messageText,
|
||||
},
|
||||
respond: params.respond as PluginInteractiveTelegramHandlerContext["respond"],
|
||||
});
|
||||
} else {
|
||||
result = (
|
||||
match.registration as RegisteredInteractiveHandler &
|
||||
PluginInteractiveDiscordHandlerRegistration
|
||||
).handler({
|
||||
...(params.ctx as Omit<
|
||||
PluginInteractiveDiscordHandlerContext,
|
||||
"interaction" | "respond" | "channel"
|
||||
> & {
|
||||
interaction: Omit<
|
||||
PluginInteractiveDiscordHandlerContext["interaction"],
|
||||
"data" | "namespace" | "payload"
|
||||
>;
|
||||
}),
|
||||
channel: "discord",
|
||||
interaction: {
|
||||
...(
|
||||
params.ctx as {
|
||||
interaction: Omit<
|
||||
PluginInteractiveDiscordHandlerContext["interaction"],
|
||||
"data" | "namespace" | "payload"
|
||||
>;
|
||||
}
|
||||
).interaction,
|
||||
data: params.data,
|
||||
namespace: match.namespace,
|
||||
payload: match.payload,
|
||||
},
|
||||
respond: params.respond as PluginInteractiveDiscordHandlerContext["respond"],
|
||||
});
|
||||
}
|
||||
const resolved = await result;
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
handled: result?.handled ?? true,
|
||||
handled: resolved?.handled ?? true,
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,18 @@ import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.
|
||||
import { probeDiscord } from "../../../extensions/discord/src/probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js";
|
||||
import { sendMessageDiscord, sendPollDiscord } from "../../../extensions/discord/src/send.js";
|
||||
import {
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editChannelDiscord,
|
||||
editMessageDiscord,
|
||||
pinMessageDiscord,
|
||||
sendDiscordComponentMessage,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendTypingDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "../../../extensions/discord/src/send.js";
|
||||
import { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js";
|
||||
import { probeIMessage } from "../../../extensions/imessage/src/probe.js";
|
||||
import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js";
|
||||
@@ -114,6 +125,8 @@ import {
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { createDiscordTypingLease } from "./runtime-discord-typing.js";
|
||||
import { createTelegramTypingLease } from "./runtime-telegram-typing.js";
|
||||
import { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
|
||||
import type { PluginRuntime } from "./types.js";
|
||||
|
||||
@@ -209,9 +222,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
||||
probeDiscord,
|
||||
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
|
||||
resolveUserAllowlist: resolveDiscordUserAllowlist,
|
||||
sendComponentMessage: sendDiscordComponentMessage,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
monitorDiscordProvider,
|
||||
typing: {
|
||||
pulse: sendTypingDiscord,
|
||||
start: async ({ channelId, accountId, cfg, intervalMs }) =>
|
||||
await createDiscordTypingLease({
|
||||
channelId,
|
||||
accountId,
|
||||
cfg,
|
||||
intervalMs,
|
||||
pulse: async ({ channelId, accountId, cfg }) =>
|
||||
void (await sendTypingDiscord(channelId, {
|
||||
accountId,
|
||||
cfg,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
conversationActions: {
|
||||
editMessage: editMessageDiscord,
|
||||
deleteMessage: deleteMessageDiscord,
|
||||
pinMessage: pinMessageDiscord,
|
||||
unpinMessage: unpinMessageDiscord,
|
||||
createThread: createThreadDiscord,
|
||||
editChannel: editChannelDiscord,
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
|
||||
|
||||
38
src/plugins/runtime/runtime-discord-typing.test.ts
Normal file
38
src/plugins/runtime/runtime-discord-typing.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDiscordTypingLease } from "./runtime-discord-typing.js";
|
||||
|
||||
describe("createDiscordTypingLease", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pulses immediately and keeps leases independent", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pulse = vi.fn(async () => undefined);
|
||||
|
||||
const leaseA = await createDiscordTypingLease({
|
||||
channelId: "123",
|
||||
intervalMs: 2_000,
|
||||
pulse,
|
||||
});
|
||||
const leaseB = await createDiscordTypingLease({
|
||||
channelId: "123",
|
||||
intervalMs: 2_000,
|
||||
pulse,
|
||||
});
|
||||
|
||||
expect(pulse).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(pulse).toHaveBeenCalledTimes(4);
|
||||
|
||||
leaseA.stop();
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(pulse).toHaveBeenCalledTimes(5);
|
||||
|
||||
await leaseB.refresh();
|
||||
expect(pulse).toHaveBeenCalledTimes(6);
|
||||
|
||||
leaseB.stop();
|
||||
});
|
||||
});
|
||||
57
src/plugins/runtime/runtime-discord-typing.ts
Normal file
57
src/plugins/runtime/runtime-discord-typing.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type CreateDiscordTypingLeaseParams = {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
intervalMs?: number;
|
||||
pulse: (params: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000;
|
||||
|
||||
export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{
|
||||
refresh: () => Promise<void>;
|
||||
stop: () => void;
|
||||
}> {
|
||||
const intervalMs =
|
||||
typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs)
|
||||
? Math.max(1_000, Math.floor(params.intervalMs))
|
||||
: DEFAULT_DISCORD_TYPING_INTERVAL_MS;
|
||||
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const pulse = async () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
await params.pulse({
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
};
|
||||
|
||||
await pulse();
|
||||
|
||||
timer = setInterval(() => {
|
||||
void pulse();
|
||||
}, intervalMs);
|
||||
timer.unref?.();
|
||||
|
||||
return {
|
||||
refresh: async () => {
|
||||
await pulse();
|
||||
},
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -97,9 +97,30 @@ export type PluginRuntimeChannel = {
|
||||
probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord;
|
||||
resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist;
|
||||
resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist;
|
||||
sendComponentMessage: typeof import("../../../extensions/discord/src/send.js").sendDiscordComponentMessage;
|
||||
sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord;
|
||||
sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord;
|
||||
monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider;
|
||||
typing: {
|
||||
pulse: typeof import("../../../extensions/discord/src/send.js").sendTypingDiscord;
|
||||
start: (params: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
intervalMs?: number;
|
||||
}) => Promise<{
|
||||
refresh: () => Promise<void>;
|
||||
stop: () => void;
|
||||
}>;
|
||||
};
|
||||
conversationActions: {
|
||||
editMessage: typeof import("../../../extensions/discord/src/send.js").editMessageDiscord;
|
||||
deleteMessage: typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord;
|
||||
pinMessage: typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord;
|
||||
unpinMessage: typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord;
|
||||
createThread: typeof import("../../../extensions/discord/src/send.js").createThreadDiscord;
|
||||
editChannel: typeof import("../../../extensions/discord/src/send.js").editChannelDiscord;
|
||||
};
|
||||
};
|
||||
slack: {
|
||||
listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { TopLevelComponents } from "@buape/carbon";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { Command } from "commander";
|
||||
import type {
|
||||
@@ -305,7 +306,7 @@ export type OpenClawPluginCommandDefinition = {
|
||||
handler: PluginCommandHandler;
|
||||
};
|
||||
|
||||
export type PluginInteractiveChannel = "telegram";
|
||||
export type PluginInteractiveChannel = "telegram" | "discord";
|
||||
|
||||
export type PluginInteractiveButtons = Array<
|
||||
Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
|
||||
@@ -346,14 +347,60 @@ export type PluginInteractiveTelegramHandlerContext = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginInteractiveHandlerRegistration = {
|
||||
channel: PluginInteractiveChannel;
|
||||
export type PluginInteractiveDiscordHandlerResult = {
|
||||
handled?: boolean;
|
||||
} | void;
|
||||
|
||||
export type PluginInteractiveDiscordHandlerContext = {
|
||||
channel: "discord";
|
||||
accountId: string;
|
||||
interactionId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
guildId?: string;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
auth: {
|
||||
isAuthorizedSender: boolean;
|
||||
};
|
||||
interaction: {
|
||||
kind: "button" | "select" | "modal";
|
||||
data: string;
|
||||
namespace: string;
|
||||
payload: string;
|
||||
messageId?: string;
|
||||
values?: string[];
|
||||
fields?: Array<{ id: string; name: string; values: string[] }>;
|
||||
};
|
||||
respond: {
|
||||
acknowledge: () => Promise<void>;
|
||||
reply: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
|
||||
followUp: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
|
||||
editMessage: (params: { text?: string; components?: TopLevelComponents[] }) => Promise<void>;
|
||||
clearComponents: (params?: { text?: string }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginInteractiveTelegramHandlerRegistration = {
|
||||
channel: "telegram";
|
||||
namespace: string;
|
||||
handler: (
|
||||
ctx: PluginInteractiveTelegramHandlerContext,
|
||||
) => Promise<PluginInteractiveTelegramHandlerResult> | PluginInteractiveTelegramHandlerResult;
|
||||
};
|
||||
|
||||
export type PluginInteractiveDiscordHandlerRegistration = {
|
||||
channel: "discord";
|
||||
namespace: string;
|
||||
handler: (
|
||||
ctx: PluginInteractiveDiscordHandlerContext,
|
||||
) => Promise<PluginInteractiveDiscordHandlerResult> | PluginInteractiveDiscordHandlerResult;
|
||||
};
|
||||
|
||||
export type PluginInteractiveHandlerRegistration =
|
||||
| PluginInteractiveTelegramHandlerRegistration
|
||||
| PluginInteractiveDiscordHandlerRegistration;
|
||||
|
||||
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
|
||||
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user