fix(discord): handle PluralKit DM pairing ids

Fix Discord DM pairing for PluralKit senders by storing the pairing identity with the same `pk:<member-id>` form used at inbound lookup time. Also recognizes both canonical direct DM session keys and account-scoped direct DM session keys as DM approval sessions.

Focused proof: `node scripts/run-vitest.mjs extensions/discord/src/approval-native.test.ts extensions/discord/src/monitor/dm-command-auth.test.ts extensions/discord/src/monitor/dm-command-decision.test.ts extensions/discord/src/monitor/message-handler.preflight.test.ts` passed with 4 files and 82 tests.

Closes #86332

Co-authored-by: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com>
This commit is contained in:
Sanjay Santhanam
2026-05-31 08:35:48 -07:00
committed by GitHub
parent b9dc3c3894
commit e0e7bae612
5 changed files with 148 additions and 5 deletions

View File

@@ -170,6 +170,56 @@ describe("createDiscordNativeApprovalAdapter", () => {
expect(target).toBeNull();
});
it("falls back to approver DMs for canonical Discord direct sessions", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:direct:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
it("falls back to approver DMs for account-scoped Discord direct sessions", async () => {
const adapter = createDiscordNativeApprovalAdapter();
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
approvalKind: "plugin",
request: {
id: "abc",
request: {
title: "Plugin approval",
description: "Let plugin proceed",
sessionKey: "agent:main:discord:default:direct:123456789",
turnSourceChannel: "discord",
turnSourceTo: "123456789",
turnSourceAccountId: "main",
},
createdAtMs: 1,
expiresAtMs: 2,
},
});
expect(target).toBeNull();
});
it("ignores session-store turn targets for Discord DM sessions", async () => {
writeStore({
"agent:main:discord:dm:123456789": {

View File

@@ -36,11 +36,18 @@ function extractDiscordSessionKind(sessionKey?: string | null): "channel" | "gro
if (!sessionKey) {
return null;
}
const match = sessionKey.match(/discord:(channel|group|dm):/);
// DM session keys use the `direct` peer kind in the normalized form
// (`agent:<id>:discord[:account]:direct:<userId>`); legacy keys may still use
// `dm`. Treat both as the same logical kind for downstream comparisons.
const match = sessionKey.match(/discord:(?:[^:]+:)?(channel|group|dm|direct):/);
if (!match) {
return null;
}
return match[1] as "channel" | "group" | "dm";
const raw = match[1];
if (raw === "direct") {
return "dm";
}
return raw as "channel" | "group" | "dm";
}
function normalizeDiscordOriginChannelId(value?: string | null): string | null {

View File

@@ -167,6 +167,24 @@ describe("resolveDiscordDmCommandAccess", () => {
expect(dmCommandAuthorized(result)).toBe(true);
});
it("authorizes PluralKit senders from prefixed pairing-store allowlist entries", async () => {
const result = await resolveDiscordDmCommandAccess({
accountId: "default",
dmPolicy: "pairing",
configuredAllowFrom: [],
sender: {
id: "pk-member-1",
name: "Echo",
tag: "Echo",
},
allowNameMatching: false,
readStoreAllowFrom: async () => ["pk:pk-member-1"],
});
expect(result.senderAccess.decision).toBe("allow");
expect(dmCommandAuthorized(result)).toBe(true);
});
it("authorizes allowlist DMs from a Discord channel audience access group", async () => {
canViewDiscordGuildChannelMock.mockResolvedValueOnce(true);

View File

@@ -24,6 +24,10 @@ async function loadDiscordSendRuntime() {
return await discordSendRuntimePromise;
}
function resolveDiscordDmPairingSenderId(sender: DiscordSenderIdentity): string {
return sender.isPluralKit ? `pk:${sender.id}` : sender.id;
}
export async function resolveDiscordDmPreflightAccess(params: {
preflight: DiscordMessagePreflightParams;
author: User;
@@ -79,10 +83,15 @@ export async function resolveDiscordDmPreflightAccess(params: {
await handleDiscordDmCommandDecision({
senderAccess: dmAccess.senderAccess,
accountId: params.resolvedAccountId,
// Use the resolved sender identity (e.g. PluralKit member UUID) here so
// the pairing record is keyed under the same stableId that
// resolveDiscordDmCommandAccess / createDiscordDmIngressSubject use on
// subsequent inbound messages. Previously this used the raw gateway
// author id, which only matched non-PK users.
sender: {
id: params.author.id,
tag: formatDiscordUserTag(params.author),
name: params.author.username ?? undefined,
id: resolveDiscordDmPairingSenderId(params.sender),
tag: params.sender.tag ?? formatDiscordUserTag(params.author),
name: params.sender.name ?? params.author.username ?? undefined,
},
onPairingCreated: async (code) => {
logVerbose(

View File

@@ -961,6 +961,65 @@ describe("preflightDiscordMessage", () => {
expect(preflight.canonicalMessageId).toBe("orig-123");
});
it("uses the resolved PluralKit member id when creating DM pairing requests", async () => {
fetchPluralKitMessageInfoMock.mockResolvedValue({
id: "proxy-dm-1",
original: "orig-dm-1",
member: { id: "pk-member-1", name: "Echo" },
system: { id: "system-1", name: "System" },
});
resolveDiscordDmCommandAccessMock.mockResolvedValue({
senderAccess: {
allowed: false,
decision: "pairing",
reasonCode: "dm_policy_pairing_required",
},
commandAccess: {
authorized: false,
},
});
const result = await runDmPreflight({
channelId: "dm-channel-pk-1",
message: createDiscordMessage({
id: "proxy-dm-1",
channelId: "dm-channel-pk-1",
content: "hello",
webhookId: "pluralkit-webhook-1",
author: {
id: "webhook-author",
bot: true,
username: "PluralKit",
},
}),
discordConfig: {
allowBots: true,
dmPolicy: "pairing",
pluralkit: { enabled: true },
} as DiscordConfig,
});
expect(result).toBeNull();
expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledWith(
expect.objectContaining({
sender: {
id: "pk-member-1",
name: "Echo",
tag: "Echo",
},
}),
);
expect(handleDiscordDmCommandDecisionMock).toHaveBeenCalledWith(
expect.objectContaining({
sender: {
id: "pk:pk-member-1",
tag: "Echo",
name: "Echo",
},
}),
);
});
it("skips PluralKit lookup for bound-thread webhook echoes", async () => {
const threadBinding = createThreadBinding({
targetKind: "session",