@scotthamilton77/discord-bot-lib 0.1.2 → 1.0.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.
package/README.md CHANGED
@@ -10,8 +10,8 @@ TypeScript library for managing Discord bot identities, messaging, and multi-bot
10
10
  ## Features
11
11
 
12
12
  - **Factory construction** -- `Bot.fromConfig()` connects, verifies guilds, and returns a ready bot in one call
13
- - **Dual receive API** -- event-driven callbacks (`onMention`, `onReply`, `onMessage`) and async iterables (`mentions()`, `replies()`, `messages()`)
14
- - **Multi-bot management** -- `ConnectorManager` aggregates mentions across bots with lifecycle events
13
+ - **Unified receive API** -- event-driven callback (`onMessage`) and async iterable (`messages()`) with full routing metadata (`isDM`, `isMention`, `isReply`, `replyTo`)
14
+ - **Multi-bot management** -- `ConnectorManager` aggregates messages across bots with lifecycle events
15
15
  - **Guided onboarding** -- `BotOnboarding` walks through token validation, server invitation, and permission verification
16
16
  - **Message chunking** -- automatically splits messages at paragraph/line/word boundaries to stay within Discord's 2000-char limit
17
17
  - **File attachments** -- send files from Buffer or file path, with size validation and filename sanitization
@@ -37,19 +37,14 @@ const bot = await Bot.fromConfig({
37
37
  token: process.env.DISCORD_TOKEN!,
38
38
  });
39
39
 
