openclaw/src/plugins/interactive.test.ts

670 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
import * as conversationBinding from "./conversation-binding.js";
import {
clearPluginInteractiveHandlers,
dispatchPluginInteractiveHandler,
registerPluginInteractiveHandler,
} from "./interactive.js";
let requestPluginConversationBindingMock: MockInstance<
typeof conversationBinding.requestPluginConversationBinding
>;
let detachPluginConversationBindingMock: MockInstance<
typeof conversationBinding.detachPluginConversationBinding
>;
let getCurrentPluginConversationBindingMock: MockInstance<
typeof conversationBinding.getCurrentPluginConversationBinding
>;
describe("plugin interactive handlers", () => {
beforeEach(() => {
clearPluginInteractiveHandlers();
requestPluginConversationBindingMock = vi
.spyOn(conversationBinding, "requestPluginConversationBinding")
.mockResolvedValue({
status: "bound",
binding: {
bindingId: "binding-1",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
boundAt: 1,
},
});
detachPluginConversationBindingMock = vi
.spyOn(conversationBinding, "detachPluginConversationBinding")
.mockResolvedValue({ removed: true });
getCurrentPluginConversationBindingMock = vi
.spyOn(conversationBinding, "getCurrentPluginConversationBinding")
.mockResolvedValue({
bindingId: "binding-1",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
boundAt: 1,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
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("routes Slack interactions by namespace and dedupes interaction ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "slack",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
const baseParams = {
channel: "slack" as const,
data: "codex:approve:thread-1",
interactionId: "slack-ix-1",
ctx: {
channel: "slack" as const,
accountId: "default",
interactionId: "slack-ix-1",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
senderId: "U123",
senderUsername: "ada",
auth: { isAuthorizedSender: true },
interaction: {
kind: "button" as const,
actionId: "codex",
blockId: "codex_actions",
messageTs: "1710000000.000200",
threadTs: "1710000000.000100",
value: "approve:thread-1",
selectedValues: ["approve:thread-1"],
selectedLabels: ["Approve"],
triggerId: "trigger-1",
responseUrl: "https://hooks.slack.test/response",
},
},
respond: {
acknowledge: vi.fn(async () => {}),
reply: vi.fn(async () => {}),
followUp: vi.fn(async () => {}),
editMessage: 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: "slack",
conversationId: "C123",
threadId: "1710000000.000100",
interaction: expect.objectContaining({
namespace: "codex",
payload: "approve:thread-1",
actionId: "codex",
messageTs: "1710000000.000200",
}),
}),
);
});
it("wires Telegram conversation binding helpers with topic context", async () => {
const requestResult = {
status: "bound" as const,
binding: {
bindingId: "binding-telegram",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
boundAt: 1,
},
};
const currentBinding = {
...requestResult.binding,
boundAt: 2,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(
ctx.requestConversationBinding({
summary: "Bind this topic",
detachHint: "Use /new to detach",
}),
).resolves.toEqual(requestResult);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
channel: "telegram",
namespace: "codex",
handler,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler({
channel: "telegram",
data: "codex:bind",
callbackId: "cb-bind",
ctx: {
accountId: "default",
callbackId: "cb-bind",
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 () => {}),
},
}),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
},
binding: {
summary: "Bind this topic",
detachHint: "Use /new to detach",
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
},
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: 77,
},
});
});
it("wires Discord conversation binding helpers with parent channel context", async () => {
const requestResult = {
status: "bound" as const,
binding: {
bindingId: "binding-discord",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
boundAt: 1,
},
};
const currentBinding = {
...requestResult.binding,
boundAt: 2,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(ctx.requestConversationBinding({ summary: "Bind Discord" })).resolves.toEqual(
requestResult,
);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
channel: "discord",
namespace: "codex",
handler,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler({
channel: "discord",
data: "codex:bind",
interactionId: "ix-bind",
ctx: {
accountId: "default",
interactionId: "ix-bind",
conversationId: "channel-1",
parentConversationId: "parent-1",
guildId: "guild-1",
senderId: "user-1",
senderUsername: "ada",
auth: { isAuthorizedSender: true },
interaction: {
kind: "button",
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 () => {}),
},
}),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
},
binding: {
summary: "Bind Discord",
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
},
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel-1",
parentConversationId: "parent-1",
},
});
});
it("wires Slack conversation binding helpers with thread context", async () => {
const requestResult = {
status: "bound" as const,
binding: {
bindingId: "binding-slack",
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
boundAt: 1,
},
};
const currentBinding = {
...requestResult.binding,
boundAt: 2,
};
requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);
const handler = vi.fn(async (ctx) => {
await expect(ctx.requestConversationBinding({ summary: "Bind Slack" })).resolves.toEqual(
requestResult,
);
await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
return { handled: true };
});
expect(
registerPluginInteractiveHandler(
"codex-plugin",
{
channel: "slack",
namespace: "codex",
handler,
},
{ pluginName: "Codex", pluginRoot: "/plugins/codex" },
),
).toEqual({ ok: true });
await expect(
dispatchPluginInteractiveHandler({
channel: "slack",
data: "codex:bind",
interactionId: "slack-bind",
ctx: {
accountId: "default",
interactionId: "slack-bind",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
senderId: "user-1",
senderUsername: "ada",
auth: { isAuthorizedSender: true },
interaction: {
kind: "button",
actionId: "codex",
blockId: "codex_actions",
messageTs: "1710000000.000200",
threadTs: "1710000000.000100",
value: "bind",
selectedValues: ["bind"],
selectedLabels: ["Bind"],
triggerId: "trigger-1",
responseUrl: "https://hooks.slack.test/response",
},
},
respond: {
acknowledge: vi.fn(async () => {}),
reply: vi.fn(async () => {}),
followUp: vi.fn(async () => {}),
editMessage: vi.fn(async () => {}),
},
}),
).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
pluginId: "codex-plugin",
pluginName: "Codex",
pluginRoot: "/plugins/codex",
requestedBySenderId: "user-1",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
},
binding: {
summary: "Bind Slack",
},
});
expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
},
});
expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
pluginRoot: "/plugins/codex",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
},
});
});
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);
});
});