@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/lib/endpoint.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { createDiscordSelfProfile, createDiscordRestSelfProfile } from './self-profile.js';
2
2
  import { DiscordRestRequestError } from './rest-request-error.js';
3
+ import { isDiscordChannelObfuscated } from './channel-visibility.js';
3
4
  /**
4
5
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
5
6
  */
6
7
  import { Client, ChannelType } from 'discord.js';
7
8
  import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createEndpointOperation, } from 'zhin.js/adapter';
8
9
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
- import { connectDiscordGatewayClient, discordClientOptions, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
10
- import { discordInboundConversation, formatButtonContent, formatButtonSegments, formatInboundContent, formatInboundSegments, formatOutboundBody, senderDisplayName, } from './protocol.js';
10
+ import { connectDiscordGatewayClient, discordClientOptions, DEFAULT_INTENTS, toMessageCreateOptions, } from './gateway.js';
11
+ import { discordChannelOf, discordInboundConversation, formatButtonContent, formatButtonSegments, formatInboundContent, formatInboundSegments, formatOutboundBody, senderDisplayName, } from './protocol.js';
11
12
  import { registerDiscordInteractionRoutes } from './webhook.js';
12
13
  import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
14
+ import { discordGuildPermissionNamesFromChannelBitfield, discordPermissionNames, discordSenderStanding, discordStandingFromPermissions, } from './sender-standing.js';
13
15
  const DISCORD_API = 'https://discord.com/api/v10';
14
16
  /** 出站 HTTP 调用统一 30s 超时。 */
15
17
  const OUTBOUND_TIMEOUT_MS = 30_000;
@@ -137,11 +139,11 @@ export class DiscordGatewayEndpoint extends Client {
137
139
  }
138
140
  async #sendFramework({ conversation, payload, signal }) {
139
141
  const body = formatOutboundBody(payload);
140
- const snowflake = await this.#sendBody({ channelId: conversation.id, body, signal });
142
+ const snowflake = await this.#sendBody({ channelId: discordChannelOf(conversation), body, signal });
141
143
  this.#logger.debug(formatCompact({
142
144
  op: 'discord_send',
143
145
  endpoint: this.#options.config.id,
144
- target: conversation.id,
146
+ target: discordChannelOf(conversation),
145
147
  messageId: snowflake,
146
148
  }));
147
149
  return snowflake;
