Discord: add PluralKit sender identity resolver (#5838)

* Discord: add PluralKit sender identity resolver

* fix: resolve PluralKit sender identities (#5838) (thanks @thewilloftheshadow)
This commit is contained in:
Shadow
2026-01-31 19:50:06 -06:00
committed by GitHub
parent 66e33abd7b
commit 8e2b17e0c5
15 changed files with 354 additions and 55 deletions

View File

@@ -141,7 +141,7 @@ export function resolveDiscordUserAllowed(params: {
userName?: string;
userTag?: string;
}) {
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:"]);
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
if (!allowList) {
return true;
}
@@ -161,7 +161,7 @@ export function resolveDiscordCommandAuthorized(params: {
if (!params.isDirectMessage) {
return true;
}
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:", "pk:"]);
if (!allowList) {
return true;
}
@@ -409,7 +409,7 @@ export function shouldEmitDiscordReactionNotification(params: {
return Boolean(params.botId && params.messageAuthorId === params.botId);
}
if (mode === "allowlist") {
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:"]);
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:", "pk:"]);
if (!list) {
return false;
}

View File

@@ -1,8 +1,5 @@
import { ChannelType, MessageType, type User } from "@buape/carbon";
import type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import {
@@ -27,6 +24,7 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js";
import {
allowListMatches,
@@ -45,13 +43,19 @@ import {
resolveDiscordSystemLocation,
resolveTimestampMs,
} from "./format.js";
import type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
export type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
DiscordSenderIdentity,
} from "./message-handler.preflight.types.js";
export async function preflightDiscordMessage(
@@ -65,12 +69,33 @@ export async function preflightDiscordMessage(
}
const allowBots = params.discordConfig?.allowBots ?? false;
if (author.bot) {
if (author.bot && params.botUserId && author.id === params.botUserId) {
// Always ignore own messages to prevent self-reply loops
if (params.botUserId && author.id === params.botUserId) {
return null;
return null;
}
const pluralkitConfig = params.discordConfig?.pluralkit;
const webhookId = resolveDiscordWebhookId(message);
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
let pluralkitInfo: Awaited<ReturnType<typeof fetchPluralKitMessageInfo>> = null;
if (shouldCheckPluralKit) {
try {
pluralkitInfo = await fetchPluralKitMessageInfo({
messageId: message.id,
config: pluralkitConfig,
});
} catch (err) {
logVerbose(`discord: pluralkit lookup failed for ${message.id}: ${String(err)}`);
}
if (!allowBots) {
}
const sender = resolveDiscordSenderIdentity({
author,
member: params.data.member,
pluralkitInfo,
});
if (author.bot) {
if (!allowBots && !sender.isPluralKit) {
logVerbose("discord: drop bot message (allowBots=false)");
return null;
}
@@ -100,14 +125,14 @@ export async function preflightDiscordMessage(
if (dmPolicy !== "open") {
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
id: sender.id,
name: sender.name,
tag: sender.tag,
},
})
: { allowed: false };
@@ -148,7 +173,7 @@ export async function preflightDiscordMessage(
}
} else {
logVerbose(
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return null;
@@ -349,7 +374,7 @@ export async function preflightDiscordMessage(
const historyEntry =
isGuildMessage && params.historyLimit > 0 && textForHistory
? ({
sender: params.data.member?.nickname ?? author.globalName ?? author.username ?? author.id,
sender: sender.label,
body: textForHistory,
timestamp: resolveTimestampMs(message.timestamp),
messageId: message.id,
@@ -372,12 +397,16 @@ export async function preflightDiscordMessage(
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
if (!isDirectMessage) {
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
"discord:",
"user:",
"pk:",
]);
const ownerOk = ownerAllowList
? allowListMatches(ownerAllowList, {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
const channelUsers = channelConfig?.users ?? guildInfo?.users;
@@ -385,9 +414,9 @@ export async function preflightDiscordMessage(
Array.isArray(channelUsers) && channelUsers.length > 0
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: author.id,
userName: author.username,
userTag: formatDiscordUserTag(author),
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
@@ -408,7 +437,7 @@ export async function preflightDiscordMessage(
log: logVerbose,
channel: "discord",
reason: "control command (unauthorized)",
target: author.id,
target: sender.id,
});
return null;
}
@@ -452,12 +481,12 @@ export async function preflightDiscordMessage(
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId: author.id,
userName: author.username,
userTag: formatDiscordUserTag(author),
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
});
if (!userOk) {
logVerbose(`Blocked discord guild sender ${author.id} (not in channel users allowlist)`);
logVerbose(`Blocked discord guild sender ${sender.id} (not in channel users allowlist)`);
return null;
}
}
@@ -501,6 +530,7 @@ export async function preflightDiscordMessage(
client: params.client,
message,
author,
sender,
channelInfo,
channelName,
isGuildMessage,

View File

@@ -4,6 +4,7 @@ import type { ReplyToMode } from "../../config/config.js";
import type { resolveAgentRoute } from "../../routing/resolve-route.js";
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordChannelInfo } from "./message-utils.js";
import type { DiscordSenderIdentity } from "./sender-identity.js";
import type { DiscordThreadChannel } from "./threading.js";
export type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
@@ -32,6 +33,7 @@ export type DiscordMessagePreflightContext = {
client: Client;
message: DiscordMessageEvent["message"];
author: User;
sender: DiscordSenderIdentity;
channelInfo: DiscordChannelInfo | null;
channelName?: string;

View File

@@ -57,6 +57,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
ackReactionScope,
message,
author,
sender,
data,
client,
channelInfo,
@@ -125,12 +126,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
channelName: channelName ?? message.channelId,
channelId: message.channelId,
});
const senderTag = formatDiscordUserTag(author);
const senderDisplay = data.member?.nickname ?? author.globalName ?? author.username;
const senderLabel =
senderDisplay && senderTag && senderDisplay !== senderTag
? `${senderDisplay} (${senderTag})`
: (senderDisplay ?? senderTag ?? author.id);
const senderLabel = sender.label;
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =

View File

@@ -50,7 +50,7 @@ import {
resolveDiscordGuildEntry,
resolveDiscordUserAllowed,
} from "./allow-list.js";
import { formatDiscordUserTag } from "./format.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
@@ -525,6 +525,7 @@ async function dispatchDiscordCommandInteraction(params: {
if (!user) {
return;
}
const sender = resolveDiscordSenderIdentity({ author: user, pluralkitInfo: null });
const channel = interaction.channel;
const channelType = channel?.type;
const isDirectMessage = channelType === ChannelType.DM;
@@ -539,13 +540,14 @@ async function dispatchDiscordCommandInteraction(params: {
const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [
"discord:",
"user:",
"pk:",
]);
const ownerOk =
ownerAllowList && user
? allowListMatches(ownerAllowList, {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
const guildInfo = resolveDiscordGuildEntry({
@@ -618,12 +620,12 @@ async function dispatchDiscordCommandInteraction(params: {
if (dmPolicy !== "open") {
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
const permitted = allowList
? allowListMatches(allowList, {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
if (!permitted) {
@@ -633,8 +635,8 @@ async function dispatchDiscordCommandInteraction(params: {
channel: "discord",
id: user.id,
meta: {
tag: formatDiscordUserTag(user),
name: user.username ?? undefined,
tag: sender.tag,
name: sender.name,
},
});
if (created) {
@@ -661,9 +663,9 @@ async function dispatchDiscordCommandInteraction(params: {
const userOk = hasUserAllowlist
? resolveDiscordUserAllowed({
allowList: channelUsers,
userId: user.id,
userName: user.username,
userTag: formatDiscordUserTag(user),
userId: sender.id,
userName: sender.name,
userTag: sender.tag,
})
: false;
const authorizers = useAccessGroups
@@ -768,7 +770,7 @@ async function dispatchDiscordCommandInteraction(params: {
SenderName: user.globalName ?? user.username,
SenderId: user.id,
SenderUsername: user.username,
SenderTag: formatDiscordUserTag(user),
SenderTag: sender.tag,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,

View File

@@ -1,6 +1,7 @@
import type { Guild, Message, User } from "@buape/carbon";
import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
import { resolveTimestampMs } from "./format.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
export function resolveReplyContext(
message: Message,
@@ -17,8 +18,12 @@ export function resolveReplyContext(
if (!referencedText) {
return null;
}
const fromLabel = referenced.author ? buildDirectLabel(referenced.author) : "Unknown";
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${formatDiscordUserTag(referenced.author)} user id:${referenced.author?.id ?? "unknown"}]`;
const sender = resolveDiscordSenderIdentity({
author: referenced.author,
pluralkitInfo: null,
});
const fromLabel = referenced.author ? buildDirectLabel(referenced.author, sender.tag) : "Unknown";
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${sender.tag ?? sender.label} user id:${sender.id}]`;
return formatAgentEnvelope({
channel: "Discord",
from: fromLabel,
@@ -28,9 +33,10 @@ export function resolveReplyContext(
});
}
export function buildDirectLabel(author: User) {
const username = formatDiscordUserTag(author);
return `${username} user id:${author.id}`;
export function buildDirectLabel(author: User, tagOverride?: string) {
const username =
tagOverride?.trim() || resolveDiscordSenderIdentity({ author, pluralkitInfo: null }).tag;
return `${username ?? "unknown"} user id:${author.id}`;
}
export function buildGuildLabel(params: { guild?: Guild; channelName: string; channelId: string }) {

View File

@@ -0,0 +1,82 @@
import type { User } from "@buape/carbon";
import { formatDiscordUserTag } from "./format.js";
import type { DiscordMessageEvent } from "./listeners.js";
import type { PluralKitMessageInfo } from "../pluralkit.js";
export type DiscordSenderIdentity = {
id: string;
name?: string;
tag?: string;
label: string;
isPluralKit: boolean;
pluralkit?: {
memberId: string;
memberName?: string;
systemId?: string;
systemName?: string;
};
};
type DiscordWebhookMessageLike = {
webhookId?: string | null;
webhook_id?: string | null;
};
export function resolveDiscordWebhookId(message: DiscordWebhookMessageLike): string | null {
const candidate = message.webhookId ?? message.webhook_id;
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : null;
}
export function resolveDiscordSenderIdentity(params: {
author: User;
member?: DiscordMessageEvent["member"] | null;
pluralkitInfo?: PluralKitMessageInfo | null;
}): DiscordSenderIdentity {
const pkInfo = params.pluralkitInfo ?? null;
const pkMember = pkInfo?.member ?? undefined;
const pkSystem = pkInfo?.system ?? undefined;
const memberId = pkMember?.id?.trim();
const memberNameRaw = pkMember?.display_name ?? pkMember?.name ?? "";
const memberName = memberNameRaw?.trim();
if (memberId && memberName) {
const systemName = pkSystem?.name?.trim();
const label = systemName ? `${memberName} (PK:${systemName})` : `${memberName} (PK)`;
return {
id: memberId,
name: memberName,
tag: pkMember?.name?.trim() || undefined,
label,
isPluralKit: true,
pluralkit: {
memberId,
memberName,
systemId: pkSystem?.id?.trim() || undefined,
systemName,
},
};
}
const senderTag = formatDiscordUserTag(params.author);
const senderDisplay =
params.member?.nickname ?? params.author.globalName ?? params.author.username;
const senderLabel =
senderDisplay && senderTag && senderDisplay !== senderTag
? `${senderDisplay} (${senderTag})`
: (senderDisplay ?? senderTag ?? params.author.id);
return {
id: params.author.id,
name: params.author.username ?? undefined,
tag: senderTag,
label: senderLabel,
isPluralKit: false,
};
}
export function resolveDiscordSenderLabel(params: {
author: User;
member?: DiscordMessageEvent["member"] | null;
pluralkitInfo?: PluralKitMessageInfo | null;
}): string {
return resolveDiscordSenderIdentity(params).label;
}