@openclaw/discord 2026.5.1-beta.1 → 2026.5.2-beta.1

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.
Files changed (63) hide show
  1. package/openclaw.plugin.json +50 -6
  2. package/package.json +5 -4
  3. package/src/actions/handle-action.test.ts +121 -0
  4. package/src/actions/handle-action.ts +49 -9
  5. package/src/actions/runtime.messaging.send.ts +8 -4
  6. package/src/actions/runtime.messaging.shared.ts +5 -0
  7. package/src/actions/runtime.test.ts +32 -1
  8. package/src/actions/runtime.ts +6 -0
  9. package/src/channel-actions.test.ts +42 -3
  10. package/src/channel-actions.ts +5 -0
  11. package/src/channel-api.ts +1 -0
  12. package/src/channel.test.ts +8 -0
  13. package/src/channel.ts +1 -0
  14. package/src/client.ts +0 -7
  15. package/src/config-ui-hints.ts +6 -6
  16. package/src/internal/client.test.ts +111 -0
  17. package/src/internal/client.ts +63 -1
  18. package/src/internal/command-deploy.ts +41 -6
  19. package/src/internal/gateway.test.ts +128 -0
  20. package/src/internal/gateway.ts +44 -5
  21. package/src/internal/interaction-dispatch.ts +33 -1
  22. package/src/internal/interactions.test.ts +72 -0
  23. package/src/internal/interactions.ts +41 -0
  24. package/src/internal/rest-scheduler.ts +188 -43
  25. package/src/internal/rest.test.ts +236 -0
  26. package/src/internal/rest.ts +114 -5
  27. package/src/internal/structures.test.ts +43 -0
  28. package/src/internal/structures.ts +2 -0
  29. package/src/monitor/agent-components-context.ts +12 -2
  30. package/src/monitor/agent-components.dispatch.ts +2 -2
  31. package/src/monitor/agent-components.types.ts +1 -0
  32. package/src/monitor/allow-list.test.ts +14 -0
  33. package/src/monitor/allow-list.ts +10 -0
  34. package/src/monitor/channel-access.test.ts +99 -0
  35. package/src/monitor/channel-access.ts +36 -4
  36. package/src/monitor/message-handler.context.ts +16 -3
  37. package/src/monitor/message-handler.preflight-channel-context.test.ts +18 -0
  38. package/src/monitor/message-handler.preflight-channel-context.ts +4 -1
  39. package/src/monitor/message-handler.preflight-pluralkit.ts +1 -2
  40. package/src/monitor/message-handler.preflight.test.ts +79 -0
  41. package/src/monitor/message-handler.preflight.ts +21 -22
  42. package/src/monitor/message-handler.preflight.types.ts +1 -0
  43. package/src/monitor/message-handler.process.test.ts +70 -2
  44. package/src/monitor/message-handler.process.ts +4 -2
  45. package/src/monitor/message-media.ts +2 -0
  46. package/src/monitor/message-utils.test.ts +7 -1
  47. package/src/monitor/native-command-agent-reply.ts +3 -1
  48. package/src/monitor/native-command-reply.ts +2 -0
  49. package/src/monitor/native-command.plugin-dispatch.test.ts +82 -0
  50. package/src/monitor/native-command.ts +2 -1
  51. package/src/monitor/provider.lifecycle.test.ts +56 -1
  52. package/src/monitor/provider.lifecycle.ts +7 -0
  53. package/src/monitor/thread-bindings.discord-api.ts +6 -14
  54. package/src/proxy-request-client.ts +1 -34
  55. package/src/send.components.ts +2 -4
  56. package/src/send.outbound.ts +4 -5
  57. package/src/send.sends-basic-channel-messages.test.ts +22 -0
  58. package/src/send.shared.ts +3 -1
  59. package/src/setup-core.ts +37 -5
  60. package/src/setup-surface.test.ts +41 -0
  61. package/src/shared.test.ts +6 -0
  62. package/src/subagent-hooks.test.ts +91 -38
  63. package/src/subagent-hooks.ts +28 -29
