@zhin.js/adapter-discord 5.0.2 → 6.0.0
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 +62 -0
- package/README.md +49 -79
- package/adapters/discord.ts +46 -0
- package/agent/tools/add_role.ts +2 -2
- package/agent/tools/create_thread.ts +2 -2
- package/agent/tools/forum_post.ts +2 -2
- package/agent/tools/list_roles.ts +2 -2
- package/agent/tools/react.ts +2 -2
- package/agent/tools/remove_role.ts +2 -2
- package/agent/tools/send_embed.ts +2 -2
- package/lib/discord-agent-deps.d.ts +35 -0
- package/lib/discord-agent-deps.js +32 -0
- package/lib/endpoint.d.ts +71 -0
- package/lib/endpoint.js +355 -0
- package/lib/gateway.d.ts +122 -0
- package/lib/gateway.js +235 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +6 -0
- package/lib/platform-permit.d.ts +15 -0
- package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
- package/lib/protocol.d.ts +135 -0
- package/lib/protocol.js +234 -0
- package/lib/webhook.d.ts +13 -0
- package/lib/webhook.js +86 -0
- package/package.json +43 -36
- package/plugin.ts +13 -0
- package/schema.json +105 -0
- package/src/discord-agent-deps.ts +68 -11
- package/src/endpoint.ts +385 -1167
- package/src/gateway.ts +337 -0
- package/src/index.ts +56 -157
- package/src/platform-permit.ts +1 -1
- package/src/protocol.ts +392 -0
- package/src/webhook.ts +121 -0
- package/client/Dashboard.tsx +0 -195
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -30
- package/dist/index.js +0 -29
- package/lib/agent/tools/add_role.js +0 -22
- package/lib/agent/tools/add_role.js.map +0 -1
- package/lib/agent/tools/create_thread.js +0 -23
- package/lib/agent/tools/create_thread.js.map +0 -1
- package/lib/agent/tools/forum_post.js +0 -22
- package/lib/agent/tools/forum_post.js.map +0 -1
- package/lib/agent/tools/list_roles.js +0 -20
- package/lib/agent/tools/list_roles.js.map +0 -1
- package/lib/agent/tools/react.js +0 -20
- package/lib/agent/tools/react.js.map +0 -1
- package/lib/agent/tools/remove_role.js +0 -22
- package/lib/agent/tools/remove_role.js.map +0 -1
- package/lib/agent/tools/send_embed.js +0 -40
- package/lib/agent/tools/send_embed.js.map +0 -1
- package/lib/src/adapter.js +0 -96
- package/lib/src/adapter.js.map +0 -1
- package/lib/src/discord-agent-deps.js +0 -10
- package/lib/src/discord-agent-deps.js.map +0 -1
- package/lib/src/endpoint-interactions.js +0 -284
- package/lib/src/endpoint-interactions.js.map +0 -1
- package/lib/src/endpoint.js +0 -1093
- package/lib/src/endpoint.js.map +0 -1
- package/lib/src/index.js +0 -159
- package/lib/src/index.js.map +0 -1
- package/lib/src/platform-permit.js.map +0 -1
- package/lib/src/segment-mapper.js +0 -2
- package/lib/src/segment-mapper.js.map +0 -1
- package/lib/src/types.js +0 -2
- package/lib/src/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -108
- package/src/endpoint-interactions.ts +0 -354
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -60
package/lib/gateway.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { createReadStream, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Client, GatewayIntentBits, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, REST, Routes, PermissionFlagsBits, } from 'discord.js';
|
|
4
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
5
|
+
import { activityTypeCode, resolveChannelKind, } from './protocol.js';
|
|
6
|
+
const logger = getLogger('discord');
|
|
7
|
+
export const DEFAULT_INTENTS = [
|
|
8
|
+
GatewayIntentBits.Guilds,
|
|
9
|
+
GatewayIntentBits.GuildMessages,
|
|
10
|
+
GatewayIntentBits.MessageContent,
|
|
11
|
+
GatewayIntentBits.DirectMessages,
|
|
12
|
+
GatewayIntentBits.GuildMembers,
|
|
13
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
14
|
+
];
|
|
15
|
+
export function defaultCreateClient(intents) {
|
|
16
|
+
return new Client({ intents: [...intents] });
|
|
17
|
+
}
|
|
18
|
+
export function resolveSenderRole(msg) {
|
|
19
|
+
if (msg.isGuildOwner)
|
|
20
|
+
return 'owner';
|
|
21
|
+
const tokens = msg.permissionTokens ?? [];
|
|
22
|
+
if (tokens.includes('ADMINISTRATOR') || tokens.includes('MODERATE_MEMBERS'))
|
|
23
|
+
return 'admin';
|
|
24
|
+
if (msg.guildId)
|
|
25
|
+
return 'member';
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function normalizeDiscordMessage(raw) {
|
|
29
|
+
if (!raw || typeof raw !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
const msg = raw;
|
|
32
|
+
if (!msg.author || !msg.channel)
|
|
33
|
+
return null;
|
|
34
|
+
const permissionTokens = [];
|
|
35
|
+
let isGuildOwner = false;
|
|
36
|
+
const member = msg.member;
|
|
37
|
+
const guild = msg.guild;
|
|
38
|
+
if (member && guild) {
|
|
39
|
+
const checks = [
|
|
40
|
+
[PermissionFlagsBits.Administrator, 'ADMINISTRATOR'],
|
|
41
|
+
[PermissionFlagsBits.ManageRoles, 'MANAGE_ROLES'],
|
|
42
|
+
[PermissionFlagsBits.ModerateMembers, 'MODERATE_MEMBERS'],
|
|
43
|
+
[PermissionFlagsBits.ManageChannels, 'MANAGE_CHANNELS'],
|
|
44
|
+
[PermissionFlagsBits.ManageGuild, 'MANAGE_GUILD'],
|
|
45
|
+
];
|
|
46
|
+
for (const [bit, name] of checks) {
|
|
47
|
+
if (member.permissions.has(bit))
|
|
48
|
+
permissionTokens.push(name);
|
|
49
|
+
}
|
|
50
|
+
if (guild.ownerId === msg.author.id) {
|
|
51
|
+
isGuildOwner = true;
|
|
52
|
+
permissionTokens.push('guild_owner', 'ADMINISTRATOR');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
id: msg.id,
|
|
57
|
+
content: msg.content ?? '',
|
|
58
|
+
channelId: msg.channel.id,
|
|
59
|
+
channelKind: resolveChannelKind(msg.channel.type),
|
|
60
|
+
authorId: msg.author.id,
|
|
61
|
+
authorName: member?.displayName || msg.author.displayName || msg.author.username,
|
|
62
|
+
authorBot: msg.author.bot,
|
|
63
|
+
createdTimestamp: msg.createdTimestamp,
|
|
64
|
+
guildId: guild?.id,
|
|
65
|
+
isGuildOwner,
|
|
66
|
+
permissionTokens,
|
|
67
|
+
attachments: [...msg.attachments.values()].map((a) => ({
|
|
68
|
+
id: a.id,
|
|
69
|
+
name: a.name ?? undefined,
|
|
70
|
+
url: a.url,
|
|
71
|
+
contentType: a.contentType ?? undefined,
|
|
72
|
+
size: a.size,
|
|
73
|
+
})),
|
|
74
|
+
embedTitles: msg.embeds.map((e) => e.title || e.description || 'embed').filter(Boolean),
|
|
75
|
+
stickerNames: [...msg.stickers.values()].map((s) => s.name),
|
|
76
|
+
replyToId: msg.reference?.messageId ?? undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export async function toMessageCreateOptions(body) {
|
|
80
|
+
const options = {};
|
|
81
|
+
if (body.content)
|
|
82
|
+
options.content = body.content;
|
|
83
|
+
if (body.embeds?.length) {
|
|
84
|
+
options.embeds = body.embeds.map((data) => {
|
|
85
|
+
const embed = new EmbedBuilder();
|
|
86
|
+
if (data.title)
|
|
87
|
+
embed.setTitle(String(data.title));
|
|
88
|
+
if (data.description)
|
|
89
|
+
embed.setDescription(String(data.description));
|
|
90
|
+
if (data.color != null)
|
|
91
|
+
embed.setColor(data.color);
|
|
92
|
+
if (data.url)
|
|
93
|
+
embed.setURL(String(data.url));
|
|
94
|
+
const thumb = data.thumbnail;
|
|
95
|
+
if (thumb?.url)
|
|
96
|
+
embed.setThumbnail(thumb.url);
|
|
97
|
+
const image = data.image;
|
|
98
|
+
if (image?.url)
|
|
99
|
+
embed.setImage(image.url);
|
|
100
|
+
if (data.author)
|
|
101
|
+
embed.setAuthor(data.author);
|
|
102
|
+
if (data.footer)
|
|
103
|
+
embed.setFooter(data.footer);
|
|
104
|
+
if (data.timestamp)
|
|
105
|
+
embed.setTimestamp(new Date(String(data.timestamp)));
|
|
106
|
+
if (Array.isArray(data.fields))
|
|
107
|
+
embed.addFields(data.fields);
|
|
108
|
+
return embed;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (body.files?.length) {
|
|
112
|
+
const files = [];
|
|
113
|
+
for (const file of body.files) {
|
|
114
|
+
if (file.file && await fileExists(file.file)) {
|
|
115
|
+
files.push(new AttachmentBuilder(createReadStream(file.file), {
|
|
116
|
+
name: file.name || path.basename(file.file),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
else if (file.url) {
|
|
120
|
+
files.push(new AttachmentBuilder(file.url, { name: file.name || 'attachment' }));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (files.length)
|
|
124
|
+
options.files = files;
|
|
125
|
+
}
|
|
126
|
+
if (body.components?.length) {
|
|
127
|
+
options.components = body.components.map((row) => new ActionRowBuilder().addComponents(...row.components.map((btn) => {
|
|
128
|
+
const b = new ButtonBuilder()
|
|
129
|
+
.setCustomId(btn.custom_id)
|
|
130
|
+
.setLabel(btn.label)
|
|
131
|
+
.setDisabled(!!btn.disabled);
|
|
132
|
+
if (btn.style === 4)
|
|
133
|
+
b.setStyle(ButtonStyle.Danger);
|
|
134
|
+
else if (btn.style === 1)
|
|
135
|
+
b.setStyle(ButtonStyle.Primary);
|
|
136
|
+
else
|
|
137
|
+
b.setStyle(ButtonStyle.Secondary);
|
|
138
|
+
return b;
|
|
139
|
+
})));
|
|
140
|
+
}
|
|
141
|
+
return options;
|
|
142
|
+
}
|
|
143
|
+
async function registerSlashCommands(config, applicationId) {
|
|
144
|
+
if (!config.slashCommands?.length)
|
|
145
|
+
return;
|
|
146
|
+
const rest = new REST({ version: '10' }).setToken(config.token);
|
|
147
|
+
if (config.globalCommands) {
|
|
148
|
+
await rest.put(Routes.applicationCommands(applicationId), {
|
|
149
|
+
body: config.slashCommands,
|
|
150
|
+
});
|
|
151
|
+
logger.info(formatCompact({ op: 'slash_commands', scope: 'global' }));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function fileExists(filePath) {
|
|
155
|
+
try {
|
|
156
|
+
await fs.access(filePath);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export async function connectDiscordGatewayClient(client, config, handlers) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
client.on('messageCreate', (raw) => {
|
|
167
|
+
const msg = normalizeDiscordMessage(raw);
|
|
168
|
+
if (!msg)
|
|
169
|
+
return;
|
|
170
|
+
// clientReady 之后 client.user 一定可用;消息事件只会在此之后到达
|
|
171
|
+
const botId = client.user?.id;
|
|
172
|
+
const mentions = raw.mentions;
|
|
173
|
+
const mentionedBot = !!botId && mentions?.users?.has?.(botId) === true;
|
|
174
|
+
handlers.onMessage(mentionedBot ? { ...msg, mentionedBot: true } : msg);
|
|
175
|
+
});
|
|
176
|
+
client.on('interactionCreate', (raw) => {
|
|
177
|
+
const interaction = raw;
|
|
178
|
+
if (!interaction.isButton?.())
|
|
179
|
+
return;
|
|
180
|
+
void interaction.deferUpdate?.().catch(() => { });
|
|
181
|
+
if (!interaction.channel)
|
|
182
|
+
return;
|
|
183
|
+
handlers.onButton({
|
|
184
|
+
id: interaction.id,
|
|
185
|
+
customId: interaction.customId,
|
|
186
|
+
channelId: interaction.channel.id,
|
|
187
|
+
channelKind: resolveChannelKind(interaction.channel.type),
|
|
188
|
+
userId: interaction.user.id,
|
|
189
|
+
userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
|
|
190
|
+
sourceMessageId: interaction.message?.id,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
client.once('clientReady', () => {
|
|
194
|
+
void (async () => {
|
|
195
|
+
try {
|
|
196
|
+
if (config.defaultActivity && client.user?.setActivity) {
|
|
197
|
+
client.user.setActivity(config.defaultActivity.name, {
|
|
198
|
+
type: activityTypeCode(config.defaultActivity.type),
|
|
199
|
+
url: config.defaultActivity.url,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (config.enableSlashCommands && config.slashCommands?.length && client.user) {
|
|
203
|
+
await registerSlashCommands(config, client.user.id);
|
|
204
|
+
}
|
|
205
|
+
if (!settled) {
|
|
206
|
+
settled = true;
|
|
207
|
+
resolve();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (!settled) {
|
|
212
|
+
settled = true;
|
|
213
|
+
reject(error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
217
|
+
});
|
|
218
|
+
client.on('error', (error) => {
|
|
219
|
+
logger.error('Discord client error:', error);
|
|
220
|
+
if (!settled) {
|
|
221
|
+
settled = true;
|
|
222
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
client.on('warn', (info) => {
|
|
226
|
+
logger.warn('Discord client warning:', info);
|
|
227
|
+
});
|
|
228
|
+
client.login(config.token).catch((error) => {
|
|
229
|
+
if (!settled) {
|
|
230
|
+
settled = true;
|
|
231
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { activityTypeCode, formatButtonContent, formatInboundContent, formatOutboundBody, resolveChannelKind, resolveDiscordConfig, senderDisplayName, type DiscordAdapterConfig, type DiscordButtonInbound, type DiscordInboundAttachment, type DiscordInboundMessage, type DiscordOutboundBody, type DiscordWireSegment, type ResolvedDiscordConfig, type ResolvedDiscordGatewayConfig, type ResolvedDiscordInteractionsConfig, } from './protocol.js';
|
|
2
|
+
export { getDiscordAgentDeps, registerDiscordAgentEndpoint, setDiscordAgentDeps, type DiscordAgentDeps, type DiscordAgentEndpoint, } from './discord-agent-deps.js';
|
|
3
|
+
export { checkDiscordPlatformPermit, discordGroupPermitResolver, normalizeDiscordSenderForPermit, platformPermit, registerDiscordPlatformPermitChecker, } from './platform-permit.js';
|
|
4
|
+
export { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, type CreateDiscordClient, type DiscordClientTransport, type DiscordEndpointOptions, type DiscordInteractionsEndpointOptions, } from './endpoint.js';
|
|
5
|
+
export { connectDiscordGatewayClient, defaultCreateClient, normalizeDiscordMessage, resolveSenderRole, toMessageCreateOptions, type DiscordGatewayConnectHandlers, } from './gateway.js';
|
|
6
|
+
export { handleDiscordInteractionRequest, registerDiscordInteractionRoutes, type DiscordInteractionsHandler, } from './webhook.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { activityTypeCode, formatButtonContent, formatInboundContent, formatOutboundBody, resolveChannelKind, resolveDiscordConfig, senderDisplayName, } from './protocol.js';
|
|
2
|
+
export { getDiscordAgentDeps, registerDiscordAgentEndpoint, setDiscordAgentDeps, } from './discord-agent-deps.js';
|
|
3
|
+
export { checkDiscordPlatformPermit, discordGroupPermitResolver, normalizeDiscordSenderForPermit, platformPermit, registerDiscordPlatformPermitChecker, } from './platform-permit.js';
|
|
4
|
+
export { DiscordGatewayEndpoint, DiscordInteractionsEndpoint, } from './endpoint.js';
|
|
5
|
+
export { connectDiscordGatewayClient, defaultCreateClient, normalizeDiscordMessage, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
|
|
6
|
+
export { handleDiscordInteractionRequest, registerDiscordInteractionRoutes, } from './webhook.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord platform permit — Guild 权限位
|
|
3
|
+
*/
|
|
4
|
+
import { type Message } from '@zhin.js/core';
|
|
5
|
+
export declare function platformPermit(perm: string): string;
|
|
6
|
+
export declare function discordGroupPermitResolver(logicalPerm: string): string;
|
|
7
|
+
export declare function normalizeDiscordSenderForPermit(input: {
|
|
8
|
+
isOwner?: boolean;
|
|
9
|
+
permissions?: string[];
|
|
10
|
+
}): {
|
|
11
|
+
role?: string;
|
|
12
|
+
permissions?: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function checkDiscordPlatformPermit(perm: string, message: Message<any>): boolean;
|
|
15
|
+
export declare function registerDiscordPlatformPermitChecker(): () => void;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Discord platform permit — Guild 权限位
|
|
3
3
|
*/
|
|
4
|
-
import { registerPlatformPermitChecker } from 'zhin.js';
|
|
4
|
+
import { registerPlatformPermitChecker } from '@zhin.js/core';
|
|
5
5
|
const ADAPTER = 'discord';
|
|
6
6
|
export function platformPermit(perm) {
|
|
7
7
|
return `platform(${ADAPTER},${perm})`;
|
|
@@ -50,4 +50,3 @@ export function checkDiscordPlatformPermit(perm, message) {
|
|
|
50
50
|
export function registerDiscordPlatformPermitChecker() {
|
|
51
51
|
return registerPlatformPermitChecker(ADAPTER, checkDiscordPlatformPermit);
|
|
52
52
|
}
|
|
53
|
-
//# sourceMappingURL=platform-permit.js.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord Gateway protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
6
|
+
export interface DiscordAdapterConfig {
|
|
7
|
+
readonly name?: string;
|
|
8
|
+
readonly token?: string;
|
|
9
|
+
/** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
|
|
10
|
+
readonly connection?: 'gateway' | 'interactions';
|
|
11
|
+
readonly intents?: readonly number[];
|
|
12
|
+
readonly enableSlashCommands?: boolean;
|
|
13
|
+
readonly globalCommands?: boolean;
|
|
14
|
+
readonly defaultActivity?: {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly type: 'PLAYING' | 'STREAMING' | 'LISTENING' | 'WATCHING' | 'COMPETING';
|
|
17
|
+
readonly url?: string;
|
|
18
|
+
};
|
|
19
|
+
readonly slashCommands?: readonly Record<string, unknown>[];
|
|
20
|
+
/** Interactions-only fields. */
|
|
21
|
+
readonly applicationId?: string;
|
|
22
|
+
readonly publicKey?: string;
|
|
23
|
+
readonly interactionsPath?: string;
|
|
24
|
+
/** Transitional: legacy root `endpoints[]` with `context: discord`. */
|
|
25
|
+
readonly endpoints?: ReadonlyArray<{
|
|
26
|
+
readonly context?: string;
|
|
27
|
+
readonly name?: string;
|
|
28
|
+
readonly token?: string;
|
|
29
|
+
readonly connection?: 'gateway' | 'interactions';
|
|
30
|
+
readonly intents?: readonly number[];
|
|
31
|
+
readonly enableSlashCommands?: boolean;
|
|
32
|
+
readonly globalCommands?: boolean;
|
|
33
|
+
readonly defaultActivity?: DiscordAdapterConfig['defaultActivity'];
|
|
34
|
+
readonly slashCommands?: readonly Record<string, unknown>[];
|
|
35
|
+
readonly applicationId?: string;
|
|
36
|
+
readonly publicKey?: string;
|
|
37
|
+
readonly interactionsPath?: string;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
export interface ResolvedDiscordGatewayConfig {
|
|
41
|
+
readonly context: 'discord';
|
|
42
|
+
readonly connection: 'gateway';
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly token: string;
|
|
45
|
+
readonly intents?: readonly number[];
|
|
46
|
+
readonly enableSlashCommands: boolean;
|
|
47
|
+
readonly globalCommands: boolean;
|
|
48
|
+
readonly defaultActivity?: DiscordAdapterConfig['defaultActivity'];
|
|
49
|
+
readonly slashCommands?: readonly Record<string, unknown>[];
|
|
50
|
+
}
|
|
51
|
+
export interface ResolvedDiscordInteractionsConfig {
|
|
52
|
+
readonly context: 'discord';
|
|
53
|
+
readonly connection: 'interactions';
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly token: string;
|
|
56
|
+
readonly applicationId: string;
|
|
57
|
+
readonly publicKey: string;
|
|
58
|
+
readonly interactionsPath: string;
|
|
59
|
+
}
|
|
60
|
+
export type ResolvedDiscordConfig = ResolvedDiscordGatewayConfig | ResolvedDiscordInteractionsConfig;
|
|
61
|
+
export interface DiscordInboundAttachment {
|
|
62
|
+
readonly id?: string;
|
|
63
|
+
readonly name?: string;
|
|
64
|
+
readonly url?: string;
|
|
65
|
+
readonly contentType?: string;
|
|
66
|
+
readonly size?: number;
|
|
67
|
+
}
|
|
68
|
+
export interface DiscordInboundMessage {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly content: string;
|
|
71
|
+
readonly channelId: string;
|
|
72
|
+
readonly channelKind: 'private' | 'group' | 'channel';
|
|
73
|
+
readonly authorId: string;
|
|
74
|
+
readonly authorName: string;
|
|
75
|
+
readonly authorBot?: boolean;
|
|
76
|
+
readonly createdTimestamp: number;
|
|
77
|
+
readonly guildId?: string;
|
|
78
|
+
readonly isGuildOwner?: boolean;
|
|
79
|
+
readonly permissionTokens?: readonly string[];
|
|
80
|
+
readonly attachments?: readonly DiscordInboundAttachment[];
|
|
81
|
+
readonly embedTitles?: readonly string[];
|
|
82
|
+
readonly stickerNames?: readonly string[];
|
|
83
|
+
readonly replyToId?: string;
|
|
84
|
+
/** 入站 mentions 数组含 bot 用户时由 gateway connect 装配标注(Message.content 纯文本,@ 只能走 metadata)。 */
|
|
85
|
+
readonly mentionedBot?: boolean;
|
|
86
|
+
}
|
|
87
|
+
export interface DiscordButtonInbound {
|
|
88
|
+
readonly id: string;
|
|
89
|
+
readonly customId: string;
|
|
90
|
+
readonly channelId: string;
|
|
91
|
+
readonly channelKind: 'private' | 'group' | 'channel';
|
|
92
|
+
readonly userId: string;
|
|
93
|
+
readonly userName: string;
|
|
94
|
+
readonly sourceMessageId?: string;
|
|
95
|
+
}
|
|
96
|
+
export interface DiscordWireSegment {
|
|
97
|
+
readonly type: string;
|
|
98
|
+
readonly data?: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
export interface DiscordOutboundComponentButton {
|
|
101
|
+
type: 2;
|
|
102
|
+
custom_id: string;
|
|
103
|
+
label: string;
|
|
104
|
+
style: number;
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
}
|
|
107
|
+
export interface DiscordOutboundActionRow {
|
|
108
|
+
type: 1;
|
|
109
|
+
components: DiscordOutboundComponentButton[];
|
|
110
|
+
}
|
|
111
|
+
export interface DiscordOutboundBody {
|
|
112
|
+
readonly content?: string;
|
|
113
|
+
readonly embeds?: ReadonlyArray<Record<string, unknown>>;
|
|
114
|
+
readonly files?: ReadonlyArray<{
|
|
115
|
+
name: string;
|
|
116
|
+
url?: string;
|
|
117
|
+
file?: string;
|
|
118
|
+
}>;
|
|
119
|
+
readonly components?: ReadonlyArray<DiscordOutboundActionRow>;
|
|
120
|
+
}
|
|
121
|
+
export declare function resolveDiscordConfig(config?: DiscordAdapterConfig): ResolvedDiscordConfig;
|
|
122
|
+
export declare function resolveChannelKind(channelType: number | string | undefined): 'private' | 'group' | 'channel';
|
|
123
|
+
export declare function senderDisplayName(msg: DiscordInboundMessage): string;
|
|
124
|
+
/** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
|
|
125
|
+
export declare function formatInboundContent(msg: DiscordInboundMessage): string;
|
|
126
|
+
export declare function formatButtonContent(interaction: DiscordButtonInbound): string;
|
|
127
|
+
/**
|
|
128
|
+
* Wire-encode an already-rendered outbound payload into Discord message body.
|
|
129
|
+
* Segment canonicalization is intentionally not done here.
|
|
130
|
+
*/
|
|
131
|
+
export declare function formatOutboundBody(payload: unknown): DiscordOutboundBody;
|
|
132
|
+
export declare function activityTypeCode(type: NonNullable<DiscordAdapterConfig['defaultActivity']>['type']): number;
|
|
133
|
+
export declare function verifyDiscordInteractionSignature(publicKeyHex: string, body: string, signature: string, timestamp: string): boolean;
|
|
134
|
+
export declare function formatSlashCommandContent(interaction: Record<string, unknown>): string;
|
|
135
|
+
export declare function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage;
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord Gateway protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
6
|
+
export function resolveDiscordConfig(config = {}) {
|
|
7
|
+
const entry = config.endpoints?.find((item) => item.context === 'discord' || !item.context);
|
|
8
|
+
const token = (typeof config.token === 'string' && config.token)
|
|
9
|
+
|| (typeof entry?.token === 'string' && entry.token)
|
|
10
|
+
|| process.env.DISCORD_BOT_TOKEN
|
|
11
|
+
|| '';
|
|
12
|
+
if (!token) {
|
|
13
|
+
throw new TypeError('Discord adapter requires token (plugins.<key>.token or endpoints with context: discord)');
|
|
14
|
+
}
|
|
15
|
+
const name = (typeof config.name === 'string' && config.name)
|
|
16
|
+
|| (typeof entry?.name === 'string' && entry.name)
|
|
17
|
+
|| process.env.DISCORD_BOT_NAME
|
|
18
|
+
|| 'discord-bot';
|
|
19
|
+
const connection = config.connection
|
|
20
|
+
?? entry?.connection
|
|
21
|
+
?? 'gateway';
|
|
22
|
+
if (connection === 'interactions') {
|
|
23
|
+
const applicationId = config.applicationId || entry?.applicationId || '';
|
|
24
|
+
const publicKey = config.publicKey || entry?.publicKey || '';
|
|
25
|
+
if (!applicationId || !publicKey) {
|
|
26
|
+
throw new TypeError('Discord connection:interactions requires applicationId and publicKey');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
context: 'discord',
|
|
30
|
+
connection: 'interactions',
|
|
31
|
+
name,
|
|
32
|
+
token,
|
|
33
|
+
applicationId,
|
|
34
|
+
publicKey,
|
|
35
|
+
interactionsPath: config.interactionsPath || entry?.interactionsPath || '/discord/interactions',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
context: 'discord',
|
|
40
|
+
connection: 'gateway',
|
|
41
|
+
name,
|
|
42
|
+
token,
|
|
43
|
+
intents: config.intents ?? entry?.intents,
|
|
44
|
+
enableSlashCommands: config.enableSlashCommands === true
|
|
45
|
+
|| entry?.enableSlashCommands === true,
|
|
46
|
+
globalCommands: config.globalCommands === true || entry?.globalCommands === true,
|
|
47
|
+
defaultActivity: config.defaultActivity ?? entry?.defaultActivity,
|
|
48
|
+
slashCommands: config.slashCommands ?? entry?.slashCommands,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function resolveChannelKind(channelType) {
|
|
52
|
+
// discord.js ChannelType.DM = 1, GroupDM = 3
|
|
53
|
+
if (channelType === 1 || channelType === 'DM' || channelType === 'private')
|
|
54
|
+
return 'private';
|
|
55
|
+
if (channelType === 3 || channelType === 'GroupDM' || channelType === 'group')
|
|
56
|
+
return 'group';
|
|
57
|
+
return 'channel';
|
|
58
|
+
}
|
|
59
|
+
export function senderDisplayName(msg) {
|
|
60
|
+
return msg.authorName || msg.authorId;
|
|
61
|
+
}
|
|
62
|
+
/** Build inbound text for MessageGateway.receive (gateway owns reply routing). */
|
|
63
|
+
export function formatInboundContent(msg) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (msg.replyToId)
|
|
66
|
+
parts.push(`[reply:${msg.replyToId}]`);
|
|
67
|
+
if (msg.content?.trim())
|
|
68
|
+
parts.push(msg.content.trim());
|
|
69
|
+
for (const attachment of msg.attachments ?? []) {
|
|
70
|
+
const kind = attachment.contentType?.startsWith('image/')
|
|
71
|
+
? 'image'
|
|
72
|
+
: attachment.contentType?.startsWith('audio/')
|
|
73
|
+
? 'audio'
|
|
74
|
+
: attachment.contentType?.startsWith('video/')
|
|
75
|
+
? 'video'
|
|
76
|
+
: 'file';
|
|
77
|
+
const name = attachment.name || attachment.url || 'attachment';
|
|
78
|
+
parts.push(`[${kind}: ${name}]`);
|
|
79
|
+
}
|
|
80
|
+
for (const title of msg.embedTitles ?? []) {
|
|
81
|
+
parts.push(`[embed: ${title}]`);
|
|
82
|
+
}
|
|
83
|
+
for (const name of msg.stickerNames ?? []) {
|
|
84
|
+
parts.push(`[sticker: ${name}]`);
|
|
85
|
+
}
|
|
86
|
+
const text = parts.join('\n').trim();
|
|
87
|
+
return text || '(Empty message)';
|
|
88
|
+
}
|
|
89
|
+
export function formatButtonContent(interaction) {
|
|
90
|
+
return `[action: ${interaction.customId}]`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Wire-encode an already-rendered outbound payload into Discord message body.
|
|
94
|
+
* Segment canonicalization is intentionally not done here.
|
|
95
|
+
*/
|
|
96
|
+
export function formatOutboundBody(payload) {
|
|
97
|
+
if (typeof payload === 'string') {
|
|
98
|
+
return { content: payload };
|
|
99
|
+
}
|
|
100
|
+
const segments = Array.isArray(payload)
|
|
101
|
+
? payload
|
|
102
|
+
: payload && typeof payload === 'object' && 'type' in payload
|
|
103
|
+
? [payload]
|
|
104
|
+
: [];
|
|
105
|
+
if (segments.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
content: payload == null
|
|
108
|
+
? ''
|
|
109
|
+
: typeof payload === 'object'
|
|
110
|
+
? JSON.stringify(payload)
|
|
111
|
+
: String(payload),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
let content = '';
|
|
115
|
+
const embeds = [];
|
|
116
|
+
const files = [];
|
|
117
|
+
let components;
|
|
118
|
+
for (const item of segments) {
|
|
119
|
+
if (typeof item === 'string') {
|
|
120
|
+
content += item;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const data = item.data ?? {};
|
|
124
|
+
switch (item.type) {
|
|
125
|
+
case 'text':
|
|
126
|
+
content += String(data.text ?? data.content ?? '');
|
|
127
|
+
break;
|
|
128
|
+
case 'at':
|
|
129
|
+
content += `<@${String(data.id ?? '')}>`;
|
|
130
|
+
break;
|
|
131
|
+
case 'channel_mention':
|
|
132
|
+
content += `<#${String(data.id ?? '')}>`;
|
|
133
|
+
break;
|
|
134
|
+
case 'role_mention':
|
|
135
|
+
content += `<@&${String(data.id ?? '')}>`;
|
|
136
|
+
break;
|
|
137
|
+
case 'emoji':
|
|
138
|
+
content += data.animated
|
|
139
|
+
? `<a:${String(data.name)}:${String(data.id)}>`
|
|
140
|
+
: `<:${String(data.name)}:${String(data.id)}>`;
|
|
141
|
+
break;
|
|
142
|
+
case 'image':
|
|
143
|
+
case 'audio':
|
|
144
|
+
case 'video':
|
|
145
|
+
case 'file': {
|
|
146
|
+
const name = String(data.name || data.filename || item.type);
|
|
147
|
+
if (typeof data.file === 'string' && data.file) {
|
|
148
|
+
files.push({ name, file: data.file });
|
|
149
|
+
}
|
|
150
|
+
else if (typeof data.url === 'string' && data.url) {
|
|
151
|
+
files.push({ name, url: data.url });
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'embed':
|
|
156
|
+
embeds.push({ ...data });
|
|
157
|
+
break;
|
|
158
|
+
case 'keyboard': {
|
|
159
|
+
const rows = (data.rows ?? []);
|
|
160
|
+
components = rows.map((row) => ({
|
|
161
|
+
type: 1,
|
|
162
|
+
components: row.map((btn) => ({
|
|
163
|
+
type: 2,
|
|
164
|
+
custom_id: String(btn.payload).slice(0, 100),
|
|
165
|
+
label: btn.label,
|
|
166
|
+
style: btn.style === 'danger' ? 4 : btn.style === 'primary' ? 1 : 2,
|
|
167
|
+
disabled: !!btn.disabled,
|
|
168
|
+
})),
|
|
169
|
+
}));
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
if (data.text != null)
|
|
174
|
+
content += String(data.text);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
...(content.trim() ? { content: content.trim() } : {}),
|
|
180
|
+
...(embeds.length > 0 ? { embeds: embeds.slice(0, 10) } : {}),
|
|
181
|
+
...(files.length > 0 ? { files } : {}),
|
|
182
|
+
...(components ? { components } : {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export function activityTypeCode(type) {
|
|
186
|
+
const map = {
|
|
187
|
+
PLAYING: 0,
|
|
188
|
+
STREAMING: 1,
|
|
189
|
+
LISTENING: 2,
|
|
190
|
+
WATCHING: 3,
|
|
191
|
+
COMPETING: 5,
|
|
192
|
+
};
|
|
193
|
+
return map[type] ?? 0;
|
|
194
|
+
}
|
|
195
|
+
export function verifyDiscordInteractionSignature(publicKeyHex, body, signature, timestamp) {
|
|
196
|
+
if (!publicKeyHex || !signature || !timestamp)
|
|
197
|
+
return false;
|
|
198
|
+
try {
|
|
199
|
+
const key = createPublicKey({
|
|
200
|
+
key: Buffer.concat([
|
|
201
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
202
|
+
Buffer.from(publicKeyHex, 'hex'),
|
|
203
|
+
]),
|
|
204
|
+
format: 'der',
|
|
205
|
+
type: 'spki',
|
|
206
|
+
});
|
|
207
|
+
return cryptoVerify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex'));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function formatSlashCommandContent(interaction) {
|
|
214
|
+
const data = interaction.data;
|
|
215
|
+
const parts = [`/${data?.name ?? 'command'}`];
|
|
216
|
+
for (const opt of data?.options ?? []) {
|
|
217
|
+
parts.push(`${opt.name}:${String(opt.value)}`);
|
|
218
|
+
}
|
|
219
|
+
return parts.join(' ');
|
|
220
|
+
}
|
|
221
|
+
export function interactionToInboundMessage(interaction) {
|
|
222
|
+
const user = interaction.member?.user
|
|
223
|
+
?? interaction.user;
|
|
224
|
+
return {
|
|
225
|
+
id: String(interaction.id),
|
|
226
|
+
content: formatSlashCommandContent(interaction),
|
|
227
|
+
channelId: String(interaction.channel_id ?? ''),
|
|
228
|
+
channelKind: interaction.guild_id ? 'channel' : 'private',
|
|
229
|
+
authorId: String(user?.id ?? ''),
|
|
230
|
+
authorName: String(user?.username ?? user?.id ?? ''),
|
|
231
|
+
createdTimestamp: Date.now(),
|
|
232
|
+
guildId: interaction.guild_id != null ? String(interaction.guild_id) : undefined,
|
|
233
|
+
};
|
|
234
|
+
}
|
package/lib/webhook.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord interactions HTTP: signature → parse → admit.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
|
+
import { type DiscordInboundMessage, type ResolvedDiscordInteractionsConfig } from './protocol.js';
|
|
7
|
+
export interface DiscordInteractionsHandler {
|
|
8
|
+
readonly config: ResolvedDiscordInteractionsConfig;
|
|
9
|
+
readonly isOpen: boolean;
|
|
10
|
+
admit(msg: DiscordInboundMessage): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function registerDiscordInteractionRoutes(http: HttpHost, handler: DiscordInteractionsHandler): HttpRouteRegistration[];
|
|
13
|
+
export declare function handleDiscordInteractionRequest(request: IncomingMessage, response: ServerResponse, handler: DiscordInteractionsHandler): Promise<void>;
|