diff --git a/extensions/discord/src/approval-native.test.ts b/extensions/discord/src/approval-native.test.ts index 89e3d1cd5ec..9f71ca04c0a 100644 --- a/extensions/discord/src/approval-native.test.ts +++ b/extensions/discord/src/approval-native.test.ts @@ -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": { diff --git a/extensions/discord/src/approval-native.ts b/extensions/discord/src/approval-native.ts index 796e86fa9f2..a060d15e965 100644 --- a/extensions/discord/src/approval-native.ts +++ b/extensions/discord/src/approval-native.ts @@ -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::discord[:account]:direct:`); 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 { diff --git a/extensions/discord/src/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts index 78e1d921386..3c5fa90b7b2 100644 --- a/extensions/discord/src/monitor/dm-command-auth.test.ts +++ b/extensions/discord/src/monitor/dm-command-auth.test.ts @@ -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); diff --git a/extensions/discord/src/monitor/message-handler.dm-preflight.ts b/extensions/discord/src/monitor/message-handler.dm-preflight.ts index 9f7a3a6d6f4..97d99616c60 100644 --- a/extensions/discord/src/monitor/message-handler.dm-preflight.ts +++ b/extensions/discord/src/monitor/message-handler.dm-preflight.ts @@ -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( diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index a6afefeb352..f1428b36561 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -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",