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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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=["*"].',
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -276,6 +276,13 @@ export const DiscordAccountSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
pluralkit: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
82
src/discord/monitor/sender-identity.ts
Normal file
82
src/discord/monitor/sender-identity.ts
Normal 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;
|
||||
}
|
||||
67
src/discord/pluralkit.test.ts
Normal file
67
src/discord/pluralkit.test.ts
Normal 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
58
src/discord/pluralkit.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user