nubis 0.0.0 → 0.1.0-next.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.
@@ -0,0 +1,24 @@
1
+ import { type Server } from "node:http";
2
+ import type { NubisApi } from "@nubisjs/core";
3
+ import { type NubisConfig } from "./config";
4
+ import { type Project } from "./loader";
5
+ import type { RouteOptions } from "./route";
6
+ /** The dispatch options a config asks for, shared by both transports. */
7
+ export declare function routeOptionsFor(config: NubisConfig, api: NubisApi): RouteOptions;
8
+ /** `nubis sync [--global] [--dry-run]` */
9
+ export declare function runSync(cwd: string, args: readonly string[]): Promise<number>;
10
+ /** Serves the project as an Interactions Endpoint URL. Exported for tests. */
11
+ export declare function createHttpServer(options: {
12
+ readonly publicKey: string;
13
+ readonly project: Project;
14
+ readonly config: NubisConfig;
15
+ readonly api: NubisApi;
16
+ }): Server;
17
+ /** `nubis start`: runs the bot until it's stopped. */
18
+ export declare function runStart(cwd: string): Promise<number>;
19
+ /**
20
+ * `nubis dev`: syncs commands to the test server and runs the bot, then does
21
+ * both again whenever a file changes, nubis.config.ts included. Each run is
22
+ * a fresh process, so edits to helpers are picked up too.
23
+ */
24
+ export declare function runDev(cwd: string): Promise<number>;
@@ -0,0 +1,10 @@
1
+ import { type NubisConfig } from "./config";
2
+ export interface LoadedConfig {
3
+ readonly config: NubisConfig;
4
+ /** The file it came from; undefined when the bot has none. */
5
+ readonly file: string | undefined;
6
+ /** Where the bot's folders are: `root` resolved against the config's folder. */
7
+ readonly root: string;
8
+ }
9
+ /** Loads `nubis.config.*` from `cwd`. A bot without one gets the defaults. */
10
+ export declare function loadConfig(cwd: string): Promise<LoadedConfig>;
@@ -0,0 +1,51 @@
1
+ import type { ErrorHandler, Guard, Middleware } from "@nubisjs/core";
2
+ import { GatewayIntentBits, type GatewayPresenceUpdateData } from "discord-api-types/v10";
3
+ export type IntentName = Exclude<keyof typeof GatewayIntentBits, "GuildBans" | "GuildEmojisAndStickers">;
4
+ export interface PresenceConfig {
5
+ readonly status?: "online" | "idle" | "dnd" | "invisible";
6
+ /** What the member list shows under the bot's name. */
7
+ readonly activity?: {
8
+ readonly type: "playing" | "listening" | "watching" | "competing" | "custom";
9
+ /** For "custom", the status text itself. */
10
+ readonly name: string;
11
+ };
12
+ }
13
+ /** Settings for the whole bot, in `nubis.config.ts` next to its folders. */
14
+ export interface NubisConfig {
15
+ /** Where commands/, components/, menus/ and events/ live, relative to the config. */
16
+ readonly root?: string;
17
+ /** Intents beyond what the bot's events need, e.g. ["MessageContent"]. */
18
+ readonly intents?: readonly IntentName[];
19
+ readonly presence?: PresenceConfig;
20
+ /** Guards for every command, component, modal and menu. */
21
+ readonly guards?: readonly Guard[];
22
+ /** Middleware around every handler. */
23
+ readonly middleware?: readonly Middleware[];
24
+ /** Every handler error, after the user has been told. Defaults to logging. */
25
+ readonly onError?: ErrorHandler;
26
+ /** See LifecycleOptions.autoDeferMs. */
27
+ readonly autoDeferMs?: number | false;
28
+ /**
29
+ * How `nubis start` talks to Discord: the gateway (default), or an HTTP
30
+ * endpoint for the Interactions Endpoint URL. HTTP bots get no events.
31
+ */
32
+ readonly transport?: "gateway" | "http";
33
+ readonly http?: {
34
+ /** Defaults to PORT, then 3000. */
35
+ readonly port?: number;
36
+ /** Defaults to "/interactions". */
37
+ readonly path?: string;
38
+ };
39
+ /** The test server for `nubis dev` and `nubis sync`, if not in DISCORD_DEV_GUILD_ID. */
40
+ readonly devGuildId?: string;
41
+ }
42
+ /** Types a `nubis.config.ts`. An identity function. */
43
+ export declare function defineConfig(config: NubisConfig): NubisConfig;
44
+ /**
45
+ * Checks a loaded config, so a typo fails at startup with the file and key
46
+ * named instead of being silently ignored.
47
+ */
48
+ export declare function validateConfig(config: unknown, file: string): NubisConfig;
49
+ export declare function intentBits(names?: readonly IntentName[]): number;
50
+ /** The gateway's presence payload for a config's `presence`. */
51
+ export declare function presenceData(presence: PresenceConfig | undefined): GatewayPresenceUpdateData | undefined;
package/dist/env.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** Parses KEY=VALUE lines. Quotes are optional; `#` starts a comment line. */
2
+ export declare function parseEnv(text: string): Record<string, string>;
3
+ /**
4
+ * Loads the nearest `.env`, looking in `start` and then its parents, into
5
+ * `target` without overriding what's already set. Returns the file it used.
6
+ */
7
+ export declare function loadEnv(start: string, target?: Record<string, string | undefined>): Promise<string | undefined>;
8
+ export interface DiscordEnv {
9
+ readonly token: string;
10
+ readonly applicationId: string;
11
+ readonly publicKey: string | undefined;
12
+ readonly devGuildId: string | undefined;
13
+ }
14
+ /** Reads the DISCORD_* settings, naming any that are missing. */
15
+ export declare function discordEnv(env?: Record<string, string | undefined>): DiscordEnv;
package/dist/http.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { type APIInteraction } from "discord-api-types/v10";
2
+ import { type InteractionProject, type RouteOptions } from "./route";
3
+ export type { InteractionProject } from "./route";
4
+ export interface InteractionHandlerOptions {
5
+ /** The application's public key from the Developer Portal (hex). */
6
+ readonly publicKey: string;
7
+ readonly project: InteractionProject;
8
+ /** Guards, middleware and auto-defer settings for every interaction. */
9
+ readonly dispatch?: RouteOptions;
10
+ /**
11
+ * Keeps work alive after the HTTP response is sent (follow-ups, edits).
12
+ * Serverless platforms provide this, e.g. `ctx.waitUntil` on Workers.
13
+ */
14
+ readonly waitUntil?: (work: Promise<unknown>) => void;
15
+ /** Called once an interaction has been fully handled, for logging. */
16
+ readonly onResult?: (result: InteractionResult) => void;
17
+ /** Discord's API base. Defaults to https://discord.com/api/v10. */
18
+ readonly api?: string;
19
+ /** Swappable in tests. */
20
+ readonly fetch?: typeof fetch;
21
+ }
22
+ export type InteractionResult = {
23
+ readonly interaction: APIInteraction;
24
+ readonly status: string;
25
+ readonly error?: unknown;
26
+ };
27
+ /**
28
+ * Checks Discord's Ed25519 signature on an interaction request. Discord
29
+ * sends test requests with bad signatures and disables the endpoint if they
30
+ * aren't rejected, so this must run before anything else.
31
+ */
32
+ export declare function verifyDiscordRequest(publicKey: string, signature: string, timestamp: string, body: string): Promise<boolean>;
33
+ /**
34
+ * An HTTP endpoint for Discord's "Interactions Endpoint URL". Takes a web
35
+ * standard Request and returns a Response, so it runs on Node, Bun, Deno and
36
+ * serverless platforms alike:
37
+ *
38
+ * const handle = createInteractionHandler({ publicKey, project });
39
+ * export default { fetch: handle };
40
+ */
41
+ export declare function createInteractionHandler(options: InteractionHandlerOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,6 @@
1
+ export * from "@nubisjs/core";
2
+ export type { API } from "./api";
3
+ export type { IntentName, NubisConfig, PresenceConfig } from "./config";
4
+ export { defineConfig } from "./config";
5
+ export type { InteractionHandlerOptions, InteractionProject, InteractionResult, } from "./http";
6
+ export { createInteractionHandler, verifyDiscordRequest } from "./http";
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ var __esm = (fn, res, err) => () => {
2
+ if (fn)
3
+ try {
4
+ res = fn(fn = 0);
5
+ } catch (e) {
6
+ err = [e];
7
+ }
8
+ if (err)
9
+ throw err[0];
10
+ return res;
11
+ };
12
+
13
+ // src/config.ts
14
+ import {
15
+ ActivityType,
16
+ GatewayIntentBits,
17
+ PresenceUpdateStatus
18
+ } from "discord-api-types/v10";
19
+ function defineConfig(config) {
20
+ return config;
21
+ }
22
+ function validateConfig(config, file) {
23
+ const where = (detail) => new Error(`${file}: ${detail}`);
24
+ if (typeof config !== "object" || config === null) {
25
+ throw where("export the config as default: export default defineConfig({ ... }).");
26
+ }
27
+ const value = config;
28
+ for (const key of Object.keys(value)) {
29
+ if (!KEYS.has(key)) {
30
+ throw where(`"${key}" isn't a config setting. The settings are: ${[...KEYS].join(", ")}.`);
31
+ }
32
+ }
33
+ for (const name of value.intents ?? []) {
34
+ if (typeof name !== "string" || !(name in GatewayIntentBits) || !Number.isNaN(Number(name))) {
35
+ throw where(`"${String(name)}" isn't an intent. Use names like "MessageContent" or "GuildMembers".`);
36
+ }
37
+ }
38
+ const presence = value.presence;
39
+ if (presence?.status && !(presence.status in STATUSES)) {
40
+ throw where(`presence.status must be online, idle, dnd or invisible (got "${presence.status}").`);
41
+ }
42
+ if (presence?.activity && !(presence.activity.type in ACTIVITY_TYPES)) {
43
+ throw where(`presence.activity.type must be playing, listening, watching, competing or custom (got "${presence.activity.type}").`);
44
+ }
45
+ if (value.transport !== undefined && value.transport !== "gateway" && value.transport !== "http") {
46
+ throw where(`transport must be "gateway" or "http" (got "${String(value.transport)}").`);
47
+ }
48
+ return value;
49
+ }
50
+ function intentBits(names = []) {
51
+ return names.reduce((bits, name) => bits | GatewayIntentBits[name], 0);
52
+ }
53
+ function presenceData(presence) {
54
+ if (!presence)
55
+ return;
56
+ const activity = presence.activity;
57
+ return {
58
+ since: null,
59
+ afk: false,
60
+ status: STATUSES[presence.status ?? "online"] ?? PresenceUpdateStatus.Online,
61
+ activities: activity ? [
62
+ activity.type === "custom" ? {
63
+ type: ActivityType.Custom,
64
+ name: "Custom Status",
65
+ state: activity.name
66
+ } : { type: ACTIVITY_TYPES[activity.type], name: activity.name }
67
+ ] : []
68
+ };
69
+ }
70
+ var KEYS, ACTIVITY_TYPES, STATUSES;
71
+ var init_config = __esm(() => {
72
+ KEYS = new Set([
73
+ "root",
74
+ "intents",
75
+ "presence",
76
+ "guards",
77
+ "middleware",
78
+ "onError",
79
+ "autoDeferMs",
80
+ "transport",
81
+ "http",
82
+ "devGuildId"
83
+ ]);
84
+ ACTIVITY_TYPES = {
85
+ playing: ActivityType.Playing,
86
+ listening: ActivityType.Listening,
87
+ watching: ActivityType.Watching,
88
+ competing: ActivityType.Competing,
89
+ custom: ActivityType.Custom
90
+ };
91
+ STATUSES = {
92
+ online: PresenceUpdateStatus.Online,
93
+ idle: PresenceUpdateStatus.Idle,
94
+ dnd: PresenceUpdateStatus.DoNotDisturb,
95
+ invisible: PresenceUpdateStatus.Invisible
96
+ };
97
+ });
98
+
99
+ // src/route.ts
100
+ import {
101
+ dispatch,
102
+ dispatchAutocomplete,
103
+ dispatchComponent,
104
+ dispatchMenu,
105
+ dispatchModal
106
+ } from "@nubisjs/core";
107
+ import {
108
+ ApplicationCommandType,
109
+ InteractionType
110
+ } from "discord-api-types/v10";
111
+ function routeInteraction(project, interaction, transport, options) {
112
+ switch (interaction.type) {
113
+ case InteractionType.ApplicationCommand:
114
+ return interaction.data.type === ApplicationCommandType.ChatInput ? dispatch(project.tree, interaction, transport, options) : dispatchMenu(project.menus, interaction, transport, options);
115
+ case InteractionType.MessageComponent:
116
+ return dispatchComponent(project.components, interaction, transport, options);
117
+ case InteractionType.ApplicationCommandAutocomplete:
118
+ return dispatchAutocomplete(project.tree, interaction, transport, options);
119
+ case InteractionType.ModalSubmit:
120
+ return dispatchModal(project.components, interaction, transport, options);
121
+ default:
122
+ return Promise.resolve({ status: "unsupported" });
123
+ }
124
+ }
125
+ var init_route = () => {};
126
+
127
+ // src/http.ts
128
+ import { NubisError } from "@nubisjs/core";
129
+ import {
130
+ InteractionResponseType,
131
+ InteractionType as InteractionType2
132
+ } from "discord-api-types/v10";
133
+ function importPublicKey(publicKey) {
134
+ let key = keyCache.get(publicKey);
135
+ if (!key) {
136
+ const raw = hexToBytes(publicKey);
137
+ if (!raw || raw.length !== 32) {
138
+ throw new NubisError("The public key must be the 64 hex characters shown in the Developer Portal.");
139
+ }
140
+ key = crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, [
141
+ "verify"
142
+ ]);
143
+ keyCache.set(publicKey, key);
144
+ }
145
+ return key;
146
+ }
147
+ async function verifyDiscordRequest(publicKey, signature, timestamp, body) {
148
+ const signatureBytes = hexToBytes(signature);
149
+ if (!signatureBytes || signatureBytes.length !== 64)
150
+ return false;
151
+ const key = await importPublicKey(publicKey);
152
+ return crypto.subtle.verify({ name: "Ed25519" }, key, signatureBytes, new TextEncoder().encode(timestamp + body));
153
+ }
154
+ async function discordError(response, what) {
155
+ const text = await response.text().catch(() => "");
156
+ let code;
157
+ try {
158
+ code = JSON.parse(text).code;
159
+ } catch {}
160
+ if (code === INVALID_WEBHOOK_TOKEN || code === UNKNOWN_WEBHOOK || code === UNKNOWN_INTERACTION) {
161
+ return new NubisError(`${what} failed: the interaction token has expired. Discord allows follow-ups and edits for 15 minutes after an interaction.`);
162
+ }
163
+ return new NubisError(`${what} failed: Discord answered ${response.status}${text ? ` ${text.slice(0, 200)}` : ""}.`);
164
+ }
165
+ function requestBody(body, files) {
166
+ if (body === undefined)
167
+ return { headers: {} };
168
+ if (!files?.length) {
169
+ return {
170
+ body: JSON.stringify(body),
171
+ headers: { "content-type": "application/json" }
172
+ };
173
+ }
174
+ const form = new FormData;
175
+ form.append("payload_json", JSON.stringify(body));
176
+ files.forEach((file, index) => {
177
+ const blob = file.data instanceof Blob ? file.data : new Blob([file.data], {
178
+ type: file.contentType ?? "application/octet-stream"
179
+ });
180
+ form.append(`files[${index}]`, blob, file.name);
181
+ });
182
+ return { body: form, headers: {} };
183
+ }
184
+ function webhookTransport(options) {
185
+ const { interaction, api } = options;
186
+ const webhook = `${api}/webhooks/${interaction.application_id}/${interaction.token}`;
187
+ const call = async (method, url, what, body, files) => {
188
+ const request = requestBody(body, files);
189
+ const response = await options.fetch(url, { method, ...request });
190
+ if (!response.ok)
191
+ throw await discordError(response, what);
192
+ };
193
+ const editOriginal = (body, files) => call("PATCH", `${webhook}/messages/@original`, "Editing the reply", body, files);
194
+ return {
195
+ respond: async (response, files) => {
196
+ if (files?.length && "data" in response) {
197
+ const updating = response.type === InteractionResponseType.UpdateMessage;
198
+ const flags = response.data?.flags;
199
+ await options.respond(updating ? { type: InteractionResponseType.DeferredMessageUpdate } : {
200
+ type: InteractionResponseType.DeferredChannelMessageWithSource,
201
+ data: flags ? { flags } : {}
202
+ });
203
+ await editOriginal(response.data, files);
204
+ return;
205
+ }
206
+ await options.respond(response);
207
+ },
208
+ editOriginal,
209
+ deleteOriginal: () => call("DELETE", `${webhook}/messages/@original`, "Deleting the reply"),
210
+ followUp: (body, files) => call("POST", webhook, "Sending a follow-up", body, files)
211
+ };
212
+ }
213
+ function createInteractionHandler(options) {
214
+ const api = options.api ?? DEFAULT_API;
215
+ const fetcher = options.fetch ?? fetch;
216
+ importPublicKey(options.publicKey);
217
+ return async (request) => {
218
+ if (request.method !== "POST")
219
+ return new Response(null, { status: 405 });
220
+ const signature = request.headers.get("x-signature-ed25519");
221
+ const timestamp = request.headers.get("x-signature-timestamp");
222
+ const body = await request.text();
223
+ if (!signature || !timestamp || !await verifyDiscordRequest(options.publicKey, signature, timestamp, body)) {
224
+ return new Response("Bad request signature", { status: 401 });
225
+ }
226
+ const interaction = JSON.parse(body);
227
+ if (interaction.type === InteractionType2.Ping) {
228
+ return json({ type: InteractionResponseType.Pong });
229
+ }
230
+ let answered = false;
231
+ let answer = () => {};
232
+ const initial = new Promise((resolve) => {
233
+ answer = resolve;
234
+ });
235
+ const transport = webhookTransport({
236
+ interaction,
237
+ api,
238
+ fetch: fetcher,
239
+ respond: async (response) => {
240
+ if (!answered) {
241
+ answered = true;
242
+ answer(response);
243
+ return;
244
+ }
245
+ const callback = await fetcher(`${api}/interactions/${interaction.id}/${interaction.token}/callback`, {
246
+ method: "POST",
247
+ headers: { "content-type": "application/json" },
248
+ body: JSON.stringify(response)
249
+ });
250
+ if (!callback.ok)
251
+ throw await discordError(callback, "Responding");
252
+ }
253
+ });
254
+ const work = routeInteraction(options.project, interaction, transport, options.dispatch ?? {}).catch((error) => ({ status: "error", error })).then((result) => {
255
+ options.onResult?.({ interaction, ...result });
256
+ return result;
257
+ });
258
+ options.waitUntil?.(work);
259
+ const first = await Promise.race([initial, work.then(() => {
260
+ return;
261
+ })]);
262
+ if (first === undefined) {
263
+ answered = true;
264
+ return json({ error: "The handler finished without responding." }, 500);
265
+ }
266
+ return json(first);
267
+ };
268
+ }
269
+ var DEFAULT_API = "https://discord.com/api/v10", hexToBytes = (hex) => {
270
+ if (hex.length % 2 !== 0 || /[^0-9a-f]/i.test(hex))
271
+ return;
272
+ const bytes = new Uint8Array(hex.length / 2);
273
+ for (let i = 0;i < bytes.length; i++) {
274
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
275
+ }
276
+ return bytes;
277
+ }, keyCache, UNKNOWN_INTERACTION = 10062, UNKNOWN_WEBHOOK = 10015, INVALID_WEBHOOK_TOKEN = 50027, json = (body, status = 200) => new Response(JSON.stringify(body), {
278
+ status,
279
+ headers: { "content-type": "application/json" }
280
+ });
281
+ var init_http = __esm(() => {
282
+ init_route();
283
+ keyCache = new Map;
284
+ });
285
+
286
+ // src/index.ts
287
+ init_config();
288
+ init_http();
289
+
290
+ export * from "@nubisjs/core";
291
+ export {
292
+ createInteractionHandler,
293
+ defineConfig,
294
+ verifyDiscordRequest
295
+ };
@@ -0,0 +1,11 @@
1
+ import type { EventRegistry } from "@nubisjs/core";
2
+ /** Intents Discord makes you switch on in the Developer Portal first. */
3
+ export declare const PRIVILEGED_INTENTS: number;
4
+ /**
5
+ * The intents a bot needs for the events it listens to, plus any extra it
6
+ * asks for (MessageContent, for instance, is never added automatically).
7
+ * Interactions need no intent at all.
8
+ */
9
+ export declare function intentsFor(events: EventRegistry, extra?: number): number;
10
+ /** Readable names for a bit field, for logs and errors. */
11
+ export declare function intentNames(intents: number): string[];
@@ -0,0 +1,33 @@
1
+ import { type CommandTree, type ComponentRegistry, type EventRegistry, type MenuDefinition } from "@nubisjs/core";
2
+ /** Everything a bot is made of, read from its folders. */
3
+ export interface Project {
4
+ readonly root: string;
5
+ readonly tree: CommandTree;
6
+ readonly components: ComponentRegistry;
7
+ readonly menus: readonly MenuDefinition[];
8
+ readonly events: EventRegistry;
9
+ /** Every module that was loaded, relative to `root`, for watching and messages. */
10
+ readonly files: readonly string[];
11
+ }
12
+ export interface LoadOptions {
13
+ /** File extensions to load. Defaults to .ts, .mts, .js and .mjs. */
14
+ readonly extensions?: readonly string[];
15
+ /**
16
+ * Appended to import URLs so a changed file is imported fresh, for hot
17
+ * reload. Leave it out to use the module cache.
18
+ */
19
+ readonly version?: string | number;
20
+ }
21
+ /**
22
+ * Loads a bot from its folders:
23
+ *
24
+ * - `commands/` — one command per file, `export default command(...)`.
25
+ * Folders become subcommands; `_folder.ts` sets a folder's description,
26
+ * guards and permissions.
27
+ * - `components/` — buttons, selects and modals; every export that is one.
28
+ * - `menus/` — right-click commands, `export default userCommand(...)`.
29
+ * - `events/` — `export default event(...)`, or an array of them.
30
+ *
31
+ * Files starting with `_`, tests and `.d.ts` files are skipped.
32
+ */
33
+ export declare function loadProject(root: string, options?: LoadOptions): Promise<Project>;
@@ -0,0 +1,8 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /**
3
+ * Adapts a fetch-style handler (`Request` in, `Response` out) to a
4
+ * `node:http` listener:
5
+ *
6
+ * createServer(toNodeListener(createInteractionHandler({ ... }))).listen(3000);
7
+ */
8
+ export declare function toNodeListener(handler: (request: Request) => Promise<Response>): (request: IncomingMessage, response: ServerResponse) => void;
package/dist/node.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Node and Bun only: reading a bot from disk. Kept out of the main `nubis`
3
+ * entry so code that imports `nubis` can still be bundled for edge runtimes.
4
+ */
5
+ export type { LoadOptions, Project } from "./loader";
6
+ export { loadProject } from "./loader";
7
+ export { toNodeListener } from "./node-listener";
8
+ export type { Bot, BotOptions, GatewayLike } from "./bot";
9
+ export { createBot } from "./bot";
10
+ export { intentBits, presenceData, validateConfig } from "./config";
11
+ export type { LoadedConfig } from "./config-loader";
12
+ export { loadConfig } from "./config-loader";
13
+ export type { DiscordEnv } from "./env";
14
+ export { discordEnv, loadEnv, parseEnv } from "./env";
15
+ export { intentNames, intentsFor, PRIVILEGED_INTENTS } from "./intents";
16
+ export type { RestLike } from "./rest-transport";
17
+ export { restTransport } from "./rest-transport";
18
+ export type { SyncOptions, SyncResult } from "./sync";
19
+ export { describePlan, hasChanges, syncCommands } from "./sync";