40
- // Respond to @mentions
41
- bot.onMention(async (event) => {
42
- await bot.send(event.channelId, `Hello, ${event.author.username}!`);
43
- });
44
-
45
- // Respond to replies to messages this bot sent
46
- bot.onReply(async (event, original) => {
47
- await bot.send(event.channelId, `You replied to my message ${original.messageId}`);
48
- });
49
-
50
- // Listen to all messages in a specific channel
51
- bot.onMessage("123456789012345678", (event) => {
52
- console.log(`${event.author.username}: ${event.content}`);
40
+ // Every incoming message — route by metadata
41
+ bot.onMessage(async (event) => {
42
+ if (event.isMention) {
43
+ await bot.send(event.channelId, `Hello, ${event.author.username}!`);
44
+ }
45
+ if (event.isReply) {
46
+ await bot.send(event.channelId, `You replied to my message ${event.replyTo!.messageId}`);
47
+ }
53
48
  });
54
49
 
55
50
  // Error handling
@@ -58,7 +53,7 @@ bot.on("error", (err) => console.error("Bot error:", err.message));
58
53
 
59
54
  ## Async Iterables
60
55
 
61
- Every event type is also available as an `AsyncIterable`, useful for sequential processing with `for await`:
56
+ All messages are also available as an `AsyncIterable`, useful for sequential processing with `for await`:
62
57
 
63
58
  ```typescript
64
59
  import { Bot } from "@scotthamilton77/discord-bot-lib";
@@ -70,16 +65,14 @@ const bot = await Bot.fromConfig({
70
65
  });
71
66
 
72
67
  // Process mentions one at a time
73
- for await (const event of bot.mentions()) {
68
+ for await (const event of bot.messages({ filter: (e) => e.isMention })) {
74
69
  await bot.reply(event.channelId, event.messageId, "Got it!");
75
70
  }
76
71
  ```
77
72
 
78
73
  ```typescript
79
- // Filter channel messages with a predicate
80
- for await (const event of bot.messages("123456789012345678", {
81
- filter: (msg) => msg.content.startsWith("!cmd"),
82
- })) {
74
+ // Filter messages with a predicate
75
+ for await (const event of bot.messages({ filter: (msg) => msg.content.startsWith("!cmd") })) {
83
76
  await bot.send(event.channelId, `Command received: ${event.content}`);
84
77
  }
85
78
  ```
@@ -99,9 +92,11 @@ const botB = await Bot.fromConfig({ id: "b", name: "Bravo", token: TOKEN_B });
99
92
  manager.addBot(botA);
100
93
  manager.addBot(botB);
101
94
 
102
- // Single handler receives mentions from all bots
103
- manager.onMention((event, bot) => {
104
- console.log(`${bot.name} was mentioned by ${event.author.username}`);
95
+ // Single handler receives all messages from all bots
96
+ manager.onMessage((event, bot) => {
97
+ if (event.isMention) {
98
+ console.log(`${bot.name} was mentioned by ${event.author.username}`);
99
+ }
105
100
  });
106
101
 
107
102
  // Lifecycle events
@@ -266,12 +261,12 @@ const { buffer, filename, contentType } = await downloadAttachment(attachment);
266
261
  |---|---|
267
262
  | `BotConfig` | Configuration for `Bot.fromConfig()` -- id, name, token, optional intents/channels/cacheSize. |
268
263
  | `BotStatus` | `"unregistered" \| "configuring" \| "connecting" \| "verifying" \| "ready" \| "disconnected" \| "failed"` |
269
- | `MessageEvent` | Incoming message with author, content, channelId, mentions, and `raw` discord.js Message. |
264
+ | `MessageEvent` | Incoming message with routing metadata (`isDM`, `isMention`, `isReply`, `replyTo`), author, content, channelId, mentions, and `raw` discord.js Message. |
270
265
  | `SentMessage` | Tracked outgoing message with messageId, channelId, timestamp, and `raw` escape hatch. |
271
266
  | `MessageContent` | `string \| { content?: string; embeds?: unknown[]; files?: FileAttachment[] }` |
272
267
  | `FetchedMessage` | Lightweight message from `fetchMessages()` / `fetchHistory()`. |
273
268
  | `FetchHistoryOptions` | Discriminated union: `{ after: string } \| { before: string }` with optional `limit`. |
274
- | `MessageFilter` | `(message: MessageEvent) => boolean` predicate for `onMessage()` / `messages()`. |
269
+ | `MessageFilter` | `(message: MessageEvent) => boolean` predicate for `messages({ filter })`. |
275
270
  | `FileAttachment` | `{ data: Buffer \| string; name: string }` for sending files. |
276
271
  | `OnboardingStep` | Step in the onboarding flow with id, label, instructions, status, and `complete()`. |
277
272
  | `AttachmentLike` | Minimal shape for attachment validation utilities. |
package/dist/index.cjs CHANGED
@@ -43,6 +43,10 @@ var EventBuffer = class {
43
43
  buffer = [];
44
44
  resolve = null;
45
45
  closed = false;
46
+ onClose;
47
+ constructor(options) {
48
+ this.onClose = options?.onClose;
49
+ }
46
50
  push(value) {
47
51
  if (this.closed) return;
48
52
  if (this.resolve) {
@@ -54,7 +58,9 @@ var EventBuffer = class {
54
58
  }
55
59
  }
56
60
  close() {
61
+ if (this.closed) return;
57
62
  this.closed = true;
63
+ this.onClose?.();
58
64
  if (this.resolve) {
59
65
  const r = this.resolve;
60
66
  this.resolve = null;
@@ -77,6 +83,10 @@ var EventBuffer = class {
77
83
  return new Promise((resolve) => {
78
84
  this.resolve = resolve;
79
85
  });
86
+ },
87
+ return: () => {
88
+ this.close();
89
+ return Promise.resolve({ value: void 0, done: true });
80
90
  }
81
91
  };
82
92
  }
@@ -153,9 +163,7 @@ var Bot = class _Bot {
153
163
  client;
154
164
  sentMessages;
155
165
  _connectedAt = null;
156
- mentionHandlers = [];
157
- replyHandlers = [];
158
- channelSubscriptions = /* @__PURE__ */ new Map();
166
+ messageHandlers = [];
159
167
  errorHandlers = [];
160
168
  _includeBotMessages;
161
169
  constructor(id, name, client, sentMessageCacheSize, includeBotMessages) {
@@ -221,18 +229,10 @@ var Bot = class _Bot {
221
229
  );
222
230
  }
223
231
  async reply(channelId, messageId, content) {
224
- const channel = await this.fetchTextChannel(channelId);
232
+ const { channel, message } = await this.fetchMessage(channelId, messageId, "reply to");
225
233
  if (!("send" in channel)) {
226
234
  throw new Error(`Channel ${channelId} is not a sendable channel`);
227
235
  }
228
- let message;
229
- try {
230
- message = await channel.messages.fetch(messageId);
231
- } catch (error) {
232
- throw new Error(
233
- `Cannot reply to message ${messageId}: ${error instanceof Error ? error.message : String(error)}`
234
- );
235
- }
236
236
  return this.sendPayloads(content, (payload, i) => {
237
237
  if (i === 0) {
238
238
  return message.reply(payload);
@@ -242,9 +242,8 @@ var Bot = class _Bot {
242
242
  }
243
243
  // --- Channel operations ---
244
244
  async react(channelId, messageId, emoji) {
245
- const channel = await this.fetchTextChannel(channelId);
245
+ const { message } = await this.fetchMessage(channelId, messageId, "react to");
246
246
  try {
247
- const message = await channel.messages.fetch(messageId);
248
247
  await message.react(emoji);
249
248
  } catch (error) {
250
249
  throw new Error(
@@ -253,9 +252,8 @@ var Bot = class _Bot {
253
252
  }
254
253
  }
255
254
  async editMessage(channelId, messageId, content) {
256
- const channel = await this.fetchTextChannel(channelId);
255
+ const { message } = await this.fetchMessage(channelId, messageId, "edit");
257
256
  try {
258
- const message = await channel.messages.fetch(messageId);
259
257
  await message.edit(content);
260
258
  } catch (error) {
261
259
  throw new Error(
@@ -306,76 +304,48 @@ var Bot = class _Bot {
306
304
  }
307
305
  }
308
306
  async getMessageAttachments(channelId, messageId) {
309
- const channel = await this.fetchTextChannel(channelId);
310
- try {
311
- const message = await channel.messages.fetch(messageId);
312
- return [...message.attachments.values()].map((att) => ({
313
- id: att.id,
314
- size: att.size,
315
- name: att.name,
316
- url: att.url,
317
- contentType: att.contentType
318
- }));
319
- } catch (error) {
320
- throw new Error(
321
- `Cannot fetch attachments for message ${messageId}: ${error instanceof Error ? error.message : String(error)}`
322
- );
323
- }
307
+ const { message } = await this.fetchMessage(channelId, messageId, "fetch attachments for");
308
+ return [...message.attachments.values()].map((att) => ({
309
+ id: att.id,
310
+ size: att.size,
311
+ name: att.name,
312
+ url: att.url,
313
+ contentType: att.contentType
314
+ }));
324
315
  }
325
316
  // --- Receiving (event-driven) ---
326
- onMention(handler) {
327
- this.mentionHandlers.push(handler);
328
- }
329
- onReply(handler) {
330
- this.replyHandlers.push(handler);
331
- }
332
- onMessage(channelId, handlerOrOpts, maybeHandler) {
333
- let filter;
334
- let handler;
335
- if (typeof handlerOrOpts === "function") {
336
- handler = handlerOrOpts;
337
- } else {
338
- filter = handlerOrOpts.filter;
339
- if (!maybeHandler) {
340
- throw new Error("Handler is required when providing filter options");
341
- }
342
- handler = maybeHandler;
343
- }
344
- const subs = this.channelSubscriptions.get(channelId) ?? [];
345
- subs.push({ filter, handler });
346
- this.channelSubscriptions.set(channelId, subs);
317
+ onMessage(handler) {
318
+ this.messageHandlers.push(handler);
319
+ let removed = false;
320
+ return () => {
321
+ if (removed) return;
322
+ removed = true;
323
+ const index = this.messageHandlers.indexOf(handler);
324
+ if (index !== -1) this.messageHandlers.splice(index, 1);
325
+ };
347
326
  }
348
327
  // --- Receiving (async iterable) ---
349
- mentions() {
350
- const buffer = new EventBuffer();
351
- this.onMention((event) => {
352
- buffer.push(event);
328
+ messages(options) {
329
+ let unsubscribe;
330
+ const buffer = new EventBuffer({
331
+ onClose: () => unsubscribe?.()
353
332
  });
354
- return buffer;
355
- }
356
- replies() {
357
- const buffer = new EventBuffer();
358
- this.onReply((event, original) => {
359
- buffer.push([event, original]);
333
+ unsubscribe = this.onMessage((event) => {
334
+ if (options?.filter && !options.filter(event)) return;
335
+ buffer.push(event);
360
336
  });
361
337
  return buffer;
362
338
  }
363
- messages(channelId, options) {
364
- const buffer = new EventBuffer();
365
- if (options?.filter) {
366
- this.onMessage(channelId, { filter: options.filter }, (event) => {
367
- buffer.push(event);
368
- });
369
- } else {
370
- this.onMessage(channelId, (event) => {
371
- buffer.push(event);
372
- });
373
- }
374
- return buffer;
375
- }
376
339
  // --- Error handling ---
377
340
  on(_event, handler) {
378
341
  this.errorHandlers.push(handler);
342
+ let removed = false;
343
+ return () => {
344
+ if (removed) return;
345
+ removed = true;
346
+ const index = this.errorHandlers.indexOf(handler);
347
+ if (index !== -1) this.errorHandlers.splice(index, 1);
348
+ };
379
349
  }
380
350
  emitError(error) {
381
351
  const err = error instanceof Error ? error : new Error(String(error));
@@ -387,6 +357,8 @@ var Bot = class _Bot {
387
357
  async disconnect() {
388
358
  this.client.removeAllListeners();
389
359
  await this.client.destroy();
360
+ this.messageHandlers.length = 0;
361
+ this.errorHandlers.length = 0;
390
362
  this._status = "disconnected";
391
363
  this._connectedAt = null;
392
364
  }
@@ -400,6 +372,17 @@ var Bot = class _Bot {
400
372
  }
401
373
  return channel;
402
374
  }
375
+ async fetchMessage(channelId, messageId, verb) {
376
+ const channel = await this.fetchTextChannel(channelId);
377
+ try {
378
+ const message = await channel.messages.fetch(messageId);
379
+ return { channel, message };
380
+ } catch (error) {
381
+ throw new Error(
382
+ `Cannot ${verb} message ${messageId}: ${error instanceof Error ? error.message : String(error)}`
383
+ );
384
+ }
385
+ }
403
386
  toFetchedMessage(msg) {
404
387
  return {
405
388
  messageId: msg.id,
@@ -447,27 +430,8 @@ var Bot = class _Bot {
447
430
  if (message.author.id === this.client.user?.id) return;
448
431
  if (message.author.bot && !this._includeBotMessages) return;
449
432
  const event = this.toMessageEvent(message);
450
- const botUser = this.client.user;
451
- const isDM = message.channel.type === import_discord.ChannelType.DM;
452
- if (botUser && (message.mentions.has(botUser.id) || isDM)) {
453
- for (const handler of this.mentionHandlers) {
454
- handler(event);
455
- }
456
- }
457
- if (message.reference?.messageId) {
458
- const original = this.sentMessages.get(message.reference.messageId);
459
- if (original) {
460
- for (const handler of this.replyHandlers) {
461
- handler(event, original);
462
- }
463
- }
464
- }
465
- const subs = this.channelSubscriptions.get(message.channelId);
466
- if (subs) {
467
- for (const sub of subs) {
468
- if (sub.filter && !sub.filter(event)) continue;
469
- sub.handler(event);
470
- }
433
+ for (const handler of this.messageHandlers) {
434
+ handler(event);
471
435
  }
472
436
  } catch (error) {
473
437
  this.emitError(error);
@@ -475,6 +439,18 @@ var Bot = class _Bot {
475
439
  });
476
440
  }
477
441
  toMessageEvent(message) {
442
+ const isDM = message.channel.type === import_discord.ChannelType.DM;
443
+ const botUser = this.client.user;
444
+ const isMention = isDM || (botUser ? message.mentions.has(botUser.id) : false);
445
+ let isReply = false;
446
+ let replyTo = null;
447
+ if (message.reference?.messageId) {
448
+ const original = this.sentMessages.get(message.reference.messageId);
449
+ if (original) {
450
+ isReply = true;
451
+ replyTo = original;
452
+ }
453
+ }
478
454
  return {
479
455
  messageId: message.id,
480
456
  author: {
@@ -487,7 +463,11 @@ var Bot = class _Bot {
487
463
  guildId: message.guildId ?? null,
488
464
  timestamp: message.createdAt,
489
465
  mentions: [...message.mentions.users.keys()],
490
- raw: message
466
+ raw: message,
467
+ isDM,
468
+ isMention,
469
+ isReply,
470
+ replyTo
491
471
  };
492
472
  }
493
473
  async sendPayloads(content, dispatcher) {
@@ -694,21 +674,28 @@ var BotOnboarding = class {
694
674
  // src/connector-manager.ts
695
675
  var ConnectorManager = class {
696
676
  botMap = /* @__PURE__ */ new Map();
697
- mentionHandlers = [];
677
+ botUnsubscribers = /* @__PURE__ */ new Map();
678
+ messageHandlers = [];
698
679
  lifecycleHandlers = /* @__PURE__ */ new Map();
699
680
  addBot(bot) {
700
681
  if (this.botMap.has(bot.id)) {
701
682
  throw new Error(`Bot with id "${bot.id}" already exists in this manager.`);
702
683
  }
703
684
  this.botMap.set(bot.id, bot);
704
- bot.onMention((event) => {
705
- for (const handler of this.mentionHandlers) {
706
- handler(event, bot);
707
- }
708
- });
709
- bot.on("error", (error) => {
710
- this.emitLifecycle("botError", bot, error);
711
- });
685
+ const unsubs = [];
686
+ unsubs.push(
687
+ bot.onMessage((event) => {
688
+ for (const handler of this.messageHandlers) {
689
+ handler(event, bot);
690
+ }
691
+ })
692
+ );
693
+ unsubs.push(
694
+ bot.on("error", (error) => {
695
+ this.emitLifecycle("botError", bot, error);
696
+ })
697
+ );
698
+ this.botUnsubscribers.set(bot.id, unsubs);
712
699
  }
713
700
  getBot(id) {
714
701
  return this.botMap.get(id);
@@ -717,6 +704,11 @@ var ConnectorManager = class {
717
704
  return this.botMap.values();
718
705
  }
719
706
  removeBot(id) {
707
+ const unsubs = this.botUnsubscribers.get(id);
708
+ if (unsubs) {
709
+ for (const unsub of unsubs) unsub();
710
+ this.botUnsubscribers.delete(id);
711
+ }
720
712
  this.botMap.delete(id);
721
713
  }
722
714
  status() {
@@ -726,8 +718,8 @@ var ConnectorManager = class {
726
718
  status: bot.status
727
719
  }));
728
720
  }
729
- onMention(callback) {
730
- this.mentionHandlers.push(callback);
721
+ onMessage(handler) {
722
+ this.messageHandlers.push(handler);
731
723
  }
732
724
  on(event, callback) {
733
725
  const handlers = this.lifecycleHandlers.get(event) ?? [];