@@ -193,6 +193,16 @@
193
193
  "dangerouslyAllowNameMatching": {
194
194
  "type": "boolean"
195
195
  },
196
+ "mentionAliases": {
197
+ "type": "object",
198
+ "propertyNames": {
199
+ "type": "string"
200
+ },
201
+ "additionalProperties": {
202
+ "type": "string",
203
+ "pattern": "^\\d+$"
204
+ }
205
+ },
196
206
  "groupPolicy": {
197
207
  "default": "allowlist",
198
208
  "type": "string",
@@ -857,6 +867,16 @@
857
867
  "type": "number",
858
868
  "minimum": 0
859
869
  },
870
+ "spawnSessions": {
871
+ "type": "boolean"
872
+ },
873
+ "defaultSpawnContext": {
874
+ "type": "string",
875
+ "enum": [
876
+ "isolated",
877
+ "fork"
878
+ ]
879
+ },
860
880
  "spawnSubagentSessions": {
861
881
  "type": "boolean"
862
882
  },
@@ -1698,6 +1718,16 @@
1698
1718
  "dangerouslyAllowNameMatching": {
1699
1719
  "type": "boolean"
1700
1720
  },
1721
+ "mentionAliases": {
1722
+ "type": "object",
1723
+ "propertyNames": {
1724
+ "type": "string"
1725
+ },
1726
+ "additionalProperties": {
1727
+ "type": "string",
1728
+ "pattern": "^\\d+$"
1729
+ }
1730
+ },
1701
1731
  "groupPolicy": {
1702
1732
  "default": "allowlist",
1703
1733
  "type": "string",
@@ -2362,6 +2392,16 @@
2362
2392
  "type": "number",
2363
2393
  "minimum": 0
2364
2394
  },
2395
+ "spawnSessions": {
2396
+ "type": "boolean"
2397
+ },
2398
+ "defaultSpawnContext": {
2399
+ "type": "string",
2400
+ "enum": [
2401
+ "isolated",
2402
+ "fork"
2403
+ ]
2404
+ },
2365
2405
  "spawnSubagentSessions": {
2366
2406
  "type": "boolean"
2367
2407
  },
@@ -3155,13 +3195,13 @@
3155
3195
  "label": "Discord Thread Binding Max Age (hours)",
3156
3196
  "help": "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."
3157
3197
  },