@@ -150,7 +152,7 @@ export class DiscordGatewayEndpoint extends Client {
150
152
  if (!message.id)
151
153
  return;
152
154
  const operation = createEndpointOperation({ signal });
153
- const channel = await operation.prepare(() => this.#requireClient().channels.fetch(message.conversation.id));
155
+ const channel = await operation.prepare(() => this.#requireClient().channels.fetch(discordChannelOf(message.conversation)));
154
156
  if (!channel?.isTextBased() || !channel.messages)
155
157
  return;
156
158
  const msg = await operation.prepare(() => channel.messages.fetch(message.id));
@@ -163,6 +165,7 @@ export class DiscordGatewayEndpoint extends Client {
163
165
  if (msg.authorBot)
164
166
  return;
165
167
  const conversation = discordInboundConversation(String(this.#options.id), msg);
168
+ const platform = discordSenderStanding(msg);
166
169
  void this[endpointSpec].events.emit('message.receive', {
167
170
  conversation,
168
171
  event: msg.event,
@@ -173,7 +176,7 @@ export class DiscordGatewayEndpoint extends Client {
173
176
  sender: {
174
177
  id: msg.authorId,
175
178
  name: senderDisplayName(msg) || undefined,
176
- ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)] } : {}),
179
+ ...(platform ? { platform } : {}),
177
180
  },
178
181
  endpointId: this.#options.config.id,
179
182
  ...(msg.mentionedBot ? { mentioned: true } : {}),
@@ -183,7 +186,7 @@ export class DiscordGatewayEndpoint extends Client {
183
186
  userId: msg.authorId,
184
187
  guildId: msg.guildId,
185
188
  permissions: msg.permissionTokens,
186
- role: resolveSenderRole(msg),
189
+ role: platform?.standing,
187
190
  }),
188
191
  }).catch((err) => {
189
192
  this.#logger.warn(formatCompact({
@@ -221,7 +224,7 @@ export class DiscordGatewayEndpoint extends Client {
221
224
  });
222
225
  }
223
226
  async #addReaction({ message, emoji, hint, signal }) {
224
- const channelId = hint?.channelId ?? message.conversation.id;
227
+ const channelId = hint?.channelId ?? discordChannelOf(message.conversation);
225
228
  if (!channelId || !message.id)
226
229
  return null;
227
230
  const operation = createEndpointOperation({ signal });
@@ -325,6 +328,8 @@ export class DiscordRestClient {
325
328
  return this.request({ method: 'DELETE', path: `/channels/${channelId}/messages/${messageId}`, signal });
326
329
  }
327
330
  }
331
+ const APP_PERMISSIONS_TTL_MS = 10 * 60_000;
332
+ const APP_PERMISSIONS_MAX = 1_000;
328
333
  export class DiscordInteractionsEndpoint extends DiscordRestClient {
329
334
  [endpointSpec];
330
335
  #logger;
@@ -333,6 +338,8 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient {
333
338
  #routeReleases = [];
334
339
  #open = false;
335
340
  #started = false;
341
+ /** Latest interaction `app_permissions` per guild (guild-level names), for the Bot's own standing. */
342
+ #appPermissions = new Map();
336
343
  #control = Object.freeze({
337
344
  recall: request => this.#recallMessage(request),
338
345
  });
@@ -347,6 +354,12 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient {
347
354
  this[endpointSpec] = createEndpointSpecification(this, {
348
355
  start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
349
356
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
357
+ // Interactions mode has no gateway session to read the bot's guild member: its own standing
358
+ // comes from the latest interaction's `app_permissions` in that guild (ADR 0056 §1), reduced
359
+ // to guild-level names like senders; without a recent interaction it stays unknown.
360
+ management: Object.freeze({
361
+ inspectSelfStanding: async ({ conversation }) => this.#selfStandingFromInteractions(conversation),
362
+ }),
350
363
  control: this.#control, content: this.#content,
351
364
  profile: createDiscordRestSelfProfile(this, id => this[endpointSpec].reportSelfUserId(id)),
352
365
  });
@@ -384,18 +397,41 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient {
384
397
  }
385
398
  async #sendFramework({ conversation, payload, signal }) {
386
399
  const body = formatOutboundBody(payload);
387
- const data = await this.createMessage({ channelId: conversation.id, body, signal });
400
+ const data = await this.createMessage({ channelId: discordChannelOf(conversation), body, signal });
388
401
  return data.id ?? '';
389
402
  }
390
403
  async #recallMessage({ message, signal }) {
391
404
  if (!message.id)
392
405
  return;
393
- await this.deleteMessage({ channelId: message.conversation.id, messageId: message.id, signal });
406
+ await this.deleteMessage({ channelId: discordChannelOf(message.conversation), messageId: message.id, signal });
407
+ }
408
+ #selfStandingFromInteractions(conversation) {
409
+ const guildId = conversation.kind === 'channel' && conversation.parent?.kind === 'channel' ? conversation.parent.id : undefined;
410
+ const entry = guildId ? this.#appPermissions.get(guildId) : undefined;
411
+ if (!entry || Date.now() - entry.at > APP_PERMISSIONS_TTL_MS)
412
+ return undefined;
413
+ // The guild owner is not visible to an interaction; `administrator` still reads as `admin`.
414
+ return { standing: discordStandingFromPermissions(entry.names), ...(entry.names.length ? { permissions: entry.names } : {}) };
415
+ }
416
+ #recordAppPermissions(msg) {
417
+ const appPermissions = msg.event?.app_permissions;
418
+ if (!msg.guildId || appPermissions === undefined)
419
+ return;
420
+ this.#appPermissions.delete(msg.guildId);
421
+ this.#appPermissions.set(msg.guildId, { names: discordGuildPermissionNamesFromChannelBitfield(appPermissions), at: Date.now() });
422
+ while (this.#appPermissions.size > APP_PERMISSIONS_MAX) {
423
+ const oldest = this.#appPermissions.keys().next().value;
424
+ if (oldest === undefined)
425
+ break;
426
+ this.#appPermissions.delete(oldest);
427
+ }
394
428
  }
