@seedcord/http 0.1.0-next.1 → 0.1.0-next.3

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.
@@ -2,14 +2,14 @@ import { n as __reExport, t as __exportAll } from "./rolldown-runtime-BUUfjzRC.m
2
2
  import { SeedcordError, validateDiscordToken } from "@seedcord/errors/internal";
3
3
  import { Converters, Envapter } from "envapt";
4
4
  import { DiscordAPIError, REST } from "@discordjs/rest";
5
- import { BaseHandler, DispatchContext, Fault, Notice, RepliableHandler, Silence } from "@seedcord/core";
6
- import { BaseReplySender, GatedMetadataKey, decodeComponentRoute, deferFlags, prefixOf, runHandlerGates, sendFlags, slowGateMonitor } from "@seedcord/core/internal";
5
+ import { ArraySource, BaseHandler, Bus, CursorSource, DispatchContext, Fault, Notice, PaginatorBase, RepliableHandler, Silence, Subscriber, WebhookLog } from "@seedcord/core";
6
+ import { BaseReplySender, FaultThrottle, GatedMetadataKey, PublishDefault, accessorStore, asError, clearStore, decodeComponentRoute, deferFlags, dispatchedPayload, guardedAccessor, isEmojiTuple, outcomeFor, prefixOf, queuedMsFor, reportedWrite, runHandlerGates, sendFlags, slowGateMonitor } from "@seedcord/core/internal";
7
7
  import { Logger, paint } from "@seedcord/logger";
8
8
  import { MemoryRateLimiter } from "@seedcord/rate-limiter";
9
9
  import { ApplicationCommandOptionType, ApplicationCommandType, ComponentType, InteractionResponseType, InteractionType, MessageFlags, RESTJSONErrorCodes, Routes } from "discord-api-types/v10";
10
10
  import { API } from "@discordjs/core/http-only";
11
- import { SeedcordErrorCode } from "@seedcord/errors";
12
11
  import { buildSlashRoute } from "@seedcord/utils/internal";
12
+ import { SeedcordErrorCode } from "@seedcord/errors";
13
13
 
14
14
  //#region src/reply/ReplySender.ts
