Skip to content

Commit 8e2b17e

Browse files
Discord: add PluralKit sender identity resolver (#5838)
* Discord: add PluralKit sender identity resolver * fix: resolve PluralKit sender identities (#5838) (thanks @thewilloftheshadow)
1 parent 66e33ab commit 8e2b17e

15 files changed

Lines changed: 354 additions & 55 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
### Fixes
1010

11+
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
1112
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
1213
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
1314

docs/channels/discord.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ ack reaction after the bot replies.
340340
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
341341
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
342342
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
343+
- `pluralkit`: resolve PluralKit proxied messages so system members appear as distinct senders.
343344
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
344345
- `reactions` (covers react + read reactions)
345346
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
@@ -355,6 +356,34 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
355356
- `all`: all reactions on all messages.
356357
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
357358

359+
### PluralKit (PK) support
360+
361+
Enable PK lookups so proxied messages resolve to the underlying system + member.
362+
When enabled, OpenClaw uses the member identity for allowlists and labels the
363+
sender as `Member (PK:System)` to avoid accidental Discord pings.
364+
365+
```json5
366+
{
367+
channels: {
368+
discord: {
369+
pluralkit: {
370+
enabled: true,
371+
token: "pk_live_..." // optional; required for private systems
372+
}
373+
}
374+
}
375+
}
376+
```
377+
378+
Allowlist notes (PK-enabled):
379+
380+
- Use `pk:<memberId>` in `dm.allowFrom`, `guilds.<id>.users`, or per-channel `users`.
381+
- Member display names are also matched by name/slug.
382+
- Lookups use the **original** Discord message ID (the pre-proxy message), so
383+
the PK API only resolves it within its 30-minute window.
384+
- If PK lookups fail (e.g., private system without a token), proxied messages
385+
are treated as bot messages and are dropped unless `channels.discord.allowBots=true`.
386+
358387
### Tool action defaults
359388

360389
| Action group | Default | Notes |

src/config/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ const FIELD_LABELS: Record<string, string> = {
328328
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
329329
"channels.discord.intents.presence": "Discord Presence Intent",
330330
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
331+
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
332+
"channels.discord.pluralkit.token": "Discord PluralKit Token",
331333
"channels.slack.dm.policy": "Slack DM Policy",
332334
"channels.slack.allowBots": "Slack Allow Bot Messages",
333335
"channels.discord.token": "Discord Bot Token",
@@ -674,6 +676,10 @@ const FIELD_HELP: Record<string, string> = {
674676
"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
675677
"channels.discord.intents.guildMembers":
676678
"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
679+
"channels.discord.pluralkit.enabled":
680+
"Resolve PluralKit proxied messages and treat system members as distinct senders.",
681+
"channels.discord.pluralkit.token":
682+
"Optional PluralKit token for resolving private systems or members.",
677683
"channels.slack.dm.policy":
678684
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
679685
};

src/config/types.discord.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
OutboundRetryConfig,
77
ReplyToMode,
88
} from "./types.base.js";
9+
import type { DiscordPluralKitConfig } from "../discord/pluralkit.js";
910
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
1011
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
1112
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
@@ -150,6 +151,8 @@ export type DiscordAccountConfig = {
150151
execApprovals?: DiscordExecApprovalConfig;
151152
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
152153
intents?: DiscordIntentsConfig;
154+
/** PluralKit identity resolution for proxied messages. */
155+
pluralkit?: DiscordPluralKitConfig;
153156
};
154157

155158
export type DiscordConfig = {

src/config/zod-schema.providers-core.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,13 @@ export const DiscordAccountSchema = z
276276
})
277277
.strict()
278278
.optional(),
279+
pluralkit: z
280+
.object({
281+
enabled: z.boolean().optional(),
282+
token: z.string().optional(),
283+
})
284+
.strict()
285+
.optional(),
279286
})
280287
.strict();
281288

src/discord/monitor.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,16 @@ describe("discord allowlist helpers", () => {
149149
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true);
150150
expect(allowListMatches(allow, { name: "other" })).toBe(false);
151151
});
152+
153+
it("matches pk-prefixed allowlist entries", () => {
154+
const allow = normalizeDiscordAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]);
155+
expect(allow).not.toBeNull();
156+
if (!allow) {
157+
throw new Error("Expected allow list to be normalized");
158+
}
159+
expect(allowListMatches(allow, { id: "member-123" })).toBe(true);
160+
expect(allowListMatches(allow, { id: "member-999" })).toBe(false);
161+
});
152162
});
153163

