@openclaw/discord 2026.2.21 → 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/package.json +1 -1
- package/src/channel.test.ts +36 -0
- package/src/channel.ts +19 -3
- package/src/subagent-hooks.test.ts +128 -170
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,
|
|
@@ -130,8 +132,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|
|
130
132
|
},
|
|
131
133
|
collectWarnings: ({ account, cfg }) => {
|
|
132
134
|
const warnings: string[] = [];
|
|
133
|
-
const defaultGroupPolicy = cfg
|
|
134
|
-
const
|
|
135
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
136
|
+
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
|
|
137
|
+
providerConfigPresent: cfg.channels?.discord !== undefined,
|
|
138
|
+
groupPolicy: account.config.groupPolicy,
|
|
139
|
+
defaultGroupPolicy,
|
|
140
|
+
});
|
|
135
141
|
const guildEntries = account.config.guilds ?? {};
|
|
136
142
|
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
|
137
143
|
const channelAllowlistConfigured = guildsConfigured;
|
|
@@ -305,11 +311,21 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|
|
305
311
|
});
|
|
306
312
|
return { channel: "discord", ...result };
|
|
307
313
|
},
|
|
308
|
-
sendMedia: async ({
|
|
314
|
+
sendMedia: async ({
|
|
315
|
+
to,
|
|
316
|
+
text,
|
|
317
|
+
mediaUrl,
|
|
318
|
+
mediaLocalRoots,
|
|
319
|
+
accountId,
|
|
320
|
+
deps,
|
|
321
|
+
replyToId,
|
|
322
|
+
silent,
|
|
323
|
+
}) => {
|
|
309
324
|
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
|
310
325
|
const result = await send(to, text, {
|
|
311
326
|
verbose: false,
|
|
312
327
|
mediaUrl,
|
|
328
|
+
mediaLocalRoots,
|
|
313
329
|
replyTo: replyToId ?? undefined,
|
|
314
330
|
accountId: accountId ?? undefined,
|
|
315
331
|
silent: silent ?? undefined,
|
|
@@ -64,6 +64,95 @@ function registerHandlersForTest(
|
|
|
64
64
|
return handlers;
|
|
65
65
|
}
|
|
66
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
|
+
|
|
67
156
|
describe("discord subagent hook handlers", () => {
|
|
68
157
|
beforeEach(() => {
|
|
69
158
|
hookMocks.resolveDiscordAccount.mockClear();
|
|
@@ -90,27 +179,9 @@ describe("discord subagent hook handlers", () => {
|
|
|
90
179
|
|
|
91
180
|
it("binds thread routing on subagent_spawning", async () => {
|
|
92
181
|
const handlers = registerHandlersForTest();
|
|
93
|
-
const handler = handlers
|
|
94
|
-
if (!handler) {
|
|
95
|
-
throw new Error("expected subagent_spawning hook handler");
|
|
96
|
-
}
|
|
182
|
+
const handler = getRequiredHandler(handlers, "subagent_spawning");
|
|
97
183
|
|
|
98
|
-
const result = await handler(
|
|
99
|
-
{
|
|
100
|
-
childSessionKey: "agent:main:subagent:child",
|
|
101
|
-
agentId: "main",
|
|
102
|
-
label: "banana",
|
|
103
|
-
mode: "session",
|
|
104
|
-
requester: {
|
|
105
|
-
channel: "discord",
|
|
106
|
-
accountId: "work",
|
|
107
|
-
to: "channel:123",
|
|
108
|
-
threadId: "456",
|
|
109
|
-
},
|
|
110
|
-
threadRequested: true,
|
|
111
|
-
},
|
|
112
|
-
{},
|
|
113
|
-
);
|
|
184
|
+
const result = await handler(createSpawnEvent(), {});
|
|
114
185
|
|
|
115
186
|
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
|
|
116
187
|
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith({
|
|
@@ -127,82 +198,42 @@ describe("discord subagent hook handlers", () => {
|
|
|
127
198
|
});
|
|
128
199
|
|
|
129
200
|
it("returns error when thread-bound subagent spawn is disabled", async () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
201
|
+
await expectSubagentSpawningError({
|
|
202
|
+
config: {
|
|
203
|
+
channels: {
|
|
204
|
+
discord: {
|
|
205
|
+
threadBindings: {
|
|
206
|
+
spawnSubagentSessions: false,
|
|
207
|
+
},
|
|
135
208
|
},
|
|
136
209
|
},
|
|
137
210
|
},
|
|
211
|
+
errorContains: "spawnSubagentSessions=true",
|
|
138
212
|
});
|
|
139
|
-
const handler = handlers.get("subagent_spawning");
|
|
140
|
-
if (!handler) {
|
|
141
|
-
throw new Error("expected subagent_spawning hook handler");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const result = await handler(
|
|
145
|
-
{
|
|
146
|
-
childSessionKey: "agent:main:subagent:child",
|
|
147
|
-
agentId: "main",
|
|
148
|
-
requester: {
|
|
149
|
-
channel: "discord",
|
|
150
|
-
accountId: "work",
|
|
151
|
-
to: "channel:123",
|
|
152
|
-
},
|
|
153
|
-
threadRequested: true,
|
|
154
|
-
},
|
|
155
|
-
{},
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
|
159
|
-
expect(result).toMatchObject({ status: "error" });
|
|
160
|
-
const errorText = (result as { error?: string }).error ?? "";
|
|
161
|
-
expect(errorText).toContain("spawnSubagentSessions=true");
|
|
162
213
|
});
|
|
163
214
|
|
|
164
215
|
it("returns error when global thread bindings are disabled", async () => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
enabled: false,
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
channels: {
|
|
172
|
-
discord: {
|
|
216
|
+
await expectSubagentSpawningError({
|
|
217
|
+
config: {
|
|
218
|
+
session: {
|
|
173
219
|
threadBindings: {
|
|
174
|
-
|
|
220
|
+
enabled: false,
|
|
175
221
|
},
|
|
176
222
|
},
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const result = await handler(
|
|
185
|
-
{
|
|
186
|
-
childSessionKey: "agent:main:subagent:child",
|
|
187
|
-
agentId: "main",
|
|
188
|
-
requester: {
|
|
189
|
-
channel: "discord",
|
|
190
|
-
accountId: "work",
|
|
191
|
-
to: "channel:123",
|
|
223
|
+
channels: {
|
|
224
|
+
discord: {
|
|
225
|
+
threadBindings: {
|
|
226
|
+
spawnSubagentSessions: true,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
192
229
|
},
|
|
193
|
-
threadRequested: true,
|
|
194
230
|
},
|
|
195
|
-
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
|
199
|
-
expect(result).toMatchObject({ status: "error" });
|
|
200
|
-
const errorText = (result as { error?: string }).error ?? "";
|
|
201
|
-
expect(errorText).toContain("threadBindings.enabled=true");
|
|
231
|
+
errorContains: "threadBindings.enabled=true",
|
|
232
|
+
});
|
|
202
233
|
});
|
|
203
234
|
|
|
204
235
|
it("allows account-level threadBindings.enabled to override global disable", async () => {
|
|
205
|
-
const
|
|
236
|
+
const result = await runSubagentSpawning({
|
|
206
237
|
session: {
|
|
207
238
|
threadBindings: {
|
|
208
239
|
enabled: false,
|
|
@@ -221,79 +252,34 @@ describe("discord subagent hook handlers", () => {
|
|
|
221
252
|
},
|
|
222
253
|
},
|
|
223
254
|
});
|
|
224
|
-
const handler = handlers.get("subagent_spawning");
|
|
225
|
-
if (!handler) {
|
|
226
|
-
throw new Error("expected subagent_spawning hook handler");
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const result = await handler(
|
|
230
|
-
{
|
|
231
|
-
childSessionKey: "agent:main:subagent:child",
|
|
232
|
-
agentId: "main",
|
|
233
|
-
requester: {
|
|
234
|
-
channel: "discord",
|
|
235
|
-
accountId: "work",
|
|
236
|
-
to: "channel:123",
|
|
237
|
-
},
|
|
238
|
-
threadRequested: true,
|
|
239
|
-
},
|
|
240
|
-
{},
|
|
241
|
-
);
|
|
242
255
|
|
|
243
256
|
expect(hookMocks.autoBindSpawnedDiscordSubagent).toHaveBeenCalledTimes(1);
|
|
244
257
|
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
|
|
245
258
|
});
|
|
246
259
|
|
|
247
260
|
it("defaults thread-bound subagent spawn to disabled when unset", async () => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
261
|
+
await expectSubagentSpawningError({
|
|
262
|
+
config: {
|
|
263
|
+
channels: {
|
|
264
|
+
discord: {
|
|
265
|
+
threadBindings: {},
|
|
266
|
+
},
|
|
252
267
|
},
|
|
253
268
|
},
|
|
254
269
|
});
|
|
255
|
-
const handler = handlers.get("subagent_spawning");
|
|
256
|
-
if (!handler) {
|
|
257
|
-
throw new Error("expected subagent_spawning hook handler");
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const result = await handler(
|
|
261
|
-
{
|
|
262
|
-
childSessionKey: "agent:main:subagent:child",
|
|
263
|
-
agentId: "main",
|
|
264
|
-
requester: {
|
|
265
|
-
channel: "discord",
|
|
266
|
-
accountId: "work",
|
|
267
|
-
to: "channel:123",
|
|
268
|
-
},
|
|
269
|
-
threadRequested: true,
|
|
270
|
-
},
|
|
271
|
-
{},
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
|
275
|
-
expect(result).toMatchObject({ status: "error" });
|
|
276
270
|
});
|
|
277
271
|
|
|
278
272
|
it("no-ops when thread binding is requested on non-discord channel", async () => {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
throw new Error("expected subagent_spawning hook handler");
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const result = await handler(
|
|
286
|
-
{
|
|
287
|
-
childSessionKey: "agent:main:subagent:child",
|
|
288
|
-
agentId: "main",
|
|
289
|
-
mode: "session",
|
|
273
|
+
const result = await runSubagentSpawning(
|
|
274
|
+
undefined,
|
|
275
|
+
createSpawnEvent({
|
|
290
276
|
requester: {
|
|
291
277
|
channel: "signal",
|
|
278
|
+
accountId: "",
|
|
292
279
|
to: "+123",
|
|
280
|
+
threadId: undefined,
|
|
293
281
|
},
|
|
294
|
-
|
|
295
|
-
},
|
|
296
|
-
{},
|
|
282
|
+
}),
|
|
297
283
|
);
|
|
298
284
|
|
|
299
285
|
expect(hookMocks.autoBindSpawnedDiscordSubagent).not.toHaveBeenCalled();
|
|
@@ -302,26 +288,7 @@ describe("discord subagent hook handlers", () => {
|
|
|
302
288
|
|
|
303
289
|
it("returns error when thread bind fails", async () => {
|
|
304
290
|
hookMocks.autoBindSpawnedDiscordSubagent.mockResolvedValueOnce(null);
|
|
305
|
-
const
|
|
306
|
-
const handler = handlers.get("subagent_spawning");
|
|
307
|
-
if (!handler) {
|
|
308
|
-
throw new Error("expected subagent_spawning hook handler");
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const result = await handler(
|
|
312
|
-
{
|
|
313
|
-
childSessionKey: "agent:main:subagent:child",
|
|
314
|
-
agentId: "main",
|
|
315
|
-
mode: "session",
|
|
316
|
-
requester: {
|
|
317
|
-
channel: "discord",
|
|
318
|
-
accountId: "work",
|
|
319
|
-
to: "channel:123",
|
|
320
|
-
},
|
|
321
|
-
threadRequested: true,
|
|
322
|
-
},
|
|
323
|
-
{},
|
|
324
|
-
);
|
|
291
|
+
const result = await runSubagentSpawning();
|
|
325
292
|
|
|
326
293
|
expect(result).toMatchObject({ status: "error" });
|
|
327
294
|
const errorText = (result as { error?: string }).error ?? "";
|
|
@@ -330,10 +297,7 @@ describe("discord subagent hook handlers", () => {
|
|
|
330
297
|
|
|
331
298
|
it("unbinds thread routing on subagent_ended", () => {
|
|
332
299
|
const handlers = registerHandlersForTest();
|
|
333
|
-
const handler = handlers
|
|
334
|
-
if (!handler) {
|
|
335
|
-
throw new Error("expected subagent_ended hook handler");
|
|
336
|
-
}
|
|
300
|
+
const handler = getRequiredHandler(handlers, "subagent_ended");
|
|
337
301
|
|
|
338
302
|
handler(
|
|
339
303
|
{
|
|
@@ -361,10 +325,7 @@ describe("discord subagent hook handlers", () => {
|
|
|
361
325
|
{ accountId: "work", threadId: "777" },
|
|
362
326
|
]);
|
|
363
327
|
const handlers = registerHandlersForTest();
|
|
364
|
-
const handler = handlers
|
|
365
|
-
if (!handler) {
|
|
366
|
-
throw new Error("expected subagent_delivery_target hook handler");
|
|
367
|
-
}
|
|
328
|
+
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
|
368
329
|
|
|
369
330
|
const result = handler(
|
|
370
331
|
{
|
|
@@ -404,10 +365,7 @@ describe("discord subagent hook handlers", () => {
|
|
|
404
365
|
{ accountId: "work", threadId: "888" },
|
|
405
366
|
]);
|
|
406
367
|
const handlers = registerHandlersForTest();
|
|
407
|
-
const handler = handlers
|
|
408
|
-
if (!handler) {
|
|
409
|
-
throw new Error("expected subagent_delivery_target hook handler");
|
|
410
|
-
}
|
|
368
|
+
const handler = getRequiredHandler(handlers, "subagent_delivery_target");
|
|
411
369
|
|
|
412
370
|
const result = handler(
|
|
413
371
|
{
|