15
15
  function wireAllowedMentions(mentions) {
@@ -43,8 +43,11 @@ function callbackData(reply, flags) {
43
43
  var ReplySender = class extends BaseReplySender {
44
44
  ref;
45
45
  rest;
46
- constructor(ref, rest, routeId) {
47
- super(routeId);
46
+ constructor(ref, rest, routeId, bus) {
47
+ super(routeId, "unacked", bus && {
48
+ bus,
49
+ interactionId: ref.id
50
+ });
48
51
  this.ref = ref;
49
52
  this.rest = rest;
50
53
  }
@@ -140,12 +143,16 @@ function apiFor(rest) {
140
143
  * @typeParam Event - The repliable interaction type this handler processes
141
144
  */
142
145
  var RepliableHandler$1 = class extends RepliableHandler {
146
+ /** @internal */
147
+ getEvent() {
148
+ return this.event;
149
+ }
143
150
  buildSender(event, core, routeId) {
144
151
  return new ReplySender({
145
152
  application_id: event.application_id,
146
153
  id: event.id,
147
154
  token: event.token
148
- }, core.rest, routeId);
155
+ }, core.rest, routeId, core.bus);
149
156
  }
150
157
  /**
151
158
  * Typed Discord REST API (`@discordjs/core/http-only`) over `core.rest`, one instance per core.
@@ -178,31 +185,132 @@ function interactionGateContext(payload, core, routeId) {
178
185
  };
179
186
  }
180
187
 
188
+ //#endregion
189
+ //#region src/dispatch/slashRouteOf.ts
190
+ /**
191
+ * Computes the dispatch route for a chat-input or autocomplete payload, the raw-payload port of the
192
+ * gateway's `slashRouteOf`. Both assemble keys through `buildSlashRoute`, so a dispatched interaction
193
+ * and a generated registry key always match.
194
+ */
195
+ function slashRouteOf(data) {
196
+ const first = data.options?.[0];
197
+ if (first?.type === ApplicationCommandOptionType.SubcommandGroup) return buildSlashRoute(data.name, first.options[0]?.name, first.name);
198
+ if (first?.type === ApplicationCommandOptionType.Subcommand) return buildSlashRoute(data.name, first.name);
199
+ return data.name;
200
+ }
201
+
202
+ //#endregion
203
+ //#region src/dispatch/reportFault.ts
204
+ let faultLogger;
205
+ function logger$1() {
206
+ faultLogger ??= new Logger("Faults", { channel: "errors" });
207
+ return faultLogger;
208
+ }
209
+ /**
210
+ * Publishes a fault on the bus, the raw-payload equivalent of the reporting half of gateway's
211
+ * `extractErrorResponse`. A reported {@link Notice} publishes `handledException`, everything else
212
+ * publishes `unknownException`.
213
+ *
214
+ * @internal
215
+ */
216
+ function reportFault(error, uuid, payload, core) {
217
+ const throttle = FaultThrottle.for(core);
218
+ const key = `${routeKeyOf(payload)}:${nameOf(error)}`;
219
+ if (!throttle.shouldReport(key)) {
220
+ logger$1().debug(`throttled duplicate fault ${paint.mute(key)}`);
221
+ return;
222
+ }
223
+ const source = interactionSource(payload);
224
+ if (error instanceof Notice && source) core.bus[PublishDefault]("handledException", {
225
+ denial: error,
226
+ uuid,
227
+ source
228
+ });
229
+ else core.bus[PublishDefault]("unknownException", {
230
+ uuid,
231
+ error,
232
+ ...actors(payload),
233
+ metadata: payload
234
+ });
235
+ throttle.markReported(key);
236
+ }
237
+ function routeKeyOf(payload) {
238
+ if (payload.type === InteractionType.ApplicationCommandAutocomplete) return `autocomplete:${slashRouteOf(payload.data)}`;
239
+ if (payload.type === InteractionType.ApplicationCommand) return payload.data.type === ApplicationCommandType.ChatInput ? slashRouteOf(payload.data) : payload.data.name;
240
+ return prefixOf(payload.data.custom_id) || payload.data.custom_id;
241
+ }
242
+ function nameOf(error) {
243
+ if (error instanceof Notice) return error.name;
244
+ if (error instanceof DiscordAPIError) return String(error.code);
245
+ return error.constructor.name;
246
+ }
247
+ function actors(payload) {
248
+ const user = payload.member?.user ?? payload.user;
249
+ return {
250
+ guild: void 0,
251
+ user: user ? {
252
+ id: user.id,
253
+ username: user.username
254
+ } : void 0
255
+ };
256
+ }
257
+ function interactionSource(payload) {
258
+ const user = payload.member?.user ?? payload.user;
259
+ if (!user) return null;
260
+ const common = {
261
+ kind: "interaction",
262
+ userId: user.id,
263
+ guildId: payload.guild_id ?? null,
264
+ channelId: payload.channel?.id ?? null,
265
+ interactionId: payload.id,
266
+ raw: payload
267
+ };
268
+ if (payload.type === InteractionType.ApplicationCommand) {
269
+ const isSlash = payload.data.type === ApplicationCommandType.ChatInput;
270
+ return {
271
+ ...common,
272
+ interactionKind: isSlash ? "slash" : "context-menu",
273
+ command: routeKeyOf(payload),
274
+ customId: null
275
+ };
276
+ }
277
+ if (payload.type === InteractionType.ModalSubmit) return {
278
+ ...common,
279
+ interactionKind: "modal",
280
+ command: null,
281
+ customId: payload.data.custom_id
282
+ };
283
+ if (payload.type === InteractionType.MessageComponent) {
284
+ const isButton = payload.data.component_type === ComponentType.Button;
285
+ return {
286
+ ...common,
287
+ interactionKind: isButton ? "button" : "select",
288
+ command: null,
289
+ customId: payload.data.custom_id
290
+ };
291
+ }
292
+ return null;
293
+ }
294
+
181
295
  //#endregion
182
296
  //#region src/dispatch/dispatchInteraction.ts
183
297
  let dispatchLogger;
184
298
  function logger() {
185
- dispatchLogger ??= new Logger("Dispatcher");
299
+ dispatchLogger ??= new Logger("Dispatcher", { channel: "interactions" });
186
300
  return dispatchLogger;
187
301
  }
188
302
  function createCore(config, token) {
189
- return {
303
+ const draft = {
190
304
  config,
191
305
  rateLimiter: config.store ?? new MemoryRateLimiter(),
192
- rest: new REST().setToken(token),
193
- version: "0.1.0-next.1",
194
- username: void 0,
195
- augmentTarget: "@seedcord/http"
306
+ rest: new REST().setToken(token)
196
307
  };
308
+ draft.bus = new Bus(draft);
309
+ return draft;
197
310
  }
198
311
  function isHandlerCtor(value) {
199
312
  return typeof value === "function" && value.prototype instanceof BaseHandler;
200
313
  }
201
- function handlerCtorOf(moduleExports) {
202
- if (typeof moduleExports !== "object" || moduleExports === null) return null;
203
- for (const value of Object.values(moduleExports)) if (isHandlerCtor(value)) return value;
204
- return null;
205
- }
206
314
  const HARMLESS_API_CODES = new Set([
207
315
  RESTJSONErrorCodes.UnknownInteraction,
208
316
  RESTJSONErrorCodes.InteractionHasAlreadyBeenAcknowledged,
@@ -220,10 +328,13 @@ async function sendGuarded(routeId, send) {
220
328
  }
221
329
  }
222
330
  async function respondEmptyChoices(scope) {
223
- await scope.core.rest.post(Routes.interactionCallback(scope.payload.id, scope.payload.token), { body: {
331
+ await reportedWrite({
332
+ bus: scope.core.bus,
333
+ interactionId: scope.payload.id
334
+ }, scope.routeId, "respond", () => scope.core.rest.post(Routes.interactionCallback(scope.payload.id, scope.payload.token), { body: {
224
335
  type: InteractionResponseType.ApplicationCommandAutocompleteResult,
225
336
  data: { choices: [] }
226
- } });
337
+ } }));
227
338
  }
228
339
  function renderContext(core, uuid) {
229
340
  const developerUsername = core.config.notifications?.developerUsername;
@@ -233,7 +344,10 @@ function renderContext(core, uuid) {
233
344
  };
234
345
  }
235
346
  async function handleNotice(notice, uuid, scope) {
236
- if (notice.report) logger().error(`${notice.name}: ${paint.mute(uuid)}`, notice);
347
+ if (notice.report) {
348
+ logger().error(`${notice.name}: ${paint.mute(uuid)}`, notice);
349
+ reportFault(notice, uuid, scope.payload, scope.core);
350
+ }
237
351
  const { sender } = scope;
238
352
  if (!sender) {
239
353
  await sendGuarded(scope.routeId, () => respondEmptyChoices(scope));
@@ -246,6 +360,7 @@ async function handleRawFault(error, uuid, scope) {
246
360
  const { core, sender } = scope;
247
361
  if (core.config.errors?.errorStack ?? false) logger().error(paint.mute(uuid), error);
248
362
  else logger().error(`${paint.mute(uuid)} | ${error.message}`);
363
+ reportFault(error, uuid, scope.payload, core);
249
364
  if (!sender) {
250
365
  await sendGuarded(scope.routeId, () => respondEmptyChoices(scope));
251
366
  return;
@@ -256,7 +371,7 @@ async function handleRawFault(error, uuid, scope) {
256
371
  }
257
372
  async function handleFault(caught, scope) {
258
373
  if (caught instanceof Silence) {
259
- if (caught.reason !== void 0) logger().debug(`Silence: ${caught.reason}`);
374
+ if (caught.reason !== void 0 && (scope.core.config.errors?.logSilences ?? true)) logger().debug(`Silence: ${caught.reason}`);
260
375
  return;
261
376
  }
262
377
  const uuid = crypto.randomUUID();
@@ -264,7 +379,7 @@ async function handleFault(caught, scope) {
264
379
  await handleNotice(caught, uuid, scope);
265
380
  return;
266
381
  }
267
- const error = Error.isError(caught) ? caught : new Error(String(caught));
382
+ const error = asError(caught);
268
383
  const ignore = new Set(scope.core.config.errors?.ignoreApiCodes ?? []);
269
384
  if (error instanceof DiscordAPIError && ignore.has(error.code)) {
270
385
  logger().debug(`swallowed api code ${paint.amber(String(error.code))}`);
@@ -272,79 +387,149 @@ async function handleFault(caught, scope) {
272
387
  }
273
388
  await handleRawFault(error, uuid, scope);
274
389
  }
390
+ function unhandledRouteId(match) {
391
+ if (match.routeId) return match.routeId;
392
+ const key = match.attemptedKey ?? "";
393
+ return `${match.kind}:${key.length > 0 ? key : "unrouted"}`;
394
+ }
275
395
  function freshScope(match, payload, core) {
276
396
  const ref = {
277
397
  application_id: payload.application_id,
278
398
  id: payload.id,
279
399
  token: payload.token
280
400
  };
281
- const routeId = match.routeId ?? "unhandled";
401
+ const routeId = unhandledRouteId(match);
282
402
  return {
283
403
  core,
284
404
  payload,
285
405
  routeId,
286
- sender: match.kind === "autocomplete" ? null : new ReplySender(ref, core.rest, routeId)
406
+ sender: match.kind === "autocomplete" ? null : new ReplySender(ref, core.rest, routeId, core.bus)
287
407
  };
288
408
  }
289
- /**
290
- * Runs the pre-ack phase for a matched interaction. Loads the route module, constructs the handler, and
291
- * runs its gates, and the sender posts any refusal. Returns the post-ack execute continuation, or null
292
- * when a gate refuses or nothing can run.
293
- */
294
- async function dispatchInteraction(args) {
295
- const { match, payload, core } = args;
296
- let moduleExports;
409
+ function dispatchReporter(match, payload, core) {
410
+ const startedAt = performance.now();
411
+ const queuedMs = queuedMsFor(payload.id);
412
+ return (outcome) => {
413
+ core.bus[PublishDefault]("interactionDispatched", dispatchedPayload({
414
+ routeId: unhandledRouteId(match),
415
+ interactionId: payload.id,
416
+ kind: match.kind,
417
+ outcome,
418
+ fallback: match.routeId === null,
419
+ startedAt,
420
+ queuedMs
421
+ }));
422
+ };
423
+ }
424
+ async function answer(caught, scope, report, outcome = outcomeFor(caught)) {
297
425
  try {
298
- moduleExports = await match.load();
299
- } catch (caught) {
300
- logger().error(`Route ${paint.sky.bold(match.routeId ?? "unhandled")} failed to load its module.`, caught);
301
- await handleFault(caught, freshScope(match, payload, core));
302
- return null;
426
+ await handleFault(caught, scope);
427
+ } finally {
428
+ report(outcome);
303
429
  }
304
- const ctor = handlerCtorOf(moduleExports);
305
- if (!ctor) {
306
- const routeId = match.routeId ?? "unhandled";
307
- logger().error(`Route ${paint.sky.bold(routeId)} loaded a module with no handler class.`);
308
- await handleFault(/* @__PURE__ */ new Error(`route ${routeId} loaded a module with no handler class`), freshScope(match, payload, core));
430
+ }
431
+ async function loadHandlerCtor(match, payload, core, report) {
432
+ const routeId = unhandledRouteId(match);
433
+ let exported;
434
+ try {
435
+ exported = await match.load();
436
+ } catch (caught) {
437
+ logger().error(`Route ${paint.sky.bold(routeId)} failed to load its handler.`, caught);
438
+ await answer(caught, freshScope(match, payload, core), report, "failed");
309
439
  return null;
310
440
  }
311
- const dispatch = new DispatchContext(match.routeId);
441
+ if (isHandlerCtor(exported)) return exported;
442
+ logger().error(`Route ${paint.sky.bold(routeId)} loaded an export that is not a handler class.`);
443
+ await answer(/* @__PURE__ */ new Error(`route ${routeId} loaded an export that is not a handler class`), freshScope(match, payload, core), report, "failed");
444
+ return null;
445
+ }
446
+ /**
447
+ * Runs the pre-ack phase for a matched interaction. Loads the route's handler class, constructs it, and
448
+ * runs its gates. The sender posts any refusal. Returns the post-ack execute continuation, or null when a
449
+ * gate refuses or nothing can run.
450
+ */
451
+ async function dispatchInteraction(args) {
452
+ const { match, payload, core } = args;
453
+ const report = dispatchReporter(match, payload, core);
454
+ const ctor = await loadHandlerCtor(match, payload, core, report);
455
+ if (!ctor) return null;
456
+ const dispatch = new DispatchContext(unhandledRouteId(match));
312
457
  let handler;
313
458
  try {
314
459
  handler = new ctor(payload, core, dispatch);
315
460
  } catch (caught) {
316
- await handleFault(caught, freshScope(match, payload, core));
461
+ await answer(caught, freshScope(match, payload, core), report);
317
462
  return null;
318
463
  }
319
464
  const scope = {
320
465
  core,
321
466
  payload,
322
- routeId: match.routeId ?? handler.constructor.name,
467
+ routeId: unhandledRouteId(match),
323
468
  sender: handler instanceof RepliableHandler$1 ? handler.getSender() : null
324
469
  };
325
- if (!await passedGates(ctor, match, payload, core, scope)) return null;
470
+ const refusal = await gateRefusal(ctor, match, payload, core);
471
+ if (refusal) {
472
+ await answer(refusal.caught, scope, report);
473
+ return null;
474
+ }
326
475
  return async () => {
327
476
  try {
328
477
  await handler.execute();
478
+ report("handled");
329
479
  } catch (caught) {
330
- await handleFault(caught, scope);
480
+ await answer(caught, scope, report);
331
481
  }
332
482
  };
333
483
  }
334
- async function passedGates(ctor, match, payload, core, scope) {
335
- if (match.kind === "autocomplete" || payload.type === InteractionType.ApplicationCommandAutocomplete) return true;
484
+ async function gateRefusal(ctor, match, payload, core) {
485
+ if (match.kind === "autocomplete" || payload.type === InteractionType.ApplicationCommandAutocomplete) return null;
336
486
  const monitor = slowGateMonitor();
337
487
  try {
338
488
  await runHandlerGates(ctor, interactionGateContext(payload, core, match.routeId), match.routeId ?? void 0, monitor?.observe);
339
- return true;
489
+ return null;
340
490
  } catch (caught) {
341
- await handleFault(caught, scope);
342
- return false;
491
+ return { caught };
343
492
  } finally {
344
493
  monitor?.report(match.routeId);
345
494
  }
346
495
  }
347
496
 
497
+ //#endregion
498
+ //#region src/dispatch/registerSubscribers.ts
499
+ /**
500
+ * Registers a manifest's subscribers on the bus without importing them. Each row's module loads on
501
+ * the first publish of one of its keys, which keeps an isolate that never publishes free of the cost.
502
+ *
503
+ * @internal
504
+ */
505
+ function registerSubscribers(bus, manifest) {
506
+ for (const row of manifest.subscriberRoutes) bus.register({
507
+ keys: row.keys,
508
+ frequency: row.frequency,
509
+ resolve: async () => {
510
+ const keys = row.keys.join(", ");
511
+ let module;
512
+ try {
513
+ module = await row.load();
514
+ } catch (caught) {
515
+ throw new SeedcordError(SeedcordErrorCode.RouteModuleLoadFailed, [keys, row.from], { cause: caught });
516
+ }
517
+ if (!Object.hasOwn(module, row.exportName)) throw new SeedcordError(SeedcordErrorCode.InteractionRouteExportMissing, [
518
+ keys,
519
+ row.exportName,
520
+ row.from
521
+ ]);
522
+ const exported = module[row.exportName];
523
+ if (typeof exported !== "function" || !(exported.prototype instanceof Subscriber)) throw new SeedcordError(SeedcordErrorCode.SubscriberRouteNotASubscriber, [
524
+ keys,
525
+ row.exportName,
526
+ row.from
527
+ ]);
528
+ return exported;
529
+ }
530
+ });
531
+ }
532
+
348
533
  //#endregion
349
534
  //#region src/inputs/HttpSlashOptions.ts
350
535
  var HttpSlashOptions = class {
@@ -421,20 +606,6 @@ function numeric(value) {
421
606
  return Number.isNaN(parsed) ? null : parsed;
422
607
  }
423
608
 
424
- //#endregion
425
- //#region src/dispatch/slashRouteOf.ts
426
- /**
427
- * Computes the dispatch route for a chat-input or autocomplete payload, the raw-payload port of the
428
- * gateway's `slashRouteOf`. Both assemble keys through `buildSlashRoute`, so a dispatched interaction
429
- * and a generated registry key always match.
430
- */
431
- function slashRouteOf(data) {
432
- const first = data.options?.[0];
433
- if (first?.type === ApplicationCommandOptionType.SubcommandGroup) return buildSlashRoute(data.name, first.options[0]?.name, first.name);
434
- if (first?.type === ApplicationCommandOptionType.Subcommand) return buildSlashRoute(data.name, first.name);
435
- return data.name;
436
- }
437
-
438
609
  //#endregion
439
610
  //#region src/handlers/interaction/AutocompleteHandler.ts
440
611
  /**
@@ -448,7 +619,7 @@ function slashRouteOf(data) {
448
619
  */
449
620
  var AutocompleteHandler = class extends BaseHandler {
450
621
  constructor(event, core, dispatch) {
451
- super(event, core, dispatch);
622
+ super(event, core, dispatch, "interactions");
452
623
  }
453
624
  /**
454
625
  * Typed Discord REST API (`@discordjs/core/http-only`) over `core.rest`, one instance per core.
@@ -491,10 +662,14 @@ var AutocompleteHandler = class extends BaseHandler {
491
662
  }
492
663
  /** Send autocomplete suggestions, callback type 8. Prefer {@link match}, which restricts each field's choices to its declared type. */
493
664
  async respond(choices) {
494
- await this.core.rest.post(Routes.interactionCallback(this.event.id, this.event.token), { body: {
665
+ const routeId = this.dispatch?.routeId ?? "autocomplete";
666
+ await reportedWrite({
667
+ bus: this.core.bus,
668
+ interactionId: this.event.id
669
+ }, routeId, "respond", () => this.core.rest.post(Routes.interactionCallback(this.event.id, this.event.token), { body: {
495
670
  type: InteractionResponseType.ApplicationCommandAutocompleteResult,
496
671
  data: { choices }
497
- } });
672
+ } }));
498
673
  }
499
674
  /** The firing command route, for a field whose completion differs per registered command. */
500
675
  get route() {
@@ -592,14 +767,30 @@ function componentMapKey(type) {
592
767
  default: return null;
593
768
  }
594
769
  }
595
- function set(map, kind, key, load) {
596
- map.set(key, {
597
- kind,
598
- routeId: `${kind}:${key}`,
599
- load
600
- });
770
+ function namedExport(routeId, row) {
771
+ return async () => {
772
+ let moduleExports;
773
+ try {
774
+ moduleExports = await row.load();
775
+ } catch (caught) {
776
+ throw new SeedcordError(SeedcordErrorCode.RouteModuleLoadFailed, [routeId, row.from], { cause: caught });
777
+ }
778
+ if (!Object.hasOwn(moduleExports, row.exportName)) throw new SeedcordError(SeedcordErrorCode.InteractionRouteExportMissing, [
779
+ routeId,
780
+ row.exportName,
781
+ row.from
782
+ ]);
783
+ return moduleExports[row.exportName];
784
+ };
601
785
  }
602
- /** Builds the per-kind lookup maps. */
786
+ function describeRow(row) {
787
+ return `${row.exportName} (${row.from})`;
788
+ }
789
+ /**
790
+ * Builds the per-kind lookup maps.
791
+ *
792
+ * @throws A **SeedcordError** when two rows resolve to the same route.
793
+ */
603
794
  function buildRouteMaps(manifest) {
604
795
  const maps = {
605
796
  slash: /* @__PURE__ */ new Map(),
@@ -616,19 +807,36 @@ function buildRouteMaps(manifest) {
616
807
  modal: /* @__PURE__ */ new Map()
617
808
  }
618
809
  };
619
- for (const row of manifest.commands) {
810
+ const owners = /* @__PURE__ */ new Map();
811
+ function set(map, kind, key, row) {
812
+ const routeId = `${kind}:${key}`;
813
+ const owner = owners.get(routeId);
814
+ if (owner) throw new SeedcordError(SeedcordErrorCode.InteractionDuplicateRoute, [
815
+ routeId,
816
+ describeRow(owner),
817
+ describeRow(row)
818
+ ]);
819
+ owners.set(routeId, row);
820
+ map.set(key, {
821
+ kind,
822
+ routeId,
823
+ load: namedExport(routeId, row)
824
+ });
825
+ }
826
+ for (const row of manifest.commandRoutes) {
620
827
  const kind = commandKind(row.type);
621
- if (kind) set(maps[kind], kind, row.name, row.load);
828
+ if (kind) set(maps[kind], kind, row.name, row);
622
829
  }
623
- for (const row of manifest.autocomplete) set(maps.autocomplete, "autocomplete", row.name, row.load);
624
- for (const row of manifest.components) set(maps.components[row.kind], COMPONENT_KIND[row.kind], row.prefix, row.load);
830
+ for (const row of manifest.autocompleteRoutes) set(maps.autocomplete, "autocomplete", row.name, row);
831
+ for (const row of manifest.componentRoutes) set(maps.components[row.kind], COMPONENT_KIND[row.kind], row.prefix, row);
625
832
  return maps;
626
833
  }
627
- function unhandled(kind) {
834
+ function unhandled(kind, attemptedKey) {
628
835
  return {
629
836
  kind,
630
837
  routeId: null,
631
- load: () => Promise.resolve(kind === "autocomplete" ? { UnhandledAutocomplete } : { UnhandledRepliable })
838
+ attemptedKey,
839
+ load: () => Promise.resolve(kind === "autocomplete" ? UnhandledAutocomplete : UnhandledRepliable)
632
840
  };
633
841
  }
634
842
  /**
@@ -641,17 +849,27 @@ function unhandled(kind) {
641
849
  function resolve(maps, interaction) {
642
850
  switch (interaction.type) {
643
851
  case InteractionType.ApplicationCommand: {
644
- if (interaction.data.type === ApplicationCommandType.ChatInput) return maps.slash.get(slashRouteOf(interaction.data)) ?? unhandled("slash");
852
+ if (interaction.data.type === ApplicationCommandType.ChatInput) {
853
+ const route = slashRouteOf(interaction.data);
854
+ return maps.slash.get(route) ?? unhandled("slash", route);
855
+ }
645
856
  const kind = commandKind(interaction.data.type);
646
- return kind ? maps[kind].get(interaction.data.name) ?? unhandled(kind) : null;
857
+ return kind ? maps[kind].get(interaction.data.name) ?? unhandled(kind, interaction.data.name) : null;
858
+ }
859
+ case InteractionType.ApplicationCommandAutocomplete: {
860
+ const route = slashRouteOf(interaction.data);
861
+ return maps.autocomplete.get(route) ?? unhandled("autocomplete", route);
647
862
  }
648
- case InteractionType.ApplicationCommandAutocomplete: return maps.autocomplete.get(slashRouteOf(interaction.data)) ?? unhandled("autocomplete");
649
863
  case InteractionType.MessageComponent: {
650
864
  const key = componentMapKey(interaction.data.component_type);
651
865
  if (!key) return null;
652
- return maps.components[key].get(prefixOf(interaction.data.custom_id)) ?? unhandled(COMPONENT_KIND[key]);
866
+ const prefix = prefixOf(interaction.data.custom_id);
867
+ return maps.components[key].get(prefix) ?? unhandled(COMPONENT_KIND[key], prefix);
868
+ }
869
+ case InteractionType.ModalSubmit: {
870
+ const prefix = prefixOf(interaction.data.custom_id);
871
+ return maps.components.modal.get(prefix) ?? unhandled("modal", prefix);
653
872
  }
654
- case InteractionType.ModalSubmit: return maps.components.modal.get(prefixOf(interaction.data.custom_id)) ?? unhandled("modal");
655
873
  default: return null;
656
874
  }
657
875
  }
@@ -732,7 +950,7 @@ function buildEngine(core, maps) {
732
950
  const verifier = new Ed25519Verifier(Envapter.getRequired("DISCORD_PUBLIC_KEY", Converters.String));
733
951
  const replays = new ReplayGuard();
734
952
  const inFlight = /* @__PURE__ */ new Set();
735
- const logger = new Logger("Engine");
953
+ const logger = new Logger("Engine", { channel: "bot" });
736
954
  async function dispatchMatched(match, payload, ctx) {
737
955
  const start = await dispatchInteraction({
738
956
  match,
@@ -740,13 +958,18 @@ function buildEngine(core, maps) {
740
958
  core
741
959
  });
742
960
  if (!start) return;
743
- const work = start();
961
+ const work = start().catch(rootFault);
744
962
  if (ctx) ctx.waitUntil(work);
745
963
  else {
746
964
  inFlight.add(work);
747
965
  work.finally(() => inFlight.delete(work));
748
966
  }
749
967
  }
968
+ function rootFault(caught) {
969
+ const error = asError(caught);
970
+ logger.error("dispatch failed past the boundary", error);
971
+ core.bus[PublishDefault]("unhandledInteractionError", { error });
972
+ }
750
973
  const handle = async (request, ctx) => {
751
974
  if (request.method !== "POST") return new Response(null, {
752
975
  status: METHOD_NOT_ALLOWED,
@@ -767,10 +990,11 @@ function buildEngine(core, maps) {
767
990
  if (!hasInteractionType(payload)) return new Response(null, { status: BAD_REQUEST });
768
991
  if (payload.type === PING) return Response.json({ type: InteractionResponseType.Pong });
769
992
  try {
993
+ core.bus[PublishDefault]("anyInteraction", { interaction: payload });
770
994
  const match = resolve(maps, payload);
771
995
  if (match) await dispatchMatched(match, payload, ctx);
772
996
  } catch (caught) {
773
- logger.error("dispatch failed, acking anyway", caught);
997
+ rootFault(caught);
774
998
  }
775
999
  return new Response(null, { status: ACCEPTED });
776
1000
  };
@@ -796,9 +1020,120 @@ function buildEngine(core, maps) {
796
1020
  * `SeedcordError` when either is missing or malformed.
797
1021
  */
798
1022
  function createSeedcord(config, manifest) {
799
- return buildEngine(createCore(config, validateDiscordToken(Envapter.get("DISCORD_BOT_TOKEN"))), buildRouteMaps(manifest)).handle;
1023
+ const core = createCore(config, validateDiscordToken(Envapter.get("DISCORD_BOT_TOKEN")));
1024
+ core.bus.registerDefaults();
1025
+ registerSubscribers(core.bus, manifest);
1026
+ return buildEngine(core, buildRouteMaps(manifest)).handle;
800
1027
  }
801
1028
 
1029
+ //#endregion
1030
+ //#region src/applicationId.ts
1031
+ /**
1032
+ * Reads the bot's own application id over REST. A gateway host takes it off the logged-in client, and
1033
+ * an http host has no session to read it from.
1034
+ *
1035
+ * @internal
1036
+ */
1037
+ async function fetchApplicationId(rest) {
1038
+ try {
1039
+ return (await rest.get(Routes.currentApplication())).id;
1040
+ } catch (error) {
1041
+ throw new SeedcordError(SeedcordErrorCode.CoreApplicationUnavailable, { cause: error });
1042
+ }
1043
+ }
1044
+
1045
+ //#endregion
1046
+ //#region src/emojis/EmojiInjector.ts
1047
+ var Emoji = class {
1048
+ name;
1049
+ id;
1050
+ animated;
1051
+ constructor(name, id, animated = false) {
1052
+ this.name = name;
1053
+ this.id = id;
1054
+ this.animated = animated;
1055
+ }
1056
+ toString() {
1057
+ return `<${this.animated ? "a" : ""}:${this.name}:${this.id}>`;
1058
+ }
1059
+ };
1060
+ const emojiStorage = accessorStore();
1061
+ function reasonOf(error) {
1062
+ return Error.isError(error) ? error.message : String(error);
1063
+ }
1064
+ function byName(list) {
1065
+ const map = /* @__PURE__ */ new Map();
1066
+ for (const entry of list) if (entry.name) map.set(entry.name, entry);
1067
+ return map;
1068
+ }
1069
+ /**
1070
+ * The bot's resolved emojis, keyed by {@link EmojiMap}. Filled by {@link EmojiInjector} during startup.
1071
+ * A read before that throws.
1072
+ */
1073
+ const Emojis = guardedAccessor("Emojis", emojiStorage);
1074
+ /**
1075
+ * Resolves the configured emojis over REST at startup.
1076
+ *
1077
+ * @internal
1078
+ */
1079
+ var EmojiInjector = class {
1080
+ core;
1081
+ applicationId;
1082
+ logger = new Logger("Emojis", { channel: "bot" });
1083
+ constructor(core, applicationId = () => fetchApplicationId(core.rest)) {
1084
+ this.core = core;
1085
+ this.applicationId = applicationId;
1086
+ }
1087
+ async init() {
1088
+ clearStore(emojiStorage);
1089
+ const configured = this.core.config.bot.emojis;
1090
+ if (!configured || Object.keys(configured).length === 0) {
1091
+ this.logger.debug("No emojis configured, skipping emoji injection.");
1092
+ return;
1093
+ }
1094
+ const failures = [];
1095
+ const guilds = /* @__PURE__ */ new Map();
1096
+ let application;
1097
+ for (const [key, value] of Object.entries(configured)) if (isEmojiTuple(value)) await this.resolveTuple(key, value, guilds, failures);
1098
+ else if (typeof value === "string") {
1099
+ application ??= await this.applicationEmojis(failures);
1100
+ this.resolve(key, value, application, failures, "the application");
1101
+ } else failures.push(` - "${key}" has an invalid value (expected a name or [name, guildId])`);
1102
+ if (failures.length > 0) throw new SeedcordError(SeedcordErrorCode.ConfigEmojiUnresolved, [failures.length, failures.join("\n")]);
1103
+ this.logger.utils.summary("Loaded emojis", { emojis: Object.keys(emojiStorage).length });
1104
+ }
1105
+ async applicationEmojis(failures) {
1106
+ try {
1107
+ const appId = await this.applicationId();
1108
+ return byName((await this.core.rest.get(Routes.applicationEmojis(appId))).items);
1109
+ } catch (error) {
1110
+ failures.push(` - the application emojis could not be read (${reasonOf(error)})`);
1111
+ return /* @__PURE__ */ new Map();
1112
+ }
1113
+ }
1114
+ async resolveTuple(key, [name, guildId], guilds, failures) {
1115
+ let guild = guilds.get(guildId);
1116
+ if (!guild) {
1117
+ try {
1118
+ guild = byName(await this.core.rest.get(Routes.guildEmojis(guildId)));
1119
+ } catch (error) {
1120
+ failures.push(` - "${name}" for "${key}" targets guild ${guildId}, which could not be read (${reasonOf(error)})`);
1121
+ return;
1122
+ }
1123
+ guilds.set(guildId, guild);
1124
+ }
1125
+ this.resolve(key, name, guild, failures, `guild ${guildId}`);
1126
+ }
1127
+ resolve(key, name, source, failures, where) {
1128
+ const found = source.get(name);
1129
+ if (!found?.id) {
1130
+ failures.push(` - "${name}" for "${key}" was not found in ${where}`);
1131
+ return;
1132
+ }
1133
+ emojiStorage[key] = new Emoji(name, found.id, found.animated ?? false);
1134
+ }
1135
+ };
1136
+
802
1137
  //#endregion
803
1138
  //#region src/handlers/interaction/SlashHandler.ts
804
1139
  /**
@@ -979,12 +1314,12 @@ var SelectMenuHandler = class extends ComponentHandler {};
979
1314
  * @typeParam Defs - The customId definitions this handler decodes, e.g. `[typeof ConfigId]`.
980
1315
  */
981
1316
  var ModalHandler = class extends ComponentHandler {
982
- /** Rewrite the message this modal was opened from (only when a component opened it). */
1317
+ /** Rewrite the message this modal was opened from. Throws when a command opened the modal. */
983
1318
  update(response) {
984
1319
  this.ensureSourceMessage("update");
985
1320
  return super.update(response);
986
1321
  }
987
- /** Silently acknowledge, leaving the source message untouched. */
1322
+ /** Acknowledge the submit without changing the source message. Throws when a command opened the modal. */
988
1323
  deferUpdate() {
989
1324
  this.ensureSourceMessage("deferUpdate");
990
1325
  return super.deferUpdate();
@@ -995,6 +1330,98 @@ var ModalHandler = class extends ComponentHandler {
995
1330
  }
996
1331
  };
997
1332
 
1333
+ //#endregion
1334
+ //#region src/subscribers/index.ts
1335
+ /**
1336
+ * Base class for an HTTP subscriber, binding `this.core` to the HTTP {@link Core}.
1337
+ *
1338
+ * @typeParam KeyOfSubscribers - The subscription key this handler receives
1339
+ */
1340
+ var Subscriber$1 = class extends Subscriber {};
1341
+ /**
1342
+ * Base class for an HTTP subscriber that delivers its event to a Discord webhook, binding
1343
+ * `this.core` to the HTTP {@link Core}.
1344
+ *
1345
+ * @typeParam KeyOfSubscribers - The subscription key this reporter receives
1346
+ */
1347
+ var WebhookLog$1 = class extends WebhookLog {};
1348
+
1349
+ //#endregion
1350
+ //#region src/pagination/Paginator.ts
1351
+ function contextOf(interaction, core) {
1352
+ return {
1353
+ interaction,
1354
+ user: interaction.member?.user ?? interaction.user,
1355
+ guildId: interaction.guild_id ?? null,
1356
+ core
1357
+ };
1358
+ }
1359
+ /**
1360
+ * A restart-proof paginator. Each nav button's customId encodes its full target page, so clicks are
1361
+ * idempotent and survive a restart. A persistent `@ButtonRoute` on `Handler` dispatches the clicks.
1362
+ *
1363
+ * @typeParam Item - The item type, inferred from the source.
1364
+ * @typeParam Prefix - The route prefix, inferred from `config.prefix`.
1365
+ *
1366
+ * @example
1367
+ * ```ts
1368
+ * export const Bans = new Paginator({
1369
+ * prefix: 'bans',
1370
+ * source: new ArraySource((ctx) => loadBans(ctx.guildId), { perPage: 10 }),
1371
+ * renderItem: (ban) => ban.user.username
1372
+ * });
1373
+ *
1374
+ * \@ButtonRoute(Bans.cursor)
1375
+ * export class BansNav extends Bans.Handler {}
1376
+ * ```
1377
+ */
1378
+ var Paginator = class extends PaginatorBase {
1379
+ /** The nav handler base. Extend it with an empty body and decorate it, `@ButtonRoute(p.cursor)`. */
1380
+ Handler;
1381
+ constructor(config) {
1382
+ super(config);
1383
+ const loadPage = (ctx, n) => this.page(ctx, n);
1384
+ this.Handler = class Nav extends ButtonHandler {
1385
+ async execute() {
1386
+ await this.deferUpdate();
1387
+ const response = await loadPage(contextOf(this.event, this.core), this.params.page);
1388
+ await this.update(response);
1389
+ }
1390
+ };
1391
+ }
1392
+ /**
1393
+ * Render page 0 and send it through the handler's sender, which picks reply, edit, or followUp from its
1394
+ * ack state.
1395
+ *
1396
+ * @param handler - The handler starting the paginator, normally `this`.
1397
+ */
1398
+ async start(handler) {
1399
+ const interaction = handler.getEvent();
1400
+ const response = await this.page(contextOf(interaction, handler.core), 0);
1401
+ return handler.getSender().send(response, { ephemeral: this.config.ephemeral ?? false });
1402
+ }
1403
+ };
1404
+
1405
+ //#endregion
1406
+ //#region src/pagination/sources.ts
1407
+ /**
1408
+ * A source for a bounded list you can load whole. It loads the full list on every click, then slices the
1409
+ * page, so the real total is known (a real last button, "Page X of Y"). Cache inside your loader if the
1410
+ * load is expensive.
1411
+ *
1412
+ * @typeParam Item - The item type, inferred from the loader's return.
1413
+ */
1414
+ var ArraySource$1 = class extends ArraySource {};
1415
+ /**
1416
+ * A source for a large or unknown-length set you fetch one page at a time (SQL LIMIT/OFFSET, a paged API).
1417
+ * The fetcher receives the page index and the page size and reports whether a next page exists. A cursor
1418
+ * source has no cheap total, so `totalPages` is undefined, the last button is omitted, and the indicator
1419
+ * reads "Page X".
1420
+ *
1421
+ * @typeParam Item - The item type, inferred from the fetcher's slice.
1422
+ */
1423
+ var CursorSource$1 = class extends CursorSource {};
1424
+
998
1425
  //#endregion
999
1426
  //#region src/gates/Gated.ts
1000
1427
  /**
@@ -1039,8 +1466,8 @@ function Gated(...gates) {
1039
1466
  //#endregion
1040
1467
  //#region src/version.ts
1041
1468
  /** Package version */
1042
- const version = "0.1.0-next.1";
1469
+ const version = "0.1.0-next.3";
1043
1470
 
1044
1471
  //#endregion
1045
- export { ButtonHandler as a, SlashHandler as c, buildRouteMaps as d, InteractionHandler as f, SelectMenuHandler as i, createSeedcord as l, RepliableHandler$1 as m, Gated as n, ComponentHandler as o, AutocompleteHandler as p, ModalHandler as r, ContextMenuHandler as s, version as t, buildEngine as u };
1046
- //# sourceMappingURL=edge.index-BAgNb6TV.mjs.map
1472
+ export { RepliableHandler$1 as S, createSeedcord as _, Paginator as a, InteractionHandler as b, ModalHandler as c, ComponentHandler as d, ContextMenuHandler as f, fetchApplicationId as g, Emojis as h, CursorSource$1 as i, SelectMenuHandler as l, EmojiInjector as m, Gated as n, Subscriber$1 as o, SlashHandler as p, ArraySource$1 as r, WebhookLog$1 as s, version as t, ButtonHandler as u, buildEngine as v, AutocompleteHandler as x, buildRouteMaps as y };
1473
+ //# sourceMappingURL=edge.index-Cv4plNWY.mjs.map