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

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.

View File

@@ -340,6 +340,7 @@ ack reaction after the bot replies.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `pluralkit`: resolve PluralKit proxied messages so system members appear as distinct senders.
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions)
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
@@ -355,6 +356,34 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
- `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
### PluralKit (PK) support
Enable PK lookups so proxied messages resolve to the underlying system + member.
When enabled, OpenClaw uses the member identity for allowlists and labels the
sender as `Member (PK:System)` to avoid accidental Discord pings.
```json5
{
channels: {
discord: {
pluralkit: {
enabled: true,
token: "pk_live_..." // optional; required for private systems
}
}
}
}
```
Allowlist notes (PK-enabled):
- Use `pk:<memberId>` in `dm.allowFrom`, `guilds.<id>.users`, or per-channel `users`.
- Member display names are also matched by name/slug.
- Lookups use the **original** Discord message ID (the pre-proxy message), so
the PK API only resolves it within its 30-minute window.
- If PK lookups fail (e.g., private system without a token), proxied messages
are treated as bot messages and are dropped unless `channels.discord.allowBots=true`.
### Tool action defaults
| Action group | Default | Notes |

View File

@@ -328,6 +328,8 @@ const FIELD_LABELS: Record<string, string> = {
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"channels.discord.intents.presence": "Discord Presence Intent",
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
"channels.discord.pluralkit.token": "Discord PluralKit Token",
"channels.slack.dm.policy": "Slack DM Policy",
"channels.slack.allowBots": "Slack Allow Bot Messages",
"channels.discord.token": "Discord Bot Token",
@@ -674,6 +676,10 @@ const FIELD_HELP: Record<string, string> = {
"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
"channels.discord.intents.guildMembers":
"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
"channels.discord.pluralkit.enabled":
"Resolve PluralKit proxied messages and treat system members as distinct senders.",
"channels.discord.pluralkit.token":
"Optional PluralKit token for resolving private systems or members.",
"channels.slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
};

View File

@@ -6,6 +6,7 @@ import type {
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
import type { DiscordPluralKitConfig } from "../discord/pluralkit.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
@@ -150,6 +151,8 @@ export type DiscordAccountConfig = {
execApprovals?: DiscordExecApprovalConfig;
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
intents?: DiscordIntentsConfig;
/** PluralKit identity resolution for proxied messages. */
pluralkit?: DiscordPluralKitConfig;
};
export type DiscordConfig = {

View File

@@ -276,6 +276,13 @@ export const DiscordAccountSchema = z
})
.strict()
.optional(),
pluralkit: z
.object({
enabled: z.boolean().optional(),
token: z.string().optional(),
})
.strict()
.optional(),
})
.strict();

View File

@@ -149,6 +149,16 @@ describe("discord allowlist helpers", () => {
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true);
expect(allowListMatches(allow, { name: "other" })).toBe(false);
});
it("matches pk-prefixed allowlist entries", () => {
const allow = normalizeDiscordAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]);
expect(allow).not.toBeNull();
if (!allow) {
throw new Error("Expected allow list to be normalized");
}
expect(allowListMatches(allow, { id: "member-123" })).toBe(true);
expect(allowListMatches(allow, { id: "member-999" })).toBe(false);
});
});
describe("discord guild/channel resolution", () => {

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;
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { fetchPluralKitMessageInfo } from "./pluralkit.js";
type MockResponse = {
status: number;
ok: boolean;
text: () => Promise<string>;
json: () => Promise<unknown>;
};
const buildResponse = (params: { status: number; body?: unknown }): MockResponse => {
const body = params.body;
const textPayload = typeof body === "string" ? body : body == null ? "" : JSON.stringify(body);
return {
status: params.status,
ok: params.status >= 200 && params.status < 300,
text: async () => textPayload,
json: async () => body ?? {},
};
};
describe("fetchPluralKitMessageInfo", () => {
it("returns null when disabled", async () => {
const fetcher = vi.fn();
const result = await fetchPluralKitMessageInfo({
messageId: "123",
config: { enabled: false },
fetcher: fetcher as unknown as typeof fetch,
});
expect(result).toBeNull();
expect(fetcher).not.toHaveBeenCalled();
});
it("returns null on 404", async () => {
const fetcher = vi.fn(async () => buildResponse({ status: 404 }));
const result = await fetchPluralKitMessageInfo({
messageId: "missing",
config: { enabled: true },
fetcher: fetcher as unknown as typeof fetch,
});
expect(result).toBeNull();
});
it("returns payload and sends token when configured", async () => {
let receivedHeaders: Record<string, string> | undefined;
const fetcher = vi.fn(async (_url: string, init?: RequestInit) => {
receivedHeaders = init?.headers as Record<string, string> | undefined;
return buildResponse({
status: 200,
body: {
id: "123",
member: { id: "mem_1", name: "Alex" },
system: { id: "sys_1", name: "System" },
},
});
});
const result = await fetchPluralKitMessageInfo({
messageId: "123",
config: { enabled: true, token: "pk_test" },
fetcher: fetcher as unknown as typeof fetch,
});
expect(result?.member?.id).toBe("mem_1");
expect(receivedHeaders?.Authorization).toBe("pk_test");
});
});

58
src/discord/pluralkit.ts Normal file
View File

@@ -0,0 +1,58 @@
import { resolveFetch } from "../infra/fetch.js";
const PLURALKIT_API_BASE = "https://api.pluralkit.me/v2";
export type DiscordPluralKitConfig = {
enabled?: boolean;
token?: string;
};
export type PluralKitSystemInfo = {
id: string;
name?: string | null;
tag?: string | null;
};
export type PluralKitMemberInfo = {
id: string;
name?: string | null;
display_name?: string | null;
};
export type PluralKitMessageInfo = {
id: string;
original?: string | null;
sender?: string | null;
system?: PluralKitSystemInfo | null;
member?: PluralKitMemberInfo | null;
};
export async function fetchPluralKitMessageInfo(params: {
messageId: string;
config?: DiscordPluralKitConfig;
fetcher?: typeof fetch;
}): Promise<PluralKitMessageInfo | null> {
if (!params.config?.enabled) {
return null;
}
const fetchImpl = resolveFetch(params.fetcher);
if (!fetchImpl) {
return null;
}
const headers: Record<string, string> = {};
if (params.config.token?.trim()) {
headers.Authorization = params.config.token.trim();
}
const res = await fetchImpl(`${PLURALKIT_API_BASE}/messages/${params.messageId}`, {
headers,
});
if (res.status === 404) {
return null;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
const detail = text.trim() ? `: ${text.trim()}` : "";
throw new Error(`PluralKit API failed (${res.status})${detail}`);
}
return (await res.json()) as PluralKitMessageInfo;
}