@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/plugin.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { definePlugin } from 'zhin.js';
3
- import { permissionHostToken } from '@zhin.js/permission';
4
- import { checkDiscordPlatformPermit } from "./lib/platform-permit.js";
5
3
  import { createDiscordConsoleRpc } from "./lib/console.js";
6
4
  export default definePlugin({
7
5
  name: 'discord',
@@ -10,12 +8,8 @@ export default definePlugin({
10
8
  displayName: 'Discord Gateway Adapter',
11
9
  },
12
10
  setup(context) {
13
- const dispose = context.resources.has(permissionHostToken)
14
- ? context.resources.use(permissionHostToken).registerPlatform('discord', checkDiscordPlatformPermit)
15
- : undefined;
16
11
  return {
17
12
  consoleRpc: createDiscordConsoleRpc(() => context.config.get()),
18
- ...(dispose ? { dispose } : {}),
19
13
  };
20
14
  },
21
15
  });
package/schema.json CHANGED
@@ -71,34 +71,6 @@
71
71
  "default": "/discord/interactions",
72
72
  "description": "POST path on httpHostToken when connection is interactions."
73
73
  },
74
- "master": {
75
- "type": [
76
- "string",
77
- "number"
78
- ],
79
- "description": "框架 master(Discord user snowflake;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
80
- "x-i18n": {
81
- "en": {
82
- "description": "Framework masters (Discord user snowflake; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
83
- }
84
- }
85
- },
86
- "trusted": {
87
- "type": "array",
88
- "items": {
89
- "type": [
90
- "string",
91
- "number"
92
- ],
93
- "description": "Trusted Discord user snowflake"
94
- },
95
- "description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
96
- "x-i18n": {
97
- "en": {
98
- "description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
99
- }
100
- }
101
- },
102
74
  "endpoints": {
103
75
  "type": "array",
104
76
  "minItems": 1,
@@ -107,34 +79,6 @@
107
79
  "type": "object",
108
80
  "additionalProperties": false,
109
81
  "properties": {
110
- "master": {
111
- "type": [
112
- "string",
113
- "number"
114
- ],
115
- "description": "本 endpoint 的框架 master(Discord user snowflake);覆盖顶层 master",
116
- "x-i18n": {
117
- "en": {
118
- "description": "Framework masters for this endpoint (Discord user snowflake), overriding the top-level master list."
119
- }
120
- }
121
- },
122
- "trusted": {
123
- "type": "array",
124
- "items": {
125
- "type": [
126
- "string",
127
- "number"
128
- ],
129
- "description": "Trusted Discord user snowflake"
130
- },
131
- "description": "本 endpoint 的 trusted 列表",
132
- "x-i18n": {
133
- "en": {
134
- "description": "Trusted users for this endpoint."
135
- }
136
- }
137
- },
138
82
  "token": {
139
83
  "type": "string",
140
84
  "description": "Discord bot token"
package/src/endpoint.ts CHANGED
@@ -28,8 +28,11 @@ import {
28
28
  } from 'zhin.js/adapter';
29
29
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
30
30
  import {
31
+ type ConversationRef,
31
32
  type ConversationReference,
32
33
  type ConversationResolution,
34
+ type PlatformStanding,
35
+ type SelfStandingRequest,
33
36
  } from '@zhin.js/im-contract';
34
37
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
35
38
  import type { CapabilityId } from 'zhin.js';
@@ -37,11 +40,11 @@ import {
37
40
  connectDiscordGatewayClient,
38
41
  discordClientOptions,
39
42
  DEFAULT_INTENTS,
40
- resolveSenderRole,
41
43
  toMessageCreateOptions,
42
44
  type DiscordClientTransport,
43
45
  } from './gateway.js';
44
46
  import {
47
+ discordChannelOf,
45
48
  discordInboundConversation,
46
49
  formatButtonContent,
47
50
  formatButtonSegments,
@@ -57,6 +60,9 @@ import {
57
60
  } from './protocol.js';
58
61
  import { registerDiscordInteractionRoutes } from './webhook.js';
59
62
  import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
63
+ import {
64
+ discordGuildPermissionNamesFromChannelBitfield, discordPermissionNames, discordSenderStanding, discordStandingFromPermissions,
65
+ } from './sender-standing.js';
60
66
 
61
67
  const DISCORD_API = 'https://discord.com/api/v10';
62
68
  /** 出站 HTTP 调用统一 30s 超时。 */
@@ -202,11 +208,11 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
202
208
 
203
209
  async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
204
210
  const body = formatOutboundBody(payload);
205
- const snowflake = await this.#sendBody({ channelId: conversation.id, body, signal });
211
+ const snowflake = await this.#sendBody({ channelId: discordChannelOf(conversation), body, signal });
206
212
  this.#logger.debug(formatCompact({
207
213
  op: 'discord_send',
208
214
  endpoint: this.#options.config.id,
209
- target: conversation.id,
215
+ target: discordChannelOf(conversation),
210
216
  messageId: snowflake,
211
217
  }));
212
218
  return snowflake;
@@ -215,7 +221,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
215
221
  async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
216
222
  if (!message.id) return;
217
223
  const operation = createEndpointOperation({ signal });
218
- const channel = await operation.prepare(() => this.#requireClient().channels.fetch(message.conversation.id));
224
+ const channel = await operation.prepare(() => this.#requireClient().channels.fetch(discordChannelOf(message.conversation)));
219
225
  if (!channel?.isTextBased() || !channel.messages) return;
220
226
  const msg = await operation.prepare(() => channel.messages.fetch(message.id));
221
227
  await operation.run(() => msg.delete());
@@ -226,6 +232,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
226
232
  if (!this.#open) return;
227
233
  if (msg.authorBot) return;
228
234
  const conversation = discordInboundConversation(String(this.#options.id), msg);
235
+ const platform = discordSenderStanding(msg);
229
236
  void this[endpointSpec].events.emit('message.receive', {
230
237
  conversation,
231
238
  event: msg.event,
@@ -236,7 +243,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
236
243
  sender: {
237
244
  id: msg.authorId,
238
245
  name: senderDisplayName(msg) || undefined,
239
- ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)!] } : {}),
246
+ ...(platform ? { platform } : {}),
240
247
  },
241
248
  endpointId: this.#options.config.id,
242
249
  ...(msg.mentionedBot ? { mentioned: true } : {}),
@@ -246,7 +253,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
246
253
  userId: msg.authorId,
247
254
  guildId: msg.guildId,
248
255
  permissions: msg.permissionTokens,
249
- role: resolveSenderRole(msg),
256
+ role: platform?.standing,
250
257
  }),
251
258
  }).catch((err) => {
252
259
  this.#logger.warn(formatCompact({
@@ -285,7 +292,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
285
292
  }
286
293
 
287
294
  async #addReaction({ message, emoji, hint, signal }: EndpointReactionRequest): Promise<string | null> {
288
- const channelId = hint?.channelId ?? message.conversation.id;
295
+ const channelId = hint?.channelId ?? discordChannelOf(message.conversation);
289
296
  if (!channelId || !message.id) return null;
290
297
  const operation = createEndpointOperation({ signal });
291
298
  const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
@@ -406,6 +413,9 @@ export class DiscordRestClient {
406
413
  }
407
414
  }
