@zhin.js/adapter-discord 1.1.27 → 1.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +169 -0
- package/LICENSE +21 -0
- package/PERMITS.md +15 -14
- package/README.md +6 -2
- package/adapters/discord/index.js +3 -0
- package/adapters/discord/index.ts +3 -0
- package/agents/discord/conversation.js +70 -0
- package/agents/discord/conversation.ts +77 -0
- package/agents/discord/messages.js +18 -2
- package/agents/discord/messages.ts +18 -2
- package/agents/discord/skills/discord/SKILL.md +1 -1
- package/agents/discord/skills/discord/tools/react/index.js +7 -6
- package/agents/discord/skills/discord/tools/react/index.ts +8 -7
- package/agents/discord/skills/discord/tools/send_embed/index.js +6 -11
- package/agents/discord/skills/discord/tools/send_embed/index.ts +7 -12
- package/agents/discord/skills/discord-guild/tools/add_role/index.js +11 -9
- package/agents/discord/skills/discord-guild/tools/add_role/index.ts +11 -10
- package/agents/discord/skills/discord-guild/tools/create_thread/index.js +5 -5
- package/agents/discord/skills/discord-guild/tools/create_thread/index.ts +6 -6
- package/agents/discord/skills/discord-guild/tools/forum_post/index.js +7 -3
- package/agents/discord/skills/discord-guild/tools/forum_post/index.ts +7 -4
- package/agents/discord/skills/discord-guild/tools/list_roles/index.js +7 -8
- package/agents/discord/skills/discord-guild/tools/list_roles/index.ts +8 -9
- package/agents/discord/skills/discord-guild/tools/remove_role/index.js +11 -9
- package/agents/discord/skills/discord-guild/tools/remove_role/index.ts +11 -10
- package/lib/endpoint.js +71 -12
- package/lib/gateway.d.ts +1 -0
- package/lib/gateway.js +21 -8
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/platform-permit.d.ts +4 -11
- package/lib/platform-permit.js +7 -40
- package/lib/protocol.d.ts +18 -0
- package/lib/protocol.js +32 -3
- package/lib/sender-standing.d.ts +18 -0
- package/lib/sender-standing.js +91 -0
- package/lib/webhook.d.ts +2 -6
- package/lib/webhook.js +23 -55
- package/package.json +23 -25
- package/plugin.js +0 -6
- package/schema.json +0 -56
- package/src/endpoint.ts +73 -11
- package/src/gateway.ts +22 -5
- package/src/index.ts +1 -6
- package/src/platform-permit.ts +5 -52
- package/src/protocol.ts +43 -3
- package/src/sender-standing.ts +91 -0
- package/src/webhook.ts +21 -58
package/lib/gateway.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { Partials, GatewayIntentBits, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, REST, Routes, PermissionFlagsBits, } from 'discord.js';
|
|
5
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
6
6
|
import { activityTypeCode, resolveChannelKind, } from './protocol.js';
|
|
7
|
+
import { discordPermissionNames, discordSenderStanding } from './sender-standing.js';
|
|
7
8
|
const logger = getLogger('discord');
|
|
8
9
|
export const DEFAULT_INTENTS = [
|
|
9
10
|
GatewayIntentBits.Guilds,
|
|
@@ -16,15 +17,14 @@ export const DEFAULT_INTENTS = [
|
|
|
16
17
|
export function discordClientOptions(intents, gatewayFaultProxyUrl, restOptions = {}) {
|
|
17
18
|
return { rest: { ...restOptions, retries: 0, rejectOnRateLimit: () => true }, intents: [...intents], partials: [Partials.Channel], ...(gatewayFaultProxyUrl ? { ws: { buildStrategy: manager => new DiscordFaultProxyStrategy(manager, gatewayFaultProxyUrl) } } : {}) };
|
|
18
19
|
}
|
|
20
|
+
/** Normalized standing word (`owner` / `admin` / `member`); `undefined` outside a guild. */
|
|
19
21
|
export function resolveSenderRole(msg) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return 'member';
|
|
27
|
-
return undefined;
|
|
22
|
+
return discordSenderStanding(msg)?.standing;
|
|
23
|
+
}
|
|
24
|
+
/** A discord.js thread channel's parent channel id (threads share their parent's conversation key, ADR 0056 §2). */
|
|
25
|
+
function threadParentOf(channel) {
|
|
26
|
+
const thread = channel;
|
|
27
|
+
return thread && typeof thread.isThread === 'function' && thread.isThread() && thread.parentId ? thread.parentId : undefined;
|
|
28
28
|
}
|
|
29
29
|
export function normalizeDiscordMessage(raw) {
|
|
30
30
|
if (!raw || typeof raw !== 'object')
|
|
@@ -33,6 +33,8 @@ export function normalizeDiscordMessage(raw) {
|
|
|
33
33
|
if (!msg.author || !msg.channel)
|
|
34
34
|
return null;
|
|
35
35
|
const permissionTokens = [];
|
|
36
|
+
let platformPermissions = [];
|
|
37
|
+
let authorRoles = [];
|
|
36
38
|
let isGuildOwner = false;
|
|
37
39
|
const member = msg.member;
|
|
38
40
|
const guild = msg.guild;
|
|
@@ -48,6 +50,13 @@ export function normalizeDiscordMessage(raw) {
|
|
|
48
50
|
if (member.permissions.has(bit))
|
|
49
51
|
permissionTokens.push(name);
|
|
50
52
|
}
|
|
53
|
+
platformPermissions = discordPermissionNames(bit => member.permissions.has(bit));
|
|
54
|
+
const cache = member.roles?.cache;
|
|
55
|
+
if (cache && typeof cache.values === 'function') {
|
|
56
|
+
authorRoles = [...cache.values()]
|
|
57
|
+
.filter((role) => role && role.id && role.id !== guild.id)
|
|
58
|
+
.map((role) => (role.name ? { id: String(role.id), name: String(role.name) } : { id: String(role.id) }));
|
|
59
|
+
}
|
|
51
60
|
if (guild.ownerId === msg.author.id) {
|
|
52
61
|
isGuildOwner = true;
|
|
53
62
|
permissionTokens.push('guild_owner', 'ADMINISTRATOR');
|
|
@@ -60,6 +69,7 @@ export function normalizeDiscordMessage(raw) {
|
|
|
60
69
|
content: msg.content ?? '',
|
|
61
70
|
channelId: msg.channel.id,
|
|
62
71
|
channelKind: resolveChannelKind(msg.channel.type),
|
|
72
|
+
...(threadParentOf(msg.channel) ? { threadParentId: threadParentOf(msg.channel) } : {}),
|
|
63
73
|
authorId: msg.author.id,
|
|
64
74
|
authorName: member?.displayName || msg.author.displayName || msg.author.username,
|
|
65
75
|
authorBot: msg.author.bot,
|
|
@@ -67,6 +77,8 @@ export function normalizeDiscordMessage(raw) {
|
|
|
67
77
|
guildId: guild?.id,
|
|
68
78
|
isGuildOwner,
|
|
69
79
|
permissionTokens,
|
|
80
|
+
...(platformPermissions.length ? { platformPermissions } : {}),
|
|
81
|
+
...(authorRoles.length ? { authorRoles } : {}),
|
|
70
82
|
attachments: [...msg.attachments.values()].map((a) => ({
|
|
71
83
|
id: a.id,
|
|
72
84
|
name: a.name ?? undefined,
|
|
@@ -256,6 +268,7 @@ export async function connectDiscordGatewayClient(client, config, handlers, sign
|
|
|
256
268
|
customId: interaction.customId,
|
|
257
269
|
channelId,
|
|
258
270
|
channelKind: interaction.channel ? resolveChannelKind(interaction.channel.type) : interaction.guildId ? 'channel' : 'private',
|
|
271
|
+
...(threadParentOf(interaction.channel) ? { threadParentId: threadParentOf(interaction.channel) } : {}),
|
|
259
272
|
...(interaction.guildId ? { guildId: interaction.guildId } : {}),
|
|
260
273
|
userId: interaction.user.id,
|
|
261
274
|
userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { activityTypeCode, formatButtonContent, formatInboundContent, formatOutboundBody, resolveChannelKind, resolveDiscordConfig, senderDisplayName, type DiscordActivity, type DiscordEndpointConfig, type DiscordButtonInbound, type DiscordInboundAttachment, type DiscordInboundMessage, type DiscordOutboundBody, type DiscordWireSegment, type ResolvedDiscordConfig, type ResolvedDiscordGatewayConfig, type ResolvedDiscordInteractionsConfig, } from './protocol.js';
|
|
2
2
|
export { requireDiscordGatewayClient, type DiscordClient, type DiscordClientEventMap, } from './client.js';
|
|
3
|
-
export {
|
|
3
|
+
export { platformPermit } from './platform-permit.js';
|
|
4
4
|
export { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, type DiscordClientTransport, type DiscordEndpointOptions, type DiscordInteractionsEndpointOptions, } from './endpoint.js';
|
|
5
5
|
export { connectDiscordGatewayClient, discordClientOptions, normalizeDiscordMessage, resolveSenderRole, toMessageCreateOptions, type DiscordGatewayConnectHandlers, } from './gateway.js';
|
|
6
6
|
export { handleDiscordInteractionRequest, registerDiscordInteractionRoutes, type DiscordInteractionsHandler, } from './webhook.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { activityTypeCode, formatButtonContent, formatInboundContent, formatOutboundBody, resolveChannelKind, resolveDiscordConfig, senderDisplayName, } from './protocol.js';
|
|
2
2
|
export { requireDiscordGatewayClient, } from './client.js';
|
|
3
|
-
export {
|
|
3
|
+
export { platformPermit } from './platform-permit.js';
|
|
4
4
|
export { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, } from './endpoint.js';
|
|
5
5
|
export { connectDiscordGatewayClient, discordClientOptions, normalizeDiscordMessage, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
|
|
6
6
|
export { handleDiscordInteractionRequest, registerDiscordInteractionRoutes, } from './webhook.js';
|
package/lib/platform-permit.d.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Discord platform permit
|
|
2
|
+
* Discord platform permit helper(ADR 0056 §5)。
|
|
3
|
+
*
|
|
4
|
+
* 平台身份由适配器上报到 `sender.platform`,permit 由内置评估器(`@zhin.js/permission`)判断;
|
|
5
|
+
* 适配器不再导出自有的 permit 解析器或检查函数。
|
|
3
6
|
*/
|
|
4
|
-
import type { PermissionSubject } from '@zhin.js/permission';
|
|
5
7
|
export declare function platformPermit(perm: string): string;
|
|
6
|
-
export declare function discordGroupPermitResolver(logicalPerm: string): string;
|
|
7
|
-
export declare function normalizeDiscordSenderForPermit(input: {
|
|
8
|
-
isOwner?: boolean;
|
|
9
|
-
permissions?: string[];
|
|
10
|
-
}): {
|
|
11
|
-
role?: string;
|
|
12
|
-
permissions?: string[];
|
|
13
|
-
};
|
|
14
|
-
export declare function checkDiscordPlatformPermit(perm: string, subject: PermissionSubject): boolean;
|
package/lib/platform-permit.js
CHANGED
|
@@ -1,42 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Discord platform permit helper(ADR 0056 §5)。
|
|
3
|
+
*
|
|
4
|
+
* 平台身份由适配器上报到 `sender.platform`,permit 由内置评估器(`@zhin.js/permission`)判断;
|
|
5
|
+
* 适配器不再导出自有的 permit 解析器或检查函数。
|
|
6
|
+
*/
|
|
2
7
|
export function platformPermit(perm) {
|
|
3
|
-
return `platform
|
|
4
|
-
}
|
|
5
|
-
const FACTORY_PERM_MAP = {
|
|
6
|
-
scene_admin: 'moderate_members',
|
|
7
|
-
scene_owner: 'guild_owner',
|
|
8
|
-
};
|
|
9
|
-
export function discordGroupPermitResolver(logicalPerm) {
|
|
10
|
-
return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
|
|
11
|
-
}
|
|
12
|
-
export function normalizeDiscordSenderForPermit(input) {
|
|
13
|
-
const permissions = [...(input.permissions ?? [])];
|
|
14
|
-
if (input.isOwner) {
|
|
15
|
-
permissions.push('guild_owner', 'ADMINISTRATOR');
|
|
16
|
-
return { role: 'owner', permissions };
|
|
17
|
-
}
|
|
18
|
-
if (permissions.includes('ADMINISTRATOR')) {
|
|
19
|
-
return { role: 'admin', permissions };
|
|
20
|
-
}
|
|
21
|
-
if (permissions.includes('MODERATE_MEMBERS') || permissions.includes('MANAGE_GUILD')) {
|
|
22
|
-
return { role: 'admin', permissions };
|
|
23
|
-
}
|
|
24
|
-
return { role: 'member', permissions };
|
|
25
|
-
}
|
|
26
|
-
export function checkDiscordPlatformPermit(perm, subject) {
|
|
27
|
-
const role = subject.sender?.role?.[0];
|
|
28
|
-
const permissions = subject.sender?.permissions ?? [];
|
|
29
|
-
const has = (t) => permissions.includes(t);
|
|
30
|
-
switch (perm) {
|
|
31
|
-
case 'guild_owner':
|
|
32
|
-
return role === 'owner' || has('guild_owner');
|
|
33
|
-
case 'moderate_members':
|
|
34
|
-
return role === 'owner' || role === 'admin' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
|
|
35
|
-
case 'manage_roles':
|
|
36
|
-
return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_ROLES');
|
|
37
|
-
case 'manage_channels':
|
|
38
|
-
return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_CHANNELS');
|
|
39
|
-
default:
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
8
|
+
return `platform-permission:${perm}`;
|
|
42
9
|
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -68,8 +68,17 @@ export interface DiscordInboundMessage {
|
|
|
68
68
|
readonly authorBot?: boolean;
|
|
69
69
|
readonly createdTimestamp: number;
|
|
70
70
|
readonly guildId?: string;
|
|
71
|
+
/** The message was sent in a thread: its parent channel (the thread itself is `channelId`). */
|
|
72
|
+
readonly threadParentId?: string;
|
|
71
73
|
readonly isGuildOwner?: boolean;
|
|
72
74
|
readonly permissionTokens?: readonly string[];
|
|
75
|
+
/** ADR 0056 §1: guild permission flags as snake_case names (`ban_members`, …). */
|
|
76
|
+
readonly platformPermissions?: readonly string[];
|
|
77
|
+
/** ADR 0056 §1: the member's guild roles (`@everyone` excluded). */
|
|
78
|
+
readonly authorRoles?: readonly {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
readonly name?: string;
|
|
81
|
+
}[];
|
|
73
82
|
readonly attachments?: readonly DiscordInboundAttachment[];
|
|
74
83
|
readonly embedTitles?: readonly string[];
|
|
75
84
|
readonly stickerNames?: readonly string[];
|
|
@@ -85,6 +94,8 @@ export interface DiscordButtonInbound {
|
|
|
85
94
|
readonly customId: string;
|
|
86
95
|
readonly channelId: string;
|
|
87
96
|
readonly channelKind: 'private' | 'group' | 'channel';
|
|
97
|
+
/** Pressed inside a thread: its parent channel. */
|
|
98
|
+
readonly threadParentId?: string;
|
|
88
99
|
readonly userId: string;
|
|
89
100
|
readonly userName: string;
|
|
90
101
|
readonly sourceMessageId?: string;
|
|
@@ -125,13 +136,18 @@ export declare function resolveChannelKind(channelType: number | string | undefi
|
|
|
125
136
|
/**
|
|
126
137
|
* 入站归一化 → ConversationRef:DM → 'private';GroupDM → 'group';
|
|
127
138
|
* guild 频道 → 'channel' + guild 容器进 parent(guild 是频道容器,kind 'channel')。
|
|
139
|
+
* 线程(thread):会话 id 是父频道、`threadId` 是线程频道(ConversationRef 的子话题约定),
|
|
140
|
+
* 所以父频道上的角色 / 功能配置在线程里同样生效(ADR 0056 §2);出站用 {@link discordChannelOf}。
|
|
128
141
|
* endpoint.adapter = owner PluginId(CapabilityId 以 \0 分隔)。
|
|
129
142
|
*/
|
|
130
143
|
export declare function discordInboundConversation(endpointKey: string, msg: {
|
|
131
144
|
readonly channelId: string;
|
|
132
145
|
readonly channelKind: 'private' | 'group' | 'channel';
|
|
133
146
|
readonly guildId?: string;
|
|
147
|
+
readonly threadParentId?: string;
|
|
134
148
|
}): ConversationRef;
|
|
149
|
+
/** The Discord channel a conversation's messages live in: the thread when there is one. */
|
|
150
|
+
export declare function discordChannelOf(conversation: Pick<ConversationRef, 'id' | 'threadId'>): string;
|
|
135
151
|
export declare function senderDisplayName(msg: DiscordInboundMessage): string;
|
|
136
152
|
/** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
|
|
137
153
|
export declare function formatInboundContent(msg: DiscordInboundMessage): string;
|
|
@@ -158,4 +174,6 @@ export declare function activityTypeCode(type: DiscordActivity['type']): number;
|
|
|
158
174
|
export declare const DISCORD_SIGNATURE_MAX_AGE_MS: number;
|
|
159
175
|
export declare function verifyDiscordInteractionSignature(publicKeyHex: string, body: string, signature: string, timestamp: string, nowMs?: number): boolean;
|
|
160
176
|
export declare function formatSlashCommandContent(interaction: Record<string, unknown>): string;
|
|
177
|
+
/** Parent channel of a raw API channel object when it is a thread. */
|
|
178
|
+
export declare function discordThreadParentOf(channel: unknown): string | undefined;
|
|
161
179
|
export declare function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage;
|
package/lib/protocol.js
CHANGED
|
@@ -2,6 +2,7 @@ import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
|
|
|
2
2
|
/** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
|
|
3
3
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
4
4
|
import { isMediaRef } from '@zhin.js/im-contract';
|
|
5
|
+
import { discordGuildPermissionNamesFromChannelBitfield } from './sender-standing.js';
|
|
5
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
6
7
|
const logger = getLogger('discord');
|
|
7
8
|
export function resolveDiscordConfig(config) {
|
|
@@ -56,18 +57,26 @@ export function resolveChannelKind(channelType) {
|
|
|
56
57
|
/**
|
|
57
58
|
* 入站归一化 → ConversationRef:DM → 'private';GroupDM → 'group';
|
|
58
59
|
* guild 频道 → 'channel' + guild 容器进 parent(guild 是频道容器,kind 'channel')。
|
|
60
|
+
* 线程(thread):会话 id 是父频道、`threadId` 是线程频道(ConversationRef 的子话题约定),
|
|
61
|
+
* 所以父频道上的角色 / 功能配置在线程里同样生效(ADR 0056 §2);出站用 {@link discordChannelOf}。
|
|
59
62
|
* endpoint.adapter = owner PluginId(CapabilityId 以 \0 分隔)。
|
|
60
63
|
*/
|
|
61
64
|
export function discordInboundConversation(endpointKey, msg) {
|
|
65
|
+
const thread = msg.channelKind === 'channel' && msg.threadParentId && msg.threadParentId !== msg.channelId;
|
|
62
66
|
return {
|
|
63
67
|
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
64
68
|
kind: msg.channelKind,
|
|
65
|
-
id: msg.channelId,
|
|
69
|
+
id: thread ? msg.threadParentId : msg.channelId,
|
|
70
|
+
...(thread ? { threadId: msg.channelId } : {}),
|
|
66
71
|
...(msg.guildId && msg.channelKind === 'channel'
|
|
67
72
|
? { parent: { kind: 'channel', id: msg.guildId } }
|
|
68
73
|
: {}),
|
|
69
74
|
};
|
|
70
75
|
}
|
|
76
|
+
/** The Discord channel a conversation's messages live in: the thread when there is one. */
|
|
77
|
+
export function discordChannelOf(conversation) {
|
|
78
|
+
return conversation.threadId ?? conversation.id;
|
|
79
|
+
}
|
|
71
80
|
export function senderDisplayName(msg) {
|
|
72
81
|
return msg.authorName || msg.authorId;
|
|
73
82
|
}
|
|
@@ -253,6 +262,7 @@ export function formatOutboundBody(payload) {
|
|
|
253
262
|
embeds.push({ title: String(data.title ?? ''), url: String(data.url ?? ''), description: String(data.description ?? data.content ?? ''), ...(data.image ? { image: { url: String(data.image) } } : {}) });
|
|
254
263
|
break;
|
|
255
264
|
case 'embed':
|
|
265
|
+
case 'discord:embed':
|
|
256
266
|
embeds.push({ ...data });
|
|
257
267
|
break;
|
|
258
268
|
case 'keyboard': {
|
|
@@ -338,18 +348,37 @@ export function formatSlashCommandContent(interaction) {
|
|
|
338
348
|
}
|
|
339
349
|
return parts.join(' ');
|
|
340
350
|
}
|
|
351
|
+
/** Thread channel types (announcement / public / private thread) carry `parent_id`, the channel they belong to. */
|
|
352
|
+
const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]);
|
|
353
|
+
/** Parent channel of a raw API channel object when it is a thread. */
|
|
354
|
+
export function discordThreadParentOf(channel) {
|
|
355
|
+
if (!channel || typeof channel !== 'object')
|
|
356
|
+
return undefined;
|
|
357
|
+
const raw = channel;
|
|
358
|
+
return THREAD_CHANNEL_TYPES.has(Number(raw.type)) && typeof raw.parent_id === 'string' && raw.parent_id ? raw.parent_id : undefined;
|
|
359
|
+
}
|
|
341
360
|
export function interactionToInboundMessage(interaction) {
|
|
342
|
-
const
|
|
361
|
+
const member = interaction.member;
|
|
362
|
+
const user = member?.user
|
|
343
363
|
?? interaction.user;
|
|
364
|
+
const guildId = interaction.guild_id != null ? String(interaction.guild_id) : undefined;
|
|
365
|
+
// ADR 0056 §1: guild-level names only (the interaction bitfield includes channel overwrites).
|
|
366
|
+
const platformPermissions = guildId ? discordGuildPermissionNamesFromChannelBitfield(member?.permissions) : [];
|
|
367
|
+
const authorRoles = guildId && Array.isArray(member?.roles)
|
|
368
|
+
? member.roles.map(String).filter((id) => id && id !== guildId).map((id) => ({ id }))
|
|
369
|
+
: [];
|
|
344
370
|
return {
|
|
345
371
|
event: interaction,
|
|
346
372
|
id: String(interaction.id),
|
|
347
373
|
content: formatSlashCommandContent(interaction),
|
|
348
374
|
channelId: String(interaction.channel_id ?? ''),
|
|
349
375
|
channelKind: interaction.guild_id ? 'channel' : 'private',
|
|
376
|
+
...(discordThreadParentOf(interaction.channel) ? { threadParentId: discordThreadParentOf(interaction.channel) } : {}),
|
|
350
377
|
authorId: String(user?.id ?? ''),
|
|
351
378
|
authorName: String(user?.username ?? user?.id ?? ''),
|
|
352
379
|
createdTimestamp: Date.now(),
|
|
353
|
-
guildId
|
|
380
|
+
guildId,
|
|
381
|
+
...(platformPermissions.length ? { platformPermissions } : {}),
|
|
382
|
+
...(authorRoles.length ? { authorRoles } : {}),
|
|
354
383
|
};
|
|
355
384
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PlatformStanding } from '@zhin.js/im-contract';
|
|
2
|
+
import type { DiscordInboundMessage } from './protocol.js';
|
|
3
|
+
/** `ManageRoles` → `manage_roles`, `SendTTSMessages` → `send_tts_messages`. */
|
|
4
|
+
export declare function discordPermissionName(flag: string): string;
|
|
5
|
+
/** All permission flag names the predicate grants. */
|
|
6
|
+
export declare function discordPermissionNames(has: (bit: bigint) => boolean): string[];
|
|
7
|
+
/** Permission names from an interaction's decimal bitfield string (`member.permissions`). */
|
|
8
|
+
export declare function discordPermissionNamesFromBitfield(bitfield: unknown): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Guild-level permission names from a channel-level bitfield (interaction `member.permissions` /
|
|
11
|
+
* `app_permissions`): an administrator holds every flag server-wide; otherwise only the flags that
|
|
12
|
+
* channel overwrites cannot change are reported, so a channel overwrite (e.g. "Manage Permissions" in
|
|
13
|
+
* one channel) never reads as a server-wide `manage_roles`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function discordGuildPermissionNamesFromChannelBitfield(bitfield: unknown): string[];
|
|
16
|
+
/** Normalized standing from guild-level permission names (the guild owner is reported separately). */
|
|
17
|
+
export declare function discordStandingFromPermissions(permissions: readonly string[], isOwner?: boolean): PlatformStanding['standing'];
|
|
18
|
+
export declare function discordSenderStanding(msg: DiscordInboundMessage): PlatformStanding | undefined;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0056 §1: Discord guild member → normalized `sender.platform`.
|
|
3
|
+
*
|
|
4
|
+
* - `standing`: guild owner → `owner`; `Administrator` / `ModerateMembers` → `admin`; other members →
|
|
5
|
+
* `member`. Direct messages report nothing.
|
|
6
|
+
* - `roles`: the member's guild roles (`@everyone` excluded), with names when the event carries them.
|
|
7
|
+
* - `permissions`: the member's **guild-level** permission flags as snake_case names (`ban_members`,
|
|
8
|
+
* `manage_roles`, …). Gateway mode reads `GuildMember.permissions` (server-wide, no channel overwrites)
|
|
9
|
+
* for senders and the Bot alike. Interactions only carry channel-level bitfields (overwrites applied),
|
|
10
|
+
* so they report just the flags that cannot be overwritten per channel (identical at both levels) —
|
|
11
|
+
* all flags when the member is an administrator.
|
|
12
|
+
*/
|
|
13
|
+
import { PermissionFlagsBits } from 'discord.js';
|
|
14
|
+
const FLAG_ENTRIES = Object.entries(PermissionFlagsBits);
|
|
15
|
+
/** `ManageRoles` → `manage_roles`, `SendTTSMessages` → `send_tts_messages`. */
|
|
16
|
+
export function discordPermissionName(flag) {
|
|
17
|
+
return flag.replace(/([A-Z]+)([A-Z][a-z])/gu, '$1_$2').replace(/([a-z0-9])([A-Z])/gu, '$1_$2').toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
/** All permission flag names the predicate grants. */
|
|
20
|
+
export function discordPermissionNames(has) {
|
|
21
|
+
const names = [];
|
|
22
|
+
for (const [flag, bit] of FLAG_ENTRIES) {
|
|
23
|
+
try {
|
|
24
|
+
if (has(bit))
|
|
25
|
+
names.push(discordPermissionName(flag));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A partial permission view cannot answer this bit; leave it out.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
/** Permission names from an interaction's decimal bitfield string (`member.permissions`). */
|
|
34
|
+
export function discordPermissionNamesFromBitfield(bitfield) {
|
|
35
|
+
const value = parseBitfield(bitfield);
|
|
36
|
+
return value === undefined ? [] : discordPermissionNames(bit => (value & bit) === bit);
|
|
37
|
+
}
|
|
38
|
+
function parseBitfield(bitfield) {
|
|
39
|
+
if (typeof bitfield !== 'string' && typeof bitfield !== 'number' && typeof bitfield !== 'bigint')
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return BigInt(bitfield);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Flags Discord does not allow in channel overwrites: a channel-level bitfield shows their guild value. */
|
|
49
|
+
const GUILD_ONLY_FLAGS = [
|
|
50
|
+
PermissionFlagsBits.Administrator, PermissionFlagsBits.KickMembers, PermissionFlagsBits.BanMembers,
|
|
51
|
+
PermissionFlagsBits.ManageGuild, PermissionFlagsBits.ViewAuditLog, PermissionFlagsBits.ViewGuildInsights,
|
|
52
|
+
PermissionFlagsBits.ChangeNickname, PermissionFlagsBits.ManageNicknames, PermissionFlagsBits.ManageGuildExpressions,
|
|
53
|
+
PermissionFlagsBits.ModerateMembers, PermissionFlagsBits.ViewCreatorMonetizationAnalytics,
|
|
54
|
+
PermissionFlagsBits.CreateGuildExpressions,
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Guild-level permission names from a channel-level bitfield (interaction `member.permissions` /
|
|
58
|
+
* `app_permissions`): an administrator holds every flag server-wide; otherwise only the flags that
|
|
59
|
+
* channel overwrites cannot change are reported, so a channel overwrite (e.g. "Manage Permissions" in
|
|
60
|
+
* one channel) never reads as a server-wide `manage_roles`.
|
|
61
|
+
*/
|
|
62
|
+
export function discordGuildPermissionNamesFromChannelBitfield(bitfield) {
|
|
63
|
+
const value = parseBitfield(bitfield);
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return [];
|
|
66
|
+
if ((value & PermissionFlagsBits.Administrator) === PermissionFlagsBits.Administrator)
|
|
67
|
+
return discordPermissionNames(() => true);
|
|
68
|
+
return discordPermissionNames(bit => GUILD_ONLY_FLAGS.includes(bit) && (value & bit) === bit);
|
|
69
|
+
}
|
|
70
|
+
/** Normalized standing from guild-level permission names (the guild owner is reported separately). */
|
|
71
|
+
export function discordStandingFromPermissions(permissions, isOwner = false) {
|
|
72
|
+
return isOwner ? 'owner'
|
|
73
|
+
: permissions.includes('administrator') || permissions.includes('moderate_members') ? 'admin' : 'member';
|
|
74
|
+
}
|
|
75
|
+
export function discordSenderStanding(msg) {
|
|
76
|
+
if (!msg.guildId)
|
|
77
|
+
return undefined;
|
|
78
|
+
const permissions = msg.platformPermissions ?? [];
|
|
79
|
+
const legacy = msg.permissionTokens ?? [];
|
|
80
|
+
const standing = msg.isGuildOwner
|
|
81
|
+
? 'owner'
|
|
82
|
+
: permissions.includes('administrator') || permissions.includes('moderate_members')
|
|
83
|
+
|| legacy.includes('ADMINISTRATOR') || legacy.includes('MODERATE_MEMBERS')
|
|
84
|
+
? 'admin'
|
|
85
|
+
: 'member';
|
|
86
|
+
return {
|
|
87
|
+
standing,
|
|
88
|
+
...(msg.authorRoles?.length ? { roles: msg.authorRoles } : {}),
|
|
89
|
+
...(permissions.length ? { permissions } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
package/lib/webhook.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { type Endpoint } from 'zhin.js/adapter';
|
|
2
|
-
|
|
3
|
-
* Discord interactions HTTP: signature → parse → admit.
|
|
4
|
-
*/
|
|
5
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
-
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
2
|
+
import { type HttpHost, type HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
3
|
import { type DiscordInboundMessage, type DiscordButtonInbound, type ResolvedDiscordInteractionsConfig } from './protocol.js';
|
|
8
4
|
export interface DiscordInteractionsHandler extends Endpoint {
|
|
9
5
|
readonly config: ResolvedDiscordInteractionsConfig;
|
|
@@ -13,4 +9,4 @@ export interface DiscordInteractionsHandler extends Endpoint {
|
|
|
13
9
|
admitPlatform(event: Record<string, unknown>): void;
|
|
14
10
|
}
|
|
15
11
|
export declare function registerDiscordInteractionRoutes(http: HttpHost, handler: DiscordInteractionsHandler): HttpRouteRegistration[];
|
|
16
|
-
export declare function handleDiscordInteractionRequest(request:
|
|
12
|
+
export declare function handleDiscordInteractionRequest(request: Request, handler: DiscordInteractionsHandler): Promise<Response>;
|
package/lib/webhook.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { endpointSpec } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* Discord interactions HTTP: signature → parse → admit.
|
|
4
|
+
*/
|
|
2
5
|
import { createMessages } from 'zhin.js/i18n';
|
|
6
|
+
import { readTextBody } from '@zhin.js/host-http';
|
|
3
7
|
import { getLogger } from '@zhin.js/logger';
|
|
4
8
|
import { interactionToInboundMessage, verifyDiscordInteractionSignature, } from './protocol.js';
|
|
5
9
|
const logger = getLogger('discord');
|
|
@@ -15,55 +19,44 @@ const INTERACTION_FLAG_EPHEMERAL = 64;
|
|
|
15
19
|
export function registerDiscordInteractionRoutes(http, handler) {
|
|
16
20
|
const path = handler.config.interactionsPath;
|
|
17
21
|
return [
|
|
18
|
-
http.route('POST', path,
|
|
19
|
-
await handleDiscordInteractionRequest(request, response, handler);
|
|
20
|
-
}, { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
22
|
+
http.route('POST', path, (request) => handleDiscordInteractionRequest(request, handler), { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
21
23
|
];
|
|
22
24
|
}
|
|
23
|
-
export async function handleDiscordInteractionRequest(request,
|
|
25
|
+
export async function handleDiscordInteractionRequest(request, handler) {
|
|
24
26
|
try {
|
|
25
|
-
const signature =
|
|
26
|
-
const timestamp =
|
|
27
|
-
|
|
27
|
+
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
|
28
|
+
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
|
29
|
+
// Oversize bodies (> 1 MiB) fail and answer 500, as before.
|
|
30
|
+
const rawBody = await readTextBody(request);
|
|
28
31
|
if (!signature || !timestamp) {
|
|
29
|
-
|
|
30
|
-
response.end('Unauthorized');
|
|
31
|
-
return;
|
|
32
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
32
33
|
}
|
|
33
34
|
if (!verifyDiscordInteractionSignature(handler.config.publicKey, rawBody, signature, timestamp)) {
|
|
34
|
-
|
|
35
|
-
response.end('Unauthorized');
|
|
36
|
-
return;
|
|
35
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
37
36
|
}
|
|
38
37
|
const interaction = JSON.parse(rawBody);
|
|
39
38
|
if (!interaction || interaction.application_id !== handler.config.applicationId) {
|
|
40
|
-
|
|
41
|
-
response.end('Unauthorized');
|
|
42
|
-
return;
|
|
39
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
43
40
|
}
|
|
44
41
|
// A verified protocol challenge is not a business message and keeps its immediate ACK.
|
|
45
42
|
if (interaction.type === INTERACTION_TYPE_PING) {
|
|
46
43
|
if (handler.isOpen)
|
|
47
44
|
void handler[endpointSpec].events.run(() => handler.admitPlatform(interaction))
|
|
48
45
|
.catch(() => logger.debug('Protocol event dispatch did not complete'));
|
|
49
|
-
writeJson(
|
|
50
|
-
return;
|
|
46
|
+
return writeJson(200, { type: INTERACTION_RESPONSE_PONG });
|
|
51
47
|
}
|
|
52
|
-
await handler[endpointSpec].events.run(async () => {
|
|
48
|
+
return await handler[endpointSpec].events.run(async () => {
|
|
53
49
|
if (handler.isOpen)
|
|
54
50
|
handler.admitPlatform(interaction);
|
|
55
51
|
if (interaction.type === 3) {
|
|
56
52
|
const data = interaction.data;
|
|
57
53
|
if (data?.component_type === 2 && typeof data.custom_id === 'string') {
|
|
58
54
|
if (!handler.isOpen) {
|
|
59
|
-
|
|
60
|
-
response.end('Unavailable');
|
|
61
|
-
return;
|
|
55
|
+
return new Response('Unavailable', { status: 503 });
|
|
62
56
|
}
|
|
63
57
|
const message = interactionToInboundMessage(interaction);
|
|
64
|
-
handler.admitButton({ event: interaction, id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), sourceMessageId: interaction.message?.id });
|
|
65
|
-
writeJson(
|
|
66
|
-
return;
|
|
58
|
+
handler.admitButton({ event: interaction, id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), ...(message.threadParentId ? { threadParentId: message.threadParentId } : {}), sourceMessageId: interaction.message?.id });
|
|
59
|
+
return writeJson(200, { type: 6 });
|
|
67
60
|
}
|
|
68
61
|
}
|
|
69
62
|
if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
|
|
@@ -74,47 +67,22 @@ export async function handleDiscordInteractionRequest(request, response, handler
|
|
|
74
67
|
const locale = await handler[endpointSpec].resolveLocale(message.authorId);
|
|
75
68
|
// 即时响应(type 4):defer(type 5) 需要 followup PATCH,未实现会让用户端一直转圈
|
|
76
69
|
const commandName = String(interaction.data?.name ?? '');
|
|
77
|
-
writeJson(
|
|
70
|
+
return writeJson(200, {
|
|
78
71
|
type: INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
79
72
|
data: {
|
|
80
73
|
content: messages(locale, 'processing', { command: commandName }),
|
|
81
74
|
flags: INTERACTION_FLAG_EPHEMERAL,
|
|
82
75
|
},
|
|
83
76
|
});
|
|
84
|
-
return;
|
|
85
77
|
}
|
|
86
|
-
|
|
87
|
-
response.end('Unsupported interaction type');
|
|
78
|
+
return new Response('Unsupported interaction type', { status: 400, headers: { 'Content-Type': 'text/plain' } });
|
|
88
79
|
});
|
|
89
80
|
}
|
|
90
81
|
catch (error) {
|
|
91
82
|
logger.error('Discord interactions error:', error);
|
|
92
|
-
|
|
93
|
-
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
94
|
-
response.end('Internal Server Error');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
function headerValue(value) {
|
|
99
|
-
if (Array.isArray(value))
|
|
100
|
-
return value[0] ?? '';
|
|
101
|
-
return value ?? '';
|
|
102
|
-
}
|
|
103
|
-
async function readInteractionBody(request) {
|
|
104
|
-
const chunks = [];
|
|
105
|
-
let size = 0;
|
|
106
|
-
for await (const chunk of request) {
|
|
107
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
108
|
-
size += buffer.length;
|
|
109
|
-
if (size > 1_048_576) {
|
|
110
|
-
request.destroy();
|
|
111
|
-
throw new Error('Request body exceeds 1MB');
|
|
112
|
-
}
|
|
113
|
-
chunks.push(buffer);
|
|
83
|
+
return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } });
|
|
114
84
|
}
|
|
115
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
116
85
|
}
|
|
117
|
-
function writeJson(
|
|
118
|
-
|
|
119
|
-
response.end(JSON.stringify(body));
|
|
86
|
+
function writeJson(status, body) {
|
|
87
|
+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
120
88
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-discord",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.28",
|
|
4
4
|
"description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -35,25 +35,25 @@
|
|
|
35
35
|
"@discordjs/ws": "1.2.3",
|
|
36
36
|
"discord.js": "^14.27.0",
|
|
37
37
|
"undici": "6.29.1",
|
|
38
|
-
"
|
|
39
|
-
"@zhin.js/
|
|
40
|
-
"@zhin.js/feature
|
|
41
|
-
"@zhin.js/
|
|
42
|
-
"@zhin.js/
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
-
"@zhin.js/
|
|
46
|
-
"@zhin.js/
|
|
47
|
-
"@zhin.js/
|
|
48
|
-
"@zhin.js/
|
|
49
|
-
"@zhin.js/
|
|
38
|
+
"zod": "^4.6.7",
|
|
39
|
+
"@zhin.js/adapter": "1.1.33",
|
|
40
|
+
"@zhin.js/agent-feature": "1.1.17",
|
|
41
|
+
"@zhin.js/console-contract": "1.1.6",
|
|
42
|
+
"@zhin.js/core": "1.2.0",
|
|
43
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
44
|
+
"@zhin.js/host-http": "1.2.0",
|
|
45
|
+
"@zhin.js/im-contract": "1.1.14",
|
|
46
|
+
"@zhin.js/logger": "1.1.3",
|
|
47
|
+
"@zhin.js/page": "1.1.18",
|
|
48
|
+
"@zhin.js/permission": "1.2.0",
|
|
49
|
+
"@zhin.js/skill": "1.1.17",
|
|
50
|
+
"@zhin.js/tool": "1.1.19"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
|
-
"
|
|
53
|
-
"@zhin.js/command": "^1.
|
|
54
|
-
"zhin.js": "^1.
|
|
55
|
-
"
|
|
56
|
-
"@zhin.js/core": "^1.1.60"
|
|
53
|
+
"@zhin.js/adapter": "^1.1.33",
|
|
54
|
+
"@zhin.js/command": "^1.2.0",
|
|
55
|
+
"@zhin.js/core": "^1.2.0",
|
|
56
|
+
"zhin.js": "^1.1.26"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"@zhin.js/command": {
|
|
@@ -61,18 +61,16 @@
|
|
|
61
61
|
},
|
|
62
62
|
"zhin.js": {
|
|
63
63
|
"optional": true
|
|
64
|
-
},
|
|
65
|
-
"zod": {
|
|
66
|
-
"optional": true
|
|
67
64
|
}
|
|
68
65
|
},
|
|
69
66
|
"devDependencies": {
|
|
70
67
|
"@types/node": "^26.1.2",
|
|
68
|
+
"ajv": "8.18.0",
|
|
71
69
|
"typescript": "^6.0.3",
|
|
72
70
|
"vitest": "^4.1.10",
|
|
73
|
-
"
|
|
74
|
-
"@zhin.js/host-http": "1.
|
|
75
|
-
"zhin.js": "1.1.
|
|
71
|
+
"ws": "^8.21.1",
|
|
72
|
+
"@zhin.js/host-http": "1.2.0",
|
|
73
|
+
"zhin.js": "1.1.26"
|
|
76
74
|
},
|
|
77
75
|
"files": [
|
|
78
76
|
"adapters",
|
|
@@ -131,6 +129,6 @@
|
|
|
131
129
|
"scripts": {
|
|
132
130
|
"build": "tsc",
|
|
133
131
|
"clean": "rimraf lib",
|
|
134
|
-
"test": "
|
|
132
|
+
"test": "vitest run --root ../../.. plugins/adapters/discord/tests"
|
|
135
133
|
}
|
|
136
134
|
}
|