Skip to content

Commit aa1454d

Browse files
huntharovincentkoc
andauthored
Plugins: broaden plugin surface for Codex App Server (openclaw#45318)
* Plugins: add inbound claim and Telegram interaction seams * Plugins: add Discord interaction surface * Chore: fix formatting after plugin rebase * fix(hooks): preserve observers after inbound claim * test(hooks): cover claimed inbound observer delivery * fix(plugins): harden typing lease refreshes * fix(discord): pass real auth to plugin interactions * fix(plugins): remove raw session binding runtime exposure * fix(plugins): tighten interactive callback handling * Plugins: gate conversation binding with approvals * Plugins: migrate legacy plugin binding records * Plugins/phone-control: update test command context * Plugins: migrate legacy binding ids * Plugins: migrate legacy codex session bindings * Discord: fix plugin interaction handling * Discord: support direct plugin conversation binds * Plugins: preserve Discord command bind targets * Tests: fix plugin binding and interactive fallout * Discord: stabilize directory lookup tests * Discord: route bound DMs to plugins * Discord: restore plugin bindings after restart * Telegram: persist detached plugin bindings * Plugins: limit binding APIs to Telegram and Discord * Plugins: harden bound conversation routing * Plugins: fix extension target imports * Plugins: fix Telegram runtime extension imports * Plugins: format rebased binding handlers * Discord: bind group DM interactions by channel --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 4eee827 commit aa1454d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+5322
-123
lines changed

extensions/discord/src/components.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ describe("discord components", () => {
1919
blocks: [
2020
{
2121
type: "actions",
22-
buttons: [{ label: "Approve", style: "success" }],
22+
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
2323
},
2424
],
2525
modal: {
2626
title: "Details",
27+
callbackData: "codex:modal",
28+
allowedUsers: ["discord:user-1"],
2729
fields: [{ type: "text", label: "Requester" }],
2830
},
2931
});
@@ -39,6 +41,11 @@ describe("discord components", () => {
3941

4042
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
4143
expect(trigger?.modalId).toBe(result.modals[0]?.id);
44+
expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe(
45+
"codex:approve",
46+
);
47+
expect(result.modals[0]?.callbackData).toBe("codex:modal");
48+
expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]);
4249
});
4350