408
415
 
416
+ const APP_PERMISSIONS_TTL_MS = 10 * 60_000;
417
+ const APP_PERMISSIONS_MAX = 1_000;
418
+
409
419
  export class DiscordInteractionsEndpoint extends DiscordRestClient implements Endpoint {
410
420
  readonly [endpointSpec]: EndpointSpecification;
411
421
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
@@ -415,6 +425,8 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
415
425
  #routeReleases: HttpRouteRegistration[] = [];
416
426
  #open = false;
417
427
  #started = false;
428
+ /** Latest interaction `app_permissions` per guild (guild-level names), for the Bot's own standing. */
429
+ readonly #appPermissions = new Map<string, { readonly names: readonly string[]; readonly at: number }>();
418
430
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
419
431
  recall: request => this.#recallMessage(request),
420
432
  });
@@ -431,6 +443,12 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
431
443
  this[endpointSpec] = createEndpointSpecification(this, {
432
444
  start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
433
445
  stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
446
+ // Interactions mode has no gateway session to read the bot's guild member: its own standing
447
+ // comes from the latest interaction's `app_permissions` in that guild (ADR 0056 §1), reduced
448
+ // to guild-level names like senders; without a recent interaction it stays unknown.
449
+ management: Object.freeze<EndpointManagement>({
450
+ inspectSelfStanding: async ({ conversation }) => this.#selfStandingFromInteractions(conversation),
451
+ }),
434
452
  control: this.#control, content: this.#content,
435
453
  profile: createDiscordRestSelfProfile(this, id => this[endpointSpec].reportSelfUserId(id)),
436
454
  });
@@ -473,18 +491,40 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
473
491
 
474
492
  async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
475
493
  const body = formatOutboundBody(payload);
476
- const data = await this.createMessage({ channelId: conversation.id, body, signal });
494
+ const data = await this.createMessage({ channelId: discordChannelOf(conversation), body, signal });
477
495
  return data.id ?? '';
478
496
  }
479
497
 
480
498
  async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
481
499
  if (!message.id) return;
