Skip to content

Commit 43e811f

Browse files
committed
Discord: resolve trusted principals via identity links
1 parent 4f4d108 commit 43e811f

15 files changed

Lines changed: 233 additions & 15 deletions

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,43 @@ describe("preflightDiscordMessage", () => {
360360
handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
361361
});
362362

363+
it("resolves trusted sender principal from configured identity links for guild messages", async () => {
364+
const message = createDiscordMessage({
365+
id: "m-identity-link",
366+
channelId: "channel-1",
367+
content: "hello",
368+
author: {
369+
id: "123",
370+
bot: false,
371+
username: "alias-attempt",
372+
},
373+
});
374+
375+
const result = await runGuildPreflight({
376+
channelId: "channel-1",
377+
guildId: "guild-1",
378+
message,
379+
cfg: {
380+
...DEFAULT_PREFLIGHT_CFG,
381+
session: {
382+
...DEFAULT_PREFLIGHT_CFG.session,
383+
identityLinks: {
384+
alice: ["discord:123"],
385+
},
386+
},
387+
},
388+
discordConfig: {} as DiscordConfig,
389+
guildEntries: {
390+
"guild-1": {
391+
requireMention: false,
392+
},
393+
},
394+
});
395+
396+
expect(result?.sender.id).toBe("123");
397+
expect(result?.sender.trustedPrincipal).toBe("alice");
398+
});
399+
363400
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
364401
const threadBinding = createThreadBinding({
365402
targetKind: "session",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export async function preflightDiscordMessage(
302302
author,
303303
member: params.data.member,
304304
pluralkitInfo,
305+
identityLinks: params.cfg.session?.identityLinks,
305306
});
306307

307308
if (author.bot) {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ function getLastDispatchCtx():
451451
ThreadStarterBody?: string;
452452
To?: string;
453453
Transcript?: string;
454+
TrustedSenderPrincipal?: string;
454455
}
455456
| undefined {
456457
const callArgs = dispatchInboundMessage.mock.calls[
@@ -474,6 +475,7 @@ function getLastDispatchCtx():
474475
ThreadStarterBody?: string;
475476
To?: string;
476477
Transcript?: string;
478+
TrustedSenderPrincipal?: string;
477479
};
478480
}
479481
| undefined;
@@ -1667,6 +1669,22 @@ describe("processDiscordMessage session routing", () => {
16671669
});
16681670
expect(getLastDispatchCtx()?.ThreadStarterBody).toBeUndefined();
16691671
});
1672+
1673+
it("carries trusted sender principal into dispatch context", async () => {
1674+
const ctx = await createBaseContext({
1675+
sender: {
1676+
id: "pk-member-1",
1677+
label: "Display Name",
1678+
trustedPrincipal: "alice",
1679+
},
1680+
});
1681+
1682+
await runProcessDiscordMessage(ctx);
1683+
1684+
expect(getLastDispatchCtx()).toMatchObject({
1685+
TrustedSenderPrincipal: "alice",
1686+
});
1687+
});
16701688
});
16711689

