@zhin.js/adapter-discord 1.0.87 → 1.1.2
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 +949 -66
- package/PERMITS.md +25 -0
- package/README.md +60 -67
- package/adapters/discord/index.js +41 -0
- package/adapters/discord/index.ts +57 -0
- package/agents/discord/agent.json +20 -0
- package/agents/discord/boundaries.md +3 -0
- package/agents/discord/conventions.md +3 -0
- package/agents/discord/skills/discord/SKILL.md +21 -0
- package/agents/discord/skills/discord/tools/add_role/index.js +24 -0
- package/agents/discord/skills/discord/tools/add_role/index.ts +26 -0
- package/agents/discord/skills/discord/tools/create_thread/index.js +29 -0
- package/agents/discord/skills/discord/tools/create_thread/index.ts +28 -0
- package/agents/discord/skills/discord/tools/forum_post/index.js +33 -0
- package/agents/discord/skills/discord/tools/forum_post/index.ts +33 -0
- package/agents/discord/skills/discord/tools/list_roles/index.js +28 -0
- package/agents/discord/skills/discord/tools/list_roles/index.ts +34 -0
- package/agents/discord/skills/discord/tools/react/index.js +24 -0
- package/agents/discord/skills/discord/tools/react/index.ts +24 -0
- package/agents/discord/skills/discord/tools/remove_role/index.js +24 -0
- package/agents/discord/skills/discord/tools/remove_role/index.ts +26 -0
- package/agents/discord/skills/discord/tools/send_embed/index.js +43 -0
- package/agents/discord/skills/discord/tools/send_embed/index.ts +38 -0
- package/agents/discord/system.md +3 -0
- package/commands/discord/endpoint/add/[id]/index.js +3 -0
- package/commands/discord/endpoint/add/[id]/index.ts +3 -0
- package/commands/discord/endpoint/definition.js +19 -0
- package/commands/discord/endpoint/definition.ts +19 -0
- package/commands/discord/endpoint/list/index.js +3 -0
- package/commands/discord/endpoint/list/index.ts +3 -0
- package/commands/discord/endpoint/remove/[id]/index.js +3 -0
- package/commands/discord/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +16 -0
- package/lib/client.js +8 -0
- package/lib/discord-runtime-state.d.ts +1 -0
- package/lib/discord-runtime-state.js +6 -0
- package/lib/endpoint.d.ts +72 -0
- package/lib/endpoint.js +458 -0
- package/lib/gateway.d.ts +144 -0
- package/lib/gateway.js +303 -0
- package/lib/index.d.ts +6 -18
- package/lib/index.js +6 -323
- package/lib/platform-permit.d.ts +14 -0
- package/lib/platform-permit.js +42 -0
- package/lib/protocol.d.ts +145 -0
- package/lib/protocol.js +320 -0
- package/lib/side-event-dispatch.d.ts +8 -0
- package/lib/side-event-dispatch.js +24 -0
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +88 -0
- package/package.json +74 -30
- package/plugin.js +19 -0
- package/schema.json +205 -0
- package/src/client.ts +24 -0
- package/src/discord-runtime-state.ts +7 -0
- package/src/endpoint.ts +562 -0
- package/src/gateway.ts +426 -0
- package/src/index.ts +57 -323
- package/src/platform-permit.ts +57 -0
- package/src/protocol.ts +475 -0
- package/src/side-event-dispatch.ts +37 -0
- package/src/webhook.ts +123 -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 -17
- package/dist/index.js +0 -29
- package/lib/adapter.d.ts +0 -19
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -90
- package/lib/adapter.js.map +0 -1
- package/lib/bot-interactions.d.ts +0 -33
- package/lib/bot-interactions.d.ts.map +0 -1
- package/lib/bot-interactions.js +0 -278
- package/lib/bot-interactions.js.map +0 -1
- package/lib/bot.d.ts +0 -117
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -904
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -49
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/skills/discord/SKILL.md +0 -104
- package/src/adapter.ts +0 -107
- package/src/bot-interactions.ts +0 -348
- package/src/bot.ts +0 -1037
- package/src/types.ts +0 -60
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
|
|
2
|
+
|
|
3
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
4
|
+
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
5
|
+
import type { Segment } from '@zhin.js/core/runtime';
|
|
6
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
|
|
8
|
+
const logger = getLogger('discord');
|
|
9
|
+
|
|
10
|
+
export interface DiscordActivity {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly type: 'PLAYING' | 'STREAMING' | 'LISTENING' | 'WATCHING' | 'COMPETING';
|
|
13
|
+
readonly url?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
|
|
17
|
+
export interface DiscordEndpointConfig {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly token: string;
|
|
20
|
+
/** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
|
|
21
|
+
readonly connection?: 'gateway' | 'interactions';
|
|
22
|
+
readonly intents?: readonly number[];
|
|
23
|
+
readonly enableSlashCommands?: boolean;
|
|
24
|
+
readonly globalCommands?: boolean;
|
|
25
|
+
readonly defaultActivity?: DiscordActivity;
|
|
26
|
+
readonly slashCommands?: readonly Record<string, unknown>[];
|
|
27
|
+
/** Interactions-only fields. */
|
|
28
|
+
readonly applicationId?: string;
|
|
29
|
+
readonly publicKey?: string;
|
|
30
|
+
readonly interactionsPath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolvedDiscordGatewayConfig {
|
|
34
|
+
readonly context: 'discord';
|
|
35
|
+
readonly connection: 'gateway';
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly token: string;
|
|
38
|
+
readonly intents?: readonly number[];
|
|
39
|
+
readonly enableSlashCommands: boolean;
|
|
40
|
+
readonly globalCommands: boolean;
|
|
41
|
+
readonly defaultActivity?: DiscordActivity;
|
|
42
|
+
readonly slashCommands?: readonly Record<string, unknown>[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ResolvedDiscordInteractionsConfig {
|
|
46
|
+
readonly context: 'discord';
|
|
47
|
+
readonly connection: 'interactions';
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly token: string;
|
|
50
|
+
readonly applicationId: string;
|
|
51
|
+
readonly publicKey: string;
|
|
52
|
+
readonly interactionsPath: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ResolvedDiscordConfig =
|
|
56
|
+
| ResolvedDiscordGatewayConfig
|
|
57
|
+
| ResolvedDiscordInteractionsConfig;
|
|
58
|
+
|
|
59
|
+
export interface DiscordInboundAttachment {
|
|
60
|
+
readonly id?: string;
|
|
61
|
+
readonly name?: string;
|
|
62
|
+
readonly url?: string;
|
|
63
|
+
readonly contentType?: string;
|
|
64
|
+
readonly size?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DiscordInboundMessage {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly content: string;
|
|
70
|
+
readonly channelId: string;
|
|
71
|
+
readonly channelKind: 'private' | 'group' | 'channel';
|
|
72
|
+
readonly authorId: string;
|
|
73
|
+
readonly authorName: string;
|
|
74
|
+
readonly authorBot?: boolean;
|
|
75
|
+
readonly createdTimestamp: number;
|
|
76
|
+
readonly guildId?: string;
|
|
77
|
+
readonly isGuildOwner?: boolean;
|
|
78
|
+
readonly permissionTokens?: readonly string[];
|
|
79
|
+
readonly attachments?: readonly DiscordInboundAttachment[];
|
|
80
|
+
readonly embedTitles?: readonly string[];
|
|
81
|
+
readonly stickerNames?: readonly string[];
|
|
82
|
+
readonly replyToId?: string;
|
|
83
|
+
/** 入站 mentions 数组含 bot 用户时由 gateway connect 装配标注(Message.content 纯文本,@ 只能走 metadata)。 */
|
|
84
|
+
readonly mentionedBot?: boolean;
|
|
85
|
+
}
|
|
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
|
+
|
|
97
|
+
export interface DiscordWireSegment {
|
|
98
|
+
readonly type: string;
|
|
99
|
+
readonly data?: Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface DiscordOutboundComponentButton {
|
|
103
|
+
type: 2;
|
|
104
|
+
custom_id: string;
|
|
105
|
+
label: string;
|
|
106
|
+
style: number;
|
|
107
|
+
disabled?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DiscordOutboundActionRow {
|
|
111
|
+
type: 1;
|
|
112
|
+
components: DiscordOutboundComponentButton[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface DiscordOutboundBody {
|
|
116
|
+
readonly content?: string;
|
|
117
|
+
readonly embeds?: ReadonlyArray<Record<string, unknown>>;
|
|
118
|
+
readonly files?: ReadonlyArray<{
|
|
119
|
+
name: string;
|
|
120
|
+
url?: string;
|
|
121
|
+
file?: string;
|
|
122
|
+
base64?: string;
|
|
123
|
+
}>;
|
|
124
|
+
readonly components?: ReadonlyArray<DiscordOutboundActionRow>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function resolveDiscordConfig(config: DiscordEndpointConfig): ResolvedDiscordConfig {
|
|
128
|
+
const id = requiredEndpointField(config.id, 'id');
|
|
129
|
+
const token = requiredEndpointField(config.token, 'token');
|
|
130
|
+
const connection = config.connection ?? 'gateway';
|
|
131
|
+
|
|
132
|
+
if (connection === 'interactions') {
|
|
133
|
+
const applicationId = requiredEndpointField(config.applicationId, 'applicationId');
|
|
134
|
+
const publicKey = requiredEndpointField(config.publicKey, 'publicKey');
|
|
135
|
+
return {
|
|
136
|
+
context: 'discord',
|
|
137
|
+
connection: 'interactions',
|
|
138
|
+
id,
|
|
139
|
+
token,
|
|
140
|
+
applicationId,
|
|
141
|
+
publicKey,
|
|
142
|
+
interactionsPath: config.interactionsPath || '/discord/interactions',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
context: 'discord',
|
|
148
|
+
connection: 'gateway',
|
|
149
|
+
id,
|
|
150
|
+
token,
|
|
151
|
+
intents: config.intents,
|
|
152
|
+
enableSlashCommands: config.enableSlashCommands === true,
|
|
153
|
+
globalCommands: config.globalCommands === true,
|
|
154
|
+
defaultActivity: config.defaultActivity,
|
|
155
|
+
slashCommands: config.slashCommands,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requiredEndpointField(
|
|
160
|
+
value: unknown,
|
|
161
|
+
field: 'id' | 'token' | 'applicationId' | 'publicKey',
|
|
162
|
+
): string {
|
|
163
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
164
|
+
throw new TypeError(`Discord endpoint requires a non-empty ${field}`);
|
|
165
|
+
}
|
|
166
|
+
return value.trim();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function resolveChannelKind(channelType: number | string | undefined): 'private' | 'group' | 'channel' {
|
|
170
|
+
// discord.js ChannelType.DM = 1, GroupDM = 3
|
|
171
|
+
if (channelType === 1 || channelType === 'DM' || channelType === 'private') return 'private';
|
|
172
|
+
if (channelType === 3 || channelType === 'GroupDM' || channelType === 'group') return 'group';
|
|
173
|
+
return 'channel';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 入站归一化 → ConversationRef:DM → 'private';GroupDM → 'group';
|
|
178
|
+
* guild 频道 → 'channel' + guild 容器进 parent(guild 是频道容器,kind 'channel')。
|
|
179
|
+
* endpoint.adapter = owner PluginId(CapabilityId 以 \0 分隔)。
|
|
180
|
+
*/
|
|
181
|
+
export function discordInboundConversation(
|
|
182
|
+
endpointKey: string,
|
|
183
|
+
msg: {
|
|
184
|
+
readonly channelId: string;
|
|
185
|
+
readonly channelKind: 'private' | 'group' | 'channel';
|
|
186
|
+
readonly guildId?: string;
|
|
187
|
+
},
|
|
188
|
+
): ConversationRef {
|
|
189
|
+
return {
|
|
190
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
191
|
+
kind: msg.channelKind,
|
|
192
|
+
id: msg.channelId,
|
|
193
|
+
...(msg.guildId && msg.channelKind === 'channel'
|
|
194
|
+
? { parent: { kind: 'channel' as const, id: msg.guildId } }
|
|
195
|
+
: {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function senderDisplayName(msg: DiscordInboundMessage): string {
|
|
200
|
+
return msg.authorName || msg.authorId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
|
|
204
|
+
export function formatInboundContent(msg: DiscordInboundMessage): string {
|
|
205
|
+
const parts: string[] = [];
|
|
206
|
+
if (msg.content?.trim()) parts.push(msg.content.trim());
|
|
207
|
+
for (const title of msg.embedTitles ?? []) {
|
|
208
|
+
parts.push(`[embed: ${title}]`);
|
|
209
|
+
}
|
|
210
|
+
for (const name of msg.stickerNames ?? []) {
|
|
211
|
+
parts.push(`[sticker: ${name}]`);
|
|
212
|
+
}
|
|
213
|
+
const text = parts.join('\n').trim();
|
|
214
|
+
return text;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function formatButtonContent(interaction: DiscordButtonInbound): string {
|
|
218
|
+
return `[action: ${interaction.customId}]`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function attachmentMediaKind(contentType: string | undefined): 'image' | 'audio' | 'video' | 'file' {
|
|
222
|
+
if (contentType?.startsWith('image/')) return 'image';
|
|
223
|
+
if (contentType?.startsWith('audio/')) return 'audio';
|
|
224
|
+
if (contentType?.startsWith('video/')) return 'video';
|
|
225
|
+
return 'file';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 入站消息 → canonical Segment[];引用与附件不重复编码进文本视图。
|
|
230
|
+
* 附件 url 是 Discord CDN 真实 http(s) 地址,MediaRef kind=url;
|
|
231
|
+
* embeds / stickers 仅留在纯文本视图,不进段(最小侵入)。
|
|
232
|
+
*/
|
|
233
|
+
export function formatInboundSegments(msg: DiscordInboundMessage): Segment[] {
|
|
234
|
+
const out: Segment[] = [];
|
|
235
|
+
if (msg.replyToId) {
|
|
236
|
+
out.push({ type: 'reply', data: { message_id: msg.replyToId } });
|
|
237
|
+
}
|
|
238
|
+
const text = msg.content?.trim();
|
|
239
|
+
if (text) out.push({ type: 'text', data: { text } });
|
|
240
|
+
for (const attachment of msg.attachments ?? []) {
|
|
241
|
+
if (!attachment.url) continue;
|
|
242
|
+
const kind = attachmentMediaKind(attachment.contentType);
|
|
243
|
+
out.push({
|
|
244
|
+
type: kind,
|
|
245
|
+
data: {
|
|
246
|
+
media: {
|
|
247
|
+
kind: 'url',
|
|
248
|
+
value: attachment.url,
|
|
249
|
+
...(attachment.contentType ? { mime_type: attachment.contentType } : {}),
|
|
250
|
+
},
|
|
251
|
+
...(attachment.name
|
|
252
|
+
? kind === 'image'
|
|
253
|
+
? { alt: attachment.name }
|
|
254
|
+
: { name: attachment.name }
|
|
255
|
+
: {}),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* button interaction → action 段(Wave 1 C interactive 约定:
|
|
264
|
+
* {type:'action', data:{id, payload, sourceMessageId?}}),
|
|
265
|
+
* 与 formatButtonContent / metadata.payload 同源。
|
|
266
|
+
*/
|
|
267
|
+
export function formatButtonSegments(interaction: DiscordButtonInbound): Segment[] {
|
|
268
|
+
return [{
|
|
269
|
+
type: 'action',
|
|
270
|
+
data: {
|
|
271
|
+
id: interaction.id,
|
|
272
|
+
payload: interaction.customId,
|
|
273
|
+
...(interaction.sourceMessageId
|
|
274
|
+
? { sourceMessageId: interaction.sourceMessageId }
|
|
275
|
+
: {}),
|
|
276
|
+
},
|
|
277
|
+
}];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Wire-encode an already-rendered outbound payload into Discord message body.
|
|
282
|
+
* Segment canonicalization is intentionally not done here.
|
|
283
|
+
*/
|
|
284
|
+
export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
285
|
+
if (typeof payload === 'string') {
|
|
286
|
+
return { content: payload };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const segments: Array<string | DiscordWireSegment> = Array.isArray(payload)
|
|
290
|
+
? payload as Array<string | DiscordWireSegment>
|
|
291
|
+
: payload && typeof payload === 'object' && 'type' in (payload as object)
|
|
292
|
+
? [payload as DiscordWireSegment]
|
|
293
|
+
: [];
|
|
294
|
+
|
|
295
|
+
if (segments.length === 0) {
|
|
296
|
+
return {
|
|
297
|
+
content: payload == null
|
|
298
|
+
? ''
|
|
299
|
+
: typeof payload === 'object'
|
|
300
|
+
? JSON.stringify(payload)
|
|
301
|
+
: String(payload),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let content = '';
|
|
306
|
+
const embeds: Record<string, unknown>[] = [];
|
|
307
|
+
const files: Array<{ name: string; url?: string; file?: string; base64?: string }> = [];
|
|
308
|
+
let components: DiscordOutboundBody['components'];
|
|
309
|
+
|
|
310
|
+
for (const item of segments) {
|
|
311
|
+
if (typeof item === 'string') {
|
|
312
|
+
content += item;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const data = item.data ?? {};
|
|
316
|
+
switch (item.type) {
|
|
317
|
+
case 'text':
|
|
318
|
+
content += String(data.text ?? data.content ?? '');
|
|
319
|
+
break;
|
|
320
|
+
case 'markdown':
|
|
321
|
+
content += String(data.content ?? data.text ?? '');
|
|
322
|
+
break;
|
|
323
|
+
case 'at':
|
|
324
|
+
content += `<@${String(data.id ?? '')}>`;
|
|
325
|
+
break;
|
|
326
|
+
case 'channel_mention':
|
|
327
|
+
content += `<#${String(data.id ?? '')}>`;
|
|
328
|
+
break;
|
|
329
|
+
case 'role_mention':
|
|
330
|
+
content += `<@&${String(data.id ?? '')}>`;
|
|
331
|
+
break;
|
|
332
|
+
case 'emoji':
|
|
333
|
+
content += data.animated
|
|
334
|
+
? `<a:${String(data.name)}:${String(data.id)}>`
|
|
335
|
+
: `<:${String(data.name)}:${String(data.id)}>`;
|
|
336
|
+
break;
|
|
337
|
+
case 'image':
|
|
338
|
+
case 'audio':
|
|
339
|
+
case 'video':
|
|
340
|
+
case 'file': {
|
|
341
|
+
// canonical MediaRef 唯一媒体来源(中央 normalizeOutboundPayload 已保证形状)
|
|
342
|
+
const media = data.media;
|
|
343
|
+
if (!isMediaRef(media)) {
|
|
344
|
+
logger.warn(formatCompact({
|
|
345
|
+
op: 'discord_outbound_media_dropped',
|
|
346
|
+
type: item.type,
|
|
347
|
+
reason: 'missing_media_ref',
|
|
348
|
+
}));
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
const name = media.file_name
|
|
352
|
+
?? (typeof data.name === 'string' && data.name ? data.name : item.type);
|
|
353
|
+
if (media.kind === 'url') {
|
|
354
|
+
files.push({ name, url: media.value });
|
|
355
|
+
} else if (media.kind === 'base64') {
|
|
356
|
+
files.push({ name, base64: media.value });
|
|
357
|
+
} else if (media.kind === 'path') {
|
|
358
|
+
files.push({ name, file: media.value });
|
|
359
|
+
} else {
|
|
360
|
+
// kind=file:Discord 无平台不透明文件引用,无法投递
|
|
361
|
+
logger.warn(formatCompact({
|
|
362
|
+
op: 'discord_outbound_media_dropped',
|
|
363
|
+
type: item.type,
|
|
364
|
+
reason: 'unsupported_media_kind',
|
|
365
|
+
kind: media.kind,
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case 'embed':
|
|
371
|
+
embeds.push({ ...data });
|
|
372
|
+
break;
|
|
373
|
+
case 'keyboard': {
|
|
374
|
+
const rows = (data.rows ?? []) as Array<Array<{
|
|
375
|
+
label: string;
|
|
376
|
+
payload: string;
|
|
377
|
+
disabled?: boolean;
|
|
378
|
+
style?: string;
|
|
379
|
+
}>>;
|
|
380
|
+
components = rows.map((row) => ({
|
|
381
|
+
type: 1 as const,
|
|
382
|
+
components: row.map((btn) => ({
|
|
383
|
+
type: 2 as const,
|
|
384
|
+
custom_id: String(btn.payload).slice(0, 100),
|
|
385
|
+
label: btn.label,
|
|
386
|
+
style: btn.style === 'danger' ? 4 : btn.style === 'primary' ? 1 : 2,
|
|
387
|
+
disabled: !!btn.disabled,
|
|
388
|
+
})),
|
|
389
|
+
}));
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
default:
|
|
393
|
+
if (data.text != null) content += String(data.text);
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
...(content.trim() ? { content: content.trim() } : {}),
|
|
400
|
+
...(embeds.length > 0 ? { embeds: embeds.slice(0, 10) } : {}),
|
|
401
|
+
...(files.length > 0 ? { files } : {}),
|
|
402
|
+
...(components ? { components } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function activityTypeCode(
|
|
407
|
+
type: DiscordActivity['type'],
|
|
408
|
+
): number {
|
|
409
|
+
const map = {
|
|
410
|
+
PLAYING: 0,
|
|
411
|
+
STREAMING: 1,
|
|
412
|
+
LISTENING: 2,
|
|
413
|
+
WATCHING: 3,
|
|
414
|
+
COMPETING: 5,
|
|
415
|
+
} as const;
|
|
416
|
+
return map[type] ?? 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** interactions 验签时间戳时效窗口(防重放)。 */
|
|
420
|
+
export const DISCORD_SIGNATURE_MAX_AGE_MS = 5 * 60_000;
|
|
421
|
+
|
|
422
|
+
export function verifyDiscordInteractionSignature(
|
|
423
|
+
publicKeyHex: string,
|
|
424
|
+
body: string,
|
|
425
|
+
signature: string,
|
|
426
|
+
timestamp: string,
|
|
427
|
+
nowMs: number = Date.now(),
|
|
428
|
+
): boolean {
|
|
429
|
+
if (!publicKeyHex || !signature || !timestamp) return false;
|
|
430
|
+
try {
|
|
431
|
+
// 时效窗口 ±5min,超出直接拒绝(timestamp 为秒)
|
|
432
|
+
const ts = Number(timestamp);
|
|
433
|
+
if (!Number.isFinite(ts) || Math.abs(nowMs - ts * 1000) > DISCORD_SIGNATURE_MAX_AGE_MS) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
const key = createPublicKey({
|
|
437
|
+
key: Buffer.concat([
|
|
438
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
439
|
+
Buffer.from(publicKeyHex, 'hex'),
|
|
440
|
+
]),
|
|
441
|
+
format: 'der',
|
|
442
|
+
type: 'spki',
|
|
443
|
+
});
|
|
444
|
+
return cryptoVerify(null, Buffer.from(timestamp + body), key, Buffer.from(signature, 'hex'));
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function formatSlashCommandContent(interaction: Record<string, unknown>): string {
|
|
451
|
+
const data = interaction.data as {
|
|
452
|
+
name?: string;
|
|
453
|
+
options?: Array<{ name: string; value: unknown }>;
|
|
454
|
+
} | undefined;
|
|
455
|
+
const parts = [`/${data?.name ?? 'command'}`];
|
|
456
|
+
for (const opt of data?.options ?? []) {
|
|
457
|
+
parts.push(`${opt.name}:${String(opt.value)}`);
|
|
458
|
+
}
|
|
459
|
+
return parts.join(' ');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function interactionToInboundMessage(interaction: Record<string, unknown>): DiscordInboundMessage {
|
|
463
|
+
const user = (interaction.member as { user?: Record<string, unknown> } | undefined)?.user
|
|
464
|
+
?? (interaction.user as Record<string, unknown> | undefined);
|
|
465
|
+
return {
|
|
466
|
+
id: String(interaction.id),
|
|
467
|
+
content: formatSlashCommandContent(interaction),
|
|
468
|
+
channelId: String(interaction.channel_id ?? ''),
|
|
469
|
+
channelKind: interaction.guild_id ? 'channel' : 'private',
|
|
470
|
+
authorId: String(user?.id ?? ''),
|
|
471
|
+
authorName: String(user?.username ?? user?.id ?? ''),
|
|
472
|
+
createdTimestamp: Date.now(),
|
|
473
|
+
guildId: interaction.guild_id != null ? String(interaction.guild_id) : undefined,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildNotice, senderFromId } from '@zhin.js/core';
|
|
2
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
|
+
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
|
+
|
|
5
|
+
export interface DiscordGuildMemberSideEvent {
|
|
6
|
+
readonly guildId: string;
|
|
7
|
+
readonly userId: string;
|
|
8
|
+
readonly userName?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function receiveDiscordGuildMemberSideEvent(
|
|
12
|
+
emit: EndpointEventEmitter,
|
|
13
|
+
configId: string,
|
|
14
|
+
kind: 'member_increase' | 'member_decrease',
|
|
15
|
+
event: DiscordGuildMemberSideEvent,
|
|
16
|
+
logger: ReturnType<typeof getAdapterLogger>,
|
|
17
|
+
): void {
|
|
18
|
+
if (!emit) return;
|
|
19
|
+
void emit('notice.receive', buildNotice(event, {
|
|
20
|
+
$id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
|
|
21
|
+
$adapter: 'discord' as never,
|
|
22
|
+
$endpoint: configId,
|
|
23
|
+
$type: 'notice',
|
|
24
|
+
$scene_id: event.guildId,
|
|
25
|
+
$scene_type: 'group',
|
|
26
|
+
$sub_type: kind,
|
|
27
|
+
$actor: senderFromId(event.userId, event.userName),
|
|
28
|
+
$timestamp: Date.now(),
|
|
29
|
+
})).catch((err) => {
|
|
30
|
+
logger.warn(formatCompact({
|
|
31
|
+
op: 'discord_side_event_failed',
|
|
32
|
+
endpoint: configId,
|
|
33
|
+
event: kind,
|
|
34
|
+
error: err instanceof Error ? err.message : String(err),
|
|
35
|
+
}));
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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 { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
import {
|
|
8
|
+
interactionToInboundMessage,
|
|
9
|
+
verifyDiscordInteractionSignature,
|
|
10
|
+
type DiscordInboundMessage,
|
|
11
|
+
type ResolvedDiscordInteractionsConfig,
|
|
12
|
+
} from './protocol.js';
|
|
13
|
+
|
|
14
|
+
const logger = getLogger('discord');
|
|
15
|
+
|
|
16
|
+
const INTERACTION_TYPE_PING = 1;
|
|
17
|
+
const INTERACTION_TYPE_APPLICATION_COMMAND = 2;
|
|
18
|
+
const INTERACTION_RESPONSE_PONG = 1;
|
|
19
|
+
const INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE = 4;
|
|
20
|
+
/** EPHEHEMERAL — 仅发起者可见(对齐旧 endpoint-interactions 默认响应)。 */
|
|
21
|
+
const INTERACTION_FLAG_EPHEMERAL = 64;
|
|
22
|
+
|
|
23
|
+
export interface DiscordInteractionsHandler {
|
|
24
|
+
readonly config: ResolvedDiscordInteractionsConfig;
|
|
25
|
+
readonly isOpen: boolean;
|
|
26
|
+
admit(msg: DiscordInboundMessage): void;
|
|
27
|
+
admitPlatform(event: Record<string, unknown>): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerDiscordInteractionRoutes(
|
|
31
|
+
http: HttpHost,
|
|
32
|
+
handler: DiscordInteractionsHandler,
|
|
33
|
+
): HttpRouteRegistration[] {
|
|
34
|
+
const path = handler.config.interactionsPath;
|
|
35
|
+
return [
|
|
36
|
+
http.route('POST', path, async (request, response) => {
|
|
37
|
+
await handleDiscordInteractionRequest(request, response, handler);
|
|
38
|
+
}, { summary: 'Discord interactions callback', tags: ['discord'] }),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function handleDiscordInteractionRequest(
|
|
43
|
+
request: IncomingMessage,
|
|
44
|
+
response: ServerResponse,
|
|
45
|
+
handler: DiscordInteractionsHandler,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
try {
|
|
48
|
+
const signature = headerValue(request.headers['x-signature-ed25519']);
|
|
49
|
+
const timestamp = headerValue(request.headers['x-signature-timestamp']);
|
|
50
|
+
const rawBody = await readInteractionBody(request);
|
|
51
|
+
if (!signature || !timestamp) {
|
|
52
|
+
response.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
53
|
+
response.end('Unauthorized');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!verifyDiscordInteractionSignature(
|
|
57
|
+
handler.config.publicKey,
|
|
58
|
+
rawBody,
|
|
59
|
+
signature,
|
|
60
|
+
timestamp,
|
|
61
|
+
)) {
|
|
62
|
+
response.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
63
|
+
response.end('Unauthorized');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const interaction = JSON.parse(rawBody) as Record<string, unknown>;
|
|
67
|
+
if (handler.isOpen) handler.admitPlatform(interaction);
|
|
68
|
+
if (interaction.type === INTERACTION_TYPE_PING) {
|
|
69
|
+
writeJson(response, 200, { type: INTERACTION_RESPONSE_PONG });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
|
|
73
|
+
if (handler.isOpen) {
|
|
74
|
+
handler.admit(interactionToInboundMessage(interaction));
|
|
75
|
+
}
|
|
76
|
+
// 即时响应(type 4):defer(type 5) 需要 followup PATCH,未实现会让用户端一直转圈
|
|
77
|
+
const commandName = String(
|
|
78
|
+
(interaction.data as { name?: unknown } | undefined)?.name ?? '',
|
|
79
|
+
);
|
|
80
|
+
writeJson(response, 200, {
|
|
81
|
+
type: INTERACTION_RESPONSE_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
82
|
+
data: {
|
|
83
|
+
content: `处理命令: ${commandName}`,
|
|
84
|
+
flags: INTERACTION_FLAG_EPHEMERAL,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
response.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
90
|
+
response.end('Unsupported interaction type');
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.error('Discord interactions error:', error);
|
|
93
|
+
if (!response.headersSent) {
|
|
94
|
+
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
95
|
+
response.end('Internal Server Error');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function headerValue(value: string | string[] | undefined): string {
|
|
101
|
+
if (Array.isArray(value)) return value[0] ?? '';
|
|
102
|
+
return value ?? '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readInteractionBody(request: IncomingMessage): Promise<string> {
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
let size = 0;
|
|
108
|
+
for await (const chunk of request) {
|
|
109
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
110
|
+
size += buffer.length;
|
|
111
|
+
if (size > 1_048_576) {
|
|
112
|
+
request.destroy();
|
|
113
|
+
throw new Error('Request body exceeds 1MB');
|
|
114
|
+
}
|
|
115
|
+
chunks.push(buffer);
|
|
116
|
+
}
|
|
117
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeJson(response: ServerResponse, status: number, body: unknown): void {
|
|
121
|
+
response.writeHead(status, { 'Content-Type': 'application/json' });
|
|
122
|
+
response.end(JSON.stringify(body));
|
|
123
|
+
}
|