395
429
  admit(msg) {
396
430
  if (!this.#open)
397
431
  return;
432
+ this.#recordAppPermissions(msg);
398
433
  const conversation = discordInboundConversation(String(this.#options.id), msg);
434
+ const platform = discordSenderStanding(msg);
399
435
  void this[endpointSpec].events.emit('message.receive', {
400
436
  conversation,
401
437
  event: msg.event,
@@ -406,7 +442,7 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient {
406
442
  sender: {
407
443
  id: msg.authorId,
408
444
  name: senderDisplayName(msg) || undefined,
409
- ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)] } : {}),
445
+ ...(platform ? { platform } : {}),
410
446
  },
411
447
  endpointId: this.#options.config.id,
412
448
  metadata: Object.freeze({
@@ -474,7 +510,7 @@ async function resolveDiscordContent(fetch, token, reference, context) {
474
510
  }
475
511
  try {
476
512
  context.signal.throwIfAborted();
477
- const response = await fetch(`${DISCORD_API}/channels/${reference.message.conversation.id}/messages/${reference.message.id}`, { headers: { Authorization: `Bot ${token}` }, signal: context.signal });
513
+ const response = await fetch(`${DISCORD_API}/channels/${discordChannelOf(reference.message.conversation)}/messages/${reference.message.id}`, { headers: { Authorization: `Bot ${token}` }, signal: context.signal });
478
514
  if (response.status === 404)
479
515
  return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
480
516
  if (response.status === 403)
@@ -515,9 +551,34 @@ async function resolveDiscordContent(fetch, token, reference, context) {
515
551
  /**
516
552
  * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
517
553
  * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
554
+ * listChannels 会过滤带 CHANNEL_OBFUSCATED flag 的混淆频道(见 channel-visibility.ts)。
518
555
  */
519
556
  export function createDiscordEndpointManagement(requireClient) {
520
557
  return Object.freeze({
558
+ /**
559
+ * ADR 0056 §1: the Bot's own guild member in the conversation's guild — guild owner → `owner`;
560
+ * `administrator` / `moderate_members` → `admin`; roles and **guild-level** permission names
561
+ * (`GuildMember.permissions`, as for senders) so `bot-permission:*` and `platform-permission:*`
562
+ * read the same scope. Channel overwrites are left to the platform at execution time.
563
+ */
564
+ async inspectSelfStanding({ conversation, signal }) {
565
+ if (conversation.kind === 'private')
566
+ return undefined;
567
+ const operation = createEndpointOperation({ signal });
568
+ const client = requireClient();
569
+ const channel = await operation.run(() => client.channels.fetch(conversation.threadId ?? conversation.id));
570
+ if (!channel || !('guild' in channel) || !client.user)
571
+ return undefined;
572
+ const guild = channel.guild;
573
+ const me = guild.members.me ?? await operation.run(() => guild.members.fetchMe());
574
+ const permissions = me.permissions;
575
+ const names = permissions ? discordPermissionNames(bit => permissions.has(bit)) : [];
576
+ const roles = [...me.roles.cache.values()]
577
+ .filter(role => role.id !== guild.id)
578
+ .map(role => (role.name ? { id: String(role.id), name: String(role.name) } : { id: String(role.id) }));
579
+ const standing = discordStandingFromPermissions(names, guild.ownerId === client.user.id);
580
+ return { standing, ...(roles.length ? { roles } : {}), ...(names.length ? { permissions: names } : {}) };
581
+ },
521
582
  async listGroups(request = {}) {
522
583
  return createEndpointOperation(request).run(() => {
523
584
  const groups = [];
@@ -545,6 +606,9 @@ export function createDiscordEndpointManagement(requireClient) {
545
606
  continue;
546
607
  if (channel.type !== ChannelType.GuildText)
547
608
  continue;
609
+ // 机器人无 VIEW_CHANNEL 的频道会被 Gateway 混淆下发(HTTP 列表已不返回),默认过滤以保持两条路径一致。
610
+ if (isDiscordChannelObfuscated(channel))
611
+ continue;
548
612
  channels.push({
549
613
  id: String(channel.id),
550
614
  name: channel.name ? String(channel.name) : undefined,
package/lib/gateway.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare const DEFAULT_INTENTS: GatewayIntentBits[];
5
5
  /** Native SDK surface; tests inject transport below the Client rather than replacing its identity. */
6
6
  export type DiscordClientTransport = Client;
7
7
  export declare function discordClientOptions(intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions?: Partial<RESTOptions>): ClientOptions;
8
+ /** Normalized standing word (`owner` / `admin` / `member`); `undefined` outside a guild. */
8
9
  export declare function resolveSenderRole(msg: DiscordInboundMessage): string | undefined;
9
10
  export declare function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | null;
10
11
  export declare function toMessageCreateOptions(body: DiscordOutboundBody): Promise<MessageCreateOptions>;
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
+ }