From aa1454d1a80c35417bc047e78c8cd85ecfecb33c Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Sun, 15 Mar 2026 19:06:11 -0400 Subject: [PATCH] Plugins: broaden plugin surface for Codex App Server (#45318) * Plugins: add inbound claim and Telegram interaction seams * Plugins: add Discord interaction surface * Chore: fix formatting after plugin rebase * fix(hooks): preserve observers after inbound claim * test(hooks): cover claimed inbound observer delivery * fix(plugins): harden typing lease refreshes * fix(discord): pass real auth to plugin interactions * fix(plugins): remove raw session binding runtime exposure * fix(plugins): tighten interactive callback handling * Plugins: gate conversation binding with approvals * Plugins: migrate legacy plugin binding records * Plugins/phone-control: update test command context * Plugins: migrate legacy binding ids * Plugins: migrate legacy codex session bindings * Discord: fix plugin interaction handling * Discord: support direct plugin conversation binds * Plugins: preserve Discord command bind targets * Tests: fix plugin binding and interactive fallout * Discord: stabilize directory lookup tests * Discord: route bound DMs to plugins * Discord: restore plugin bindings after restart * Telegram: persist detached plugin bindings * Plugins: limit binding APIs to Telegram and Discord * Plugins: harden bound conversation routing * Plugins: fix extension target imports * Plugins: fix Telegram runtime extension imports * Plugins: format rebased binding handlers * Discord: bind group DM interactions by channel --------- Co-authored-by: Vincent Koc --- extensions/discord/src/components.test.ts | 9 +- extensions/discord/src/components.ts | 27 + extensions/discord/src/directory-live.test.ts | 91 +- .../discord/src/monitor/agent-components.ts | 258 +++++- .../monitor/message-handler.preflight.test.ts | 87 ++ .../src/monitor/message-handler.preflight.ts | 10 +- .../discord/src/monitor/monitor.test.ts | 252 ++++++ .../discord/src/monitor/native-command.ts | 38 +- .../monitor/thread-bindings.discord-api.ts | 2 +- .../monitor/thread-bindings.lifecycle.test.ts | 93 ++ .../src/monitor/thread-bindings.lifecycle.ts | 7 +- .../src/monitor/thread-bindings.manager.ts | 24 +- .../src/monitor/thread-bindings.state.ts | 3 + .../src/monitor/thread-bindings.types.ts | 2 + extensions/discord/src/send.ts | 1 + extensions/discord/src/send.typing.ts | 9 + extensions/discord/src/targets.test.ts | 17 +- extensions/lobster/src/lobster-tool.test.ts | 1 + extensions/phone-control/index.test.ts | 6 + extensions/telegram/src/bot-handlers.ts | 88 ++ extensions/telegram/src/bot.test.ts | 63 +- extensions/telegram/src/conversation-route.ts | 25 +- extensions/telegram/src/send.test-harness.ts | 4 + extensions/telegram/src/send.test.ts | 42 + extensions/telegram/src/send.ts | 103 +++ .../telegram/src/thread-bindings.test.ts | 36 + extensions/telegram/src/thread-bindings.ts | 17 +- extensions/test-utils/plugin-api.ts | 1 + .../reply/dispatch-from-config.test.ts | 507 ++++++++++- src/auto-reply/reply/dispatch-from-config.ts | 187 +++- src/hooks/message-hook-mappers.test.ts | 48 + src/hooks/message-hook-mappers.ts | 132 +++ src/plugin-sdk/index.ts | 14 + src/plugins/commands.test.ts | 104 +++ src/plugins/commands.ts | 125 ++- src/plugins/conversation-binding.test.ts | 575 ++++++++++++ src/plugins/conversation-binding.ts | 825 ++++++++++++++++++ src/plugins/hooks.test-helpers.ts | 21 + src/plugins/hooks.ts | 200 +++++ src/plugins/interactive.test.ts | 201 +++++ src/plugins/interactive.ts | 366 ++++++++ src/plugins/loader.ts | 6 + src/plugins/registry.ts | 47 +- src/plugins/runtime/runtime-channel.ts | 78 +- .../runtime/runtime-discord-typing.test.ts | 57 ++ src/plugins/runtime/runtime-discord-typing.ts | 62 ++ .../runtime/runtime-telegram-typing.test.ts | 83 ++ .../runtime/runtime-telegram-typing.ts | 60 ++ src/plugins/runtime/types-channel.ts | 54 ++ src/plugins/services.test.ts | 11 +- src/plugins/services.ts | 6 +- src/plugins/types.ts | 185 ++++ src/plugins/wired-hooks-inbound-claim.test.ts | 175 ++++ 53 files changed, 5322 insertions(+), 123 deletions(-) create mode 100644 extensions/discord/src/send.typing.ts create mode 100644 src/plugins/conversation-binding.test.ts create mode 100644 src/plugins/conversation-binding.ts create mode 100644 src/plugins/interactive.test.ts create mode 100644 src/plugins/interactive.ts create mode 100644 src/plugins/runtime/runtime-discord-typing.test.ts create mode 100644 src/plugins/runtime/runtime-discord-typing.ts create mode 100644 src/plugins/runtime/runtime-telegram-typing.test.ts create mode 100644 src/plugins/runtime/runtime-telegram-typing.ts create mode 100644 src/plugins/wired-hooks-inbound-claim.test.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/directory-live.test.ts b/extensions/discord/src/directory-live.test.ts index 8ba3bc52c4a..afc0fd94170 100644 --- a/extensions/discord/src/directory-live.test.ts +++ b/extensions/discord/src/directory-live.test.ts @@ -1,74 +1,72 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; - -const mocks = vi.hoisted(() => ({ - fetchDiscord: vi.fn(), - normalizeDiscordToken: vi.fn((token: string) => token.trim()), - resolveDiscordAccount: vi.fn(), -})); - -vi.mock("./accounts.js", () => ({ - resolveDiscordAccount: mocks.resolveDiscordAccount, -})); - -vi.mock("./api.js", () => ({ - fetchDiscord: mocks.fetchDiscord, -})); - -vi.mock("./token.js", () => ({ - normalizeDiscordToken: mocks.normalizeDiscordToken, -})); - +import type { OpenClawConfig } from "../../../src/config/config.js"; import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js"; function makeParams(overrides: Partial = {}): DirectoryConfigParams { return { - cfg: {} as DirectoryConfigParams["cfg"], + cfg: { + channels: { + discord: { + token: "test-token", + }, + }, + } as OpenClawConfig, + accountId: "default", ...overrides, }; } +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + describe("discord directory live lookups", () => { beforeEach(() => { - vi.clearAllMocks(); - mocks.resolveDiscordAccount.mockReturnValue({ token: "test-token" }); - mocks.normalizeDiscordToken.mockImplementation((token: string) => token.trim()); + vi.restoreAllMocks(); }); it("returns empty group directory when token is missing", async () => { - mocks.normalizeDiscordToken.mockReturnValue(""); - - const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "general" })); + const rows = await listDiscordDirectoryGroupsLive({ + ...makeParams(), + cfg: { channels: { discord: { token: "" } } } as OpenClawConfig, + query: "general", + }); expect(rows).toEqual([]); - expect(mocks.fetchDiscord).not.toHaveBeenCalled(); }); it("returns empty peer directory without query and skips guild listing", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " })); expect(rows).toEqual([]); - expect(mocks.fetchDiscord).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("filters group channels by query and respects limit", async () => { - mocks.fetchDiscord.mockImplementation(async (path: string) => { - if (path === "/users/@me/guilds") { - return [ + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([ { id: "g1", name: "Guild 1" }, { id: "g2", name: "Guild 2" }, - ]; + ]); } - if (path === "/guilds/g1/channels") { - return [ + if (url.endsWith("/guilds/g1/channels")) { + return jsonResponse([ { id: "c1", name: "general" }, { id: "c2", name: "random" }, - ]; + ]); } - if (path === "/guilds/g2/channels") { - return [{ id: "c3", name: "announcements" }]; + if (url.endsWith("/guilds/g2/channels")) { + return jsonResponse([{ id: "c3", name: "announcements" }]); } - return []; + return jsonResponse([]); }); const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "an", limit: 2 })); @@ -80,21 +78,22 @@ describe("discord directory live lookups", () => { }); it("returns ranked peer results and caps member search by limit", async () => { - mocks.fetchDiscord.mockImplementation(async (path: string) => { - if (path === "/users/@me/guilds") { - return [{ id: "g1", name: "Guild 1" }]; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "g1", name: "Guild 1" }]); } - if (path.startsWith("/guilds/g1/members/search?")) { - const params = new URLSearchParams(path.split("?")[1] ?? ""); + if (url.includes("/guilds/g1/members/search?")) { + const params = new URL(url).searchParams; expect(params.get("query")).toBe("alice"); expect(params.get("limit")).toBe("2"); - return [ + return jsonResponse([ { user: { id: "u1", username: "alice", bot: false }, nick: "Ali" }, { user: { id: "u2", username: "alice-bot", bot: true }, nick: null }, { user: { id: "u3", username: "ignored", bot: false }, nick: null }, - ]; + ]); } - return []; + return jsonResponse([]); }); const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 })); diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index e954c372bb1..e28bd17b70e 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,12 @@ 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 { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "../../../../src/plugins/conversation-binding.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 +778,159 @@ 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; + isAuthorizedSender: boolean; + data: string; + kind: "button" | "select" | "modal"; + values?: string[]; + fields?: Array<{ id: string; name: string; values: string[] }>; + messageId?: string; +}): Promise<"handled" | "unmatched"> { + const normalizedConversationId = + params.interactionCtx.rawGuildId || params.channelCtx.channelType === ChannelType.GroupDM + ? `channel:${params.interactionCtx.channelId}` + : `user:${params.interactionCtx.userId}`; + 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 pluginBindingApproval = parsePluginBindingApprovalCustomId(params.data); + if (pluginBindingApproval) { + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: params.interactionCtx.userId, + }); + let cleared = false; + try { + await respond.clearComponents(); + cleared = true; + } catch { + try { + await respond.acknowledge(); + } catch { + // Interaction may already be acknowledged; continue with best-effort follow-up. + } + } + 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. + } + } + } + return "handled"; + } + const dispatched = await dispatchPluginInteractiveHandler({ + channel: "discord", + data: params.data, + interactionId: resolveDiscordInteractionId(params.interaction), + ctx: { + accountId: params.ctx.accountId, + interactionId: resolveDiscordInteractionId(params.interaction), + conversationId: normalizedConversationId, + parentConversationId: params.channelCtx.parentId, + guildId: params.interactionCtx.rawGuildId, + senderId: params.interactionCtx.userId, + senderUsername: params.interactionCtx.username, + auth: { isAuthorizedSender: params.isAuthorizedSender }, + interaction: { + kind: params.kind, + messageId: params.messageId, + values: params.values, + fields: params.fields, + }, + }, + respond, + }); + if (!dispatched.matched) { + return "unmatched"; + } + if (dispatched.handled) { + if (!responded) { + try { + await respond.acknowledge(); + } catch { + // Interaction may have expired after the handler finished. + } + } + return "handled"; + } + return "unmatched"; +} + function resolveComponentCommandAuthorized(params: { ctx: AgentComponentContext; interactionCtx: ComponentInteractionContext; @@ -1102,6 +1262,17 @@ async function handleDiscordComponentEvent(params: { guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`; const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction: params.interaction, @@ -1114,7 +1285,7 @@ async function handleDiscordComponentEvent(params: { replyOpts, componentLabel: params.componentLabel, unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + allowNameMatching, }); if (!memberAllowed) { return; @@ -1127,11 +1298,18 @@ async function handleDiscordComponentEvent(params: { replyOpts, componentLabel: params.componentLabel, unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + allowNameMatching, }); if (!componentAllowed) { return; } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: params.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); const consumed = resolveDiscordComponentEntry({ id: parsed.componentId, @@ -1162,6 +1340,22 @@ 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, + isAuthorizedSender: commandAuthorized, + 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, @@ -1706,6 +1900,17 @@ class DiscordComponentModal extends Modal { guildEntries: this.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction, guildInfo, @@ -1717,12 +1922,37 @@ class DiscordComponentModal extends Modal { replyOpts, componentLabel: "form", unauthorizedReply: "You are not authorized to use this form.", - allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig), + allowNameMatching, }); if (!memberAllowed) { 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, + }); + if (!modalAllowed) { + return; + } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: this.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); + const consumed = resolveDiscordModalEntry({ id: modalId, consume: !modalEntry.reusable, @@ -1739,6 +1969,28 @@ 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, + isAuthorizedSender: commandAuthorized, + 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/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index a7a5ff2f6ef..2fb14bafe8e 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -90,6 +90,20 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis } as unknown as DiscordClient; } +function createDmClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.DM, + }; + } + return null; + }, + } as unknown as DiscordClient; +} + async function runThreadBoundPreflight(params: { threadId: string; parentId: string; @@ -157,6 +171,25 @@ async function runGuildPreflight(params: { }); } +async function runDmPreflight(params: { + channelId: string; + message: import("@buape/carbon").Message; + discordConfig: DiscordConfig; +}) { + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: params.discordConfig, + data: { + channel_id: params.channelId, + author: params.message.author, + message: params.message, + } as DiscordMessageEvent, + client: createDmClient(params.channelId), + }), + }); +} + async function runMentionOnlyBotPreflight(params: { channelId: string; guildId: string; @@ -258,6 +291,60 @@ describe("preflightDiscordMessage", () => { expect(result).toBeNull(); }); + it("restores direct-message bindings by user target instead of DM channel id", async () => { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "user:user-1" + ? createThreadBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:user-1", + }, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }) + : null, + }); + + const result = await runDmPreflight({ + channelId: "dm-channel-1", + message: createDiscordMessage({ + id: "m-dm-1", + channelId: "dm-channel-1", + content: "who are you", + author: { + id: "user-1", + bot: false, + username: "alice", + }, + }), + discordConfig: { + allowBots: true, + dmPolicy: "open", + } as DiscordConfig, + }); + + expect(result).not.toBeNull(); + expect(result?.threadBinding).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:user-1", + }, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + }, + }); + }); + it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => { const threadBinding = createThreadBinding({ targetKind: "session", diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index d88b0cd03ec..77640784063 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -29,6 +29,7 @@ import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; import { logDebug } from "../../../../src/logger.js"; import { getChildLogger } from "../../../../src/logging.js"; import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js"; +import { isPluginOwnedSessionBindingRecord } from "../../../../src/plugins/conversation-binding.js"; import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js"; import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { sendMessageDiscord } from "../send.js"; @@ -350,12 +351,13 @@ export async function preflightDiscordMessage( }), parentConversationId: earlyThreadParentId, }); + const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId; let threadBinding: SessionBindingRecord | undefined; threadBinding = getSessionBindingService().resolveByConversation({ channel: "discord", accountId: params.accountId, - conversationId: messageChannelId, + conversationId: bindingConversationId, parentConversationId: earlyThreadParentId, }) ?? undefined; const configuredRoute = @@ -384,7 +386,9 @@ export async function preflightDiscordMessage( logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`); return null; } - const boundSessionKey = threadBinding?.targetSessionKey?.trim(); + const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding) + ? "" + : threadBinding?.targetSessionKey?.trim(); const effectiveRoute = resolveDiscordEffectiveRoute({ route, boundSessionKey, @@ -392,7 +396,7 @@ export async function preflightDiscordMessage( matchedBy: "binding.channel", }); const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined; - const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel); + const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel); if ( isBoundThreadBotSystemMessage({ isBoundThreadSession, diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index b4d5478f921..da916c4bd2b 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -5,10 +5,12 @@ import type { StringSelectMenuInteraction, } from "@buape/carbon"; import type { Client } from "@buape/carbon"; +import { ChannelType } from "discord-api-types/v10"; import type { GatewayPresenceUpdate } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js"; import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js"; import { clearDiscordComponentEntries, @@ -52,6 +54,9 @@ const deliverDiscordReplyMock = vi.hoisted(() => vi.fn()); const recordInboundSessionMock = vi.hoisted(() => vi.fn()); const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn()); const resolveStorePathMock = vi.hoisted(() => vi.fn()); +const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn()); +const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn()); +const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; vi.mock("../../../../src/pairing/pairing-store.js", () => ({ @@ -88,6 +93,27 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { }; }); +vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolvePluginConversationBindingApproval: (...args: unknown[]) => + resolvePluginConversationBindingApprovalMock(...args), + buildPluginBindingResolvedText: (...args: unknown[]) => + buildPluginBindingResolvedTextMock(...args), + }; +}); + +vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchPluginInteractiveHandler: (...args: unknown[]) => + dispatchPluginInteractiveHandlerMock(...args), + }; +}); + describe("agent components", () => { const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; @@ -341,6 +367,38 @@ describe("discord component interactions", () => { recordInboundSessionMock.mockClear().mockResolvedValue(undefined); readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined); resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json"); + dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({ + matched: false, + handled: false, + duplicate: false, + }); + resolvePluginConversationBindingApprovalMock.mockReset().mockResolvedValue({ + status: "approved", + binding: { + bindingId: "binding-1", + pluginId: "openclaw-codex-app-server", + pluginName: "OpenClaw App Server", + pluginRoot: "/plugins/codex", + channel: "discord", + accountId: "default", + conversationId: "user:123456789", + boundAt: Date.now(), + }, + request: { + id: "approval-1", + pluginId: "openclaw-codex-app-server", + pluginName: "OpenClaw App Server", + pluginRoot: "/plugins/codex", + requestedAt: Date.now(), + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:123456789", + }, + }, + decision: "allow-once", + }); + buildPluginBindingResolvedTextMock.mockReset().mockReturnValue("Binding approved."); }); it("routes button clicks with reply references", async () => { @@ -499,6 +557,200 @@ describe("discord component interactions", () => { expect(acknowledge).toHaveBeenCalledTimes(1); expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); }); + + it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["owner-1"], + }), + ); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-plugin-1", + member: { roles: [] }, + } as unknown as ButtonInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + auth: { isAuthorizedSender: false }, + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("passes true auth to plugin Discord interactions for allowlisted guild users", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["123456789"], + }), + ); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-plugin-2", + member: { roles: [] }, + } as unknown as ButtonInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + auth: { isAuthorizedSender: true }, + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("routes plugin Discord interactions in group DMs by channel id instead of sender id", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "group-dm-1", + id: "interaction-group-dm-1", + } as unknown as ButtonInteraction["rawData"], + channel: { + id: "group-dm-1", + type: ChannelType.GroupDM, + } as unknown as ButtonInteraction["channel"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + conversationId: "channel:group-dm-1", + senderId: "123456789", + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("does not fall through to Claw when a plugin Discord interaction already replied", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => { + await params.respond.reply({ text: "✓", ephemeral: true }); + return { + matched: true, + handled: true, + duplicate: false, + }; + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction, reply } = createComponentButtonInteraction(); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true }); + 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" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: false, + duplicate: false, + }); + + const button = createDiscordComponentButton(createComponentContext()); + const { interaction, reply } = createComponentButtonInteraction(); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1); + expect(reply).toHaveBeenCalledWith({ content: "✓" }); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + }); + + it("resolves plugin binding approvals without falling through to Claw", async () => { + registerDiscordComponentEntries({ + entries: [ + createButtonEntry({ + callbackData: buildPluginBindingApprovalCustomId("approval-1", "allow-once"), + }), + ], + modals: [], + }); + const button = createDiscordComponentButton(createComponentContext()); + const update = vi.fn().mockResolvedValue(undefined); + const followUp = vi.fn().mockResolvedValue(undefined); + const interaction = { + ...(createComponentButtonInteraction().interaction as any), + update, + followUp, + } as ButtonInteraction; + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ components: [] }); + expect(followUp).toHaveBeenCalledWith({ + content: "Binding approved.", + ephemeral: true, + }); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); }); describe("resolveDiscordOwnerAllowFrom", () => { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index bc038927d9c..49fe53843f3 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -6,6 +6,7 @@ import { Row, StringSelectMenu, TextDisplay, + type TopLevelComponents, type AutocompleteInteraction, type ButtonInteraction, type CommandInteraction, @@ -274,6 +275,12 @@ function hasRenderableReplyPayload(payload: ReplyPayload): boolean { if (payload.mediaUrls?.some((entry) => entry.trim())) { return true; } + const discordData = payload.channelData?.discord as + | { components?: TopLevelComponents[] } + | undefined; + if (Array.isArray(discordData?.components) && discordData.components.length > 0) { + return true; + } return false; } @@ -1772,13 +1779,25 @@ async function deliverDiscordInteractionReply(params: { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; + const discordData = payload.channelData?.discord as + | { components?: TopLevelComponents[] } + | undefined; + let firstMessageComponents = + Array.isArray(discordData?.components) && discordData.components.length > 0 + ? discordData.components + : undefined; let hasReplied = false; - const sendMessage = async (content: string, files?: { name: string; data: Buffer }[]) => { + const sendMessage = async ( + content: string, + files?: { name: string; data: Buffer }[], + components?: TopLevelComponents[], + ) => { const payload = files && files.length > 0 ? { content, + ...(components ? { components } : {}), files: files.map((file) => { if (file.data instanceof Blob) { return { name: file.name, data: file.data }; @@ -1787,15 +1806,20 @@ async function deliverDiscordInteractionReply(params: { return { name: file.name, data: new Blob([arrayBuffer]) }; }), } - : { content }; + : { + content, + ...(components ? { components } : {}), + }; await safeDiscordInteractionCall("interaction send", async () => { if (!preferFollowUp && !hasReplied) { await interaction.reply(payload); hasReplied = true; + firstMessageComponents = undefined; return; } await interaction.followUp(payload); hasReplied = true; + firstMessageComponents = undefined; }); }; @@ -1820,7 +1844,7 @@ async function deliverDiscordInteractionReply(params: { chunks.push(text); } const caption = chunks[0] ?? ""; - await sendMessage(caption, media); + await sendMessage(caption, media, firstMessageComponents); for (const chunk of chunks.slice(1)) { if (!chunk.trim()) { continue; @@ -1830,7 +1854,7 @@ async function deliverDiscordInteractionReply(params: { return; } - if (!text.trim()) { + if (!text.trim() && !firstMessageComponents) { return; } const chunks = chunkDiscordTextWithMode(text, { @@ -1838,13 +1862,13 @@ async function deliverDiscordInteractionReply(params: { maxLines: maxLinesPerMessage, chunkMode, }); - if (!chunks.length && text) { + if (!chunks.length && (text || firstMessageComponents)) { chunks.push(text); } for (const chunk of chunks) { - if (!chunk.trim()) { + if (!chunk.trim() && !firstMessageComponents) { continue; } - await sendMessage(chunk); + await sendMessage(chunk, undefined, firstMessageComponents); } } diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 38360b27728..134eda0f109 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -17,7 +17,7 @@ import { } from "./thread-bindings.types.js"; function buildThreadTarget(threadId: string): string { - return `channel:${threadId}`; + return /^(channel:|user:)/i.test(threadId) ? threadId : `channel:${threadId}`; } export function isThreadArchived(raw: unknown): boolean { diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 013952e7c71..ed221645fcf 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -7,6 +7,7 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, } from "../../../../src/config/config.js"; +import { getSessionBindingService } from "../../../../src/infra/outbound/session-binding-service.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -788,6 +789,57 @@ describe("thread binding lifecycle", () => { expect(usedTokenNew).toBe(true); }); + it("binds current Discord DMs as direct conversation bindings", async () => { + createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + hoisted.restGet.mockClear(); + hoisted.restPost.mockClear(); + + const bound = await getSessionBindingService().bind({ + targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + placement: "current", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }); + + expect(bound).toMatchObject({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + parentConversationId: "user:1177378744822943744", + }, + }); + expect( + getSessionBindingService().resolveByConversation({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }), + ).toMatchObject({ + conversation: { + conversationId: "user:1177378744822943744", + }, + }); + expect(hoisted.restGet).not.toHaveBeenCalled(); + expect(hoisted.restPost).not.toHaveBeenCalled(); + }); + it("keeps overlapping thread ids isolated per account", async () => { const a = createThreadBindingManager({ accountId: "a", @@ -948,6 +1000,47 @@ describe("thread binding lifecycle", () => { expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined(); }); + it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => { + const manager = createThreadBindingManager({ + accountId: "default", + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + await manager.bindTarget({ + threadId: "user:1177378744822943744", + channelId: "user:1177378744822943744", + targetKind: "acp", + targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm", + agentId: "codex", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + }); + + hoisted.readAcpSessionEntry.mockReturnValue(null); + + const result = await reconcileAcpThreadBindingsOnStartup({ + cfg: {} as OpenClawConfig, + accountId: "default", + }); + + expect(result.checked).toBe(0); + expect(result.removed).toBe(0); + expect(result.staleSessionKeys).toEqual([]); + expect(manager.getByThreadId("user:1177378744822943744")).toMatchObject({ + threadId: "user:1177378744822943744", + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + }, + }); + }); + it("removes ACP bindings when health probe marks running session as stale", async () => { const manager = createThreadBindingManager({ accountId: "default", diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts index d7389d68439..d7d96857250 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts @@ -323,7 +323,12 @@ export async function reconcileAcpThreadBindingsOnStartup(params: { }; } - const acpBindings = manager.listBindings().filter((binding) => binding.targetKind === "acp"); + const acpBindings = manager + .listBindings() + .filter( + (binding) => + binding.targetKind === "acp" && binding.metadata?.pluginBindingOwner !== "plugin", + ); const staleBindings: ThreadBindingRecord[] = []; const probeTargets: Array<{ binding: ThreadBindingRecord; diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts index 6595f053ea9..efa599cadc2 100644 --- a/extensions/discord/src/monitor/thread-bindings.manager.ts +++ b/extensions/discord/src/monitor/thread-bindings.manager.ts @@ -117,6 +117,11 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" { return raw === "subagent" ? "subagent" : "acp"; } +function isDirectConversationBindingId(value?: string | null): boolean { + const trimmed = value?.trim(); + return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed)); +} + function toSessionBindingRecord( record: ThreadBindingRecord, defaults: { idleTimeoutMs: number; maxAgeMs: number }, @@ -158,6 +163,7 @@ function toSessionBindingRecord( record, defaultMaxAgeMs: defaults.maxAgeMs, }), + ...record.metadata, }, }; } @@ -264,6 +270,8 @@ export function createThreadBindingManager( const cfg = resolveCurrentCfg(); let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; + const directConversationBinding = + isDirectConversationBindingId(threadId) || isDirectConversationBindingId(channelId); if (!threadId && bindParams.createThread) { if (!channelId) { @@ -287,6 +295,10 @@ export function createThreadBindingManager( return null; } + if (!channelId && directConversationBinding) { + channelId = threadId; + } + if (!channelId) { channelId = (await resolveChannelIdForBinding({ @@ -309,12 +321,12 @@ export function createThreadBindingManager( const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey); let webhookId = bindParams.webhookId?.trim() || ""; let webhookToken = bindParams.webhookToken?.trim() || ""; - if (!webhookId || !webhookToken) { + if (!directConversationBinding && (!webhookId || !webhookToken)) { const cachedWebhook = findReusableWebhook({ accountId, channelId }); webhookId = cachedWebhook.webhookId ?? ""; webhookToken = cachedWebhook.webhookToken ?? ""; } - if (!webhookId || !webhookToken) { + if (!directConversationBinding && (!webhookId || !webhookToken)) { const createdWebhook = await createWebhookForChannel({ cfg, accountId, @@ -341,6 +353,10 @@ export function createThreadBindingManager( lastActivityAt: now, idleTimeoutMs, maxAgeMs, + metadata: + bindParams.metadata && typeof bindParams.metadata === "object" + ? { ...bindParams.metadata } + : undefined, }; setBindingRecord(record); @@ -508,6 +524,9 @@ export function createThreadBindingManager( }); continue; } + if (isDirectConversationBindingId(binding.threadId)) { + continue; + } try { const channel = await rest.get(Routes.channel(binding.threadId)); if (!channel || typeof channel !== "object") { @@ -604,6 +623,7 @@ export function createThreadBindingManager( label, boundBy, introText, + metadata, }); return bound ? toSessionBindingRecord(bound, { diff --git a/extensions/discord/src/monitor/thread-bindings.state.ts b/extensions/discord/src/monitor/thread-bindings.state.ts index 892d7a46293..cfcbc65f3f5 100644 --- a/extensions/discord/src/monitor/thread-bindings.state.ts +++ b/extensions/discord/src/monitor/thread-bindings.state.ts @@ -183,6 +183,8 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs) ? Math.max(0, Math.floor(value.maxAgeMs)) : undefined; + const metadata = + value.metadata && typeof value.metadata === "object" ? { ...value.metadata } : undefined; const legacyExpiresAt = typeof (value as { expiresAt?: unknown }).expiresAt === "number" && Number.isFinite((value as { expiresAt?: unknown }).expiresAt) @@ -222,6 +224,7 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin lastActivityAt, idleTimeoutMs: migratedIdleTimeoutMs, maxAgeMs: migratedMaxAgeMs, + metadata, }; } diff --git a/extensions/discord/src/monitor/thread-bindings.types.ts b/extensions/discord/src/monitor/thread-bindings.types.ts index 228c81c58cc..2403958e385 100644 --- a/extensions/discord/src/monitor/thread-bindings.types.ts +++ b/extensions/discord/src/monitor/thread-bindings.types.ts @@ -17,6 +17,7 @@ export type ThreadBindingRecord = { idleTimeoutMs?: number; /** Hard max-age window in milliseconds from bind time (0 disables hard cap). */ maxAgeMs?: number; + metadata?: Record; }; export type PersistedThreadBindingRecord = ThreadBindingRecord & { @@ -56,6 +57,7 @@ export type ThreadBindingManager = { introText?: string; webhookId?: string; webhookToken?: string; + metadata?: Record; }) => Promise; unbindThread: (params: { threadId: string; 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/extensions/discord/src/targets.test.ts b/extensions/discord/src/targets.test.ts index 527e0164ba8..fa8b739b3b5 100644 --- a/extensions/discord/src/targets.test.ts +++ b/extensions/discord/src/targets.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { listDiscordDirectoryPeersLive } from "./directory-live.js"; +import * as directoryLive from "./directory-live.js"; import { normalizeDiscordMessagingTarget } from "./normalize.js"; import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js"; -vi.mock("./directory-live.js", () => ({ - listDiscordDirectoryPeersLive: vi.fn(), -})); - describe("parseDiscordTarget", () => { it("parses user mention and prefixes", () => { const cases = [ @@ -73,14 +69,15 @@ describe("resolveDiscordChannelId", () => { describe("resolveDiscordTarget", () => { const cfg = { channels: { discord: {} } } as OpenClawConfig; - const listPeers = vi.mocked(listDiscordDirectoryPeersLive); beforeEach(() => { - listPeers.mockClear(); + vi.restoreAllMocks(); }); it("returns a resolved user for usernames", async () => { - listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]); + vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([ + { kind: "user", id: "user:999", name: "Jane" } as const, + ]); await expect( resolveDiscordTarget("jane", { cfg, accountId: "default" }), @@ -88,14 +85,14 @@ describe("resolveDiscordTarget", () => { }); it("falls back to parsing when lookup misses", async () => { - listPeers.mockResolvedValueOnce([]); + vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([]); await expect( resolveDiscordTarget("general", { cfg, accountId: "default" }), ).resolves.toMatchObject({ kind: "channel", id: "general" }); }); it("does not call directory lookup for explicit user ids", async () => { - listPeers.mockResolvedValueOnce([]); + const listPeers = vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive"); await expect( resolveDiscordTarget("user:123", { cfg, accountId: "default" }), ).resolves.toMatchObject({ kind: "user", id: "123" }); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 40e9a0b64e8..7c62501aa6f 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -43,6 +43,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerInteractiveHandler() {}, registerHook() {}, registerHttpRoute() {}, registerCommand() {}, diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 2c3462c82a9..1eee0ff9d64 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -42,6 +42,12 @@ function createCommandContext(args: string): PluginCommandContext { commandBody: `/phone ${args}`, args, config: {}, + requestConversationBinding: async () => ({ + status: "error", + message: "unsupported", + }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, }; } diff --git a/extensions/telegram/src/bot-handlers.ts b/extensions/telegram/src/bot-handlers.ts index 295c4092ec6..88e61e1c567 100644 --- a/extensions/telegram/src/bot-handlers.ts +++ b/extensions/telegram/src/bot-handlers.ts @@ -33,6 +33,12 @@ import { danger, logVerbose, warn } from "../../../src/globals.js"; import { enqueueSystemEvent } from "../../../src/infra/system-events.js"; import { MediaFetchError } from "../../../src/media/fetch.js"; import { readChannelAllowFromStore } from "../../../src/pairing/pairing-store.js"; +import { + buildPluginBindingResolvedText, + parsePluginBindingApprovalCustomId, + resolvePluginConversationBindingApproval, +} from "../../../src/plugins/conversation-binding.js"; +import { dispatchPluginInteractiveHandler } from "../../../src/plugins/interactive.js"; import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js"; import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js"; @@ -1121,6 +1127,24 @@ export const registerTelegramHandlers = ({ } return await editCallbackMessage(messageText, replyMarkup); }; + const editCallbackButtons = async ( + buttons: Array< + Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> + >, + ) => { + const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; + const replyMarkup = { reply_markup: keyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + }; const deleteCallbackMessage = async () => { const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage; if (typeof deleteFn === "function") { @@ -1201,6 +1225,70 @@ export const registerTelegramHandlers = ({ return; } + const callbackConversationId = + messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId); + const pluginBindingApproval = parsePluginBindingApprovalCustomId(data); + if (pluginBindingApproval) { + const resolved = await resolvePluginConversationBindingApproval({ + approvalId: pluginBindingApproval.approvalId, + decision: pluginBindingApproval.decision, + senderId: senderId || undefined, + }); + await clearCallbackButtons(); + await replyToCallbackChat(buildPluginBindingResolvedText(resolved)); + return; + } + const pluginCallback = await dispatchPluginInteractiveHandler({ + channel: "telegram", + data, + callbackId: callback.id, + ctx: { + accountId, + callbackId: callback.id, + conversationId: callbackConversationId, + parentConversationId: messageThreadId != null ? String(chatId) : undefined, + senderId: senderId || undefined, + senderUsername: senderUsername || undefined, + threadId: messageThreadId, + isGroup, + isForum, + auth: { + isAuthorizedSender: true, + }, + callbackMessage: { + messageId: callbackMessage.message_id, + chatId: String(chatId), + messageText: callbackMessage.text ?? callbackMessage.caption, + }, + }, + respond: { + reply: async ({ text, buttons }) => { + await replyToCallbackChat( + text, + buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, + ); + }, + editMessage: async ({ text, buttons }) => { + await editCallbackMessage( + text, + buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined, + ); + }, + editButtons: async ({ buttons }) => { + await editCallbackButtons(buttons); + }, + clearButtons: async () => { + await clearCallbackButtons(); + }, + deleteMessage: async () => { + await deleteCallbackMessage(); + }, + }, + }); + if (pluginCallback.handled) { + return; + } + if (isApprovalCallback) { if ( !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index db19faa8fe3..9468f64c789 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,5 +1,10 @@ import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginInteractiveHandlers, + registerPluginInteractiveHandler, +} from "../../../src/plugins/interactive.js"; +import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import { @@ -49,6 +54,7 @@ describe("createTelegramBot", () => { beforeEach(() => { setMyCommandsSpy.mockClear(); + clearPluginInteractiveHandlers(); loadConfig.mockReturnValue({ agents: { defaults: { @@ -201,7 +207,7 @@ describe("createTelegramBot", () => { }, }, }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); @@ -244,7 +250,7 @@ describe("createTelegramBot", () => { }, }, }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); @@ -288,7 +294,7 @@ describe("createTelegramBot", () => { }, }); createTelegramBot({ token: "tok" }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + const callbackHandler = getOnHandler("callback_query") as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); @@ -1359,6 +1365,57 @@ describe("createTelegramBot", () => { expect(replySpy).not.toHaveBeenCalled(); }); + + it.skip("routes plugin-owned callback namespaces before synthetic command fallback", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageTextSpy.mockClear(); + sendMessageSpy.mockClear(); + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler: async ({ respond, callback }: PluginInteractiveTelegramHandlerContext) => { + await respond.editMessage({ + text: `Handled ${callback.payload}`, + }); + return { handled: true }; + }, + }); + + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }, + }); + const callbackHandler = getOnHandler("callback_query") as ( + ctx: Record, + ) => Promise; + + await callbackHandler({ + callbackQuery: { + id: "cbq-codex-1", + data: "codex:resume:thread-1", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 11, + text: "Select a thread", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageTextSpy).toHaveBeenCalledWith(1234, 11, "Handled resume:thread-1", undefined); + expect(replySpy).not.toHaveBeenCalled(); + }); it("sets command target session key for dm topic commands", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index ea48592eadb..f12c896d0ca 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -2,6 +2,7 @@ import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings. import type { OpenClawConfig } from "../../../src/config/config.js"; import { logVerbose } from "../../../src/globals.js"; import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js"; +import { isPluginOwnedSessionBindingRecord } from "../../../src/plugins/conversation-binding.js"; import { buildAgentSessionKey, deriveLastRoutePolicy, @@ -118,21 +119,25 @@ export function resolveTelegramConversationRoute(params: { }); const boundSessionKey = threadBinding?.targetSessionKey?.trim(); if (threadBinding && boundSessionKey) { - route = { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey), - lastRoutePolicy: deriveLastRoutePolicy({ + if (!isPluginOwnedSessionBindingRecord(threadBinding)) { + route = { + ...route, sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; + agentId: resolveAgentIdFromSessionKey(boundSessionKey), + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey: boundSessionKey, + mainSessionKey: route.mainSessionKey, + }), + matchedBy: "binding.channel", + }; + } configuredBinding = null; configuredBindingSessionKey = ""; getSessionBindingService().touch(threadBinding.bindingId); logVerbose( - `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, + isPluginOwnedSessionBindingRecord(threadBinding) + ? `telegram: plugin-bound conversation ${threadBindingConversationId}` + : `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, ); } } diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 6d53a3d20e7..604a7d27dd1 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -4,7 +4,10 @@ import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { deleteMessage: vi.fn(), + editForumTopic: vi.fn(), editMessageText: vi.fn(), + editMessageReplyMarkup: vi.fn(), + pinChatMessage: vi.fn(), sendChatAction: vi.fn(), sendMessage: vi.fn(), sendPoll: vi.fn(), @@ -16,6 +19,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({ sendAnimation: vi.fn(), setMessageReaction: vi.fn(), sendSticker: vi.fn(), + unpinChatMessage: vi.fn(), }, botCtorSpy: vi.fn(), })); diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 7a29ecf07de..8a234ce92cb 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -16,11 +16,14 @@ const { buildInlineKeyboard, createForumTopicTelegram, editMessageTelegram, + pinMessageTelegram, reactMessageTelegram, + renameForumTopicTelegram, sendMessageTelegram, sendTypingTelegram, sendPollTelegram, sendStickerTelegram, + unpinMessageTelegram, } = await importTelegramSendModule(); async function expectChatNotFoundWithChatId( @@ -215,6 +218,45 @@ describe("sendMessageTelegram", () => { }); }); + it("pins and unpins Telegram messages", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.pinChatMessage.mockResolvedValue(true); + botApi.unpinChatMessage.mockResolvedValue(true); + + await pinMessageTelegram("-1001234567890", 101, { accountId: "default" }); + await unpinMessageTelegram("-1001234567890", 101, { accountId: "default" }); + + expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, { + disable_notification: true, + }); + expect(botApi.unpinChatMessage).toHaveBeenCalledWith("-1001234567890", 101); + }); + + it("renames a Telegram forum topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.editForumTopic.mockResolvedValue(true); + + await renameForumTopicTelegram("-1001234567890", 271, "Codex Thread", { + accountId: "default", + }); + + expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { + name: "Codex Thread", + }); + }); + it("applies timeoutSeconds config precedence", async () => { const cases = [ { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index e7d2c48e9fc..89d6f7d337d 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -1067,6 +1067,109 @@ export async function deleteMessageTelegram( return { ok: true }; } +export async function pinMessageTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; messageId: string; chatId: string }> { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageId = normalizeMessageId(messageIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + await requestWithDiag( + () => api.pinChatMessage(chatId, messageId, { disable_notification: true }), + "pinChatMessage", + ); + logVerbose(`[telegram] Pinned message ${messageId} in chat ${chatId}`); + return { ok: true, messageId: String(messageId), chatId }; +} + +export async function unpinMessageTelegram( + chatIdInput: string | number, + messageIdInput?: string | number, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; chatId: string; messageId?: string }> { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageId = messageIdInput === undefined ? undefined : normalizeMessageId(messageIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + await requestWithDiag(() => api.unpinChatMessage(chatId, messageId), "unpinChatMessage"); + logVerbose( + `[telegram] Unpinned ${messageId != null ? `message ${messageId}` : "active message"} in chat ${chatId}`, + ); + return { + ok: true, + chatId, + ...(messageId != null ? { messageId: String(messageId) } : {}), + }; +} + +export async function renameForumTopicTelegram( + chatIdInput: string | number, + messageThreadIdInput: string | number, + name: string, + opts: TelegramDeleteOpts = {}, +): Promise<{ ok: true; chatId: string; messageThreadId: number; name: string }> { + const trimmedName = name.trim(); + if (!trimmedName) { + throw new Error("Telegram forum topic name is required"); + } + if (trimmedName.length > 128) { + throw new Error("Telegram forum topic name must be 128 characters or fewer"); + } + const { cfg, account, api } = resolveTelegramApiContext(opts); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageThreadId = normalizeMessageId(messageThreadIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + await requestWithDiag( + () => api.editForumTopic(chatId, messageThreadId, { name: trimmedName }), + "editForumTopic", + ); + logVerbose(`[telegram] Renamed forum topic ${messageThreadId} in chat ${chatId}`); + return { + ok: true, + chatId, + messageThreadId, + name: trimmedName, + }; +} + type TelegramEditOpts = { token?: string; accountId?: string; diff --git a/extensions/telegram/src/thread-bindings.test.ts b/extensions/telegram/src/thread-bindings.test.ts index 3b05f50ac9b..39b9c63338b 100644 --- a/extensions/telegram/src/thread-bindings.test.ts +++ b/extensions/telegram/src/thread-bindings.test.ts @@ -211,4 +211,40 @@ describe("telegram thread bindings", () => { ); expect(fs.existsSync(statePath)).toBe(false); }); + + it("persists unbinds before restart so removed bindings do not come back", async () => { + stateDirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-bindings-")); + process.env.OPENCLAW_STATE_DIR = stateDirOverride; + + createTelegramThreadBindingManager({ + accountId: "default", + persist: true, + enableSweeper: false, + }); + + const bound = await getSessionBindingService().bind({ + targetSessionKey: "plugin-binding:openclaw-codex-app-server:abc123", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + }); + + await getSessionBindingService().unbind({ + bindingId: bound.bindingId, + reason: "test-detach", + }); + + __testing.resetTelegramThreadBindingsForTests(); + + const reloaded = createTelegramThreadBindingManager({ + accountId: "default", + persist: true, + enableSweeper: false, + }); + + expect(reloaded.getByConversationId("8460800771")).toBeUndefined(); + }); }); diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index 831e46d952f..d10fef7f72c 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -34,6 +34,7 @@ export type TelegramThreadBindingRecord = { lastActivityAt: number; idleTimeoutMs?: number; maxAgeMs?: number; + metadata?: Record; }; type StoredTelegramBindingState = { @@ -173,6 +174,7 @@ function toSessionBindingRecord( typeof record.maxAgeMs === "number" ? Math.max(0, Math.floor(record.maxAgeMs)) : defaults.maxAgeMs, + ...record.metadata, }, }; } @@ -214,6 +216,10 @@ function fromSessionBindingInput(params: { : existing?.boundBy, boundAt: now, lastActivityAt: now, + metadata: { + ...existing?.metadata, + ...metadata, + }, }; if (typeof metadata.idleTimeoutMs === "number" && Number.isFinite(metadata.idleTimeoutMs)) { @@ -299,6 +305,9 @@ function loadBindingsFromDisk(accountId: string): TelegramThreadBindingRecord[] if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) { record.boundBy = entry.boundBy.trim(); } + if (entry?.metadata && typeof entry.metadata === "object") { + record.metadata = { ...entry.metadata }; + } bindings.push(record); } return bindings; @@ -535,7 +544,7 @@ export function createTelegramThreadBindingManager( resolveBindingKey({ accountId, conversationId }), record, ); - void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); logVerbose( `telegram: bound conversation ${conversationId} -> ${targetSessionKey} (${summarizeLifecycleForLog( record, @@ -595,6 +604,9 @@ export function createTelegramThreadBindingManager( reason: input.reason, sendFarewell: false, }); + if (removed.length > 0) { + await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + } return removed.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, @@ -614,6 +626,9 @@ export function createTelegramThreadBindingManager( reason: input.reason, sendFarewell: false, }); + if (removed) { + await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + } return removed ? [ toSessionBindingRecord(removed, { diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index 5c9693c1a80..a757344bd31 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -14,6 +14,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerInteractiveHandler() {}, registerCommand() {}, registerContextEngine() {}, resolvePath(input: string) { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 666964eb865..ed41db9664e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -23,8 +23,17 @@ const diagnosticMocks = vi.hoisted(() => ({ logSessionStateChange: vi.fn(), })); const hookMocks = vi.hoisted(() => ({ + registry: { + plugins: [] as Array<{ + id: string; + status: "loaded" | "disabled" | "error"; + }>, + }, runner: { hasHooks: vi.fn(() => false), + runInboundClaim: vi.fn(async () => undefined), + runInboundClaimForPlugin: vi.fn(async () => undefined), + runInboundClaimForPluginOutcome: vi.fn(async () => ({ status: "no_handler" as const })), runMessageReceived: vi.fn(async () => {}), }, })); @@ -40,6 +49,15 @@ const acpMocks = vi.hoisted(() => ({ })); const sessionBindingMocks = vi.hoisted(() => ({ listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []), + resolveByConversation: vi.fn< + (ref: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }) => SessionBindingRecord | null + >(() => null), + touch: vi.fn(), })); const sessionStoreMocks = vi.hoisted(() => ({ currentEntry: undefined as Record | undefined, @@ -125,6 +143,7 @@ vi.mock("../../config/sessions.js", async (importOriginal) => { vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, + getGlobalPluginRegistry: () => hookMocks.registry, })); vi.mock("../../hooks/internal-hooks.js", () => ({ createInternalHookEvent: internalHookMocks.createInternalHookEvent, @@ -155,8 +174,8 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal })), listBySession: (targetSessionKey: string) => sessionBindingMocks.listBySession(targetSessionKey), - resolveByConversation: vi.fn(() => null), - touch: vi.fn(), + resolveByConversation: sessionBindingMocks.resolveByConversation, + touch: sessionBindingMocks.touch, unbind: vi.fn(async () => []), }), }; @@ -170,6 +189,7 @@ vi.mock("../../tts/tts.js", () => ({ const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); +const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"); const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; @@ -239,7 +259,16 @@ describe("dispatchReplyFromConfig", () => { diagnosticMocks.logSessionStateChange.mockClear(); hookMocks.runner.hasHooks.mockClear(); hookMocks.runner.hasHooks.mockReturnValue(false); + hookMocks.runner.runInboundClaim.mockClear(); + hookMocks.runner.runInboundClaim.mockResolvedValue(undefined); + hookMocks.runner.runInboundClaimForPlugin.mockClear(); + hookMocks.runner.runInboundClaimForPlugin.mockResolvedValue(undefined); + hookMocks.runner.runInboundClaimForPluginOutcome.mockClear(); + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "no_handler", + }); hookMocks.runner.runMessageReceived.mockClear(); + hookMocks.registry.plugins = []; internalHookMocks.createInternalHookEvent.mockClear(); internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload); internalHookMocks.triggerInternalHook.mockClear(); @@ -250,6 +279,10 @@ describe("dispatchReplyFromConfig", () => { acpMocks.requireAcpRuntimeBackend.mockReset(); sessionBindingMocks.listBySession.mockReset(); sessionBindingMocks.listBySession.mockReturnValue([]); + pluginBindingTesting.reset(); + sessionBindingMocks.resolveByConversation.mockReset(); + sessionBindingMocks.resolveByConversation.mockReturnValue(null); + sessionBindingMocks.touch.mockReset(); sessionStoreMocks.currentEntry = undefined; sessionStoreMocks.loadSessionStore.mockClear(); sessionStoreMocks.resolveStorePath.mockClear(); @@ -1861,6 +1894,71 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("does not broadcast inbound claims without a core-owned plugin binding", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.runner.runInboundClaim.mockResolvedValue({ handled: true } as never); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:-10099", + To: "telegram:-10099", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + MessageThreadId: 77, + CommandAuthorized: true, + WasMentioned: true, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-1", + SessionKey: "agent:main:telegram:group:-10099:77", + }); + const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: true, counts: { tool: 0, block: 0, final: 0 } }); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith( + expect.objectContaining({ + from: ctx.From, + content: "who are you", + metadata: expect.objectContaining({ + messageId: "msg-claim-1", + originatingChannel: "telegram", + originatingTo: "telegram:-10099", + senderId: "user-9", + senderUsername: "ada", + threadId: 77, + }), + }), + expect.objectContaining({ + channelId: "telegram", + accountId: "default", + conversationId: "telegram:-10099", + }), + ); + expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "received", + sessionKey: "agent:main:telegram:group:-10099:77", + }), + ); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "core reply" }), + ); + }); + it("emits internal message:received hook when a session key is available", async () => { setNoAbort(); const cfg = emptyConfig; @@ -1944,6 +2042,411 @@ describe("dispatchReplyFromConfig", () => { ); }); + it("routes plugin-owned bindings to the owning plugin before generic inbound claim broadcast", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "handled", + result: { handled: true }, + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-1", + targetSessionKey: "plugin-binding:codex:abc123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:1481858418548412579", + To: "discord:channel:1481858418548412579", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + CommandAuthorized: true, + WasMentioned: false, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-plugin-1", + SessionKey: "agent:main:discord:channel:1481858418548412579", + }); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-1"); + expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( + "openclaw-codex-app-server", + expect.objectContaining({ + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + content: "who are you", + }), + expect.objectContaining({ + channelId: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + }), + ); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(replyResolver).not.toHaveBeenCalled(); + }); + + it("routes plugin-owned Discord DM bindings to the owning plugin before generic inbound claim broadcast", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "handled", + result: { handled: true }, + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-dm-1", + targetSessionKey: "plugin-binding:codex:dm123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:1177378744822943744", + OriginatingTo: "channel:1480574946919846079", + To: "channel:1480574946919846079", + AccountId: "default", + SenderId: "user-9", + SenderUsername: "ada", + CommandAuthorized: true, + WasMentioned: false, + CommandBody: "who are you", + RawBody: "who are you", + Body: "who are you", + MessageSid: "msg-claim-plugin-dm-1", + SessionKey: "agent:main:discord:user:1177378744822943744", + }); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-dm-1"); + expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( + "openclaw-codex-app-server", + expect.objectContaining({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + content: "who are you", + }), + expect.objectContaining({ + channelId: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }), + ); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + expect(replyResolver).not.toHaveBeenCalled(); + }); + + it("falls back to OpenClaw once per startup when a bound plugin is missing", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "missing_plugin", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-missing-1", + targetSessionKey: "plugin-binding:codex:missing123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:missing-plugin", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + detachHint: "/codex_detach", + }, + } satisfies SessionBindingRecord); + + const replyResolver = vi.fn(async () => ({ text: "openclaw fallback" }) satisfies ReplyPayload); + + const firstDispatcher = createDispatcher(); + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:missing-plugin", + To: "discord:channel:missing-plugin", + AccountId: "default", + MessageSid: "msg-missing-plugin-1", + SessionKey: "agent:main:discord:channel:missing-plugin", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher: firstDispatcher, + replyResolver, + }); + + const firstNotice = (firstDispatcher.sendToolResult as ReturnType).mock + .calls[0]?.[0] as ReplyPayload | undefined; + expect(firstNotice?.text).toContain("Routing this message to OpenClaw instead."); + expect(firstNotice?.text).toContain("/codex_detach"); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + + replyResolver.mockClear(); + hookMocks.runner.runInboundClaim.mockClear(); + + const secondDispatcher = createDispatcher(); + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:missing-plugin", + To: "discord:channel:missing-plugin", + AccountId: "default", + MessageSid: "msg-missing-plugin-2", + SessionKey: "agent:main:discord:channel:missing-plugin", + CommandBody: "still there?", + RawBody: "still there?", + Body: "still there?", + }), + cfg: emptyConfig, + dispatcher: secondDispatcher, + replyResolver, + }); + + expect(secondDispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + + it("falls back to OpenClaw when the bound plugin is loaded but has no inbound_claim handler", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "no_handler", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-no-handler-1", + targetSessionKey: "plugin-binding:codex:nohandler123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:no-handler", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "openclaw fallback" }) satisfies ReplyPayload); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:no-handler", + To: "discord:channel:no-handler", + AccountId: "default", + MessageSid: "msg-no-handler-1", + SessionKey: "agent:main:discord:channel:no-handler", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + const notice = (dispatcher.sendToolResult as ReturnType).mock.calls[0]?.[0] as + | ReplyPayload + | undefined; + expect(notice?.text).toContain("Routing this message to OpenClaw instead."); + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + + it("notifies the user when a bound plugin declines the turn and keeps the binding attached", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "declined", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-declined-1", + targetSessionKey: "plugin-binding:codex:declined123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:declined", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + detachHint: "/codex_detach", + }, + } satisfies SessionBindingRecord); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:declined", + To: "discord:channel:declined", + AccountId: "default", + MessageSid: "msg-declined-1", + SessionKey: "agent:main:discord:channel:declined", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + const finalNotice = (dispatcher.sendFinalReply as ReturnType).mock + .calls[0]?.[0] as ReplyPayload | undefined; + expect(finalNotice?.text).toContain("did not handle this message"); + expect(finalNotice?.text).toContain("/codex_detach"); + expect(replyResolver).not.toHaveBeenCalled(); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + + it("notifies the user when a bound plugin errors and keeps raw details out of the reply", async () => { + setNoAbort(); + hookMocks.runner.hasHooks.mockImplementation( + ((hookName?: string) => + hookName === "inbound_claim" || hookName === "message_received") as () => boolean, + ); + hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }]; + hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({ + status: "error", + error: "boom", + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-error-1", + targetSessionKey: "plugin-binding:codex:error123", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:error", + }, + status: "active", + boundAt: 1710000000000, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/Users/huntharo/github/openclaw-app-server", + }, + } satisfies SessionBindingRecord); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload); + + await dispatchReplyFromConfig({ + ctx: buildTestCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + OriginatingTo: "discord:channel:error", + To: "discord:channel:error", + AccountId: "default", + MessageSid: "msg-error-1", + SessionKey: "agent:main:discord:channel:error", + CommandBody: "hello", + RawBody: "hello", + Body: "hello", + }), + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + const finalNotice = (dispatcher.sendFinalReply as ReturnType).mock + .calls[0]?.[0] as ReplyPayload | undefined; + expect(finalNotice?.text).toContain("hit an error handling this message"); + expect(finalNotice?.text).not.toContain("boom"); + expect(replyResolver).not.toHaveBeenCalled(); + expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled(); + }); + it("marks diagnostics skipped for duplicate inbound messages", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5b679fa59e5..1e90dd58887 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -13,17 +13,29 @@ import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { deriveInboundMessageHookContext, + toPluginInboundClaimContext, + toPluginInboundClaimEvent, toInternalMessageReceivedContext, toPluginMessageContext, toPluginMessageReceivedEvent, } from "../../hooks/message-hook-mappers.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; +import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { logMessageProcessed, logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js"; -import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { + buildPluginBindingDeclinedText, + buildPluginBindingErrorText, + buildPluginBindingUnavailableText, + hasShownPluginBindingFallbackNotice, + isPluginOwnedSessionBindingRecord, + markPluginBindingFallbackNoticeShown, + toPluginConversationBinding, +} from "../../plugins/conversation-binding.js"; +import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -190,30 +202,12 @@ export async function dispatchReplyFromConfig(params: { ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; const hookContext = deriveInboundMessageHookContext(ctx, { messageId: messageIdForHook }); const { isGroup, groupId } = hookContext; - - // Trigger plugin hooks (fire-and-forget) - if (hookRunner?.hasHooks("message_received")) { - fireAndForgetHook( - hookRunner.runMessageReceived( - toPluginMessageReceivedEvent(hookContext), - toPluginMessageContext(hookContext), - ), - "dispatch-from-config: message_received plugin hook failed", - ); - } - - // Bridge to internal hooks (HOOK.md discovery system) - refs #8807 - if (sessionKey) { - fireAndForgetHook( - triggerInternalHook( - createInternalHookEvent("message", "received", sessionKey, { - ...toInternalMessageReceivedContext(hookContext), - timestamp, - }), - ), - "dispatch-from-config: message_received internal hook failed", - ); - } + const inboundClaimContext = toPluginInboundClaimContext(hookContext); + const inboundClaimEvent = toPluginInboundClaimEvent(hookContext, { + commandAuthorized: + typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : undefined, + wasMentioned: typeof ctx.WasMentioned === "boolean" ? ctx.WasMentioned : undefined, + }); // Check if we should route replies to originating channel instead of dispatcher. // Only route when the originating channel is DIFFERENT from the current surface. @@ -279,6 +273,144 @@ export async function dispatchReplyFromConfig(params: { } }; + const sendBindingNotice = async ( + payload: ReplyPayload, + mode: "additive" | "terminal", + ): Promise => { + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: routeThreadId, + cfg, + isGroup, + groupId, + }); + if (!result.ok) { + logVerbose( + `dispatch-from-config: route-reply (plugin binding notice) failed: ${result.error ?? "unknown error"}`, + ); + } + return result.ok; + } + return mode === "additive" + ? dispatcher.sendToolResult(payload) + : dispatcher.sendFinalReply(payload); + }; + + const pluginOwnedBindingRecord = + inboundClaimContext.conversationId && inboundClaimContext.channelId + ? getSessionBindingService().resolveByConversation({ + channel: inboundClaimContext.channelId, + accountId: inboundClaimContext.accountId ?? "default", + conversationId: inboundClaimContext.conversationId, + parentConversationId: inboundClaimContext.parentConversationId, + }) + : null; + const pluginOwnedBinding = isPluginOwnedSessionBindingRecord(pluginOwnedBindingRecord) + ? toPluginConversationBinding(pluginOwnedBindingRecord) + : null; + + let pluginFallbackReason: + | "plugin-bound-fallback-missing-plugin" + | "plugin-bound-fallback-no-handler" + | undefined; + + if (pluginOwnedBinding) { + getSessionBindingService().touch(pluginOwnedBinding.bindingId); + logVerbose( + `plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`, + ); + const targetedClaimOutcome = hookRunner?.runInboundClaimForPluginOutcome + ? await hookRunner.runInboundClaimForPluginOutcome( + pluginOwnedBinding.pluginId, + inboundClaimEvent, + inboundClaimContext, + ) + : (() => { + const pluginLoaded = + getGlobalPluginRegistry()?.plugins.some( + (plugin) => plugin.id === pluginOwnedBinding.pluginId && plugin.status === "loaded", + ) ?? false; + return pluginLoaded + ? ({ status: "no_handler" } as const) + : ({ status: "missing_plugin" } as const); + })(); + + switch (targetedClaimOutcome.status) { + case "handled": { + markIdle("plugin_binding_dispatch"); + recordProcessed("completed", { reason: "plugin-bound-handled" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + case "missing_plugin": + case "no_handler": { + pluginFallbackReason = + targetedClaimOutcome.status === "missing_plugin" + ? "plugin-bound-fallback-missing-plugin" + : "plugin-bound-fallback-no-handler"; + if (!hasShownPluginBindingFallbackNotice(pluginOwnedBinding.bindingId)) { + const didSendNotice = await sendBindingNotice( + { text: buildPluginBindingUnavailableText(pluginOwnedBinding) }, + "additive", + ); + if (didSendNotice) { + markPluginBindingFallbackNoticeShown(pluginOwnedBinding.bindingId); + } + } + break; + } + case "declined": { + await sendBindingNotice( + { text: buildPluginBindingDeclinedText(pluginOwnedBinding) }, + "terminal", + ); + markIdle("plugin_binding_declined"); + recordProcessed("completed", { reason: "plugin-bound-declined" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + case "error": { + logVerbose( + `plugin-bound inbound claim failed for ${pluginOwnedBinding.pluginId}: ${targetedClaimOutcome.error}`, + ); + await sendBindingNotice( + { text: buildPluginBindingErrorText(pluginOwnedBinding) }, + "terminal", + ); + markIdle("plugin_binding_error"); + recordProcessed("completed", { reason: "plugin-bound-error" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + } + } + + // Trigger plugin hooks (fire-and-forget) + if (hookRunner?.hasHooks("message_received")) { + fireAndForgetHook( + hookRunner.runMessageReceived( + toPluginMessageReceivedEvent(hookContext), + toPluginMessageContext(hookContext), + ), + "dispatch-from-config: message_received plugin hook failed", + ); + } + + // Bridge to internal hooks (HOOK.md discovery system) - refs #8807 + if (sessionKey) { + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent("message", "received", sessionKey, { + ...toInternalMessageReceivedContext(hookContext), + timestamp, + }), + ), + "dispatch-from-config: message_received internal hook failed", + ); + } + markProcessing(); try { @@ -606,7 +738,10 @@ export async function dispatchReplyFromConfig(params: { const counts = dispatcher.getQueuedCounts(); counts.final += routedFinalCount; - recordProcessed("completed"); + recordProcessed( + "completed", + pluginFallbackReason ? { reason: pluginFallbackReason } : undefined, + ); markIdle("message_completed"); return { queuedFinal, counts }; } catch (err) { diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index c365f463ade..53660054a15 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { buildCanonicalSentMessageHookContext, deriveInboundMessageHookContext, + toPluginInboundClaimContext, toInternalMessagePreprocessedContext, toInternalMessageReceivedContext, toInternalMessageSentContext, @@ -99,6 +100,53 @@ describe("message hook mappers", () => { }); }); + it("normalizes Discord channel targets for inbound claim contexts", () => { + const canonical = deriveInboundMessageHookContext( + makeInboundCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + To: "channel:123456789012345678", + OriginatingTo: "channel:123456789012345678", + GroupChannel: "general", + GroupSubject: "guild", + }), + ); + + expect(toPluginInboundClaimContext(canonical)).toEqual({ + channelId: "discord", + accountId: "acc-1", + conversationId: "channel:123456789012345678", + parentConversationId: undefined, + senderId: "sender-1", + messageId: "msg-1", + }); + }); + + it("normalizes Discord DM targets for inbound claim contexts", () => { + const canonical = deriveInboundMessageHookContext( + makeInboundCtx({ + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + From: "discord:1177378744822943744", + To: "channel:1480574946919846079", + OriginatingTo: "channel:1480574946919846079", + GroupChannel: undefined, + GroupSubject: undefined, + }), + ); + + expect(toPluginInboundClaimContext(canonical)).toEqual({ + channelId: "discord", + accountId: "acc-1", + conversationId: "user:1177378744822943744", + parentConversationId: undefined, + senderId: "sender-1", + messageId: "msg-1", + }); + }); + it("maps transcribed and preprocessed internal payloads", () => { const cfg = {} as OpenClawConfig; const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined })); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 1cdd12a93ac..968a4d50719 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -1,6 +1,8 @@ import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; import type { + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, PluginHookMessageContext, PluginHookMessageReceivedEvent, PluginHookMessageSentEvent, @@ -147,6 +149,136 @@ export function toPluginMessageContext( }; } +function stripChannelPrefix(value: string | undefined, channelId: string): string | undefined { + if (!value) { + return undefined; + } + const genericPrefixes = ["channel:", "chat:", "user:"]; + for (const prefix of genericPrefixes) { + if (value.startsWith(prefix)) { + return value.slice(prefix.length); + } + } + const prefix = `${channelId}:`; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; +} + +function deriveParentConversationId( + canonical: CanonicalInboundMessageHookContext, +): string | undefined { + if (canonical.channelId !== "telegram") { + return undefined; + } + if (typeof canonical.threadId !== "number" && typeof canonical.threadId !== "string") { + return undefined; + } + return stripChannelPrefix( + canonical.to ?? canonical.originatingTo ?? canonical.conversationId, + "telegram", + ); +} + +function deriveConversationId(canonical: CanonicalInboundMessageHookContext): string | undefined { + if (canonical.channelId === "discord") { + const rawTarget = canonical.to ?? canonical.originatingTo ?? canonical.conversationId; + const rawSender = canonical.from; + const senderUserId = rawSender?.startsWith("discord:user:") + ? rawSender.slice("discord:user:".length) + : rawSender?.startsWith("discord:") + ? rawSender.slice("discord:".length) + : undefined; + if (!canonical.isGroup && senderUserId) { + return `user:${senderUserId}`; + } + if (!rawTarget) { + return undefined; + } + if (rawTarget.startsWith("discord:channel:")) { + return `channel:${rawTarget.slice("discord:channel:".length)}`; + } + if (rawTarget.startsWith("discord:user:")) { + return `user:${rawTarget.slice("discord:user:".length)}`; + } + if (rawTarget.startsWith("discord:")) { + return `user:${rawTarget.slice("discord:".length)}`; + } + if (rawTarget.startsWith("channel:") || rawTarget.startsWith("user:")) { + return rawTarget; + } + } + const baseConversationId = stripChannelPrefix( + canonical.to ?? canonical.originatingTo ?? canonical.conversationId, + canonical.channelId, + ); + if (canonical.channelId === "telegram" && baseConversationId) { + const threadId = + typeof canonical.threadId === "number" || typeof canonical.threadId === "string" + ? String(canonical.threadId).trim() + : ""; + if (threadId) { + return `${baseConversationId}:topic:${threadId}`; + } + } + return baseConversationId; +} + +export function toPluginInboundClaimContext( + canonical: CanonicalInboundMessageHookContext, +): PluginHookInboundClaimContext { + const conversationId = deriveConversationId(canonical); + return { + channelId: canonical.channelId, + accountId: canonical.accountId, + conversationId, + parentConversationId: deriveParentConversationId(canonical), + senderId: canonical.senderId, + messageId: canonical.messageId, + }; +} + +export function toPluginInboundClaimEvent( + canonical: CanonicalInboundMessageHookContext, + extras?: { + commandAuthorized?: boolean; + wasMentioned?: boolean; + }, +): PluginHookInboundClaimEvent { + const context = toPluginInboundClaimContext(canonical); + return { + content: canonical.content, + body: canonical.body, + bodyForAgent: canonical.bodyForAgent, + transcript: canonical.transcript, + timestamp: canonical.timestamp, + channel: canonical.channelId, + accountId: canonical.accountId, + conversationId: context.conversationId, + parentConversationId: context.parentConversationId, + senderId: canonical.senderId, + senderName: canonical.senderName, + senderUsername: canonical.senderUsername, + threadId: canonical.threadId, + messageId: canonical.messageId, + isGroup: canonical.isGroup, + commandAuthorized: extras?.commandAuthorized, + wasMentioned: extras?.wasMentioned, + metadata: { + from: canonical.from, + to: canonical.to, + provider: canonical.provider, + surface: canonical.surface, + originatingChannel: canonical.originatingChannel, + originatingTo: canonical.originatingTo, + senderE164: canonical.senderE164, + mediaPath: canonical.mediaPath, + mediaType: canonical.mediaType, + guildId: canonical.guildId, + channelName: canonical.channelName, + groupId: canonical.groupId, + }, + }; +} + export function toPluginMessageReceivedEvent( canonical: CanonicalInboundMessageHookContext, ): PluginHookMessageReceivedEvent { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 8b4a4f28a4e..308c63e2920 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -100,6 +100,12 @@ export type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, + PluginHookInboundClaimResult, + PluginInteractiveDiscordHandlerContext, + PluginInteractiveHandlerRegistration, + PluginInteractiveTelegramHandlerContext, PluginLogger, ProviderAuthContext, ProviderAuthResult, @@ -113,6 +119,14 @@ export type { ProviderRuntimeModel, ProviderWrapStreamFnContext, } from "../plugins/types.js"; +export type { + ConversationRef, + SessionBindingBindInput, + SessionBindingCapabilities, + SessionBindingRecord, + SessionBindingService, + SessionBindingUnbindInput, +} from "../infra/outbound/session-binding-service.js"; export type { GatewayRequestHandler, GatewayRequestHandlerOptions, diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 34d411702a0..64f953fb014 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { + __testing, clearPluginCommands, + executePluginCommand, getPluginCommandSpecs, listPluginCommands, registerPluginCommand, @@ -93,5 +95,107 @@ describe("registerPluginCommand", () => { acceptsArgs: false, }, ]); + expect(getPluginCommandSpecs("slack")).toEqual([]); + }); + + it("resolves Discord DM command bindings with the user target prefix intact", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "discord", + from: "discord:1177378744822943744", + to: "slash:1177378744822943744", + accountId: "default", + }), + ).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }); + }); + + it("resolves Discord guild command bindings with the channel target prefix intact", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "discord", + from: "discord:channel:1480554272859881494", + accountId: "default", + }), + ).toEqual({ + channel: "discord", + accountId: "default", + conversationId: "channel:1480554272859881494", + }); + }); + + it("does not resolve binding conversations for unsupported command channels", () => { + expect( + __testing.resolveBindingConversationFromCommand({ + channel: "slack", + from: "slack:U123", + to: "C456", + accountId: "default", + }), + ).toBeNull(); + }); + + it("does not expose binding APIs to plugin commands on unsupported channels", async () => { + const handler = async (ctx: { + requestConversationBinding: (params: { summary: string }) => Promise; + getCurrentConversationBinding: () => Promise; + detachConversationBinding: () => Promise; + }) => { + const requested = await ctx.requestConversationBinding({ + summary: "Bind this conversation.", + }); + const current = await ctx.getCurrentConversationBinding(); + const detached = await ctx.detachConversationBinding(); + return { + text: JSON.stringify({ + requested, + current, + detached, + }), + }; + }; + registerPluginCommand( + "demo-plugin", + { + name: "bindcheck", + description: "Demo command", + acceptsArgs: false, + handler, + }, + { pluginRoot: "/plugins/demo-plugin" }, + ); + + const result = await executePluginCommand({ + command: { + name: "bindcheck", + description: "Demo command", + acceptsArgs: false, + handler, + pluginId: "demo-plugin", + pluginRoot: "/plugins/demo-plugin", + }, + channel: "slack", + senderId: "U123", + isAuthorizedSender: true, + commandBody: "/bindcheck", + config: {} as never, + from: "slack:U123", + to: "C456", + accountId: "default", + }); + + expect(result.text).toBe( + JSON.stringify({ + requested: { + status: "error", + message: "This command cannot bind the current conversation.", + }, + current: null, + detached: { removed: false }, + }), + ); }); }); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 00e4b3b34ae..6bc049ff626 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -5,8 +5,15 @@ * These commands are processed before built-in commands and before agent invocation. */ +import { parseDiscordTarget } from "../../extensions/discord/src/targets.js"; +import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; import type { OpenClawPluginCommandDefinition, PluginCommandContext, @@ -15,6 +22,8 @@ import type { type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { pluginId: string; + pluginName?: string; + pluginRoot?: string; }; // Registry of plugin commands @@ -109,6 +118,7 @@ export type CommandRegistrationResult = { export function registerPluginCommand( pluginId: string, command: OpenClawPluginCommandDefinition, + opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed if (registryLocked) { @@ -149,7 +159,14 @@ export function registerPluginCommand( }; } - pluginCommands.set(key, { ...command, name, description, pluginId }); + pluginCommands.set(key, { + ...command, + name, + description, + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; } @@ -235,6 +252,63 @@ function sanitizeArgs(args: string | undefined): string | undefined { return sanitized; } +function stripPrefix(raw: string | undefined, prefix: string): string | undefined { + if (!raw) { + return undefined; + } + return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw; +} + +function resolveBindingConversationFromCommand(params: { + channel: string; + from?: string; + to?: string; + accountId?: string; + messageThreadId?: number; +}): { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; +} | null { + const accountId = params.accountId?.trim() || "default"; + if (params.channel === "telegram") { + const rawTarget = params.to ?? params.from; + if (!rawTarget) { + return null; + } + const target = parseTelegramTarget(rawTarget); + return { + channel: "telegram", + accountId, + conversationId: target.chatId, + threadId: params.messageThreadId ?? target.messageThreadId, + }; + } + if (params.channel === "discord") { + const source = params.from ?? params.to; + const rawTarget = source?.startsWith("discord:channel:") + ? stripPrefix(source, "discord:") + : source?.startsWith("discord:user:") + ? stripPrefix(source, "discord:") + : source; + if (!rawTarget || rawTarget.startsWith("slash:")) { + return null; + } + const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + channel: "discord", + accountId, + conversationId: `${target.kind}:${target.id}`, + }; + } + return null; +} + /** * Execute a plugin command handler. * @@ -268,6 +342,13 @@ export async function executePluginCommand(params: { // Sanitize args before passing to handler const sanitizedArgs = sanitizeArgs(args); + const bindingConversation = resolveBindingConversationFromCommand({ + channel, + from: params.from, + to: params.to, + accountId: params.accountId, + messageThreadId: params.messageThreadId, + }); const ctx: PluginCommandContext = { senderId, @@ -281,6 +362,40 @@ export async function executePluginCommand(params: { to: params.to, accountId: params.accountId, messageThreadId: params.messageThreadId, + requestConversationBinding: async (bindingParams) => { + if (!command.pluginRoot || !bindingConversation) { + return { + status: "error", + message: "This command cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: command.pluginId, + pluginName: command.pluginName, + pluginRoot: command.pluginRoot, + requestedBySenderId: senderId, + conversation: bindingConversation, + binding: bindingParams, + }); + }, + detachConversationBinding: async () => { + if (!command.pluginRoot || !bindingConversation) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot: command.pluginRoot, + conversation: bindingConversation, + }); + }, + getCurrentConversationBinding: async () => { + if (!command.pluginRoot || !bindingConversation) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot: command.pluginRoot, + conversation: bindingConversation, + }); + }, }; // Lock registry during execution to prevent concurrent modifications @@ -341,9 +456,17 @@ export function getPluginCommandSpecs(provider?: string): Array<{ description: string; acceptsArgs: boolean; }> { + const providerName = provider?.trim().toLowerCase(); + if (providerName && providerName !== "telegram" && providerName !== "discord") { + return []; + } return Array.from(pluginCommands.values()).map((cmd) => ({ name: resolvePluginNativeName(cmd, provider), description: cmd.description, acceptsArgs: cmd.acceptsArgs ?? false, })); } + +export const __testing = { + resolveBindingConversationFromCommand, +}; diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts new file mode 100644 index 00000000000..821fd9e3b48 --- /dev/null +++ b/src/plugins/conversation-binding.test.ts @@ -0,0 +1,575 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ConversationRef, + SessionBindingAdapter, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-")); +const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json"); + +const sessionBindingState = vi.hoisted(() => { + const records = new Map(); + let nextId = 1; + + function normalizeRef(ref: ConversationRef): ConversationRef { + return { + channel: ref.channel.trim().toLowerCase(), + accountId: ref.accountId.trim() || "default", + conversationId: ref.conversationId.trim(), + parentConversationId: ref.parentConversationId?.trim() || undefined, + }; + } + + function toKey(ref: ConversationRef): string { + const normalized = normalizeRef(ref); + return JSON.stringify(normalized); + } + + return { + records, + bind: vi.fn( + async (input: { + targetSessionKey: string; + targetKind: "session" | "subagent"; + conversation: ConversationRef; + metadata?: Record; + }) => { + const normalized = normalizeRef(input.conversation); + const record: SessionBindingRecord = { + bindingId: `binding-${nextId++}`, + targetSessionKey: input.targetSessionKey, + targetKind: input.targetKind, + conversation: normalized, + status: "active", + boundAt: Date.now(), + metadata: input.metadata, + }; + records.set(toKey(normalized), record); + return record; + }, + ), + resolveByConversation: vi.fn((ref: ConversationRef) => { + return records.get(toKey(ref)) ?? null; + }), + touch: vi.fn(), + unbind: vi.fn(async (input: { bindingId?: string }) => { + const removed: SessionBindingRecord[] = []; + for (const [key, record] of records.entries()) { + if (record.bindingId !== input.bindingId) { + continue; + } + removed.push(record); + records.delete(key); + } + return removed; + }), + reset() { + records.clear(); + nextId = 1; + this.bind.mockClear(); + this.resolveByConversation.mockClear(); + this.touch.mockClear(); + this.unbind.mockClear(); + }, + setRecord(record: SessionBindingRecord) { + records.set(toKey(record.conversation), record); + }, + }; +}); + +vi.mock("../infra/home-dir.js", () => ({ + expandHomePrefix: (value: string) => { + if (value === "~/.openclaw/plugin-binding-approvals.json") { + return approvalsPath; + } + return value; + }, +})); + +const { + __testing, + buildPluginBindingApprovalCustomId, + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + parsePluginBindingApprovalCustomId, + requestPluginConversationBinding, + resolvePluginConversationBindingApproval, +} = await import("./conversation-binding.js"); +const { registerSessionBindingAdapter, unregisterSessionBindingAdapter } = + await import("../infra/outbound/session-binding-service.js"); + +function createAdapter(channel: string, accountId: string): SessionBindingAdapter { + return { + channel, + accountId, + capabilities: { + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }, + bind: sessionBindingState.bind, + listBySession: () => [], + resolveByConversation: sessionBindingState.resolveByConversation, + touch: sessionBindingState.touch, + unbind: sessionBindingState.unbind, + }; +} + +describe("plugin conversation binding approvals", () => { + beforeEach(() => { + sessionBindingState.reset(); + __testing.reset(); + fs.rmSync(approvalsPath, { force: true }); + unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" }); + unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" }); + unregisterSessionBindingAdapter({ channel: "discord", accountId: "isolated" }); + unregisterSessionBindingAdapter({ channel: "telegram", accountId: "default" }); + registerSessionBindingAdapter(createAdapter("discord", "default")); + registerSessionBindingAdapter(createAdapter("discord", "work")); + registerSessionBindingAdapter(createAdapter("discord", "isolated")); + registerSessionBindingAdapter(createAdapter("telegram", "default")); + }); + + it("keeps Telegram bind approval callback_data within Telegram's limit", () => { + const allowOnce = buildPluginBindingApprovalCustomId("abcdefghijkl", "allow-once"); + const allowAlways = buildPluginBindingApprovalCustomId("abcdefghijkl", "allow-always"); + const deny = buildPluginBindingApprovalCustomId("abcdefghijkl", "deny"); + + expect(Buffer.byteLength(allowOnce, "utf8")).toBeLessThanOrEqual(64); + expect(Buffer.byteLength(allowAlways, "utf8")).toBeLessThanOrEqual(64); + expect(Buffer.byteLength(deny, "utf8")).toBeLessThanOrEqual(64); + expect(parsePluginBindingApprovalCustomId(allowAlways)).toEqual({ + approvalId: "abcdefghijkl", + decision: "allow-always", + }); + }); + + it("requires a fresh approval again after allow-once is consumed", async () => { + const firstRequest = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(firstRequest.status).toBe("pending"); + if (firstRequest.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const approved = await resolvePluginConversationBindingApproval({ + approvalId: firstRequest.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + + const secondRequest = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:2", + }, + binding: { summary: "Bind this conversation to Codex thread 456." }, + }); + + expect(secondRequest.status).toBe("pending"); + }); + + it("persists always-allow by plugin root plus channel/account only", async () => { + const firstRequest = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(firstRequest.status).toBe("pending"); + if (firstRequest.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const approved = await resolvePluginConversationBindingApproval({ + approvalId: firstRequest.approvalId, + decision: "allow-always", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + + const sameScope = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:2", + }, + binding: { summary: "Bind this conversation to Codex thread 456." }, + }); + + expect(sameScope.status).toBe("bound"); + + const differentAccount = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "work", + conversationId: "channel:3", + }, + binding: { summary: "Bind this conversation to Codex thread 789." }, + }); + + expect(differentAccount.status).toBe("pending"); + }); + + it("does not share persistent approvals across plugin roots even with the same plugin id", async () => { + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-always", + senderId: "user-1", + }); + + const samePluginNewPath = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-b", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:78", + parentConversationId: "-10099", + threadId: "78", + }, + binding: { summary: "Bind this conversation to Codex thread def." }, + }); + + expect(samePluginNewPath.status).toBe("pending"); + }); + + it("persists detachHint on approved plugin bindings", async () => { + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:detach-hint", + }, + binding: { + summary: "Bind this conversation to Codex thread 999.", + detachHint: "/codex_detach", + }, + }); + + expect(["pending", "bound"]).toContain(request.status); + + if (request.status === "pending") { + const approved = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind request"); + } + + expect(approved.binding.detachHint).toBe("/codex_detach"); + } else { + expect(request.binding.detachHint).toBe("/codex_detach"); + } + + const currentBinding = await getCurrentPluginConversationBinding({ + pluginRoot: "/plugins/codex-a", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:detach-hint", + }, + }); + + expect(currentBinding?.detachHint).toBe("/codex_detach"); + }); + + it("returns and detaches only bindings owned by the requesting plugin root", async () => { + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(["pending", "bound"]).toContain(request.status); + if (request.status === "pending") { + await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }); + } + + const current = await getCurrentPluginConversationBinding({ + pluginRoot: "/plugins/codex-a", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }); + + expect(current).toEqual( + expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/codex-a", + conversationId: "channel:1", + }), + ); + + const otherPluginView = await getCurrentPluginConversationBinding({ + pluginRoot: "/plugins/codex-b", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }); + + expect(otherPluginView).toBeNull(); + + expect( + await detachPluginConversationBinding({ + pluginRoot: "/plugins/codex-b", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }), + ).toEqual({ removed: false }); + + expect( + await detachPluginConversationBinding({ + pluginRoot: "/plugins/codex-a", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:1", + }, + }), + ).toEqual({ removed: true }); + }); + + it("refuses to claim a conversation already bound by core", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-core", + targetSessionKey: "agent:main:discord:channel:1", + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1", + }, + status: "active", + boundAt: Date.now(), + metadata: { owner: "core" }, + }); + + const result = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1", + }, + binding: { summary: "Bind this conversation to Codex thread 123." }, + }); + + expect(result).toEqual({ + status: "error", + message: + "This conversation is already bound by core routing and cannot be claimed by a plugin.", + }); + }); + + it("migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-legacy", + targetSessionKey: "plugin-binding:old-codex-plugin:legacy123", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + }, + status: "active", + boundAt: Date.now(), + metadata: { + label: "legacy plugin bind", + }, + }); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(["pending", "bound"]).toContain(request.status); + const binding = + request.status === "pending" + ? await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((approved) => { + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind result"); + } + return approved.binding; + }) + : request.status === "bound" + ? request.binding + : (() => { + throw new Error("expected pending or bound bind result"); + })(); + + expect(binding).toEqual( + expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/codex-a", + conversationId: "-10099:topic:77", + }), + ); + }); + + it("migrates a legacy codex thread binding session key through the new approval flow", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-legacy-codex-thread", + targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + status: "active", + boundAt: Date.now(), + metadata: { + label: "legacy codex thread bind", + }, + }); + + const request = await requestPluginConversationBinding({ + pluginId: "openclaw-codex-app-server", + pluginName: "Codex App Server", + pluginRoot: "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + binding: { + summary: "Bind this conversation to Codex thread 019ce411-6322-7db2-a821-1a61c530e7d9.", + }, + }); + + expect(["pending", "bound"]).toContain(request.status); + const binding = + request.status === "pending" + ? await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((approved) => { + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind result"); + } + return approved.binding; + }) + : request.status === "bound" + ? request.binding + : (() => { + throw new Error("expected pending or bound bind result"); + })(); + + expect(binding).toEqual( + expect.objectContaining({ + pluginId: "openclaw-codex-app-server", + pluginRoot: "/plugins/codex-a", + conversationId: "8460800771", + }), + ); + }); +}); diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts new file mode 100644 index 00000000000..3de655abbe1 --- /dev/null +++ b/src/plugins/conversation-binding.ts @@ -0,0 +1,825 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Button, Row, type TopLevelComponents } from "@buape/carbon"; +import { ButtonStyle } from "discord-api-types/v10"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import { expandHomePrefix } from "../infra/home-dir.js"; +import { writeJsonAtomic } from "../infra/json-files.js"; +import { + getSessionBindingService, + type ConversationRef, +} from "../infra/outbound/session-binding-service.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { + PluginConversationBinding, + PluginConversationBindingRequestParams, + PluginConversationBindingRequestResult, +} from "./types.js"; + +const log = createSubsystemLogger("plugins/binding"); + +const APPROVALS_PATH = "~/.openclaw/plugin-binding-approvals.json"; +const PLUGIN_BINDING_CUSTOM_ID_PREFIX = "pluginbind"; +const PLUGIN_BINDING_OWNER = "plugin"; +const PLUGIN_BINDING_SESSION_PREFIX = "plugin-binding"; +const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [ + "openclaw-app-server:thread:", + "openclaw-codex-app-server:thread:", +] as const; + +type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny"; + +type PluginBindingApprovalEntry = { + pluginRoot: string; + pluginId: string; + pluginName?: string; + channel: string; + accountId: string; + approvedAt: number; +}; + +type PluginBindingApprovalsFile = { + version: 1; + approvals: PluginBindingApprovalEntry[]; +}; + +type PluginBindingConversation = { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; +}; + +type PendingPluginBindingRequest = { + id: string; + pluginId: string; + pluginName?: string; + pluginRoot: string; + conversation: PluginBindingConversation; + requestedAt: number; + requestedBySenderId?: string; + summary?: string; + detachHint?: string; +}; + +type PluginBindingApprovalAction = { + approvalId: string; + decision: PluginBindingApprovalDecision; +}; + +type PluginBindingIdentity = { + pluginId: string; + pluginName?: string; + pluginRoot: string; +}; + +type PluginBindingMetadata = { + pluginBindingOwner: "plugin"; + pluginId: string; + pluginName?: string; + pluginRoot: string; + summary?: string; + detachHint?: string; +}; + +type PluginBindingResolveResult = + | { + status: "approved"; + binding: PluginConversationBinding; + request: PendingPluginBindingRequest; + decision: PluginBindingApprovalDecision; + } + | { + status: "denied"; + request: PendingPluginBindingRequest; + } + | { + status: "expired"; + }; + +const pendingRequests = new Map(); + +type PluginBindingGlobalState = { + fallbackNoticeBindingIds: Set; +}; + +const pluginBindingGlobalStateKey = Symbol.for("openclaw.plugins.binding.global-state"); + +let approvalsCache: PluginBindingApprovalsFile | null = null; +let approvalsLoaded = false; + +function getPluginBindingGlobalState(): PluginBindingGlobalState { + const globalStore = globalThis as typeof globalThis & { + [pluginBindingGlobalStateKey]?: PluginBindingGlobalState; + }; + return (globalStore[pluginBindingGlobalStateKey] ??= { + fallbackNoticeBindingIds: new Set(), + }); +} + +class PluginBindingApprovalButton extends Button { + customId: string; + label: string; + style: ButtonStyle; + + constructor(params: { + approvalId: string; + decision: PluginBindingApprovalDecision; + label: string; + style: ButtonStyle; + }) { + super(); + this.customId = buildPluginBindingApprovalCustomId(params.approvalId, params.decision); + this.label = params.label; + this.style = params.style; + } +} + +function resolveApprovalsPath(): string { + return expandHomePrefix(APPROVALS_PATH); +} + +function normalizeChannel(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeConversation(params: PluginBindingConversation): PluginBindingConversation { + return { + channel: normalizeChannel(params.channel), + accountId: params.accountId.trim() || "default", + conversationId: params.conversationId.trim(), + parentConversationId: params.parentConversationId?.trim() || undefined, + threadId: + typeof params.threadId === "number" + ? Math.trunc(params.threadId) + : params.threadId?.toString().trim() || undefined, + }; +} + +function toConversationRef(params: PluginBindingConversation): ConversationRef { + const normalized = normalizeConversation(params); + if (normalized.channel === "telegram") { + const threadId = + typeof normalized.threadId === "number" || typeof normalized.threadId === "string" + ? String(normalized.threadId).trim() + : ""; + if (threadId) { + const parent = normalized.parentConversationId?.trim() || normalized.conversationId; + return { + channel: "telegram", + accountId: normalized.accountId, + conversationId: `${parent}:topic:${threadId}`, + }; + } + } + return { + channel: normalized.channel, + accountId: normalized.accountId, + conversationId: normalized.conversationId, + ...(normalized.parentConversationId + ? { parentConversationId: normalized.parentConversationId } + : {}), + }; +} + +function buildApprovalScopeKey(params: { + pluginRoot: string; + channel: string; + accountId: string; +}): string { + return [ + params.pluginRoot, + normalizeChannel(params.channel), + params.accountId.trim() || "default", + ].join("::"); +} + +function buildPluginBindingSessionKey(params: { + pluginId: string; + channel: string; + accountId: string; + conversationId: string; +}): string { + const hash = crypto + .createHash("sha256") + .update( + JSON.stringify({ + pluginId: params.pluginId, + channel: normalizeChannel(params.channel), + accountId: params.accountId, + conversationId: params.conversationId, + }), + ) + .digest("hex") + .slice(0, 24); + return `${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:${hash}`; +} + +function isLegacyPluginBindingRecord(params: { + record: + | { + targetSessionKey: string; + metadata?: Record; + } + | null + | undefined; +}): boolean { + if (!params.record || isPluginOwnedBindingMetadata(params.record.metadata)) { + return false; + } + const targetSessionKey = params.record.targetSessionKey.trim(); + return ( + targetSessionKey.startsWith(`${PLUGIN_BINDING_SESSION_PREFIX}:`) || + LEGACY_CODEX_PLUGIN_SESSION_PREFIXES.some((prefix) => targetSessionKey.startsWith(prefix)) + ); +} + +function buildDiscordButtonRow( + approvalId: string, + labels?: { once?: string; always?: string; deny?: string }, +): TopLevelComponents[] { + return [ + new Row([ + new PluginBindingApprovalButton({ + approvalId, + decision: "allow-once", + label: labels?.once ?? "Allow once", + style: ButtonStyle.Success, + }), + new PluginBindingApprovalButton({ + approvalId, + decision: "allow-always", + label: labels?.always ?? "Always allow", + style: ButtonStyle.Primary, + }), + new PluginBindingApprovalButton({ + approvalId, + decision: "deny", + label: labels?.deny ?? "Deny", + style: ButtonStyle.Danger, + }), + ]), + ]; +} + +function buildTelegramButtons(approvalId: string) { + return [ + [ + { + text: "Allow once", + callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-once"), + style: "success" as const, + }, + { + text: "Always allow", + callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-always"), + style: "primary" as const, + }, + { + text: "Deny", + callback_data: buildPluginBindingApprovalCustomId(approvalId, "deny"), + style: "danger" as const, + }, + ], + ]; +} + +function createApprovalRequestId(): string { + // Keep approval ids compact so Telegram callback_data stays under its 64-byte limit. + return crypto.randomBytes(9).toString("base64url"); +} + +function loadApprovalsFromDisk(): PluginBindingApprovalsFile { + const filePath = resolveApprovalsPath(); + try { + if (!fs.existsSync(filePath)) { + return { version: 1, approvals: [] }; + } + const raw = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (!Array.isArray(parsed.approvals)) { + return { version: 1, approvals: [] }; + } + return { + version: 1, + approvals: parsed.approvals + .filter((entry): entry is PluginBindingApprovalEntry => + Boolean(entry && typeof entry === "object"), + ) + .map((entry) => ({ + pluginRoot: typeof entry.pluginRoot === "string" ? entry.pluginRoot : "", + pluginId: typeof entry.pluginId === "string" ? entry.pluginId : "", + pluginName: typeof entry.pluginName === "string" ? entry.pluginName : undefined, + channel: typeof entry.channel === "string" ? normalizeChannel(entry.channel) : "", + accountId: + typeof entry.accountId === "string" ? entry.accountId.trim() || "default" : "default", + approvedAt: + typeof entry.approvedAt === "number" && Number.isFinite(entry.approvedAt) + ? Math.floor(entry.approvedAt) + : Date.now(), + })) + .filter((entry) => entry.pluginRoot && entry.pluginId && entry.channel), + }; + } catch (error) { + log.warn(`plugin binding approvals load failed: ${String(error)}`); + return { version: 1, approvals: [] }; + } +} + +async function saveApprovals(file: PluginBindingApprovalsFile): Promise { + const filePath = resolveApprovalsPath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + approvalsCache = file; + approvalsLoaded = true; + await writeJsonAtomic(filePath, file, { + mode: 0o600, + trailingNewline: true, + }); +} + +function getApprovals(): PluginBindingApprovalsFile { + if (!approvalsLoaded || !approvalsCache) { + approvalsCache = loadApprovalsFromDisk(); + approvalsLoaded = true; + } + return approvalsCache; +} + +function hasPersistentApproval(params: { + pluginRoot: string; + channel: string; + accountId: string; +}): boolean { + const key = buildApprovalScopeKey(params); + return getApprovals().approvals.some( + (entry) => + buildApprovalScopeKey({ + pluginRoot: entry.pluginRoot, + channel: entry.channel, + accountId: entry.accountId, + }) === key, + ); +} + +async function addPersistentApproval(entry: PluginBindingApprovalEntry): Promise { + const file = getApprovals(); + const key = buildApprovalScopeKey(entry); + const approvals = file.approvals.filter( + (existing) => + buildApprovalScopeKey({ + pluginRoot: existing.pluginRoot, + channel: existing.channel, + accountId: existing.accountId, + }) !== key, + ); + approvals.push(entry); + await saveApprovals({ + version: 1, + approvals, + }); +} + +function buildBindingMetadata(params: { + pluginId: string; + pluginName?: string; + pluginRoot: string; + summary?: string; + detachHint?: string; +}): PluginBindingMetadata { + return { + pluginBindingOwner: PLUGIN_BINDING_OWNER, + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + summary: params.summary?.trim() || undefined, + detachHint: params.detachHint?.trim() || undefined, + }; +} + +export function isPluginOwnedBindingMetadata(metadata: unknown): metadata is PluginBindingMetadata { + if (!metadata || typeof metadata !== "object") { + return false; + } + const record = metadata as Record; + return ( + record.pluginBindingOwner === PLUGIN_BINDING_OWNER && + typeof record.pluginId === "string" && + typeof record.pluginRoot === "string" + ); +} + +export function isPluginOwnedSessionBindingRecord( + record: + | { + metadata?: Record; + } + | null + | undefined, +): boolean { + return isPluginOwnedBindingMetadata(record?.metadata); +} + +export function toPluginConversationBinding( + record: + | { + bindingId: string; + conversation: ConversationRef; + boundAt: number; + metadata?: Record; + } + | null + | undefined, +): PluginConversationBinding | null { + if (!record || !isPluginOwnedBindingMetadata(record.metadata)) { + return null; + } + const metadata = record.metadata; + return { + bindingId: record.bindingId, + pluginId: metadata.pluginId, + pluginName: metadata.pluginName, + pluginRoot: metadata.pluginRoot, + channel: record.conversation.channel, + accountId: record.conversation.accountId, + conversationId: record.conversation.conversationId, + parentConversationId: record.conversation.parentConversationId, + boundAt: record.boundAt, + summary: metadata.summary, + detachHint: metadata.detachHint, + }; +} + +async function bindConversationNow(params: { + identity: PluginBindingIdentity; + conversation: PluginBindingConversation; + summary?: string; + detachHint?: string; +}): Promise { + const ref = toConversationRef(params.conversation); + const targetSessionKey = buildPluginBindingSessionKey({ + pluginId: params.identity.pluginId, + channel: ref.channel, + accountId: ref.accountId, + conversationId: ref.conversationId, + }); + const record = await getSessionBindingService().bind({ + targetSessionKey, + targetKind: "session", + conversation: ref, + placement: "current", + metadata: buildBindingMetadata({ + pluginId: params.identity.pluginId, + pluginName: params.identity.pluginName, + pluginRoot: params.identity.pluginRoot, + summary: params.summary, + detachHint: params.detachHint, + }), + }); + const binding = toPluginConversationBinding(record); + if (!binding) { + throw new Error("plugin binding was created without plugin metadata"); + } + return { + ...binding, + parentConversationId: params.conversation.parentConversationId, + threadId: params.conversation.threadId, + }; +} + +function buildApprovalMessage(request: PendingPluginBindingRequest): string { + const lines = [ + `Plugin bind approval required`, + `Plugin: ${request.pluginName ?? request.pluginId}`, + `Channel: ${request.conversation.channel}`, + `Account: ${request.conversation.accountId}`, + ]; + if (request.summary?.trim()) { + lines.push(`Request: ${request.summary.trim()}`); + } else { + lines.push("Request: Bind this conversation so future plain messages route to the plugin."); + } + lines.push("Choose whether to allow this plugin to bind the current conversation."); + return lines.join("\n"); +} + +function resolvePluginBindingDisplayName(binding: { + pluginId: string; + pluginName?: string; +}): string { + return binding.pluginName?.trim() || binding.pluginId; +} + +function buildDetachHintSuffix(detachHint?: string): string { + const trimmed = detachHint?.trim(); + return trimmed ? ` To detach this conversation, use ${trimmed}.` : ""; +} + +export function buildPluginBindingUnavailableText(binding: PluginConversationBinding): string { + return `The bound plugin ${resolvePluginBindingDisplayName(binding)} is not currently loaded. Routing this message to OpenClaw instead.${buildDetachHintSuffix(binding.detachHint)}`; +} + +export function buildPluginBindingDeclinedText(binding: PluginConversationBinding): string { + return `The bound plugin ${resolvePluginBindingDisplayName(binding)} did not handle this message. This conversation is still bound to that plugin.${buildDetachHintSuffix(binding.detachHint)}`; +} + +export function buildPluginBindingErrorText(binding: PluginConversationBinding): string { + return `The bound plugin ${resolvePluginBindingDisplayName(binding)} hit an error handling this message. This conversation is still bound to that plugin.${buildDetachHintSuffix(binding.detachHint)}`; +} + +export function hasShownPluginBindingFallbackNotice(bindingId: string): boolean { + const normalized = bindingId.trim(); + if (!normalized) { + return false; + } + return getPluginBindingGlobalState().fallbackNoticeBindingIds.has(normalized); +} + +export function markPluginBindingFallbackNoticeShown(bindingId: string): void { + const normalized = bindingId.trim(); + if (!normalized) { + return; + } + getPluginBindingGlobalState().fallbackNoticeBindingIds.add(normalized); +} + +function buildPendingReply(request: PendingPluginBindingRequest): ReplyPayload { + return { + text: buildApprovalMessage(request), + channelData: { + telegram: { + buttons: buildTelegramButtons(request.id), + }, + discord: { + components: buildDiscordButtonRow(request.id), + }, + }, + }; +} + +function encodeCustomIdValue(value: string): string { + return encodeURIComponent(value); +} + +function decodeCustomIdValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function buildPluginBindingApprovalCustomId( + approvalId: string, + decision: PluginBindingApprovalDecision, +): string { + const decisionCode = decision === "allow-once" ? "o" : decision === "allow-always" ? "a" : "d"; + return `${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:${encodeCustomIdValue(approvalId)}:${decisionCode}`; +} + +export function parsePluginBindingApprovalCustomId( + value: string, +): PluginBindingApprovalAction | null { + const trimmed = value.trim(); + if (!trimmed.startsWith(`${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:`)) { + return null; + } + const body = trimmed.slice(`${PLUGIN_BINDING_CUSTOM_ID_PREFIX}:`.length); + const separator = body.lastIndexOf(":"); + if (separator <= 0 || separator === body.length - 1) { + return null; + } + const rawId = body.slice(0, separator).trim(); + const rawDecisionCode = body.slice(separator + 1).trim(); + if (!rawId) { + return null; + } + const rawDecision = + rawDecisionCode === "o" + ? "allow-once" + : rawDecisionCode === "a" + ? "allow-always" + : rawDecisionCode === "d" + ? "deny" + : null; + if (!rawDecision) { + return null; + } + return { + approvalId: decodeCustomIdValue(rawId), + decision: rawDecision, + }; +} + +export async function requestPluginConversationBinding(params: { + pluginId: string; + pluginName?: string; + pluginRoot: string; + conversation: PluginBindingConversation; + requestedBySenderId?: string; + binding: PluginConversationBindingRequestParams | undefined; +}): Promise { + const conversation = normalizeConversation(params.conversation); + const ref = toConversationRef(conversation); + const existing = getSessionBindingService().resolveByConversation(ref); + const existingPluginBinding = toPluginConversationBinding(existing); + const existingLegacyPluginBinding = isLegacyPluginBindingRecord({ + record: existing, + }); + if (existing && !existingPluginBinding) { + if (existingLegacyPluginBinding) { + log.info( + `plugin binding migrating legacy record plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + } else { + return { + status: "error", + message: + "This conversation is already bound by core routing and cannot be claimed by a plugin.", + }; + } + } + if (existingPluginBinding && existingPluginBinding.pluginRoot !== params.pluginRoot) { + return { + status: "error", + message: `This conversation is already bound by plugin "${existingPluginBinding.pluginName ?? existingPluginBinding.pluginId}".`, + }; + } + + if (existingPluginBinding && existingPluginBinding.pluginRoot === params.pluginRoot) { + const rebound = await bindConversationNow({ + identity: { + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + }, + conversation, + summary: params.binding?.summary, + detachHint: params.binding?.detachHint, + }); + log.info( + `plugin binding auto-refresh plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + return { status: "bound", binding: rebound }; + } + + if ( + hasPersistentApproval({ + pluginRoot: params.pluginRoot, + channel: ref.channel, + accountId: ref.accountId, + }) + ) { + const bound = await bindConversationNow({ + identity: { + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + }, + conversation, + summary: params.binding?.summary, + detachHint: params.binding?.detachHint, + }); + log.info( + `plugin binding auto-approved plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + return { status: "bound", binding: bound }; + } + + const request: PendingPluginBindingRequest = { + id: createApprovalRequestId(), + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + conversation, + requestedAt: Date.now(), + requestedBySenderId: params.requestedBySenderId?.trim() || undefined, + summary: params.binding?.summary?.trim() || undefined, + detachHint: params.binding?.detachHint?.trim() || undefined, + }; + pendingRequests.set(request.id, request); + log.info( + `plugin binding requested plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + return { + status: "pending", + approvalId: request.id, + reply: buildPendingReply(request), + }; +} + +export async function getCurrentPluginConversationBinding(params: { + pluginRoot: string; + conversation: PluginBindingConversation; +}): Promise { + const record = getSessionBindingService().resolveByConversation( + toConversationRef(params.conversation), + ); + const binding = toPluginConversationBinding(record); + if (!binding || binding.pluginRoot !== params.pluginRoot) { + return null; + } + return { + ...binding, + parentConversationId: params.conversation.parentConversationId, + threadId: params.conversation.threadId, + }; +} + +export async function detachPluginConversationBinding(params: { + pluginRoot: string; + conversation: PluginBindingConversation; +}): Promise<{ removed: boolean }> { + const ref = toConversationRef(params.conversation); + const record = getSessionBindingService().resolveByConversation(ref); + const binding = toPluginConversationBinding(record); + if (!binding || binding.pluginRoot !== params.pluginRoot) { + return { removed: false }; + } + await getSessionBindingService().unbind({ + bindingId: binding.bindingId, + reason: "plugin-detach", + }); + log.info( + `plugin binding detached plugin=${binding.pluginId} root=${binding.pluginRoot} channel=${binding.channel} account=${binding.accountId} conversation=${binding.conversationId}`, + ); + return { removed: true }; +} + +export async function resolvePluginConversationBindingApproval(params: { + approvalId: string; + decision: PluginBindingApprovalDecision; + senderId?: string; +}): Promise { + const request = pendingRequests.get(params.approvalId); + if (!request) { + return { status: "expired" }; + } + if ( + request.requestedBySenderId && + params.senderId?.trim() && + request.requestedBySenderId !== params.senderId.trim() + ) { + return { status: "expired" }; + } + pendingRequests.delete(params.approvalId); + if (params.decision === "deny") { + log.info( + `plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, + ); + return { status: "denied", request }; + } + if (params.decision === "allow-always") { + await addPersistentApproval({ + pluginRoot: request.pluginRoot, + pluginId: request.pluginId, + pluginName: request.pluginName, + channel: request.conversation.channel, + accountId: request.conversation.accountId, + approvedAt: Date.now(), + }); + } + const binding = await bindConversationNow({ + identity: { + pluginId: request.pluginId, + pluginName: request.pluginName, + pluginRoot: request.pluginRoot, + }, + conversation: request.conversation, + summary: request.summary, + detachHint: request.detachHint, + }); + log.info( + `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, + ); + return { + status: "approved", + binding, + request, + decision: params.decision, + }; +} + +export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string { + if (params.status === "expired") { + return "That plugin bind approval expired. Retry the bind command."; + } + if (params.status === "denied") { + return `Denied plugin bind request for ${params.request.pluginName ?? params.request.pluginId}.`; + } + const summarySuffix = params.request.summary?.trim() ? ` ${params.request.summary.trim()}` : ""; + if (params.decision === "allow-always") { + return `Allowed ${params.request.pluginName ?? params.request.pluginId} to bind this conversation.${summarySuffix}`; + } + return `Allowed ${params.request.pluginName ?? params.request.pluginId} to bind this conversation once.${summarySuffix}`; +} + +export const __testing = { + reset() { + pendingRequests.clear(); + approvalsCache = null; + approvalsLoaded = false; + getPluginBindingGlobalState().fallbackNoticeBindingIds.clear(); + }, +}; diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 8b7076239c2..7954257e714 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -5,6 +5,27 @@ export function createMockPluginRegistry( hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>, ): PluginRegistry { return { + plugins: [ + { + id: "test-plugin", + name: "Test Plugin", + source: "test", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: hooks.length, + configSchema: false, + }, + ], hooks: hooks as never[], typedHooks: hooks.map((h) => ({ pluginId: "test-plugin", diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 4d74267d4ca..cffafd6645d 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -19,6 +19,9 @@ import type { PluginHookBeforePromptBuildEvent, PluginHookBeforePromptBuildResult, PluginHookBeforeCompactionEvent, + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, + PluginHookInboundClaimResult, PluginHookLlmInputEvent, PluginHookLlmOutputEvent, PluginHookBeforeResetEvent, @@ -66,6 +69,9 @@ export type { PluginHookAgentEndEvent, PluginHookBeforeCompactionEvent, PluginHookBeforeResetEvent, + PluginHookInboundClaimContext, + PluginHookInboundClaimEvent, + PluginHookInboundClaimResult, PluginHookAfterCompactionEvent, PluginHookMessageContext, PluginHookMessageReceivedEvent, @@ -108,6 +114,25 @@ export type HookRunnerOptions = { catchErrors?: boolean; }; +export type PluginTargetedInboundClaimOutcome = + | { + status: "handled"; + result: PluginHookInboundClaimResult; + } + | { + status: "missing_plugin"; + } + | { + status: "no_handler"; + } + | { + status: "declined"; + } + | { + status: "error"; + error: string; + }; + /** * Get hooks for a specific hook name, sorted by priority (higher first). */ @@ -120,6 +145,14 @@ function getHooksForName( .toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } +function getHooksForNameAndPlugin( + registry: PluginRegistry, + hookName: K, + pluginId: string, +): PluginHookRegistration[] { + return getHooksForName(registry, hookName).filter((hook) => hook.pluginId === pluginId); +} + /** * Create a hook runner for a specific registry. */ @@ -196,6 +229,12 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp throw new Error(msg, { cause: params.error }); }; + const sanitizeHookError = (error: unknown): string => { + const raw = error instanceof Error ? error.message : String(error); + const firstLine = raw.split("\n")[0]?.trim(); + return firstLine || "unknown error"; + }; + /** * Run a hook that doesn't return a value (fire-and-forget style). * All handlers are executed in parallel for performance. @@ -263,6 +302,123 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return result; } + /** + * Run a sequential claim hook where the first `{ handled: true }` result wins. + */ + async function runClaimingHook( + hookName: K, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { + const hooks = getHooksForName(registry, hookName); + if (hooks.length === 0) { + return undefined; + } + + logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, first-claim wins)`); + + for (const hook of hooks) { + try { + const handlerResult = await ( + hook.handler as (event: unknown, ctx: unknown) => Promise + )(event, ctx); + if (handlerResult?.handled) { + return handlerResult; + } + } catch (err) { + handleHookError({ hookName, pluginId: hook.pluginId, error: err }); + } + } + + return undefined; + } + + async function runClaimingHookForPlugin< + K extends PluginHookName, + TResult extends { handled: boolean }, + >( + hookName: K, + pluginId: string, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { + const hooks = getHooksForNameAndPlugin(registry, hookName, pluginId); + if (hooks.length === 0) { + return undefined; + } + + logger?.debug?.( + `[hooks] running ${hookName} for ${pluginId} (${hooks.length} handlers, targeted)`, + ); + + for (const hook of hooks) { + try { + const handlerResult = await ( + hook.handler as (event: unknown, ctx: unknown) => Promise + )(event, ctx); + if (handlerResult?.handled) { + return handlerResult; + } + } catch (err) { + handleHookError({ hookName, pluginId: hook.pluginId, error: err }); + } + } + + return undefined; + } + + async function runClaimingHookForPluginOutcome< + K extends PluginHookName, + TResult extends { handled: boolean }, + >( + hookName: K, + pluginId: string, + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise< + | { status: "handled"; result: TResult } + | { status: "missing_plugin" } + | { status: "no_handler" } + | { status: "declined" } + | { status: "error"; error: string } + > { + const pluginLoaded = registry.plugins.some( + (plugin) => plugin.id === pluginId && plugin.status === "loaded", + ); + if (!pluginLoaded) { + return { status: "missing_plugin" }; + } + + const hooks = getHooksForNameAndPlugin(registry, hookName, pluginId); + if (hooks.length === 0) { + return { status: "no_handler" }; + } + + logger?.debug?.( + `[hooks] running ${hookName} for ${pluginId} (${hooks.length} handlers, targeted outcome)`, + ); + + let firstError: string | null = null; + for (const hook of hooks) { + try { + const handlerResult = await ( + hook.handler as (event: unknown, ctx: unknown) => Promise + )(event, ctx); + if (handlerResult?.handled) { + return { status: "handled", result: handlerResult }; + } + } catch (err) { + firstError ??= sanitizeHookError(err); + handleHookError({ hookName, pluginId: hook.pluginId, error: err }); + } + } + + if (firstError) { + return { status: "error", error: firstError }; + } + return { status: "declined" }; + } + // ========================================================================= // Agent Hooks // ========================================================================= @@ -384,6 +540,47 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp // Message Hooks // ========================================================================= + /** + * Run inbound_claim hook. + * Allows plugins to claim an inbound event before commands/agent dispatch. + */ + async function runInboundClaim( + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ): Promise { + return runClaimingHook<"inbound_claim", PluginHookInboundClaimResult>( + "inbound_claim", + event, + ctx, + ); + } + + async function runInboundClaimForPlugin( + pluginId: string, + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ): Promise { + return runClaimingHookForPlugin<"inbound_claim", PluginHookInboundClaimResult>( + "inbound_claim", + pluginId, + event, + ctx, + ); + } + + async function runInboundClaimForPluginOutcome( + pluginId: string, + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ): Promise { + return runClaimingHookForPluginOutcome<"inbound_claim", PluginHookInboundClaimResult>( + "inbound_claim", + pluginId, + event, + ctx, + ); + } + /** * Run message_received hook. * Runs in parallel (fire-and-forget). @@ -734,6 +931,9 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp runAfterCompaction, runBeforeReset, // Message hooks + runInboundClaim, + runInboundClaimForPlugin, + runInboundClaimForPluginOutcome, runMessageReceived, runMessageSending, runMessageSent, diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts new file mode 100644 index 00000000000..f794cde4037 --- /dev/null +++ b/src/plugins/interactive.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginInteractiveHandlers, + dispatchPluginInteractiveHandler, + registerPluginInteractiveHandler, +} from "./interactive.js"; + +describe("plugin interactive handlers", () => { + beforeEach(() => { + clearPluginInteractiveHandlers(); + }); + + it("routes Telegram callbacks by namespace and dedupes callback ids", async () => { + const handler = vi.fn(async () => ({ handled: true })); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + const baseParams = { + channel: "telegram" as const, + data: "codex:resume:thread-1", + callbackId: "cb-1", + ctx: { + accountId: "default", + callbackId: "cb-1", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: 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: "telegram", + conversationId: "-10099:topic:77", + callback: expect.objectContaining({ + namespace: "codex", + payload: "resume:thread-1", + chatId: "-10099", + messageId: 55, + }), + }), + ); + }); + + it("rejects duplicate namespace registrations", () => { + const first = registerPluginInteractiveHandler("plugin-a", { + channel: "telegram", + namespace: "codex", + handler: async () => ({ handled: true }), + }); + const second = registerPluginInteractiveHandler("plugin-b", { + channel: "telegram", + namespace: "codex", + handler: async () => ({ handled: true }), + }); + + expect(first).toEqual({ ok: true }); + expect(second).toEqual({ + ok: false, + 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"], + }), + }), + ); + }); + + it("does not consume dedupe keys when a handler throws", async () => { + const handler = vi + .fn(async () => ({ handled: true })) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ handled: true }); + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: "telegram", + namespace: "codex", + handler, + }), + ).toEqual({ ok: true }); + + const baseParams = { + channel: "telegram" as const, + data: "codex:resume:thread-1", + callbackId: "cb-throw", + ctx: { + accountId: "default", + callbackId: "cb-throw", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + senderId: "user-1", + senderUsername: "ada", + threadId: 77, + isGroup: true, + isForum: true, + auth: { isAuthorizedSender: true }, + callbackMessage: { + messageId: 55, + chatId: "-10099", + messageText: "Pick a thread", + }, + }, + respond: { + reply: vi.fn(async () => {}), + editMessage: vi.fn(async () => {}), + editButtons: vi.fn(async () => {}), + clearButtons: vi.fn(async () => {}), + deleteMessage: vi.fn(async () => {}), + }, + }; + + await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom"); + await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + expect(handler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts new file mode 100644 index 00000000000..66d79fd71ec --- /dev/null +++ b/src/plugins/interactive.ts @@ -0,0 +1,366 @@ +import { createDedupeCache } from "../infra/dedupe.js"; +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; +import type { + PluginInteractiveDiscordHandlerContext, + PluginInteractiveButtons, + PluginInteractiveDiscordHandlerRegistration, + PluginInteractiveHandlerRegistration, + PluginInteractiveTelegramHandlerRegistration, + PluginInteractiveTelegramHandlerContext, +} from "./types.js"; + +type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type InteractiveRegistrationResult = { + ok: boolean; + error?: string; +}; + +type InteractiveDispatchResult = + | { matched: false; handled: false; duplicate: false } + | { matched: true; handled: boolean; duplicate: boolean }; + +type TelegramInteractiveDispatchContext = Omit< + PluginInteractiveTelegramHandlerContext, + | "callback" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; +}; + +type DiscordInteractiveDispatchContext = Omit< + PluginInteractiveDiscordHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + PluginInteractiveDiscordHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +const interactiveHandlers = new Map(); +const callbackDedupe = createDedupeCache({ + ttlMs: 5 * 60_000, + maxSize: 4096, +}); + +function toRegistryKey(channel: string, namespace: string): string { + return `${channel.trim().toLowerCase()}:${namespace.trim()}`; +} + +function normalizeNamespace(namespace: string): string { + return namespace.trim(); +} + +function validateNamespace(namespace: string): string | null { + if (!namespace.trim()) { + return "Interactive handler namespace cannot be empty"; + } + if (!/^[A-Za-z0-9._-]+$/.test(namespace.trim())) { + return "Interactive handler namespace must contain only letters, numbers, dots, underscores, and hyphens"; + } + return null; +} + +function resolveNamespaceMatch( + channel: string, + data: string, +): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null { + const trimmedData = data.trim(); + if (!trimmedData) { + return null; + } + + const separatorIndex = trimmedData.indexOf(":"); + const namespace = + separatorIndex >= 0 ? trimmedData.slice(0, separatorIndex) : normalizeNamespace(trimmedData); + const registration = interactiveHandlers.get(toRegistryKey(channel, namespace)); + if (!registration) { + return null; + } + + return { + registration, + namespace, + payload: separatorIndex >= 0 ? trimmedData.slice(separatorIndex + 1) : "", + }; +} + +export function registerPluginInteractiveHandler( + pluginId: string, + registration: PluginInteractiveHandlerRegistration, + opts?: { pluginName?: string; pluginRoot?: string }, +): InteractiveRegistrationResult { + const namespace = normalizeNamespace(registration.namespace); + const validationError = validateNamespace(namespace); + if (validationError) { + return { ok: false, error: validationError }; + } + const key = toRegistryKey(registration.channel, namespace); + const existing = interactiveHandlers.get(key); + if (existing) { + return { + ok: false, + error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`, + }; + } + if (registration.channel === "telegram") { + interactiveHandlers.set(key, { + ...registration, + namespace, + channel: "telegram", + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); + } else { + interactiveHandlers.set(key, { + ...registration, + namespace, + channel: "discord", + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); + } + return { ok: true }; +} + +export function clearPluginInteractiveHandlers(): void { + interactiveHandlers.clear(); + callbackDedupe.clear(); +} + +export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void { + for (const [key, value] of interactiveHandlers.entries()) { + if (value.pluginId === pluginId) { + interactiveHandlers.delete(key); + } + } +} + +export async function dispatchPluginInteractiveHandler(params: { + channel: "telegram"; + data: string; + callbackId: string; + ctx: TelegramInteractiveDispatchContext; + respond: { + reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + }; +}): Promise; +export async function dispatchPluginInteractiveHandler(params: { + channel: "discord"; + data: string; + interactionId: string; + ctx: DiscordInteractiveDispatchContext; + respond: PluginInteractiveDiscordHandlerContext["respond"]; +}): Promise; +export async function dispatchPluginInteractiveHandler(params: { + channel: "telegram" | "discord"; + data: string; + callbackId?: string; + interactionId?: string; + ctx: TelegramInteractiveDispatchContext | DiscordInteractiveDispatchContext; + 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 }; + } + + const dedupeKey = + params.channel === "telegram" ? params.callbackId?.trim() : params.interactionId?.trim(); + if (dedupeKey && callbackDedupe.peek(dedupeKey)) { + return { matched: true, handled: true, duplicate: true }; + } + + let result: + | ReturnType + | ReturnType; + if (params.channel === "telegram") { + const pluginRoot = match.registration.pluginRoot; + const { callbackMessage, ...handlerContext } = params.ctx as TelegramInteractiveDispatchContext; + 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"], + requestConversationBinding: async (bindingParams) => { + if (!pluginRoot) { + return { + status: "error", + message: "This interaction cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: match.registration.pluginId, + pluginName: match.registration.pluginName, + pluginRoot, + requestedBySenderId: handlerContext.senderId, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + binding: bindingParams, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }); + }, + }); + } else { + const pluginRoot = match.registration.pluginRoot; + result = ( + match.registration as RegisteredInteractiveHandler & + PluginInteractiveDiscordHandlerRegistration + ).handler({ + ...(params.ctx as DiscordInteractiveDispatchContext), + channel: "discord", + interaction: { + ...(params.ctx as DiscordInteractiveDispatchContext).interaction, + data: params.data, + namespace: match.namespace, + payload: match.payload, + }, + respond: params.respond as PluginInteractiveDiscordHandlerContext["respond"], + requestConversationBinding: async (bindingParams) => { + if (!pluginRoot) { + return { + status: "error", + message: "This interaction cannot bind the current conversation.", + }; + } + const handlerContext = params.ctx as DiscordInteractiveDispatchContext; + return requestPluginConversationBinding({ + pluginId: match.registration.pluginId, + pluginName: match.registration.pluginName, + pluginRoot, + requestedBySenderId: handlerContext.senderId, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + binding: bindingParams, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + const handlerContext = params.ctx as DiscordInteractiveDispatchContext; + return detachPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + const handlerContext = params.ctx as DiscordInteractiveDispatchContext; + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation: { + channel: "discord", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + }, + }); + }, + }); + } + const resolved = await result; + if (dedupeKey) { + callbackDedupe.check(dedupeKey); + } + + return { + matched: true, + handled: resolved?.handled ?? true, + duplicate: false, + }; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 20d5772d3f7..1549835d60a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -19,6 +19,7 @@ import { } from "./config-state.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; +import { clearPluginInteractiveHandlers } from "./interactive.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { isPathInside, safeStatSync } from "./path-safety.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; @@ -317,6 +318,7 @@ function createPluginRecord(params: { description?: string; version?: string; source: string; + rootDir?: string; origin: PluginRecord["origin"]; workspaceDir?: string; enabled: boolean; @@ -328,6 +330,7 @@ function createPluginRecord(params: { description: params.description, version: params.version, source: params.source, + rootDir: params.rootDir, origin: params.origin, workspaceDir: params.workspaceDir, enabled: params.enabled, @@ -653,6 +656,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // Clear previously registered plugin commands before reloading clearPluginCommands(); + clearPluginInteractiveHandlers(); // Lazily initialize the runtime so startup paths that discover/skip plugins do // not eagerly load every channel runtime dependency. @@ -782,6 +786,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi description: manifestRecord.description, version: manifestRecord.version, source: candidate.source, + rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: false, @@ -806,6 +811,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi description: manifestRecord.description, version: manifestRecord.version, source: candidate.source, + rootDir: candidate.rootDir, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: enableState.enabled, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index fe978d6a346..8d1e5f92eb0 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -13,6 +13,7 @@ import { resolveUserPath } from "../utils.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; +import { registerPluginInteractiveHandler } from "./interactive.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -47,17 +48,21 @@ import type { export type PluginToolRegistration = { pluginId: string; + pluginName?: string; factory: OpenClawPluginToolFactory; names: string[]; optional: boolean; source: string; + rootDir?: string; }; export type PluginCliRegistration = { pluginId: string; + pluginName?: string; register: OpenClawPluginCliRegistrar; commands: string[]; source: string; + rootDir?: string; }; export type PluginHttpRouteRegistration = { @@ -71,15 +76,19 @@ export type PluginHttpRouteRegistration = { export type PluginChannelRegistration = { pluginId: string; + pluginName?: string; plugin: ChannelPlugin; dock?: ChannelDock; source: string; + rootDir?: string; }; export type PluginProviderRegistration = { pluginId: string; + pluginName?: string; provider: ProviderPlugin; source: string; + rootDir?: string; }; export type PluginHookRegistration = { @@ -87,18 +96,23 @@ export type PluginHookRegistration = { entry: HookEntry; events: string[]; source: string; + rootDir?: string; }; export type PluginServiceRegistration = { pluginId: string; + pluginName?: string; service: OpenClawPluginService; source: string; + rootDir?: string; }; export type PluginCommandRegistration = { pluginId: string; + pluginName?: string; command: OpenClawPluginCommandDefinition; source: string; + rootDir?: string; }; export type PluginRecord = { @@ -108,6 +122,7 @@ export type PluginRecord = { description?: string; kind?: PluginKind; source: string; + rootDir?: string; origin: PluginOrigin; workspaceDir?: string; enabled: boolean; @@ -212,10 +227,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } registry.tools.push({ pluginId: record.id, + pluginName: record.name, factory, names: normalized, optional, source: record.source, + rootDir: record.rootDir, }); }; @@ -443,9 +460,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.channelIds.push(id); registry.channels.push({ pluginId: record.id, + pluginName: record.name, plugin, dock: normalized.dock, source: record.source, + rootDir: record.rootDir, }); }; @@ -473,8 +492,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.providerIds.push(id); registry.providers.push({ pluginId: record.id, + pluginName: record.name, provider: normalizedProvider, source: record.source, + rootDir: record.rootDir, }); }; @@ -509,9 +530,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.cliCommands.push(...commands); registry.cliRegistrars.push({ pluginId: record.id, + pluginName: record.name, register: registrar, commands, source: record.source, + rootDir: record.rootDir, }); }; @@ -533,8 +556,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.services.push(id); registry.services.push({ pluginId: record.id, + pluginName: record.name, service, source: record.source, + rootDir: record.rootDir, }); }; @@ -551,7 +576,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } // Register with the plugin command system (validates name and checks for duplicates) - const result = registerPluginCommand(record.id, command); + const result = registerPluginCommand(record.id, command, { + pluginName: record.name, + pluginRoot: record.rootDir, + }); if (!result.ok) { pushDiagnostic({ level: "error", @@ -565,8 +593,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.commands.push(name); registry.commands.push({ pluginId: record.id, + pluginName: record.name, command, source: record.source, + rootDir: record.rootDir, }); }; @@ -640,6 +670,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { version: record.version, description: record.description, source: record.source, + rootDir: record.rootDir, config: params.config, pluginConfig: params.pluginConfig, runtime: registryParams.runtime, @@ -653,6 +684,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), + registerInteractiveHandler: (registration) => { + const result = registerPluginInteractiveHandler(record.id, registration, { + pluginName: record.name, + pluginRoot: record.rootDir, + }); + if (!result.ok) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: result.error ?? "interactive handler registration failed", + }); + } + }, registerCommand: (command) => registerCommand(record, command), registerContextEngine: (id, factory) => { if (id === defaultSlotIdForKey("contextEngine")) { diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 53a8f0ca936..94ea9a0b8cb 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"; @@ -29,7 +40,17 @@ import { } from "../../../extensions/telegram/src/audit.js"; import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js"; import { probeTelegram } from "../../../extensions/telegram/src/probe.js"; -import { sendMessageTelegram, sendPollTelegram } from "../../../extensions/telegram/src/send.js"; +import { + deleteMessageTelegram, + editMessageReplyMarkupTelegram, + editMessageTelegram, + pinMessageTelegram, + renameForumTopicTelegram, + sendMessageTelegram, + sendPollTelegram, + sendTypingTelegram, + unpinMessageTelegram, +} from "../../../extensions/telegram/src/send.js"; import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { handleSlackAction } from "../../agents/tools/slack-actions.js"; @@ -113,6 +134,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"; @@ -207,9 +230,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, @@ -230,6 +277,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { sendPollTelegram, monitorTelegramProvider, messageActions: telegramMessageActions, + typing: { + pulse: sendTypingTelegram, + start: async ({ to, accountId, cfg, intervalMs, messageThreadId }) => + await createTelegramTypingLease({ + to, + accountId, + cfg, + intervalMs, + messageThreadId, + pulse: async ({ to, accountId, cfg, messageThreadId }) => + await sendTypingTelegram(to, { + accountId, + cfg, + messageThreadId, + }), + }), + }, + conversationActions: { + editMessage: editMessageTelegram, + editReplyMarkup: editMessageReplyMarkupTelegram, + clearReplyMarkup: async (chatIdInput, messageIdInput, opts = {}) => + await editMessageReplyMarkupTelegram(chatIdInput, messageIdInput, [], opts), + deleteMessage: deleteMessageTelegram, + renameTopic: renameForumTopicTelegram, + pinMessage: pinMessageTelegram, + unpinMessage: unpinMessageTelegram, + }, }, signal: { probeSignal, 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..1eb5b6fd315 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-typing.test.ts @@ -0,0 +1,57 @@ +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(); + }); + + it("swallows background pulse failures", async () => { + vi.useFakeTimers(); + const pulse = vi + .fn<(params: { channelId: string; accountId?: string; cfg?: unknown }) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("boom")); + + const lease = await createDiscordTypingLease({ + channelId: "123", + intervalMs: 2_000, + pulse, + }); + + await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); + expect(pulse).toHaveBeenCalledTimes(2); + + lease.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..e5bed40e987 --- /dev/null +++ b/src/plugins/runtime/runtime-discord-typing.ts @@ -0,0 +1,62 @@ +import { logWarn } from "../../logger.js"; + +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(() => { + // Background lease refreshes must never escape as unhandled rejections. + void pulse().catch((err) => { + logWarn(`plugins: discord typing pulse failed: ${String(err)}`); + }); + }, intervalMs); + timer.unref?.(); + + return { + refresh: async () => { + await pulse(); + }, + stop: () => { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; +} diff --git a/src/plugins/runtime/runtime-telegram-typing.test.ts b/src/plugins/runtime/runtime-telegram-typing.test.ts new file mode 100644 index 00000000000..3394aa1cf50 --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-typing.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; + +describe("createTelegramTypingLease", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("pulses immediately and keeps leases independent", async () => { + vi.useFakeTimers(); + const pulse = vi.fn(async () => undefined); + + const leaseA = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }); + const leaseB = await createTelegramTypingLease({ + to: "telegram: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(); + }); + + it("swallows background pulse failures", async () => { + vi.useFakeTimers(); + const pulse = vi + .fn< + (params: { + to: string; + accountId?: string; + cfg?: unknown; + messageThreadId?: number; + }) => Promise + >() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("boom")); + + const lease = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: 2_000, + pulse, + }); + + await expect(vi.advanceTimersByTimeAsync(2_000)).resolves.toBe(vi); + expect(pulse).toHaveBeenCalledTimes(2); + + lease.stop(); + }); + + it("falls back to the default interval for non-finite values", async () => { + vi.useFakeTimers(); + const pulse = vi.fn(async () => undefined); + + const lease = await createTelegramTypingLease({ + to: "telegram:123", + intervalMs: Number.NaN, + pulse, + }); + + expect(pulse).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3_999); + expect(pulse).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(pulse).toHaveBeenCalledTimes(2); + + lease.stop(); + }); +}); diff --git a/src/plugins/runtime/runtime-telegram-typing.ts b/src/plugins/runtime/runtime-telegram-typing.ts new file mode 100644 index 00000000000..3a10d5f38d1 --- /dev/null +++ b/src/plugins/runtime/runtime-telegram-typing.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { logWarn } from "../../logger.js"; + +export type CreateTelegramTypingLeaseParams = { + to: string; + accountId?: string; + cfg?: OpenClawConfig; + intervalMs?: number; + messageThreadId?: number; + pulse: (params: { + to: string; + accountId?: string; + cfg?: OpenClawConfig; + messageThreadId?: number; + }) => Promise; +}; + +export async function createTelegramTypingLease(params: CreateTelegramTypingLeaseParams): Promise<{ + refresh: () => Promise; + stop: () => void; +}> { + const intervalMs = + typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs) + ? Math.max(1_000, Math.floor(params.intervalMs)) + : 4_000; + let stopped = false; + + const refresh = async () => { + if (stopped) { + return; + } + await params.pulse({ + to: params.to, + accountId: params.accountId, + cfg: params.cfg, + messageThreadId: params.messageThreadId, + }); + }; + + await refresh(); + + const timer = setInterval(() => { + // Background lease refreshes must never escape as unhandled rejections. + void refresh().catch((err) => { + logWarn(`plugins: telegram typing pulse failed: ${String(err)}`); + }); + }, intervalMs); + timer.unref?.(); + + return { + refresh, + stop: () => { + if (stopped) { + return; + } + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index bf2f2387d46..f2e775b7275 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -94,9 +94,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; @@ -117,6 +138,39 @@ export type PluginRuntimeChannel = { sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram; monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider; messageActions: typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions; + typing: { + pulse: typeof import("../../../extensions/telegram/src/send.js").sendTypingTelegram; + start: (params: { + to: string; + accountId?: string; + cfg?: ReturnType; + intervalMs?: number; + messageThreadId?: number; + }) => Promise<{ + refresh: () => Promise; + stop: () => void; + }>; + }; + conversationActions: { + editMessage: typeof import("../../../extensions/telegram/src/send.js").editMessageTelegram; + editReplyMarkup: typeof import("../../../extensions/telegram/src/send.js").editMessageReplyMarkupTelegram; + clearReplyMarkup: ( + chatIdInput: string | number, + messageIdInput: string | number, + opts?: { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Partial; + retry?: import("../../infra/retry.js").RetryConfig; + cfg?: ReturnType; + }, + ) => Promise<{ ok: true; messageId: string; chatId: string }>; + deleteMessage: typeof import("../../../extensions/telegram/src/send.js").deleteMessageTelegram; + renameTopic: typeof import("../../../extensions/telegram/src/send.js").renameForumTopicTelegram; + pinMessage: typeof import("../../../extensions/telegram/src/send.js").pinMessageTelegram; + unpinMessage: typeof import("../../../extensions/telegram/src/send.js").unpinMessageTelegram; + }; }; signal: { probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index f508396362d..3c853041ae9 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -19,7 +19,12 @@ import { startPluginServices } from "./services.js"; function createRegistry(services: OpenClawPluginService[]) { const registry = createEmptyPluginRegistry(); for (const service of services) { - registry.services.push({ pluginId: "plugin:test", service, source: "test" }); + registry.services.push({ + pluginId: "plugin:test", + service, + source: "test", + rootDir: "/plugins/test-plugin", + }); } return registry; } @@ -116,7 +121,9 @@ describe("startPluginServices", () => { await handle.stop(); expect(mockedLogger.error).toHaveBeenCalledWith( - expect.stringContaining("plugin service failed (service-start-fail):"), + expect.stringContaining( + "plugin service failed (service-start-fail, plugin=plugin:test, root=/plugins/test-plugin):", + ), ); expect(mockedLogger.warn).toHaveBeenCalledWith( expect.stringContaining("plugin service stop failed (service-stop-fail):"), diff --git a/src/plugins/services.ts b/src/plugins/services.ts index 751df4f8740..07746e1650a 100644 --- a/src/plugins/services.ts +++ b/src/plugins/services.ts @@ -54,7 +54,11 @@ export async function startPluginServices(params: { stop: service.stop ? () => service.stop?.(serviceContext) : undefined, }); } catch (err) { - log.error(`plugin service failed (${service.id}): ${String(err)}`); + const error = err as Error; + const stack = error?.stack?.trim(); + log.error( + `plugin service failed (${service.id}, plugin=${entry.pluginId}, root=${entry.rootDir ?? "unknown"}): ${error?.message ?? String(err)}${stack ? `\n${stack}` : ""}`, + ); } } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 404974f4fc1..19542b44c2d 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 { StreamFn } from "@mariozechner/pi-agent-core"; import type { Api, Model } from "@mariozechner/pi-ai"; @@ -511,8 +512,48 @@ export type PluginCommandContext = { accountId?: string; /** Thread/topic id if available */ messageThreadId?: number; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; }; +export type PluginConversationBindingRequestParams = { + summary?: string; + detachHint?: string; +}; + +export type PluginConversationBinding = { + bindingId: string; + pluginId: string; + pluginName?: string; + pluginRoot: string; + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + boundAt: number; + summary?: string; + detachHint?: string; +}; + +export type PluginConversationBindingRequestResult = + | { + status: "bound"; + binding: PluginConversationBinding; + } + | { + status: "pending"; + approvalId: string; + reply: ReplyPayload; + } + | { + status: "error"; + message: string; + }; + /** * Result returned by a plugin command handler. */ @@ -547,6 +588,111 @@ export type OpenClawPluginCommandDefinition = { handler: PluginCommandHandler; }; +export type PluginInteractiveChannel = "telegram" | "discord"; + +export type PluginInteractiveButtons = Array< + Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> +>; + +export type PluginInteractiveTelegramHandlerResult = { + handled?: boolean; +} | void; + +export type PluginInteractiveTelegramHandlerContext = { + channel: "telegram"; + accountId: string; + callbackId: string; + conversationId: string; + parentConversationId?: string; + senderId?: string; + senderUsername?: string; + threadId?: number; + isGroup: boolean; + isForum: boolean; + auth: { + isAuthorizedSender: boolean; + }; + callback: { + data: string; + namespace: string; + payload: string; + messageId: number; + chatId: string; + messageText?: string; + }; + respond: { + reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; + editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; +}; + +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; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => 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"; @@ -611,6 +757,7 @@ export type OpenClawPluginApi = { version?: string; description?: string; source: string; + rootDir?: string; config: OpenClawConfig; pluginConfig?: Record; runtime: PluginRuntime; @@ -630,6 +777,7 @@ export type OpenClawPluginApi = { registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** * Register a custom command that bypasses the LLM agent. * Plugin commands are processed before built-in commands and before agent invocation. @@ -673,6 +821,7 @@ export type PluginHookName = | "before_compaction" | "after_compaction" | "before_reset" + | "inbound_claim" | "message_received" | "message_sending" | "message_sent" @@ -699,6 +848,7 @@ export const PLUGIN_HOOK_NAMES = [ "before_compaction", "after_compaction", "before_reset", + "inbound_claim", "message_received", "message_sending", "message_sent", @@ -907,6 +1057,37 @@ export type PluginHookMessageContext = { conversationId?: string; }; +export type PluginHookInboundClaimContext = PluginHookMessageContext & { + parentConversationId?: string; + senderId?: string; + messageId?: string; +}; + +export type PluginHookInboundClaimEvent = { + content: string; + body?: string; + bodyForAgent?: string; + transcript?: string; + timestamp?: number; + channel: string; + accountId?: string; + conversationId?: string; + parentConversationId?: string; + senderId?: string; + senderName?: string; + senderUsername?: string; + threadId?: string | number; + messageId?: string; + isGroup: boolean; + commandAuthorized?: boolean; + wasMentioned?: boolean; + metadata?: Record; +}; + +export type PluginHookInboundClaimResult = { + handled: boolean; +}; + // message_received hook export type PluginHookMessageReceivedEvent = { from: string; @@ -1163,6 +1344,10 @@ export type PluginHookHandlerMap = { event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext, ) => Promise | void; + inbound_claim: ( + event: PluginHookInboundClaimEvent, + ctx: PluginHookInboundClaimContext, + ) => Promise | PluginHookInboundClaimResult | void; message_received: ( event: PluginHookMessageReceivedEvent, ctx: PluginHookMessageContext, diff --git a/src/plugins/wired-hooks-inbound-claim.test.ts b/src/plugins/wired-hooks-inbound-claim.test.ts new file mode 100644 index 00000000000..2af75392fdb --- /dev/null +++ b/src/plugins/wired-hooks-inbound-claim.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHookRunner } from "./hooks.js"; +import { createMockPluginRegistry } from "./hooks.test-helpers.js"; + +describe("inbound_claim hook runner", () => { + it("stops at the first handler that claims the event", async () => { + const first = vi.fn().mockResolvedValue({ handled: true }); + const second = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([ + { hookName: "inbound_claim", handler: first }, + { hookName: "inbound_claim", handler: second }, + ]); + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaim( + { + content: "who are you", + channel: "telegram", + accountId: "default", + conversationId: "123:topic:77", + isGroup: true, + }, + { + channelId: "telegram", + accountId: "default", + conversationId: "123:topic:77", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + it("continues to the next handler when a higher-priority handler throws", async () => { + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + const failing = vi.fn().mockRejectedValue(new Error("boom")); + const succeeding = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([ + { hookName: "inbound_claim", handler: failing }, + { hookName: "inbound_claim", handler: succeeding }, + ]); + const runner = createHookRunner(registry, { logger }); + + const result = await runner.runInboundClaim( + { + content: "hi", + channel: "telegram", + accountId: "default", + conversationId: "123", + isGroup: false, + }, + { + channelId: "telegram", + accountId: "default", + conversationId: "123", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("inbound_claim handler from test-plugin failed: Error: boom"), + ); + expect(succeeding).toHaveBeenCalledTimes(1); + }); + + it("can target a single plugin when core already owns the binding", async () => { + const first = vi.fn().mockResolvedValue({ handled: true }); + const second = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([ + { hookName: "inbound_claim", handler: first }, + { hookName: "inbound_claim", handler: second }, + ]); + registry.typedHooks[1].pluginId = "other-plugin"; + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaimForPlugin( + "test-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ handled: true }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + it("reports missing_plugin when the bound plugin is not loaded", async () => { + const registry = createMockPluginRegistry([]); + registry.plugins = []; + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaimForPluginOutcome( + "missing-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ status: "missing_plugin" }); + }); + + it("reports no_handler when the plugin is loaded but has no targeted hooks", async () => { + const registry = createMockPluginRegistry([]); + const runner = createHookRunner(registry); + + const result = await runner.runInboundClaimForPluginOutcome( + "test-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ status: "no_handler" }); + }); + + it("reports error when a targeted handler throws and none claim the event", async () => { + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + const failing = vi.fn().mockRejectedValue(new Error("boom")); + const registry = createMockPluginRegistry([{ hookName: "inbound_claim", handler: failing }]); + const runner = createHookRunner(registry, { logger }); + + const result = await runner.runInboundClaimForPluginOutcome( + "test-plugin", + { + content: "who are you", + channel: "discord", + accountId: "default", + conversationId: "channel:1", + isGroup: true, + }, + { + channelId: "discord", + accountId: "default", + conversationId: "channel:1", + }, + ); + + expect(result).toEqual({ status: "error", error: "boom" }); + }); +});