@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/LICENSE +21 -0
  3. package/PERMITS.md +15 -14
  4. package/README.md +6 -2
  5. package/adapters/discord/index.js +3 -0
  6. package/adapters/discord/index.ts +3 -0
  7. package/agents/discord/conversation.js +70 -0
  8. package/agents/discord/conversation.ts +77 -0
  9. package/agents/discord/messages.js +18 -2
  10. package/agents/discord/messages.ts +18 -2
  11. package/agents/discord/skills/discord/SKILL.md +1 -1
  12. package/agents/discord/skills/discord/tools/react/index.js +7 -6
  13. package/agents/discord/skills/discord/tools/react/index.ts +8 -7
  14. package/agents/discord/skills/discord/tools/send_embed/index.js +6 -11
  15. package/agents/discord/skills/discord/tools/send_embed/index.ts +7 -12
  16. package/agents/discord/skills/discord-guild/tools/add_role/index.js +11 -9
  17. package/agents/discord/skills/discord-guild/tools/add_role/index.ts +11 -10
  18. package/agents/discord/skills/discord-guild/tools/create_thread/index.js +5 -5
  19. package/agents/discord/skills/discord-guild/tools/create_thread/index.ts +6 -6
  20. package/agents/discord/skills/discord-guild/tools/forum_post/index.js +7 -3
  21. package/agents/discord/skills/discord-guild/tools/forum_post/index.ts +7 -4
  22. package/agents/discord/skills/discord-guild/tools/list_roles/index.js +7 -8
  23. package/agents/discord/skills/discord-guild/tools/list_roles/index.ts +8 -9
  24. package/agents/discord/skills/discord-guild/tools/remove_role/index.js +11 -9
  25. package/agents/discord/skills/discord-guild/tools/remove_role/index.ts +11 -10
  26. package/lib/endpoint.js +71 -12
  27. package/lib/gateway.d.ts +1 -0
  28. package/lib/gateway.js +21 -8
  29. package/lib/index.d.ts +1 -1
  30. package/lib/index.js +1 -1
  31. package/lib/platform-permit.d.ts +4 -11
  32. package/lib/platform-permit.js +7 -40
  33. package/lib/protocol.d.ts +18 -0
  34. package/lib/protocol.js +32 -3
  35. package/lib/sender-standing.d.ts +18 -0
  36. package/lib/sender-standing.js +91 -0
  37. package/lib/webhook.d.ts +2 -6
  38. package/lib/webhook.js +23 -55
  39. package/package.json +23 -25
  40. package/plugin.js +0 -6
  41. package/schema.json +0 -56
  42. package/src/endpoint.ts +73 -11
  43. package/src/gateway.ts +22 -5
  44. package/src/index.ts +1 -6
  45. package/src/platform-permit.ts +5 -52
  46. package/src/protocol.ts +43 -3
  47. package/src/sender-standing.ts +91 -0
  48. 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
- if (msg.isGuildOwner)
21
- return 'owner';
22
- const tokens = msg.permissionTokens ?? [];
23
- if (tokens.includes('ADMINISTRATOR') || tokens.includes('MODERATE_MEMBERS'))
24
- return 'admin';
25
- if (msg.guildId)
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 { checkDiscordPlatformPermit, discordGroupPermitResolver, normalizeDiscordSenderForPermit, platformPermit, } from './platform-permit.js';
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 { checkDiscordPlatformPermit, discordGroupPermitResolver, normalizeDiscordSenderForPermit, platformPermit, } from './platform-permit.js';
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';
@@ -1,14 +1,7 @@
1
1
  /**
2
- * Discord platform permit — Guild 权限位
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;
@@ -1,42 +1,9 @@
1
- const ADAPTER = 'discord';
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(${ADAPTER},${perm})`;
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 user = interaction.member?.user
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: interaction.guild_id != null ? String(interaction.guild_id) : undefined,
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: IncomingMessage, response: ServerResponse, handler: DiscordInteractionsHandler): Promise<void>;
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, async (request, response) => {
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, response, handler) {
25
+ export async function handleDiscordInteractionRequest(request, handler) {
24
26
  try {
25
- const signature = headerValue(request.headers['x-signature-ed25519']);
26
- const timestamp = headerValue(request.headers['x-signature-timestamp']);
27
- const rawBody = await readInteractionBody(request);
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
- response.writeHead(401, { 'Content-Type': 'text/plain' });
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
- response.writeHead(401, { 'Content-Type': 'text/plain' });
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
- response.writeHead(401, { 'Content-Type': 'text/plain' });
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(response, 200, { type: INTERACTION_RESPONSE_PONG });
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
- response.writeHead(503);
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(response, 200, { type: 6 });
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(response, 200, {
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
- response.writeHead(400, { 'Content-Type': 'text/plain' });
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
- if (!response.headersSent) {
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(response, status, body) {
118
- response.writeHead(status, { 'Content-Type': 'application/json' });
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.27",
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
- "@zhin.js/adapter": "1.1.32",
39
- "@zhin.js/console-contract": "1.1.5",
40
- "@zhin.js/feature-kit": "1.1.15",
41
- "@zhin.js/core": "1.1.60",
42
- "@zhin.js/host-http": "1.1.17",
43
- "@zhin.js/im-contract": "1.1.13",
44
- "@zhin.js/logger": "1.1.2",
45
- "@zhin.js/skill": "1.1.16",
46
- "@zhin.js/agent-feature": "1.1.16",
47
- "@zhin.js/permission": "1.1.15",
48
- "@zhin.js/tool": "1.1.18",
49
- "@zhin.js/page": "1.1.17"
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
- "zod": "^4.0.0",
53
- "@zhin.js/command": "^1.1.17",
54
- "zhin.js": "^1.1.25",
55
- "@zhin.js/adapter": "^1.1.32",
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
- "zod": "^4.4.3",
74
- "@zhin.js/host-http": "1.1.17",
75
- "zhin.js": "1.1.25"
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": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/discord/tests"
132
+ "test": "vitest run --root ../../.. plugins/adapters/discord/tests"
135
133
  }
136
134
  }