@openclaw/discord 2026.1.29

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 ADDED
@@ -0,0 +1,18 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+
4
+ import { discordPlugin } from "./src/channel.js";
5
+ import { setDiscordRuntime } from "./src/runtime.js";
6
+
7
+ const plugin = {
8
+ id: "discord",
9
+ name: "Discord",
10
+ description: "Discord channel plugin",
11
+ configSchema: emptyPluginConfigSchema(),
12
+ register(api: OpenClawPluginApi) {
13
+ setDiscordRuntime(api.runtime);
14
+ api.registerChannel({ plugin: discordPlugin });
15
+ },
16
+ };
17
+
18
+ export default plugin;
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "discord",
3
+ "channels": [
4
+ "discord"
5
+ ],
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {}
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@openclaw/discord",
3
+ "version": "2026.1.29",
4
+ "type": "module",
5
+ "description": "OpenClaw Discord channel plugin",
6
+ "openclaw": {
7
+ "extensions": [
8
+ "./index.ts"
9
+ ]
10
+ }
11
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,422 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ buildChannelConfigSchema,
4
+ collectDiscordAuditChannelIds,
5
+ collectDiscordStatusIssues,
6
+ DEFAULT_ACCOUNT_ID,
7
+ deleteAccountFromConfigSection,
8
+ discordOnboardingAdapter,
9
+ DiscordConfigSchema,
10
+ formatPairingApproveHint,
11
+ getChatChannelMeta,
12
+ listDiscordAccountIds,
13
+ listDiscordDirectoryGroupsFromConfig,
14
+ listDiscordDirectoryPeersFromConfig,
15
+ looksLikeDiscordTargetId,
16
+ migrateBaseNameToDefaultAccount,
17
+ normalizeAccountId,
18
+ normalizeDiscordMessagingTarget,
19
+ PAIRING_APPROVED_MESSAGE,
20
+ resolveDiscordAccount,
21
+ resolveDefaultDiscordAccountId,
22
+ resolveDiscordGroupRequireMention,
23
+ resolveDiscordGroupToolPolicy,
24
+ setAccountEnabledInConfigSection,
25
+ type ChannelMessageActionAdapter,
26
+ type ChannelPlugin,
27
+ type ResolvedDiscordAccount,
28
+ } from "openclaw/plugin-sdk";
29
+
30
+ import { getDiscordRuntime } from "./runtime.js";
31
+
32
+ const meta = getChatChannelMeta("discord");
33
+
34
+ const discordMessageActions: ChannelMessageActionAdapter = {
35
+ listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx),
36
+ extractToolSend: (ctx) =>
37
+ getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx),
38
+ handleAction: async (ctx) =>
39
+ await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx),
40
+ };
41
+
42
+ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
43
+ id: "discord",
44
+ meta: {
45
+ ...meta,
46
+ },
47
+ onboarding: discordOnboardingAdapter,
48
+ pairing: {
49
+ idLabel: "discordUserId",
50
+ normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
51
+ notifyApproval: async ({ id }) => {
52
+ await getDiscordRuntime().channel.discord.sendMessageDiscord(
53
+ `user:${id}`,
54
+ PAIRING_APPROVED_MESSAGE,
55
+ );
56
+ },
57
+ },
58
+ capabilities: {
59
+ chatTypes: ["direct", "channel", "thread"],
60
+ polls: true,
61
+ reactions: true,
62
+ threads: true,
63
+ media: true,
64
+ nativeCommands: true,
65
+ },
66
+ streaming: {
67
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
68
+ },
69
+ reload: { configPrefixes: ["channels.discord"] },
70
+ configSchema: buildChannelConfigSchema(DiscordConfigSchema),
71
+ config: {
72
+ listAccountIds: (cfg) => listDiscordAccountIds(cfg),
73
+ resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
74
+ defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
75
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
76
+ setAccountEnabledInConfigSection({
77
+ cfg,
78
+ sectionKey: "discord",
79
+ accountId,
80
+ enabled,
81
+ allowTopLevel: true,
82
+ }),
83
+ deleteAccount: ({ cfg, accountId }) =>
84
+ deleteAccountFromConfigSection({
85
+ cfg,
86
+ sectionKey: "discord",
87
+ accountId,
88
+ clearBaseFields: ["token", "name"],
89
+ }),
90
+ isConfigured: (account) => Boolean(account.token?.trim()),
91
+ describeAccount: (account) => ({
92
+ accountId: account.accountId,
93
+ name: account.name,
94
+ enabled: account.enabled,
95
+ configured: Boolean(account.token?.trim()),
96
+ tokenSource: account.tokenSource,
97
+ }),
98
+ resolveAllowFrom: ({ cfg, accountId }) =>
99
+ (resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
100
+ String(entry),
101
+ ),
102
+ formatAllowFrom: ({ allowFrom }) =>
103
+ allowFrom
104
+ .map((entry) => String(entry).trim())
105
+ .filter(Boolean)
106
+ .map((entry) => entry.toLowerCase()),
107
+ },
108
+ security: {
109
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
110
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
111
+ const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]);
112
+ const allowFromPath = useAccountPath
113
+ ? `channels.discord.accounts.${resolvedAccountId}.dm.`
114
+ : "channels.discord.dm.";
115
+ return {
116
+ policy: account.config.dm?.policy ?? "pairing",
117
+ allowFrom: account.config.dm?.allowFrom ?? [],
118
+ allowFromPath,
119
+ approveHint: formatPairingApproveHint("discord"),
120
+ normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
121
+ };
122
+ },
123
+ collectWarnings: ({ account, cfg }) => {
124
+ const warnings: string[] = [];
125
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
126
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
127
+ const guildEntries = account.config.guilds ?? {};
128
+ const guildsConfigured = Object.keys(guildEntries).length > 0;
129
+ const channelAllowlistConfigured = guildsConfigured;
130
+
131
+ if (groupPolicy === "open") {
132
+ if (channelAllowlistConfigured) {
133
+ warnings.push(
134
+ `- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
135
+ );
136
+ } else {
137
+ warnings.push(
138
+ `- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds.<id>.channels.`,
139
+ );
140
+ }
141
+ }
142
+
143
+ return warnings;
144
+ },
145
+ },
146
+ groups: {
147
+ resolveRequireMention: resolveDiscordGroupRequireMention,
148
+ resolveToolPolicy: resolveDiscordGroupToolPolicy,
149
+ },
150
+ mentions: {
151
+ stripPatterns: () => ["<@!?\\d+>"],
152
+ },
153
+ threading: {
154
+ resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
155
+ },
156
+ messaging: {
157
+ normalizeTarget: normalizeDiscordMessagingTarget,
158
+ targetResolver: {
159
+ looksLikeId: looksLikeDiscordTargetId,
160
+ hint: "<channelId|user:ID|channel:ID>",
161
+ },
162
+ },
163
+ directory: {
164
+ self: async () => null,
165
+ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
166
+ listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
167
+ listPeersLive: async (params) =>
168
+ getDiscordRuntime().channel.discord.listDirectoryPeersLive(params),
169
+ listGroupsLive: async (params) =>
170
+ getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params),
171
+ },
172
+ resolver: {
173
+ resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
174
+ const account = resolveDiscordAccount({ cfg, accountId });
175
+ const token = account.token?.trim();
176
+ if (!token) {
177
+ return inputs.map((input) => ({
178
+ input,
179
+ resolved: false,
180
+ note: "missing Discord token",
181
+ }));
182
+ }
183
+ if (kind === "group") {
184
+ const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({
185
+ token,
186
+ entries: inputs,
187
+ });
188
+ return resolved.map((entry) => ({
189
+ input: entry.input,
190
+ resolved: entry.resolved,
191
+ id: entry.channelId ?? entry.guildId,
192
+ name:
193
+ entry.channelName ??
194
+ entry.guildName ??
195
+ (entry.guildId && !entry.channelId ? entry.guildId : undefined),
196
+ note: entry.note,
197
+ }));
198
+ }
199
+ const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({
200
+ token,
201
+ entries: inputs,
202
+ });
203
+ return resolved.map((entry) => ({
204
+ input: entry.input,
205
+ resolved: entry.resolved,
206
+ id: entry.id,
207
+ name: entry.name,
208
+ note: entry.note,
209
+ }));
210
+ },
211
+ },
212
+ actions: discordMessageActions,
213
+ setup: {
214
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
215
+ applyAccountName: ({ cfg, accountId, name }) =>
216
+ applyAccountNameToChannelSection({
217
+ cfg,
218
+ channelKey: "discord",
219
+ accountId,
220
+ name,
221
+ }),
222
+ validateInput: ({ accountId, input }) => {
223
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
224
+ return "DISCORD_BOT_TOKEN can only be used for the default account.";
225
+ }
226
+ if (!input.useEnv && !input.token) {
227
+ return "Discord requires token (or --use-env).";
228
+ }
229
+ return null;
230
+ },
231
+ applyAccountConfig: ({ cfg, accountId, input }) => {
232
+ const namedConfig = applyAccountNameToChannelSection({
233
+ cfg,
234
+ channelKey: "discord",
235
+ accountId,
236
+ name: input.name,
237
+ });
238
+ const next =
239
+ accountId !== DEFAULT_ACCOUNT_ID
240
+ ? migrateBaseNameToDefaultAccount({
241
+ cfg: namedConfig,
242
+ channelKey: "discord",
243
+ })
244
+ : namedConfig;
245
+ if (accountId === DEFAULT_ACCOUNT_ID) {
246
+ return {
247
+ ...next,
248
+ channels: {
249
+ ...next.channels,
250
+ discord: {
251
+ ...next.channels?.discord,
252
+ enabled: true,
253
+ ...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
254
+ },
255
+ },
256
+ };
257
+ }
258
+ return {
259
+ ...next,
260
+ channels: {
261
+ ...next.channels,
262
+ discord: {
263
+ ...next.channels?.discord,
264
+ enabled: true,
265
+ accounts: {
266
+ ...next.channels?.discord?.accounts,
267
+ [accountId]: {
268
+ ...next.channels?.discord?.accounts?.[accountId],
269
+ enabled: true,
270
+ ...(input.token ? { token: input.token } : {}),
271
+ },
272
+ },
273
+ },
274
+ },
275
+ };
276
+ },
277
+ },
278
+ outbound: {
279
+ deliveryMode: "direct",
280
+ chunker: null,
281
+ textChunkLimit: 2000,
282
+ pollMaxOptions: 10,
283
+ sendText: async ({ to, text, accountId, deps, replyToId }) => {
284
+ const send =
285
+ deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
286
+ const result = await send(to, text, {
287
+ verbose: false,
288
+ replyTo: replyToId ?? undefined,
289
+ accountId: accountId ?? undefined,
290
+ });
291
+ return { channel: "discord", ...result };
292
+ },
293
+ sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
294
+ const send =
295
+ deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
296
+ const result = await send(to, text, {
297
+ verbose: false,
298
+ mediaUrl,
299
+ replyTo: replyToId ?? undefined,
300
+ accountId: accountId ?? undefined,
301
+ });
302
+ return { channel: "discord", ...result };
303
+ },
304
+ sendPoll: async ({ to, poll, accountId }) =>
305
+ await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, {
306
+ accountId: accountId ?? undefined,
307
+ }),
308
+ },
309
+ status: {
310
+ defaultRuntime: {
311
+ accountId: DEFAULT_ACCOUNT_ID,
312
+ running: false,
313
+ lastStartAt: null,
314
+ lastStopAt: null,
315
+ lastError: null,
316
+ },
317
+ collectStatusIssues: collectDiscordStatusIssues,
318
+ buildChannelSummary: ({ snapshot }) => ({
319
+ configured: snapshot.configured ?? false,
320
+ tokenSource: snapshot.tokenSource ?? "none",
321
+ running: snapshot.running ?? false,
322
+ lastStartAt: snapshot.lastStartAt ?? null,
323
+ lastStopAt: snapshot.lastStopAt ?? null,
324
+ lastError: snapshot.lastError ?? null,
325
+ probe: snapshot.probe,
326
+ lastProbeAt: snapshot.lastProbeAt ?? null,
327
+ }),
328
+ probeAccount: async ({ account, timeoutMs }) =>
329
+ getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
330
+ includeApplication: true,
331
+ }),
332
+ auditAccount: async ({ account, timeoutMs, cfg }) => {
333
+ const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
334
+ cfg,
335
+ accountId: account.accountId,
336
+ });
337
+ if (!channelIds.length && unresolvedChannels === 0) return undefined;
338
+ const botToken = account.token?.trim();
339
+ if (!botToken) {
340
+ return {
341
+ ok: unresolvedChannels === 0,
342
+ checkedChannels: 0,
343
+ unresolvedChannels,
344
+ channels: [],
345
+ elapsedMs: 0,
346
+ };
347
+ }
348
+ const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({
349
+ token: botToken,
350
+ accountId: account.accountId,
351
+ channelIds,
352
+ timeoutMs,
353
+ });
354
+ return { ...audit, unresolvedChannels };
355
+ },
356
+ buildAccountSnapshot: ({ account, runtime, probe, audit }) => {
357
+ const configured = Boolean(account.token?.trim());
358
+ const app = runtime?.application ?? (probe as { application?: unknown })?.application;
359
+ const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot;
360
+ return {
361
+ accountId: account.accountId,
362
+ name: account.name,
363
+ enabled: account.enabled,
364
+ configured,
365
+ tokenSource: account.tokenSource,
366
+ running: runtime?.running ?? false,
367
+ lastStartAt: runtime?.lastStartAt ?? null,
368
+ lastStopAt: runtime?.lastStopAt ?? null,
369
+ lastError: runtime?.lastError ?? null,
370
+ application: app ?? undefined,
371
+ bot: bot ?? undefined,
372
+ probe,
373
+ audit,
374
+ lastInboundAt: runtime?.lastInboundAt ?? null,
375
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
376
+ };
377
+ },
378
+ },
379
+ gateway: {
380
+ startAccount: async (ctx) => {
381
+ const account = ctx.account;
382
+ const token = account.token.trim();
383
+ let discordBotLabel = "";
384
+ try {
385
+ const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, {
386
+ includeApplication: true,
387
+ });
388
+ const username = probe.ok ? probe.bot?.username?.trim() : null;
389
+ if (username) discordBotLabel = ` (@${username})`;
390
+ ctx.setStatus({
391
+ accountId: account.accountId,
392
+ bot: probe.bot,
393
+ application: probe.application,
394
+ });
395
+ const messageContent = probe.application?.intents?.messageContent;
396
+ if (messageContent === "disabled") {
397
+ ctx.log?.warn(
398
+ `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
399
+ );
400
+ } else if (messageContent === "limited") {
401
+ ctx.log?.info(
402
+ `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`,
403
+ );
404
+ }
405
+ } catch (err) {
406
+ if (getDiscordRuntime().logging.shouldLogVerbose()) {
407
+ ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
408
+ }
409
+ }
410
+ ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`);
411
+ return getDiscordRuntime().channel.discord.monitorDiscordProvider({
412
+ token,
413
+ accountId: account.accountId,
414
+ config: ctx.cfg,
415
+ runtime: ctx.runtime,
416
+ abortSignal: ctx.abortSignal,
417
+ mediaMaxMb: account.config.mediaMaxMb,
418
+ historyLimit: account.config.historyLimit,
419
+ });
420
+ },
421
+ },
422
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setDiscordRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getDiscordRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Discord runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }