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 <vincentkoc@ieee.org>
This commit is contained in:
Harold Hunt
2026-03-15 19:06:11 -04:00
committed by GitHub
parent 4eee827dce
commit aa1454d1a8
53 changed files with 5322 additions and 123 deletions

View File

@@ -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", () => {

View File

@@ -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({

View File

@@ -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> = {}): 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 }));

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<string, unknown> | 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<typeof import("../../../../src/plugins/conversation-binding.js")>();
return {
...actual,
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
resolvePluginConversationBindingApprovalMock(...args),
buildPluginBindingResolvedText: (...args: unknown[]) =>
buildPluginBindingResolvedTextMock(...args),
};
});
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
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", () => {

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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,
};
}

View File

@@ -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<string, unknown>;
};
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
@@ -56,6 +57,7 @@ export type ThreadBindingManager = {
introText?: string;
webhookId?: string;
webhookToken?: string;
metadata?: Record<string, unknown>;
}) => Promise<ThreadBindingRecord | null>;
unbindThread: (params: {
threadId: string;

View File

@@ -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,

View File

@@ -0,0 +1,9 @@
import { Routes } from "discord-api-types/v10";
import { resolveDiscordRest } from "./client.js";
import type { DiscordReactOpts } from "./send.types.js";
export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts = {}) {
const rest = resolveDiscordRest(opts);
await rest.post(Routes.channelTyping(channelId));
return { ok: true, channelId };
}

View File

@@ -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" });

View File

@@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerCli() {},
registerService() {},
registerProvider() {},
registerInteractiveHandler() {},
registerHook() {},
registerHttpRoute() {},
registerCommand() {},

View File

@@ -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,
};
}

View File

@@ -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 }) ||

View File

@@ -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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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();

View File

