@zhin.js/adapter-discord 1.1.3 → 1.1.5
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 +45 -0
- package/README.md +27 -0
- package/lib/endpoint.d.ts +4 -2
- package/lib/endpoint.js +144 -51
- package/lib/gateway-fault-proxy.d.ts +19 -0
- package/lib/gateway-fault-proxy.js +104 -0
- package/lib/gateway.d.ts +5 -4
- package/lib/gateway.js +43 -10
- package/lib/protocol.d.ts +11 -1
- package/lib/protocol.js +23 -1
- package/lib/rest-api-proxy.d.ts +8 -0
- package/lib/rest-api-proxy.js +47 -0
- package/lib/side-event-dispatch.js +9 -10
- package/lib/webhook.d.ts +2 -1
- package/lib/webhook.js +14 -0
- package/package.json +18 -15
- package/schema.json +25 -0
- package/src/endpoint.ts +100 -34
- package/src/gateway-fault-proxy.ts +82 -0
- package/src/gateway.ts +40 -9
- package/src/protocol.ts +29 -1
- package/src/rest-api-proxy.ts +37 -0
- package/src/side-event-dispatch.ts +9 -10
- package/src/webhook.ts +12 -0
package/src/gateway.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { DiscordFaultProxyStrategy } from './gateway-fault-proxy.js';
|
|
1
2
|
import { createReadStream, promises as fs } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {
|
|
4
5
|
Client,
|
|
6
|
+
Partials,
|
|
5
7
|
GatewayIntentBits,
|
|
6
8
|
EmbedBuilder,
|
|
7
9
|
AttachmentBuilder,
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
ButtonBuilder,
|
|
10
12
|
ButtonStyle,
|
|
11
13
|
REST,
|
|
14
|
+
type RESTOptions,
|
|
12
15
|
Routes,
|
|
13
16
|
PermissionFlagsBits,
|
|
14
17
|
type MessageCreateOptions,
|
|
@@ -37,6 +40,7 @@ export const DEFAULT_INTENTS = [
|
|
|
37
40
|
|
|
38
41
|
/** Minimal client surface used by the endpoint (real discord.js or test mock). */
|
|
39
42
|
export interface DiscordClientTransport {
|
|
43
|
+
isReady?(): boolean;
|
|
40
44
|
login(token: string): Promise<string>;
|
|
41
45
|
destroy(): Promise<void>;
|
|
42
46
|
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
@@ -109,10 +113,10 @@ export interface DiscordClientTransport {
|
|
|
109
113
|
};
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
export type CreateDiscordClient = (intents: readonly number[]) => DiscordClientTransport;
|
|
116
|
+
export type CreateDiscordClient = (intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions?: Partial<RESTOptions>) => DiscordClientTransport;
|
|
113
117
|
|
|
114
|
-
export function defaultCreateClient(intents: readonly number[]): DiscordClientTransport {
|
|
115
|
-
return new Client({ intents: [...intents] }) as unknown as DiscordClientTransport;
|
|
118
|
+
export function defaultCreateClient(intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions: Partial<RESTOptions> = {}): DiscordClientTransport {
|
|
119
|
+
return new Client({ rest: { ...restOptions, retries: 0, rejectOnRateLimit: () => true }, intents: [...intents], partials: [Partials.Channel], ...(gatewayFaultProxyUrl ? { ws: { buildStrategy: manager => new DiscordFaultProxyStrategy(manager, gatewayFaultProxyUrl) } } : {}) }) as unknown as DiscordClientTransport;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
export function resolveSenderRole(msg: DiscordInboundMessage): string | undefined {
|
|
@@ -176,6 +180,7 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
|
|
|
176
180
|
|
|
177
181
|
export async function toMessageCreateOptions(body: DiscordOutboundBody): Promise<MessageCreateOptions> {
|
|
178
182
|
const options: MessageCreateOptions = {};
|
|
183
|
+
if (body.reply) options.reply = { ...body.reply };
|
|
179
184
|
if (body.content) options.content = body.content;
|
|
180
185
|
if (body.embeds?.length) {
|
|
181
186
|
options.embeds = body.embeds.map((data) => {
|
|
@@ -296,9 +301,14 @@ export async function connectDiscordGatewayClient(
|
|
|
296
301
|
client: DiscordClientTransport,
|
|
297
302
|
config: ResolvedDiscordGatewayConfig,
|
|
298
303
|
handlers: DiscordGatewayConnectHandlers,
|
|
304
|
+
signal?: AbortSignal,
|
|
299
305
|
): Promise<void> {
|
|
300
306
|
return new Promise((resolve, reject) => {
|
|
301
307
|
let settled = false;
|
|
308
|
+
const onAbort = () => { if (!settled) { settled = true; reject(new Error('Discord startup stopped')); } };
|
|
309
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
310
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
311
|
+
const releaseAbort = () => signal?.removeEventListener('abort', onAbort);
|
|
302
312
|
|
|
303
313
|
client.on('messageCreate', (raw) => {
|
|
304
314
|
handlers.onPlatformEvent('messageCreate', raw);
|
|
@@ -319,17 +329,31 @@ export async function connectDiscordGatewayClient(
|
|
|
319
329
|
id: string;
|
|
320
330
|
customId: string;
|
|
321
331
|
channel?: { id: string; type: number } | null;
|
|
332
|
+
channelId?: string;
|
|
333
|
+
guildId?: string | null;
|
|
322
334
|
user: { id: string; username?: string; displayName?: string };
|
|
323
335
|
message?: { id: string };
|
|
324
336
|
};
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (!
|
|
337
|
+
const isButton = interaction.isButton?.() === true;
|
|
338
|
+
logger.info(formatCompact({ op: 'discord_gateway_interaction_received', button: isButton, cachedChannel: !!interaction.channel, hasChannelId: !!interaction.channelId }));
|
|
339
|
+
if (!isButton) return;
|
|
340
|
+
void interaction.deferUpdate?.().then(() => {
|
|
341
|
+
logger.info(formatCompact({ op: 'discord_gateway_button_ack', status: 'confirmed' }));
|
|
342
|
+
}).catch(() => {
|
|
343
|
+
// SDK errors may contain token-bearing callback URLs; log no raw error.
|
|
344
|
+
logger.warn(formatCompact({ op: 'discord_gateway_button_ack', status: 'failed' }));
|
|
345
|
+
});
|
|
346
|
+
const channelId = interaction.channelId ?? interaction.channel?.id;
|
|
347
|
+
if (!channelId) {
|
|
348
|
+
logger.warn(formatCompact({ op: 'discord_gateway_button_dropped', reason: 'missing_channel_id' }));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
328
351
|
handlers.onButton({
|
|
329
352
|
id: interaction.id,
|
|
330
353
|
customId: interaction.customId,
|
|
331
|
-
channelId
|
|
332
|
-
channelKind: resolveChannelKind(interaction.channel.type),
|
|
354
|
+
channelId,
|
|
355
|
+
channelKind: interaction.channel ? resolveChannelKind(interaction.channel.type) : interaction.guildId ? 'channel' : 'private',
|
|
356
|
+
...(interaction.guildId ? { guildId: interaction.guildId } : {}),
|
|
333
357
|
userId: interaction.user.id,
|
|
334
358
|
userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
|
|
335
359
|
sourceMessageId: interaction.message?.id,
|
|
@@ -369,6 +393,7 @@ export async function connectDiscordGatewayClient(
|
|
|
369
393
|
});
|
|
370
394
|
|
|
371
395
|
client.once('clientReady', () => {
|
|
396
|
+
if (signal?.aborted) return;
|
|
372
397
|
handlers.onPlatformEvent('clientReady', client.user);
|
|
373
398
|
void (async () => {
|
|
374
399
|
try {
|
|
@@ -384,11 +409,13 @@ export async function connectDiscordGatewayClient(
|
|
|
384
409
|
}
|
|
385
410
|
if (!settled) {
|
|
386
411
|
settled = true;
|
|
412
|
+
releaseAbort();
|
|
387
413
|
resolve();
|
|
388
414
|
}
|
|
389
415
|
} catch (error) {
|
|
390
416
|
if (!settled) {
|
|
391
417
|
settled = true;
|
|
418
|
+
releaseAbort();
|
|
392
419
|
reject(error);
|
|
393
420
|
}
|
|
394
421
|
}
|
|
@@ -399,6 +426,7 @@ export async function connectDiscordGatewayClient(
|
|
|
399
426
|
logger.error('Discord client error:', error);
|
|
400
427
|
if (!settled) {
|
|
401
428
|
settled = true;
|
|
429
|
+
releaseAbort();
|
|
402
430
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
403
431
|
}
|
|
404
432
|
});
|
|
@@ -407,9 +435,12 @@ export async function connectDiscordGatewayClient(
|
|
|
407
435
|
logger.warn('Discord client warning:', info);
|
|
408
436
|
});
|
|
409
437
|
|
|
410
|
-
client.login(config.token).
|
|
438
|
+
client.login(config.token).then(() => {
|
|
439
|
+
if (signal?.aborted) void Promise.resolve(client.destroy()).catch(() => {});
|
|
440
|
+
}).catch((error) => {
|
|
411
441
|
if (!settled) {
|
|
412
442
|
settled = true;
|
|
443
|
+
releaseAbort();
|
|
413
444
|
const raw = error instanceof Error ? error.message : String(error);
|
|
414
445
|
const tokenShapeHint = config.token.split('.').length === 3
|
|
415
446
|
? ''
|
package/src/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
|
|
1
2
|
/** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
|
|
2
3
|
|
|
3
4
|
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
@@ -19,6 +20,9 @@ export interface DiscordEndpointConfig {
|
|
|
19
20
|
readonly token: string;
|
|
20
21
|
/** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
|
|
21
22
|
readonly connection?: 'gateway' | 'interactions';
|
|
23
|
+
/** Test instance only: ws://127.0.0.1:PORT/ fault proxy. */
|
|
24
|
+
readonly restApiProxy?: import('./rest-api-proxy.js').DiscordRestApiProxy;
|
|
25
|
+
readonly gatewayFaultProxyUrl?: string;
|
|
22
26
|
readonly intents?: readonly number[];
|
|
23
27
|
readonly enableSlashCommands?: boolean;
|
|
24
28
|
readonly globalCommands?: boolean;
|
|
@@ -35,6 +39,9 @@ export interface ResolvedDiscordGatewayConfig {
|
|
|
35
39
|
readonly connection: 'gateway';
|
|
36
40
|
readonly id: string;
|
|
37
41
|
readonly token: string;
|
|
42
|
+
/** Test instance only: ws://127.0.0.1:PORT/ fault proxy. */
|
|
43
|
+
readonly restApiProxy?: import('./rest-api-proxy.js').DiscordRestApiProxy;
|
|
44
|
+
readonly gatewayFaultProxyUrl?: string;
|
|
38
45
|
readonly intents?: readonly number[];
|
|
39
46
|
readonly enableSlashCommands: boolean;
|
|
40
47
|
readonly globalCommands: boolean;
|
|
@@ -85,6 +92,7 @@ export interface DiscordInboundMessage {
|
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
export interface DiscordButtonInbound {
|
|
95
|
+
readonly guildId?: string;
|
|
88
96
|
readonly id: string;
|
|
89
97
|
readonly customId: string;
|
|
90
98
|
readonly channelId: string;
|
|
@@ -113,6 +121,7 @@ export interface DiscordOutboundActionRow {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
export interface DiscordOutboundBody {
|
|
124
|
+
readonly reply?: { readonly messageReference: string; readonly failIfNotExists: boolean };
|
|
116
125
|
readonly content?: string;
|
|
117
126
|
readonly embeds?: ReadonlyArray<Record<string, unknown>>;
|
|
118
127
|
readonly files?: ReadonlyArray<{
|
|
@@ -125,6 +134,8 @@ export interface DiscordOutboundBody {
|
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
export function resolveDiscordConfig(config: DiscordEndpointConfig): ResolvedDiscordConfig {
|
|
137
|
+
if (config.restApiProxy && (!Number.isInteger(config.restApiProxy.port) || config.restApiProxy.port < 1 || config.restApiProxy.port > 65535)) throw new TypeError('Invalid Discord restApiProxy port');
|
|
138
|
+
if (config.restApiProxy && config.connection === 'interactions') throw new TypeError('Discord restApiProxy requires gateway mode');
|
|
128
139
|
const id = requiredEndpointField(config.id, 'id');
|
|
129
140
|
const token = requiredEndpointField(config.token, 'token');
|
|
130
141
|
const connection = config.connection ?? 'gateway';
|
|
@@ -148,6 +159,8 @@ export function resolveDiscordConfig(config: DiscordEndpointConfig): ResolvedDis
|
|
|
148
159
|
connection: 'gateway',
|
|
149
160
|
id,
|
|
150
161
|
token,
|
|
162
|
+
...(config.restApiProxy ? { restApiProxy: config.restApiProxy } : {}),
|
|
163
|
+
gatewayFaultProxyUrl: config.gatewayFaultProxyUrl ? validateDiscordGatewayFaultProxy(config.gatewayFaultProxyUrl) : undefined,
|
|
151
164
|
intents: config.intents,
|
|
152
165
|
enableSlashCommands: config.enableSlashCommands === true,
|
|
153
166
|
globalCommands: config.globalCommands === true,
|
|
@@ -306,6 +319,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
306
319
|
const embeds: Record<string, unknown>[] = [];
|
|
307
320
|
const files: Array<{ name: string; url?: string; file?: string; base64?: string }> = [];
|
|
308
321
|
let components: DiscordOutboundBody['components'];
|
|
322
|
+
let reply: DiscordOutboundBody['reply'];
|
|
309
323
|
|
|
310
324
|
for (const item of segments) {
|
|
311
325
|
if (typeof item === 'string') {
|
|
@@ -348,8 +362,13 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
348
362
|
}));
|
|
349
363
|
break;
|
|
350
364
|
}
|
|
365
|
+
const extension = ({
|
|
366
|
+
'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
|
|
367
|
+
'image/webp': 'webp', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg',
|
|
368
|
+
'audio/wav': 'wav', 'video/mp4': 'mp4', 'video/webm': 'webm',
|
|
369
|
+
} as Record<string, string>)[media.mime_type?.split(';')[0].trim().toLowerCase() ?? ''];
|
|
351
370
|
const name = media.file_name
|
|
352
|
-
?? (typeof data.name === 'string' && data.name ? data.name : item.type);
|
|
371
|
+
?? (typeof data.name === 'string' && data.name ? data.name : `${item.type}${extension ? `.${extension}` : ''}`);
|
|
353
372
|
if (media.kind === 'url') {
|
|
354
373
|
files.push({ name, url: media.value });
|
|
355
374
|
} else if (media.kind === 'base64') {
|
|
@@ -367,6 +386,14 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
367
386
|
}
|
|
368
387
|
break;
|
|
369
388
|
}
|
|
389
|
+
case 'reply':
|
|
390
|
+
if (typeof data.message_id === 'string' && data.message_id) {
|
|
391
|
+
reply = { messageReference: data.message_id, failIfNotExists: true };
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
case 'share':
|
|
395
|
+
embeds.push({ title: String(data.title ?? ''), url: String(data.url ?? ''), description: String(data.description ?? data.content ?? ''), ...(data.image ? { image: { url: String(data.image) } } : {}) });
|
|
396
|
+
break;
|
|
370
397
|
case 'embed':
|
|
371
398
|
embeds.push({ ...data });
|
|
372
399
|
break;
|
|
@@ -400,6 +427,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
|
|
|
400
427
|
...(embeds.length > 0 ? { embeds: embeds.slice(0, 10) } : {}),
|
|
401
428
|
...(files.length > 0 ? { files } : {}),
|
|
402
429
|
...(components ? { components } : {}),
|
|
430
|
+
...(reply ? { reply } : {}),
|
|
403
431
|
};
|
|
404
432
|
}
|
|
405
433
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import { Agent } from 'undici';
|
|
3
|
+
|
|
4
|
+
export interface DiscordRestApiProxy { readonly port: number }
|
|
5
|
+
|
|
6
|
+
/** JSON REST only, fixed Discord TLS identity and loopback TCP route. */
|
|
7
|
+
export function createDiscordRestApiAgent(proxy: DiscordRestApiProxy): Agent & { destroyProxy(): Promise<void> } {
|
|
8
|
+
const pending = new Set<() => void>();
|
|
9
|
+
let closed = false;
|
|
10
|
+
const agent = new Agent({ connect(options, callback) {
|
|
11
|
+
if (closed) { queueMicrotask(() => callback(new Error('Discord REST proxy stopped'), null)); return; }
|
|
12
|
+
if (options.hostname !== 'discord.com' || options.protocol !== 'https:' || Number(options.port || 443) !== 443) {
|
|
13
|
+
queueMicrotask(() => callback(new Error('Discord REST proxy rejected destination identity'), null));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const socket = tls.connect({ host: '127.0.0.1', port: proxy.port,
|
|
17
|
+
servername: 'discord.com', rejectUnauthorized: true, ALPNProtocols: ['http/1.1'] });
|
|
18
|
+
let complete = false;
|
|
19
|
+
const finish = (error?: Error) => {
|
|
20
|
+
if (complete) return; complete = true;
|
|
21
|
+
pending.delete(cancel);
|
|
22
|
+
clearTimeout(timer); socket.off('error', onError);
|
|
23
|
+
if (error) { socket.destroy(); callback(error, null); }
|
|
24
|
+
else callback(null, socket);
|
|
25
|
+
};
|
|
26
|
+
const cancel = () => finish(new Error('Discord REST proxy stopped'));
|
|
27
|
+
pending.add(cancel);
|
|
28
|
+
const onError = (error: Error) => finish(error);
|
|
29
|
+
const timer = setTimeout(() => finish(new Error('Discord REST proxy TLS connection timed out')), 10_000);
|
|
30
|
+
timer.unref(); socket.once('error', onError); socket.once('secureConnect', () => finish());
|
|
31
|
+
} });
|
|
32
|
+
return Object.assign(agent, { async destroyProxy() {
|
|
33
|
+
closed = true;
|
|
34
|
+
for (const cancel of [...pending]) cancel();
|
|
35
|
+
await agent.destroy();
|
|
36
|
+
} });
|
|
37
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildNotice, senderFromId } from '@zhin.js/core';
|
|
1
|
+
import { composeSideEventName, buildNotice, senderFromId } from '@zhin.js/core';
|
|
2
2
|
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
3
|
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
4
|
|
|
@@ -17,15 +17,14 @@ export function receiveDiscordGuildMemberSideEvent(
|
|
|
17
17
|
): void {
|
|
18
18
|
if (!emit) return;
|
|
19
19
|
void emit('notice.receive', buildNotice(event, {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
$timestamp: Date.now(),
|
|
20
|
+
id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
|
|
21
|
+
clientAdapter: 'discord',
|
|
22
|
+
endpointId: configId,
|
|
23
|
+
type: 'notice',
|
|
24
|
+
// Guild membership does not identify a replyable channel.
|
|
25
|
+
name: composeSideEventName('notice', 'group', kind),
|
|
26
|
+
target: senderFromId(event.userId, event.userName),
|
|
27
|
+
timestamp: Date.now(),
|
|
29
28
|
})).catch((err) => {
|
|
30
29
|
logger.warn(formatCompact({
|
|
31
30
|
op: 'discord_side_event_failed',
|
package/src/webhook.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
interactionToInboundMessage,
|
|
9
9
|
verifyDiscordInteractionSignature,
|
|
10
10
|
type DiscordInboundMessage,
|
|
11
|
+
type DiscordButtonInbound,
|
|
11
12
|
type ResolvedDiscordInteractionsConfig,
|
|
12
13
|
} from './protocol.js';
|
|
13
14
|
|
|
@@ -24,6 +25,7 @@ export interface DiscordInteractionsHandler {
|
|
|
24
25
|
readonly config: ResolvedDiscordInteractionsConfig;
|
|
25
26
|
readonly isOpen: boolean;
|
|
26
27
|
admit(msg: DiscordInboundMessage): void;
|
|
28
|
+
admitButton(interaction: DiscordButtonInbound): void;
|
|
27
29
|
admitPlatform(event: Record<string, unknown>): void;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -69,6 +71,16 @@ export async function handleDiscordInteractionRequest(
|
|
|
69
71
|
writeJson(response, 200, { type: INTERACTION_RESPONSE_PONG });
|
|
70
72
|
return;
|
|
71
73
|
}
|
|
74
|
+
if (interaction.type === 3) {
|
|
75
|
+
const data = interaction.data as { component_type?: number; custom_id?: string } | undefined;
|
|
76
|
+
if (data?.component_type === 2 && typeof data.custom_id === 'string') {
|
|
77
|
+
if (!handler.isOpen) { response.writeHead(503); response.end('Unavailable'); return; }
|
|
78
|
+
const message = interactionToInboundMessage(interaction);
|
|
79
|
+
handler.admitButton({ 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 as { id?: string } | undefined)?.id });
|
|
80
|
+
writeJson(response, 200, { type: 6 });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
72
84
|
if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
|
|
73
85
|
if (handler.isOpen) {
|
|
74
86
|
handler.admit(interactionToInboundMessage(interaction));
|