mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 20:40:45 +00:00
* 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>
367 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|