@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/LICENSE +21 -0
  3. package/PERMITS.md +15 -14
  4. package/README.md +7 -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/channel-visibility.d.ts +9 -0
  27. package/lib/channel-visibility.js +44 -0
  28. package/lib/endpoint.d.ts +1 -0
  29. package/lib/endpoint.js +76 -12
  30. package/lib/gateway.d.ts +1 -0
  31. package/lib/gateway.js +21 -8
  32. package/lib/index.d.ts +1 -1
  33. package/lib/index.js +1 -1
  34. package/lib/platform-permit.d.ts +4 -11
  35. package/lib/platform-permit.js +7 -40
  36. package/lib/protocol.d.ts +18 -0
  37. package/lib/protocol.js +32 -3
  38. package/lib/sender-standing.d.ts +18 -0
  39. package/lib/sender-standing.js +91 -0
  40. package/lib/webhook.d.ts +2 -6
  41. package/lib/webhook.js +23 -55
  42. package/package.json +23 -25
  43. package/plugin.js +0 -6
  44. package/schema.json +0 -56
  45. package/src/channel-visibility.ts +44 -0
  46. package/src/endpoint.ts +77 -11
  47. package/src/gateway.ts +22 -5
  48. package/src/index.ts +1 -6
  49. package/src/platform-permit.ts +5 -52
  50. package/src/protocol.ts +43 -3
  51. package/src/sender-standing.ts +91 -0
  52. 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
- if (msg.isGuildOwner) return 'owner';
51
- const tokens = msg.permissionTokens ?? [];
52
- if (tokens.includes('ADMINISTRATOR') || tokens.includes('MODERATE_MEMBERS')) return 'admin';
53
- if (msg.guildId) return 'member';
54
- return undefined;
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,
@@ -1,57 +1,10 @@
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
-
6
- const ADAPTER = 'discord';
7
7
 
8
8
  export function platformPermit(perm: string): string {
9
- return `platform(${ADAPTER},${perm})`;
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 user = (interaction.member as { user?: Record<string, unknown> } | undefined)?.user
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: interaction.guild_id != null ? String(interaction.guild_id) : undefined,
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 { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
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, async (request, response) => {
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: IncomingMessage,
51
- response: ServerResponse,
47
+ request: Request,
52
48
  handler: DiscordInteractionsHandler,
53
- ): Promise<void> {
49
+ ): Promise<Response> {
54
50
  try {
55
- const signature = headerValue(request.headers['x-signature-ed25519']);
56
- const timestamp = headerValue(request.headers['x-signature-timestamp']);
57
- const rawBody = await readInteractionBody(request);
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
- response.writeHead(401, { 'Content-Type': 'text/plain' });
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
- response.writeHead(401, { 'Content-Type': 'text/plain' });
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
- response.writeHead(401, { 'Content-Type': 'text/plain' });
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(response, 200, { type: INTERACTION_RESPONSE_PONG });
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) { response.writeHead(503); response.end('Unavailable'); return; }
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(response, 200, { type: 6 });
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(response, 200, {
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
- response.writeHead(400, { 'Content-Type': 'text/plain' });
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
- if (!response.headersSent) {
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(response: ServerResponse, status: number, body: unknown): void {
150
- response.writeHead(status, { 'Content-Type': 'application/json' });
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
  }