16721690
describe("processDiscordMessage draft streaming", () => {

extensions/discord/src/monitor/native-command-context.test.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ describe("buildDiscordNativeCommandContext", () => {
66
const ctx = buildDiscordNativeCommandContext({
77
prompt: "/status",
88
commandArgs: {},
9-
sessionKey: "agent:codex:discord:slash:user-1",
10-
commandTargetSessionKey: "agent:codex:discord:direct:user-1",
9+
sessionKey: "agent:codex:discord:slash:123456789",
10+
commandTargetSessionKey: "agent:codex:discord:direct:123456789",
1111
accountId: "default",
1212
interactionId: "interaction-1",
1313
channelId: "dm-1",
@@ -17,24 +17,28 @@ describe("buildDiscordNativeCommandContext", () => {
1717
isGuild: false,
1818
isThreadChannel: false,
1919
user: {
20-
id: "user-1",
20+
id: "123456789",
2121
username: "tester",
2222
globalName: "Tester",
2323
},
2424
sender: {
25-
id: "user-1",
25+
id: "123456789",
2626
tag: "tester#0001",
2727
},
28+
identityLinks: {
29+
owner: ["discord:123456789"],
30+
},
2831
timestampMs: 123,
2932
});
3033

31-
expect(ctx.From).toBe("discord:user-1");
32-
expect(ctx.To).toBe("slash:user-1");
34+
expect(ctx.From).toBe("discord:123456789");
35+
expect(ctx.To).toBe("slash:123456789");
3336
expect(ctx.ChatType).toBe("direct");
3437
expect(ctx.ConversationLabel).toBe("Tester");
35-
expect(ctx.SessionKey).toBe("agent:codex:discord:slash:user-1");
36-
expect(ctx.CommandTargetSessionKey).toBe("agent:codex:discord:direct:user-1");
37-
expect(ctx.OriginatingTo).toBe("user:user-1");
38+
expect(ctx.SessionKey).toBe("agent:codex:discord:slash:123456789");
39+
expect(ctx.CommandTargetSessionKey).toBe("agent:codex:discord:direct:123456789");
40+
expect(ctx.OriginatingTo).toBe("user:123456789");
41+
expect(ctx.TrustedSenderPrincipal).toBe("owner");
3842
expect(ctx.UntrustedContext).toBeUndefined();
3943
expect(ctx.UntrustedStructuredContext).toBeUndefined();
4044
expect(ctx.GroupSystemPrompt).toBeUndefined();
@@ -45,7 +49,7 @@ describe("buildDiscordNativeCommandContext", () => {
4549
const ctx = buildDiscordNativeCommandContext({
4650
prompt: "/status",
4751
commandArgs: { values: { model: "gpt-5.2" } },
48-
sessionKey: "agent:codex:discord:slash:user-1",
52+
sessionKey: "agent:codex:discord:slash:123456789",
4953
commandTargetSessionKey: "agent:codex:discord:channel:chan-1",
5054
accountId: "default",
5155
interactionId: "interaction-1",
@@ -56,7 +60,7 @@ describe("buildDiscordNativeCommandContext", () => {
5660
channelTopic: "Production alerts only",
5761
channelConfig: {
5862
allowed: true,
59-
users: ["discord:user-1"],
63+
users: ["discord:123456789"],
6064
systemPrompt: "Use the runbook.",
6165
},
6266
guildInfo: {
@@ -69,14 +73,17 @@ describe("buildDiscordNativeCommandContext", () => {
6973
isGuild: true,
7074
isThreadChannel: true,
7175
user: {
72-
id: "user-1",
76+
id: "123456789",
7377
username: "tester",
7478
},
7579
sender: {
76-
id: "user-1",
80+
id: "123456789",
7781
name: "tester",
7882
tag: "tester#0001",
7983
},
84+
identityLinks: {
85+
alice: ["discord:123456789"],
86+
},
8087
timestampMs: 456,
8188
});
8289

@@ -87,7 +94,8 @@ describe("buildDiscordNativeCommandContext", () => {
8794
expect(ctx.GroupSpace).toBe("guild-1");
8895
expect(ctx.MemberRoleIds).toEqual(["admin"]);
8996
expect(ctx.GroupSystemPrompt).toBe("Use the runbook.");
90-
expect(ctx.OwnerAllowFrom).toEqual(["user-1"]);
97+
expect(ctx.OwnerAllowFrom).toEqual(["123456789"]);
98+
expect(ctx.TrustedSenderPrincipal).toBe("alice");
9199
expect(ctx.MessageThreadId).toBe("chan-1");
92100
expect(ctx.ThreadParentId).toBe("parent-1");
93101
expect(ctx.OriginatingTo).toBe("channel:chan-1");

extensions/discord/src/monitor/native-command-context.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runti
33
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
44
import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js";
55
import { buildDiscordInboundAccessContext } from "./inbound-context.js";
6+
import { resolveDiscordTrustedPrincipalFromUserId } from "./sender-identity.js";
67

78
type BuildDiscordNativeCommandContextParams = {
89
prompt: string;
@@ -35,10 +36,15 @@ type BuildDiscordNativeCommandContextParams = {
3536
name?: string;
3637
tag?: string;
3738
};
39+
identityLinks?: Record<string, string[]>;
3840
timestampMs?: number;
3941
};
4042

4143
export function buildDiscordNativeCommandContext(params: BuildDiscordNativeCommandContextParams) {
44+
const trustedSenderPrincipal = resolveDiscordTrustedPrincipalFromUserId({
45+
userId: params.user.id,
46+
identityLinks: params.identityLinks,
47+
});
4248
const conversationLabel = params.isDirectMessage
4349
? (params.user.globalName ?? params.user.username)
4450
: params.channelId;
@@ -80,6 +86,7 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma
8086
SenderId: params.user.id,
8187
SenderUsername: params.user.username,
8288
SenderTag: params.sender.tag,
89+
TrustedSenderPrincipal: trustedSenderPrincipal,
8390
Provider: "discord" as const,
8491
Surface: "discord" as const,
8592
WasMentioned: true,

extensions/discord/src/monitor/native-command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ async function dispatchDiscordCommandInteraction(params: {
700700
globalName: user.globalName,
701701
},
702702
sender: { id: sender.id, name: sender.name, tag: sender.tag },
703+
identityLinks: cfg.session?.identityLinks,
703704
});
704705

705706
await dispatchDiscordNativeAgentReply({
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
resolveDiscordSenderIdentity,
4+
resolveDiscordTrustedPrincipalFromUserId,
5+
} from "./sender-identity.js";
6+
7+
describe("resolveDiscordTrustedPrincipalFromUserId", () => {
8+
it("resolves canonical principal through identity links", () => {
9+
expect(
10+
resolveDiscordTrustedPrincipalFromUserId({
11+
userId: " 123456789 ",
12+
identityLinks: {
13+
alice: ["discord:123456789"],
14+
},
15+
}),
16+
).toBe("alice");
17+
});
18+
19+
it("keeps unmapped user ids unresolved", () => {
20+
expect(resolveDiscordTrustedPrincipalFromUserId({ userId: "123456789" })).toBeUndefined();
21+
});
22+
23+
it("keeps malformed user ids unresolved", () => {
24+
expect(resolveDiscordTrustedPrincipalFromUserId({ userId: "alice" })).toBeUndefined();
25+
expect(resolveDiscordTrustedPrincipalFromUserId({ userId: "" })).toBeUndefined();
26+
expect(resolveDiscordTrustedPrincipalFromUserId({ userId: undefined })).toBeUndefined();
27+
});
28+
});
29+
30+
describe("resolveDiscordSenderIdentity", () => {
31+
it("resolves trusted principal through identity links for regular users", () => {
32+
const sender = resolveDiscordSenderIdentity({
33+
author: {
34+
id: "111222333",
35+
username: "alice",
36+
globalName: "Alice",
37+
} as never,
38+
member: { nickname: "Display Name" },
39+
pluralkitInfo: null,
40+
identityLinks: {
41+
alice: ["discord:111222333"],
42+
},
43+
});
44+
45+
expect(sender.id).toBe("111222333");
46+
expect(sender.trustedPrincipal).toBe("alice");
47+
});
48+
49+
it("resolves trusted principal through identity links for pluralkit messages", () => {
50+
const sender = resolveDiscordSenderIdentity({
51+
author: {
52+
id: "444555666",
53+
username: "relay-bot",
54+
} as never,
55+
pluralkitInfo: {
56+
member: {
57+
id: "pk-member-1",
58+
display_name: "Proxy Name",
59+
name: "proxy",
60+
},
61+
system: {
62+
id: "pk-system-1",
63+
name: "System Name",
64+
},
65+
} as never,
66+
identityLinks: {
67+
system_owner: ["discord:444555666"],
68+
},
69+
});
70+
71+
expect(sender.id).toBe("pk-member-1");
72+
expect(sender.trustedPrincipal).toBe("system_owner");
73+
});
74+
});

extensions/discord/src/monitor/sender-identity.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
2+
import { resolveCanonicalIdentityFromLinks } from "openclaw/plugin-sdk/routing";
23
import type { User } from "../internal/discord.js";
34
import type { PluralKitMessageInfo } from "../pluralkit.js";
45
import { formatDiscordUserTag } from "./format.js";
@@ -8,6 +9,7 @@ export type DiscordSenderIdentity = {
89
name?: string;
910
tag?: string;
1011
label: string;
12+
trustedPrincipal?: string;
1113
isPluralKit: boolean;
1214
pluralkit?: {
1315
memberId: string;
@@ -32,11 +34,35 @@ export function resolveDiscordWebhookId(message: DiscordWebhookMessageLike): str
3234
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : null;
3335
}
3436

37+
export function resolveDiscordTrustedPrincipalFromUserId(params: {
38+
userId: string | undefined | null;
39+
identityLinks?: Record<string, string[]>;
40+
}): string | undefined {
41+
if (typeof params.userId !== "string") {
42+
return undefined;
43+
}
44+
const trimmed = params.userId.trim();
45+
if (!/^\d+$/.test(trimmed)) {
46+
return undefined;
47+
}
48+
const canonical = resolveCanonicalIdentityFromLinks({
49+
identityLinks: params.identityLinks,
50+
channel: "discord",
51+
peerId: trimmed,
52+
});
53+
return canonical ?? undefined;
54+
}
55+
3556
export function resolveDiscordSenderIdentity(params: {
3657
author: User;
3758
member?: DiscordMemberLike | null;
3859
pluralkitInfo?: PluralKitMessageInfo | null;
60+
identityLinks?: Record<string, string[]>;
3961
}): DiscordSenderIdentity {
62+
const trustedPrincipal = resolveDiscordTrustedPrincipalFromUserId({
63+
userId: params.author.id,
64+
identityLinks: params.identityLinks,
65+
});
4066
const pkInfo = params.pluralkitInfo ?? null;
4167
const pkMember = pkInfo?.member ?? undefined;
4268
const pkSystem = pkInfo?.system ?? undefined;
@@ -51,6 +77,7 @@ export function resolveDiscordSenderIdentity(params: {
5177
name: memberName,
5278
tag: normalizeOptionalString(pkMember?.name),
5379
label,
80+
trustedPrincipal,
5481
isPluralKit: true,
5582
pluralkit: {
5683
memberId,
@@ -76,6 +103,7 @@ export function resolveDiscordSenderIdentity(params: {
76103
name: params.author.username ?? undefined,
77104
tag: senderTag,
78105
label: senderLabel,
106+
trustedPrincipal,
79107
isPluralKit: false,
80108
};
81109
}

src/auto-reply/reply/inbound-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function finalizeInboundContext<T extends Record<string, unknown>>(
5050
normalized.Transcript = normalizeTextField(normalized.Transcript);
5151
normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody);
5252
normalized.ThreadHistoryBody = normalizeTextField(normalized.ThreadHistoryBody);
53+
normalized.TrustedSenderPrincipal = normalizeTextField(normalized.TrustedSenderPrincipal);
5354
if (Array.isArray(normalized.UntrustedContext)) {
5455
const normalizedUntrusted = normalized.UntrustedContext.map((entry) =>
5556
sanitizeInboundSystemTags(normalizeInboundTextNewlines(entry)),

0 commit comments

Comments
 (0)