3158
- "threadBindings.spawnSubagentSessions": {
3159
- "label": "Discord Thread-Bound Subagent Spawn",
3160
- "help": "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel."
3198
+ "threadBindings.spawnSessions": {
3199
+ "label": "Discord Thread-Bound Session Spawn",
3200
+ "help": "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."
3161
3201
  },
3162
- "threadBindings.spawnAcpSessions": {
3163
- "label": "Discord Thread-Bound ACP Spawn",
3164
- "help": "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel."
3202
+ "threadBindings.defaultSpawnContext": {
3203
+ "label": "Discord Thread Spawn Context",
3204
+ "help": "Default native subagent context for thread-bound spawns. \"fork\" starts from the requester transcript; \"isolated\" starts clean. Default: \"fork\"."
3165
3205
  },
3166
3206
  "ui.components.accentColor": {
3167
3207
  "label": "Discord Component Accent Color",
@@ -3275,6 +3315,10 @@
3275
3315
  "label": "Discord Allow Bot Messages",
3276
3316
  "help": "Allow bot-authored messages to trigger Discord replies (default: false). Set \"mentions\" to only accept bot messages that mention the bot."
3277
3317
  },
3318
+ "mentionAliases": {
3319
+ "label": "Discord Mention Aliases",
3320
+ "help": "Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts.<id>.mentionAliases."
3321
+ },
3278
3322
  "token": {
3279
3323
  "label": "Discord Bot Token",
3280
3324
  "help": "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/discord",
3
- "version": "2026.5.1-beta.1",
3
+ "version": "2026.5.2-beta.1",
4
4
  "description": "OpenClaw Discord channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "openclaw": "workspace:*"
22
22
  },
23
23
  "peerDependencies": {
24
- "openclaw": ">=2026.4.25"
24
+ "openclaw": ">=2026.5.2-beta.1"
25
25
  },
26
26
  "peerDependenciesMeta": {
27
27
  "openclaw": {
@@ -43,6 +43,7 @@
43
43
  "blurb": "very well supported right now.",
44
44
  "systemImage": "bubble.left.and.bubble.right",
45
45
  "markdownCapable": true,
46
+ "preferSessionLookupForAnnounceTarget": true,
46
47
  "commands": {
47
48
  "nativeCommandsAutoEnabled": true,
48
49
  "nativeSkillsAutoEnabled": true
@@ -63,10 +64,10 @@
63
64
  "minHostVersion": ">=2026.4.10"
64
65
  },
65
66
  "compat": {
66
- "pluginApi": ">=2026.4.25"
67
+ "pluginApi": ">=2026.5.2-beta.1"
67
68
  },
68
69
  "build": {
69
- "openclawVersion": "2026.5.1-beta.1"
70
+ "openclawVersion": "2026.5.2-beta.1"
70
71
  },
71
72
  "release": {
72
73
  "publishToClawHub": true,
@@ -125,6 +125,127 @@ describe("handleDiscordMessageAction", () => {
125
125
  );
126
126
  });
127
127
 
128
+ it("maps upload-file to Discord sendMessage with media read context", async () => {
129
+ const mediaReadFile = vi.fn(async () => Buffer.from("image"));
130
+ const mediaAccess = {
131
+ localRoots: ["/tmp/agent-root"],
132
+ readFile: mediaReadFile,
133
+ };
134
+
135
+ await handleDiscordMessageAction({
136
+ action: "upload-file",
137
+ params: {
138
+ target: "channel:123",
139
+ filePath: "/tmp/agent-root/image.png",
140
+ message: "caption",
141
+ filename: "image.png",
142
+ replyTo: "message-1",
143
+ silent: true,
144
+ __sessionKey: "session-1",
145
+ __agentId: "agent-1",
146
+ },
147
+ cfg: {
148
+ channels: { discord: { token: "tok" } },
149
+ } as OpenClawConfig,
150
+ mediaAccess,
151
+ mediaLocalRoots: ["/tmp/agent-root"],
152
+ mediaReadFile,
153
+ });
154
+
155
+ expect(handleDiscordActionMock).toHaveBeenCalledWith(
156
+ expect.objectContaining({
157
+ action: "sendMessage",
158
+ to: "channel:123",
159
+ content: "caption",
160
+ mediaUrl: "/tmp/agent-root/image.png",
161
+ filename: "image.png",
162
+ replyTo: "message-1",
163
+ silent: true,
164
+ __sessionKey: "session-1",
165
+ __agentId: "agent-1",
166
+ }),
167
+ expect.any(Object),
168
+ {
169
+ mediaAccess,
170
+ mediaLocalRoots: ["/tmp/agent-root"],
171
+ mediaReadFile,
172
+ },
173
+ );
174
+ });
175
+
176
+ it("falls back to Discord toolContext.currentChannelId for upload-file", async () => {
177
+ await handleDiscordMessageAction({
178
+ action: "upload-file",
179
+ params: {
180
+ path: "/tmp/agent-root/image.png",
181
+ },
182
+ cfg: {
183
+ channels: { discord: { token: "tok" } },
184
+ } as OpenClawConfig,
185
+ toolContext: {
186
+ currentChannelProvider: "discord",
187
+ currentChannelId: "channel:123",
188
+ },
189
+ });
190
+
191
+ expect(handleDiscordActionMock).toHaveBeenCalledWith(
192
+ expect.objectContaining({
193
+ action: "sendMessage",
194
+ to: "channel:123",
195
+ content: "",
196
+ mediaUrl: "/tmp/agent-root/image.png",
197
+ }),
198
+ expect.any(Object),
199
+ expect.any(Object),
200
+ );
201
+ });
202
+
203
+ it("requires a file path for upload-file", async () => {
204
+ await expect(
205
+ handleDiscordMessageAction({
206
+ action: "upload-file",
207
+ params: {
208
+ to: "channel:123",
209
+ },
210
+ cfg: {
211
+ channels: { discord: { token: "tok" } },
212
+ } as OpenClawConfig,
213
+ }),
214
+ ).rejects.toThrow(/upload-file requires filePath, path, or media/i);
215
+
216
+ expect(handleDiscordActionMock).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it("forwards top-level components on sends", async () => {
220
+ const components = { blocks: [{ type: "text", text: "Pick one" }] };
221
+
222
+ await handleDiscordMessageAction({
223
+ action: "send",
224
+ params: {
225
+ message: "hello",
226
+ components,
227
+ },
228
+ cfg: {
229
+ channels: { discord: { token: "tok" } },
230
+ } as OpenClawConfig,
231
+ toolContext: {
232
+ currentChannelProvider: "discord",
233
+ currentChannelId: "channel:123",
234
+ },
235
+ });
236
+
237
+ expect(handleDiscordActionMock).toHaveBeenCalledWith(
238
+ expect.objectContaining({
239
+ action: "sendMessage",
240
+ to: "channel:123",
241
+ content: "hello",
242
+ components,
243
+ }),
244
+ expect.any(Object),
245
+ expect.any(Object),
246
+ );
247
+ });
248
+
128
249
  it("does not use another provider's current target for Discord sends", async () => {
129
250
  await expect(
130
251
  handleDiscordMessageAction({
@@ -66,32 +66,37 @@ export async function handleDiscordMessageAction(
66
66
  return target;
67
67
  };
68
68
  const resolveChannelId = () => resolveDiscordChannelId(readTarget());
69
-
70
- if (action === "send") {
71
- const to =
69
+ const readSendTarget = () => {
70
+ const target =
72
71
  readStringParam(params, "to") ??
73
72
  readStringParam(params, "target") ??
74
73
  readCurrentDiscordTarget(ctx.toolContext);
75
- if (!to) {
74
+ if (!target) {
76
75
  throw new Error("Discord channel target is required (use channel:<id>).");
77
76
  }
77
+ return target;
78
+ };
79
+
80
+ if (action === "send") {
81
+ const to = readSendTarget();
78
82
  const asVoice = readBooleanParam(params, "asVoice") === true;
79
83
  const rawComponents =
84
+ params.components ??
80
85
  buildDiscordPresentationComponents(normalizeMessagePresentation(params.presentation)) ??
81
86
  buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive));
82
87
  const hasComponents =
83
88
  Boolean(rawComponents) &&
84
89
  (typeof rawComponents === "function" || typeof rawComponents === "object");
85
90
  const components = hasComponents ? rawComponents : undefined;
86
- const content = readStringParam(params, "message", {
87
- required: !asVoice && !hasComponents,
88
- allowEmpty: true,
89
- });
90
91
  // Support media, path, and filePath for media URL
91
92
  const mediaUrl =
92
93
  readStringParam(params, "media", { trim: false }) ??
93
94
  readStringParam(params, "path", { trim: false }) ??
94
95
  readStringParam(params, "filePath", { trim: false });
96
+ const content = readStringParam(params, "message", {
97
+ required: !asVoice && !hasComponents && !mediaUrl,
98
+ allowEmpty: true,
99
+ });
95
100
  const filename = readStringParam(params, "filename");
96
101
  const replyTo = readStringParam(params, "replyTo");
97
102
  const rawEmbeds = params.embeds;
@@ -104,7 +109,7 @@ export async function handleDiscordMessageAction(
104
109
  action: "sendMessage",
105
110
  accountId: accountId ?? undefined,
106
111
  to,
107
- content,
112
+ content: content ?? "",
108
113
  mediaUrl: mediaUrl ?? undefined,
109
114
  filename: filename ?? undefined,
110
115
  replyTo: replyTo ?? undefined,
@@ -120,6 +125,41 @@ export async function handleDiscordMessageAction(
120
125
  );
121
126
  }
122
127
 
128
+ if (action === "upload-file") {
129
+ const to = readSendTarget();
130
+ const mediaUrl =
131
+ readStringParam(params, "filePath", { trim: false }) ??
132
+ readStringParam(params, "path", { trim: false }) ??
133
+ readStringParam(params, "media", { trim: false });
134
+ if (!mediaUrl) {
135
+ throw new Error("upload-file requires filePath, path, or media.");
136
+ }
137
+ const content =
138
+ readStringParam(params, "message", { allowEmpty: true }) ??
139
+ readStringParam(params, "content", { allowEmpty: true });
140
+ const filename = readStringParam(params, "filename");
141
+ const replyTo = readStringParam(params, "replyTo");
142
+ const silent = readBooleanParam(params, "silent") === true;
143
+ const sessionKey = readStringParam(params, "__sessionKey");
144
+ const agentId = readStringParam(params, "__agentId");
145
+ return await handleDiscordAction(
146
+ {
147
+ action: "sendMessage",
148
+ accountId: accountId ?? undefined,
149
+ to,
150
+ content: content ?? "",
151
+ mediaUrl,
152
+ filename: filename ?? undefined,
153
+ replyTo: replyTo ?? undefined,
154
+ silent,
155
+ __sessionKey: sessionKey ?? undefined,
156
+ __agentId: agentId ?? undefined,
157
+ },
158
+ cfg,
159
+ actionOptions,
160
+ );
161
+ }
162
+
123
163
  if (action === "poll") {
124
164
  const to = readStringParam(params, "to", { required: true });
125
165
  const question = readStringParam(params, "pollQuestion", {
@@ -78,14 +78,14 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
78
78
  Array.isArray(rawComponents) || typeof rawComponents === "function"
79
79
  ? (rawComponents as DiscordSendComponents)
80
80
  : undefined;
81
- const content = readStringParam(ctx.params, "content", {
82
- required: !asVoice && !componentSpec && !components,
83
- allowEmpty: true,
84
- });
85
81
  const mediaUrl =
86
82
  readStringParam(ctx.params, "mediaUrl", { trim: false }) ??
87
83
  readStringParam(ctx.params, "path", { trim: false }) ??
88
84
  readStringParam(ctx.params, "filePath", { trim: false });
85
+ const content = readStringParam(ctx.params, "content", {
86
+ required: !asVoice && !componentSpec && !components && !mediaUrl,
87
+ allowEmpty: true,
88
+ });
89
89
  const filename = readStringParam(ctx.params, "filename");
90
90
  const replyTo = readStringParam(ctx.params, "replyTo");
91
91
  const rawEmbeds = ctx.params.embeds;
@@ -117,6 +117,9 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
117
117
  agentId: agentId ?? undefined,
118
118
  mediaUrl: mediaUrl ?? undefined,
119
119
  filename: filename ?? undefined,
120
+ mediaAccess: ctx.options?.mediaAccess,
121
+ mediaLocalRoots: ctx.options?.mediaLocalRoots,
122
+ mediaReadFile: ctx.options?.mediaReadFile,
120
123
  },
121
124
  );
122
125
  return jsonResult({ ok: true, result, components: true });
@@ -144,6 +147,7 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
144
147
 
145
148
  const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", {
146
149
  ...ctx.withOpts(),
150
+ mediaAccess: ctx.options?.mediaAccess,
147
151
  mediaUrl,
148
152
  filename: filename ?? undefined,
149
153
  mediaLocalRoots: ctx.options?.mediaLocalRoots,
@@ -12,6 +12,11 @@ import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
12
12
  import { createDiscordActionOptions } from "./runtime.shared.js";
13
13
 
14
14
  export type DiscordMessagingActionOptions = {
15
+ mediaAccess?: {
16
+ localRoots?: readonly string[];
17
+ readFile?: (filePath: string) => Promise<Buffer>;
18
+ workspaceDir?: string;
19
+ };
15
20
  mediaLocalRoots?: readonly string[];
16
21
  mediaReadFile?: (filePath: string) => Promise<Buffer>;
17
22
  };
@@ -93,6 +93,11 @@ function handleMessagingAction(
93
93
  isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
94
94
  cfg: OpenClawConfig = DISCORD_TEST_CFG,
95
95
  options?: {
96
+ mediaAccess?: {
97
+ localRoots?: readonly string[];
98
+ readFile?: (filePath: string) => Promise<Buffer>;
99
+ workspaceDir?: string;
100
+ };
96
101
  mediaLocalRoots?: readonly string[];
97
102
  mediaReadFile?: (filePath: string) => Promise<Buffer>;
98
103
  },
@@ -463,6 +468,8 @@ describe("handleDiscordMessagingAction", () => {
463
468
 
464
469
  it("forwards trusted mediaLocalRoots into sendMessageDiscord", async () => {
465
470
  sendMessageDiscord.mockClear();
471
+ const mediaReadFile = vi.fn(async () => Buffer.from("image"));
472
+ const mediaAccess = { localRoots: ["/tmp/agent-root"], readFile: mediaReadFile };
466
473
  await handleMessagingAction(
467
474
  "sendMessage",
468
475
  {
@@ -472,11 +479,35 @@ describe("handleDiscordMessagingAction", () => {
472
479
  },
473
480
  enableAllActions,
474
481
  DISCORD_TEST_CFG,
475
- { mediaLocalRoots: ["/tmp/agent-root"] },
482
+ { mediaAccess, mediaLocalRoots: ["/tmp/agent-root"], mediaReadFile },
476
483
  );
477
484
  expect(sendMessageDiscord).toHaveBeenCalledWith(
478
485
  "channel:123",
479
486
  "hello",
487
+ expect.objectContaining({
488
+ mediaAccess,
489
+ mediaUrl: "/tmp/image.png",
490
+ mediaLocalRoots: ["/tmp/agent-root"],
491
+ mediaReadFile,
492
+ }),
493
+ );
494
+ });
495
+
496
+ it("allows media-only message sends", async () => {
497
+ sendMessageDiscord.mockClear();
498
+ await handleMessagingAction(
499
+ "sendMessage",
500
+ {
501
+ to: "channel:123",
502
+ mediaUrl: "/tmp/image.png",
503
+ },
504
+ enableAllActions,
505
+ DISCORD_TEST_CFG,
506
+ { mediaLocalRoots: ["/tmp/agent-root"] },
507
+ );
508
+ expect(sendMessageDiscord).toHaveBeenCalledWith(
509
+ "channel:123",
510
+ "",
480
511
  expect.objectContaining({
481
512
  mediaUrl: "/tmp/image.png",
482
513
  mediaLocalRoots: ["/tmp/agent-root"],
@@ -58,7 +58,13 @@ export async function handleDiscordAction(
58
58
  params: Record<string, unknown>,
59
59
  cfg: OpenClawConfig,
60
60
  options?: {
61
+ mediaAccess?: {
62
+ localRoots?: readonly string[];
63
+ readFile?: (filePath: string) => Promise<Buffer>;
64
+ workspaceDir?: string;
65
+ };
61
66
  mediaLocalRoots?: readonly string[];
67
+ mediaReadFile?: (filePath: string) => Promise<Buffer>;
62
68
  },
63
69
  ): Promise<AgentToolResult<unknown>> {
64
70
  const action = readStringParam(params, "action", { required: true });
@@ -55,7 +55,15 @@ describe("discordMessageActions", () => {
55
55
  expect(discovery?.capabilities).toEqual(["presentation"]);
56
56
  expect(discovery?.schema).toBeUndefined();
57
57
  expect(discovery?.actions).toEqual(
58
- expect.arrayContaining(["send", "poll", "react", "reactions", "emoji-list", "permissions"]),
58
+ expect.arrayContaining([
59
+ "send",
60
+ "upload-file",
61
+ "poll",
62
+ "react",
63
+ "reactions",
64
+ "emoji-list",
65
+ "permissions",
66
+ ]),
59
67
  );
60
68
  expect(discovery?.actions).not.toContain("channel-create");
61
69
  expect(discovery?.actions).not.toContain("role-add");
@@ -144,13 +152,35 @@ describe("discordMessageActions", () => {
144
152
  });
145
153
 
146
154
  expect(defaultDiscovery?.actions).toEqual(expect.arrayContaining(["send", "poll"]));
155
+ expect(defaultDiscovery?.actions).toContain("upload-file");
147
156
  expect(defaultDiscovery?.actions).not.toContain("react");
148
157
  expect(workDiscovery?.actions).toEqual(
149
- expect.arrayContaining(["send", "react", "reactions", "emoji-list"]),
158
+ expect.arrayContaining(["send", "upload-file", "react", "reactions", "emoji-list"]),
150
159
  );
151
160
  expect(workDiscovery?.actions).not.toContain("poll");
152
161
  });
153
162
 
163
+ it("hides upload-file when Discord message actions are disabled", () => {
164
+ const discovery = discordMessageActions.describeMessageTool?.({
165
+ cfg: {
166
+ channels: {
167
+ discord: {
168
+ token: "Bot token-main",
169
+ actions: {
170
+ messages: false,
171
+ },
172
+ },
173
+ },
174
+ } as OpenClawConfig,
175
+ });
176
+
177
+ expect(discovery?.actions).toContain("send");
178
+ expect(discovery?.actions).not.toContain("upload-file");
179
+ expect(discovery?.actions).not.toContain("read");
180
+ expect(discovery?.actions).not.toContain("edit");
181
+ expect(discovery?.actions).not.toContain("delete");
182
+ });
183
+
154
184
  it("does not expose Discord-native message tool schema", () => {
155
185
  const discovery = discordMessageActions.describeMessageTool?.({
156
186
  cfg: {
@@ -170,7 +200,7 @@ describe("discordMessageActions", () => {
170
200
  );
171
201
  });
172
202
 
173
- it.each(["send", "edit", "delete", "react", "pin", "poll"])(
203
+ it.each(["send", "upload-file", "edit", "delete", "react", "pin", "poll"])(
174
204
  "routes %s actions through local execution mode",
175
205
  (action) => {
176
206
  expect(discordMessageActions.resolveExecutionMode?.({ action: action as never })).toBe(
@@ -210,6 +240,11 @@ describe("discordMessageActions", () => {
210
240
  const toolContext: ChannelMessageActionContext["toolContext"] = {
211
241
  currentChannelProvider: "discord",
212
242
  };
243
+ const mediaReadFile = vi.fn(async () => Buffer.from("image"));
244
+ const mediaAccess: NonNullable<ChannelMessageActionContext["mediaAccess"]> = {
245
+ localRoots: ["/tmp/media"],
246
+ readFile: mediaReadFile,
247
+ };
213
248
  const mediaLocalRoots = ["/tmp/media"];
214
249
 
215
250
  await discordMessageActions.handleAction?.({
@@ -220,7 +255,9 @@ describe("discordMessageActions", () => {
220
255
  accountId: "ops",
221
256
  requesterSenderId: "user-1",
222
257
  toolContext,
258
+ mediaAccess,
223
259
  mediaLocalRoots,
260
+ mediaReadFile,
224
261
  });
225
262
 
226
263
  expect(handleDiscordMessageActionMock).toHaveBeenCalledWith({
@@ -230,7 +267,9 @@ describe("discordMessageActions", () => {
230
267
  accountId: "ops",
231
268
  requesterSenderId: "user-1",
232
269
  toolContext,
270
+ mediaAccess,
233
271
  mediaLocalRoots,
272
+ mediaReadFile,
234
273
  });
235
274
  });
236
275
  });
@@ -86,6 +86,7 @@ function describeDiscordMessageTool({
86
86
  actions.add("emoji-list");
87
87
  }
88
88
  if (discovery.isEnabled("messages")) {
89
+ actions.add("upload-file");
89
90
  actions.add("read");
90
91
  actions.add("edit");
91
92
  actions.add("delete");
@@ -181,7 +182,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
181
182
  accountId,
182
183
  requesterSenderId,
183
184
  toolContext,
185
+ mediaAccess,
184
186
  mediaLocalRoots,
187
+ mediaReadFile,
185
188
  }) => {
186
189
  return await (
187
190
  await loadDiscordChannelActionsRuntime()
@@ -192,7 +195,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
192
195
  accountId,
193
196
  requesterSenderId,
194
197
  toolContext,
198
+ mediaAccess,
195
199
  mediaLocalRoots,
200
+ mediaReadFile,
196
201
  });
197
202
  },
198
203
  };
@@ -18,6 +18,7 @@ const DISCORD_CHANNEL_META = {
18
18
  blurb: "very well supported right now.",
19
19
  systemImage: "bubble.left.and.bubble.right",
20
20
  markdownCapable: true,
21
+ preferSessionLookupForAnnounceTarget: true,
21
22
  } as const;
22
23
 
23
24
  export function getChatChannelMeta(id: string) {
@@ -119,6 +119,14 @@ describe("discordPlugin outbound", () => {
119
119
  expect(discordPlugin.outbound?.preferFinalAssistantVisibleText).toBe(true);
120
120
  });
121
121
 
122
+ it("adds Discord mention formatting to agent prompt hints", () => {
123
+ const hints = discordPlugin.agentPrompt?.messageToolHints?.({} as never) ?? [];
124
+
125
+ expect(hints).toContain(
126
+ "- Discord mentions: use canonical outbound syntax: users `<@USER_ID>`, channels `<#CHANNEL_ID>`, and roles `<@&ROLE_ID>`. Do not use the legacy `<@!USER_ID>` nickname form.",
127
+ );
128
+ });
129
+
122
130
  it("preserves normalized explicit Discord targets for delivery routing", () => {
123
131
  const parseExplicitTarget = discordPlugin.messaging?.parseExplicitTarget;
124
132
  if (!parseExplicitTarget) {
package/src/channel.ts CHANGED
@@ -213,6 +213,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
213
213
  },
214
214
  agentPrompt: {
215
215
  messageToolHints: () => [
216
+ "- Discord mentions: use canonical outbound syntax: users `<@USER_ID>`, channels `<#CHANNEL_ID>`, and roles `<@&ROLE_ID>`. Do not use the legacy `<@!USER_ID>` nickname form.",
216
217
  "- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.",
217
218
  "- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.",
218
219
  ],
package/src/client.ts CHANGED
@@ -61,13 +61,6 @@ function resolveToken(params: { accountId: string; fallbackToken?: string }) {
61
61
  return fallback;
62
62
  }
63
63
 
64
- export function resolveDiscordProxyFetch(
65
- opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
66
- runtime?: Pick<RuntimeEnv, "error">,
67
- ): typeof fetch | undefined {
68
- return resolveDiscordClientAccountContext(opts, runtime).proxyFetch;
69
- }
70
-
71
64
  function resolveRest(
72
65
  token: string,
73
66
  account: ResolvedDiscordAccount,
@@ -113,13 +113,13 @@ export const discordChannelConfigUiHints = {
113
113
  label: "Discord Thread Binding Max Age (hours)",
114
114
  help: "Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
115
115
  },
116
- "threadBindings.spawnSubagentSessions": {
117
- label: "Discord Thread-Bound Subagent Spawn",
118
- help: "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
116
+ "threadBindings.spawnSessions": {
117
+ label: "Discord Thread-Bound Session Spawn",
118
+ help: "Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel.",
119
119
  },
120
- "threadBindings.spawnAcpSessions": {
121
- label: "Discord Thread-Bound ACP Spawn",
122
- help: "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.",
120
+ "threadBindings.defaultSpawnContext": {
121
+ label: "Discord Thread Spawn Context",
122
+ help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
123
123
  },
124
124
  "ui.components.accentColor": {
125
125
  label: "Discord Component Accent Color",