@zhin.js/adapter-discord 1.1.26 → 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 +175 -0
- package/LICENSE +21 -0
- package/PERMITS.md +15 -14
- package/README.md +7 -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/channel-visibility.d.ts +9 -0
- package/lib/channel-visibility.js +44 -0
- package/lib/endpoint.d.ts +1 -0
- package/lib/endpoint.js +76 -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/channel-visibility.ts +44 -0
- package/src/endpoint.ts +77 -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/src/gateway.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type DiscordOutboundBody,
|
|
29
29
|
type ResolvedDiscordGatewayConfig,
|
|
30
30
|
} from './protocol.js';
|
|
31
|
+
import { discordPermissionNames, discordSenderStanding } from './sender-standing.js';
|
|
31
32
|
|
|
32
33
|
const logger = getLogger('discord');
|
|
33
34
|
|
|
@@ -46,12 +47,15 @@ export function discordClientOptions(intents: readonly number[], gatewayFaultPro
|
|
|
46
47
|
return { rest: { ...restOptions, retries: 0, rejectOnRateLimit: () => true }, intents: [...intents], partials: [Partials.Channel], ...(gatewayFaultProxyUrl ? { ws: { buildStrategy: manager => new DiscordFaultProxyStrategy(manager, gatewayFaultProxyUrl) } } : {}) };
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** Normalized standing word (`owner` / `admin` / `member`); `undefined` outside a guild. */
|
|
49
51
|
export function resolveSenderRole(msg: DiscordInboundMessage): string | undefined {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
return discordSenderStanding(msg)?.standing;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A discord.js thread channel's parent channel id (threads share their parent's conversation key, ADR 0056 §2). */
|
|
56
|
+
function threadParentOf(channel: unknown): string | undefined {
|
|
57
|
+
const thread = channel as { isThread?: () => boolean; parentId?: string | null } | null | undefined;
|
|
58
|
+
return thread && typeof thread.isThread === 'function' && thread.isThread() && thread.parentId ? thread.parentId : undefined;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | null {
|
|
@@ -60,6 +64,8 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
60
64
|
if (!msg.author || !msg.channel) return null;
|
|
61
65
|
|
|
62
66
|
const permissionTokens: string[] = [];
|
|
67
|
+
let platformPermissions: string[] = [];
|
|
68
|
+
let authorRoles: { id: string; name?: string }[] = [];
|
|
63
69
|
let isGuildOwner = false;
|
|
64
70
|
const member = msg.member;
|
|
65
71
|
const guild = msg.guild;
|
|
@@ -74,6 +80,13 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
74
80
|
for (const [bit, name] of checks) {
|
|
75
81
|
if (member.permissions.has(bit)) permissionTokens.push(name);
|
|
76
82
|
}
|
|
83
|
+
platformPermissions = discordPermissionNames(bit => member.permissions.has(bit));
|
|
84
|
+
const cache = (member.roles as { cache?: { values?: () => Iterable<{ id: string; name?: string }> } } | undefined)?.cache;
|
|
85
|
+
if (cache && typeof cache.values === 'function') {
|
|
86
|
+
authorRoles = [...cache.values()]
|
|
87
|
+
.filter((role) => role && role.id && role.id !== guild.id)
|
|
88
|
+
.map((role) => (role.name ? { id: String(role.id), name: String(role.name) } : { id: String(role.id) }));
|
|
89
|
+
}
|
|
77
90
|
if (guild.ownerId === msg.author.id) {
|
|
78
91
|
isGuildOwner = true;
|
|
79
92
|
permissionTokens.push('guild_owner', 'ADMINISTRATOR');
|
|
@@ -87,6 +100,7 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
87
100
|
content: msg.content ?? '',
|
|
88
101
|
channelId: msg.channel.id,
|
|
89
102
|
channelKind: resolveChannelKind(msg.channel.type),
|
|
103
|
+
...(threadParentOf(msg.channel) ? { threadParentId: threadParentOf(msg.channel)! } : {}),
|
|
90
104
|
authorId: msg.author.id,
|
|
91
105
|
authorName: member?.displayName || msg.author.displayName || msg.author.username,
|
|
92
106
|
authorBot: msg.author.bot,
|
|
@@ -94,6 +108,8 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
94
108
|
guildId: guild?.id,
|
|
95
109
|
isGuildOwner,
|
|
96
110
|
permissionTokens,
|
|
111
|
+
...(platformPermissions.length ? { platformPermissions } : {}),
|
|
112
|
+
...(authorRoles.length ? { authorRoles } : {}),
|
|
97
113
|
attachments: [...msg.attachments.values()].map((a) => ({
|
|
98
114
|
id: a.id,
|
|
99
115
|
name: a.name ?? undefined,
|
|
@@ -292,6 +308,7 @@ export async function connectDiscordGatewayClient(
|
|
|
292
308
|
customId: interaction.customId,
|
|
293
309
|
channelId,
|
|
294
310
|
channelKind: interaction.channel ? resolveChannelKind(interaction.channel.type) : interaction.guildId ? 'channel' : 'private',
|
|
311
|
+
...(threadParentOf(interaction.channel) ? { threadParentId: threadParentOf(interaction.channel)! } : {}),
|
|
295
312
|
...(interaction.guildId ? { guildId: interaction.guildId } : {}),
|
|
296
313
|
userId: interaction.user.id,
|
|
297
314
|
userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
|
package/src/index.ts
CHANGED
|
@@ -24,12 +24,7 @@ export {
|
|
|
24
24
|
type DiscordClientEventMap,
|
|
25
25
|
} from './client.js';
|
|
26
26
|
|
|
27
|
-
export {
|
|
28
|
-
checkDiscordPlatformPermit,
|
|
29
|
-
discordGroupPermitResolver,
|
|
30
|
-
normalizeDiscordSenderForPermit,
|
|
31
|
-
platformPermit,
|
|
32
|
-
} from './platform-permit.js';
|
|
27
|
+
export { platformPermit } from './platform-permit.js';
|
|
33
28
|
|
|
34
29
|
export {
|
|
35
30
|
DiscordGatewayEndpoint,
|
package/src/platform-permit.ts
CHANGED
|
@@ -1,57 +1,10 @@
|
|
|
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
|
-
|
|
6
|
-
const ADAPTER = 'discord';
|
|
7
7
|
|
|
8
8
|
export function platformPermit(perm: string): string {
|
|
9
|
-
return `platform
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const FACTORY_PERM_MAP: Record<string, string> = {
|
|
13
|
-
scene_admin: 'moderate_members',
|
|
14
|
-
scene_owner: 'guild_owner',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export function discordGroupPermitResolver(logicalPerm: string): string {
|
|
18
|
-
return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function normalizeDiscordSenderForPermit(input: {
|
|
22
|
-
isOwner?: boolean;
|
|
23
|
-
permissions?: string[];
|
|
24
|
-
}): { role?: string; permissions?: string[] } {
|
|
25
|
-
const permissions = [...(input.permissions ?? [])];
|
|
26
|
-
if (input.isOwner) {
|
|
27
|
-
permissions.push('guild_owner', 'ADMINISTRATOR');
|
|
28
|
-
return { role: 'owner', permissions };
|
|
29
|
-
}
|
|
30
|
-
if (permissions.includes('ADMINISTRATOR')) {
|
|
31
|
-
return { role: 'admin', permissions };
|
|
32
|
-
}
|
|
33
|
-
if (permissions.includes('MODERATE_MEMBERS') || permissions.includes('MANAGE_GUILD')) {
|
|
34
|
-
return { role: 'admin', permissions };
|
|
35
|
-
}
|
|
36
|
-
return { role: 'member', permissions };
|
|
9
|
+
return `platform-permission:${perm}`;
|
|
37
10
|
}
|
|
38
|
-
|
|
39
|
-
export function checkDiscordPlatformPermit(perm: string, subject: PermissionSubject): boolean {
|
|
40
|
-
const role = subject.sender?.role?.[0];
|
|
41
|
-
const permissions = subject.sender?.permissions ?? [];
|
|
42
|
-
const has = (t: string) => permissions.includes(t);
|
|
43
|
-
|
|
44
|
-
switch (perm) {
|
|
45
|
-
case 'guild_owner':
|
|
46
|
-
return role === 'owner' || has('guild_owner');
|
|
47
|
-
case 'moderate_members':
|
|
48
|
-
return role === 'owner' || role === 'admin' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
|
|
49
|
-
case 'manage_roles':
|
|
50
|
-
return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_ROLES');
|
|
51
|
-
case 'manage_channels':
|
|
52
|
-
return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_CHANNELS');
|
|
53
|
-
default:
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
package/src/protocol.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
|
|
|
4
4
|
|
|
5
5
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
6
6
|
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
7
|
+
import { discordGuildPermissionNamesFromChannelBitfield } from './sender-standing.js';
|
|
7
8
|
import type { Segment } from '@zhin.js/core/runtime';
|
|
8
9
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
10
|
|
|
@@ -84,8 +85,14 @@ export interface DiscordInboundMessage {
|
|
|
84
85
|
readonly authorBot?: boolean;
|
|
85
86
|
readonly createdTimestamp: number;
|
|
86
87
|
readonly guildId?: string;
|
|
88
|
+
/** The message was sent in a thread: its parent channel (the thread itself is `channelId`). */
|
|
89
|
+
readonly threadParentId?: string;
|
|
87
90
|
readonly isGuildOwner?: boolean;
|
|
88
91
|
readonly permissionTokens?: readonly string[];
|
|
92
|
+
/** ADR 0056 §1: guild permission flags as snake_case names (`ban_members`, …). */
|
|
93
|
+
readonly platformPermissions?: readonly string[];
|
|
94
|
+
/** ADR 0056 §1: the member's guild roles (`@everyone` excluded). */
|
|
95
|
+
readonly authorRoles?: readonly { readonly id: string; readonly name?: string }[];
|
|
89
96
|
readonly attachments?: readonly DiscordInboundAttachment[];
|
|
90
97
|
readonly embedTitles?: readonly string[];
|
|
91
98
|
readonly stickerNames?: readonly string[];
|
|
@@ -102,6 +109,8 @@ export interface DiscordButtonInbound {
|
|
|
102
109
|
readonly customId: string;
|
|
103
110
|
readonly channelId: string;
|
|
104
111
|
readonly channelKind: 'private' | 'group' | 'channel';
|
|
112
|
+
/** Pressed inside a thread: its parent channel. */
|
|
113
|
+
readonly threadParentId?: string;
|
|
105
114
|
readonly userId: string;
|
|
106
115
|
readonly userName: string;
|
|
107
116
|
readonly sourceMessageId?: string;
|
|
@@ -195,6 +204,8 @@ export function resolveChannelKind(channelType: number | string | undefined): 'p
|
|
|
195
204
|
/**
|
|
196
205
|
* 入站归一化 → ConversationRef:DM → 'private';GroupDM → 'group';
|
|
197
206
|
* guild 频道 → 'channel' + guild 容器进 parent(guild 是频道容器,kind 'channel')。
|
|
207
|
+
* 线程(thread):会话 id 是父频道、`threadId` 是线程频道(ConversationRef 的子话题约定),
|
|
208
|
+
* 所以父频道上的角色 / 功能配置在线程里同样生效(ADR 0056 §2);出站用 {@link discordChannelOf}。
|
|
198
209
|
* endpoint.adapter = owner PluginId(CapabilityId 以 \0 分隔)。
|
|
199
210
|
*/
|
|
200
211
|
export function discordInboundConversation(
|
|
@@ -203,18 +214,26 @@ export function discordInboundConversation(
|
|
|
203
214
|
readonly channelId: string;
|
|
204
215
|
readonly channelKind: 'private' | 'group' | 'channel';
|
|
205
216
|
readonly guildId?: string;
|
|
217
|
+
readonly threadParentId?: string;
|
|
206
218
|
},
|
|
207
219
|
): ConversationRef {
|
|
220
|
+
const thread = msg.channelKind === 'channel' && msg.threadParentId && msg.threadParentId !== msg.channelId;
|
|
208
221
|
return {
|
|
209
222
|
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
210
223
|
kind: msg.channelKind,
|
|
211
|
-
id: msg.channelId,
|
|
224
|
+
id: thread ? msg.threadParentId! : msg.channelId,
|
|
225
|
+
...(thread ? { threadId: msg.channelId } : {}),
|
|
212
226
|
...(msg.guildId && msg.channelKind === 'channel'
|
|
213
227
|
? { parent: { kind: 'channel' as const, id: msg.guildId } }
|
|
214
228
|
: {}),
|
|
215
229
|
};
|
|
216
230
|
}
|
|
217
231
|
|
|
232
|
+
/** The Discord channel a conversation's messages live in: the thread when there is one. */
|
|
233
|
+
export function discordChannelOf(conversation: Pick<ConversationRef, 'id' | 'threadId'>): string {
|
|
234
|
+
return conversation.threadId ?? conversation.id;
|
|
235
|
+
}
|
|
236
|
+
|
|
218
237
|
export function senderDisplayName(msg: DiscordInboundMessage): string {
|
|
219
238
|
return msg.authorName || msg.authorId;
|
|
220
239
|
}
|
|
@@ -401,6 +420,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
401
420
|
embeds.push({ title: String(data.title ?? ''), url: String(data.url ?? ''), description: String(data.description ?? data.content ?? ''), ...(data.image ? { image: { url: String(data.image) } } : {}) });
|
|
402
421
|
break;
|
|
403
422
|
case 'embed':
|
|
423
|
+
case 'discord:embed':
|
|
404
424
|
embeds.push({ ...data });
|
|
405
425
|
break;
|
|
406
426
|
case 'keyboard': {
|
|
@@ -506,18 +526,38 @@ export function formatSlashCommandContent(interaction: Record<string, unknown>):
|
|
|
506
526
|
return parts.join(' ');
|
|
507
527
|
}
|
|
508
528
|
|
|
529
|
+
/** Thread channel types (announcement / public / private thread) carry `parent_id`, the channel they belong to. */
|
|
530
|
+
const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]);
|
|
531
|
+
|
|
532
|
+
/** Parent channel of a raw API channel object when it is a thread. */
|
|
533
|
+
export function discordThreadParentOf(channel: unknown): string | undefined {
|
|
534
|
+
if (!channel || typeof channel !== 'object') return undefined;
|
|
535
|
+
const raw = channel as { type?: unknown; parent_id?: unknown };
|
|
536
|
+
return THREAD_CHANNEL_TYPES.has(Number(raw.type)) && typeof raw.parent_id === 'string' && raw.parent_id ? raw.parent_id : undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
509
539
|
export function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage {
|
|
510
|
-
const
|
|
540
|
+
const member = interaction.member as { user?: Record<string, unknown>; permissions?: unknown; roles?: unknown } | undefined;
|
|
541
|
+
const user = member?.user
|
|
511
542
|
?? (interaction.user as Record<string, unknown> | undefined);
|
|
543
|
+
const guildId = interaction.guild_id != null ? String(interaction.guild_id) : undefined;
|
|
544
|
+
// ADR 0056 §1: guild-level names only (the interaction bitfield includes channel overwrites).
|
|
545
|
+
const platformPermissions = guildId ? discordGuildPermissionNamesFromChannelBitfield(member?.permissions) : [];
|
|
546
|
+
const authorRoles = guildId && Array.isArray(member?.roles)
|
|
547
|
+
? member.roles.map(String).filter((id) => id && id !== guildId).map((id) => ({ id }))
|
|
548
|
+
: [];
|
|
512
549
|
return {
|
|
513
550
|
event: interaction,
|
|
514
551
|
id: String(interaction.id),
|
|
515
552
|
content: formatSlashCommandContent(interaction),
|
|
516
553
|
channelId: String(interaction.channel_id ?? ''),
|
|
517
554
|
channelKind: interaction.guild_id ? 'channel' : 'private',
|
|
555
|
+
...(discordThreadParentOf(interaction.channel) ? { threadParentId: discordThreadParentOf(interaction.channel)! } : {}),
|
|
518
556
|
authorId: String(user?.id ?? ''),
|
|
519
557
|
authorName: String(user?.username ?? user?.id ?? ''),
|
|
520
558
|
createdTimestamp: Date.now(),
|
|
521
|
-
guildId
|
|
559
|
+
guildId,
|
|
560
|
+
...(platformPermissions.length ? { platformPermissions } : {}),
|
|
561
|
+
...(authorRoles.length ? { authorRoles } : {}),
|
|
522
562
|
};
|
|
523
563
|
}
|
|
@@ -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
|
+
import type { PlatformStanding } from '@zhin.js/im-contract';
|
|
15
|
+
import type { DiscordInboundMessage } from './protocol.js';
|
|
16
|
+
|
|
17
|
+
const FLAG_ENTRIES = Object.entries(PermissionFlagsBits) as [string, bigint][];
|
|
18
|
+
|
|
19
|
+
/** `ManageRoles` → `manage_roles`, `SendTTSMessages` → `send_tts_messages`. */
|
|
20
|
+
export function discordPermissionName(flag: string): string {
|
|
21
|
+
return flag.replace(/([A-Z]+)([A-Z][a-z])/gu, '$1_$2').replace(/([a-z0-9])([A-Z])/gu, '$1_$2').toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** All permission flag names the predicate grants. */
|
|
25
|
+
export function discordPermissionNames(has: (bit: bigint) => boolean): string[] {
|
|
26
|
+
const names: string[] = [];
|
|
27
|
+
for (const [flag, bit] of FLAG_ENTRIES) {
|
|
28
|
+
try {
|
|
29
|
+
if (has(bit)) names.push(discordPermissionName(flag));
|
|
30
|
+
} catch {
|
|
31
|
+
// A partial permission view cannot answer this bit; leave it out.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Permission names from an interaction's decimal bitfield string (`member.permissions`). */
|
|
38
|
+
export function discordPermissionNamesFromBitfield(bitfield: unknown): string[] {
|
|
39
|
+
const value = parseBitfield(bitfield);
|
|
40
|
+
return value === undefined ? [] : discordPermissionNames(bit => (value & bit) === bit);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseBitfield(bitfield: unknown): bigint | undefined {
|
|
44
|
+
if (typeof bitfield !== 'string' && typeof bitfield !== 'number' && typeof bitfield !== 'bigint') return undefined;
|
|
45
|
+
try { return BigInt(bitfield); } catch { 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: readonly bigint[] = [
|
|
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
|
+
/**
|
|
58
|
+
* Guild-level permission names from a channel-level bitfield (interaction `member.permissions` /
|
|
59
|
+
* `app_permissions`): an administrator holds every flag server-wide; otherwise only the flags that
|
|
60
|
+
* channel overwrites cannot change are reported, so a channel overwrite (e.g. "Manage Permissions" in
|
|
61
|
+
* one channel) never reads as a server-wide `manage_roles`.
|
|
62
|
+
*/
|
|
63
|
+
export function discordGuildPermissionNamesFromChannelBitfield(bitfield: unknown): string[] {
|
|
64
|
+
const value = parseBitfield(bitfield);
|
|
65
|
+
if (value === undefined) return [];
|
|
66
|
+
if ((value & PermissionFlagsBits.Administrator) === PermissionFlagsBits.Administrator) return discordPermissionNames(() => true);
|
|
67
|
+
return discordPermissionNames(bit => GUILD_ONLY_FLAGS.includes(bit) && (value & bit) === bit);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Normalized standing from guild-level permission names (the guild owner is reported separately). */
|
|
71
|
+
export function discordStandingFromPermissions(permissions: readonly string[], isOwner = false): PlatformStanding['standing'] {
|
|
72
|
+
return isOwner ? 'owner'
|
|
73
|
+
: permissions.includes('administrator') || permissions.includes('moderate_members') ? 'admin' : 'member';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function discordSenderStanding(msg: DiscordInboundMessage): PlatformStanding | undefined {
|
|
77
|
+
if (!msg.guildId) 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/src/webhook.ts
CHANGED
|
@@ -2,9 +2,8 @@ import { endpointSpec, type Endpoint } from 'zhin.js/adapter';
|
|
|
2
2
|
/**
|
|
3
3
|
* Discord interactions HTTP: signature → parse → admit.
|
|
4
4
|
*/
|
|
5
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
5
|
import { createMessages } from 'zhin.js/i18n';
|
|
7
|
-
import type
|
|
6
|
+
import { readTextBody, type HttpHost, type HttpRouteRegistration } from '@zhin.js/host-http';
|
|
8
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
8
|
import {
|
|
10
9
|
interactionToInboundMessage,
|
|
@@ -40,25 +39,21 @@ export function registerDiscordInteractionRoutes(
|
|
|
40
39
|
): HttpRouteRegistration[] {
|
|
41
40
|
const path = handler.config.interactionsPath;
|
|
42
41
|
return [
|
|
43
|
-
http.route('POST', path,
|
|
44
|
-
await handleDiscordInteractionRequest(request, response, handler);
|
|
45
|
-
}, { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
42
|
+
http.route('POST', path, (request) => handleDiscordInteractionRequest(request, handler), { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
46
43
|
];
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
export async function handleDiscordInteractionRequest(
|
|
50
|
-
request:
|
|
51
|
-
response: ServerResponse,
|
|
47
|
+
request: Request,
|
|
52
48
|
handler: DiscordInteractionsHandler,
|
|
53
|
-
): Promise<
|
|
49
|
+
): Promise<Response> {
|
|
54
50
|
try {
|
|
55
|
-
const signature =
|
|
56
|
-
const timestamp =
|
|
57
|
-
|
|
51
|
+
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
|
52
|
+
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
|
53
|
+
// Oversize bodies (> 1 MiB) fail and answer 500, as before.
|
|
54
|
+
const rawBody = await readTextBody(request);
|
|
58
55
|
if (!signature || !timestamp) {
|
|
59
|
-
|
|
60
|
-
response.end('Unauthorized');
|
|
61
|
-
return;
|
|
56
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
62
57
|
}
|
|
63
58
|
if (!verifyDiscordInteractionSignature(
|
|
64
59
|
handler.config.publicKey,
|
|
@@ -66,33 +61,27 @@ export async function handleDiscordInteractionRequest(
|
|
|
66
61
|
signature,
|
|
67
62
|
timestamp,
|
|
68
63
|
)) {
|
|
69
|
-
|
|
70
|
-
response.end('Unauthorized');
|
|
71
|
-
return;
|
|
64
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
72
65
|
}
|
|
73
66
|
const interaction = JSON.parse(rawBody) as Record<string, unknown>;
|
|
74
67
|
if (!interaction || interaction.application_id !== handler.config.applicationId) {
|
|
75
|
-
|
|
76
|
-
response.end('Unauthorized');
|
|
77
|
-
return;
|
|
68
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
78
69
|
}
|
|
79
70
|
// A verified protocol challenge is not a business message and keeps its immediate ACK.
|
|
80
71
|
if (interaction.type === INTERACTION_TYPE_PING) {
|
|
81
72
|
if (handler.isOpen) void handler[endpointSpec].events.run(() => handler.admitPlatform(interaction))
|
|
82
73
|
.catch(() => logger.debug('Protocol event dispatch did not complete'));
|
|
83
|
-
writeJson(
|
|
84
|
-
return;
|
|
74
|
+
return writeJson(200, { type: INTERACTION_RESPONSE_PONG });
|
|
85
75
|
}
|
|
86
|
-
await handler[endpointSpec].events.run(async () => {
|
|
76
|
+
return await handler[endpointSpec].events.run(async () => {
|
|
87
77
|
if (handler.isOpen) handler.admitPlatform(interaction);
|
|
88
78
|
if (interaction.type === 3) {
|
|
89
79
|
const data = interaction.data as { component_type?: number; custom_id?: string } | undefined;
|
|
90
80
|
if (data?.component_type === 2 && typeof data.custom_id === 'string') {
|
|
91
|
-
if (!handler.isOpen) {
|
|
81
|
+
if (!handler.isOpen) { return new Response('Unavailable', { status: 503 }); }
|
|
92
82
|
const message = interactionToInboundMessage(interaction);
|
|
93
|
-
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 as { id?: string } | undefined)?.id });
|
|
94
|
-
writeJson(
|
|
95
|
-
return;
|
|
83
|
+
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 as { id?: string } | undefined)?.id });
|
|
84
|
+
return writeJson(200, { type: 6 });
|
|
96
85
|
}
|
|
97
86
|
}
|
|
98
87
|
if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
|
|
@@ -105,48 +94,22 @@ export async function handleDiscordInteractionRequest(
|
|
|
105
94
|
const commandName = String(
|
|
106
95
|
(interaction.data as { name?: unknown } | undefined)?.name ?? '',
|
|
107
96
|
);
|
|
108
|
-
writeJson(
|
|
97
|
+
return writeJson(200, {
|
|
109
98
|
type: INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
110
99
|
data: {
|
|
111
100
|
content: messages(locale, 'processing', { command: commandName }),
|
|
112
101
|
flags: INTERACTION_FLAG_EPHEMERAL,
|
|
113
102
|
},
|
|
114
103
|
});
|
|
115
|
-
return;
|
|
116
104
|
}
|
|
117
|
-
|
|
118
|
-
response.end('Unsupported interaction type');
|
|
105
|
+
return new Response('Unsupported interaction type', { status: 400, headers: { 'Content-Type': 'text/plain' } });
|
|
119
106
|
});
|
|
120
107
|
} catch (error) {
|
|
121
108
|
logger.error('Discord interactions error:', error);
|
|
122
|
-
|
|
123
|
-
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
124
|
-
response.end('Internal Server Error');
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function headerValue(value: string | string[] | undefined): string {
|
|
130
|
-
if (Array.isArray(value)) return value[0] ?? '';
|
|
131
|
-
return value ?? '';
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function readInteractionBody(request: IncomingMessage): Promise<string> {
|
|
135
|
-
const chunks: Buffer[] = [];
|
|
136
|
-
let size = 0;
|
|
137
|
-
for await (const chunk of request) {
|
|
138
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
139
|
-
size += buffer.length;
|
|
140
|
-
if (size > 1_048_576) {
|
|
141
|
-
request.destroy();
|
|
142
|
-
throw new Error('Request body exceeds 1MB');
|
|
143
|
-
}
|
|
144
|
-
chunks.push(buffer);
|
|
109
|
+
return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } });
|
|
145
110
|
}
|
|
146
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
147
111
|
}
|
|
148
112
|
|
|
149
|
-
function writeJson(
|
|
150
|
-
|
|
151
|
-
response.end(JSON.stringify(body));
|
|
113
|
+
function writeJson(status: number, body: unknown): Response {
|
|
114
|
+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
152
115
|
}
|