Skip to content

Commit 70dcd81

Browse files
committed
fix: canonicalize discord pluralkit inbound ids
1 parent 9e983ef commit 70dcd81

7 files changed

Lines changed: 131 additions & 25 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
4141
- Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack.
4242
- Discord: preserve attachment and sticker filenames when saving inbound media, so agents can see human-readable file names instead of only UUID-based paths. Fixes #59744. Thanks @xela92 and @rockcent.
4343
- Discord: preserve non-ASCII channel names in session display labels while keeping allowlist matching on the existing ASCII slug contract. Thanks @swjeong9.
44+
- Discord/PluralKit: canonicalize proxied webhook turns to the original Discord message id for inbound dedupe, while preserving the proxy message id for reply routing. Thanks @acgh213.
4445
- Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.
4546
- Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq.
4647
- Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc.

extensions/discord/src/monitor/message-handler.context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export async function buildDiscordMessageProcessContext(params: {
5252
message,
5353
author,
5454
sender,
55+
canonicalMessageId,
5556
data,
5657
client,
5758
channelInfo,
@@ -323,7 +324,10 @@ export async function buildDiscordMessageProcessContext(params: {
323324
Provider: "discord" as const,
324325
Surface: "discord" as const,
325326
WasMentioned: ctx.effectiveWasMentioned,
326-
MessageSid: message.id,
327+
MessageSid: canonicalMessageId ?? message.id,
328+
...(canonicalMessageId && canonicalMessageId !== message.id
329+
? { MessageSidFull: message.id }
330+
: {}),
327331
ReplyToId: filteredReplyContext?.id,
328332
ReplyToBody: filteredReplyContext?.body,
329333
ReplyToSender: filteredReplyContext?.sender,

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import type { DiscordMessageEvent } from "./message-handler.preflight.types.js";
44

55
export async function resolveDiscordPreflightPluralKitInfo(params: {
66
message: DiscordMessageEvent["message"];
7-
webhookId?: string | null;
87
config?: NonNullable<
98
NonNullable<import("openclaw/plugin-sdk/config-types").OpenClawConfig["channels"]>["discord"]
109
>["pluralkit"];
1110
abortSignal?: AbortSignal;
1211
}): Promise<Awaited<ReturnType<typeof import("../pluralkit.js").fetchPluralKitMessageInfo>>> {
13-
if (!params.config?.enabled || params.webhookId) {
12+
if (!params.config?.enabled) {
1413
return null;
1514
}
1615
try {

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { ChannelType } from "../internal/discord.js";
33
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
44

55
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
6+
const fetchPluralKitMessageInfoMock = vi.hoisted(() => vi.fn());
67
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
78
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
89

10+
vi.mock("../pluralkit.js", () => ({
11+
fetchPluralKitMessageInfo: (...args: unknown[]) => fetchPluralKitMessageInfoMock(...args),
12+
}));
913
vi.mock("./preflight-audio.runtime.js", () => ({
1014
transcribeFirstAudio: transcribeFirstAudioMock,
1115
}));
@@ -45,6 +49,10 @@ beforeAll(async () => {
4549
await import("./thread-bindings.js"));
4650
});
4751

52+
beforeEach(() => {
53+
fetchPluralKitMessageInfoMock.mockReset();
54+
});
55+
4856
function createThreadBinding(
4957
overrides?: Partial<import("openclaw/plugin-sdk/conversation-runtime").SessionBindingRecord>,
5058
) {
@@ -717,6 +725,77 @@ describe("preflightDiscordMessage", () => {
717725
expect(result).toBeNull();
718726
});
719727

728+
it("canonicalizes PluralKit webhook messages to the original Discord message id", async () => {
729+
fetchPluralKitMessageInfoMock.mockResolvedValue({
730+
id: "proxy-456",
731+
original: "orig-123",
732+
member: { id: "member-1", name: "Echo" },
733+
system: { id: "system-1", name: "System" },
734+
});
735+
736+
const result = await runGuildPreflight({
737+
channelId: "c1",
738+
guildId: "g1",
739+
message: createDiscordMessage({
740+
id: "proxy-456",
741+
channelId: "c1",
742+
content: "<@openclaw-bot> hello",
743+
webhookId: "pluralkit-webhook-1",
744+
author: {
745+
id: "webhook-author",
746+
bot: true,
747+
username: "PluralKit",
748+
},
749+
mentionedUsers: [{ id: "openclaw-bot" }],
750+
}),
751+
discordConfig: {
752+
pluralkit: { enabled: true },
753+
} as DiscordConfig,
754+
});
755+
756+
expect(fetchPluralKitMessageInfoMock).toHaveBeenCalledWith(
757+
expect.objectContaining({
758+
messageId: "proxy-456",
759+
config: expect.objectContaining({ enabled: true }),
760+
}),
761+
);
762+
expect(result).not.toBeNull();
763+
expect(result?.sender.isPluralKit).toBe(true);
764+
expect(result?.canonicalMessageId).toBe("orig-123");
765+
});
766+
767+
it("skips PluralKit lookup for bound-thread webhook echoes", async () => {
768+
const threadBinding = createThreadBinding({
769+
targetKind: "session",
770+
targetSessionKey: "agent:main:acp:discord-thread-1",
771+
});
772+
const threadId = "thread-webhook-pk-echo-1";
773+
const parentId = "channel-parent-webhook-pk-echo-1";
774+
775+
const result = await runThreadBoundPreflight({
776+
threadId,
777+
parentId,
778+
threadBinding,
779+
message: createDiscordMessage({
780+
id: "m-webhook-pk-echo-1",
781+
channelId: threadId,
782+
content: "proxied user message",
783+
webhookId: "pluralkit-webhook-1",
784+
author: {
785+
id: "relay-bot-1",
786+
bot: true,
787+
username: "Proxy",
788+
},
789+
}),
790+
discordConfig: {
791+
pluralkit: { enabled: true },
792+
} as DiscordConfig,
793+
});
794+
795+
expect(result).toBeNull();
796+
expect(fetchPluralKitMessageInfoMock).not.toHaveBeenCalled();
797+
});
798+
720799
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
721800
const threadBinding = createThreadBinding();
722801
const threadId = "thread-bot-focus";

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

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -121,28 +121,6 @@ export async function preflightDiscordMessage(
121121

122122
const pluralkitConfig = params.discordConfig?.pluralkit;
123123
const webhookId = resolveDiscordWebhookId(message);
124-
const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({
125-
message,
126-
webhookId,
127-
config: pluralkitConfig,
128-
abortSignal: params.abortSignal,
129-
});
130-
if (isPreflightAborted(params.abortSignal)) {
131-
return null;
132-
}
133-
const sender = resolveDiscordSenderIdentity({
134-
author,
135-
member: params.data.member,
136-
pluralkitInfo,
137-
});
138-
139-
if (author.bot) {
140-
if (allowBotsMode === "off" && !sender.isPluralKit) {
141-
logVerbose("discord: drop bot message (allowBots=false)");
142-
return null;
143-
}
144-
}
145-
146124
const isGuildMessage = Boolean(params.data.guild_id);
147125
const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId);
148126
if (isPreflightAborted(params.abortSignal)) {
@@ -189,6 +167,26 @@ export async function preflightDiscordMessage(
189167
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
190168
return null;
191169
}
170+
const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({
171+
message,
172+
config: pluralkitConfig,
173+
abortSignal: params.abortSignal,
174+
});
175+
if (isPreflightAborted(params.abortSignal)) {
176+
return null;
177+
}
178+
const sender = resolveDiscordSenderIdentity({
179+
author,
180+
member: params.data.member,
181+
pluralkitInfo,
182+
});
183+
184+
if (author.bot) {
185+
if (allowBotsMode === "off" && !sender.isPluralKit) {
186+
logVerbose("discord: drop bot message (allowBots=false)");
187+
return null;
188+
}
189+
}
192190
const data = message === params.data.message ? params.data : { ...params.data, message };
193191
logDebug(
194192
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
@@ -639,6 +637,7 @@ export async function preflightDiscordMessage(
639637
messageChannelId,
640638
author,
641639
sender,
640+
canonicalMessageId: pluralkitInfo?.original?.trim() || undefined,
642641
memberRoleIds,
643642
channelInfo,
644643
channelName,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
4242
messageChannelId: string;
4343
author: User;
4444
sender: DiscordSenderIdentity;
45+
canonicalMessageId?: string;
4546
memberRoleIds: string[];
4647

4748
channelInfo: DiscordChannelInfo | null;

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ function getLastDispatchCtx():
357357
CommandBody?: string;
358358
From?: string;
359359
MediaTranscribedIndexes?: number[];
360+
MessageSid?: string;
361+
MessageSidFull?: string;
360362
MessageThreadId?: string | number;
361363
ModelParentSessionKey?: string;
362364
OriginatingTo?: string;
@@ -375,6 +377,8 @@ function getLastDispatchCtx():
375377
CommandBody?: string;
376378
From?: string;
377379
MediaTranscribedIndexes?: number[];
380+
MessageSid?: string;
381+
MessageSidFull?: string;
378382
MessageThreadId?: string | number;
379383
ModelParentSessionKey?: string;
380384
OriginatingTo?: string;
@@ -925,6 +929,25 @@ describe("processDiscordMessage session routing", () => {
925929
expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalled();
926930
});
927931

932+
it("uses PluralKit original ids for inbound dedupe while preserving the Discord message id", async () => {
933+
const ctx = await createBaseContext({
934+
canonicalMessageId: "orig-123",
935+
message: {
936+
id: "proxy-456",
937+
channelId: "c1",
938+
timestamp: new Date().toISOString(),
939+
attachments: [],
940+
},
941+
});
942+
943+
await runProcessDiscordMessage(ctx);
944+
945+
expect(getLastDispatchCtx()).toMatchObject({
946+
MessageSid: "orig-123",
947+
MessageSidFull: "proxy-456",
948+
});
949+
});
950+
928951
it("defaults guild replies to message-tool-only source delivery", async () => {
929952
await runProcessDiscordMessage(
930953
await createBaseContext({

0 commit comments

Comments
 (0)