Files
openclaw/src/plugins/interactive.ts
Harold Hunt aa1454d1a8 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>
2026-03-15 16:06:11 -07:00

367 lines
12 KiB
TypeScript

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