mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 20:02:57 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user