4451
it("requires options for modal select fields", () => {

extensions/discord/src/components.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type DiscordComponentButtonSpec = {
4646
label: string;
4747
style?: DiscordComponentButtonStyle;
4848
url?: string;
49+
callbackData?: string;
4950
emoji?: {
5051
name: string;
5152
id?: string;
@@ -70,10 +71,12 @@ export type DiscordComponentSelectOption = {
7071

7172
export type DiscordComponentSelectSpec = {
7273
type?: DiscordComponentSelectType;
74+
callbackData?: string;
7375
placeholder?: string;
7476
minValues?: number;
7577
maxValues?: number;
7678
options?: DiscordComponentSelectOption[];
79+
allowedUsers?: string[];
7780
};
7881

7982
export type DiscordComponentSectionAccessory =
@@ -136,8 +139,10 @@ export type DiscordModalFieldSpec = {
136139

137140
export type DiscordModalSpec = {
138141
title: string;
142+
callbackData?: string;
139143
triggerLabel?: string;
140144
triggerStyle?: DiscordComponentButtonStyle;
145+
allowedUsers?: string[];
141146
fields: DiscordModalFieldSpec[];
142147
};
143148

@@ -156,6 +161,7 @@ export type DiscordComponentEntry = {
156161
id: string;
157162
kind: "button" | "select" | "modal-trigger";
158163
label: string;
164+
callbackData?: string;
159165
selectType?: DiscordComponentSelectType;
160166
options?: Array<{ value: string; label: string }>;
161167
modalId?: string;
@@ -188,6 +194,7 @@ export type DiscordModalFieldDefinition = {
188194
export type DiscordModalEntry = {
189195
id: string;
190196
title: string;
197+
callbackData?: string;
191198
fields: DiscordModalFieldDefinition[];
192199
sessionKey?: string;
193200
agentId?: string;
@@ -196,6 +203,7 @@ export type DiscordModalEntry = {
196203
messageId?: string;
197204
createdAt?: number;
198205
expiresAt?: number;
206+
allowedUsers?: string[];
199207
};
200208

201209
export type DiscordComponentBuildResult = {
@@ -364,6 +372,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe
364372
label: readString(obj.label, `${label}.label`),
365373
style,
366374
url,
375+
callbackData: readOptionalString(obj.callbackData),
367376
emoji:
368377
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
369378
? {
@@ -395,10 +404,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
395404
}
396405
return {
397406
type,
407+
callbackData: readOptionalString(obj.callbackData),
398408
placeholder: readOptionalString(obj.placeholder),
399409
minValues: readOptionalNumber(obj.minValues),
400410
maxValues: readOptionalNumber(obj.maxValues),
401411
options: parseSelectOptions(obj.options, `${label}.options`),
412+
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
402413
};
403414
}
404415

@@ -578,8 +589,10 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
578589
);
579590
modal = {
580591
title: readString(modalObj.title, "components.modal.title"),
592+
callbackData: readOptionalString(modalObj.callbackData),
581593
triggerLabel: readOptionalString(modalObj.triggerLabel),
582594
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
595+
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
583596
fields,
584597
};
585598
}
@@ -718,6 +731,7 @@ function createButtonComponent(params: {
718731
id: componentId,
719732
kind: params.modalId ? "modal-trigger" : "button",
720733
label: params.spec.label,
734+
callbackData: params.spec.callbackData,
721735
modalId: params.modalId,
722736
allowedUsers: params.spec.allowedUsers,
723737
},
@@ -758,8 +772,10 @@ function createSelectComponent(params: {
758772
id: componentId,
759773
kind: "select",
760774
label: params.spec.placeholder ?? "select",
775+
callbackData: params.spec.callbackData,
761776
selectType: "string",
762777
options: options.map((option) => ({ value: option.value, label: option.label })),
778+
allowedUsers: params.spec.allowedUsers,
763779
},
764780
};
765781
}
@@ -777,7 +793,9 @@ function createSelectComponent(params: {
777793
id: componentId,
778794
kind: "select",
779795
label: params.spec.placeholder ?? "user select",
796+
callbackData: params.spec.callbackData,
780797
selectType: "user",
798+
allowedUsers: params.spec.allowedUsers,
781799
},
782800
};
783801
}
@@ -795,7 +813,9 @@ function createSelectComponent(params: {
795813
id: componentId,
796814
kind: "select",
797815
label: params.spec.placeholder ?? "role select",
816+
callbackData: params.spec.callbackData,
798817
selectType: "role",
818+
allowedUsers: params.spec.allowedUsers,
799819
},
800820
};
801821
}
@@ -813,7 +833,9 @@ function createSelectComponent(params: {
813833
id: componentId,
814834
kind: "select",
815835
label: params.spec.placeholder ?? "mentionable select",
836+
callbackData: params.spec.callbackData,
816837
selectType: "mentionable",
838+
allowedUsers: params.spec.allowedUsers,
817839
},
818840
};
819841
}
@@ -830,7 +852,9 @@ function createSelectComponent(params: {
830852
id: componentId,
831853
kind: "select",
832854
label: params.spec.placeholder ?? "channel select",
855+
callbackData: params.spec.callbackData,
833856
selectType: "channel",
857+
allowedUsers: params.spec.allowedUsers,
834858
},
835859
};
836860
}
@@ -1047,16 +1071,19 @@ export function buildDiscordComponentMessage(params: {
10471071
modals.push({
10481072
id: modalId,
10491073
title: params.spec.modal.title,
1074+
callbackData: params.spec.modal.callbackData,
10501075
fields,
10511076
sessionKey: params.sessionKey,
10521077
agentId: params.agentId,
10531078
accountId: params.accountId,
10541079
reusable: params.spec.reusable,
1080+
allowedUsers: params.spec.modal.allowedUsers,
10551081
});
10561082

10571083
const triggerSpec: DiscordComponentButtonSpec = {
10581084
label: params.spec.modal.triggerLabel ?? "Open form",
10591085
style: params.spec.modal.triggerStyle ?? "primary",
1086+
allowedUsers: params.spec.modal.allowedUsers,
10601087
};
10611088

10621089
const { component, entry } = createButtonComponent({

extensions/discord/src/directory-live.test.ts

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,72 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
3-
4-
const mocks = vi.hoisted(() => ({
5-
fetchDiscord: vi.fn(),
6-
normalizeDiscordToken: vi.fn((token: string) => token.trim()),
7-
resolveDiscordAccount: vi.fn(),
8-
}));
9-
10-
vi.mock("./accounts.js", () => ({
11-
resolveDiscordAccount: mocks.resolveDiscordAccount,
12-
}));
13-
14-
vi.mock("./api.js", () => ({
15-
fetchDiscord: mocks.fetchDiscord,
16-
}));
17-
18-
vi.mock("./token.js", () => ({
19-
normalizeDiscordToken: mocks.normalizeDiscordToken,
20-
}));
21-
3+
import type { OpenClawConfig } from "../../../src/config/config.js";
224
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
235

246
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
257
return {
26-
cfg: {} as DirectoryConfigParams["cfg"],
8+
cfg: {
9+
channels: {
10+
discord: {
11+
token: "test-token",
12+
},
13+
},
14+
} as OpenClawConfig,
15+
accountId: "default",
2716
...overrides,
2817
};
2918
}
3019

20+
function jsonResponse(value: unknown): Response {
21+
return new Response(JSON.stringify(value), {
22+
status: 200,
23+
headers: { "content-type": "application/json" },
24+
});
25+
}
26+
3127
describe("discord directory live lookups", () => {
3228
beforeEach(() => {
33-
vi.clearAllMocks();
34-
mocks.resolveDiscordAccount.mockReturnValue({ token: "test-token" });
35-
mocks.normalizeDiscordToken.mockImplementation((token: string) => token.trim());
29+
vi.restoreAllMocks();
3630
});
3731

3832
it("returns empty group directory when token is missing", async () => {
39-
mocks.normalizeDiscordToken.mockReturnValue("");
40-
41-
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "general" }));
33+
const rows = await listDiscordDirectoryGroupsLive({
34+
...makeParams(),
35+
cfg: { channels: { discord: { token: "" } } } as OpenClawConfig,
36+
query: "general",
37+
});
4238

4339
expect(rows).toEqual([]);
44-
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
4540
});
4641

4742
it("returns empty peer directory without query and skips guild listing", async () => {
43+
const fetchSpy = vi.spyOn(globalThis, "fetch");
44+
4845
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " }));
4946

5047
expect(rows).toEqual([]);
51-
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
48+
expect(fetchSpy).not.toHaveBeenCalled();
5249
});
5350

5451
it("filters group channels by query and respects limit", async () => {
55-
mocks.fetchDiscord.mockImplementation(async (path: string) => {
56-
if (path === "/users/@me/guilds") {
57-
return [
52+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
53+
const url = String(input);
54+
if (url.endsWith("/users/@me/guilds")) {
55+
return jsonResponse([
5856
{ id: "g1", name: "Guild 1" },
5957
{ id: "g2", name: "Guild 2" },
60-
];
58+
]);
6159
}
62-
if (path === "/guilds/g1/channels") {
63-
return [
60+
if (url.endsWith("/guilds/g1/channels")) {
61+
return jsonResponse([
6462
{ id: "c1", name: "general" },
6563
{ id: "c2", name: "random" },
66-
];
64+
]);
6765
}
68-
if (path === "/guilds/g2/channels") {
69-
return [{ id: "c3", name: "announcements" }];
66+
if (url.endsWith("/guilds/g2/channels")) {
67+
return jsonResponse([{ id: "c3", name: "announcements" }]);
7068
}
71-
return [];
69+
return jsonResponse([]);
7270
});
7371

7472
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "an", limit: 2 }));
@@ -80,21 +78,22 @@ describe("discord directory live lookups", () => {
8078
});
8179

8280
it("returns ranked peer results and caps member search by limit", async () => {
83-
mocks.fetchDiscord.mockImplementation(async (path: string) => {
84-
if (path === "/users/@me/guilds") {
85-
return [{ id: "g1", name: "Guild 1" }];
81+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
82+
const url = String(input);
83+
if (url.endsWith("/users/@me/guilds")) {
84+
return jsonResponse([{ id: "g1", name: "Guild 1" }]);
8685
}
87-
if (path.startsWith("/guilds/g1/members/search?")) {
88-
const params = new URLSearchParams(path.split("?")[1] ?? "");
86+
if (url.includes("/guilds/g1/members/search?")) {
87+
const params = new URL(url).searchParams;
8988
expect(params.get("query")).toBe("alice");
9089
expect(params.get("limit")).toBe("2");
91-
return [
90+
return jsonResponse([
9291
{ user: { id: "u1", username: "alice", bot: false }, nick: "Ali" },
9392
{ user: { id: "u2", username: "alice-bot", bot: true }, nick: null },
9493
{ user: { id: "u3", username: "ignored", bot: false }, nick: null },
95-
];
94+
]);
9695
}
97-
return [];
96+
return jsonResponse([]);
9897
});
9998

10099
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 }));

0 commit comments

Comments
 (0)