@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.
- package/CHANGELOG.md +175 -0
- package/LICENSE +21 -0
- package/PERMITS.md +15 -14
- package/README.md +7 -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/channel-visibility.d.ts +9 -0
- package/lib/channel-visibility.js +44 -0
- package/lib/endpoint.d.ts +1 -0
- package/lib/endpoint.js +76 -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/channel-visibility.ts +44 -0
- package/src/endpoint.ts +77 -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/lib/webhook.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { type Endpoint } from 'zhin.js/adapter';
|
|
2
|
-
|
|
3
|
-
* Discord interactions HTTP: signature → parse → admit.
|
|
4
|
-
*/
|
|
5
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
-
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
2
|
+
import { type HttpHost, type HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
3
|
import { type DiscordInboundMessage, type DiscordButtonInbound, type ResolvedDiscordInteractionsConfig } from './protocol.js';
|
|
8
4
|
export interface DiscordInteractionsHandler extends Endpoint {
|
|
9
5
|
readonly config: ResolvedDiscordInteractionsConfig;
|
|
@@ -13,4 +9,4 @@ export interface DiscordInteractionsHandler extends Endpoint {
|
|
|
13
9
|
admitPlatform(event: Record<string, unknown>): void;
|
|
14
10
|
}
|
|
15
11
|
export declare function registerDiscordInteractionRoutes(http: HttpHost, handler: DiscordInteractionsHandler): HttpRouteRegistration[];
|
|
16
|
-
export declare function handleDiscordInteractionRequest(request:
|
|
12
|
+
export declare function handleDiscordInteractionRequest(request: Request, handler: DiscordInteractionsHandler): Promise<Response>;
|
package/lib/webhook.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { endpointSpec } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* Discord interactions HTTP: signature → parse → admit.
|
|
4
|
+
*/
|
|
2
5
|
import { createMessages } from 'zhin.js/i18n';
|
|
6
|
+
import { readTextBody } from '@zhin.js/host-http';
|
|
3
7
|
import { getLogger } from '@zhin.js/logger';
|
|
4
8
|
import { interactionToInboundMessage, verifyDiscordInteractionSignature, } from './protocol.js';
|
|
5
9
|
const logger = getLogger('discord');
|
|
@@ -15,55 +19,44 @@ const INTERACTION_FLAG_EPHEMERAL = 64;
|
|
|
15
19
|
export function registerDiscordInteractionRoutes(http, handler) {
|
|
16
20
|
const path = handler.config.interactionsPath;
|
|
17
21
|
return [
|
|
18
|
-
http.route('POST', path,
|
|
19
|
-
await handleDiscordInteractionRequest(request, response, handler);
|
|
20
|
-
}, { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
22
|
+
http.route('POST', path, (request) => handleDiscordInteractionRequest(request, handler), { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
21
23
|
];
|
|
22
24
|
}
|
|
23
|
-
export async function handleDiscordInteractionRequest(request,
|
|
25
|
+
export async function handleDiscordInteractionRequest(request, handler) {
|
|
24
26
|
try {
|
|
25
|
-
const signature =
|
|
26
|
-
const timestamp =
|
|
27
|
-
|
|
27
|
+
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
|
28
|
+
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
|
29
|
+
// Oversize bodies (> 1 MiB) fail and answer 500, as before.
|
|
30
|
+
const rawBody = await readTextBody(request);
|
|
28
31
|
if (!signature || !timestamp) {
|
|
29
|
-
|
|
30
|
-
response.end('Unauthorized');
|
|
31
|
-
return;
|
|
32
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
32
33
|
}
|
|
33
34
|
if (!verifyDiscordInteractionSignature(handler.config.publicKey, rawBody, signature, timestamp)) {
|
|
34
|
-
|
|
35
|
-
response.end('Unauthorized');
|
|
36
|
-
return;
|
|
35
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
37
36
|
}
|
|
38
37
|
const interaction = JSON.parse(rawBody);
|
|
39
38
|
if (!interaction || interaction.application_id !== handler.config.applicationId) {
|
|
40
|
-
|
|
41
|
-
response.end('Unauthorized');
|
|
42
|
-
return;
|
|
39
|
+
return new Response('Unauthorized', { status: 401, headers: { 'Content-Type': 'text/plain' } });
|
|
43
40
|
}
|
|
44
41
|
// A verified protocol challenge is not a business message and keeps its immediate ACK.
|
|
45
42
|
if (interaction.type === INTERACTION_TYPE_PING) {
|
|
46
43
|
if (handler.isOpen)
|
|
47
44
|
void handler[endpointSpec].events.run(() => handler.admitPlatform(interaction))
|
|
48
45
|
.catch(() => logger.debug('Protocol event dispatch did not complete'));
|
|
49
|
-
writeJson(
|
|
50
|
-
return;
|
|
46
|
+
return writeJson(200, { type: INTERACTION_RESPONSE_PONG });
|
|
51
47
|
}
|
|
52
|
-
await handler[endpointSpec].events.run(async () => {
|
|
48
|
+
return await handler[endpointSpec].events.run(async () => {
|
|
53
49
|
if (handler.isOpen)
|
|
54
50
|
handler.admitPlatform(interaction);
|
|
55
51
|
if (interaction.type === 3) {
|
|
56
52
|
const data = interaction.data;
|
|
57
53
|
if (data?.component_type === 2 && typeof data.custom_id === 'string') {
|
|
58
54
|
if (!handler.isOpen) {
|
|
59
|
-
|
|
60
|
-
response.end('Unavailable');
|
|
61
|
-
return;
|
|
55
|
+
return new Response('Unavailable', { status: 503 });
|
|
62
56
|
}
|
|
63
57
|
const message = interactionToInboundMessage(interaction);
|
|
64
|
-
handler.admitButton({ event: interaction, id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), sourceMessageId: interaction.message?.id });
|
|
65
|
-
writeJson(
|
|
66
|
-
return;
|
|
58
|
+
handler.admitButton({ event: interaction, id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), ...(message.threadParentId ? { threadParentId: message.threadParentId } : {}), sourceMessageId: interaction.message?.id });
|
|
59
|
+
return writeJson(200, { type: 6 });
|
|
67
60
|
}
|
|
68
61
|
}
|
|
69
62
|
if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
|
|
@@ -74,47 +67,22 @@ export async function handleDiscordInteractionRequest(request, response, handler
|
|
|
74
67
|
const locale = await handler[endpointSpec].resolveLocale(message.authorId);
|
|
75
68
|
// 即时响应(type 4):defer(type 5) 需要 followup PATCH,未实现会让用户端一直转圈
|
|
76
69
|
const commandName = String(interaction.data?.name ?? '');
|
|
77
|
-
writeJson(
|
|
70
|
+
return writeJson(200, {
|
|
78
71
|
type: INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
79
72
|
data: {
|
|
80
73
|
content: messages(locale, 'processing', { command: commandName }),
|
|
81
74
|
flags: INTERACTION_FLAG_EPHEMERAL,
|
|
82
75
|
},
|
|
83
76
|
});
|
|
84
|
-
return;
|
|
85
77
|
}
|
|
86
|
-
|
|
87
|
-
response.end('Unsupported interaction type');
|
|
78
|
+
return new Response('Unsupported interaction type', { status: 400, headers: { 'Content-Type': 'text/plain' } });
|
|
88
79
|
});
|
|
89
80
|
}
|
|
90
81
|
catch (error) {
|
|
91
82
|
logger.error('Discord interactions error:', error);
|
|
92
|
-
|
|
93
|
-
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
94
|
-
response.end('Internal Server Error');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
function headerValue(value) {
|
|
99
|
-
if (Array.isArray(value))
|
|
100
|
-
return value[0] ?? '';
|
|
101
|
-
return value ?? '';
|
|
102
|
-
}
|
|
103
|
-
async function readInteractionBody(request) {
|
|
104
|
-
const chunks = [];
|
|
105
|
-
let size = 0;
|
|
106
|
-
for await (const chunk of request) {
|
|
107
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
108
|
-
size += buffer.length;
|
|
109
|
-
if (size > 1_048_576) {
|
|
110
|
-
request.destroy();
|
|
111
|
-
throw new Error('Request body exceeds 1MB');
|
|
112
|
-
}
|
|
113
|
-
chunks.push(buffer);
|
|
83
|
+
return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } });
|
|
114
84
|
}
|
|
115
|
-
return Buffer.concat(chunks).toString('utf8');
|
|
116
85
|
}
|
|
117
|
-
function writeJson(
|
|
118
|
-
|
|
119
|
-
response.end(JSON.stringify(body));
|
|
86
|
+
function writeJson(status, body) {
|
|
87
|
+
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
120
88
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-discord",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.28",
|
|
4
4
|
"description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -35,25 +35,25 @@
|
|
|
35
35
|
"@discordjs/ws": "1.2.3",
|
|
36
36
|
"discord.js": "^14.27.0",
|
|
37
37
|
"undici": "6.29.1",
|
|
38
|
-
"
|
|
39
|
-
"@zhin.js/
|
|
40
|
-
"@zhin.js/
|
|
41
|
-
"@zhin.js/
|
|
42
|
-
"@zhin.js/
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
-
"@zhin.js/
|
|
46
|
-
"@zhin.js/
|
|
47
|
-
"@zhin.js/
|
|
48
|
-
"@zhin.js/
|
|
49
|
-
"@zhin.js/
|
|
38
|
+
"zod": "^4.6.7",
|
|
39
|
+
"@zhin.js/adapter": "1.1.33",
|
|
40
|
+
"@zhin.js/agent-feature": "1.1.17",
|
|
41
|
+
"@zhin.js/console-contract": "1.1.6",
|
|
42
|
+
"@zhin.js/core": "1.2.0",
|
|
43
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
44
|
+
"@zhin.js/host-http": "1.2.0",
|
|
45
|
+
"@zhin.js/im-contract": "1.1.14",
|
|
46
|
+
"@zhin.js/logger": "1.1.3",
|
|
47
|
+
"@zhin.js/page": "1.1.18",
|
|
48
|
+
"@zhin.js/permission": "1.2.0",
|
|
49
|
+
"@zhin.js/skill": "1.1.17",
|
|
50
|
+
"@zhin.js/tool": "1.1.19"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
|
-
"
|
|
53
|
-
"@zhin.js/
|
|
54
|
-
"@zhin.js/
|
|
55
|
-
"
|
|
56
|
-
"zhin.js": "^1.1.25"
|
|
53
|
+
"@zhin.js/adapter": "^1.1.33",
|
|
54
|
+
"@zhin.js/command": "^1.2.0",
|
|
55
|
+
"@zhin.js/core": "^1.2.0",
|
|
56
|
+
"zhin.js": "^1.1.26"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
59
59
|
"@zhin.js/command": {
|
|
@@ -61,18 +61,16 @@
|
|
|
61
61
|
},
|
|
62
62
|
"zhin.js": {
|
|
63
63
|
"optional": true
|
|
64
|
-
},
|
|
65
|
-
"zod": {
|
|
66
|
-
"optional": true
|
|
67
64
|
}
|
|
68
65
|
},
|
|
69
66
|
"devDependencies": {
|
|
70
67
|
"@types/node": "^26.1.2",
|
|
68
|
+
"ajv": "8.18.0",
|
|
71
69
|
"typescript": "^6.0.3",
|
|
72
70
|
"vitest": "^4.1.10",
|
|
73
|
-
"
|
|
74
|
-
"@zhin.js/host-http": "1.
|
|
75
|
-
"zhin.js": "1.1.
|
|
71
|
+
"ws": "^8.21.1",
|
|
72
|
+
"@zhin.js/host-http": "1.2.0",
|
|
73
|
+
"zhin.js": "1.1.26"
|
|
76
74
|
},
|
|
77
75
|
"files": [
|
|
78
76
|
"adapters",
|
|
@@ -131,6 +129,6 @@
|
|
|
131
129
|
"scripts": {
|
|
132
130
|
"build": "tsc",
|
|
133
131
|
"clean": "rimraf lib",
|
|
134
|
-
"test": "
|
|
132
|
+
"test": "vitest run --root ../../.. plugins/adapters/discord/tests"
|
|
135
133
|
}
|
|
136
134
|
}
|
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"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ChannelFlagsBitField } from 'discord.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discord 频道混淆(Channel Obfuscation)判定。
|
|
5
|
+
*
|
|
6
|
+
* 自 2026-11-16 起,Gateway 会继续下发机器人缺少 `VIEW_CHANNEL` 的频道,但其元数据被混淆:
|
|
7
|
+
* `name` 变为 `"___hidden___"`,并在 `flags` 上置 `CHANNEL_OBFUSCATED`;HTTP 的
|
|
8
|
+
* `GET /guilds/{guild.id}/channels` 则直接不返回这些频道。
|
|
9
|
+
*
|
|
10
|
+
* 出处:
|
|
11
|
+
* - https://docs.discord.com/developers/resources/channel#channel-object-channel-flags
|
|
12
|
+
* (Channel Flags 表:`CHANNEL_OBFUSCATED` = `1 << 17`)
|
|
13
|
+
* - https://docs.discord.com/developers/resources/channel#channel-object-obfuscated-channels
|
|
14
|
+
* - https://docs.discord.com/developers/change-log (2026-08-12 "Channel Obfuscation for Users and Bots")
|
|
15
|
+
* - discord-api-types `ChannelFlags.ChannelObfuscated`(0.38.54 起,值为 `131072`)。
|
|
16
|
+
* discord.js 14.27.0 把它声明为 dependencies `^0.38.49`,并通过
|
|
17
|
+
* `ChannelFlagsBitField.Flags` 暴露同一枚举。0.38.49–0.38.53 上该枚举是 `undefined`,
|
|
18
|
+
* 因此这里用 `?? (1 << 17)` 兜底,避免过滤悄悄失效。
|
|
19
|
+
*
|
|
20
|
+
* 官方要求以 flag 判定,不要依赖 `name` 等被混淆字段。
|
|
21
|
+
*/
|
|
22
|
+
function obfuscatedFlagBit(): number {
|
|
23
|
+
return ChannelFlagsBitField.Flags.ChannelObfuscated ?? (1 << 17);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DISCORD_CHANNEL_FLAG_OBFUSCATED = obfuscatedFlagBit();
|
|
27
|
+
|
|
28
|
+
type ChannelFlagsLike = number | bigint | { readonly bitfield?: number | bigint } | null | undefined;
|
|
29
|
+
|
|
30
|
+
function readFlagBits(flags: ChannelFlagsLike): number {
|
|
31
|
+
if (flags == null) return 0;
|
|
32
|
+
if (typeof flags === 'number') return flags;
|
|
33
|
+
if (typeof flags === 'bigint') return Number(flags & 0xffffffffn);
|
|
34
|
+
const bitfield = flags.bitfield;
|
|
35
|
+
if (typeof bitfield === 'number') return bitfield;
|
|
36
|
+
if (typeof bitfield === 'bigint') return Number(bitfield & 0xffffffffn);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 频道是否被 Discord 混淆(机器人无 `VIEW_CHANNEL`)。兼容 discord.js 的 `ChannelFlagsBitField` 与原始 `flags` 数值。 */
|
|
41
|
+
export function isDiscordChannelObfuscated(channel: { readonly flags?: ChannelFlagsLike } | null | undefined): boolean {
|
|
42
|
+
if (!channel) return false;
|
|
43
|
+
return (readFlagBits(channel.flags) & obfuscatedFlagBit()) !== 0;
|
|
44
|
+
}
|
package/src/endpoint.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createDiscordRestApiAgent } from './rest-api-proxy.js';
|
|
2
2
|
import { createDiscordSelfProfile, createDiscordRestSelfProfile } from './self-profile.js';
|
|
3
3
|
import { DiscordRestRequestError } from './rest-request-error.js';
|
|
4
|
+
import { isDiscordChannelObfuscated } from './channel-visibility.js';
|
|
4
5
|
/**
|
|
5
6
|
* DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
|
|
6
7
|
*/
|
|
@@ -27,8 +28,11 @@ import {
|
|
|
27
28
|
} from 'zhin.js/adapter';
|
|
28
29
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
29
30
|
import {
|
|
31
|
+
type ConversationRef,
|
|
30
32
|
type ConversationReference,
|
|
31
33
|
type ConversationResolution,
|
|
34
|
+
type PlatformStanding,
|
|
35
|
+
type SelfStandingRequest,
|
|
32
36
|
} from '@zhin.js/im-contract';
|
|
33
37
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
34
38
|
import type { CapabilityId } from 'zhin.js';
|
|
@@ -36,11 +40,11 @@ import {
|
|
|
36
40
|
connectDiscordGatewayClient,
|
|
37
41
|
discordClientOptions,
|
|
38
42
|
DEFAULT_INTENTS,
|
|
39
|
-
resolveSenderRole,
|
|
40
43
|
toMessageCreateOptions,
|
|
41
44
|
type DiscordClientTransport,
|
|
42
45
|
} from './gateway.js';
|
|
43
46
|
import {
|
|
47
|
+
discordChannelOf,
|
|
44
48
|
discordInboundConversation,
|
|
45
49
|
formatButtonContent,
|
|
46
50
|
formatButtonSegments,
|
|
@@ -56,6 +60,9 @@ import {
|
|
|
56
60
|
} from './protocol.js';
|
|
57
61
|
import { registerDiscordInteractionRoutes } from './webhook.js';
|
|
58
62
|
import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
|
|
63
|
+
import {
|
|
64
|
+
discordGuildPermissionNamesFromChannelBitfield, discordPermissionNames, discordSenderStanding, discordStandingFromPermissions,
|
|
65
|
+
} from './sender-standing.js';
|
|
59
66
|
|
|
60
67
|
const DISCORD_API = 'https://discord.com/api/v10';
|
|
61
68
|
/** 出站 HTTP 调用统一 30s 超时。 */
|
|
@@ -201,11 +208,11 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
|
201
208
|
|
|
202
209
|
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
203
210
|
const body = formatOutboundBody(payload);
|
|
204
|
-
const snowflake = await this.#sendBody({ channelId: conversation
|
|
211
|
+
const snowflake = await this.#sendBody({ channelId: discordChannelOf(conversation), body, signal });
|
|
205
212
|
this.#logger.debug(formatCompact({
|
|
206
213
|
op: 'discord_send',
|
|
207
214
|
endpoint: this.#options.config.id,
|
|
208
|
-
target: conversation
|
|
215
|
+
target: discordChannelOf(conversation),
|
|
209
216
|
messageId: snowflake,
|
|
210
217
|
}));
|
|
211
218
|
return snowflake;
|
|
@@ -214,7 +221,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
|
214
221
|
async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
|
|
215
222
|
if (!message.id) return;
|
|
216
223
|
const operation = createEndpointOperation({ signal });
|
|
217
|
-
const channel = await operation.prepare(() => this.#requireClient().channels.fetch(message.conversation
|
|
224
|
+
const channel = await operation.prepare(() => this.#requireClient().channels.fetch(discordChannelOf(message.conversation)));
|
|
218
225
|
if (!channel?.isTextBased() || !channel.messages) return;
|
|
219
226
|
const msg = await operation.prepare(() => channel.messages.fetch(message.id));
|
|
220
227
|
await operation.run(() => msg.delete());
|
|
@@ -225,6 +232,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
|
225
232
|
if (!this.#open) return;
|
|
226
233
|
if (msg.authorBot) return;
|
|
227
234
|
const conversation = discordInboundConversation(String(this.#options.id), msg);
|
|
235
|
+
const platform = discordSenderStanding(msg);
|
|
228
236
|
void this[endpointSpec].events.emit('message.receive', {
|
|
229
237
|
conversation,
|
|
230
238
|
event: msg.event,
|
|
@@ -235,7 +243,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
|
235
243
|
sender: {
|
|
236
244
|
id: msg.authorId,
|
|
237
245
|
name: senderDisplayName(msg) || undefined,
|
|
238
|
-
...(
|
|
246
|
+
...(platform ? { platform } : {}),
|
|
239
247
|
},
|
|
240
248
|
endpointId: this.#options.config.id,
|
|
241
249
|
...(msg.mentionedBot ? { mentioned: true } : {}),
|
|
@@ -245,7 +253,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
|
245
253
|
userId: msg.authorId,
|
|
246
254
|
guildId: msg.guildId,
|
|
247
255
|
permissions: msg.permissionTokens,
|
|
248
|
-
role:
|
|
256
|
+
role: platform?.standing,
|
|
249
257
|
}),
|
|
250
258
|
}).catch((err) => {
|
|
251
259
|
this.#logger.warn(formatCompact({
|
|
@@ -284,7 +292,7 @@ export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
|
284
292
|
}
|
|
285
293
|
|
|
286
294
|
async #addReaction({ message, emoji, hint, signal }: EndpointReactionRequest): Promise<string | null> {
|
|
287
|
-
const channelId = hint?.channelId ?? message.conversation
|
|
295
|
+
const channelId = hint?.channelId ?? discordChannelOf(message.conversation);
|
|
288
296
|
if (!channelId || !message.id) return null;
|
|
289
297
|
const operation = createEndpointOperation({ signal });
|
|
290
298
|
const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
|
|
@@ -405,6 +413,9 @@ export class DiscordRestClient {
|
|
|
405
413
|
}
|
|
406
414
|
}
|
|
407
415
|
|
|
416
|
+
const APP_PERMISSIONS_TTL_MS = 10 * 60_000;
|
|
417
|
+
const APP_PERMISSIONS_MAX = 1_000;
|
|
418
|
+
|
|
408
419
|
export class DiscordInteractionsEndpoint extends DiscordRestClient implements Endpoint {
|
|
409
420
|
readonly [endpointSpec]: EndpointSpecification;
|
|
410
421
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
@@ -414,6 +425,8 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
|
|
|
414
425
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
415
426
|
#open = false;
|
|
416
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 }>();
|
|
417
430
|
readonly #control: EndpointControl = Object.freeze<EndpointControl>({
|
|
418
431
|
recall: request => this.#recallMessage(request),
|
|
419
432
|
});
|
|
@@ -430,6 +443,12 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
|
|
|
430
443
|
this[endpointSpec] = createEndpointSpecification(this, {
|
|
431
444
|
start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
432
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
|
+
}),
|
|
433
452
|
control: this.#control, content: this.#content,
|
|
434
453
|
profile: createDiscordRestSelfProfile(this, id => this[endpointSpec].reportSelfUserId(id)),
|
|
435
454
|
});
|
|
@@ -472,18 +491,40 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
|
|
|
472
491
|
|
|
473
492
|
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
474
493
|
const body = formatOutboundBody(payload);
|
|
475
|
-
const data = await this.createMessage({ channelId: conversation
|
|
494
|
+
const data = await this.createMessage({ channelId: discordChannelOf(conversation), body, signal });
|
|
476
495
|
return data.id ?? '';
|
|
477
496
|
}
|
|
478
497
|
|
|
479
498
|
async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
|
|
480
499
|
if (!message.id) return;
|
|
481
|
-
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
|
+
}
|
|
482
521
|
}
|
|
483
522
|
|
|
484
523
|
admit(msg: DiscordInboundMessage): void {
|
|
485
524
|
if (!this.#open) return;
|
|
525
|
+
this.#recordAppPermissions(msg);
|
|
486
526
|
const conversation = discordInboundConversation(String(this.#options.id), msg);
|
|
527
|
+
const platform = discordSenderStanding(msg);
|
|
487
528
|
void this[endpointSpec].events.emit('message.receive', {
|
|
488
529
|
conversation,
|
|
489
530
|
event: msg.event,
|
|
@@ -494,7 +535,7 @@ export class DiscordInteractionsEndpoint extends DiscordRestClient implements En
|
|
|
494
535
|
sender: {
|
|
495
536
|
id: msg.authorId,
|
|
496
537
|
name: senderDisplayName(msg) || undefined,
|
|
497
|
-
...(
|
|
538
|
+
...(platform ? { platform } : {}),
|
|
498
539
|
},
|
|
499
540
|
endpointId: this.#options.config.id,
|
|
500
541
|
metadata: Object.freeze({
|
|
@@ -569,7 +610,7 @@ async function resolveDiscordContent(
|
|
|
569
610
|
try {
|
|
570
611
|
context.signal.throwIfAborted();
|
|
571
612
|
const response = await fetch(
|
|
572
|
-
`${DISCORD_API}/channels/${reference.message.conversation
|
|
613
|
+
`${DISCORD_API}/channels/${discordChannelOf(reference.message.conversation)}/messages/${reference.message.id}`,
|
|
573
614
|
{ headers: { Authorization: `Bot ${token}` }, signal: context.signal },
|
|
574
615
|
);
|
|
575
616
|
if (response.status === 404) return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
|
|
@@ -606,11 +647,34 @@ async function resolveDiscordContent(
|
|
|
606
647
|
/**
|
|
607
648
|
* DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
|
|
608
649
|
* 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
|
|
650
|
+
* listChannels 会过滤带 CHANNEL_OBFUSCATED flag 的混淆频道(见 channel-visibility.ts)。
|
|
609
651
|
*/
|
|
610
652
|
export function createDiscordEndpointManagement(
|
|
611
653
|
requireClient: () => DiscordClientTransport,
|
|
612
654
|
): EndpointManagement {
|
|
613
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
|
+
},
|
|
614
678
|
async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
|
|
615
679
|
return createEndpointOperation(request).run(() => {
|
|
616
680
|
const groups: EndpointGroup[] = [];
|
|
@@ -634,6 +698,8 @@ export function createDiscordEndpointManagement(
|
|
|
634
698
|
for (const channel of guild.channels?.cache?.values() ?? []) {
|
|
635
699
|
if (!channel?.id) continue;
|
|
636
700
|
if (channel.type !== ChannelType.GuildText) continue;
|
|
701
|
+
// 机器人无 VIEW_CHANNEL 的频道会被 Gateway 混淆下发(HTTP 列表已不返回),默认过滤以保持两条路径一致。
|
|
702
|
+
if (isDiscordChannelObfuscated(channel)) continue;
|
|
637
703
|
channels.push({
|
|
638
704
|
id: String(channel.id),
|
|
639
705
|
name: channel.name ? String(channel.name) : undefined,
|