482
- await this.deleteMessage({ channelId: message.conversation.id, messageId: message.id, signal });
500
+ await this.deleteMessage({ channelId: discordChannelOf(message.conversation), messageId: message.id, signal });
501
+ }
502
+
503
+ #selfStandingFromInteractions(conversation: Pick<ConversationRef, 'kind' | 'parent'>): PlatformStanding | undefined {
504
+ const guildId = conversation.kind === 'channel' && conversation.parent?.kind === 'channel' ? conversation.parent.id : undefined;
505
+ const entry = guildId ? this.#appPermissions.get(guildId) : undefined;
506
+ if (!entry || Date.now() - entry.at > APP_PERMISSIONS_TTL_MS) return undefined;
507
+ // The guild owner is not visible to an interaction; `administrator` still reads as `admin`.
508
+ return { standing: discordStandingFromPermissions(entry.names), ...(entry.names.length ? { permissions: entry.names } : {}) };
509
+ }
510
+
511
+ #recordAppPermissions(msg: DiscordInboundMessage): void {
512
+ const appPermissions = (msg.event as { app_permissions?: unknown } | undefined)?.app_permissions;
513
+ if (!msg.guildId || appPermissions === undefined) return;
514
+ this.#appPermissions.delete(msg.guildId);
515
+ this.#appPermissions.set(msg.guildId, { names: discordGuildPermissionNamesFromChannelBitfield(appPermissions), at: Date.now() });
516
+ while (this.#appPermissions.size > APP_PERMISSIONS_MAX) {
517
+ const oldest = this.#appPermissions.keys().next().value;
518
+ if (oldest === undefined) break;
519
+ this.#appPermissions.delete(oldest);
520
+ }
483
521
  }
484
522
 
485
523
  admit(msg: DiscordInboundMessage): void {
486
524
  if (!this.#open) return;
525
+ this.#recordAppPermissions(msg);
487
526
  const conversation = discordInboundConversation(String(this.#options.id), msg);
527
+ const platform = discordSenderStanding(msg);
488
528
  void this[endpointSpec].events.emit('message.receive', {
489
529
  conversation,
490
530
  event: msg.event,
@@ -495,7 +535,7 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
495
535
  sender: {
496
536
  id: msg.authorId,
497
537
  name: senderDisplayName(msg) || undefined,
498
- ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)!] } : {}),
538
+ ...(platform ? { platform } : {}),
499
539
  },
500
540
  endpointId: this.#options.config.id,
501
541
  metadata: Object.freeze({
@@ -570,7 +610,7 @@ async function resolveDiscordContent(
570
610
  try {
571
611
  context.signal.throwIfAborted();
572
612
  const response = await fetch(
573
- `${DISCORD_API}/channels/${reference.message.conversation.id}/messages/${reference.message.id}`,
613
+ `${DISCORD_API}/channels/${discordChannelOf(reference.message.conversation)}/messages/${reference.message.id}`,
574
614
  { headers: { Authorization: `Bot ${token}` }, signal: context.signal },
575
615
  );
576
616
  if (response.status === 404) return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
@@ -613,6 +653,28 @@ export function createDiscordEndpointManagement(
613
653
  requireClient: () => DiscordClientTransport,
614
654
  ): EndpointManagement {
615
655
  return Object.freeze<EndpointManagement>({
656
+ /**
657
+ * ADR 0056 §1: the Bot's own guild member in the conversation's guild — guild owner → `owner`;
658
+ * `administrator` / `moderate_members` → `admin`; roles and **guild-level** permission names
659
+ * (`GuildMember.permissions`, as for senders) so `bot-permission:*` and `platform-permission:*`
660
+ * read the same scope. Channel overwrites are left to the platform at execution time.
661
+ */
662
+ async inspectSelfStanding({ conversation, signal }: SelfStandingRequest): Promise<PlatformStanding | undefined> {
663
+ if (conversation.kind === 'private') return undefined;
664
+ const operation = createEndpointOperation({ signal });
665
+ const client = requireClient();
666
+ const channel = await operation.run(() => client.channels.fetch(conversation.threadId ?? conversation.id));
667
+ if (!channel || !('guild' in channel) || !client.user) return undefined;
668
+ const guild = channel.guild;
669
+ const me = guild.members.me ?? await operation.run(() => guild.members.fetchMe());
670
+ const permissions = me.permissions;
671
+ const names = permissions ? discordPermissionNames(bit => permissions.has(bit)) : [];
672
+ const roles = [...me.roles.cache.values()]
673
+ .filter(role => role.id !== guild.id)
674
+ .map(role => (role.name ? { id: String(role.id), name: String(role.name) } : { id: String(role.id) }));
675
+ const standing = discordStandingFromPermissions(names, guild.ownerId === client.user.id);
676
+ return { standing, ...(roles.length ? { roles } : {}), ...(names.length ? { permissions: names } : {}) };
677
+ },
616
678
  async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
617
679
  return createEndpointOperation(request).run(() => {
618
680
  const groups: EndpointGroup[] = [];
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
+ }