@@ -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}`,
);
}
}

View File

@@ -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(),
}));

View File

@@ -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 = [
{

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -34,6 +34,7 @@ export type TelegramThreadBindingRecord = {
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
metadata?: Record<string, unknown>;
};
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, {

View File

@@ -14,6 +14,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
registerCli() {},
registerService() {},
registerProvider() {},
registerInteractiveHandler() {},
registerCommand() {},
registerContextEngine() {},
resolvePath(input: string) {

View File

@@ -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<string, unknown> | 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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;

View File

@@ -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<boolean> => {
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) {

View File

@@ -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 }));

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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<unknown>;
getCurrentConversationBinding: () => Promise<unknown>;
detachConversationBinding: () => Promise<unknown>;
}) => {
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 },
}),
);
});
});

View File

@@ -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,
};

View File

@@ -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<string, SessionBindingRecord>();
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<string, unknown>;
}) => {
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",
}),
);
});
});

View File

@@ -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<string, PendingPluginBindingRequest>();
type PluginBindingGlobalState = {
fallbackNoticeBindingIds: Set<string>;
};
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<string>(),
});
}
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<string, unknown>;
}
| 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<PluginBindingApprovalsFile>;
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<void> {
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<void> {
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<string, unknown>;
return (
record.pluginBindingOwner === PLUGIN_BINDING_OWNER &&
typeof record.pluginId === "string" &&
typeof record.pluginRoot === "string"
);
}
export function isPluginOwnedSessionBindingRecord(
record:
| {
metadata?: Record<string, unknown>;
}
| null
| undefined,
): boolean {
return isPluginOwnedBindingMetadata(record?.metadata);
}
export function toPluginConversationBinding(
record:
| {
bindingId: string;
conversation: ConversationRef;
boundAt: number;
metadata?: Record<string, unknown>;
}
| 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<PluginConversationBinding> {
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<PluginConversationBindingRequestResult> {
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<PluginConversationBinding | null> {
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<PluginBindingResolveResult> {
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();
},
};

View File

@@ -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",

View File

@@ -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<K extends PluginHookName>(
.toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
function getHooksForNameAndPlugin<K extends PluginHookName>(
registry: PluginRegistry,
hookName: K,
pluginId: string,
): PluginHookRegistration<K>[] {
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<K extends PluginHookName, TResult extends { handled: boolean }>(
hookName: K,
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
): Promise<TResult | undefined> {
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<TResult | void>
)(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<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
): Promise<TResult | undefined> {
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<TResult | void>
)(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<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["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<TResult | void>
)(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<PluginHookInboundClaimResult | undefined> {
return runClaimingHook<"inbound_claim", PluginHookInboundClaimResult>(
"inbound_claim",
event,
ctx,
);
}
async function runInboundClaimForPlugin(
pluginId: string,
event: PluginHookInboundClaimEvent,
ctx: PluginHookInboundClaimContext,
): Promise<PluginHookInboundClaimResult | undefined> {
return runClaimingHookForPlugin<"inbound_claim", PluginHookInboundClaimResult>(
"inbound_claim",
pluginId,
event,
ctx,
);
}
async function runInboundClaimForPluginOutcome(
pluginId: string,
event: PluginHookInboundClaimEvent,
ctx: PluginHookInboundClaimContext,
): Promise<PluginTargetedInboundClaimOutcome> {
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,

View File

@@ -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);
});
});

366
src/plugins/interactive.ts Normal file
View File

@@ -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<string, RegisteredInteractiveHandler>();
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<void>;
editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise<void>;
editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise<void>;
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
};
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "discord";
data: string;
interactionId: string;
ctx: DiscordInteractiveDispatchContext;
respond: PluginInteractiveDiscordHandlerContext["respond"];
}): Promise<InteractiveDispatchResult>;
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<void>;
editMessage: (params: {
text: string;
buttons?: PluginInteractiveButtons;
}) => Promise<void>;
editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise<void>;
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
}
| PluginInteractiveDiscordHandlerContext["respond"];
}): Promise<InteractiveDispatchResult> {
const match = resolveNamespaceMatch(params.channel, params.data);
if (!match) {
return { matched: false, handled: false, duplicate: false };
}
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<PluginInteractiveTelegramHandlerRegistration["handler"]>
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>;
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,
};
}

View File

@@ -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,

View File

@@ -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")) {

View File

@@ -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,

View File

@@ -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<void>>()
.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();
});
});

View File

@@ -0,0 +1,62 @@
import { logWarn } from "../../logger.js";
export type CreateDiscordTypingLeaseParams = {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
pulse: (params: {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
}) => Promise<void>;
};
const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000;
export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{
refresh: () => Promise<void>;
stop: () => void;
}> {
const intervalMs =
typeof params.intervalMs === "number" && Number.isFinite(params.intervalMs)
? Math.max(1_000, Math.floor(params.intervalMs))
: DEFAULT_DISCORD_TYPING_INTERVAL_MS;
let stopped = false;
let timer: ReturnType<typeof setInterval> | null = null;
const pulse = async () => {
if (stopped) {
return;
}
await params.pulse({
channelId: params.channelId,
accountId: params.accountId,
cfg: params.cfg,
});
};
await pulse();
timer = setInterval(() => {
// 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;
}
},
};
}

View File

@@ -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<unknown>
>()
.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();
});
});

View File

@@ -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<unknown>;
};
export async function createTelegramTypingLease(params: CreateTelegramTypingLeaseParams): Promise<{
refresh: () => Promise<void>;
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);
},
};
}

View File

@@ -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<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
}) => Promise<{
refresh: () => Promise<void>;
stop: () => void;
}>;
};
conversationActions: {
editMessage: typeof import("../../../extensions/discord/src/send.js").editMessageDiscord;
deleteMessage: typeof import("../../../extensions/discord/src/send.js").deleteMessageDiscord;
pinMessage: typeof import("../../../extensions/discord/src/send.js").pinMessageDiscord;
unpinMessage: typeof import("../../../extensions/discord/src/send.js").unpinMessageDiscord;
createThread: typeof import("../../../extensions/discord/src/send.js").createThreadDiscord;
editChannel: typeof import("../../../extensions/discord/src/send.js").editChannelDiscord;
};
};
slack: {
listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive;
@@ -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<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
messageThreadId?: number;
}) => Promise<{
refresh: () => Promise<void>;
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<import("grammy").Bot["api"]>;
retry?: import("../../infra/retry.js").RetryConfig;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
},
) => 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;

View File

@@ -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):"),

View File

@@ -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}` : ""}`,
);
}
}

View File

@@ -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<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
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<void>;
editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise<void>;
editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise<void>;
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
};
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type PluginInteractiveDiscordHandlerResult = {
handled?: boolean;
} | void;
export type PluginInteractiveDiscordHandlerContext = {
channel: "discord";
accountId: string;
interactionId: string;
conversationId: string;
parentConversationId?: string;
guildId?: string;
senderId?: string;
senderUsername?: string;
auth: {
isAuthorizedSender: boolean;
};
interaction: {
kind: "button" | "select" | "modal";
data: string;
namespace: string;
payload: string;
messageId?: string;
values?: string[];
fields?: Array<{ id: string; name: string; values: string[] }>;
};
respond: {
acknowledge: () => Promise<void>;
reply: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
followUp: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
editMessage: (params: { text?: string; components?: TopLevelComponents[] }) => Promise<void>;
clearComponents: (params?: { text?: string }) => Promise<void>;
};
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type PluginInteractiveTelegramHandlerRegistration = {
channel: "telegram";
namespace: string;
handler: (
ctx: PluginInteractiveTelegramHandlerContext,
) => Promise<PluginInteractiveTelegramHandlerResult> | PluginInteractiveTelegramHandlerResult;
};
export type PluginInteractiveDiscordHandlerRegistration = {
channel: "discord";
namespace: string;
handler: (
ctx: PluginInteractiveDiscordHandlerContext,
) => Promise<PluginInteractiveDiscordHandlerResult> | PluginInteractiveDiscordHandlerResult;
};
export type PluginInteractiveHandlerRegistration =
| PluginInteractiveTelegramHandlerRegistration
| PluginInteractiveDiscordHandlerRegistration;
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
@@ -611,6 +757,7 @@ export type OpenClawPluginApi = {
version?: string;
description?: string;
source: string;
rootDir?: string;
config: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
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<string, unknown>;
};
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> | void;
inbound_claim: (
event: PluginHookInboundClaimEvent,
ctx: PluginHookInboundClaimContext,
) => Promise<PluginHookInboundClaimResult | void> | PluginHookInboundClaimResult | void;
message_received: (
event: PluginHookMessageReceivedEvent,
ctx: PluginHookMessageContext,

View File

@@ -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" });
});
});