@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.
- package/CHANGELOG.md +169 -0
- package/LICENSE +21 -0
- package/PERMITS.md +15 -14
- package/README.md +6 -2
- package/adapters/discord/index.js +3 -0
- package/adapters/discord/index.ts +3 -0
- package/agents/discord/conversation.js +70 -0
- package/agents/discord/conversation.ts +77 -0
- package/agents/discord/messages.js +18 -2
- package/agents/discord/messages.ts +18 -2
- package/agents/discord/skills/discord/SKILL.md +1 -1
- package/agents/discord/skills/discord/tools/react/index.js +7 -6
- package/agents/discord/skills/discord/tools/react/index.ts +8 -7
- package/agents/discord/skills/discord/tools/send_embed/index.js +6 -11
- package/agents/discord/skills/discord/tools/send_embed/index.ts +7 -12
- package/agents/discord/skills/discord-guild/tools/add_role/index.js +11 -9
- package/agents/discord/skills/discord-guild/tools/add_role/index.ts +11 -10
- package/agents/discord/skills/discord-guild/tools/create_thread/index.js +5 -5
- package/agents/discord/skills/discord-guild/tools/create_thread/index.ts +6 -6
- package/agents/discord/skills/discord-guild/tools/forum_post/index.js +7 -3
- package/agents/discord/skills/discord-guild/tools/forum_post/index.ts +7 -4
- package/agents/discord/skills/discord-guild/tools/list_roles/index.js +7 -8
- package/agents/discord/skills/discord-guild/tools/list_roles/index.ts +8 -9
- package/agents/discord/skills/discord-guild/tools/remove_role/index.js +11 -9
- package/agents/discord/skills/discord-guild/tools/remove_role/index.ts +11 -10
- package/lib/endpoint.js +71 -12
- package/lib/gateway.d.ts +1 -0
- package/lib/gateway.js +21 -8
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/platform-permit.d.ts +4 -11
- package/lib/platform-permit.js +7 -40
- package/lib/protocol.d.ts +18 -0
- package/lib/protocol.js +32 -3
- package/lib/sender-standing.d.ts +18 -0
- package/lib/sender-standing.js +91 -0
- package/lib/webhook.d.ts +2 -6
- package/lib/webhook.js +23 -55
- package/package.json +23 -25
- package/plugin.js +0 -6
- package/schema.json +0 -56
- package/src/endpoint.ts +73 -11
- package/src/gateway.ts +22 -5
- package/src/index.ts +1 -6
- package/src/platform-permit.ts +5 -52
- package/src/protocol.ts +43 -3
- package/src/sender-standing.ts +91 -0
- package/src/webhook.ts +21 -58
package/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
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
return discordSenderStanding(msg)?.standing;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A discord.js thread channel's parent channel id (threads share their parent's conversation key, ADR 0056 §2). */
|
|
56
|
+
function threadParentOf(channel: unknown): string | undefined {
|
|
57
|
+
const thread = channel as { isThread?: () => boolean; parentId?: string | null } | null | undefined;
|
|
58
|
+
return thread && typeof thread.isThread === 'function' && thread.isThread() && thread.parentId ? thread.parentId : undefined;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | null {
|
|
@@ -60,6 +64,8 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
60
64
|
if (!msg.author || !msg.channel) return null;
|
|
61
65
|
|
|
62
66
|
const permissionTokens: string[] = [];
|
|
67
|
+
let platformPermissions: string[] = [];
|
|
68
|
+
let authorRoles: { id: string; name?: string }[] = [];
|
|
63
69
|
let isGuildOwner = false;
|
|
64
70
|
const member = msg.member;
|
|
65
71
|
const guild = msg.guild;
|
|
@@ -74,6 +80,13 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
74
80
|
for (const [bit, name] of checks) {
|
|
75
81
|
if (member.permissions.has(bit)) permissionTokens.push(name);
|
|
76
82
|
}
|
|
83
|
+
platformPermissions = discordPermissionNames(bit => member.permissions.has(bit));
|
|
84
|
+
const cache = (member.roles as { cache?: { values?: () => Iterable<{ id: string; name?: string }> } } | undefined)?.cache;
|
|
85
|
+
if (cache && typeof cache.values === 'function') {
|
|
86
|
+
authorRoles = [...cache.values()]
|
|
87
|
+
.filter((role) => role && role.id && role.id !== guild.id)
|
|
88
|
+
.map((role) => (role.name ? { id: String(role.id), name: String(role.name) } : { id: String(role.id) }));
|
|
89
|
+
}
|
|
77
90
|
if (guild.ownerId === msg.author.id) {
|
|
78
91
|
isGuildOwner = true;
|
|
79
92
|
permissionTokens.push('guild_owner', 'ADMINISTRATOR');
|
|
@@ -87,6 +100,7 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
87
100
|
content: msg.content ?? '',
|
|
88
101
|
channelId: msg.channel.id,
|
|
89
102
|
channelKind: resolveChannelKind(msg.channel.type),
|
|
103
|
+
...(threadParentOf(msg.channel) ? { threadParentId: threadParentOf(msg.channel)! } : {}),
|
|
90
104
|
authorId: msg.author.id,
|
|
91
105
|
authorName: member?.displayName || msg.author.displayName || msg.author.username,
|
|
92
106
|
authorBot: msg.author.bot,
|
|
@@ -94,6 +108,8 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
94
108
|
guildId: guild?.id,
|
|
95
109
|
isGuildOwner,
|
|
96
110
|
permissionTokens,
|
|
111
|
+
...(platformPermissions.length ? { platformPermissions } : {}),
|
|
112
|
+
...(authorRoles.length ? { authorRoles } : {}),
|
|
97
113
|
attachments: [...msg.attachments.values()].map((a) => ({
|
|
98
114
|
id: a.id,
|
|
99
115
|
name: a.name ?? undefined,
|
|
@@ -292,6 +308,7 @@ export async function connectDiscordGatewayClient(
|
|
|
292
308
|
customId: interaction.customId,
|
|
293
309
|
channelId,
|
|
294
310
|
channelKind: interaction.channel ? resolveChannelKind(interaction.channel.type) : interaction.guildId ? 'channel' : 'private',
|
|
311
|
+
...(threadParentOf(interaction.channel) ? { threadParentId: threadParentOf(interaction.channel)! } : {}),
|
|
295
312
|
...(interaction.guildId ? { guildId: interaction.guildId } : {}),
|
|
296
313
|
userId: interaction.user.id,
|
|
297
314
|
userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
|
package/src/index.ts
CHANGED
|
@@ -24,12 +24,7 @@ export {
|
|
|
24
24
|
type DiscordClientEventMap,
|
|
25
25
|
} from './client.js';
|
|
26
26
|
|
|
27
|
-
export {
|
|
28
|
-
checkDiscordPlatformPermit,
|
|
29
|
-
discordGroupPermitResolver,
|
|
30
|
-
normalizeDiscordSenderForPermit,
|
|
31
|
-
platformPermit,
|
|
32
|
-
} from './platform-permit.js';
|
|
27
|
+
export { platformPermit } from './platform-permit.js';
|
|
33
28
|
|
|
34
29
|
export {
|
|
35
30
|
DiscordGatewayEndpoint,
|
package/src/platform-permit.ts
CHANGED
|
@@ -1,57 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Discord platform permit
|
|
2
|
+
* Discord platform permit helper(ADR 0056 §5)。
|
|
3
|
+
*
|
|
4
|
+
* 平台身份由适配器上报到 `sender.platform`,permit 由内置评估器(`@zhin.js/permission`)判断;
|
|
5
|
+
* 适配器不再导出自有的 permit 解析器或检查函数。
|
|
3
6
|
*/
|
|
4
|
-
import type { PermissionSubject } from '@zhin.js/permission';
|
|
5
|
-
|
|
6
|
-
const ADAPTER = 'discord';
|
|
7
7
|
|
|
8
8
|
export function platformPermit(perm: string): string {
|
|
9
|
-
return `platform
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const FACTORY_PERM_MAP: Record<string, string> = {
|
|
13
|
-
scene_admin: 'moderate_members',
|
|
14
|
-
scene_owner: 'guild_owner',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export function discordGroupPermitResolver(logicalPerm: string): string {
|
|
18
|
-
return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function normalizeDiscordSenderForPermit(input: {
|
|
22
|
-
isOwner?: boolean;
|
|
23
|
-
permissions?: string[];
|
|
24
|
-
}): { role?: string; permissions?: string[] } {
|
|
25
|
-
const permissions = [...(input.permissions ?? [])];
|
|
26
|
-
if (input.isOwner) {
|
|
27
|
-
permissions.push('guild_owner', 'ADMINISTRATOR');
|
|
28
|
-
return { role: 'owner', permissions };
|
|
29
|
-
}
|
|
30
|
-
if (permissions.includes('ADMINISTRATOR')) {
|
|
31
|
-
return { role: 'admin', permissions };
|
|
32
|
-
}
|
|
33
|
-
if (permissions.includes('MODERATE_MEMBERS') || permissions.includes('MANAGE_GUILD')) {
|
|
34
|
-
return { role: 'admin', permissions };
|
|
35
|
-
}
|
|
36
|
-
return { role: 'member', permissions };
|
|
9
|
+
return `platform-permission:${perm}`;
|
|
37
10
|
}
|
|
38
|
-
|
|
39
|
-
export function checkDiscordPlatformPermit(perm: string, subject: PermissionSubject): boolean {
|
|
40
|
-
const role = subject.sender?.role?.[0];
|
|
41
|
-
const permissions = subject.sender?.permissions ?? [];
|
|
42
|
-
const has = (t: string) => permissions.includes(t);
|
|
43
|
-
|
|
44
|
-
switch (perm) {
|
|
45
|
-
case 'guild_owner':
|
|
46
|
-
return role === 'owner' || has('guild_owner');
|
|
47
|
-
case 'moderate_members':
|
|
48
|
-
return role === 'owner' || role === 'admin' || has('ADMINISTRATOR') || has('MODERATE_MEMBERS');
|
|
49
|
-
case 'manage_roles':
|
|
50
|
-
return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_ROLES');
|
|
51
|
-
case 'manage_channels':
|
|
52
|
-
return role === 'owner' || has('ADMINISTRATOR') || has('MANAGE_CHANNELS');
|
|
53
|
-
default:
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
package/src/protocol.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
|
|
|
4
4
|
|
|
5
5
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
6
6
|
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
7
|
+
import { discordGuildPermissionNamesFromChannelBitfield } from './sender-standing.js';
|
|
7
8
|
import type { Segment } from '@zhin.js/core/runtime';
|
|
8
9
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
10
|
|
|
@@ -84,8 +85,14 @@ export interface DiscordInboundMessage {
|
|
|
84
85
|
readonly authorBot?: boolean;
|
|
85
86
|
readonly createdTimestamp: number;
|
|
86
87
|
readonly guildId?: string;
|
|
88
|
+
/** The message was sent in a thread: its parent channel (the thread itself is `channelId`). */
|
|
89
|
+
readonly threadParentId?: string;
|
|
87
90
|
readonly isGuildOwner?: boolean;
|
|
88
91
|
readonly permissionTokens?: readonly string[];
|
|
92
|
+
/** ADR 0056 §1: guild permission flags as snake_case names (`ban_members`, …). */
|
|
93
|
+
readonly platformPermissions?: readonly string[];
|
|
94
|
+
/** ADR 0056 §1: the member's guild roles (`@everyone` excluded). */
|
|
95
|
+
readonly authorRoles?: readonly { readonly id: string; readonly name?: string }[];
|
|
89
96
|
readonly attachments?: readonly DiscordInboundAttachment[];
|
|
90
97
|
readonly embedTitles?: readonly string[];
|
|
91
98
|
readonly stickerNames?: readonly string[];
|
|
@@ -102,6 +109,8 @@ export interface DiscordButtonInbound {
|
|
|
102
109
|
readonly customId: string;
|
|
103
110
|
readonly channelId: string;
|
|
104
111
|
readonly channelKind: 'private' | 'group' | 'channel';
|
|
112
|
+
/** Pressed inside a thread: its parent channel. */
|
|
113
|
+
readonly threadParentId?: string;
|
|
105
114
|
readonly userId: string;
|
|
106
115
|
readonly userName: string;
|
|
107
116
|
readonly sourceMessageId?: string;
|
|
@@ -195,6 +204,8 @@ export function resolveChannelKind(channelType: number | string | undefined): 'p
|
|
|
195
204
|
/**
|
|
196
205
|
* 入站归一化 → ConversationRef:DM → 'private';GroupDM → 'group';
|
|
197
206
|
* guild 频道 → 'channel' + guild 容器进 parent(guild 是频道容器,kind 'channel')。
|
|
207
|
+
* 线程(thread):会话 id 是父频道、`threadId` 是线程频道(ConversationRef 的子话题约定),
|
|
208
|
+
* 所以父频道上的角色 / 功能配置在线程里同样生效(ADR 0056 §2);出站用 {@link discordChannelOf}。
|
|
198
209
|
* endpoint.adapter = owner PluginId(CapabilityId 以 \0 分隔)。
|
|
199
210
|
*/
|
|
200
211
|
export function discordInboundConversation(
|
|
@@ -203,18 +214,26 @@ export function discordInboundConversation(
|
|
|
203
214
|
readonly channelId: string;
|
|
204
215
|
readonly channelKind: 'private' | 'group' | 'channel';
|
|
205
216
|
readonly guildId?: string;
|
|
217
|
+
readonly threadParentId?: string;
|
|
206
218
|
},
|
|
207
219
|
): ConversationRef {
|
|
220
|
+
const thread = msg.channelKind === 'channel' && msg.threadParentId && msg.threadParentId !== msg.channelId;
|
|
208
221
|
return {
|
|
209
222
|
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
210
223
|
kind: msg.channelKind,
|
|
211
|
-
id: msg.channelId,
|
|
224
|
+
id: thread ? msg.threadParentId! : msg.channelId,
|
|
225
|
+
...(thread ? { threadId: msg.channelId } : {}),
|
|
212
226
|
...(msg.guildId && msg.channelKind === 'channel'
|
|
213
227
|
? { parent: { kind: 'channel' as const, id: msg.guildId } }
|
|
214
228
|
: {}),
|
|
215
229
|
};
|
|
216
230
|
}
|
|
217
231
|
|
|
232
|
+
/** The Discord channel a conversation's messages live in: the thread when there is one. */
|
|
233
|
+
export function discordChannelOf(conversation: Pick<ConversationRef, 'id' | 'threadId'>): string {
|
|
234
|
+
return conversation.threadId ?? conversation.id;
|
|
235
|
+
}
|
|
236
|
+
|
|
218
237
|
export function senderDisplayName(msg: DiscordInboundMessage): string {
|
|
219
238
|
return msg.authorName || msg.authorId;
|
|
220
239
|
}
|
|
@@ -401,6 +420,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
401
420
|
embeds.push({ title: String(data.title ?? ''), url: String(data.url ?? ''), description: String(data.description ?? data.content ?? ''), ...(data.image ? { image: { url: String(data.image) } } : {}) });
|
|
402
421
|
break;
|
|
403
422
|
case 'embed':
|
|
423
|
+
case 'discord:embed':
|
|
404
424
|
embeds.push({ ...data });
|
|
405
425
|
break;
|
|
406
426
|
case 'keyboard': {
|
|
@@ -506,18 +526,38 @@ export function formatSlashCommandContent(interaction: Record<string, unknown>):
|
|
|
506
526
|
return parts.join(' ');
|
|
507
527
|
}
|
|
508
528
|
|
|
529
|
+
/** Thread channel types (announcement / public / private thread) carry `parent_id`, the channel they belong to. */
|
|
530
|
+
const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]);
|
|
531
|
+
|
|
532
|
+
/** Parent channel of a raw API channel object when it is a thread. */
|
|
533
|
+
export function discordThreadParentOf(channel: unknown): string | undefined {
|
|
534
|
+
if (!channel || typeof channel !== 'object') return undefined;
|
|
535
|
+
const raw = channel as { type?: unknown; parent_id?: unknown };
|
|
536
|
+
return THREAD_CHANNEL_TYPES.has(Number(raw.type)) && typeof raw.parent_id === 'string' && raw.parent_id ? raw.parent_id : undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
509
539
|
export function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage {
|
|
510
|
-
const
|
|
540
|
+
const member = interaction.member as { user?: Record<string, unknown>; permissions?: unknown; roles?: unknown } | undefined;
|
|
541
|
+
const user = member?.user
|
|
511
542
|
?? (interaction.user as Record<string, unknown> | undefined);
|
|
543
|
+
const guildId = interaction.guild_id != null ? String(interaction.guild_id) : undefined;
|
|
544
|
+
// ADR 0056 §1: guild-level names only (the interaction bitfield includes channel overwrites).
|
|
545
|
+
const platformPermissions = guildId ? discordGuildPermissionNamesFromChannelBitfield(member?.permissions) : [];
|
|
546
|
+
const authorRoles = guildId && Array.isArray(member?.roles)
|
|
547
|
+
? member.roles.map(String).filter((id) => id && id !== guildId).map((id) => ({ id }))
|
|
548
|
+
: [];
|
|
512
549
|
return {
|
|
513
550
|
event: interaction,
|
|
514
551
|
id: String(interaction.id),
|
|
515
552
|
content: formatSlashCommandContent(interaction),
|
|
516
553
|
channelId: String(interaction.channel_id ?? ''),
|
|
517
554
|
channelKind: interaction.guild_id ? 'channel' : 'private',
|
|
555
|
+
...(discordThreadParentOf(interaction.channel) ? { threadParentId: discordThreadParentOf(interaction.channel)! } : {}),
|
|
518
556
|
authorId: String(user?.id ?? ''),
|
|
519
557
|
authorName: String(user?.username ?? user?.id ?? ''),
|
|
520
558
|
createdTimestamp: Date.now(),
|
|
521
|
-
guildId
|
|
559
|
+
guildId,
|
|
560
|
+
...(platformPermissions.length ? { platformPermissions } : {}),
|
|
561
|
+
...(authorRoles.length ? { authorRoles } : {}),
|
|
522
562
|
};
|
|
523
563
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0056 §1: Discord guild member → normalized `sender.platform`.
|
|
3
|
+
*
|
|
4
|
+
* - `standing`: guild owner → `owner`; `Administrator` / `ModerateMembers` → `admin`; other members →
|
|
5
|
+
* `member`. Direct messages report nothing.
|
|
6
|
+
* - `roles`: the member's guild roles (`@everyone` excluded), with names when the event carries them.
|
|
7
|
+
* - `permissions`: the member's **guild-level** permission flags as snake_case names (`ban_members`,
|
|
8
|
+
* `manage_roles`, …). Gateway mode reads `GuildMember.permissions` (server-wide, no channel overwrites)
|
|
9
|
+
* for senders and the Bot alike. Interactions only carry channel-level bitfields (overwrites applied),
|
|
10
|
+
* so they report just the flags that cannot be overwritten per channel (identical at both levels) —
|
|
11
|
+
* all flags when the member is an administrator.
|
|
12
|
+
*/
|
|
13
|
+
import { PermissionFlagsBits } from 'discord.js';
|
|
14
|
+
import type { PlatformStanding } from '@zhin.js/im-contract';
|
|
15
|
+
import type { DiscordInboundMessage } from './protocol.js';
|
|
16
|
+
|
|
17
|
+
const FLAG_ENTRIES = Object.entries(PermissionFlagsBits) as [string, bigint][];
|
|
18
|
+
|
|
19
|
+
/** `ManageRoles` → `manage_roles`, `SendTTSMessages` → `send_tts_messages`. */
|
|
20
|
+
export function discordPermissionName(flag: string): string {
|
|
21
|
+
return flag.replace(/([A-Z]+)([A-Z][a-z])/gu, '$1_$2').replace(/([a-z0-9])([A-Z])/gu, '$1_$2').toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** All permission flag names the predicate grants. */
|
|
25
|
+
export function discordPermissionNames(has: (bit: bigint) => boolean): string[] {
|
|
26
|
+
const names: string[] = [];
|
|
27
|
+
for (const [flag, bit] of FLAG_ENTRIES) {
|
|
28
|
+
try {
|
|
29
|
+
if (has(bit)) names.push(discordPermissionName(flag));
|
|
30
|
+
} catch {
|
|
31
|
+
// A partial permission view cannot answer this bit; leave it out.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Permission names from an interaction's decimal bitfield string (`member.permissions`). */
|
|
38
|
+
export function discordPermissionNamesFromBitfield(bitfield: unknown): string[] {
|
|
39
|
+
const value = parseBitfield(bitfield);
|
|
40
|
+
return value === undefined ? [] : discordPermissionNames(bit => (value & bit) === bit);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseBitfield(bitfield: unknown): bigint | undefined {
|
|
44
|
+
if (typeof bitfield !== 'string' && typeof bitfield !== 'number' && typeof bitfield !== 'bigint') return undefined;
|
|
45
|
+
try { return BigInt(bitfield); } catch { return undefined; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Flags Discord does not allow in channel overwrites: a channel-level bitfield shows their guild value. */
|
|
49
|
+
const GUILD_ONLY_FLAGS: readonly bigint[] = [
|
|
50
|
+
PermissionFlagsBits.Administrator, PermissionFlagsBits.KickMembers, PermissionFlagsBits.BanMembers,
|
|
51
|
+
PermissionFlagsBits.ManageGuild, PermissionFlagsBits.ViewAuditLog, PermissionFlagsBits.ViewGuildInsights,
|
|
52
|
+
PermissionFlagsBits.ChangeNickname, PermissionFlagsBits.ManageNicknames, PermissionFlagsBits.ManageGuildExpressions,
|
|
53
|
+
PermissionFlagsBits.ModerateMembers, PermissionFlagsBits.ViewCreatorMonetizationAnalytics,
|
|
54
|
+
PermissionFlagsBits.CreateGuildExpressions,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Guild-level permission names from a channel-level bitfield (interaction `member.permissions` /
|
|
59
|
+
* `app_permissions`): an administrator holds every flag server-wide; otherwise only the flags that
|
|
60
|
+
* channel overwrites cannot change are reported, so a channel overwrite (e.g. "Manage Permissions" in
|
|
61
|
+
* one channel) never reads as a server-wide `manage_roles`.
|
|
62
|
+
*/
|
|
63
|
+
export function discordGuildPermissionNamesFromChannelBitfield(bitfield: unknown): string[] {
|
|
64
|
+
const value = parseBitfield(bitfield);
|
|
65
|
+
if (value === undefined) return [];
|
|
66
|
+
if ((value & PermissionFlagsBits.Administrator) === PermissionFlagsBits.Administrator) return discordPermissionNames(() => true);
|
|
67
|
+
return discordPermissionNames(bit => GUILD_ONLY_FLAGS.includes(bit) && (value & bit) === bit);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Normalized standing from guild-level permission names (the guild owner is reported separately). */
|
|
71
|
+
export function discordStandingFromPermissions(permissions: readonly string[], isOwner = false): PlatformStanding['standing'] {
|
|
72
|
+
return isOwner ? 'owner'
|
|
73
|
+
: permissions.includes('administrator') || permissions.includes('moderate_members') ? 'admin' : 'member';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function discordSenderStanding(msg: DiscordInboundMessage): PlatformStanding | undefined {
|
|
77
|
+
if (!msg.guildId) return undefined;
|
|
78
|
+
const permissions = msg.platformPermissions ?? [];
|
|
79
|
+
const legacy = msg.permissionTokens ?? [];
|
|
80
|
+
const standing = msg.isGuildOwner
|
|
81
|
+
? 'owner'
|
|
82
|
+
: permissions.includes('administrator') || permissions.includes('moderate_members')
|
|
83
|
+
|| legacy.includes('ADMINISTRATOR') || legacy.includes('MODERATE_MEMBERS')
|
|
84
|
+
? 'admin'
|
|
85
|
+
: 'member';
|
|
86
|
+
return {
|
|
87
|
+
standing,
|
|
88
|
+
...(msg.authorRoles?.length ? { roles: msg.authorRoles } : {}),
|
|
89
|
+
...(permissions.length ? { permissions } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|