@openclaw/discord 2026.2.19 → 2026.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/channel.test.ts +36 -0
- package/src/channel.ts +21 -3
- package/src/subagent-hooks.test.ts +388 -0
- package/src/subagent-hooks.ts +152 -0
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
2
2
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
3
|
import { discordPlugin } from "./src/channel.js";
|
|
4
4
|
import { setDiscordRuntime } from "./src/runtime.js";
|
|
5
|
+
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
|
|
5
6
|
|
|
6
7
|
const plugin = {
|
|
7
8
|
id: "discord",
|
|
@@ -11,6 +12,7 @@ const plugin = {
|
|
|
11
12
|
register(api: OpenClawPluginApi) {
|
|
12
13
|
setDiscordRuntime(api.runtime);
|
|
13
14
|
api.registerChannel({ plugin: discordPlugin });
|
|
15
|
+
registerDiscordSubagentHooks(api);
|
|
14
16
|
},
|
|
15
17
|
};
|
|
16
18
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { discordPlugin } from "./channel.js";
|
|
4
|
+
import { setDiscordRuntime } from "./runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("discordPlugin outbound", () => {
|
|
7
|
+
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
|
8
|
+
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
|
9
|
+
setDiscordRuntime({
|
|
10
|
+
channel: {
|
|
11
|
+
discord: {
|
|
12
|
+
sendMessageDiscord,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
} as unknown as PluginRuntime);
|
|
16
|
+
|
|
17
|
+
const result = await discordPlugin.outbound!.sendMedia!({
|
|
18
|
+
cfg: {} as OpenClawConfig,
|
|
19
|
+
to: "channel:123",
|
|
20
|
+
text: "hi",
|
|
21
|
+
mediaUrl: "/tmp/image.png",
|
|
22
|
+
mediaLocalRoots: ["/tmp/agent-root"],
|
|
23
|
+
accountId: "work",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(sendMessageDiscord).toHaveBeenCalledWith(
|
|
27
|
+
"channel:123",
|
|
28
|
+
"hi",
|
|
29
|
+
expect.objectContaining({
|
|
30
|
+
mediaUrl: "/tmp/image.png",
|
|
31
|
+
mediaLocalRoots: ["/tmp/agent-root"],
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
resolveDefaultDiscordAccountId,
|
|
23
23
|
resolveDiscordGroupRequireMention,
|
|
24
24
|
resolveDiscordGroupToolPolicy,
|
|
25
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
26
|
+
resolveDefaultGroupPolicy,
|
|
25
27
|
setAccountEnabledInConfigSection,
|
|
26
28
|
type ChannelMessageActionAdapter,
|
|
27
29
|
type ChannelPlugin,
|
|
@@ -110,6 +112,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|
|
110
112
|
.map((entry) => String(entry).trim())
|
|
111
113
|
.filter(Boolean)
|
|
112
114
|
.map((entry) => entry.toLowerCase()),
|
|
115
|
+
resolveDefaultTo: ({ cfg, accountId }) =>
|
|
116
|
+
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
|
113
117
|
},
|
|
114
118
|
security: {
|
|
115
119
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
@@ -128,8 +132,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|
|
128
132
|
},
|
|
129
133
|
collectWarnings: ({ account, cfg }) => {
|
|
130
134
|
const warnings: string[] = [];
|
|
131
|
-
const defaultGroupPolicy = cfg
|
|
132
|
-
const
|
|
135
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
136
|
+
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
|
|
137
|
+
providerConfigPresent: cfg.channels?.discord !== undefined,
|
|
138
|
+
groupPolicy: account.config.groupPolicy,
|
|
139
|
+
defaultGroupPolicy,
|
|
140
|
+
});
|
|
133
141
|
const guildEntries = account.config.guilds ?? {};
|
|
134
142
|
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
|
135
143
|
const channelAllowlistConfigured = guildsConfigured;
|
|
@@ -303,11 +311,21 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|
|
303
311
|
});
|
|
304
312
|
return { channel: "discord", ...result };
|
|
305
313
|
},
|
|
306
|
-
sendMedia: async ({
|
|
314
|
+
sendMedia: async ({
|
|
315
|
+
to,
|
|
316
|
+
text,
|
|
317
|
+
mediaUrl,
|
|
318
|
+
mediaLocalRoots,
|
|
319
|
+
accountId,
|
|
320
|
+
deps,
|
|
321
|
+
replyToId,
|
|
322
|
+
silent,
|
|
323
|
+
}) => {
|
|
307
324
|
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
|
308
325
|
const result = await send(to, text, {
|
|
309
326
|
verbose: false,
|
|
310
327
|
mediaUrl,
|
|
328
|
+
mediaLocalRoots,
|
|
311
329
|
replyTo: replyToId ?? undefined,
|
|
312
330
|
accountId: accountId ?? undefined,
|
|
313
331
|
silent: silent ?? undefined,
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { registerDiscordSubagentHooks } from "./subagent-hooks.js";
|
|
4
|
+
|
|
5
|
+
type ThreadBindingRecord = {
|
|
6
|
+
accountId: string;
|
|
7
|
+
threadId: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type MockResolvedDiscordAccount = {
|
|
11
|
+
accountId: string;
|
|
12
|
+
config: {
|
|
13
|
+
threadBindings?: {
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
spawnSubagentSessions?: boolean;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const hookMocks = vi.hoisted(() => ({
|
|
21
|
+
resolveDiscordAccount: vi.fn(
|
|
22
|
+
(params?: { accountId?: string }): MockResolvedDiscordAccount => ({
|
|
23
|
+
accountId: params?.accountId?.trim() || "default",
|
|
24
|
+
config: {
|
|
25
|
+
threadBindings: {
|
|
26
|
+
spawnSubagentSessions: true,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
autoBindSpawnedDiscordSubagent: vi.fn(
|
|
32
|
+
async (): Promise<{ threadId: string } | null> => ({ threadId: "thread-1" }),
|
|
33
|
+
),
|
|
34
|
+
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
|
|
35
|
+
unbindThreadBindingsBySessionKey: vi.fn(() => []),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("openclaw/plugin-sdk", () => ({
|
|
39
|
+
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
|
|
40
|
+
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
|
|
41
|
+
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
|
|
42
|
+
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
function registerHandlersForTest(
|
|
46
|
+
config: Record<string, unknown> = {
|
|
47
|
+
channels: {
|
|
48
|
+
discord: {
|
|
49
|
+
threadBindings: {
|
|
50
|
+
spawnSubagentSessions: true,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
) {
|
|
56
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
57
|
+
const api = {
|
|
58
|
+
config,
|
|
59
|
+
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
|
60
|
+
handlers.set(hookName, handler);
|
|
61
|
+
},
|
|
62
|
+
} as unknown as OpenClawPluginApi;
|
|
63
|
+
registerDiscordSubagentHooks(api);
|
|
64
|
+
return handlers;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getRequiredHandler(
|
|
68
|
+
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
|
|
69
|
+
hookName: string,
|
|
70
|
+
): (event: unknown, ctx: unknown) => unknown {
|
|
71
|
+
const handler = handlers.get(hookName);
|
|
72
|
+
if (!handler) {
|
|
73
|
+
throw new Error(`expected ${hookName} hook handler`);
|
|
74
|
+
}
|
|
75
|
+
return handler;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createSpawnEvent(overrides?: {
|
|
79
|
+
childSessionKey?: string;
|
|
80
|
+
agentId?: string;
|
|
81
|
+
label?: string;
|
|
82
|
+
mode?: string;
|
|
83
|
+
requester?: {
|
|
84
|
+
channel?: string;
|
|
85
|
+
accountId?: string;
|
|
86
|
+
to?: string;
|
|
87
|
+
threadId?: string;
|
|
88
|
+
};
|
|
89
|
+
threadRequested?: boolean;
|
|
90
|
+
}): {
|
|
91
|
+
childSessionKey: string;
|
|
92
|
+
agentId: string;
|
|
93
|
+
label: string;
|
|
94
|
+
mode: string;
|
|
95
|
+
requester: {
|
|
96
|
+
channel: string;
|
|
97
|
+
accountId: string;
|
|
98
|
+
to: string;
|
|
99
|
+
threadId?: string;
|
|
100
|
+
};
|
|
101
|
+
threadRequested: boolean;
|
|
102
|
+
} {
|
|
103
|
+
const base = {
|
|
104
|
+
childSessionKey: "agent:main:subagent:child",
|
|
105
|
+
agentId: "main",
|
|
106
|
+
label: "banana",
|
|
107
|
+
mode: "session",
|
|
108
|
+
requester: {
|
|
109
|
+
channel: "discord",
|
|
110
|
+
accountId: "work",
|
|
111
|
+
to: "channel:123",
|
|
112
|
+
threadId: "456",
|
|
113
|
+
},
|
|
114
|
+
threadRequested: true,
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
...base,
|
|
118
|
+
...overrides,
|
|
119
|
+
requester: {
|
|
120
|
+
...base.requester,
|
|
121
|
+
...(overrides?.requester ?? {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createSpawnEventWithoutThread() {
|
|
127
|
+
return createSpawnEvent({
|
|
128
|
+
label: "",
|
|
129
|
+
requester: { threadId: undefined },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function runSubagentSpawning(
|
|
134
|
+
config?: Record<string, unknown>,
|
|
135
|
+
event = createSpawnEventWithoutThread(),
|
|
136
|
+
) {
|
|
137
|
+
const handlers = registerHandlersForTest(config);
|
|
138
|
+
const handler = getRequiredHandler(handlers, "subagent_spawning");
|
|
139
|
+
return await handler(event, {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function expectSubagentSpawningError(params?: {
|
|
143
|
+
config?: Record<string, unknown>;
|
|
144
|
+
errorContains?: string;
|
|
145
|
+
event?: ReturnType<typeof createSpawnEvent>;
|
|
146
|
+
}) {
|
|
147
|
+
const result = await runSubagentSpawning(params?.config, params?.event);
|
|
148
|
+
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
|
149
|
+
expect(result).toMatchObject({ status: "error" });
|
|
150
|
+
if (params?.errorContains) {
|
|
151
|
+
const errorText = (result as { error?: string }).error ?? "";
|
|
152
|
+
expect(errorText).toContain(params.errorContains);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("discord subagent hook handlers", () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
hookMocks.resolveDiscordAccount.mockClear();
|
|
159
|
+
hookMocks.resolveDiscordAccount.mockImplementation((params?: { accountId?: string }) => ({
|
|
160
|
+
accountId: params?.accountId?.trim() || "default",
|
|
161
|
+
config: {
|
|
162
|
+
threadBindings: {
|
|
163
|
+
spawnSubagentSessions: true,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
|
|
168
|
+
hookMocks.listThreadBindingsBySessionKey.mockClear();
|
|
169
|
+
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("registers subagent hooks", () => {
|
|
173
|
+
const handlers = registerHandlersForTest();
|
|
174
|
+
expect(handlers.has("subagent_spawning")).toBe(true);
|
|
175
|
+
expect(handlers.has("subagent_delivery_target")).toBe(true);
|
|
176
|
+
expect(handlers.has("subagent_spawned")).toBe(false);
|
|
177
|
+
expect(handlers.has("subagent_ended")).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("binds thread routing on subagent_spawning", async () => {
|
|
181
|
+
const handlers = registerHandlersForTest();
|
|
182
|
+
const handler = getRequiredHandler(handlers, "subagent_spawning");
|
|
183
|
+
|
|
184
|
+
const result = await handler(createSpawnEvent(), {});
|
|
185
|
+
|
|
186
|
+
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({
|
|
188
|
+
accountId: "work",
|
|
189
|
+
channel: "discord",
|
|
190
|
+
to: "channel:123",
|
|
191
|
+
threadId: "456",
|
|
192
|
+
childSessionKey: "agent:main:subagent:child",
|
|
193
|
+
agentId: "main",
|
|
194
|
+
label: "banana",
|
|
195
|
+
boundBy: "system",
|
|
196
|
+
});
|
|
197
|
+
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns error when thread-bound subagent spawn is disabled", async () => {
|
|
201
|
+
await expectSubagentSpawningError({
|
|
202
|
+
config: {
|
|
203
|
+
channels: {
|
|
204
|
+
discord: {
|
|
205
|
+
threadBindings: {
|
|
206
|
+
spawnSubagentSessions: false,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
errorContains: "spawnSubagentSessions=true",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns error when global thread bindings are disabled", async () => {
|
|
216
|
+
await expectSubagentSpawningError({
|
|
217
|
+
config: {
|
|
218
|
+
session: {
|
|
219
|
+
threadBindings: {
|
|
220
|
+
enabled: false,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
channels: {
|
|
224
|
+
discord: {
|
|
225
|
+
threadBindings: {
|
|
226
|
+
spawnSubagentSessions: true,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
errorContains: "threadBindings.enabled=true",
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("allows account-level threadBindings.enabled to override global disable", async () => {
|
|
236
|
+
const result = await runSubagentSpawning({
|
|
237
|
+
session: {
|
|
238
|
+
threadBindings: {
|
|
239
|
+
enabled: false,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
channels: {
|
|
243
|
+
discord: {
|
|
244
|
+
accounts: {
|
|
245
|
+
work: {
|
|
246
|
+
threadBindings: {
|
|
247
|
+
enabled: true,
|
|
248
|
+
spawnSubagentSessions: true,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
|
|
261
|
+
await expectSubagentSpawningError({
|
|
262
|
+
config: {
|
|
263
|
+
channels: {
|
|
264
|
+
discord: {
|
|
265
|
+
threadBindings: {},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("no-ops when thread binding is requested on non-discord channel", async () => {
|
|
273
|
+
const result = await runSubagentSpawning(
|
|
274
|
+
undefined,
|
|
275
|
+
createSpawnEvent({
|
|
276
|
+
requester: {
|
|
277
|
+
channel: "signal",
|
|
278
|
+
accountId: "",
|
|
279
|
+
to: "+123",
|
|
280
|
+
threadId: undefined,
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
|
286
|
+
expect(result).toBeUndefined();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("returns error when thread bind fails", async () => {
|
|
290
|
+
hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null);
|
|
291
|
+
const result = await runSubagentSpawning();
|
|
292
|
+
|
|
293
|
+
expect(result).toMatchObject({ status: "error" });
|
|
294
|
+
const errorText = (result as { error?: string }).error ?? "";
|
|
295
|
+
expect(errorText).toMatch(/unable to create or bind/i);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("unbinds thread routing on subagent_ended", () => {
|
|
299
|
+
const handlers = registerHandlersForTest();
|
|
300
|
+
const handler = getRequiredHandler(handlers, "subagent_ended");
|
|
301
|
+
|
|
302
|
+
handler(
|
|
303
|
+
{
|
|
304
|
+
targetSessionKey: "agent:main:subagent:child",
|
|
305
|
+
targetKind: "subagent",
|
|
306
|
+
reason: "subagent-complete",
|
|
307
|
+
sendFarewell: true,
|
|
308
|
+
accountId: "work",
|
|
309
|
+
},
|
|
310
|
+
{},
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledTimes(1);
|
|
314
|
+
expect(hookMocks.unbindThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
|
315
|
+
targetSessionKey: "agent:main:subagent:child",
|
|
316
|
+
accountId: "work",
|
|
317
|
+
targetKind: "subagent",
|
|
318
|
+
reason: "subagent-complete",
|
|
319
|
+
sendFarewell: true,
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("resolves delivery target from matching bound thread", () => {
|
|
324
|
+
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
|
|
325
|
+
{ accountId: "work", threadId: "777" },
|
|
326
|
+
]);
|
|
327
|
+
const handlers = registerHandlersForTest();
|
|
328
|
+
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
|
329
|
+
|
|
330
|
+
const result = handler(
|
|
331
|
+
{
|
|
332
|
+
childSessionKey: "agent:main:subagent:child",
|
|
333
|
+
requesterSessionKey: "agent:main:main",
|
|
334
|
+
requesterOrigin: {
|
|
335
|
+
channel: "discord",
|
|
336
|
+
accountId: "work",
|
|
337
|
+
to: "channel:123",
|
|
338
|
+
threadId: "777",
|
|
339
|
+
},
|
|
340
|
+
childRunId: "run-1",
|
|
341
|
+
spawnMode: "session",
|
|
342
|
+
expectsCompletionMessage: true,
|
|
343
|
+
},
|
|
344
|
+
{},
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({
|
|
348
|
+
targetSessionKey: "agent:main:subagent:child",
|
|
349
|
+
accountId: "work",
|
|
350
|
+
targetKind: "subagent",
|
|
351
|
+
});
|
|
352
|
+
expect(result).toEqual({
|
|
353
|
+
origin: {
|
|
354
|
+
channel: "discord",
|
|
355
|
+
accountId: "work",
|
|
356
|
+
to: "channel:777",
|
|
357
|
+
threadId: "777",
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("keeps original routing when delivery target is ambiguous", () => {
|
|
363
|
+
hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([
|
|
364
|
+
{ accountId: "work", threadId: "777" },
|
|
365
|
+
{ accountId: "work", threadId: "888" },
|
|
366
|
+
]);
|
|
367
|
+
const handlers = registerHandlersForTest();
|
|
368
|
+
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
|
369
|
+
|
|
370
|
+
const result = handler(
|
|
371
|
+
{
|
|
372
|
+
childSessionKey: "agent:main:subagent:child",
|
|
373
|
+
requesterSessionKey: "agent:main:main",
|
|
374
|
+
requesterOrigin: {
|
|
375
|
+
channel: "discord",
|
|
376
|
+
accountId: "work",
|
|
377
|
+
to: "channel:123",
|
|
378
|
+
},
|
|
379
|
+
childRunId: "run-1",
|
|
380
|
+
spawnMode: "session",
|
|
381
|
+
expectsCompletionMessage: true,
|
|
382
|
+
},
|
|
383
|
+
{},
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
expect(result).toBeUndefined();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
autoBindSpawnedDiscordSubagent,
|
|
4
|
+
listThreadBindingsBySessionKey,
|
|
5
|
+
resolveDiscordAccount,
|
|
6
|
+
unbindThreadBindingsBySessionKey,
|
|
7
|
+
} from "openclaw/plugin-sdk";
|
|
8
|
+
|
|
9
|
+
function summarizeError(err: unknown): string {
|
|
10
|
+
if (err instanceof Error) {
|
|
11
|
+
return err.message;
|
|
12
|
+
}
|
|
13
|
+
if (typeof err === "string") {
|
|
14
|
+
return err;
|
|
15
|
+
}
|
|
16
|
+
return "error";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerDiscordSubagentHooks(api: OpenClawPluginApi) {
|
|
20
|
+
const resolveThreadBindingFlags = (accountId?: string) => {
|
|
21
|
+
const account = resolveDiscordAccount({
|
|
22
|
+
cfg: api.config,
|
|
23
|
+
accountId,
|
|
24
|
+
});
|
|
25
|
+
const baseThreadBindings = api.config.channels?.discord?.threadBindings;
|
|
26
|
+
const accountThreadBindings =
|
|
27
|
+
api.config.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
|
|
28
|
+
return {
|
|
29
|
+
enabled:
|
|
30
|
+
accountThreadBindings?.enabled ??
|
|
31
|
+
baseThreadBindings?.enabled ??
|
|
32
|
+
api.config.session?.threadBindings?.enabled ??
|
|
33
|
+
true,
|
|
34
|
+
spawnSubagentSessions:
|
|
35
|
+
accountThreadBindings?.spawnSubagentSessions ??
|
|
36
|
+
baseThreadBindings?.spawnSubagentSessions ??
|
|
37
|
+
false,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
api.on("subagent_spawning", async (event) => {
|
|
42
|
+
if (!event.threadRequested) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const channel = event.requester?.channel?.trim().toLowerCase();
|
|
46
|
+
if (channel !== "discord") {
|
|
47
|
+
// Ignore non-Discord channels so channel-specific plugins can handle
|
|
48
|
+
// their own thread/session provisioning without Discord blocking them.
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const threadBindingFlags = resolveThreadBindingFlags(event.requester?.accountId);
|
|
52
|
+
if (!threadBindingFlags.enabled) {
|
|
53
|
+
return {
|
|
54
|
+
status: "error" as const,
|
|
55
|
+
error:
|
|
56
|
+
"Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (!threadBindingFlags.spawnSubagentSessions) {
|
|
60
|
+
return {
|
|
61
|
+
status: "error" as const,
|
|
62
|
+
error:
|
|
63
|
+
"Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const binding = await autoBindSpawnedDiscordSubagent({
|
|
68
|
+
accountId: event.requester?.accountId,
|
|
69
|
+
channel: event.requester?.channel,
|
|
70
|
+
to: event.requester?.to,
|
|
71
|
+
threadId: event.requester?.threadId,
|
|
72
|
+
childSessionKey: event.childSessionKey,
|
|
73
|
+
agentId: event.agentId,
|
|
74
|
+
label: event.label,
|
|
75
|
+
boundBy: "system",
|
|
76
|
+
});
|
|
77
|
+
if (!binding) {
|
|
78
|
+
return {
|
|
79
|
+
status: "error" as const,
|
|
80
|
+
error:
|
|
81
|
+
"Unable to create or bind a Discord thread for this subagent session. Session mode is unavailable for this target.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { status: "ok" as const, threadBindingReady: true };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return {
|
|
87
|
+
status: "error" as const,
|
|
88
|
+
error: `Discord thread bind failed: ${summarizeError(err)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
api.on("subagent_ended", (event) => {
|
|
94
|
+
unbindThreadBindingsBySessionKey({
|
|
95
|
+
targetSessionKey: event.targetSessionKey,
|
|
96
|
+
accountId: event.accountId,
|
|
97
|
+
targetKind: event.targetKind,
|
|
98
|
+
reason: event.reason,
|
|
99
|
+
sendFarewell: event.sendFarewell,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
api.on("subagent_delivery_target", (event) => {
|
|
104
|
+
if (!event.expectsCompletionMessage) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const requesterChannel = event.requesterOrigin?.channel?.trim().toLowerCase();
|
|
108
|
+
if (requesterChannel !== "discord") {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const requesterAccountId = event.requesterOrigin?.accountId?.trim();
|
|
112
|
+
const requesterThreadId =
|
|
113
|
+
event.requesterOrigin?.threadId != null && event.requesterOrigin.threadId !== ""
|
|
114
|
+
? String(event.requesterOrigin.threadId).trim()
|
|
115
|
+
: "";
|
|
116
|
+
const bindings = listThreadBindingsBySessionKey({
|
|
117
|
+
targetSessionKey: event.childSessionKey,
|
|
118
|
+
...(requesterAccountId ? { accountId: requesterAccountId } : {}),
|
|
119
|
+
targetKind: "subagent",
|
|
120
|
+
});
|
|
121
|
+
if (bindings.length === 0) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let binding: (typeof bindings)[number] | undefined;
|
|
126
|
+
if (requesterThreadId) {
|
|
127
|
+
binding = bindings.find((entry) => {
|
|
128
|
+
if (entry.threadId !== requesterThreadId) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (requesterAccountId && entry.accountId !== requesterAccountId) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (!binding && bindings.length === 1) {
|
|
138
|
+
binding = bindings[0];
|
|
139
|
+
}
|
|
140
|
+
if (!binding) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
origin: {
|
|
145
|
+
channel: "discord",
|
|
146
|
+
accountId: binding.accountId,
|
|
147
|
+
to: `channel:${binding.threadId}`,
|
|
148
|
+
threadId: binding.threadId,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|