From 2eeb0d10dfcd599f8e9df16705f01226333b58ff Mon Sep 17 00:00:00 2001 From: huntharo Date: Thu, 12 Mar 2026 09:32:18 -0400 Subject: [PATCH] Plugins: add Discord interaction surface --- extensions/discord/src/components.test.ts | 9 +- extensions/discord/src/components.ts | 27 +++ .../discord/src/monitor/agent-components.ts | 163 ++++++++++++++++++ extensions/discord/src/send.ts | 1 + extensions/discord/src/send.typing.ts | 9 + src/plugin-sdk/index.ts | 1 + src/plugins/interactive.test.ts | 58 +++++++ src/plugins/interactive.ts | 150 +++++++++++++--- src/plugins/runtime/runtime-channel.ts | 39 ++++- .../runtime/runtime-discord-typing.test.ts | 38 ++++ src/plugins/runtime/runtime-discord-typing.ts | 57 ++++++ src/plugins/runtime/types-channel.ts | 21 +++ src/plugins/types.ts | 53 +++++- 13 files changed, 599 insertions(+), 27 deletions(-) create mode 100644 extensions/discord/src/send.typing.ts create mode 100644 src/plugins/runtime/runtime-discord-typing.test.ts create mode 100644 src/plugins/runtime/runtime-discord-typing.ts diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 9a49af7b469..44350b4fc4b 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -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", () => { diff --git a/extensions/discord/src/components.ts b/extensions/discord/src/components.ts index 2052c5baf69..272da58170a 100644 --- a/extensions/discord/src/components.ts +++ b/extensions/discord/src/components.ts @@ -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({ diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index e954c372bb1..5c953352ce1 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -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) { diff --git a/extensions/discord/src/send.ts b/extensions/discord/src/send.ts index e0620977631..ec710d79b19 100644 --- a/extensions/discord/src/send.ts +++ b/extensions/discord/src/send.ts @@ -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, diff --git a/extensions/discord/src/send.typing.ts b/extensions/discord/src/send.typing.ts new file mode 100644 index 00000000000..cf1db7fa484 --- /dev/null +++ b/extensions/discord/src/send.typing.ts @@ -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 }; +} diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 4c0cf3c6635..1725e968adc 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -103,6 +103,7 @@ export type { PluginHookInboundClaimContext, PluginHookInboundClaimEvent, PluginHookInboundClaimResult, + PluginInteractiveDiscordHandlerContext, PluginInteractiveHandlerRegistration, PluginInteractiveTelegramHandlerContext, PluginLogger, diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index acd225134c9..2c67652acc2 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -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"], + }), + }), + ); + }); }); diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 3fe7f8f1ef5..12dfe5ee9fe 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -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; deleteMessage: () => Promise; }; +}): Promise; +export async function dispatchPluginInteractiveHandler(params: { + channel: "discord"; + data: string; + interactionId: string; + ctx: Omit & { + interaction: Omit< + PluginInteractiveDiscordHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; + }; + respond: PluginInteractiveDiscordHandlerContext["respond"]; +}): Promise; +export async function dispatchPluginInteractiveHandler(params: { + channel: "telegram" | "discord"; + data: string; + callbackId?: string; + interactionId?: string; + ctx: + | (Omit & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; + }) + | (Omit & { + interaction: Omit< + PluginInteractiveDiscordHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; + }); + respond: + | { + reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editMessage: (params: { + text: string; + buttons?: PluginInteractiveButtons; + }) => Promise; + editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + } + | PluginInteractiveDiscordHandlerContext["respond"]; }): Promise { 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 + | ReturnType; + 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, }; } diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index dd1e16e3302..06244f4d46d 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -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, diff --git a/src/plugins/runtime/runtime-discord-typing.test.ts b/src/plugins/runtime/runtime-discord-typing.test.ts new file mode 100644 index 00000000000..a323ce284bc --- /dev/null +++ b/src/plugins/runtime/runtime-discord-typing.test.ts @@ -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(); + }); +}); diff --git a/src/plugins/runtime/runtime-discord-typing.ts b/src/plugins/runtime/runtime-discord-typing.ts new file mode 100644 index 00000000000..17b782ff2b1 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-typing.ts @@ -0,0 +1,57 @@ +export type CreateDiscordTypingLeaseParams = { + channelId: string; + accountId?: string; + cfg?: ReturnType; + intervalMs?: number; + pulse: (params: { + channelId: string; + accountId?: string; + cfg?: ReturnType; + }) => Promise; +}; + +const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000; + +export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{ + refresh: () => Promise; + 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 | 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; + } + }, + }; +} diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index bc4add449a4..2d2379a6f5c 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -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; + intervalMs?: number; + }) => Promise<{ + refresh: () => Promise; + 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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1fde7d8846f..faf6be44e91 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; + reply: (params: { text: string; ephemeral?: boolean }) => Promise; + followUp: (params: { text: string; ephemeral?: boolean }) => Promise; + editMessage: (params: { text?: string; components?: TopLevelComponents[] }) => Promise; + clearComponents: (params?: { text?: string }) => Promise; + }; +}; + +export type PluginInteractiveTelegramHandlerRegistration = { + channel: "telegram"; namespace: string; handler: ( ctx: PluginInteractiveTelegramHandlerContext, ) => Promise | PluginInteractiveTelegramHandlerResult; }; +export type PluginInteractiveDiscordHandlerRegistration = { + channel: "discord"; + namespace: string; + handler: ( + ctx: PluginInteractiveDiscordHandlerContext, + ) => Promise | PluginInteractiveDiscordHandlerResult; +}; + +export type PluginInteractiveHandlerRegistration = + | PluginInteractiveTelegramHandlerRegistration + | PluginInteractiveDiscordHandlerRegistration; + export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin"; export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";