fix: canonicalize discord pluralkit inbound ids

This commit is contained in:
Peter Steinberger
2026-05-02 10:36:13 +01:00
parent 9e983ef8e1
commit 70dcd81f03
7 changed files with 131 additions and 25 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack.
- 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.
- Discord: preserve non-ASCII channel names in session display labels while keeping allowlist matching on the existing ASCII slug contract. Thanks @swjeong9.
- 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.
- 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.
- 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.
- 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.

View File

@@ -52,6 +52,7 @@ export async function buildDiscordMessageProcessContext(params: {
message,
author,
sender,
canonicalMessageId,
data,
client,
channelInfo,
@@ -323,7 +324,10 @@ export async function buildDiscordMessageProcessContext(params: {
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: ctx.effectiveWasMentioned,
MessageSid: message.id,
MessageSid: canonicalMessageId ?? message.id,
...(canonicalMessageId && canonicalMessageId !== message.id
? { MessageSidFull: message.id }
: {}),
ReplyToId: filteredReplyContext?.id,
ReplyToBody: filteredReplyContext?.body,
ReplyToSender: filteredReplyContext?.sender,

View File

@@ -4,13 +4,12 @@ import type { DiscordMessageEvent } from "./message-handler.preflight.types.js";
export async function resolveDiscordPreflightPluralKitInfo(params: {
message: DiscordMessageEvent["message"];
webhookId?: string | null;
config?: NonNullable<
NonNullable<import("openclaw/plugin-sdk/config-types").OpenClawConfig["channels"]>["discord"]
>["pluralkit"];
abortSignal?: AbortSignal;
}): Promise<Awaited<ReturnType<typeof import("../pluralkit.js").fetchPluralKitMessageInfo>>> {
if (!params.config?.enabled || params.webhookId) {
if (!params.config?.enabled) {
return null;
}
try {

View File

@@ -3,9 +3,13 @@ import { ChannelType } from "../internal/discord.js";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
const fetchPluralKitMessageInfoMock = vi.hoisted(() => vi.fn());
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../pluralkit.js", () => ({
fetchPluralKitMessageInfo: (...args: unknown[]) => fetchPluralKitMessageInfoMock(...args),
}));
vi.mock("./preflight-audio.runtime.js", () => ({
transcribeFirstAudio: transcribeFirstAudioMock,
}));
@@ -45,6 +49,10 @@ beforeAll(async () => {
await import("./thread-bindings.js"));
});
beforeEach(() => {
fetchPluralKitMessageInfoMock.mockReset();
});
function createThreadBinding(
overrides?: Partial<import("openclaw/plugin-sdk/conversation-runtime").SessionBindingRecord>,
) {
@@ -717,6 +725,77 @@ describe("preflightDiscordMessage", () => {
expect(result).toBeNull();
});
it("canonicalizes PluralKit webhook messages to the original Discord message id", async () => {
fetchPluralKitMessageInfoMock.mockResolvedValue({
id: "proxy-456",
original: "orig-123",
member: { id: "member-1", name: "Echo" },
system: { id: "system-1", name: "System" },
});
const result = await runGuildPreflight({
channelId: "c1",
guildId: "g1",
message: createDiscordMessage({
id: "proxy-456",
channelId: "c1",
content: "<@openclaw-bot> hello",
webhookId: "pluralkit-webhook-1",
author: {
id: "webhook-author",
bot: true,
username: "PluralKit",
},
mentionedUsers: [{ id: "openclaw-bot" }],
}),
discordConfig: {
pluralkit: { enabled: true },
} as DiscordConfig,
});
expect(fetchPluralKitMessageInfoMock).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "proxy-456",
config: expect.objectContaining({ enabled: true }),
}),
);
expect(result).not.toBeNull();
expect(result?.sender.isPluralKit).toBe(true);
expect(result?.canonicalMessageId).toBe("orig-123");
});
it("skips PluralKit lookup for bound-thread webhook echoes", async () => {
const threadBinding = createThreadBinding({
targetKind: "session",
targetSessionKey: "agent:main:acp:discord-thread-1",
});
const threadId = "thread-webhook-pk-echo-1";
const parentId = "channel-parent-webhook-pk-echo-1";
const result = await runThreadBoundPreflight({
threadId,
parentId,
threadBinding,
message: createDiscordMessage({
id: "m-webhook-pk-echo-1",
channelId: threadId,
content: "proxied user message",
webhookId: "pluralkit-webhook-1",
author: {
id: "relay-bot-1",
bot: true,
username: "Proxy",
},
}),
discordConfig: {
pluralkit: { enabled: true },
} as DiscordConfig,
});
expect(result).toBeNull();
expect(fetchPluralKitMessageInfoMock).not.toHaveBeenCalled();
});
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
const threadBinding = createThreadBinding();
const threadId = "thread-bot-focus";

View File

@@ -121,28 +121,6 @@ export async function preflightDiscordMessage(
const pluralkitConfig = params.discordConfig?.pluralkit;
const webhookId = resolveDiscordWebhookId(message);
const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({
message,
webhookId,
config: pluralkitConfig,
abortSignal: params.abortSignal,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
const sender = resolveDiscordSenderIdentity({
author,
member: params.data.member,
pluralkitInfo,
});
if (author.bot) {
if (allowBotsMode === "off" && !sender.isPluralKit) {
logVerbose("discord: drop bot message (allowBots=false)");
return null;
}
}
const isGuildMessage = Boolean(params.data.guild_id);
const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId);
if (isPreflightAborted(params.abortSignal)) {
@@ -189,6 +167,26 @@ export async function preflightDiscordMessage(
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
return null;
}
const pluralkitInfo = await resolveDiscordPreflightPluralKitInfo({
message,
config: pluralkitConfig,
abortSignal: params.abortSignal,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
const sender = resolveDiscordSenderIdentity({
author,
member: params.data.member,
pluralkitInfo,
});
if (author.bot) {
if (allowBotsMode === "off" && !sender.isPluralKit) {
logVerbose("discord: drop bot message (allowBots=false)");
return null;
}
}
const data = message === params.data.message ? params.data : { ...params.data, message };
logDebug(
`[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(
messageChannelId,
author,
sender,
canonicalMessageId: pluralkitInfo?.original?.trim() || undefined,
memberRoleIds,
channelInfo,
channelName,

View File

@@ -42,6 +42,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
messageChannelId: string;
author: User;
sender: DiscordSenderIdentity;
canonicalMessageId?: string;
memberRoleIds: string[];
channelInfo: DiscordChannelInfo | null;

View File

@@ -357,6 +357,8 @@ function getLastDispatchCtx():
CommandBody?: string;
From?: string;
MediaTranscribedIndexes?: number[];
MessageSid?: string;
MessageSidFull?: string;
MessageThreadId?: string | number;
ModelParentSessionKey?: string;
OriginatingTo?: string;
@@ -375,6 +377,8 @@ function getLastDispatchCtx():
CommandBody?: string;
From?: string;
MediaTranscribedIndexes?: number[];
MessageSid?: string;
MessageSidFull?: string;
MessageThreadId?: string | number;
ModelParentSessionKey?: string;
OriginatingTo?: string;
@@ -925,6 +929,25 @@ describe("processDiscordMessage session routing", () => {
expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalled();
});
it("uses PluralKit original ids for inbound dedupe while preserving the Discord message id", async () => {
const ctx = await createBaseContext({
canonicalMessageId: "orig-123",
message: {
id: "proxy-456",
channelId: "c1",
timestamp: new Date().toISOString(),
attachments: [],
},
});
await runProcessDiscordMessage(ctx);
expect(getLastDispatchCtx()).toMatchObject({
MessageSid: "orig-123",
MessageSidFull: "proxy-456",
});
});
it("defaults guild replies to message-tool-only source delivery", async () => {
await runProcessDiscordMessage(
await createBaseContext({