diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ecaa072..dc8222dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index a9e0577db..e377ee984 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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[""].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..reactionNotifications`: - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..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:` in `dm.allowFrom`, `guilds..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 | diff --git a/src/config/schema.ts b/src/config/schema.ts index 7e9b643c0..a151b52da 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -328,6 +328,8 @@ const FIELD_LABELS: Record = { "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 = { "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=["*"].', }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 07d4e658f..d2ca453be 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -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 = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 9f0582fdc..b852cdfd3 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -276,6 +276,13 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + pluralkit: z + .object({ + enabled: z.boolean().optional(), + token: z.string().optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 9e5859f33..32545b0b6 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -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", () => { diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 29e0c6666..a9e53a971 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -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; } diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 17124cabf..befdc7d42 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -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> = 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, diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index a3203c8ca..bd17e26bf 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -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; @@ -32,6 +33,7 @@ export type DiscordMessagePreflightContext = { client: Client; message: DiscordMessageEvent["message"]; author: User; + sender: DiscordSenderIdentity; channelInfo: DiscordChannelInfo | null; channelName?: string; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 68a9a09e4..475fa1393 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -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 = diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 35890f004..332dcbf0b 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -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, diff --git a/src/discord/monitor/reply-context.ts b/src/discord/monitor/reply-context.ts index c0bee5507..39497b343 100644 --- a/src/discord/monitor/reply-context.ts +++ b/src/discord/monitor/reply-context.ts @@ -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 }) { diff --git a/src/discord/monitor/sender-identity.ts b/src/discord/monitor/sender-identity.ts new file mode 100644 index 000000000..ed3b1683b --- /dev/null +++ b/src/discord/monitor/sender-identity.ts @@ -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; +} diff --git a/src/discord/pluralkit.test.ts b/src/discord/pluralkit.test.ts new file mode 100644 index 000000000..0a4c93644 --- /dev/null +++ b/src/discord/pluralkit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchPluralKitMessageInfo } from "./pluralkit.js"; + +type MockResponse = { + status: number; + ok: boolean; + text: () => Promise; + json: () => Promise; +}; + +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 | undefined; + const fetcher = vi.fn(async (_url: string, init?: RequestInit) => { + receivedHeaders = init?.headers as Record | 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"); + }); +}); diff --git a/src/discord/pluralkit.ts b/src/discord/pluralkit.ts new file mode 100644 index 000000000..7e19df6e2 --- /dev/null +++ b/src/discord/pluralkit.ts @@ -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 { + if (!params.config?.enabled) { + return null; + } + const fetchImpl = resolveFetch(params.fetcher); + if (!fetchImpl) { + return null; + } + const headers: Record = {}; + 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; +}