154164
describe("discord guild/channel resolution", () => {

src/discord/monitor/allow-list.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export function resolveDiscordUserAllowed(params: {
141141
userName?: string;
142142
userTag?: string;
143143
}) {
144-
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:"]);
144+
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
145145
if (!allowList) {
146146
return true;
147147
}
@@ -161,7 +161,7 @@ export function resolveDiscordCommandAuthorized(params: {
161161
if (!params.isDirectMessage) {
162162
return true;
163163
}
164-
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
164+
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:", "pk:"]);
165165
if (!allowList) {
166166
return true;
167167
}
@@ -409,7 +409,7 @@ export function shouldEmitDiscordReactionNotification(params: {
409409
return Boolean(params.botId && params.messageAuthorId === params.botId);
410410
}
411411
if (mode === "allowlist") {
412-
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:"]);
412+
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:", "pk:"]);
413413
if (!list) {
414414
return false;
415415
}

src/discord/monitor/message-handler.preflight.ts

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { ChannelType, MessageType, type User } from "@buape/carbon";
2-
import type {
3-
DiscordMessagePreflightContext,
4-
DiscordMessagePreflightParams,
5-
} from "./message-handler.preflight.types.js";
2+
63
import { hasControlCommand } from "../../auto-reply/command-detection.js";
74
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
85
import {
@@ -27,6 +24,7 @@ import {
2724
upsertChannelPairingRequest,
2825
} from "../../pairing/pairing-store.js";
2926
import { resolveAgentRoute } from "../../routing/resolve-route.js";
27+
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
3028
import { sendMessageDiscord } from "../send.js";
3129
import {
3230
allowListMatches,
@@ -45,13 +43,19 @@ import {
4543
resolveDiscordSystemLocation,
4644
resolveTimestampMs,
4745
} from "./format.js";
46+
import type {
47+
DiscordMessagePreflightContext,
48+
DiscordMessagePreflightParams,
49+
} from "./message-handler.preflight.types.js";
4850
import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js";
51+
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
4952
import { resolveDiscordSystemEvent } from "./system-events.js";
5053
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
5154

5255
export type {
5356
DiscordMessagePreflightContext,
5457
DiscordMessagePreflightParams,
58+
DiscordSenderIdentity,
5559
} from "./message-handler.preflight.types.js";
5660

5761
export async function preflightDiscordMessage(
@@ -65,12 +69,33 @@ export async function preflightDiscordMessage(
6569
}
6670

6771
const allowBots = params.discordConfig?.allowBots ?? false;
68-
if (author.bot) {
72+
if (author.bot && params.botUserId && author.id === params.botUserId) {
6973
// Always ignore own messages to prevent self-reply loops
70-
if (params.botUserId && author.id === params.botUserId) {
71-
return null;
74+
return null;
75+
}
76+
77+
const pluralkitConfig = params.discordConfig?.pluralkit;
78+
const webhookId = resolveDiscordWebhookId(message);
79+
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
80+
let pluralkitInfo: Awaited<ReturnType<typeof fetchPluralKitMessageInfo>> = null;
81+
if (shouldCheckPluralKit) {
82+
try {
83+
pluralkitInfo = await fetchPluralKitMessageInfo({
84+
messageId: message.id,
85+
config: pluralkitConfig,
86+
});
87+
} catch (err) {
88+
logVerbose(`discord: pluralkit lookup failed for ${message.id}: ${String(err)}`);
7289
}
73-
if (!allowBots) {
90+
}
91+
const sender = resolveDiscordSenderIdentity({
92+
author,
93+
member: params.data.member,
94+
pluralkitInfo,
95+
});
96+
97+
if (author.bot) {
98+
if (!allowBots && !sender.isPluralKit) {
7499
logVerbose("discord: drop bot message (allowBots=false)");
75100
return null;
76101
}
@@ -100,14 +125,14 @@ export async function preflightDiscordMessage(
100125
if (dmPolicy !== "open") {
101126
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
102127
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
103-
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
128+
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
104129
const allowMatch = allowList
105130
? resolveDiscordAllowListMatch({
106131
allowList,
107132
candidate: {
108-
id: author.id,
109-
name: author.username,
110-
tag: formatDiscordUserTag(author),
133+
id: sender.id,
134+
name: sender.name,
135+
tag: sender.tag,
111136
},
112137
})
113138
: { allowed: false };
@@ -148,7 +173,7 @@ export async function preflightDiscordMessage(
148173
}
149174
} else {
150175
logVerbose(
151-
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
176+
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
152177
);
153178
}
154179
return null;
@@ -349,7 +374,7 @@ export async function preflightDiscordMessage(
349374
const historyEntry =
350375
isGuildMessage && params.historyLimit > 0 && textForHistory
351376
? ({
352-
sender: params.data.member?.nickname ?? author.globalName ?? author.username ?? author.id,
377+
sender: sender.label,
353378
body: textForHistory,
354379
timestamp: resolveTimestampMs(message.timestamp),
355380
messageId: message.id,
@@ -372,22 +397,26 @@ export async function preflightDiscordMessage(
372397
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
373398

374399
if (!isDirectMessage) {
375-
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
400+
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
401+
"discord:",
402+
"user:",
403+
"pk:",
404+
]);
376405
const ownerOk = ownerAllowList
377406
? allowListMatches(ownerAllowList, {
378-
id: author.id,
379-
name: author.username,
380-
tag: formatDiscordUserTag(author),
407+
id: sender.id,
408+
name: sender.name,
409+
tag: sender.tag,
381410
})
382411
: false;
383412
const channelUsers = channelConfig?.users ?? guildInfo?.users;
384413
const usersOk =
385414
Array.isArray(channelUsers) && channelUsers.length > 0
386415
? resolveDiscordUserAllowed({
387416
allowList: channelUsers,
388-
userId: author.id,
389-
userName: author.username,
390-
userTag: formatDiscordUserTag(author),
417+
userId: sender.id,
418+
userName: sender.name,
419+
userTag: sender.tag,
391420
})
392421
: false;
393422
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
@@ -408,7 +437,7 @@ export async function preflightDiscordMessage(
408437
log: logVerbose,
409438
channel: "discord",
410439
reason: "control command (unauthorized)",
411-
target: author.id,
440+
target: sender.id,
412441
});
413442
return null;
414443
}
@@ -452,12 +481,12 @@ export async function preflightDiscordMessage(
452481
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
453482
const userOk = resolveDiscordUserAllowed({
454483
allowList: channelUsers,
455-
userId: author.id,
456-
userName: author.username,
457-
userTag: formatDiscordUserTag(author),
484+
userId: sender.id,
485+
userName: sender.name,
486+
userTag: sender.tag,
458487
});
459488
if (!userOk) {
460-
logVerbose(`Blocked discord guild sender ${author.id} (not in channel users allowlist)`);
489+
logVerbose(`Blocked discord guild sender ${sender.id} (not in channel users allowlist)`);
461490
return null;
462491
}
463492
}
@@ -501,6 +530,7 @@ export async function preflightDiscordMessage(
501530
client: params.client,
502531
message,
503532
author,
533+
sender,
504534
channelInfo,
505535
channelName,
506536
isGuildMessage,

src/discord/monitor/message-handler.preflight.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ReplyToMode } from "../../config/config.js";
44
import type { resolveAgentRoute } from "../../routing/resolve-route.js";
55
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
66
import type { DiscordChannelInfo } from "./message-utils.js";
7+
import type { DiscordSenderIdentity } from "./sender-identity.js";
78
import type { DiscordThreadChannel } from "./threading.js";
89

910
export type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
@@ -32,6 +33,7 @@ export type DiscordMessagePreflightContext = {
3233
client: Client;
3334
message: DiscordMessageEvent["message"];
3435
author: User;
36+
sender: DiscordSenderIdentity;
3537

3638
channelInfo: DiscordChannelInfo | null;
3739
channelName?: string;

src/discord/monitor/message-handler.process.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
5757
ackReactionScope,
5858
message,
5959
author,
60+
sender,
6061
data,
6162
client,
6263
channelInfo,
@@ -125,12 +126,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
125126
channelName: channelName ?? message.channelId,
126127
channelId: message.channelId,
127128
});
128-
const senderTag = formatDiscordUserTag(author);
129-
const senderDisplay = data.member?.nickname ?? author.globalName ?? author.username;
130-
const senderLabel =
131-
senderDisplay && senderTag && senderDisplay !== senderTag
132-
? `${senderDisplay} (${senderTag})`
133-
: (senderDisplay ?? senderTag ?? author.id);
129+
const senderLabel = sender.label;
134130
const isForumParent =
135131
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
136132
const forumParentSlug =

0 commit comments

Comments
 (0)