Compare commits
46 Commits
main
...
codex/broa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c8a277af9 | ||
|
|
8b725d7879 | ||
|
|
6f5cb51be2 | ||
|
|
2cae1c7670 | ||
|
|
f35e2d1d9f | ||
|
|
d66b53c3b8 | ||
|
|
78008350f0 | ||
|
|
7666c7665a | ||
|
|
eaaaaaf465 | ||
|
|
896d87d4a7 | ||
|
|
72a6751446 | ||
|
|
fe662c54a6 | ||
|
|
35ae37ccc1 | ||
|
|
8ffa9769f7 | ||
|
|
61f8466390 | ||
|
|
8228307a0d | ||
|
|
1bec458950 | ||
|
|
f42deb4b7d | ||
|
|
329d6c8bb6 | ||
|
|
fd7c06264b | ||
|
|
b0e109ca91 | ||
|
|
1ea2e30f46 | ||
|
|
f554b736f5 | ||
|
|
4adddbdab3 | ||
|
|
24850b5cc4 | ||
|
|
7ef75b8779 | ||
|
|
eb4e96573a | ||
|
|
d0731c35b2 | ||
|
|
12bff4ee3f | ||
|
|
691677d0b6 | ||
|
|
38394ab3a8 | ||
|
|
9d55374088 | ||
|
|
7ca7fd0ef9 | ||
|
|
ddfee2a8ff | ||
|
|
e9d95c4309 | ||
|
|
a81dbf109d | ||
|
|
54fead1508 | ||
|
|
0f4775148c | ||
|
|
50df0bb00e | ||
|
|
b934c0be57 | ||
|
|
6fc600b0f6 | ||
|
|
36a323c8af | ||
|
|
94a0da0c08 | ||
|
|
eb0e41e6ff | ||
|
|
2eeb0d10df | ||
|
|
9c79c2c2a7 |
@ -19,11 +19,13 @@ describe("discord components", () => {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: "actions",
|
type: "actions",
|
||||||
buttons: [{ label: "Approve", style: "success" }],
|
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
modal: {
|
modal: {
|
||||||
title: "Details",
|
title: "Details",
|
||||||
|
callbackData: "codex:modal",
|
||||||
|
allowedUsers: ["discord:user-1"],
|
||||||
fields: [{ type: "text", label: "Requester" }],
|
fields: [{ type: "text", label: "Requester" }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -39,6 +41,11 @@ describe("discord components", () => {
|
|||||||
|
|
||||||
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
|
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
|
||||||
expect(trigger?.modalId).toBe(result.modals[0]?.id);
|
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", () => {
|
it("requires options for modal select fields", () => {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export type DiscordComponentButtonSpec = {
|
|||||||
label: string;
|
label: string;
|
||||||
style?: DiscordComponentButtonStyle;
|
style?: DiscordComponentButtonStyle;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
callbackData?: string;
|
||||||
emoji?: {
|
emoji?: {
|
||||||
name: string;
|
name: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -70,10 +71,12 @@ export type DiscordComponentSelectOption = {
|
|||||||
|
|
||||||
export type DiscordComponentSelectSpec = {
|
export type DiscordComponentSelectSpec = {
|
||||||
type?: DiscordComponentSelectType;
|
type?: DiscordComponentSelectType;
|
||||||
|
callbackData?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minValues?: number;
|
minValues?: number;
|
||||||
maxValues?: number;
|
maxValues?: number;
|
||||||
options?: DiscordComponentSelectOption[];
|
options?: DiscordComponentSelectOption[];
|
||||||
|
allowedUsers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordComponentSectionAccessory =
|
export type DiscordComponentSectionAccessory =
|
||||||
@ -136,8 +139,10 @@ export type DiscordModalFieldSpec = {
|
|||||||
|
|
||||||
export type DiscordModalSpec = {
|
export type DiscordModalSpec = {
|
||||||
title: string;
|
title: string;
|
||||||
|
callbackData?: string;
|
||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
triggerStyle?: DiscordComponentButtonStyle;
|
triggerStyle?: DiscordComponentButtonStyle;
|
||||||
|
allowedUsers?: string[];
|
||||||
fields: DiscordModalFieldSpec[];
|
fields: DiscordModalFieldSpec[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -156,6 +161,7 @@ export type DiscordComponentEntry = {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: "button" | "select" | "modal-trigger";
|
kind: "button" | "select" | "modal-trigger";
|
||||||
label: string;
|
label: string;
|
||||||
|
callbackData?: string;
|
||||||
selectType?: DiscordComponentSelectType;
|
selectType?: DiscordComponentSelectType;
|
||||||
options?: Array<{ value: string; label: string }>;
|
options?: Array<{ value: string; label: string }>;
|
||||||
modalId?: string;
|
modalId?: string;
|
||||||
@ -188,6 +194,7 @@ export type DiscordModalFieldDefinition = {
|
|||||||
export type DiscordModalEntry = {
|
export type DiscordModalEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
callbackData?: string;
|
||||||
fields: DiscordModalFieldDefinition[];
|
fields: DiscordModalFieldDefinition[];
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@ -196,6 +203,7 @@ export type DiscordModalEntry = {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
|
allowedUsers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordComponentBuildResult = {
|
export type DiscordComponentBuildResult = {
|
||||||
@ -364,6 +372,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe
|
|||||||
label: readString(obj.label, `${label}.label`),
|
label: readString(obj.label, `${label}.label`),
|
||||||
style,
|
style,
|
||||||
url,
|
url,
|
||||||
|
callbackData: readOptionalString(obj.callbackData),
|
||||||
emoji:
|
emoji:
|
||||||
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
|
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
|
||||||
? {
|
? {
|
||||||
@ -395,10 +404,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
|
callbackData: readOptionalString(obj.callbackData),
|
||||||
placeholder: readOptionalString(obj.placeholder),
|
placeholder: readOptionalString(obj.placeholder),
|
||||||
minValues: readOptionalNumber(obj.minValues),
|
minValues: readOptionalNumber(obj.minValues),
|
||||||
maxValues: readOptionalNumber(obj.maxValues),
|
maxValues: readOptionalNumber(obj.maxValues),
|
||||||
options: parseSelectOptions(obj.options, `${label}.options`),
|
options: parseSelectOptions(obj.options, `${label}.options`),
|
||||||
|
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -578,8 +589,10 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
|
|||||||
);
|
);
|
||||||
modal = {
|
modal = {
|
||||||
title: readString(modalObj.title, "components.modal.title"),
|
title: readString(modalObj.title, "components.modal.title"),
|
||||||
|
callbackData: readOptionalString(modalObj.callbackData),
|
||||||
triggerLabel: readOptionalString(modalObj.triggerLabel),
|
triggerLabel: readOptionalString(modalObj.triggerLabel),
|
||||||
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
|
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
|
||||||
|
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
|
||||||
fields,
|
fields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -718,6 +731,7 @@ function createButtonComponent(params: {
|
|||||||
id: componentId,
|
id: componentId,
|
||||||
kind: params.modalId ? "modal-trigger" : "button",
|
kind: params.modalId ? "modal-trigger" : "button",
|
||||||
label: params.spec.label,
|
label: params.spec.label,
|
||||||
|
callbackData: params.spec.callbackData,
|
||||||
modalId: params.modalId,
|
modalId: params.modalId,
|
||||||
allowedUsers: params.spec.allowedUsers,
|
allowedUsers: params.spec.allowedUsers,
|
||||||
},
|
},
|
||||||
@ -758,8 +772,10 @@ function createSelectComponent(params: {
|
|||||||
id: componentId,
|
id: componentId,
|
||||||
kind: "select",
|
kind: "select",
|
||||||
label: params.spec.placeholder ?? "select",
|
label: params.spec.placeholder ?? "select",
|
||||||
|
callbackData: params.spec.callbackData,
|
||||||
selectType: "string",
|
selectType: "string",
|
||||||
options: options.map((option) => ({ value: option.value, label: option.label })),
|
options: options.map((option) => ({ value: option.value, label: option.label })),
|
||||||
|
allowedUsers: params.spec.allowedUsers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -777,7 +793,9 @@ function createSelectComponent(params: {
|
|||||||
id: componentId,
|
id: componentId,
|
||||||
kind: "select",
|
kind: "select",
|
||||||
label: params.spec.placeholder ?? "user select",
|
label: params.spec.placeholder ?? "user select",
|
||||||
|
callbackData: params.spec.callbackData,
|
||||||
selectType: "user",
|
selectType: "user",
|
||||||
|
allowedUsers: params.spec.allowedUsers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -795,7 +813,9 @@ function createSelectComponent(params: {
|
|||||||
id: componentId,
|
id: componentId,
|
||||||
kind: "select",
|
kind: "select",
|
||||||
label: params.spec.placeholder ?? "role select",
|
label: params.spec.placeholder ?? "role select",
|
||||||
|
callbackData: params.spec.callbackData,
|
||||||
selectType: "role",
|
selectType: "role",
|
||||||
|
allowedUsers: params.spec.allowedUsers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -813,7 +833,9 @@ function createSelectComponent(params: {
|
|||||||
id: componentId,
|
id: componentId,
|
||||||
kind: "select",
|
kind: "select",
|
||||||
label: params.spec.placeholder ?? "mentionable select",
|
label: params.spec.placeholder ?? "mentionable select",
|
||||||
|
callbackData: params.spec.callbackData,
|
||||||
selectType: "mentionable",
|
selectType: "mentionable",
|
||||||
|
allowedUsers: params.spec.allowedUsers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -830,7 +852,9 @@ function createSelectComponent(params: {
|
|||||||
id: componentId,
|
id: componentId,
|
||||||
kind: "select",
|
kind: "select",
|
||||||
label: params.spec.placeholder ?? "channel select",
|
label: params.spec.placeholder ?? "channel select",
|
||||||
|
callbackData: params.spec.callbackData,
|
||||||
selectType: "channel",
|
selectType: "channel",
|
||||||
|
allowedUsers: params.spec.allowedUsers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1047,16 +1071,19 @@ export function buildDiscordComponentMessage(params: {
|
|||||||
modals.push({
|
modals.push({
|
||||||
id: modalId,
|
id: modalId,
|
||||||
title: params.spec.modal.title,
|
title: params.spec.modal.title,
|
||||||
|
callbackData: params.spec.modal.callbackData,
|
||||||
fields,
|
fields,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
reusable: params.spec.reusable,
|
reusable: params.spec.reusable,
|
||||||
|
allowedUsers: params.spec.modal.allowedUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerSpec: DiscordComponentButtonSpec = {
|
const triggerSpec: DiscordComponentButtonSpec = {
|
||||||
label: params.spec.modal.triggerLabel ?? "Open form",
|
label: params.spec.modal.triggerLabel ?? "Open form",
|
||||||
style: params.spec.modal.triggerStyle ?? "primary",
|
style: params.spec.modal.triggerStyle ?? "primary",
|
||||||
|
allowedUsers: params.spec.modal.allowedUsers,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { component, entry } = createButtonComponent({
|
const { component, entry } = createButtonComponent({
|
||||||
|
|||||||
@ -1,74 +1,72 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
|
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
|
||||||
|
import type { OpenClawConfig } from "../../../src/config/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 { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||||
|
|
||||||
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
|
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
|
||||||
return {
|
return {
|
||||||
cfg: {} as DirectoryConfigParams["cfg"],
|
cfg: {
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
token: "test-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig,
|
||||||
|
accountId: "default",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("discord directory live lookups", () => {
|
describe("discord directory live lookups", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.restoreAllMocks();
|
||||||
mocks.resolveDiscordAccount.mockReturnValue({ token: "test-token" });
|
|
||||||
mocks.normalizeDiscordToken.mockImplementation((token: string) => token.trim());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty group directory when token is missing", async () => {
|
it("returns empty group directory when token is missing", async () => {
|
||||||
mocks.normalizeDiscordToken.mockReturnValue("");
|
const rows = await listDiscordDirectoryGroupsLive({
|
||||||
|
...makeParams(),
|
||||||
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "general" }));
|
cfg: { channels: { discord: { token: "" } } } as OpenClawConfig,
|
||||||
|
query: "general",
|
||||||
|
});
|
||||||
|
|
||||||
expect(rows).toEqual([]);
|
expect(rows).toEqual([]);
|
||||||
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty peer directory without query and skips guild listing", async () => {
|
it("returns empty peer directory without query and skips guild listing", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
|
||||||
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " }));
|
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " }));
|
||||||
|
|
||||||
expect(rows).toEqual([]);
|
expect(rows).toEqual([]);
|
||||||
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters group channels by query and respects limit", async () => {
|
it("filters group channels by query and respects limit", async () => {
|
||||||
mocks.fetchDiscord.mockImplementation(async (path: string) => {
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||||
if (path === "/users/@me/guilds") {
|
const url = String(input);
|
||||||
return [
|
if (url.endsWith("/users/@me/guilds")) {
|
||||||
|
return jsonResponse([
|
||||||
{ id: "g1", name: "Guild 1" },
|
{ id: "g1", name: "Guild 1" },
|
||||||
{ id: "g2", name: "Guild 2" },
|
{ id: "g2", name: "Guild 2" },
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
if (path === "/guilds/g1/channels") {
|
if (url.endsWith("/guilds/g1/channels")) {
|
||||||
return [
|
return jsonResponse([
|
||||||
{ id: "c1", name: "general" },
|
{ id: "c1", name: "general" },
|
||||||
{ id: "c2", name: "random" },
|
{ id: "c2", name: "random" },
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
if (path === "/guilds/g2/channels") {
|
if (url.endsWith("/guilds/g2/channels")) {
|
||||||
return [{ id: "c3", name: "announcements" }];
|
return jsonResponse([{ id: "c3", name: "announcements" }]);
|
||||||
}
|
}
|
||||||
return [];
|
return jsonResponse([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "an", limit: 2 }));
|
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 () => {
|
it("returns ranked peer results and caps member search by limit", async () => {
|
||||||
mocks.fetchDiscord.mockImplementation(async (path: string) => {
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||||
if (path === "/users/@me/guilds") {
|
const url = String(input);
|
||||||
return [{ id: "g1", name: "Guild 1" }];
|
if (url.endsWith("/users/@me/guilds")) {
|
||||||
|
return jsonResponse([{ id: "g1", name: "Guild 1" }]);
|
||||||
}
|
}
|
||||||
if (path.startsWith("/guilds/g1/members/search?")) {
|
if (url.includes("/guilds/g1/members/search?")) {
|
||||||
const params = new URLSearchParams(path.split("?")[1] ?? "");
|
const params = new URL(url).searchParams;
|
||||||
expect(params.get("query")).toBe("alice");
|
expect(params.get("query")).toBe("alice");
|
||||||
expect(params.get("limit")).toBe("2");
|
expect(params.get("limit")).toBe("2");
|
||||||
return [
|
return jsonResponse([
|
||||||
{ user: { id: "u1", username: "alice", bot: false }, nick: "Ali" },
|
{ user: { id: "u1", username: "alice", bot: false }, nick: "Ali" },
|
||||||
{ user: { id: "u2", username: "alice-bot", bot: true }, nick: null },
|
{ user: { id: "u2", username: "alice-bot", bot: true }, nick: null },
|
||||||
{ user: { id: "u3", username: "ignored", bot: false }, nick: null },
|
{ user: { id: "u3", username: "ignored", bot: false }, nick: null },
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
return [];
|
return jsonResponse([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 }));
|
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 }));
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
type ModalInteraction,
|
type ModalInteraction,
|
||||||
type RoleSelectMenuInteraction,
|
type RoleSelectMenuInteraction,
|
||||||
type StringSelectMenuInteraction,
|
type StringSelectMenuInteraction,
|
||||||
|
type TopLevelComponents,
|
||||||
type UserSelectMenuInteraction,
|
type UserSelectMenuInteraction,
|
||||||
} from "@buape/carbon";
|
} from "@buape/carbon";
|
||||||
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
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 { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js";
|
||||||
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
||||||
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.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 { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
|
import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
|
||||||
import {
|
import {
|
||||||
@ -771,6 +778,161 @@ function formatModalSubmissionText(
|
|||||||
return lines.join("\n");
|
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;
|
||||||
|
if (resolved.status !== "expired") {
|
||||||
|
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: {
|
function resolveComponentCommandAuthorized(params: {
|
||||||
ctx: AgentComponentContext;
|
ctx: AgentComponentContext;
|
||||||
interactionCtx: ComponentInteractionContext;
|
interactionCtx: ComponentInteractionContext;
|
||||||
@ -1102,6 +1264,17 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
guildEntries: params.ctx.guildEntries,
|
guildEntries: params.ctx.guildEntries,
|
||||||
});
|
});
|
||||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
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 unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
|
||||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||||
interaction: params.interaction,
|
interaction: params.interaction,
|
||||||
@ -1114,7 +1287,7 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: params.componentLabel,
|
componentLabel: params.componentLabel,
|
||||||
unauthorizedReply,
|
unauthorizedReply,
|
||||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!memberAllowed) {
|
if (!memberAllowed) {
|
||||||
return;
|
return;
|
||||||
@ -1127,11 +1300,18 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: params.componentLabel,
|
componentLabel: params.componentLabel,
|
||||||
unauthorizedReply,
|
unauthorizedReply,
|
||||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!componentAllowed) {
|
if (!componentAllowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||||
|
ctx: params.ctx,
|
||||||
|
interactionCtx,
|
||||||
|
channelConfig,
|
||||||
|
guildInfo,
|
||||||
|
allowNameMatching,
|
||||||
|
});
|
||||||
|
|
||||||
const consumed = resolveDiscordComponentEntry({
|
const consumed = resolveDiscordComponentEntry({
|
||||||
id: parsed.componentId,
|
id: parsed.componentId,
|
||||||
@ -1162,6 +1342,33 @@ async function handleDiscordComponentEvent(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
|
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
|
||||||
|
if (consumed.callbackData) {
|
||||||
|
if (!commandAuthorized) {
|
||||||
|
try {
|
||||||
|
await params.interaction.reply({
|
||||||
|
content: unauthorizedReply,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Interaction may have expired
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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({
|
const eventText = formatDiscordComponentEventText({
|
||||||
kind: consumed.kind === "select" ? "select" : "button",
|
kind: consumed.kind === "select" ? "select" : "button",
|
||||||
label: consumed.label,
|
label: consumed.label,
|
||||||
@ -1706,6 +1913,17 @@ class DiscordComponentModal extends Modal {
|
|||||||
guildEntries: this.ctx.guildEntries,
|
guildEntries: this.ctx.guildEntries,
|
||||||
});
|
});
|
||||||
const channelCtx = resolveDiscordChannelContext(interaction);
|
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({
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||||
interaction,
|
interaction,
|
||||||
guildInfo,
|
guildInfo,
|
||||||
@ -1717,12 +1935,37 @@ class DiscordComponentModal extends Modal {
|
|||||||
replyOpts,
|
replyOpts,
|
||||||
componentLabel: "form",
|
componentLabel: "form",
|
||||||
unauthorizedReply: "You are not authorized to use this form.",
|
unauthorizedReply: "You are not authorized to use this form.",
|
||||||
allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig),
|
allowNameMatching,
|
||||||
});
|
});
|
||||||
if (!memberAllowed) {
|
if (!memberAllowed) {
|
||||||
return;
|
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({
|
const consumed = resolveDiscordModalEntry({
|
||||||
id: modalId,
|
id: modalId,
|
||||||
consume: !modalEntry.reusable,
|
consume: !modalEntry.reusable,
|
||||||
@ -1739,6 +1982,28 @@ class DiscordComponentModal extends Modal {
|
|||||||
return;
|
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 {
|
try {
|
||||||
await interaction.acknowledge();
|
await interaction.acknowledge();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -90,6 +90,20 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis
|
|||||||
} as unknown as DiscordClient;
|
} 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: {
|
async function runThreadBoundPreflight(params: {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
parentId: 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: {
|
async function runMentionOnlyBotPreflight(params: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@ -258,6 +291,60 @@ describe("preflightDiscordMessage", () => {
|
|||||||
expect(result).toBeNull();
|
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 () => {
|
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
|
||||||
const threadBinding = createThreadBinding({
|
const threadBinding = createThreadBinding({
|
||||||
targetKind: "session",
|
targetKind: "session",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
|||||||
import { logDebug } from "../../../../src/logger.js";
|
import { logDebug } from "../../../../src/logger.js";
|
||||||
import { getChildLogger } from "../../../../src/logging.js";
|
import { getChildLogger } from "../../../../src/logging.js";
|
||||||
import { buildPairingReply } from "../../../../src/pairing/pairing-messages.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 { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js";
|
||||||
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
@ -350,12 +351,13 @@ export async function preflightDiscordMessage(
|
|||||||
}),
|
}),
|
||||||
parentConversationId: earlyThreadParentId,
|
parentConversationId: earlyThreadParentId,
|
||||||
});
|
});
|
||||||
|
const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId;
|
||||||
let threadBinding: SessionBindingRecord | undefined;
|
let threadBinding: SessionBindingRecord | undefined;
|
||||||
threadBinding =
|
threadBinding =
|
||||||
getSessionBindingService().resolveByConversation({
|
getSessionBindingService().resolveByConversation({
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
conversationId: messageChannelId,
|
conversationId: bindingConversationId,
|
||||||
parentConversationId: earlyThreadParentId,
|
parentConversationId: earlyThreadParentId,
|
||||||
}) ?? undefined;
|
}) ?? undefined;
|
||||||
const configuredRoute =
|
const configuredRoute =
|
||||||
@ -384,7 +386,9 @@ export async function preflightDiscordMessage(
|
|||||||
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
|
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding)
|
||||||
|
? ""
|
||||||
|
: threadBinding?.targetSessionKey?.trim();
|
||||||
const effectiveRoute = resolveDiscordEffectiveRoute({
|
const effectiveRoute = resolveDiscordEffectiveRoute({
|
||||||
route,
|
route,
|
||||||
boundSessionKey,
|
boundSessionKey,
|
||||||
@ -392,7 +396,7 @@ export async function preflightDiscordMessage(
|
|||||||
matchedBy: "binding.channel",
|
matchedBy: "binding.channel",
|
||||||
});
|
});
|
||||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||||
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
|
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||||
if (
|
if (
|
||||||
isBoundThreadBotSystemMessage({
|
isBoundThreadBotSystemMessage({
|
||||||
isBoundThreadSession,
|
isBoundThreadSession,
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import type {
|
|||||||
StringSelectMenuInteraction,
|
StringSelectMenuInteraction,
|
||||||
} from "@buape/carbon";
|
} from "@buape/carbon";
|
||||||
import type { Client } 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 type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||||
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.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 { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
|
||||||
import {
|
import {
|
||||||
clearDiscordComponentEntries,
|
clearDiscordComponentEntries,
|
||||||
@ -52,6 +54,9 @@ const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
|
|||||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveStorePathMock = 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;
|
let lastDispatchCtx: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
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", () => {
|
describe("agent components", () => {
|
||||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||||
|
|
||||||
@ -341,6 +367,38 @@ describe("discord component interactions", () => {
|
|||||||
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
||||||
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
||||||
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
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 () => {
|
it("routes button clicks with reply references", async () => {
|
||||||
@ -499,6 +557,226 @@ describe("discord component interactions", () => {
|
|||||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull();
|
expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks plugin Discord interactions for non-allowlisted guild users", async () => {
|
||||||
|
registerDiscordComponentEntries({
|
||||||
|
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||||
|
modals: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
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).not.toHaveBeenCalled();
|
||||||
|
expect(interaction.reply).toHaveBeenCalledWith({
|
||||||
|
content: "You are not authorized to use this button.",
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps plugin binding approval controls when the approval is already expired", async () => {
|
||||||
|
resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" });
|
||||||
|
buildPluginBindingResolvedTextMock.mockReturnValue(
|
||||||
|
"That plugin bind approval expired. Retry the bind command.",
|
||||||
|
);
|
||||||
|
registerDiscordComponentEntries({
|
||||||
|
entries: [
|
||||||
|
createButtonEntry({
|
||||||
|
callbackData: buildPluginBindingApprovalCustomId("approval-expired", "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).not.toHaveBeenCalled();
|
||||||
|
expect(followUp).toHaveBeenCalledWith({
|
||||||
|
content: "That plugin bind approval expired. Retry the bind command.",
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveDiscordOwnerAllowFrom", () => {
|
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
StringSelectMenu,
|
StringSelectMenu,
|
||||||
TextDisplay,
|
TextDisplay,
|
||||||
|
type TopLevelComponents,
|
||||||
type AutocompleteInteraction,
|
type AutocompleteInteraction,
|
||||||
type ButtonInteraction,
|
type ButtonInteraction,
|
||||||
type CommandInteraction,
|
type CommandInteraction,
|
||||||
@ -274,6 +275,12 @@ function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
|||||||
if (payload.mediaUrls?.some((entry) => entry.trim())) {
|
if (payload.mediaUrls?.some((entry) => entry.trim())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const discordData = payload.channelData?.discord as
|
||||||
|
| { components?: TopLevelComponents[] }
|
||||||
|
| undefined;
|
||||||
|
if (Array.isArray(discordData?.components) && discordData.components.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1772,13 +1779,25 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
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;
|
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 =
|
const payload =
|
||||||
files && files.length > 0
|
files && files.length > 0
|
||||||
? {
|
? {
|
||||||
content,
|
content,
|
||||||
|
...(components ? { components } : {}),
|
||||||
files: files.map((file) => {
|
files: files.map((file) => {
|
||||||
if (file.data instanceof Blob) {
|
if (file.data instanceof Blob) {
|
||||||
return { name: file.name, data: file.data };
|
return { name: file.name, data: file.data };
|
||||||
@ -1787,15 +1806,20 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
return { name: file.name, data: new Blob([arrayBuffer]) };
|
return { name: file.name, data: new Blob([arrayBuffer]) };
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: { content };
|
: {
|
||||||
|
content,
|
||||||
|
...(components ? { components } : {}),
|
||||||
|
};
|
||||||
await safeDiscordInteractionCall("interaction send", async () => {
|
await safeDiscordInteractionCall("interaction send", async () => {
|
||||||
if (!preferFollowUp && !hasReplied) {
|
if (!preferFollowUp && !hasReplied) {
|
||||||
await interaction.reply(payload);
|
await interaction.reply(payload);
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
|
firstMessageComponents = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await interaction.followUp(payload);
|
await interaction.followUp(payload);
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
|
firstMessageComponents = undefined;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1820,7 +1844,7 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
chunks.push(text);
|
chunks.push(text);
|
||||||
}
|
}
|
||||||
const caption = chunks[0] ?? "";
|
const caption = chunks[0] ?? "";
|
||||||
await sendMessage(caption, media);
|
await sendMessage(caption, media, firstMessageComponents);
|
||||||
for (const chunk of chunks.slice(1)) {
|
for (const chunk of chunks.slice(1)) {
|
||||||
if (!chunk.trim()) {
|
if (!chunk.trim()) {
|
||||||
continue;
|
continue;
|
||||||
@ -1830,7 +1854,7 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim() && !firstMessageComponents) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chunks = chunkDiscordTextWithMode(text, {
|
const chunks = chunkDiscordTextWithMode(text, {
|
||||||
@ -1838,13 +1862,13 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
});
|
});
|
||||||
if (!chunks.length && text) {
|
if (!chunks.length && (text || firstMessageComponents)) {
|
||||||
chunks.push(text);
|
chunks.push(text);
|
||||||
}
|
}
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (!chunk.trim()) {
|
if (!chunk.trim() && !firstMessageComponents) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await sendMessage(chunk);
|
await sendMessage(chunk, undefined, firstMessageComponents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
} from "./thread-bindings.types.js";
|
} from "./thread-bindings.types.js";
|
||||||
|
|
||||||
function buildThreadTarget(threadId: string): string {
|
function buildThreadTarget(threadId: string): string {
|
||||||
return `channel:${threadId}`;
|
return /^(channel:|user:)/i.test(threadId) ? threadId : `channel:${threadId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isThreadArchived(raw: unknown): boolean {
|
export function isThreadArchived(raw: unknown): boolean {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
setRuntimeConfigSnapshot,
|
setRuntimeConfigSnapshot,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "../../../../src/config/config.js";
|
} from "../../../../src/config/config.js";
|
||||||
|
import { getSessionBindingService } from "../../../../src/infra/outbound/session-binding-service.js";
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => {
|
const hoisted = vi.hoisted(() => {
|
||||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||||
@ -788,6 +789,57 @@ describe("thread binding lifecycle", () => {
|
|||||||
expect(usedTokenNew).toBe(true);
|
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 () => {
|
it("keeps overlapping thread ids isolated per account", async () => {
|
||||||
const a = createThreadBindingManager({
|
const a = createThreadBindingManager({
|
||||||
accountId: "a",
|
accountId: "a",
|
||||||
@ -948,6 +1000,47 @@ describe("thread binding lifecycle", () => {
|
|||||||
expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined();
|
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 () => {
|
it("removes ACP bindings when health probe marks running session as stale", async () => {
|
||||||
const manager = createThreadBindingManager({
|
const manager = createThreadBindingManager({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
|||||||
@ -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 staleBindings: ThreadBindingRecord[] = [];
|
||||||
const probeTargets: Array<{
|
const probeTargets: Array<{
|
||||||
binding: ThreadBindingRecord;
|
binding: ThreadBindingRecord;
|
||||||
|
|||||||
@ -117,6 +117,11 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
|||||||
return raw === "subagent" ? "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(
|
function toSessionBindingRecord(
|
||||||
record: ThreadBindingRecord,
|
record: ThreadBindingRecord,
|
||||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||||
@ -158,6 +163,7 @@ function toSessionBindingRecord(
|
|||||||
record,
|
record,
|
||||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||||
}),
|
}),
|
||||||
|
...record.metadata,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -264,6 +270,8 @@ export function createThreadBindingManager(
|
|||||||
const cfg = resolveCurrentCfg();
|
const cfg = resolveCurrentCfg();
|
||||||
let threadId = normalizeThreadId(bindParams.threadId);
|
let threadId = normalizeThreadId(bindParams.threadId);
|
||||||
let channelId = bindParams.channelId?.trim() || "";
|
let channelId = bindParams.channelId?.trim() || "";
|
||||||
|
const directConversationBinding =
|
||||||
|
isDirectConversationBindingId(threadId) || isDirectConversationBindingId(channelId);
|
||||||
|
|
||||||
if (!threadId && bindParams.createThread) {
|
if (!threadId && bindParams.createThread) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
@ -287,6 +295,10 @@ export function createThreadBindingManager(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!channelId && directConversationBinding) {
|
||||||
|
channelId = threadId;
|
||||||
|
}
|
||||||
|
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
channelId =
|
channelId =
|
||||||
(await resolveChannelIdForBinding({
|
(await resolveChannelIdForBinding({
|
||||||
@ -309,12 +321,12 @@ export function createThreadBindingManager(
|
|||||||
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
|
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
|
||||||
let webhookId = bindParams.webhookId?.trim() || "";
|
let webhookId = bindParams.webhookId?.trim() || "";
|
||||||
let webhookToken = bindParams.webhookToken?.trim() || "";
|
let webhookToken = bindParams.webhookToken?.trim() || "";
|
||||||
if (!webhookId || !webhookToken) {
|
if (!directConversationBinding && (!webhookId || !webhookToken)) {
|
||||||
const cachedWebhook = findReusableWebhook({ accountId, channelId });
|
const cachedWebhook = findReusableWebhook({ accountId, channelId });
|
||||||
webhookId = cachedWebhook.webhookId ?? "";
|
webhookId = cachedWebhook.webhookId ?? "";
|
||||||
webhookToken = cachedWebhook.webhookToken ?? "";
|
webhookToken = cachedWebhook.webhookToken ?? "";
|
||||||
}
|
}
|
||||||
if (!webhookId || !webhookToken) {
|
if (!directConversationBinding && (!webhookId || !webhookToken)) {
|
||||||
const createdWebhook = await createWebhookForChannel({
|
const createdWebhook = await createWebhookForChannel({
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
@ -341,6 +353,10 @@ export function createThreadBindingManager(
|
|||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
idleTimeoutMs,
|
idleTimeoutMs,
|
||||||
maxAgeMs,
|
maxAgeMs,
|
||||||
|
metadata:
|
||||||
|
bindParams.metadata && typeof bindParams.metadata === "object"
|
||||||
|
? { ...bindParams.metadata }
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setBindingRecord(record);
|
setBindingRecord(record);
|
||||||
@ -508,6 +524,9 @@ export function createThreadBindingManager(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isDirectConversationBindingId(binding.threadId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||||
if (!channel || typeof channel !== "object") {
|
if (!channel || typeof channel !== "object") {
|
||||||
@ -604,6 +623,7 @@ export function createThreadBindingManager(
|
|||||||
label,
|
label,
|
||||||
boundBy,
|
boundBy,
|
||||||
introText,
|
introText,
|
||||||
|
metadata,
|
||||||
});
|
});
|
||||||
return bound
|
return bound
|
||||||
? toSessionBindingRecord(bound, {
|
? toSessionBindingRecord(bound, {
|
||||||
|
|||||||
@ -183,6 +183,8 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
|
|||||||
typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs)
|
typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs)
|
||||||
? Math.max(0, Math.floor(value.maxAgeMs))
|
? Math.max(0, Math.floor(value.maxAgeMs))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const metadata =
|
||||||
|
value.metadata && typeof value.metadata === "object" ? { ...value.metadata } : undefined;
|
||||||
const legacyExpiresAt =
|
const legacyExpiresAt =
|
||||||
typeof (value as { expiresAt?: unknown }).expiresAt === "number" &&
|
typeof (value as { expiresAt?: unknown }).expiresAt === "number" &&
|
||||||
Number.isFinite((value as { expiresAt?: unknown }).expiresAt)
|
Number.isFinite((value as { expiresAt?: unknown }).expiresAt)
|
||||||
@ -222,6 +224,7 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
|
|||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
idleTimeoutMs: migratedIdleTimeoutMs,
|
idleTimeoutMs: migratedIdleTimeoutMs,
|
||||||
maxAgeMs: migratedMaxAgeMs,
|
maxAgeMs: migratedMaxAgeMs,
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export type ThreadBindingRecord = {
|
|||||||
idleTimeoutMs?: number;
|
idleTimeoutMs?: number;
|
||||||
/** Hard max-age window in milliseconds from bind time (0 disables hard cap). */
|
/** Hard max-age window in milliseconds from bind time (0 disables hard cap). */
|
||||||
maxAgeMs?: number;
|
maxAgeMs?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
||||||
@ -56,6 +57,7 @@ export type ThreadBindingManager = {
|
|||||||
introText?: string;
|
introText?: string;
|
||||||
webhookId?: string;
|
webhookId?: string;
|
||||||
webhookToken?: string;
|
webhookToken?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}) => Promise<ThreadBindingRecord | null>;
|
}) => Promise<ThreadBindingRecord | null>;
|
||||||
unbindThread: (params: {
|
unbindThread: (params: {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export {
|
|||||||
sendVoiceMessageDiscord,
|
sendVoiceMessageDiscord,
|
||||||
} from "./send.outbound.js";
|
} from "./send.outbound.js";
|
||||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||||
|
export { sendTypingDiscord } from "./send.typing.js";
|
||||||
export {
|
export {
|
||||||
fetchChannelPermissionsDiscord,
|
fetchChannelPermissionsDiscord,
|
||||||
hasAllGuildPermissionsDiscord,
|
hasAllGuildPermissionsDiscord,
|
||||||
|
|||||||
9
extensions/discord/src/send.typing.ts
Normal file
9
extensions/discord/src/send.typing.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -1,13 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
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 { normalizeDiscordMessagingTarget } from "./normalize.js";
|
||||||
import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
|
import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
|
||||||
|
|
||||||
vi.mock("./directory-live.js", () => ({
|
|
||||||
listDiscordDirectoryPeersLive: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("parseDiscordTarget", () => {
|
describe("parseDiscordTarget", () => {
|
||||||
it("parses user mention and prefixes", () => {
|
it("parses user mention and prefixes", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
@ -73,14 +69,15 @@ describe("resolveDiscordChannelId", () => {
|
|||||||
|
|
||||||
describe("resolveDiscordTarget", () => {
|
describe("resolveDiscordTarget", () => {
|
||||||
const cfg = { channels: { discord: {} } } as OpenClawConfig;
|
const cfg = { channels: { discord: {} } } as OpenClawConfig;
|
||||||
const listPeers = vi.mocked(listDiscordDirectoryPeersLive);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listPeers.mockClear();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a resolved user for usernames", async () => {
|
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(
|
await expect(
|
||||||
resolveDiscordTarget("jane", { cfg, accountId: "default" }),
|
resolveDiscordTarget("jane", { cfg, accountId: "default" }),
|
||||||
@ -88,14 +85,14 @@ describe("resolveDiscordTarget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to parsing when lookup misses", async () => {
|
it("falls back to parsing when lookup misses", async () => {
|
||||||
listPeers.mockResolvedValueOnce([]);
|
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([]);
|
||||||
await expect(
|
await expect(
|
||||||
resolveDiscordTarget("general", { cfg, accountId: "default" }),
|
resolveDiscordTarget("general", { cfg, accountId: "default" }),
|
||||||
).resolves.toMatchObject({ kind: "channel", id: "general" });
|
).resolves.toMatchObject({ kind: "channel", id: "general" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not call directory lookup for explicit user ids", async () => {
|
it("does not call directory lookup for explicit user ids", async () => {
|
||||||
listPeers.mockResolvedValueOnce([]);
|
const listPeers = vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive");
|
||||||
await expect(
|
await expect(
|
||||||
resolveDiscordTarget("user:123", { cfg, accountId: "default" }),
|
resolveDiscordTarget("user:123", { cfg, accountId: "default" }),
|
||||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||||
|
|||||||
@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
|||||||
registerCli() {},
|
registerCli() {},
|
||||||
registerService() {},
|
registerService() {},
|
||||||
registerProvider() {},
|
registerProvider() {},
|
||||||
|
registerInteractiveHandler() {},
|
||||||
registerHook() {},
|
registerHook() {},
|
||||||
registerHttpRoute() {},
|
registerHttpRoute() {},
|
||||||
registerCommand() {},
|
registerCommand() {},
|
||||||
|
|||||||
@ -42,6 +42,12 @@ function createCommandContext(args: string): PluginCommandContext {
|
|||||||
commandBody: `/phone ${args}`,
|
commandBody: `/phone ${args}`,
|
||||||
args,
|
args,
|
||||||
config: {},
|
config: {},
|
||||||
|
requestConversationBinding: async () => ({
|
||||||
|
status: "error",
|
||||||
|
message: "unsupported",
|
||||||
|
}),
|
||||||
|
detachConversationBinding: async () => ({ removed: false }),
|
||||||
|
getCurrentConversationBinding: async () => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,12 @@ import { danger, logVerbose, warn } from "../../../src/globals.js";
|
|||||||
import { enqueueSystemEvent } from "../../../src/infra/system-events.js";
|
import { enqueueSystemEvent } from "../../../src/infra/system-events.js";
|
||||||
import { MediaFetchError } from "../../../src/media/fetch.js";
|
import { MediaFetchError } from "../../../src/media/fetch.js";
|
||||||
import { readChannelAllowFromStore } from "../../../src/pairing/pairing-store.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 { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../../../src/routing/session-key.js";
|
||||||
import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js";
|
import { applyModelOverrideToSessionEntry } from "../../../src/sessions/model-overrides.js";
|
||||||
@ -749,6 +755,32 @@ export const registerTelegramHandlers = ({
|
|||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTelegramEventCommandAuthorized = (params: {
|
||||||
|
isGroup: boolean;
|
||||||
|
senderId: string;
|
||||||
|
senderUsername: string;
|
||||||
|
context: TelegramEventAuthorizationContext;
|
||||||
|
}): boolean => {
|
||||||
|
const { isGroup, senderId, senderUsername, context } = params;
|
||||||
|
const { dmPolicy, storeAllowFrom, groupAllowOverride, effectiveGroupAllow } = context;
|
||||||
|
if (!isGroup) {
|
||||||
|
if (dmPolicy === "disabled") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (dmPolicy === "open") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||||
|
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||||
|
allowFrom: dmAllowFrom,
|
||||||
|
storeAllowFrom,
|
||||||
|
dmPolicy,
|
||||||
|
});
|
||||||
|
return isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername);
|
||||||
|
}
|
||||||
|
return isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle emoji reactions to messages.
|
// Handle emoji reactions to messages.
|
||||||
bot.on("message_reaction", async (ctx) => {
|
bot.on("message_reaction", async (ctx) => {
|
||||||
try {
|
try {
|
||||||
@ -1121,6 +1153,24 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
return await editCallbackMessage(messageText, replyMarkup);
|
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 deleteCallbackMessage = async () => {
|
||||||
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
|
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
|
||||||
if (typeof deleteFn === "function") {
|
if (typeof deleteFn === "function") {
|
||||||
@ -1201,6 +1251,79 @@ export const registerTelegramHandlers = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversationThreadId = resolvedThreadId ?? dmThreadId;
|
||||||
|
const callbackConversationId =
|
||||||
|
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
|
||||||
|
const isCommandAuthorized = isTelegramEventCommandAuthorized({
|
||||||
|
isGroup,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
context: eventAuthContext,
|
||||||
|
});
|
||||||
|
const pluginBindingApproval = parsePluginBindingApprovalCustomId(data);
|
||||||
|
if (pluginBindingApproval) {
|
||||||
|
const resolved = await resolvePluginConversationBindingApproval({
|
||||||
|
approvalId: pluginBindingApproval.approvalId,
|
||||||
|
decision: pluginBindingApproval.decision,
|
||||||
|
senderId: senderId || undefined,
|
||||||
|
});
|
||||||
|
if (resolved.status !== "expired") {
|
||||||
|
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: conversationThreadId != null ? String(chatId) : undefined,
|
||||||
|
senderId: senderId || undefined,
|
||||||
|
senderUsername: senderUsername || undefined,
|
||||||
|
threadId: conversationThreadId,
|
||||||
|
isGroup,
|
||||||
|
isForum,
|
||||||
|
auth: {
|
||||||
|
isAuthorizedSender: isCommandAuthorized,
|
||||||
|
},
|
||||||
|
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 (isApprovalCallback) {
|
||||||
if (
|
if (
|
||||||
!isTelegramExecApprovalClientEnabled({ cfg, accountId }) ||
|
!isTelegramExecApprovalClientEnabled({ cfg, accountId }) ||
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildPluginBindingApprovalCustomId } from "../../../src/plugins/conversation-binding.js";
|
||||||
|
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 { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
||||||
import {
|
import {
|
||||||
@ -49,6 +55,7 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMyCommandsSpy.mockClear();
|
setMyCommandsSpy.mockClear();
|
||||||
|
clearPluginInteractiveHandlers();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
@ -201,7 +208,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>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
expect(callbackHandler).toBeDefined();
|
expect(callbackHandler).toBeDefined();
|
||||||
@ -244,7 +251,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>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
expect(callbackHandler).toBeDefined();
|
expect(callbackHandler).toBeDefined();
|
||||||
@ -269,6 +276,97 @@ describe("createTelegramBot", () => {
|
|||||||
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1");
|
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes false auth to plugin Telegram callbacks for group users outside the allowlist", async () => {
|
||||||
|
onSpy.mockClear();
|
||||||
|
replySpy.mockClear();
|
||||||
|
const seenAuth = vi.fn();
|
||||||
|
registerPluginInteractiveHandler("codex-plugin", {
|
||||||
|
channel: "telegram",
|
||||||
|
namespace: "codex",
|
||||||
|
handler: async ({
|
||||||
|
auth,
|
||||||
|
conversationId,
|
||||||
|
threadId,
|
||||||
|
}: PluginInteractiveTelegramHandlerContext) => {
|
||||||
|
seenAuth({ isAuthorizedSender: auth.isAuthorizedSender, conversationId, threadId });
|
||||||
|
return { handled: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createTelegramBot({
|
||||||
|
token: "tok",
|
||||||
|
config: {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
dmPolicy: "open",
|
||||||
|
allowFrom: [],
|
||||||
|
capabilities: { inlineButtons: "group" },
|
||||||
|
groupPolicy: "open",
|
||||||
|
groups: { "*": { requireMention: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const callbackHandler = getOnHandler("callback_query") as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
await callbackHandler({
|
||||||
|
callbackQuery: {
|
||||||
|
id: "cbq-plugin-auth-1",
|
||||||
|
data: "codex:resume:thread-1",
|
||||||
|
from: { id: 42, first_name: "Ada", username: "ada_bot" },
|
||||||
|
message: {
|
||||||
|
chat: { id: -100999, type: "supergroup", title: "Test Group" },
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seenAuth).toHaveBeenCalledWith({
|
||||||
|
isAuthorizedSender: false,
|
||||||
|
conversationId: "-100999",
|
||||||
|
threadId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps plugin bind approval buttons when the approval is already expired", async () => {
|
||||||
|
onSpy.mockClear();
|
||||||
|
editMessageReplyMarkupSpy.mockClear();
|
||||||
|
replySpy.mockClear();
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const callbackHandler = getOnHandler("callback_query") as (
|
||||||
|
ctx: Record<string, unknown>,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
await callbackHandler({
|
||||||
|
callbackQuery: {
|
||||||
|
id: "cbq-pluginbind-expired",
|
||||||
|
data: buildPluginBindingApprovalCustomId("missing-approval", "allow-once"),
|
||||||
|
from: { id: 9, first_name: "Ada", username: "ada_bot" },
|
||||||
|
message: {
|
||||||
|
chat: { id: 1234, type: "private" },
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 24,
|
||||||
|
text: "Plugin bind approval",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledWith(
|
||||||
|
1234,
|
||||||
|
"That plugin bind approval expired. Retry the bind command.",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears approval buttons without re-editing callback message text", async () => {
|
it("clears approval buttons without re-editing callback message text", async () => {
|
||||||
onSpy.mockClear();
|
onSpy.mockClear();
|
||||||
editMessageReplyMarkupSpy.mockClear();
|
editMessageReplyMarkupSpy.mockClear();
|
||||||
@ -288,7 +386,7 @@ describe("createTelegramBot", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
createTelegramBot({ token: "tok" });
|
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>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
expect(callbackHandler).toBeDefined();
|
expect(callbackHandler).toBeDefined();
|
||||||
@ -1359,6 +1457,57 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
expect(replySpy).not.toHaveBeenCalled();
|
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 () => {
|
it("sets command target session key for dm topic commands", async () => {
|
||||||
onSpy.mockClear();
|
onSpy.mockClear();
|
||||||
sendMessageSpy.mockClear();
|
sendMessageSpy.mockClear();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.
|
|||||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
import { logVerbose } from "../../../src/globals.js";
|
import { logVerbose } from "../../../src/globals.js";
|
||||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||||
|
import { isPluginOwnedSessionBindingRecord } from "../../../src/plugins/conversation-binding.js";
|
||||||
import {
|
import {
|
||||||
buildAgentSessionKey,
|
buildAgentSessionKey,
|
||||||
deriveLastRoutePolicy,
|
deriveLastRoutePolicy,
|
||||||
@ -118,21 +119,25 @@ export function resolveTelegramConversationRoute(params: {
|
|||||||
});
|
});
|
||||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||||
if (threadBinding && boundSessionKey) {
|
if (threadBinding && boundSessionKey) {
|
||||||
route = {
|
if (!isPluginOwnedSessionBindingRecord(threadBinding)) {
|
||||||
...route,
|
route = {
|
||||||
sessionKey: boundSessionKey,
|
...route,
|
||||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
|
|
||||||
lastRoutePolicy: deriveLastRoutePolicy({
|
|
||||||
sessionKey: boundSessionKey,
|
sessionKey: boundSessionKey,
|
||||||
mainSessionKey: route.mainSessionKey,
|
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
|
||||||
}),
|
lastRoutePolicy: deriveLastRoutePolicy({
|
||||||
matchedBy: "binding.channel",
|
sessionKey: boundSessionKey,
|
||||||
};
|
mainSessionKey: route.mainSessionKey,
|
||||||
|
}),
|
||||||
|
matchedBy: "binding.channel",
|
||||||
|
};
|
||||||
|
}
|
||||||
configuredBinding = null;
|
configuredBinding = null;
|
||||||
configuredBindingSessionKey = "";
|
configuredBindingSessionKey = "";
|
||||||
getSessionBindingService().touch(threadBinding.bindingId);
|
getSessionBindingService().touch(threadBinding.bindingId);
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`,
|
isPluginOwnedSessionBindingRecord(threadBinding)
|
||||||
|
? `telegram: plugin-bound conversation ${threadBindingConversationId}`
|
||||||
|
: `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
|
|||||||
const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
||||||
botApi: {
|
botApi: {
|
||||||
deleteMessage: vi.fn(),
|
deleteMessage: vi.fn(),
|
||||||
|
editForumTopic: vi.fn(),
|
||||||
editMessageText: vi.fn(),
|
editMessageText: vi.fn(),
|
||||||
|
editMessageReplyMarkup: vi.fn(),
|
||||||
|
pinChatMessage: vi.fn(),
|
||||||
sendChatAction: vi.fn(),
|
sendChatAction: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
sendPoll: vi.fn(),
|
sendPoll: vi.fn(),
|
||||||
@ -16,6 +19,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
|
|||||||
sendAnimation: vi.fn(),
|
sendAnimation: vi.fn(),
|
||||||
setMessageReaction: vi.fn(),
|
setMessageReaction: vi.fn(),
|
||||||
sendSticker: vi.fn(),
|
sendSticker: vi.fn(),
|
||||||
|
unpinChatMessage: vi.fn(),
|
||||||
},
|
},
|
||||||
botCtorSpy: vi.fn(),
|
botCtorSpy: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -16,11 +16,14 @@ const {
|
|||||||
buildInlineKeyboard,
|
buildInlineKeyboard,
|
||||||
createForumTopicTelegram,
|
createForumTopicTelegram,
|
||||||
editMessageTelegram,
|
editMessageTelegram,
|
||||||
|
pinMessageTelegram,
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
|
renameForumTopicTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
sendTypingTelegram,
|
sendTypingTelegram,
|
||||||
sendPollTelegram,
|
sendPollTelegram,
|
||||||
sendStickerTelegram,
|
sendStickerTelegram,
|
||||||
|
unpinMessageTelegram,
|
||||||
} = await importTelegramSendModule();
|
} = await importTelegramSendModule();
|
||||||
|
|
||||||
async function expectChatNotFoundWithChatId(
|
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 () => {
|
it("applies timeoutSeconds config precedence", async () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1067,6 +1067,109 @@ export async function deleteMessageTelegram(
|
|||||||
return { ok: true };
|
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 = {
|
type TelegramEditOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
|||||||
@ -211,4 +211,40 @@ describe("telegram thread bindings", () => {
|
|||||||
);
|
);
|
||||||
expect(fs.existsSync(statePath)).toBe(false);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export type TelegramThreadBindingRecord = {
|
|||||||
lastActivityAt: number;
|
lastActivityAt: number;
|
||||||
idleTimeoutMs?: number;
|
idleTimeoutMs?: number;
|
||||||
maxAgeMs?: number;
|
maxAgeMs?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoredTelegramBindingState = {
|
type StoredTelegramBindingState = {
|
||||||
@ -173,6 +174,7 @@ function toSessionBindingRecord(
|
|||||||
typeof record.maxAgeMs === "number"
|
typeof record.maxAgeMs === "number"
|
||||||
? Math.max(0, Math.floor(record.maxAgeMs))
|
? Math.max(0, Math.floor(record.maxAgeMs))
|
||||||
: defaults.maxAgeMs,
|
: defaults.maxAgeMs,
|
||||||
|
...record.metadata,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -214,6 +216,10 @@ function fromSessionBindingInput(params: {
|
|||||||
: existing?.boundBy,
|
: existing?.boundBy,
|
||||||
boundAt: now,
|
boundAt: now,
|
||||||
lastActivityAt: now,
|
lastActivityAt: now,
|
||||||
|
metadata: {
|
||||||
|
...existing?.metadata,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof metadata.idleTimeoutMs === "number" && Number.isFinite(metadata.idleTimeoutMs)) {
|
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()) {
|
if (typeof entry?.boundBy === "string" && entry.boundBy.trim()) {
|
||||||
record.boundBy = entry.boundBy.trim();
|
record.boundBy = entry.boundBy.trim();
|
||||||
}
|
}
|
||||||
|
if (entry?.metadata && typeof entry.metadata === "object") {
|
||||||
|
record.metadata = { ...entry.metadata };
|
||||||
|
}
|
||||||
bindings.push(record);
|
bindings.push(record);
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
@ -535,7 +544,7 @@ export function createTelegramThreadBindingManager(
|
|||||||
resolveBindingKey({ accountId, conversationId }),
|
resolveBindingKey({ accountId, conversationId }),
|
||||||
record,
|
record,
|
||||||
);
|
);
|
||||||
void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
|
await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`telegram: bound conversation ${conversationId} -> ${targetSessionKey} (${summarizeLifecycleForLog(
|
`telegram: bound conversation ${conversationId} -> ${targetSessionKey} (${summarizeLifecycleForLog(
|
||||||
record,
|
record,
|
||||||
@ -595,6 +604,9 @@ export function createTelegramThreadBindingManager(
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
sendFarewell: false,
|
sendFarewell: false,
|
||||||
});
|
});
|
||||||
|
if (removed.length > 0) {
|
||||||
|
await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
|
||||||
|
}
|
||||||
return removed.map((entry) =>
|
return removed.map((entry) =>
|
||||||
toSessionBindingRecord(entry, {
|
toSessionBindingRecord(entry, {
|
||||||
idleTimeoutMs,
|
idleTimeoutMs,
|
||||||
@ -614,6 +626,9 @@ export function createTelegramThreadBindingManager(
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
sendFarewell: false,
|
sendFarewell: false,
|
||||||
});
|
});
|
||||||
|
if (removed) {
|
||||||
|
await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() });
|
||||||
|
}
|
||||||
return removed
|
return removed
|
||||||
? [
|
? [
|
||||||
toSessionBindingRecord(removed, {
|
toSessionBindingRecord(removed, {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi
|
|||||||
registerCli() {},
|
registerCli() {},
|
||||||
registerService() {},
|
registerService() {},
|
||||||
registerProvider() {},
|
registerProvider() {},
|
||||||
|
registerInteractiveHandler() {},
|
||||||
registerCommand() {},
|
registerCommand() {},
|
||||||
registerContextEngine() {},
|
registerContextEngine() {},
|
||||||
resolvePath(input: string) {
|
resolvePath(input: string) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||||
|
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
|
||||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
@ -23,8 +24,19 @@ const diagnosticMocks = vi.hoisted(() => ({
|
|||||||
logSessionStateChange: vi.fn(),
|
logSessionStateChange: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const hookMocks = vi.hoisted(() => ({
|
const hookMocks = vi.hoisted(() => ({
|
||||||
|
registry: {
|
||||||
|
plugins: [] as Array<{
|
||||||
|
id: string;
|
||||||
|
status: "loaded" | "disabled" | "error";
|
||||||
|
}>,
|
||||||
|
},
|
||||||
runner: {
|
runner: {
|
||||||
hasHooks: vi.fn(() => false),
|
hasHooks: vi.fn(() => false),
|
||||||
|
runInboundClaim: vi.fn(async () => undefined),
|
||||||
|
runInboundClaimForPlugin: vi.fn(async () => undefined),
|
||||||
|
runInboundClaimForPluginOutcome: vi.fn<() => Promise<PluginTargetedInboundClaimOutcome>>(
|
||||||
|
async () => ({ status: "no_handler" }),
|
||||||
|
),
|
||||||
runMessageReceived: vi.fn(async () => {}),
|
runMessageReceived: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -40,6 +52,15 @@ const acpMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
const sessionBindingMocks = vi.hoisted(() => ({
|
const sessionBindingMocks = vi.hoisted(() => ({
|
||||||
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
|
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(() => ({
|
const sessionStoreMocks = vi.hoisted(() => ({
|
||||||
currentEntry: undefined as Record<string, unknown> | undefined,
|
currentEntry: undefined as Record<string, unknown> | undefined,
|
||||||
@ -125,6 +146,7 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
|
|||||||
|
|
||||||
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||||
getGlobalHookRunner: () => hookMocks.runner,
|
getGlobalHookRunner: () => hookMocks.runner,
|
||||||
|
getGlobalPluginRegistry: () => hookMocks.registry,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../hooks/internal-hooks.js", () => ({
|
vi.mock("../../hooks/internal-hooks.js", () => ({
|
||||||
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
||||||
@ -155,8 +177,8 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal
|
|||||||
})),
|
})),
|
||||||
listBySession: (targetSessionKey: string) =>
|
listBySession: (targetSessionKey: string) =>
|
||||||
sessionBindingMocks.listBySession(targetSessionKey),
|
sessionBindingMocks.listBySession(targetSessionKey),
|
||||||
resolveByConversation: vi.fn(() => null),
|
resolveByConversation: sessionBindingMocks.resolveByConversation,
|
||||||
touch: vi.fn(),
|
touch: sessionBindingMocks.touch,
|
||||||
unbind: vi.fn(async () => []),
|
unbind: vi.fn(async () => []),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@ -170,6 +192,7 @@ vi.mock("../../tts/tts.js", () => ({
|
|||||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||||
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
||||||
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.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 noAbortResult = { handled: false, aborted: false } as const;
|
||||||
const emptyConfig = {} as OpenClawConfig;
|
const emptyConfig = {} as OpenClawConfig;
|
||||||
@ -239,7 +262,16 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
diagnosticMocks.logSessionStateChange.mockClear();
|
diagnosticMocks.logSessionStateChange.mockClear();
|
||||||
hookMocks.runner.hasHooks.mockClear();
|
hookMocks.runner.hasHooks.mockClear();
|
||||||
hookMocks.runner.hasHooks.mockReturnValue(false);
|
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.runner.runMessageReceived.mockClear();
|
||||||
|
hookMocks.registry.plugins = [];
|
||||||
internalHookMocks.createInternalHookEvent.mockClear();
|
internalHookMocks.createInternalHookEvent.mockClear();
|
||||||
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
|
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
|
||||||
internalHookMocks.triggerInternalHook.mockClear();
|
internalHookMocks.triggerInternalHook.mockClear();
|
||||||
@ -250,6 +282,10 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
acpMocks.requireAcpRuntimeBackend.mockReset();
|
acpMocks.requireAcpRuntimeBackend.mockReset();
|
||||||
sessionBindingMocks.listBySession.mockReset();
|
sessionBindingMocks.listBySession.mockReset();
|
||||||
sessionBindingMocks.listBySession.mockReturnValue([]);
|
sessionBindingMocks.listBySession.mockReturnValue([]);
|
||||||
|
pluginBindingTesting.reset();
|
||||||
|
sessionBindingMocks.resolveByConversation.mockReset();
|
||||||
|
sessionBindingMocks.resolveByConversation.mockReturnValue(null);
|
||||||
|
sessionBindingMocks.touch.mockReset();
|
||||||
sessionStoreMocks.currentEntry = undefined;
|
sessionStoreMocks.currentEntry = undefined;
|
||||||
sessionStoreMocks.loadSessionStore.mockClear();
|
sessionStoreMocks.loadSessionStore.mockClear();
|
||||||
sessionStoreMocks.resolveStorePath.mockClear();
|
sessionStoreMocks.resolveStorePath.mockClear();
|
||||||
@ -1861,6 +1897,60 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("broadcasts inbound claims before core reply dispatch when no plugin binding owns the conversation", 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: false, counts: { tool: 0, block: 0, final: 0 } });
|
||||||
|
expect(hookMocks.runner.runInboundClaim).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: "who are you",
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "-10099:topic:77",
|
||||||
|
wasMentioned: true,
|
||||||
|
commandAuthorized: true,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
channelId: "telegram",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "-10099:topic:77",
|
||||||
|
parentConversationId: "-10099",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hookMocks.runner.runMessageReceived).not.toHaveBeenCalled();
|
||||||
|
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
|
||||||
|
expect(replyResolver).not.toHaveBeenCalled();
|
||||||
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits internal message:received hook when a session key is available", async () => {
|
it("emits internal message:received hook when a session key is available", async () => {
|
||||||
setNoAbort();
|
setNoAbort();
|
||||||
const cfg = emptyConfig;
|
const cfg = emptyConfig;
|
||||||
@ -1944,6 +2034,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 () => {
|
it("marks diagnostics skipped for duplicate inbound messages", async () => {
|
||||||
setNoAbort();
|
setNoAbort();
|
||||||
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
||||||
|
|||||||
@ -13,17 +13,29 @@ import { fireAndForgetHook } from "../../hooks/fire-and-forget.js";
|
|||||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||||
import {
|
import {
|
||||||
deriveInboundMessageHookContext,
|
deriveInboundMessageHookContext,
|
||||||
|
toPluginInboundClaimContext,
|
||||||
|
toPluginInboundClaimEvent,
|
||||||
toInternalMessageReceivedContext,
|
toInternalMessageReceivedContext,
|
||||||
toPluginMessageContext,
|
toPluginMessageContext,
|
||||||
toPluginMessageReceivedEvent,
|
toPluginMessageReceivedEvent,
|
||||||
} from "../../hooks/message-hook-mappers.js";
|
} from "../../hooks/message-hook-mappers.js";
|
||||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
|
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||||
import {
|
import {
|
||||||
logMessageProcessed,
|
logMessageProcessed,
|
||||||
logMessageQueued,
|
logMessageQueued,
|
||||||
logSessionStateChange,
|
logSessionStateChange,
|
||||||
} from "../../logging/diagnostic.js";
|
} 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 { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
|
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.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;
|
ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
|
||||||
const hookContext = deriveInboundMessageHookContext(ctx, { messageId: messageIdForHook });
|
const hookContext = deriveInboundMessageHookContext(ctx, { messageId: messageIdForHook });
|
||||||
const { isGroup, groupId } = hookContext;
|
const { isGroup, groupId } = hookContext;
|
||||||
|
const inboundClaimContext = toPluginInboundClaimContext(hookContext);
|
||||||
// Trigger plugin hooks (fire-and-forget)
|
const inboundClaimEvent = toPluginInboundClaimEvent(hookContext, {
|
||||||
if (hookRunner?.hasHooks("message_received")) {
|
commandAuthorized:
|
||||||
fireAndForgetHook(
|
typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : undefined,
|
||||||
hookRunner.runMessageReceived(
|
wasMentioned: typeof ctx.WasMentioned === "boolean" ? ctx.WasMentioned : undefined,
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should route replies to originating channel instead of dispatcher.
|
// Check if we should route replies to originating channel instead of dispatcher.
|
||||||
// Only route when the originating channel is DIFFERENT from the current surface.
|
// Only route when the originating channel is DIFFERENT from the current surface.
|
||||||
@ -279,6 +273,156 @@ 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pluginOwnedBinding && hookRunner?.hasHooks("inbound_claim")) {
|
||||||
|
const inboundClaimResult = await hookRunner.runInboundClaim(
|
||||||
|
inboundClaimEvent,
|
||||||
|
inboundClaimContext,
|
||||||
|
);
|
||||||
|
if (inboundClaimResult?.handled) {
|
||||||
|
markIdle("plugin_inbound_claim");
|
||||||
|
recordProcessed("completed", { reason: "plugin-inbound-claimed" });
|
||||||
|
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();
|
markProcessing();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -606,7 +750,10 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
|
|
||||||
const counts = dispatcher.getQueuedCounts();
|
const counts = dispatcher.getQueuedCounts();
|
||||||
counts.final += routedFinalCount;
|
counts.final += routedFinalCount;
|
||||||
recordProcessed("completed");
|
recordProcessed(
|
||||||
|
"completed",
|
||||||
|
pluginFallbackReason ? { reason: pluginFallbackReason } : undefined,
|
||||||
|
);
|
||||||
markIdle("message_completed");
|
markIdle("message_completed");
|
||||||
return { queuedFinal, counts };
|
return { queuedFinal, counts };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import {
|
import {
|
||||||
buildCanonicalSentMessageHookContext,
|
buildCanonicalSentMessageHookContext,
|
||||||
deriveInboundMessageHookContext,
|
deriveInboundMessageHookContext,
|
||||||
|
toPluginInboundClaimContext,
|
||||||
toInternalMessagePreprocessedContext,
|
toInternalMessagePreprocessedContext,
|
||||||
toInternalMessageReceivedContext,
|
toInternalMessageReceivedContext,
|
||||||
toInternalMessageSentContext,
|
toInternalMessageSentContext,
|
||||||
@ -99,6 +100,77 @@ 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("preserves Discord channel identity for group DM inbound claims", () => {
|
||||||
|
const canonical = deriveInboundMessageHookContext(
|
||||||
|
makeInboundCtx({
|
||||||
|
Provider: "discord",
|
||||||
|
Surface: "discord",
|
||||||
|
OriginatingChannel: "discord",
|
||||||
|
From: "discord:channel:1480554272859881494",
|
||||||
|
To: "channel:1480554272859881494",
|
||||||
|
OriginatingTo: "channel:1480554272859881494",
|
||||||
|
GroupChannel: undefined,
|
||||||
|
GroupSubject: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(toPluginInboundClaimContext(canonical)).toEqual({
|
||||||
|
channelId: "discord",
|
||||||
|
accountId: "acc-1",
|
||||||
|
conversationId: "channel:1480554272859881494",
|
||||||
|
parentConversationId: undefined,
|
||||||
|
senderId: "sender-1",
|
||||||
|
messageId: "msg-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("maps transcribed and preprocessed internal payloads", () => {
|
it("maps transcribed and preprocessed internal payloads", () => {
|
||||||
const cfg = {} as OpenClawConfig;
|
const cfg = {} as OpenClawConfig;
|
||||||
const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined }));
|
const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined }));
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
|
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type {
|
import type {
|
||||||
|
PluginHookInboundClaimContext,
|
||||||
|
PluginHookInboundClaimEvent,
|
||||||
PluginHookMessageContext,
|
PluginHookMessageContext,
|
||||||
PluginHookMessageReceivedEvent,
|
PluginHookMessageReceivedEvent,
|
||||||
PluginHookMessageSentEvent,
|
PluginHookMessageSentEvent,
|
||||||
@ -147,6 +149,147 @@ 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;
|
||||||
|
const senderChannelId = rawSender?.startsWith("discord:channel:")
|
||||||
|
? rawSender.slice("discord:channel:".length)
|
||||||
|
: rawSender?.startsWith("discord:group:")
|
||||||
|
? rawSender.slice("discord:group:".length)
|
||||||
|
: undefined;
|
||||||
|
if (rawTarget?.startsWith("discord:channel:")) {
|
||||||
|
return `channel:${rawTarget.slice("discord:channel:".length)}`;
|
||||||
|
}
|
||||||
|
if (rawTarget?.startsWith("discord:group:")) {
|
||||||
|
return `channel:${rawTarget.slice("discord:group:".length)}`;
|
||||||
|
}
|
||||||
|
if (rawTarget?.startsWith("channel:") && (canonical.isGroup || senderChannelId)) {
|
||||||
|
return rawTarget;
|
||||||
|
}
|
||||||
|
if (!canonical.isGroup && senderUserId) {
|
||||||
|
return `user:${senderUserId}`;
|
||||||
|
}
|
||||||
|
if (!rawTarget) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (rawTarget.startsWith("discord:user:")) {
|
||||||
|
return `user:${rawTarget.slice("discord:user:".length)}`;
|
||||||
|
}
|
||||||
|
if (rawTarget.startsWith("user:")) {
|
||||||
|
return rawTarget;
|
||||||
|
}
|
||||||
|
if (rawTarget.startsWith("discord:")) {
|
||||||
|
return `user:${rawTarget.slice("discord:".length)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(
|
export function toPluginMessageReceivedEvent(
|
||||||
canonical: CanonicalInboundMessageHookContext,
|
canonical: CanonicalInboundMessageHookContext,
|
||||||
): PluginHookMessageReceivedEvent {
|
): PluginHookMessageReceivedEvent {
|
||||||
|
|||||||
@ -100,10 +100,24 @@ export type {
|
|||||||
OpenClawPluginApi,
|
OpenClawPluginApi,
|
||||||
OpenClawPluginService,
|
OpenClawPluginService,
|
||||||
OpenClawPluginServiceContext,
|
OpenClawPluginServiceContext,
|
||||||
|
PluginHookInboundClaimContext,
|
||||||
|
PluginHookInboundClaimEvent,
|
||||||
|
PluginHookInboundClaimResult,
|
||||||
|
PluginInteractiveDiscordHandlerContext,
|
||||||
|
PluginInteractiveHandlerRegistration,
|
||||||
|
PluginInteractiveTelegramHandlerContext,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
ProviderAuthContext,
|
ProviderAuthContext,
|
||||||
ProviderAuthResult,
|
ProviderAuthResult,
|
||||||
} from "../plugins/types.js";
|
} from "../plugins/types.js";
|
||||||
|
export type {
|
||||||
|
ConversationRef,
|
||||||
|
SessionBindingBindInput,
|
||||||
|
SessionBindingCapabilities,
|
||||||
|
SessionBindingRecord,
|
||||||
|
SessionBindingService,
|
||||||
|
SessionBindingUnbindInput,
|
||||||
|
} from "../infra/outbound/session-binding-service.js";
|
||||||
export type {
|
export type {
|
||||||
GatewayRequestHandler,
|
GatewayRequestHandler,
|
||||||
GatewayRequestHandlerOptions,
|
GatewayRequestHandlerOptions,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
__testing,
|
||||||
clearPluginCommands,
|
clearPluginCommands,
|
||||||
|
executePluginCommand,
|
||||||
getPluginCommandSpecs,
|
getPluginCommandSpecs,
|
||||||
listPluginCommands,
|
listPluginCommands,
|
||||||
registerPluginCommand,
|
registerPluginCommand,
|
||||||
@ -93,5 +95,206 @@ describe("registerPluginCommand", () => {
|
|||||||
acceptsArgs: false,
|
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("resolves Discord group DM command bindings as channel conversations", () => {
|
||||||
|
expect(
|
||||||
|
__testing.resolveBindingConversationFromCommand({
|
||||||
|
channel: "discord",
|
||||||
|
from: "discord:group: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 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a safe error payload when a runtime plugin command returns undefined", async () => {
|
||||||
|
const handler = async () => undefined as unknown as { text: string };
|
||||||
|
const command = {
|
||||||
|
name: "unsafe",
|
||||||
|
description: "Demo command",
|
||||||
|
acceptsArgs: false,
|
||||||
|
handler,
|
||||||
|
pluginId: "demo-plugin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executePluginCommand({
|
||||||
|
command,
|
||||||
|
channel: "discord",
|
||||||
|
senderId: "U123",
|
||||||
|
isAuthorizedSender: true,
|
||||||
|
commandBody: "/unsafe",
|
||||||
|
config: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
text: "⚠️ Command failed. Please try again later.",
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a safe error payload when a runtime plugin command returns an empty object", async () => {
|
||||||
|
const handler = async () => ({}) as { text: string };
|
||||||
|
const command = {
|
||||||
|
name: "empty",
|
||||||
|
description: "Demo command",
|
||||||
|
acceptsArgs: false,
|
||||||
|
handler,
|
||||||
|
pluginId: "demo-plugin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executePluginCommand({
|
||||||
|
command,
|
||||||
|
channel: "discord",
|
||||||
|
senderId: "U123",
|
||||||
|
isAuthorizedSender: true,
|
||||||
|
commandBody: "/empty",
|
||||||
|
config: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
text: "⚠️ Command failed. Please try again later.",
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves channelData-only plugin replies", async () => {
|
||||||
|
const handler = async () =>
|
||||||
|
({
|
||||||
|
channelData: {
|
||||||
|
discord: {
|
||||||
|
components: [{ type: 1, components: [] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as unknown as { text: string };
|
||||||
|
const command = {
|
||||||
|
name: "interactive",
|
||||||
|
description: "Demo command",
|
||||||
|
acceptsArgs: false,
|
||||||
|
handler,
|
||||||
|
pluginId: "demo-plugin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executePluginCommand({
|
||||||
|
command,
|
||||||
|
channel: "discord",
|
||||||
|
senderId: "U123",
|
||||||
|
isAuthorizedSender: true,
|
||||||
|
commandBody: "/interactive",
|
||||||
|
config: {} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
channelData: {
|
||||||
|
discord: {
|
||||||
|
components: [{ type: 1, components: [] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,8 +5,17 @@
|
|||||||
* These commands are processed before built-in commands and before agent invocation.
|
* 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 { isRenderablePayload } from "../auto-reply/reply/reply-payloads.js";
|
||||||
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
|
import {
|
||||||
|
detachPluginConversationBinding,
|
||||||
|
getCurrentPluginConversationBinding,
|
||||||
|
requestPluginConversationBinding,
|
||||||
|
} from "./conversation-binding.js";
|
||||||
import type {
|
import type {
|
||||||
OpenClawPluginCommandDefinition,
|
OpenClawPluginCommandDefinition,
|
||||||
PluginCommandContext,
|
PluginCommandContext,
|
||||||
@ -15,6 +24,8 @@ import type {
|
|||||||
|
|
||||||
type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
|
type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
|
pluginRoot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Registry of plugin commands
|
// Registry of plugin commands
|
||||||
@ -25,6 +36,10 @@ let registryLocked = false;
|
|||||||
|
|
||||||
// Maximum allowed length for command arguments (defense in depth)
|
// Maximum allowed length for command arguments (defense in depth)
|
||||||
const MAX_ARGS_LENGTH = 4096;
|
const MAX_ARGS_LENGTH = 4096;
|
||||||
|
const SAFE_COMMAND_FAILURE_REPLY: PluginCommandResult = {
|
||||||
|
text: "⚠️ Command failed. Please try again later.",
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserved command names that plugins cannot override.
|
* Reserved command names that plugins cannot override.
|
||||||
@ -109,6 +124,7 @@ export type CommandRegistrationResult = {
|
|||||||
export function registerPluginCommand(
|
export function registerPluginCommand(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
command: OpenClawPluginCommandDefinition,
|
command: OpenClawPluginCommandDefinition,
|
||||||
|
opts?: { pluginName?: string; pluginRoot?: string },
|
||||||
): CommandRegistrationResult {
|
): CommandRegistrationResult {
|
||||||
// Prevent registration while commands are being processed
|
// Prevent registration while commands are being processed
|
||||||
if (registryLocked) {
|
if (registryLocked) {
|
||||||
@ -149,7 +165,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})`);
|
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@ -235,6 +258,111 @@ function sanitizeArgs(args: string | undefined): string | undefined {
|
|||||||
return sanitized;
|
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:group:")
|
||||||
|
? `channel:${stripPrefix(source, "discord:group:")}`
|
||||||
|
: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginCommandResult(result: unknown): ReplyPayload | null {
|
||||||
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = result as Record<string, unknown>;
|
||||||
|
const mediaUrls = Array.isArray(payload.mediaUrls)
|
||||||
|
? payload.mediaUrls
|
||||||
|
.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const btw =
|
||||||
|
payload.btw && typeof payload.btw === "object" && !Array.isArray(payload.btw)
|
||||||
|
? (payload.btw as { question?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const channelData =
|
||||||
|
payload.channelData &&
|
||||||
|
typeof payload.channelData === "object" &&
|
||||||
|
!Array.isArray(payload.channelData)
|
||||||
|
? (payload.channelData as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const normalized: ReplyPayload = {
|
||||||
|
text: typeof payload.text === "string" ? payload.text : undefined,
|
||||||
|
mediaUrl: typeof payload.mediaUrl === "string" ? payload.mediaUrl : undefined,
|
||||||
|
mediaUrls,
|
||||||
|
btw:
|
||||||
|
btw && typeof btw.question === "string"
|
||||||
|
? {
|
||||||
|
question: btw.question,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
replyToId: typeof payload.replyToId === "string" ? payload.replyToId : undefined,
|
||||||
|
replyToTag: typeof payload.replyToTag === "boolean" ? payload.replyToTag : undefined,
|
||||||
|
replyToCurrent:
|
||||||
|
typeof payload.replyToCurrent === "boolean" ? payload.replyToCurrent : undefined,
|
||||||
|
audioAsVoice: typeof payload.audioAsVoice === "boolean" ? payload.audioAsVoice : undefined,
|
||||||
|
isError: typeof payload.isError === "boolean" ? payload.isError : undefined,
|
||||||
|
isReasoning: typeof payload.isReasoning === "boolean" ? payload.isReasoning : undefined,
|
||||||
|
channelData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return isRenderablePayload(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a plugin command handler.
|
* Execute a plugin command handler.
|
||||||
*
|
*
|
||||||
@ -268,6 +396,13 @@ export async function executePluginCommand(params: {
|
|||||||
|
|
||||||
// Sanitize args before passing to handler
|
// Sanitize args before passing to handler
|
||||||
const sanitizedArgs = sanitizeArgs(args);
|
const sanitizedArgs = sanitizeArgs(args);
|
||||||
|
const bindingConversation = resolveBindingConversationFromCommand({
|
||||||
|
channel,
|
||||||
|
from: params.from,
|
||||||
|
to: params.to,
|
||||||
|
accountId: params.accountId,
|
||||||
|
messageThreadId: params.messageThreadId,
|
||||||
|
});
|
||||||
|
|
||||||
const ctx: PluginCommandContext = {
|
const ctx: PluginCommandContext = {
|
||||||
senderId,
|
senderId,
|
||||||
@ -281,12 +416,50 @@ export async function executePluginCommand(params: {
|
|||||||
to: params.to,
|
to: params.to,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
messageThreadId: params.messageThreadId,
|
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
|
// Lock registry during execution to prevent concurrent modifications
|
||||||
registryLocked = true;
|
registryLocked = true;
|
||||||
try {
|
try {
|
||||||
const result = await command.handler(ctx);
|
const result = normalizePluginCommandResult(await command.handler(ctx));
|
||||||
|
if (!result) {
|
||||||
|
logVerbose(`Plugin command /${command.name} returned an invalid reply payload`);
|
||||||
|
return SAFE_COMMAND_FAILURE_REPLY;
|
||||||
|
}
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
|
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
|
||||||
);
|
);
|
||||||
@ -295,7 +468,7 @@ export async function executePluginCommand(params: {
|
|||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
|
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
|
||||||
// Don't leak internal error details - return a safe generic message
|
// Don't leak internal error details - return a safe generic message
|
||||||
return { text: "⚠️ Command failed. Please try again later." };
|
return SAFE_COMMAND_FAILURE_REPLY;
|
||||||
} finally {
|
} finally {
|
||||||
registryLocked = false;
|
registryLocked = false;
|
||||||
}
|
}
|
||||||
@ -341,9 +514,17 @@ export function getPluginCommandSpecs(provider?: string): Array<{
|
|||||||
description: string;
|
description: string;
|
||||||
acceptsArgs: boolean;
|
acceptsArgs: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
const providerName = provider?.trim().toLowerCase();
|
||||||
|
if (providerName && providerName !== "telegram" && providerName !== "discord") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||||
name: resolvePluginNativeName(cmd, provider),
|
name: resolvePluginNativeName(cmd, provider),
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
acceptsArgs: cmd.acceptsArgs ?? false,
|
acceptsArgs: cmd.acceptsArgs ?? false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __testing = {
|
||||||
|
resolveBindingConversationFromCommand,
|
||||||
|
};
|
||||||
|
|||||||
628
src/plugins/conversation-binding.test.ts
Normal file
628
src/plugins/conversation-binding.test.ts
Normal file
@ -0,0 +1,628 @@
|
|||||||
|
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("expires stale pending approval requests before resolution", async () => {
|
||||||
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||||
|
const request = await requestPluginConversationBinding({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginName: "Codex App Server",
|
||||||
|
pluginRoot: "/plugins/codex-a",
|
||||||
|
requestedBySenderId: "user-1",
|
||||||
|
conversation: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "channel:stale",
|
||||||
|
},
|
||||||
|
binding: { summary: "Bind this conversation." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.status).toBe("pending");
|
||||||
|
if (request.status !== "pending") {
|
||||||
|
throw new Error("expected pending bind request");
|
||||||
|
}
|
||||||
|
|
||||||
|
nowSpy.mockReturnValue(1_000 + 30 * 60_000 + 1);
|
||||||
|
const resolved = await resolvePluginConversationBindingApproval({
|
||||||
|
approvalId: request.approvalId,
|
||||||
|
decision: "allow-once",
|
||||||
|
senderId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual({ status: "expired" });
|
||||||
|
nowSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects bind requests on channels without a binding adapter", async () => {
|
||||||
|
const request = await requestPluginConversationBinding({
|
||||||
|
pluginId: "codex",
|
||||||
|
pluginName: "Codex App Server",
|
||||||
|
pluginRoot: "/plugins/codex-a",
|
||||||
|
requestedBySenderId: "user-1",
|
||||||
|
conversation: {
|
||||||
|
channel: "slack",
|
||||||
|
accountId: "default",
|
||||||
|
conversationId: "C123",
|
||||||
|
},
|
||||||
|
binding: { summary: "Bind this conversation." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
status: "error",
|
||||||
|
message: "This channel does not support plugin conversation binding.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 if (request.status === "bound") {
|
||||||
|
expect(request.binding.detachHint).toBe("/codex_detach");
|
||||||
|
} else {
|
||||||
|
throw new Error(`expected bound or pending bind request, got ${request.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
854
src/plugins/conversation-binding.ts
Normal file
854
src/plugins/conversation-binding.ts
Normal file
@ -0,0 +1,854 @@
|
|||||||
|
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>();
|
||||||
|
const PENDING_REQUEST_TTL_MS = 30 * 60_000;
|
||||||
|
const MAX_PENDING_REQUESTS = 512;
|
||||||
|
|
||||||
|
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>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prunePendingRequests(now = Date.now()): void {
|
||||||
|
for (const [id, request] of pendingRequests.entries()) {
|
||||||
|
if (now - request.requestedAt >= PENDING_REQUEST_TTL_MS) {
|
||||||
|
pendingRequests.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (pendingRequests.size > MAX_PENDING_REQUESTS) {
|
||||||
|
const oldest = pendingRequests.entries().next().value;
|
||||||
|
if (!oldest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pendingRequests.delete(oldest[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 capabilities = getSessionBindingService().getCapabilities({
|
||||||
|
channel: ref.channel,
|
||||||
|
accountId: ref.accountId,
|
||||||
|
});
|
||||||
|
if (!capabilities.bindSupported) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: "This channel does not support plugin conversation binding.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
prunePendingRequests();
|
||||||
|
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> {
|
||||||
|
prunePendingRequests();
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -5,6 +5,27 @@ export function createMockPluginRegistry(
|
|||||||
hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>,
|
hooks: Array<{ hookName: string; handler: (...args: unknown[]) => unknown }>,
|
||||||
): PluginRegistry {
|
): PluginRegistry {
|
||||||
return {
|
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[],
|
hooks: hooks as never[],
|
||||||
typedHooks: hooks.map((h) => ({
|
typedHooks: hooks.map((h) => ({
|
||||||
pluginId: "test-plugin",
|
pluginId: "test-plugin",
|
||||||
|
|||||||
@ -19,6 +19,9 @@ import type {
|
|||||||
PluginHookBeforePromptBuildEvent,
|
PluginHookBeforePromptBuildEvent,
|
||||||
PluginHookBeforePromptBuildResult,
|
PluginHookBeforePromptBuildResult,
|
||||||
PluginHookBeforeCompactionEvent,
|
PluginHookBeforeCompactionEvent,
|
||||||
|
PluginHookInboundClaimContext,
|
||||||
|
PluginHookInboundClaimEvent,
|
||||||
|
PluginHookInboundClaimResult,
|
||||||
PluginHookLlmInputEvent,
|
PluginHookLlmInputEvent,
|
||||||
PluginHookLlmOutputEvent,
|
PluginHookLlmOutputEvent,
|
||||||
PluginHookBeforeResetEvent,
|
PluginHookBeforeResetEvent,
|
||||||
@ -66,6 +69,9 @@ export type {
|
|||||||
PluginHookAgentEndEvent,
|
PluginHookAgentEndEvent,
|
||||||
PluginHookBeforeCompactionEvent,
|
PluginHookBeforeCompactionEvent,
|
||||||
PluginHookBeforeResetEvent,
|
PluginHookBeforeResetEvent,
|
||||||
|
PluginHookInboundClaimContext,
|
||||||
|
PluginHookInboundClaimEvent,
|
||||||
|
PluginHookInboundClaimResult,
|
||||||
PluginHookAfterCompactionEvent,
|
PluginHookAfterCompactionEvent,
|
||||||
PluginHookMessageContext,
|
PluginHookMessageContext,
|
||||||
PluginHookMessageReceivedEvent,
|
PluginHookMessageReceivedEvent,
|
||||||
@ -108,6 +114,25 @@ export type HookRunnerOptions = {
|
|||||||
catchErrors?: boolean;
|
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).
|
* 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));
|
.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.
|
* 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 });
|
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).
|
* Run a hook that doesn't return a value (fire-and-forget style).
|
||||||
* All handlers are executed in parallel for performance.
|
* All handlers are executed in parallel for performance.
|
||||||
@ -263,6 +302,123 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
return result;
|
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
|
// Agent Hooks
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@ -384,6 +540,47 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
// Message Hooks
|
// 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.
|
* Run message_received hook.
|
||||||
* Runs in parallel (fire-and-forget).
|
* Runs in parallel (fire-and-forget).
|
||||||
@ -734,6 +931,9 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
runAfterCompaction,
|
runAfterCompaction,
|
||||||
runBeforeReset,
|
runBeforeReset,
|
||||||
// Message hooks
|
// Message hooks
|
||||||
|
runInboundClaim,
|
||||||
|
runInboundClaimForPlugin,
|
||||||
|
runInboundClaimForPluginOutcome,
|
||||||
runMessageReceived,
|
runMessageReceived,
|
||||||
runMessageSending,
|
runMessageSending,
|
||||||
runMessageSent,
|
runMessageSent,
|
||||||
|
|||||||
349
src/plugins/interactive.test.ts
Normal file
349
src/plugins/interactive.test.ts
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
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("rejects unsupported interactive channels and malformed handlers", () => {
|
||||||
|
expect(
|
||||||
|
registerPluginInteractiveHandler("plugin-a", {
|
||||||
|
channel: "slack",
|
||||||
|
namespace: "codex",
|
||||||
|
handler: async () => ({ handled: true }),
|
||||||
|
} as unknown as Parameters<typeof registerPluginInteractiveHandler>[1]),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Interactive handler channel must be either "telegram" or "discord"',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
registerPluginInteractiveHandler("plugin-a", {
|
||||||
|
channel: "telegram",
|
||||||
|
namespace: "codex",
|
||||||
|
handler: "not-a-function",
|
||||||
|
} as unknown as Parameters<typeof registerPluginInteractiveHandler>[1]),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Interactive handler must be a function",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not share dedupe keys across channels", async () => {
|
||||||
|
const telegramHandler = vi.fn(async () => ({ handled: true }));
|
||||||
|
const discordHandler = vi.fn(async () => ({ handled: true }));
|
||||||
|
expect(
|
||||||
|
registerPluginInteractiveHandler("codex-plugin", {
|
||||||
|
channel: "telegram",
|
||||||
|
namespace: "codex",
|
||||||
|
handler: telegramHandler,
|
||||||
|
}),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
expect(
|
||||||
|
registerPluginInteractiveHandler("codex-plugin", {
|
||||||
|
channel: "discord",
|
||||||
|
namespace: "codex",
|
||||||
|
handler: discordHandler,
|
||||||
|
}),
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const telegramResult = await dispatchPluginInteractiveHandler({
|
||||||
|
channel: "telegram",
|
||||||
|
data: "codex:resume",
|
||||||
|
callbackId: "same-id",
|
||||||
|
ctx: {
|
||||||
|
accountId: "default",
|
||||||
|
callbackId: "same-id",
|
||||||
|
conversationId: "chat-1",
|
||||||
|
senderId: "user-1",
|
||||||
|
senderUsername: "ada",
|
||||||
|
isGroup: false,
|
||||||
|
isForum: false,
|
||||||
|
auth: { isAuthorizedSender: true },
|
||||||
|
callbackMessage: {
|
||||||
|
messageId: 1,
|
||||||
|
chatId: "chat-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
respond: {
|
||||||
|
reply: vi.fn(async () => {}),
|
||||||
|
editMessage: vi.fn(async () => {}),
|
||||||
|
editButtons: vi.fn(async () => {}),
|
||||||
|
clearButtons: vi.fn(async () => {}),
|
||||||
|
deleteMessage: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const discordResult = await dispatchPluginInteractiveHandler({
|
||||||
|
channel: "discord",
|
||||||
|
data: "codex:resume",
|
||||||
|
interactionId: "same-id",
|
||||||
|
ctx: {
|
||||||
|
accountId: "default",
|
||||||
|
interactionId: "same-id",
|
||||||
|
conversationId: "channel-1",
|
||||||
|
senderId: "user-1",
|
||||||
|
senderUsername: "ada",
|
||||||
|
auth: { isAuthorizedSender: true },
|
||||||
|
interaction: {
|
||||||
|
kind: "button",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
respond: {
|
||||||
|
acknowledge: vi.fn(async () => {}),
|
||||||
|
reply: vi.fn(async () => {}),
|
||||||
|
followUp: vi.fn(async () => {}),
|
||||||
|
editMessage: vi.fn(async () => {}),
|
||||||
|
clearComponents: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(telegramResult).toEqual({ matched: true, handled: true, duplicate: false });
|
||||||
|
expect(discordResult).toEqual({ matched: true, handled: true, duplicate: false });
|
||||||
|
expect(telegramHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(discordHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consume dedupe keys when a handler declines", async () => {
|
||||||
|
const handler = vi
|
||||||
|
.fn(async () => ({ handled: false }))
|
||||||
|
.mockResolvedValueOnce({ handled: false })
|
||||||
|
.mockResolvedValueOnce({ 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-decline",
|
||||||
|
ctx: {
|
||||||
|
accountId: "default",
|
||||||
|
interactionId: "ix-decline",
|
||||||
|
conversationId: "channel-1",
|
||||||
|
senderId: "user-1",
|
||||||
|
senderUsername: "ada",
|
||||||
|
auth: { isAuthorizedSender: true },
|
||||||
|
interaction: {
|
||||||
|
kind: "button" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
respond: {
|
||||||
|
acknowledge: vi.fn(async () => {}),
|
||||||
|
reply: vi.fn(async () => {}),
|
||||||
|
followUp: vi.fn(async () => {}),
|
||||||
|
editMessage: vi.fn(async () => {}),
|
||||||
|
clearComponents: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({
|
||||||
|
matched: true,
|
||||||
|
handled: false,
|
||||||
|
duplicate: false,
|
||||||
|
});
|
||||||
|
await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({
|
||||||
|
matched: true,
|
||||||
|
handled: true,
|
||||||
|
duplicate: false,
|
||||||
|
});
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
386
src/plugins/interactive.ts
Normal file
386
src/plugins/interactive.ts
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
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 normalizeInteractiveChannel(channel: unknown): "telegram" | "discord" | null {
|
||||||
|
if (channel === "telegram" || channel === "discord") {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 channel = normalizeInteractiveChannel(registration.channel);
|
||||||
|
if (!channel) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Interactive handler channel must be either "telegram" or "discord"',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const namespace = normalizeNamespace(registration.namespace);
|
||||||
|
const validationError = validateNamespace(namespace);
|
||||||
|
if (validationError) {
|
||||||
|
return { ok: false, error: validationError };
|
||||||
|
}
|
||||||
|
if (typeof registration.handler !== "function") {
|
||||||
|
return { ok: false, error: "Interactive handler must be a function" };
|
||||||
|
}
|
||||||
|
const key = toRegistryKey(channel, namespace);
|
||||||
|
const existing = interactiveHandlers.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (channel === "telegram") {
|
||||||
|
const telegramRegistration = registration as PluginInteractiveTelegramHandlerRegistration;
|
||||||
|
interactiveHandlers.set(key, {
|
||||||
|
...telegramRegistration,
|
||||||
|
namespace,
|
||||||
|
channel: "telegram",
|
||||||
|
pluginId,
|
||||||
|
pluginName: opts?.pluginName,
|
||||||
|
pluginRoot: opts?.pluginRoot,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const discordRegistration = registration as PluginInteractiveDiscordHandlerRegistration;
|
||||||
|
interactiveHandlers.set(key, {
|
||||||
|
...discordRegistration,
|
||||||
|
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 dedupeId =
|
||||||
|
params.channel === "telegram" ? params.callbackId?.trim() : params.interactionId?.trim();
|
||||||
|
const dedupeKey = dedupeId ? `${params.channel}:${match.namespace}:${dedupeId}` : undefined;
|
||||||
|
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 && (resolved?.handled ?? true)) {
|
||||||
|
callbackDedupe.check(dedupeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
handled: resolved?.handled ?? true,
|
||||||
|
duplicate: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
} from "./config-state.js";
|
} from "./config-state.js";
|
||||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||||
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
||||||
|
import { clearPluginInteractiveHandlers } from "./interactive.js";
|
||||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||||
import { isPathInside, safeStatSync } from "./path-safety.js";
|
import { isPathInside, safeStatSync } from "./path-safety.js";
|
||||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||||
@ -317,6 +318,7 @@ function createPluginRecord(params: {
|
|||||||
description?: string;
|
description?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
origin: PluginRecord["origin"];
|
origin: PluginRecord["origin"];
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -328,6 +330,7 @@ function createPluginRecord(params: {
|
|||||||
description: params.description,
|
description: params.description,
|
||||||
version: params.version,
|
version: params.version,
|
||||||
source: params.source,
|
source: params.source,
|
||||||
|
rootDir: params.rootDir,
|
||||||
origin: params.origin,
|
origin: params.origin,
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
enabled: params.enabled,
|
enabled: params.enabled,
|
||||||
@ -653,6 +656,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
|
|
||||||
// Clear previously registered plugin commands before reloading
|
// Clear previously registered plugin commands before reloading
|
||||||
clearPluginCommands();
|
clearPluginCommands();
|
||||||
|
clearPluginInteractiveHandlers();
|
||||||
|
|
||||||
// Lazily initialize the runtime so startup paths that discover/skip plugins do
|
// Lazily initialize the runtime so startup paths that discover/skip plugins do
|
||||||
// not eagerly load every channel runtime dependency.
|
// not eagerly load every channel runtime dependency.
|
||||||
@ -782,6 +786,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
description: manifestRecord.description,
|
description: manifestRecord.description,
|
||||||
version: manifestRecord.version,
|
version: manifestRecord.version,
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
|
rootDir: candidate.rootDir,
|
||||||
origin: candidate.origin,
|
origin: candidate.origin,
|
||||||
workspaceDir: candidate.workspaceDir,
|
workspaceDir: candidate.workspaceDir,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -806,6 +811,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
description: manifestRecord.description,
|
description: manifestRecord.description,
|
||||||
version: manifestRecord.version,
|
version: manifestRecord.version,
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
|
rootDir: candidate.rootDir,
|
||||||
origin: candidate.origin,
|
origin: candidate.origin,
|
||||||
workspaceDir: candidate.workspaceDir,
|
workspaceDir: candidate.workspaceDir,
|
||||||
enabled: enableState.enabled,
|
enabled: enableState.enabled,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { resolveUserPath } from "../utils.js";
|
|||||||
import { registerPluginCommand } from "./commands.js";
|
import { registerPluginCommand } from "./commands.js";
|
||||||
import { normalizePluginHttpPath } from "./http-path.js";
|
import { normalizePluginHttpPath } from "./http-path.js";
|
||||||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||||
|
import { registerPluginInteractiveHandler } from "./interactive.js";
|
||||||
import { normalizeRegisteredProvider } from "./provider-validation.js";
|
import { normalizeRegisteredProvider } from "./provider-validation.js";
|
||||||
import type { PluginRuntime } from "./runtime/types.js";
|
import type { PluginRuntime } from "./runtime/types.js";
|
||||||
import { defaultSlotIdForKey } from "./slots.js";
|
import { defaultSlotIdForKey } from "./slots.js";
|
||||||
@ -47,17 +48,21 @@ import type {
|
|||||||
|
|
||||||
export type PluginToolRegistration = {
|
export type PluginToolRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
factory: OpenClawPluginToolFactory;
|
factory: OpenClawPluginToolFactory;
|
||||||
names: string[];
|
names: string[];
|
||||||
optional: boolean;
|
optional: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginCliRegistration = {
|
export type PluginCliRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
register: OpenClawPluginCliRegistrar;
|
register: OpenClawPluginCliRegistrar;
|
||||||
commands: string[];
|
commands: string[];
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginHttpRouteRegistration = {
|
export type PluginHttpRouteRegistration = {
|
||||||
@ -71,15 +76,19 @@ export type PluginHttpRouteRegistration = {
|
|||||||
|
|
||||||
export type PluginChannelRegistration = {
|
export type PluginChannelRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
plugin: ChannelPlugin;
|
plugin: ChannelPlugin;
|
||||||
dock?: ChannelDock;
|
dock?: ChannelDock;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginProviderRegistration = {
|
export type PluginProviderRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
provider: ProviderPlugin;
|
provider: ProviderPlugin;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginHookRegistration = {
|
export type PluginHookRegistration = {
|
||||||
@ -87,18 +96,23 @@ export type PluginHookRegistration = {
|
|||||||
entry: HookEntry;
|
entry: HookEntry;
|
||||||
events: string[];
|
events: string[];
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginServiceRegistration = {
|
export type PluginServiceRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
service: OpenClawPluginService;
|
service: OpenClawPluginService;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginCommandRegistration = {
|
export type PluginCommandRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
pluginName?: string;
|
||||||
command: OpenClawPluginCommandDefinition;
|
command: OpenClawPluginCommandDefinition;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginRecord = {
|
export type PluginRecord = {
|
||||||
@ -108,6 +122,7 @@ export type PluginRecord = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
kind?: PluginKind;
|
kind?: PluginKind;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
origin: PluginOrigin;
|
origin: PluginOrigin;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -212,10 +227,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
}
|
}
|
||||||
registry.tools.push({
|
registry.tools.push({
|
||||||
pluginId: record.id,
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
factory,
|
factory,
|
||||||
names: normalized,
|
names: normalized,
|
||||||
optional,
|
optional,
|
||||||
source: record.source,
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -443,9 +460,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
record.channelIds.push(id);
|
record.channelIds.push(id);
|
||||||
registry.channels.push({
|
registry.channels.push({
|
||||||
pluginId: record.id,
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
plugin,
|
plugin,
|
||||||
dock: normalized.dock,
|
dock: normalized.dock,
|
||||||
source: record.source,
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -473,8 +492,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
record.providerIds.push(id);
|
record.providerIds.push(id);
|
||||||
registry.providers.push({
|
registry.providers.push({
|
||||||
pluginId: record.id,
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
provider: normalizedProvider,
|
provider: normalizedProvider,
|
||||||
source: record.source,
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -509,9 +530,11 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
record.cliCommands.push(...commands);
|
record.cliCommands.push(...commands);
|
||||||
registry.cliRegistrars.push({
|
registry.cliRegistrars.push({
|
||||||
pluginId: record.id,
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
register: registrar,
|
register: registrar,
|
||||||
commands,
|
commands,
|
||||||
source: record.source,
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -533,8 +556,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
record.services.push(id);
|
record.services.push(id);
|
||||||
registry.services.push({
|
registry.services.push({
|
||||||
pluginId: record.id,
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
service,
|
service,
|
||||||
source: record.source,
|
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)
|
// 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) {
|
if (!result.ok) {
|
||||||
pushDiagnostic({
|
pushDiagnostic({
|
||||||
level: "error",
|
level: "error",
|
||||||
@ -565,8 +593,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
record.commands.push(name);
|
record.commands.push(name);
|
||||||
registry.commands.push({
|
registry.commands.push({
|
||||||
pluginId: record.id,
|
pluginId: record.id,
|
||||||
|
pluginName: record.name,
|
||||||
command,
|
command,
|
||||||
source: record.source,
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -640,6 +670,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
version: record.version,
|
version: record.version,
|
||||||
description: record.description,
|
description: record.description,
|
||||||
source: record.source,
|
source: record.source,
|
||||||
|
rootDir: record.rootDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
pluginConfig: params.pluginConfig,
|
pluginConfig: params.pluginConfig,
|
||||||
runtime: registryParams.runtime,
|
runtime: registryParams.runtime,
|
||||||
@ -653,6 +684,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||||
registerService: (service) => registerService(record, service),
|
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),
|
registerCommand: (command) => registerCommand(record, command),
|
||||||
registerContextEngine: (id, factory) => {
|
registerContextEngine: (id, factory) => {
|
||||||
if (id === defaultSlotIdForKey("contextEngine")) {
|
if (id === defaultSlotIdForKey("contextEngine")) {
|
||||||
|
|||||||
@ -7,7 +7,18 @@ import { monitorDiscordProvider } from "../../../extensions/discord/src/monitor.
|
|||||||
import { probeDiscord } from "../../../extensions/discord/src/probe.js";
|
import { probeDiscord } from "../../../extensions/discord/src/probe.js";
|
||||||
import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js";
|
import { resolveDiscordChannelAllowlist } from "../../../extensions/discord/src/resolve-channels.js";
|
||||||
import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.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 { monitorIMessageProvider } from "../../../extensions/imessage/src/monitor.js";
|
||||||
import { probeIMessage } from "../../../extensions/imessage/src/probe.js";
|
import { probeIMessage } from "../../../extensions/imessage/src/probe.js";
|
||||||
import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js";
|
import { sendMessageIMessage } from "../../../extensions/imessage/src/send.js";
|
||||||
@ -29,7 +40,17 @@ import {
|
|||||||
} from "../../../extensions/telegram/src/audit.js";
|
} from "../../../extensions/telegram/src/audit.js";
|
||||||
import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js";
|
import { monitorTelegramProvider } from "../../../extensions/telegram/src/monitor.js";
|
||||||
import { probeTelegram } from "../../../extensions/telegram/src/probe.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 { resolveTelegramToken } from "../../../extensions/telegram/src/token.js";
|
||||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||||
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
|
||||||
@ -113,6 +134,8 @@ import {
|
|||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.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 { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
|
||||||
import type { PluginRuntime } from "./types.js";
|
import type { PluginRuntime } from "./types.js";
|
||||||
|
|
||||||
@ -207,9 +230,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
|||||||
probeDiscord,
|
probeDiscord,
|
||||||
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
|
resolveChannelAllowlist: resolveDiscordChannelAllowlist,
|
||||||
resolveUserAllowlist: resolveDiscordUserAllowlist,
|
resolveUserAllowlist: resolveDiscordUserAllowlist,
|
||||||
|
sendComponentMessage: sendDiscordComponentMessage,
|
||||||
sendMessageDiscord,
|
sendMessageDiscord,
|
||||||
sendPollDiscord,
|
sendPollDiscord,
|
||||||
monitorDiscordProvider,
|
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: {
|
slack: {
|
||||||
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
|
listDirectoryGroupsLive: listSlackDirectoryGroupsLive,
|
||||||
@ -230,6 +277,33 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
|
|||||||
sendPollTelegram,
|
sendPollTelegram,
|
||||||
monitorTelegramProvider,
|
monitorTelegramProvider,
|
||||||
messageActions: telegramMessageActions,
|
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: {
|
signal: {
|
||||||
probeSignal,
|
probeSignal,
|
||||||
|
|||||||
57
src/plugins/runtime/runtime-discord-typing.test.ts
Normal file
57
src/plugins/runtime/runtime-discord-typing.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/plugins/runtime/runtime-discord-typing.ts
Normal file
62
src/plugins/runtime/runtime-discord-typing.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
83
src/plugins/runtime/runtime-telegram-typing.test.ts
Normal file
83
src/plugins/runtime/runtime-telegram-typing.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/plugins/runtime/runtime-telegram-typing.ts
Normal file
60
src/plugins/runtime/runtime-telegram-typing.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -94,9 +94,30 @@ export type PluginRuntimeChannel = {
|
|||||||
probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord;
|
probeDiscord: typeof import("../../../extensions/discord/src/probe.js").probeDiscord;
|
||||||
resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist;
|
resolveChannelAllowlist: typeof import("../../../extensions/discord/src/resolve-channels.js").resolveDiscordChannelAllowlist;
|
||||||
resolveUserAllowlist: typeof import("../../../extensions/discord/src/resolve-users.js").resolveDiscordUserAllowlist;
|
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;
|
sendMessageDiscord: typeof import("../../../extensions/discord/src/send.js").sendMessageDiscord;
|
||||||
sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord;
|
sendPollDiscord: typeof import("../../../extensions/discord/src/send.js").sendPollDiscord;
|
||||||
monitorDiscordProvider: typeof import("../../../extensions/discord/src/monitor.js").monitorDiscordProvider;
|
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: {
|
slack: {
|
||||||
listDirectoryGroupsLive: typeof import("../../../extensions/slack/src/directory-live.js").listSlackDirectoryGroupsLive;
|
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;
|
sendPollTelegram: typeof import("../../../extensions/telegram/src/send.js").sendPollTelegram;
|
||||||
monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider;
|
monitorTelegramProvider: typeof import("../../../extensions/telegram/src/monitor.js").monitorTelegramProvider;
|
||||||
messageActions: typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions;
|
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: {
|
signal: {
|
||||||
probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal;
|
probeSignal: typeof import("../../../extensions/signal/src/probe.js").probeSignal;
|
||||||
|
|||||||
@ -19,7 +19,12 @@ import { startPluginServices } from "./services.js";
|
|||||||
function createRegistry(services: OpenClawPluginService[]) {
|
function createRegistry(services: OpenClawPluginService[]) {
|
||||||
const registry = createEmptyPluginRegistry();
|
const registry = createEmptyPluginRegistry();
|
||||||
for (const service of services) {
|
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;
|
return registry;
|
||||||
}
|
}
|
||||||
@ -116,7 +121,9 @@ describe("startPluginServices", () => {
|
|||||||
await handle.stop();
|
await handle.stop();
|
||||||
|
|
||||||
expect(mockedLogger.error).toHaveBeenCalledWith(
|
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(mockedLogger.warn).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("plugin service stop failed (service-stop-fail):"),
|
expect.stringContaining("plugin service stop failed (service-stop-fail):"),
|
||||||
|
|||||||
@ -54,7 +54,11 @@ export async function startPluginServices(params: {
|
|||||||
stop: service.stop ? () => service.stop?.(serviceContext) : undefined,
|
stop: service.stop ? () => service.stop?.(serviceContext) : undefined,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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}` : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { TopLevelComponents } from "@buape/carbon";
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import type {
|
import type {
|
||||||
@ -269,8 +270,48 @@ export type PluginCommandContext = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
/** Thread/topic id if available */
|
/** Thread/topic id if available */
|
||||||
messageThreadId?: number;
|
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.
|
* Result returned by a plugin command handler.
|
||||||
*/
|
*/
|
||||||
@ -305,6 +346,111 @@ export type OpenClawPluginCommandDefinition = {
|
|||||||
handler: PluginCommandHandler;
|
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 OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
|
||||||
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
|
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
|
||||||
|
|
||||||
@ -369,6 +515,7 @@ export type OpenClawPluginApi = {
|
|||||||
version?: string;
|
version?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
rootDir?: string;
|
||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
pluginConfig?: Record<string, unknown>;
|
pluginConfig?: Record<string, unknown>;
|
||||||
runtime: PluginRuntime;
|
runtime: PluginRuntime;
|
||||||
@ -388,6 +535,7 @@ export type OpenClawPluginApi = {
|
|||||||
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||||
registerService: (service: OpenClawPluginService) => void;
|
registerService: (service: OpenClawPluginService) => void;
|
||||||
registerProvider: (provider: ProviderPlugin) => void;
|
registerProvider: (provider: ProviderPlugin) => void;
|
||||||
|
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
|
||||||
/**
|
/**
|
||||||
* Register a custom command that bypasses the LLM agent.
|
* Register a custom command that bypasses the LLM agent.
|
||||||
* Plugin commands are processed before built-in commands and before agent invocation.
|
* Plugin commands are processed before built-in commands and before agent invocation.
|
||||||
@ -431,6 +579,7 @@ export type PluginHookName =
|
|||||||
| "before_compaction"
|
| "before_compaction"
|
||||||
| "after_compaction"
|
| "after_compaction"
|
||||||
| "before_reset"
|
| "before_reset"
|
||||||
|
| "inbound_claim"
|
||||||
| "message_received"
|
| "message_received"
|
||||||
| "message_sending"
|
| "message_sending"
|
||||||
| "message_sent"
|
| "message_sent"
|
||||||
@ -457,6 +606,7 @@ export const PLUGIN_HOOK_NAMES = [
|
|||||||
"before_compaction",
|
"before_compaction",
|
||||||
"after_compaction",
|
"after_compaction",
|
||||||
"before_reset",
|
"before_reset",
|
||||||
|
"inbound_claim",
|
||||||
"message_received",
|
"message_received",
|
||||||
"message_sending",
|
"message_sending",
|
||||||
"message_sent",
|
"message_sent",
|
||||||
@ -665,6 +815,37 @@ export type PluginHookMessageContext = {
|
|||||||
conversationId?: string;
|
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
|
// message_received hook
|
||||||
export type PluginHookMessageReceivedEvent = {
|
export type PluginHookMessageReceivedEvent = {
|
||||||
from: string;
|
from: string;
|
||||||
@ -921,6 +1102,10 @@ export type PluginHookHandlerMap = {
|
|||||||
event: PluginHookBeforeResetEvent,
|
event: PluginHookBeforeResetEvent,
|
||||||
ctx: PluginHookAgentContext,
|
ctx: PluginHookAgentContext,
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
|
inbound_claim: (
|
||||||
|
event: PluginHookInboundClaimEvent,
|
||||||
|
ctx: PluginHookInboundClaimContext,
|
||||||
|
) => Promise<PluginHookInboundClaimResult | void> | PluginHookInboundClaimResult | void;
|
||||||
message_received: (
|
message_received: (
|
||||||
event: PluginHookMessageReceivedEvent,
|
event: PluginHookMessageReceivedEvent,
|
||||||
ctx: PluginHookMessageContext,
|
ctx: PluginHookMessageContext,
|
||||||
|
|||||||
175
src/plugins/wired-hooks-inbound-claim.test.ts
Normal file
175
src/plugins/wired-hooks-inbound-claim.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user