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:
parent
66e33abd7b
commit
8e2b17e0c5
15 changed files with 354 additions and 55 deletions
|
|
@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
|
||||||
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
|
- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
|
||||||
- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
|
- 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).
|
- `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`.
|
- `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).
|
- `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).
|
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||||
- `reactions` (covers react + read reactions)
|
- `reactions` (covers react + read reactions)
|
||||||
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `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.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `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
|
### Tool action defaults
|
||||||
|
|
||||||
| Action group | Default | Notes |
|
| Action group | Default | Notes |
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||||
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
|
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
|
||||||
"channels.discord.intents.presence": "Discord Presence Intent",
|
"channels.discord.intents.presence": "Discord Presence Intent",
|
||||||
"channels.discord.intents.guildMembers": "Discord Guild Members 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.dm.policy": "Slack DM Policy",
|
||||||
"channels.slack.allowBots": "Slack Allow Bot Messages",
|
"channels.slack.allowBots": "Slack Allow Bot Messages",
|
||||||
"channels.discord.token": "Discord Bot Token",
|
"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.",
|
"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":
|
"channels.discord.intents.guildMembers":
|
||||||
"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
|
"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":
|
"channels.slack.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
OutboundRetryConfig,
|
OutboundRetryConfig,
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
} from "./types.base.js";
|
} from "./types.base.js";
|
||||||
|
import type { DiscordPluralKitConfig } from "../discord/pluralkit.js";
|
||||||
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||||
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
|
||||||
|
|
@ -150,6 +151,8 @@ export type DiscordAccountConfig = {
|
||||||
execApprovals?: DiscordExecApprovalConfig;
|
execApprovals?: DiscordExecApprovalConfig;
|
||||||
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
|
/** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
|
||||||
intents?: DiscordIntentsConfig;
|
intents?: DiscordIntentsConfig;
|
||||||
|
/** PluralKit identity resolution for proxied messages. */
|
||||||
|
pluralkit?: DiscordPluralKitConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordConfig = {
|
export type DiscordConfig = {
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,13 @@ export const DiscordAccountSchema = z
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
pluralkit: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,16 @@ describe("discord allowlist helpers", () => {
|
||||||
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true);
|
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true);
|
||||||
expect(allowListMatches(allow, { name: "other" })).toBe(false);
|
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", () => {
|
describe("discord guild/channel resolution", () => {
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export function resolveDiscordUserAllowed(params: {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userTag?: string;
|
userTag?: string;
|
||||||
}) {
|
}) {
|
||||||
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:"]);
|
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
|
||||||
if (!allowList) {
|
if (!allowList) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +161,7 @@ export function resolveDiscordCommandAuthorized(params: {
|
||||||
if (!params.isDirectMessage) {
|
if (!params.isDirectMessage) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
|
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:", "pk:"]);
|
||||||
if (!allowList) {
|
if (!allowList) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +409,7 @@ export function shouldEmitDiscordReactionNotification(params: {
|
||||||
return Boolean(params.botId && params.messageAuthorId === params.botId);
|
return Boolean(params.botId && params.messageAuthorId === params.botId);
|
||||||
}
|
}
|
||||||
if (mode === "allowlist") {
|
if (mode === "allowlist") {
|
||||||
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:"]);
|
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:", "pk:"]);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { ChannelType, MessageType, type User } from "@buape/carbon";
|
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 { hasControlCommand } from "../../auto-reply/command-detection.js";
|
||||||
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
|
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,6 +24,7 @@ import {
|
||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
|
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
allowListMatches,
|
||||||
|
|
@ -45,13 +43,19 @@ import {
|
||||||
resolveDiscordSystemLocation,
|
resolveDiscordSystemLocation,
|
||||||
resolveTimestampMs,
|
resolveTimestampMs,
|
||||||
} from "./format.js";
|
} from "./format.js";
|
||||||
|
import type {
|
||||||
|
DiscordMessagePreflightContext,
|
||||||
|
DiscordMessagePreflightParams,
|
||||||
|
} from "./message-handler.preflight.types.js";
|
||||||
import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js";
|
import { resolveDiscordChannelInfo, resolveDiscordMessageText } from "./message-utils.js";
|
||||||
|
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
|
||||||
import { resolveDiscordSystemEvent } from "./system-events.js";
|
import { resolveDiscordSystemEvent } from "./system-events.js";
|
||||||
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
|
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
DiscordMessagePreflightContext,
|
DiscordMessagePreflightContext,
|
||||||
DiscordMessagePreflightParams,
|
DiscordMessagePreflightParams,
|
||||||
|
DiscordSenderIdentity,
|
||||||
} from "./message-handler.preflight.types.js";
|
} from "./message-handler.preflight.types.js";
|
||||||
|
|
||||||
export async function preflightDiscordMessage(
|
export async function preflightDiscordMessage(
|
||||||
|
|
@ -65,12 +69,33 @@ export async function preflightDiscordMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowBots = params.discordConfig?.allowBots ?? false;
|
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
|
// 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)");
|
logVerbose("discord: drop bot message (allowBots=false)");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -100,14 +125,14 @@ export async function preflightDiscordMessage(
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
||||||
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
|
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
|
||||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
|
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
||||||
const allowMatch = allowList
|
const allowMatch = allowList
|
||||||
? resolveDiscordAllowListMatch({
|
? resolveDiscordAllowListMatch({
|
||||||
allowList,
|
allowList,
|
||||||
candidate: {
|
candidate: {
|
||||||
id: author.id,
|
id: sender.id,
|
||||||
name: author.username,
|
name: sender.name,
|
||||||
tag: formatDiscordUserTag(author),
|
tag: sender.tag,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: { allowed: false };
|
: { allowed: false };
|
||||||
|
|
@ -148,7 +173,7 @@ export async function preflightDiscordMessage(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -349,7 +374,7 @@ export async function preflightDiscordMessage(
|
||||||
const historyEntry =
|
const historyEntry =
|
||||||
isGuildMessage && params.historyLimit > 0 && textForHistory
|
isGuildMessage && params.historyLimit > 0 && textForHistory
|
||||||
? ({
|
? ({
|
||||||
sender: params.data.member?.nickname ?? author.globalName ?? author.username ?? author.id,
|
sender: sender.label,
|
||||||
body: textForHistory,
|
body: textForHistory,
|
||||||
timestamp: resolveTimestampMs(message.timestamp),
|
timestamp: resolveTimestampMs(message.timestamp),
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
|
|
@ -372,12 +397,16 @@ export async function preflightDiscordMessage(
|
||||||
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
|
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
|
||||||
|
|
||||||
if (!isDirectMessage) {
|
if (!isDirectMessage) {
|
||||||
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
|
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, [
|
||||||
|
"discord:",
|
||||||
|
"user:",
|
||||||
|
"pk:",
|
||||||
|
]);
|
||||||
const ownerOk = ownerAllowList
|
const ownerOk = ownerAllowList
|
||||||
? allowListMatches(ownerAllowList, {
|
? allowListMatches(ownerAllowList, {
|
||||||
id: author.id,
|
id: sender.id,
|
||||||
name: author.username,
|
name: sender.name,
|
||||||
tag: formatDiscordUserTag(author),
|
tag: sender.tag,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||||
|
|
@ -385,9 +414,9 @@ export async function preflightDiscordMessage(
|
||||||
Array.isArray(channelUsers) && channelUsers.length > 0
|
Array.isArray(channelUsers) && channelUsers.length > 0
|
||||||
? resolveDiscordUserAllowed({
|
? resolveDiscordUserAllowed({
|
||||||
allowList: channelUsers,
|
allowList: channelUsers,
|
||||||
userId: author.id,
|
userId: sender.id,
|
||||||
userName: author.username,
|
userName: sender.name,
|
||||||
userTag: formatDiscordUserTag(author),
|
userTag: sender.tag,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||||
|
|
@ -408,7 +437,7 @@ export async function preflightDiscordMessage(
|
||||||
log: logVerbose,
|
log: logVerbose,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
reason: "control command (unauthorized)",
|
reason: "control command (unauthorized)",
|
||||||
target: author.id,
|
target: sender.id,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -452,12 +481,12 @@ export async function preflightDiscordMessage(
|
||||||
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
|
||||||
const userOk = resolveDiscordUserAllowed({
|
const userOk = resolveDiscordUserAllowed({
|
||||||
allowList: channelUsers,
|
allowList: channelUsers,
|
||||||
userId: author.id,
|
userId: sender.id,
|
||||||
userName: author.username,
|
userName: sender.name,
|
||||||
userTag: formatDiscordUserTag(author),
|
userTag: sender.tag,
|
||||||
});
|
});
|
||||||
if (!userOk) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -501,6 +530,7 @@ export async function preflightDiscordMessage(
|
||||||
client: params.client,
|
client: params.client,
|
||||||
message,
|
message,
|
||||||
author,
|
author,
|
||||||
|
sender,
|
||||||
channelInfo,
|
channelInfo,
|
||||||
channelName,
|
channelName,
|
||||||
isGuildMessage,
|
isGuildMessage,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { ReplyToMode } from "../../config/config.js";
|
||||||
import type { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import type { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
|
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
|
||||||
import type { DiscordChannelInfo } from "./message-utils.js";
|
import type { DiscordChannelInfo } from "./message-utils.js";
|
||||||
|
import type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||||
import type { DiscordThreadChannel } from "./threading.js";
|
import type { DiscordThreadChannel } from "./threading.js";
|
||||||
|
|
||||||
export type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
export type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||||
|
|
@ -32,6 +33,7 @@ export type DiscordMessagePreflightContext = {
|
||||||
client: Client;
|
client: Client;
|
||||||
message: DiscordMessageEvent["message"];
|
message: DiscordMessageEvent["message"];
|
||||||
author: User;
|
author: User;
|
||||||
|
sender: DiscordSenderIdentity;
|
||||||
|
|
||||||
channelInfo: DiscordChannelInfo | null;
|
channelInfo: DiscordChannelInfo | null;
|
||||||
channelName?: string;
|
channelName?: string;
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||||
ackReactionScope,
|
ackReactionScope,
|
||||||
message,
|
message,
|
||||||
author,
|
author,
|
||||||
|
sender,
|
||||||
data,
|
data,
|
||||||
client,
|
client,
|
||||||
channelInfo,
|
channelInfo,
|
||||||
|
|
@ -125,12 +126,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||||
channelName: channelName ?? message.channelId,
|
channelName: channelName ?? message.channelId,
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
});
|
});
|
||||||
const senderTag = formatDiscordUserTag(author);
|
const senderLabel = sender.label;
|
||||||
const senderDisplay = data.member?.nickname ?? author.globalName ?? author.username;
|
|
||||||
const senderLabel =
|
|
||||||
senderDisplay && senderTag && senderDisplay !== senderTag
|
|
||||||
? `${senderDisplay} (${senderTag})`
|
|
||||||
: (senderDisplay ?? senderTag ?? author.id);
|
|
||||||
const isForumParent =
|
const isForumParent =
|
||||||
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
|
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
|
||||||
const forumParentSlug =
|
const forumParentSlug =
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ import {
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordUserAllowed,
|
resolveDiscordUserAllowed,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { formatDiscordUserTag } from "./format.js";
|
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
||||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||||
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||||||
|
|
||||||
|
|
@ -525,6 +525,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sender = resolveDiscordSenderIdentity({ author: user, pluralkitInfo: null });
|
||||||
const channel = interaction.channel;
|
const channel = interaction.channel;
|
||||||
const channelType = channel?.type;
|
const channelType = channel?.type;
|
||||||
const isDirectMessage = channelType === ChannelType.DM;
|
const isDirectMessage = channelType === ChannelType.DM;
|
||||||
|
|
@ -539,13 +540,14 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||||
const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [
|
const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [
|
||||||
"discord:",
|
"discord:",
|
||||||
"user:",
|
"user:",
|
||||||
|
"pk:",
|
||||||
]);
|
]);
|
||||||
const ownerOk =
|
const ownerOk =
|
||||||
ownerAllowList && user
|
ownerAllowList && user
|
||||||
? allowListMatches(ownerAllowList, {
|
? allowListMatches(ownerAllowList, {
|
||||||
id: user.id,
|
id: sender.id,
|
||||||
name: user.username,
|
name: sender.name,
|
||||||
tag: formatDiscordUserTag(user),
|
tag: sender.tag,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const guildInfo = resolveDiscordGuildEntry({
|
const guildInfo = resolveDiscordGuildEntry({
|
||||||
|
|
@ -618,12 +620,12 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []);
|
||||||
const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom];
|
const effectiveAllowFrom = [...(discordConfig?.dm?.allowFrom ?? []), ...storeAllowFrom];
|
||||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:"]);
|
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
||||||
const permitted = allowList
|
const permitted = allowList
|
||||||
? allowListMatches(allowList, {
|
? allowListMatches(allowList, {
|
||||||
id: user.id,
|
id: sender.id,
|
||||||
name: user.username,
|
name: sender.name,
|
||||||
tag: formatDiscordUserTag(user),
|
tag: sender.tag,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
if (!permitted) {
|
if (!permitted) {
|
||||||
|
|
@ -633,8 +635,8 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
id: user.id,
|
id: user.id,
|
||||||
meta: {
|
meta: {
|
||||||
tag: formatDiscordUserTag(user),
|
tag: sender.tag,
|
||||||
name: user.username ?? undefined,
|
name: sender.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (created) {
|
if (created) {
|
||||||
|
|
@ -661,9 +663,9 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||||
const userOk = hasUserAllowlist
|
const userOk = hasUserAllowlist
|
||||||
? resolveDiscordUserAllowed({
|
? resolveDiscordUserAllowed({
|
||||||
allowList: channelUsers,
|
allowList: channelUsers,
|
||||||
userId: user.id,
|
userId: sender.id,
|
||||||
userName: user.username,
|
userName: sender.name,
|
||||||
userTag: formatDiscordUserTag(user),
|
userTag: sender.tag,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
const authorizers = useAccessGroups
|
const authorizers = useAccessGroups
|
||||||
|
|
@ -768,7 +770,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||||
SenderName: user.globalName ?? user.username,
|
SenderName: user.globalName ?? user.username,
|
||||||
SenderId: user.id,
|
SenderId: user.id,
|
||||||
SenderUsername: user.username,
|
SenderUsername: user.username,
|
||||||
SenderTag: formatDiscordUserTag(user),
|
SenderTag: sender.tag,
|
||||||
Provider: "discord" as const,
|
Provider: "discord" as const,
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
WasMentioned: true,
|
WasMentioned: true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Guild, Message, User } from "@buape/carbon";
|
import type { Guild, Message, User } from "@buape/carbon";
|
||||||
import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
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(
|
export function resolveReplyContext(
|
||||||
message: Message,
|
message: Message,
|
||||||
|
|
@ -17,8 +18,12 @@ export function resolveReplyContext(
|
||||||
if (!referencedText) {
|
if (!referencedText) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const fromLabel = referenced.author ? buildDirectLabel(referenced.author) : "Unknown";
|
const sender = resolveDiscordSenderIdentity({
|
||||||
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${formatDiscordUserTag(referenced.author)} user id:${referenced.author?.id ?? "unknown"}]`;
|
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({
|
return formatAgentEnvelope({
|
||||||
channel: "Discord",
|
channel: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
|
|
@ -28,9 +33,10 @@ export function resolveReplyContext(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDirectLabel(author: User) {
|
export function buildDirectLabel(author: User, tagOverride?: string) {
|
||||||
const username = formatDiscordUserTag(author);
|
const username =
|
||||||
return `${username} user id:${author.id}`;
|
tagOverride?.trim() || resolveDiscordSenderIdentity({ author, pluralkitInfo: null }).tag;
|
||||||
|
return `${username ?? "unknown"} user id:${author.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGuildLabel(params: { guild?: Guild; channelName: string; channelId: string }) {
|
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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue