nubis 0.0.0 → 0.1.0-next.1

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/dist/cli.js ADDED
@@ -0,0 +1,1019 @@
1
+ #!/usr/bin/env node
2
+ var __esm = (fn, res, err) => () => {
3
+ if (fn)
4
+ try {
5
+ res = fn(fn = 0);
6
+ } catch (e) {
7
+ err = [e];
8
+ }
9
+ if (err)
10
+ throw err[0];
11
+ return res;
12
+ };
13
+
14
+ // src/intents.ts
15
+ import { GatewayIntentBits as I } from "discord-api-types/v10";
16
+ function intentsFor(events, extra = 0) {
17
+ let intents = I.Guilds | extra;
18
+ for (const name of events.keys())
19
+ intents |= EVENT_INTENTS[name] ?? 0;
20
+ return intents;
21
+ }
22
+ function intentNames(intents) {
23
+ return Object.entries(I).filter(([name, bit]) => typeof bit === "number" && (intents & bit) === bit && name !== "GuildBans" && name !== "GuildEmojisAndStickers").map(([name]) => name);
24
+ }
25
+ var EVENT_INTENTS, PRIVILEGED_INTENTS;
26
+ var init_intents = __esm(() => {
27
+ EVENT_INTENTS = {
28
+ guildMemberAdd: I.GuildMembers,
29
+ guildMemberUpdate: I.GuildMembers,
30
+ guildMemberRemove: I.GuildMembers,
31
+ guildBanAdd: I.GuildModeration,
32
+ guildBanRemove: I.GuildModeration,
33
+ guildAuditLogEntryCreate: I.GuildModeration,
34
+ guildEmojisUpdate: I.GuildExpressions,
35
+ guildStickersUpdate: I.GuildExpressions,
36
+ guildSoundboardSoundCreate: I.GuildExpressions,
37
+ guildSoundboardSoundUpdate: I.GuildExpressions,
38
+ guildSoundboardSoundDelete: I.GuildExpressions,
39
+ guildSoundboardSoundsUpdate: I.GuildExpressions,
40
+ guildIntegrationsUpdate: I.GuildIntegrations,
41
+ integrationCreate: I.GuildIntegrations,
42
+ integrationUpdate: I.GuildIntegrations,
43
+ integrationDelete: I.GuildIntegrations,
44
+ webhooksUpdate: I.GuildWebhooks,
45
+ inviteCreate: I.GuildInvites,
46
+ inviteDelete: I.GuildInvites,
47
+ voiceStateUpdate: I.GuildVoiceStates,
48
+ voiceChannelEffectSend: I.GuildVoiceStates,
49
+ presenceUpdate: I.GuildPresences,
50
+ messageCreate: I.GuildMessages | I.DirectMessages,
51
+ messageUpdate: I.GuildMessages | I.DirectMessages,
52
+ messageDelete: I.GuildMessages | I.DirectMessages,
53
+ messageDeleteBulk: I.GuildMessages,
54
+ messageReactionAdd: I.GuildMessageReactions | I.DirectMessageReactions,
55
+ messageReactionRemove: I.GuildMessageReactions | I.DirectMessageReactions,
56
+ messageReactionRemoveAll: I.GuildMessageReactions | I.DirectMessageReactions,
57
+ messageReactionRemoveEmoji: I.GuildMessageReactions | I.DirectMessageReactions,
58
+ typingStart: I.GuildMessageTyping | I.DirectMessageTyping,
59
+ guildScheduledEventCreate: I.GuildScheduledEvents,
60
+ guildScheduledEventUpdate: I.GuildScheduledEvents,
61
+ guildScheduledEventDelete: I.GuildScheduledEvents,
62
+ guildScheduledEventUserAdd: I.GuildScheduledEvents,
63
+ guildScheduledEventUserRemove: I.GuildScheduledEvents,
64
+ autoModerationRuleCreate: I.AutoModerationConfiguration,
65
+ autoModerationRuleUpdate: I.AutoModerationConfiguration,
66
+ autoModerationRuleDelete: I.AutoModerationConfiguration,
67
+ autoModerationActionExecution: I.AutoModerationExecution,
68
+ messagePollVoteAdd: I.GuildMessagePolls | I.DirectMessagePolls,
69
+ messagePollVoteRemove: I.GuildMessagePolls | I.DirectMessagePolls
70
+ };
71
+ PRIVILEGED_INTENTS = I.GuildMembers | I.GuildPresences | I.MessageContent;
72
+ });
73
+
74
+ // src/rest-transport.ts
75
+ import { NubisError } from "@nubisjs/core";
76
+ import { Routes } from "discord-api-types/v10";
77
+ async function toRawFiles(files) {
78
+ if (!files?.length)
79
+ return;
80
+ return Promise.all(files.map(async (file, index) => ({
81
+ key: `files[${index}]`,
82
+ name: file.name,
83
+ contentType: file.contentType,
84
+ data: typeof file.data === "string" || file.data instanceof Uint8Array ? file.data : file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : new Uint8Array(await file.data.arrayBuffer())
85
+ })));
86
+ }
87
+ function explain(error, what) {
88
+ const code = error.code;
89
+ if (typeof code === "number" && EXPIRED.has(code)) {
90
+ return new NubisError(`${what} failed: the interaction token has expired. Discord allows follow-ups and edits for 15 minutes after an interaction.`);
91
+ }
92
+ return error;
93
+ }
94
+ function restTransport(rest, interaction) {
95
+ const { id, token, application_id: applicationId } = interaction;
96
+ const call = async (what, send) => {
97
+ try {
98
+ await send();
99
+ } catch (error) {
100
+ throw explain(error, what);
101
+ }
102
+ };
103
+ return {
104
+ respond: async (response, files) => call("Responding", async () => rest.post(Routes.interactionCallback(id, token), {
105
+ auth: false,
106
+ body: response,
107
+ files: await toRawFiles(files)
108
+ })),
109
+ editOriginal: async (body, files) => call("Editing the reply", async () => rest.patch(Routes.webhookMessage(applicationId, token, "@original"), {
110
+ auth: false,
111
+ body,
112
+ files: await toRawFiles(files)
113
+ })),
114
+ deleteOriginal: () => call("Deleting the reply", () => rest.delete(Routes.webhookMessage(applicationId, token, "@original"), {
115
+ auth: false
116
+ })),
117
+ followUp: async (body, files) => call("Sending a follow-up", async () => rest.post(Routes.webhook(applicationId, token), {
118
+ auth: false,
119
+ body,
120
+ files: await toRawFiles(files)
121
+ }))
122
+ };
123
+ }
124
+ var EXPIRED;
125
+ var init_rest_transport = __esm(() => {
126
+ EXPIRED = new Set([10015, 10062, 50027]);
127
+ });
128
+
129
+ // src/route.ts
130
+ import {
131
+ dispatch,
132
+ dispatchAutocomplete,
133
+ dispatchComponent,
134
+ dispatchMenu,
135
+ dispatchModal
136
+ } from "@nubisjs/core";
137
+ import {
138
+ ApplicationCommandType,
139
+ InteractionType
140
+ } from "discord-api-types/v10";
141
+ function routeInteraction(project, interaction, transport, options) {
142
+ switch (interaction.type) {
143
+ case InteractionType.ApplicationCommand:
144
+ return interaction.data.type === ApplicationCommandType.ChatInput ? dispatch(project.tree, interaction, transport, options) : dispatchMenu(project.menus, interaction, transport, options);
145
+ case InteractionType.MessageComponent:
146
+ return dispatchComponent(project.components, interaction, transport, options);
147
+ case InteractionType.ApplicationCommandAutocomplete:
148
+ return dispatchAutocomplete(project.tree, interaction, transport, options);
149
+ case InteractionType.ModalSubmit:
150
+ return dispatchModal(project.components, interaction, transport, options);
151
+ default:
152
+ return Promise.resolve({ status: "unsupported" });
153
+ }
154
+ }
155
+ var init_route = () => {};
156
+
157
+ // src/bot.ts
158
+ import { API } from "@discordjs/core/http-only";
159
+ import { dispatchEvent } from "@nubisjs/core";
160
+ import { REST } from "@discordjs/rest";
161
+ import { WebSocketManager, WebSocketShardEvents } from "@discordjs/ws";
162
+ import {
163
+ GatewayDispatchEvents
164
+ } from "discord-api-types/v10";
165
+ function createBot(options) {
166
+ const rest = options.rest ?? new REST({ version: "10" }).setToken(options.token);
167
+ const intents = intentsFor(options.project.events, options.intents);
168
+ const presence = options.presence ?? null;
169
+ const gateway = options.gateway ? options.gateway({ intents, rest, presence }) : new WebSocketManager({
170
+ token: options.token,
171
+ intents,
172
+ rest,
173
+ initialPresence: presence
174
+ });
175
+ const api = new API(rest);
176
+ const routeOptions = {
177
+ ...options.dispatch,
178
+ api,
179
+ onError: options.onError ?? options.dispatch?.onError
180
+ };
181
+ gateway.on(WebSocketShardEvents.Dispatch, (payload, shardId) => {
182
+ if (payload.t === GatewayDispatchEvents.InteractionCreate) {
183
+ const interaction = payload.d;
184
+ routeInteraction(options.project, interaction, restTransport(rest, interaction), routeOptions);
185
+ return;
186
+ }
187
+ dispatchEvent(options.project.events, payload.t, payload.d, {
188
+ shardId,
189
+ onError: routeOptions.onError,
190
+ api
191
+ });
192
+ });
193
+ return {
194
+ rest,
195
+ api,
196
+ intents,
197
+ start: () => gateway.connect(),
198
+ stop: async () => {
199
+ await gateway.destroy();
200
+ }
201
+ };
202
+ }
203
+ var init_bot = __esm(() => {
204
+ init_intents();
205
+ init_rest_transport();
206
+ init_route();
207
+ });
208
+
209
+ // src/config.ts
210
+ import {
211
+ ActivityType,
212
+ GatewayIntentBits,
213
+ PresenceUpdateStatus
214
+ } from "discord-api-types/v10";
215
+ function defineConfig(config) {
216
+ return config;
217
+ }
218
+ function validateConfig(config, file) {
219
+ const where = (detail) => new Error(`${file}: ${detail}`);
220
+ if (typeof config !== "object" || config === null) {
221
+ throw where("export the config as default: export default defineConfig({ ... }).");
222
+ }
223
+ const value = config;
224
+ for (const key of Object.keys(value)) {
225
+ if (!KEYS.has(key)) {
226
+ throw where(`"${key}" isn't a config setting. The settings are: ${[...KEYS].join(", ")}.`);
227
+ }
228
+ }
229
+ for (const name of value.intents ?? []) {
230
+ if (typeof name !== "string" || !(name in GatewayIntentBits) || !Number.isNaN(Number(name))) {
231
+ throw where(`"${String(name)}" isn't an intent. Use names like "MessageContent" or "GuildMembers".`);
232
+ }
233
+ }
234
+ const presence = value.presence;
235
+ if (presence?.status && !(presence.status in STATUSES)) {
236
+ throw where(`presence.status must be online, idle, dnd or invisible (got "${presence.status}").`);
237
+ }
238
+ if (presence?.activity && !(presence.activity.type in ACTIVITY_TYPES)) {
239
+ throw where(`presence.activity.type must be playing, listening, watching, competing or custom (got "${presence.activity.type}").`);
240
+ }
241
+ if (value.transport !== undefined && value.transport !== "gateway" && value.transport !== "http") {
242
+ throw where(`transport must be "gateway" or "http" (got "${String(value.transport)}").`);
243
+ }
244
+ return value;
245
+ }
246
+ function intentBits(names = []) {
247
+ return names.reduce((bits, name) => bits | GatewayIntentBits[name], 0);
248
+ }
249
+ function presenceData(presence) {
250
+ if (!presence)
251
+ return;
252
+ const activity = presence.activity;
253
+ return {
254
+ since: null,
255
+ afk: false,
256
+ status: STATUSES[presence.status ?? "online"] ?? PresenceUpdateStatus.Online,
257
+ activities: activity ? [
258
+ activity.type === "custom" ? {
259
+ type: ActivityType.Custom,
260
+ name: "Custom Status",
261
+ state: activity.name
262
+ } : { type: ACTIVITY_TYPES[activity.type], name: activity.name }
263
+ ] : []
264
+ };
265
+ }
266
+ var KEYS, ACTIVITY_TYPES, STATUSES;
267
+ var init_config = __esm(() => {
268
+ KEYS = new Set([
269
+ "root",
270
+ "intents",
271
+ "presence",
272
+ "guards",
273
+ "middleware",
274
+ "onError",
275
+ "autoDeferMs",
276
+ "transport",
277
+ "http",
278
+ "devGuildId"
279
+ ]);
280
+ ACTIVITY_TYPES = {
281
+ playing: ActivityType.Playing,
282
+ listening: ActivityType.Listening,
283
+ watching: ActivityType.Watching,
284
+ competing: ActivityType.Competing,
285
+ custom: ActivityType.Custom
286
+ };
287
+ STATUSES = {
288
+ online: PresenceUpdateStatus.Online,
289
+ idle: PresenceUpdateStatus.Idle,
290
+ dnd: PresenceUpdateStatus.DoNotDisturb,
291
+ invisible: PresenceUpdateStatus.Invisible
292
+ };
293
+ });
294
+
295
+ // src/config-loader.ts
296
+ import { access } from "node:fs/promises";
297
+ import { join } from "node:path";
298
+ import { pathToFileURL } from "node:url";
299
+ async function loadConfig(cwd) {
300
+ for (const name of NAMES) {
301
+ const file = join(cwd, name);
302
+ const exists = await access(file).then(() => true, () => false);
303
+ if (!exists)
304
+ continue;
305
+ const url = pathToFileURL(file);
306
+ url.searchParams.set("v", String(Date.now()));
307
+ const module = await import(url.href);
308
+ const config = validateConfig(module.default, name);
309
+ return { config, file, root: join(cwd, config.root ?? ".") };
310
+ }
311
+ return { config: {}, file: undefined, root: cwd };
312
+ }
313
+ var NAMES;
314
+ var init_config_loader = __esm(() => {
315
+ init_config();
316
+ NAMES = [
317
+ "nubis.config.ts",
318
+ "nubis.config.mts",
319
+ "nubis.config.js",
320
+ "nubis.config.mjs"
321
+ ];
322
+ });
323
+
324
+ // src/env.ts
325
+ import { readFile } from "node:fs/promises";
326
+ import { dirname, join as join2 } from "node:path";
327
+ function parseEnv(text) {
328
+ const values = {};
329
+ for (const raw of text.split(/\r?\n/)) {
330
+ const line = raw.trim();
331
+ if (!line || line.startsWith("#"))
332
+ continue;
333
+ const at = line.indexOf("=");
334
+ if (at === -1)
335
+ continue;
336
+ const key = line.slice(0, at).replace(/^export\s+/, "").trim();
337
+ let value = line.slice(at + 1).trim();
338
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
339
+ value = value.slice(1, -1);
340
+ }
341
+ values[key] = value;
342
+ }
343
+ return values;
344
+ }
345
+ async function loadEnv(start, target = process.env) {
346
+ let dir = start;
347
+ for (;; ) {
348
+ const file = join2(dir, ".env");
349
+ const text = await readFile(file, "utf8").catch(() => {
350
+ return;
351
+ });
352
+ if (text !== undefined) {
353
+ for (const [key, value] of Object.entries(parseEnv(text))) {
354
+ target[key] ??= value;
355
+ }
356
+ return file;
357
+ }
358
+ const parent = dirname(dir);
359
+ if (parent === dir)
360
+ return;
361
+ dir = parent;
362
+ }
363
+ }
364
+ function discordEnv(env = process.env) {
365
+ const missing = ["DISCORD_TOKEN", "DISCORD_APPLICATION_ID"].filter((key) => !env[key]);
366
+ if (missing.length > 0) {
367
+ throw new Error(`Missing ${missing.join(" and ")}. Put them in a .env file next to your bot (see .env.example).`);
368
+ }
369
+ return {
370
+ token: env.DISCORD_TOKEN,
371
+ applicationId: env.DISCORD_APPLICATION_ID,
372
+ publicKey: env.DISCORD_PUBLIC_KEY || undefined,
373
+ devGuildId: env.DISCORD_DEV_GUILD_ID || undefined
374
+ };
375
+ }
376
+ var init_env = () => {};
377
+
378
+ // src/http.ts
379
+ import { NubisError as NubisError2 } from "@nubisjs/core";
380
+ import {
381
+ InteractionResponseType,
382
+ InteractionType as InteractionType2
383
+ } from "discord-api-types/v10";
384
+ function importPublicKey(publicKey) {
385
+ let key = keyCache.get(publicKey);
386
+ if (!key) {
387
+ const raw = hexToBytes(publicKey);
388
+ if (!raw || raw.length !== 32) {
389
+ throw new NubisError2("The public key must be the 64 hex characters shown in the Developer Portal.");
390
+ }
391
+ key = crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, [
392
+ "verify"
393
+ ]);
394
+ keyCache.set(publicKey, key);
395
+ }
396
+ return key;
397
+ }
398
+ async function verifyDiscordRequest(publicKey, signature, timestamp, body) {
399
+ const signatureBytes = hexToBytes(signature);
400
+ if (!signatureBytes || signatureBytes.length !== 64)
401
+ return false;
402
+ const key = await importPublicKey(publicKey);
403
+ return crypto.subtle.verify({ name: "Ed25519" }, key, signatureBytes, new TextEncoder().encode(timestamp + body));
404
+ }
405
+ async function discordError(response, what) {
406
+ const text = await response.text().catch(() => "");
407
+ let code;
408
+ try {
409
+ code = JSON.parse(text).code;
410
+ } catch {}
411
+ if (code === INVALID_WEBHOOK_TOKEN || code === UNKNOWN_WEBHOOK || code === UNKNOWN_INTERACTION) {
412
+ return new NubisError2(`${what} failed: the interaction token has expired. Discord allows follow-ups and edits for 15 minutes after an interaction.`);
413
+ }
414
+ return new NubisError2(`${what} failed: Discord answered ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}.`);
415
+ }
416
+ function requestBody(body, files) {
417
+ if (body === undefined)
418
+ return { headers: {} };
419
+ if (!files?.length) {
420
+ return {
421
+ body: JSON.stringify(body),
422
+ headers: { "content-type": "application/json" }
423
+ };
424
+ }
425
+ const form = new FormData;
426
+ form.append("payload_json", JSON.stringify(body));
427
+ files.forEach((file, index) => {
428
+ const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
429
+ type: file.contentType ?? "application/octet-stream"
430
+ });
431
+ form.append(`files[${index}]`, blob, file.name);
432
+ });
433
+ return { body: form, headers: {} };
434
+ }
435
+ function webhookTransport(options) {
436
+ const { interaction, api } = options;
437
+ const webhook = `${api}/webhooks/${interaction.application_id}/${interaction.token}`;
438
+ const call = async (method, url, what, body, files) => {
439
+ const request = requestBody(body, files);
440
+ const response = await options.fetch(url, { method, ...request });
441
+ if (!response.ok)
442
+ throw await discordError(response, what);
443
+ };
444
+ const editOriginal = (body, files) => call("PATCH", `${webhook}/messages/@original`, "Editing the reply", body, files);
445
+ return {
446
+ respond: async (response, files) => {
447
+ if (files?.length && "data" in response) {
448
+ const updating = response.type === InteractionResponseType.UpdateMessage;
449
+ const flags = response.data?.flags;
450
+ await options.respond(updating ? { type: InteractionResponseType.DeferredMessageUpdate } : {
451
+ type: InteractionResponseType.DeferredChannelMessageWithSource,
452
+ data: flags ? { flags } : {}
453
+ });
454
+ await editOriginal(response.data, files);
455
+ return;
456
+ }
457
+ await options.respond(response);
458
+ },
459
+ editOriginal,
460
+ deleteOriginal: () => call("DELETE", `${webhook}/messages/@original`, "Deleting the reply"),
461
+ followUp: (body, files) => call("POST", webhook, "Sending a follow-up", body, files)
462
+ };
463
+ }
464
+ function createInteractionHandler(options) {
465
+ const api = options.api ?? DEFAULT_API;
466
+ const fetcher = options.fetch ?? fetch;
467
+ importPublicKey(options.publicKey);
468
+ return async (request) => {
469
+ if (request.method !== "POST")
470
+ return new Response(null, { status: 405 });
471
+ const signature = request.headers.get("x-signature-ed25519");
472
+ const timestamp = request.headers.get("x-signature-timestamp");
473
+ const body = await request.text();
474
+ if (!signature || !timestamp || !await verifyDiscordRequest(options.publicKey, signature, timestamp, body)) {
475
+ return new Response("Bad request signature", { status: 401 });
476
+ }
477
+ const interaction = JSON.parse(body);
478
+ if (interaction.type === InteractionType2.Ping) {
479
+ return json({ type: InteractionResponseType.Pong });
480
+ }
481
+ let answered = false;
482
+ let answer = () => {};
483
+ const initial = new Promise((resolve) => {
484
+ answer = resolve;
485
+ });
486
+ const transport = webhookTransport({
487
+ interaction,
488
+ api,
489
+ fetch: fetcher,
490
+ respond: async (response) => {
491
+ if (!answered) {
492
+ answered = true;
493
+ answer(response);
494
+ return;
495
+ }
496
+ const callback = await fetcher(`${api}/interactions/${interaction.id}/${interaction.token}/callback`, {
497
+ method: "POST",
498
+ headers: { "content-type": "application/json" },
499
+ body: JSON.stringify(response)
500
+ });
501
+ if (!callback.ok)
502
+ throw await discordError(callback, "Responding");
503
+ }
504
+ });
505
+ const work = routeInteraction(options.project, interaction, transport, options.dispatch ?? {}).catch((error) => ({ status: "error", error })).then((result) => {
506
+ options.onResult?.({ interaction, ...result });
507
+ return result;
508
+ });
509
+ options.waitUntil?.(work);
510
+ const first = await Promise.race([initial, work.then(() => {
511
+ return;
512
+ })]);
513
+ if (first === undefined) {
514
+ answered = true;
515
+ return json({ error: "The handler finished without responding." }, 500);
516
+ }
517
+ return json(first);
518
+ };
519
+ }
520
+ var DEFAULT_API = "https://discord.com/api/v10", hexToBytes = (hex) => {
521
+ if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex))
522
+ return;
523
+ const bytes = new Uint8Array(hex.length / 2);
524
+ for (let i = 0;i < bytes.length; i++) {
525
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
526
+ }
527
+ return bytes;
528
+ }, keyCache, UNKNOWN_INTERACTION = 10062, UNKNOWN_WEBHOOK = 10015, INVALID_WEBHOOK_TOKEN = 50027, json = (body, status = 200) => new Response(JSON.stringify(body), {
529
+ status,
530
+ headers: { "content-type": "application/json" }
531
+ });
532
+ var init_http = __esm(() => {
533
+ init_route();
534
+ keyCache = new Map;
535
+ });
536
+
537
+ // src/loader.ts
538
+ import { readdir, stat } from "node:fs/promises";
539
+ import { extname, join as join3, relative, sep } from "node:path";
540
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
541
+ import {
542
+ buildCommandTree,
543
+ createComponentRegistry,
544
+ createEventRegistry,
545
+ NubisError as NubisError3
546
+ } from "@nubisjs/core";
547
+ async function listModules(dir, extensions) {
548
+ let names;
549
+ try {
550
+ names = await readdir(dir);
551
+ } catch (error) {
552
+ if (error.code === "ENOENT")
553
+ return [];
554
+ throw error;
555
+ }
556
+ const files = [];
557
+ for (const name of names.sort()) {
558
+ const path = join3(dir, name);
559
+ if ((await stat(path)).isDirectory()) {
560
+ files.push(...await listModules(path, extensions));
561
+ continue;
562
+ }
563
+ const base = name.slice(0, name.length - extname(name).length);
564
+ if (!extensions.includes(extname(name)))
565
+ continue;
566
+ if (name.endsWith(".d.ts") || /\.(test|spec)$/.test(base))
567
+ continue;
568
+ if (base.startsWith("_") && base !== FOLDER_FILE)
569
+ continue;
570
+ files.push(path);
571
+ }
572
+ return files;
573
+ }
574
+ async function importModule(file, root, version) {
575
+ const url = pathToFileURL2(file);
576
+ if (version !== undefined)
577
+ url.searchParams.set("v", String(version));
578
+ try {
579
+ return await import(url.href);
580
+ } catch (error) {
581
+ const code = error.code;
582
+ if (code === "ERR_UNKNOWN_FILE_EXTENSION") {
583
+ throw new NubisError3(`${relative(root, file)}: this runtime can't import ${extname(file)} files. Run the bot with Bun or Node 22.18+, or build it to JavaScript first.`);
584
+ }
585
+ throw error;
586
+ }
587
+ }
588
+ async function loadProject(root, options = {}) {
589
+ const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
590
+ const load = (file) => importModule(file, root, options.version);
591
+ const where = (file) => relative(root, file);
592
+ const files = [];
593
+ const commandsDir = join3(root, "commands");
594
+ const commandFiles = [];
595
+ const folders = [];
596
+ for (const file of await listModules(commandsDir, extensions)) {
597
+ files.push(where(file));
598
+ const module = await load(file);
599
+ const segments = segmentsOf(file, commandsDir);
600
+ if (segments.at(-1) === FOLDER_FILE) {
601
+ const settings = module.default;
602
+ if (!isObject(settings)) {
603
+ throw new NubisError3(`${where(file)}: export the folder's settings as default, e.g. export default folder({ description: "..." }).`);
604
+ }
605
+ folders.push({ ...settings, path: segments.slice(0, -1) });
606
+ continue;
607
+ }
608
+ if (!isCommand(module.default)) {
609
+ throw new NubisError3(`${where(file)}: export a command as default, e.g. export default command({ ... }). Helpers that aren't commands can live in files starting with "_".`);
610
+ }
611
+ commandFiles.push({ path: segments, definition: module.default });
612
+ }
613
+ const components = [];
614
+ for (const file of await listModules(join3(root, "components"), extensions)) {
615
+ files.push(where(file));
616
+ const found = Object.values(await load(file)).filter(isComponent);
617
+ if (found.length === 0) {
618
+ throw new NubisError3(`${where(file)}: no buttons, selects or modals are exported here.`);
619
+ }
620
+ for (const component of found) {
621
+ if (!components.includes(component))
622
+ components.push(component);
623
+ }
624
+ }
625
+ const menus = [];
626
+ for (const file of await listModules(join3(root, "menus"), extensions)) {
627
+ files.push(where(file));
628
+ const menu = (await load(file)).default;
629
+ if (!isMenu(menu)) {
630
+ throw new NubisError3(`${where(file)}: export a context menu command as default, e.g. export default userCommand({ ... }).`);
631
+ }
632
+ menus.push(menu);
633
+ }
634
+ const events = [];
635
+ for (const file of await listModules(join3(root, "events"), extensions)) {
636
+ files.push(where(file));
637
+ const exported = (await load(file)).default;
638
+ const list = Array.isArray(exported) ? exported : [exported];
639
+ if (list.length === 0 || !list.every(isEvent)) {
640
+ throw new NubisError3(`${where(file)}: export an event as default, e.g. export default event("guildMemberAdd", ...), or an array of them.`);
641
+ }
642
+ events.push(...list);
643
+ }
644
+ return {
645
+ root,
646
+ tree: buildCommandTree(commandFiles, folders),
647
+ components: createComponentRegistry(components),
648
+ menus,
649
+ events: createEventRegistry(events),
650
+ files
651
+ };
652
+ }
653
+ var DEFAULT_EXTENSIONS, FOLDER_FILE = "_folder", isObject = (value) => (typeof value === "object" || typeof value === "function") && value !== null, isCommand = (value) => isObject(value) && value.kind === "command", isComponent = (value) => isObject(value) && ("~nubis" in value), isMenu = (value) => isObject(value) && (value.kind === "user" || value.kind === "message") && ("config" in value), isEvent = (value) => isObject(value) && value.kind === "event", segmentsOf = (file, dir) => {
654
+ const path = relative(dir, file);
655
+ return path.slice(0, path.length - extname(path).length).split(sep);
656
+ };
657
+ var init_loader = __esm(() => {
658
+ DEFAULT_EXTENSIONS = [".ts", ".mts", ".js", ".mjs"];
659
+ });
660
+
661
+ // src/node-listener.ts
662
+ function toNodeListener(handler) {
663
+ return (incoming, outgoing) => {
664
+ (async () => {
665
+ try {
666
+ const chunks = [];
667
+ for await (const chunk of incoming)
668
+ chunks.push(chunk);
669
+ const headers = new Headers;
670
+ for (const [name, value] of Object.entries(incoming.headers)) {
671
+ if (typeof value === "string")
672
+ headers.set(name, value);
673
+ else if (Array.isArray(value))
674
+ headers.set(name, value.join(", "));
675
+ }
676
+ const method = incoming.method ?? "GET";
677
+ const request = new Request(`http://${incoming.headers.host ?? "localhost"}${incoming.url ?? "/"}`, {
678
+ method,
679
+ headers,
680
+ body: method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks)
681
+ });
682
+ const response = await handler(request);
683
+ outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries()));
684
+ outgoing.end(Buffer.from(await response.arrayBuffer()));
685
+ } catch (error) {
686
+ outgoing.writeHead(500, { "content-type": "text/plain" });
687
+ outgoing.end("Internal error");
688
+ console.error(error);
689
+ }
690
+ })();
691
+ };
692
+ }
693
+
694
+ // src/sync.ts
695
+ import {
696
+ planSync,
697
+ toCommandPayloads,
698
+ toMenuPayloads
699
+ } from "@nubisjs/core";
700
+ import { Routes as Routes2 } from "discord-api-types/v10";
701
+ async function syncCommands(options) {
702
+ const route = options.guildId ? Routes2.applicationGuildCommands(options.applicationId, options.guildId) : Routes2.applicationCommands(options.applicationId);
703
+ const local = [
704
+ ...toCommandPayloads(options.project.tree),
705
+ ...toMenuPayloads(options.project.menus)
706
+ ];
707
+ const remote = await options.rest.get(route, {
708
+ query: new URLSearchParams({ with_localizations: "true" })
709
+ });
710
+ const plan = planSync(local, remote);
711
+ const scope = options.guildId ? "guild" : "global";
712
+ if (options.dryRun || !hasChanges(plan))
713
+ return { plan, applied: false, scope };
714
+ await options.rest.put(route, { body: local });
715
+ return { plan, applied: true, scope };
716
+ }
717
+ function describePlan(plan) {
718
+ return [
719
+ ...plan.create.map((command) => `+ ${command.name}`),
720
+ ...plan.update.map(({ payload }) => `~ ${payload.name}`),
721
+ ...plan.remove.map((command) => `- ${command.name}`)
722
+ ];
723
+ }
724
+ var hasChanges = (plan) => plan.create.length + plan.update.length + plan.remove.length > 0;
725
+ var init_sync = () => {};
726
+
727
+ // src/commands.ts
728
+ import { spawn } from "node:child_process";
729
+ import { watch } from "node:fs";
730
+ import { createServer } from "node:http";
731
+ import { relative as relative2, sep as sep2 } from "node:path";
732
+ import { API as API2 } from "@discordjs/core/http-only";
733
+ import { REST as REST2 } from "@discordjs/rest";
734
+ async function setup(cwd) {
735
+ await loadEnv(cwd);
736
+ const env = discordEnv();
737
+ const rest = new REST2({ version: "10" }).setToken(env.token);
738
+ const loaded = await loadConfig(cwd);
739
+ return {
740
+ env,
741
+ rest,
742
+ ...loaded,
743
+ devGuildId: env.devGuildId ?? loaded.config.devGuildId
744
+ };
745
+ }
746
+ function routeOptionsFor(config, api) {
747
+ return {
748
+ api,
749
+ guards: config.guards,
750
+ middleware: config.middleware,
751
+ autoDeferMs: config.autoDeferMs,
752
+ onError: config.onError ?? logErrors
753
+ };
754
+ }
755
+ async function runSync(cwd, args) {
756
+ const { env, rest, root, devGuildId } = await setup(cwd);
757
+ const global = args.includes("--global");
758
+ const guildId = global ? undefined : devGuildId;
759
+ if (!global && !guildId) {
760
+ throw new Error("Set DISCORD_DEV_GUILD_ID (or devGuildId in nubis.config.ts) to sync to your test server, or pass --global to register commands everywhere.");
761
+ }
762
+ const project = await loadProject(root);
763
+ const result = await syncCommands({
764
+ rest,
765
+ applicationId: env.applicationId,
766
+ project,
767
+ guildId,
768
+ dryRun: args.includes("--dry-run")
769
+ });
770
+ const lines = describePlan(result.plan);
771
+ log(hasChanges(result.plan) ? `${result.applied ? "Synced" : "Would sync"} ${result.scope} commands:
772
+ ${lines.join(`
773
+ `)}` : `Commands are up to date (${result.plan.unchanged.length}).`);
774
+ return 0;
775
+ }
776
+ function createHttpServer(options) {
777
+ const path = options.config.http?.path ?? "/interactions";
778
+ const handle = createInteractionHandler({
779
+ publicKey: options.publicKey,
780
+ project: options.project,
781
+ dispatch: routeOptionsFor(options.config, options.api)
782
+ });
783
+ return createServer(toNodeListener(async (request) => new URL(request.url).pathname === path ? handle(request) : new Response("Not found", { status: 404 })));
784
+ }
785
+ async function runStart(cwd) {
786
+ const { env, rest, config, root } = await setup(cwd);
787
+ const project = await loadProject(root);
788
+ const counts = `${project.tree.size} commands, ${project.components.size} components, ${project.menus.length} menus`;
789
+ if (config.transport === "http") {
790
+ if (!env.publicKey) {
791
+ throw new Error("The HTTP transport needs DISCORD_PUBLIC_KEY to verify Discord's requests.");
792
+ }
793
+ if (project.events.size > 0) {
794
+ log("HTTP bots don't receive gateway events; the handlers in events/ won't run.");
795
+ }
796
+ const port = config.http?.port ?? Number(process.env.PORT ?? 3000);
797
+ const server = createHttpServer({
798
+ publicKey: env.publicKey,
799
+ project,
800
+ config,
801
+ api: new API2(rest)
802
+ });
803
+ server.listen(port);
804
+ log(`Listening on http://localhost:${port}${config.http?.path ?? "/interactions"} (${counts}). Set it as the Interactions Endpoint URL in the Developer Portal.`);
805
+ return new Promise(() => {});
806
+ }
807
+ const bot = createBot({
808
+ token: env.token,
809
+ project,
810
+ rest,
811
+ intents: intentBits(config.intents),
812
+ presence: presenceData(config.presence),
813
+ dispatch: {
814
+ guards: config.guards,
815
+ middleware: config.middleware,
816
+ autoDeferMs: config.autoDeferMs
817
+ },
818
+ onError: config.onError ?? logErrors
819
+ });
820
+ const privileged = intentNames(bot.intents & PRIVILEGED_INTENTS);
821
+ if (privileged.length > 0) {
822
+ log(`Using privileged intents (${privileged.join(", ")}): they must be switched on in the Developer Portal, under Bot.`);
823
+ }
824
+ const stop = async () => {
825
+ await bot.stop();
826
+ process.exit(0);
827
+ };
828
+ process.once("SIGINT", stop);
829
+ process.once("SIGTERM", stop);
830
+ await bot.start();
831
+ log(`Online: ${counts}.`);
832
+ return new Promise(() => {});
833
+ }
834
+ async function runDev(cwd) {
835
+ const { env, rest, devGuildId } = await setup(cwd);
836
+ if (!devGuildId) {
837
+ throw new Error("nubis dev needs a test server: set DISCORD_DEV_GUILD_ID (or devGuildId in nubis.config.ts). Commands go there and show up instantly.");
838
+ }
839
+ let child;
840
+ const stopChild = () => new Promise((resolve) => {
841
+ if (!child || child.exitCode !== null)
842
+ return resolve();
843
+ child.once("exit", () => resolve());
844
+ child.kill("SIGTERM");
845
+ });
846
+ const cycle = async () => {
847
+ await stopChild();
848
+ try {
849
+ const { root } = await loadConfig(cwd);
850
+ const project = await loadProject(root, { version: Date.now() });
851
+ const result = await syncCommands({
852
+ rest,
853
+ applicationId: env.applicationId,
854
+ project,
855
+ guildId: devGuildId
856
+ });
857
+ if (result.applied)
858
+ log(`Synced commands:
859
+ ${describePlan(result.plan).join(`
860
+ `)}`);
861
+ } catch (error) {
862
+ fail(error);
863
+ log("Fix the error and save; I'll try again.");
864
+ return;
865
+ }
866
+ child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
867
+ cwd,
868
+ stdio: "inherit",
869
+ env: process.env
870
+ });
871
+ };
872
+ let timer;
873
+ watch(cwd, { recursive: true }, (_event, file) => {
874
+ if (!file)
875
+ return;
876
+ const parts = relative2(cwd, `${cwd}${sep2}${file}`).split(sep2);
877
+ if (parts.some((part) => IGNORED.includes(part) || part.startsWith(".")))
878
+ return;
879
+ clearTimeout(timer);
880
+ timer = setTimeout(() => {
881
+ log(`${file} changed, restarting.`);
882
+ cycle();
883
+ }, 150);
884
+ });
885
+ process.once("SIGINT", async () => {
886
+ await stopChild();
887
+ process.exit(0);
888
+ });
889
+ log("Watching for changes. Press Ctrl+C to stop.");
890
+ await cycle();
891
+ return new Promise(() => {});
892
+ }
893
+ var log = (message) => console.log(`[nubis] ${message}`), fail = (error) => console.error(`[nubis] ${error instanceof Error ? error.message : String(error)}`), logErrors = (error, info) => {
894
+ console.error(`[nubis] ${info.kind} ${info.name} threw:`, error);
895
+ }, IGNORED;
896
+ var init_commands = __esm(() => {
897
+ init_bot();
898
+ init_config();
899
+ init_config_loader();
900
+ init_env();
901
+ init_http();
902
+ init_intents();
903
+ init_loader();
904
+ init_sync();
905
+ IGNORED = ["node_modules", ".git", "dist", ".turbo"];
906
+ });
907
+ // package.json
908
+ var package_default = {
909
+ name: "nubis",
910
+ version: "0.1.0-next.1",
911
+ description: "The Discord bot framework that feels like building a modern web app. Built by Eytak.",
912
+ keywords: [
913
+ "discord",
914
+ "discord-bot",
915
+ "framework",
916
+ "nubis"
917
+ ],
918
+ license: "MIT",
919
+ author: "Eytak",
920
+ homepage: "https://eytak.com/nubis",
921
+ repository: {
922
+ type: "git",
923
+ url: "git+https://github.com/Eytak-Technologies/nubis.git",
924
+ directory: "packages/nubis"
925
+ },
926
+ bugs: "https://github.com/Eytak-Technologies/nubis/issues",
927
+ type: "module",
928
+ exports: {
929
+ ".": {
930
+ types: "./dist/index.d.ts",
931
+ import: "./dist/index.js"
932
+ },
933
+ "./node": {
934
+ types: "./dist/node.d.ts",
935
+ import: "./dist/node.js"
936
+ },
937
+ "./testing": {
938
+ types: "./dist/testing.d.ts",
939
+ import: "./dist/testing.js"
940
+ }
941
+ },
942
+ bin: {
943
+ nubis: "./dist/cli.js"
944
+ },
945
+ files: [
946
+ "dist"
947
+ ],
948
+ engines: {
949
+ node: ">=20"
950
+ },
951
+ scripts: {
952
+ build: "bun build ./src/index.ts ./src/node.ts ./src/testing.ts ./src/cli.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
953
+ "check-types": "tsc --noEmit",
954
+ test: "bun test",
955
+ lint: "biome lint ."
956
+ },
957
+ dependencies: {
958
+ "@discordjs/core": "^2.7.0",
959
+ "@discordjs/rest": "^2.6.3",
960
+ "@discordjs/ws": "^2.0.4",
961
+ "@nubisjs/core": "workspace:*",
962
+ "@nubisjs/testing": "workspace:*",
963
+ "discord-api-types": "^0.38.59"
964
+ }
965
+ };
966
+
967
+ // src/run-cli.ts
968
+ var HELP = `nubis ${package_default.version}
969
+
970
+ Usage: nubis <command>
971
+
972
+ Commands:
973
+ dev Sync commands to your test server and run the bot, restarting on changes
974
+ start Run the bot
975
+ sync [--global] Register commands with Discord (your test server, or --global for everywhere)
976
+ [--dry-run] Show what would change without changing it
977
+
978
+ Settings come from .env: DISCORD_TOKEN, DISCORD_APPLICATION_ID and, for dev
979
+ and sync, DISCORD_DEV_GUILD_ID.
980
+
981
+ Options:
982
+ -v, --version Print the version
983
+ -h, --help Print this help`;
984
+ function runCli(args) {
985
+ const [first, ...rest] = args;
986
+ if (first === "-v" || first === "--version") {
987
+ return { code: 0, output: package_default.version };
988
+ }
989
+ if (first === undefined || first === "-h" || first === "--help") {
990
+ return { code: 0, output: HELP };
991
+ }
992
+ if (first === "dev" || first === "start" || first === "sync") {
993
+ return { run: first, args: rest };
994
+ }
995
+ return { code: 1, output: `Unknown command: ${first}
996
+
997
+ ${HELP}` };
998
+ }
999
+
1000
+ // src/cli.ts
1001
+ var result = runCli(process.argv.slice(2));
1002
+ if ("run" in result) {
1003
+ const commands = await Promise.resolve().then(() => (init_commands(), {}));
1004
+ const cwd = process.cwd();
1005
+ const run = {
1006
+ dev: () => runDev(cwd),
1007
+ start: () => runStart(cwd),
1008
+ sync: () => runSync(cwd, result.args)
1009
+ }[result.run];
1010
+ try {
1011
+ process.exitCode = await run();
1012
+ } catch (error) {
1013
+ console.error(`[nubis] ${error instanceof Error ? error.message : String(error)}`);
1014
+ process.exitCode = 1;
1015
+ }
1016
+ } else {
1017
+ (result.code === 0 ? console.log : console.error)(result.output);
1018
+ process.exitCode = result.code;
1019
+ }