taverns.js 0.2.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/dist/client.js ADDED
@@ -0,0 +1,517 @@
1
+ "use strict";
2
+ /**
3
+ * taverns.js - Client
4
+ *
5
+ * The main entry point for building Tavern bots. Construct with a token,
6
+ * listen for events, and interact with the API.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { Client } from 'taverns.js';
11
+ *
12
+ * const client = new Client({ token: 'tavbot_...' });
13
+ *
14
+ * client.on('ready', () => {
15
+ * console.log(`Logged in as ${client.user.displayName}`);
16
+ * console.log(`Connected to ${client.taverns.size} taverns`);
17
+ * });
18
+ *
19
+ * client.on('messageCreate', (message) => {
20
+ * if (message.content === '!ping') {
21
+ * message.reply('Pong!');
22
+ * }
23
+ * });
24
+ *
25
+ * client.login();
26
+ * ```
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.Client = void 0;
30
+ const events_1 = require("events");
31
+ const collection_1 = require("./collection");
32
+ const rest_1 = require("./rest");
33
+ const gateway_1 = require("./gateway");
34
+ const constants_1 = require("./constants");
35
+ // ─── Client ─────────────────────────────────────────────────
36
+ class Client extends events_1.EventEmitter {
37
+ constructor(options = {}) {
38
+ super();
39
+ /** The bot's own user identity. Set after login(). */
40
+ this._user = null;
41
+ /** The bot application info. Set after login(). */
42
+ this._application = null;
43
+ /** Collection of taverns the bot is installed in, keyed by tavern ID. */
44
+ this.taverns = new collection_1.Collection();
45
+ /** Collection of all known channels across all taverns, keyed by channel ID. */
46
+ this.channels = new collection_1.Collection();
47
+ /** Cached members per tavern: Map<tavernId, Collection<userId, Member>> */
48
+ this.memberCache = new Map();
49
+ this.ready = false;
50
+ this.options = options;
51
+ this.token = options.token || null;
52
+ // Initialize REST client
53
+ this.rest = new rest_1.RESTClient(this.token || '', options.apiUrl || constants_1.DEFAULT_API_URL);
54
+ // Initialize Gateway
55
+ this.gateway = new gateway_1.Gateway({
56
+ token: this.token || '',
57
+ url: options.wsUrl || constants_1.DEFAULT_WS_URL,
58
+ heartbeatInterval: options.heartbeatInterval || 30000,
59
+ autoReconnect: options.autoReconnect ?? true,
60
+ maxReconnectAttempts: options.maxReconnectAttempts,
61
+ intents: options.intents,
62
+ });
63
+ // Wire up gateway events
64
+ this.setupGatewayListeners();
65
+ }
66
+ /**
67
+ * The bot's user identity. Throws if accessed before login().
68
+ */
69
+ get user() {
70
+ if (!this._user) {
71
+ throw new Error('Client is not logged in. Call client.login() first.');
72
+ }
73
+ return this._user;
74
+ }
75
+ /**
76
+ * The bot application info. Throws if accessed before login().
77
+ */
78
+ get application() {
79
+ if (!this._application) {
80
+ throw new Error('Client is not logged in. Call client.login() first.');
81
+ }
82
+ return this._application;
83
+ }
84
+ /**
85
+ * Whether the client has completed login and is ready.
86
+ */
87
+ get isReady() {
88
+ return this.ready;
89
+ }
90
+ /**
91
+ * Log in with the bot token, fetch identity, connect to the gateway,
92
+ * and emit the 'ready' event.
93
+ *
94
+ * @param token - Optional token override (otherwise uses the one from constructor)
95
+ */
96
+ async login(token) {
97
+ if (token) {
98
+ this.token = token;
99
+ }
100
+ if (!this.token) {
101
+ throw new Error('No bot token provided. Pass it to the constructor or login().');
102
+ }
103
+ if (!this.token.startsWith(constants_1.BOT_TOKEN_PREFIX)) {
104
+ throw new Error(`Invalid bot token format. Tokens must start with "${constants_1.BOT_TOKEN_PREFIX}".`);
105
+ }
106
+ // Update the REST client token
107
+ this.rest.setToken(this.token);
108
+ // Step 1: Fetch bot identity
109
+ this.emit('debug', 'Fetching bot identity...');
110
+ try {
111
+ this._application = await this.rest.getSelf();
112
+ this._user = this._application.user;
113
+ }
114
+ catch (err) {
115
+ if (err instanceof rest_1.TavernAPIError && (err.status === 401 || err.status === 403)) {
116
+ throw new Error(`Authentication failed: ${err.message}`);
117
+ }
118
+ throw err;
119
+ }
120
+ // Step 2: Populate taverns cache
121
+ this.emit('debug', `Bot "${this._user.displayName}" has ${this._application.taverns.length} tavern(s)`);
122
+ this.taverns.clear();
123
+ for (const installed of this._application.taverns) {
124
+ this.taverns.set(installed.id, this.installedToTavern(installed));
125
+ }
126
+ // Step 3: Fetch channels for each tavern (parallel, best-effort)
127
+ this.emit('debug', 'Fetching channels for all taverns...');
128
+ const channelFetches = this._application.taverns.map(async (installed) => {
129
+ try {
130
+ const channels = await this.rest.getChannels(installed.id);
131
+ for (const channel of channels) {
132
+ channel.tavernId = installed.id;
133
+ this.channels.set(channel.id, channel);
134
+ }
135
+ // Attach channels to the tavern object
136
+ const tavern = this.taverns.get(installed.id);
137
+ if (tavern) {
138
+ tavern.channels = channels;
139
+ }
140
+ }
141
+ catch (err) {
142
+ this.emit('debug', `Failed to fetch channels for tavern ${installed.id}: ${err.message}`);
143
+ }
144
+ });
145
+ await Promise.all(channelFetches);
146
+ // Step 4: Connect to the WebSocket gateway
147
+ this.emit('debug', 'Connecting to gateway...');
148
+ // Update gateway token in case it was provided via login() instead of constructor
149
+ this.gateway.setToken(this.token);
150
+ await this.gateway.connect();
151
+ this.ready = true;
152
+ this.emit('ready');
153
+ }
154
+ /**
155
+ * Gracefully disconnect and clean up all resources.
156
+ */
157
+ destroy() {
158
+ this.ready = false;
159
+ this.gateway.destroy();
160
+ this.taverns.clear();
161
+ this.channels.clear();
162
+ this.memberCache.clear();
163
+ this._user = null;
164
+ this._application = null;
165
+ this.emit('debug', 'Client destroyed');
166
+ this.removeAllListeners();
167
+ }
168
+ on(event, listener) {
169
+ return super.on(event, listener);
170
+ }
171
+ once(event, listener) {
172
+ return super.once(event, listener);
173
+ }
174
+ off(event, listener) {
175
+ return super.off(event, listener);
176
+ }
177
+ emit(event, ...args) {
178
+ return super.emit(event, ...args);
179
+ }
180
+ // ─── Convenience API Methods ──────────────────────────
181
+ /**
182
+ * Send a message to a channel.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * await client.sendMessage(tavernId, channelId, { content: 'Hello!' });
187
+ * // Or with reply:
188
+ * await client.sendMessage(tavernId, channelId, {
189
+ * content: 'Replying!',
190
+ * replyToId: originalMessageId,
191
+ * });
192
+ * ```
193
+ */
194
+ async sendMessage(tavernId, channelId, options) {
195
+ const opts = typeof options === 'string' ? { content: options } : options;
196
+ return this.rest.sendMessage(tavernId, channelId, opts);
197
+ }
198
+ /**
199
+ * Get messages from a channel.
200
+ */
201
+ async getMessages(tavernId, channelId, options) {
202
+ return this.rest.getMessages(tavernId, channelId, options);
203
+ }
204
+ /**
205
+ * Edit a message.
206
+ */
207
+ async editMessage(tavernId, messageId, options) {
208
+ const opts = typeof options === 'string' ? { content: options } : options;
209
+ return this.rest.editMessage(tavernId, messageId, opts);
210
+ }
211
+ /**
212
+ * Delete a message.
213
+ */
214
+ async deleteMessage(tavernId, messageId) {
215
+ return this.rest.deleteMessage(tavernId, messageId);
216
+ }
217
+ /**
218
+ * Create a channel in a tavern.
219
+ */
220
+ async createChannel(tavernId, options) {
221
+ const channel = await this.rest.createChannel(tavernId, options);
222
+ channel.tavernId = tavernId;
223
+ this.channels.set(channel.id, channel);
224
+ return channel;
225
+ }
226
+ /**
227
+ * Update a channel.
228
+ */
229
+ async updateChannel(tavernId, channelId, options) {
230
+ const channel = await this.rest.updateChannel(tavernId, channelId, options);
231
+ channel.tavernId = tavernId;
232
+ this.channels.set(channel.id, channel);
233
+ return channel;
234
+ }
235
+ /**
236
+ * Delete a channel.
237
+ */
238
+ async deleteChannel(tavernId, channelId) {
239
+ await this.rest.deleteChannel(tavernId, channelId);
240
+ this.channels.delete(channelId);
241
+ }
242
+ /**
243
+ * Get members of a tavern (with caching).
244
+ */
245
+ async getMembers(tavernId, options) {
246
+ const members = await this.rest.getMembers(tavernId, options);
247
+ const collection = new collection_1.Collection();
248
+ for (const member of members) {
249
+ collection.set(member.userId, member);
250
+ }
251
+ this.memberCache.set(tavernId, collection);
252
+ return collection;
253
+ }
254
+ /**
255
+ * Get a specific member of a tavern.
256
+ */
257
+ async getMember(tavernId, userId) {
258
+ return this.rest.getMember(tavernId, userId);
259
+ }
260
+ /**
261
+ * Send a typing indicator to a channel.
262
+ */
263
+ startTyping(tavernId, channelId) {
264
+ this.gateway.send('tavern_typing_start', { tavernId, channelId });
265
+ }
266
+ // ─── Gateway Event Wiring ─────────────────────────────
267
+ setupGatewayListeners() {
268
+ // Forward gateway lifecycle events
269
+ this.gateway.on('debug', (msg) => {
270
+ this.emit('debug', `[Gateway] ${msg}`);
271
+ });
272
+ this.gateway.on('error', (err) => {
273
+ this.emit('error', err);
274
+ });
275
+ this.gateway.on('close', (code, reason) => {
276
+ this.emit('disconnected', code, reason);
277
+ });
278
+ this.gateway.on('reconnecting', (attempt) => {
279
+ this.emit('reconnecting', attempt);
280
+ });
281
+ this.gateway.on('reconnected', () => {
282
+ this.emit('debug', 'Reconnected to gateway');
283
+ // Re-emit ready so the bot knows it can resume
284
+ this.emit('ready');
285
+ });
286
+ this.gateway.on('reconnectFailed', () => {
287
+ this.ready = false;
288
+ this.emit('error', new Error('Failed to reconnect to gateway after maximum attempts'));
289
+ });
290
+ // Handle incoming gateway events
291
+ this.gateway.on('event', (eventName, data) => {
292
+ this.handleGatewayEvent(eventName, data);
293
+ });
294
+ }
295
+ handleGatewayEvent(eventName, data) {
296
+ // Always emit the raw event
297
+ this.emit('raw', eventName, data);
298
+ // Update caches based on events
299
+ this.updateCaches(eventName, data);
300
+ // Map to friendly event name and emit
301
+ const friendlyName = constants_1.FRIENDLY_EVENT_MAP[eventName];
302
+ if (friendlyName) {
303
+ // For messageCreate, enrich the message with helper methods
304
+ if (friendlyName === 'messageCreate') {
305
+ const message = this.enrichMessage(data);
306
+ this.emit(friendlyName, message);
307
+ }
308
+ else if (friendlyName === 'interactionCreate') {
309
+ const interaction = this.enrichInteraction(data);
310
+ this.emit(friendlyName, interaction);
311
+ }
312
+ else {
313
+ this.emit(friendlyName, data);
314
+ }
315
+ }
316
+ else {
317
+ // Emit unknown events as-is (for future event support)
318
+ this.emit(eventName, data);
319
+ }
320
+ }
321
+ /**
322
+ * Update internal caches based on gateway events.
323
+ */
324
+ updateCaches(eventName, data) {
325
+ const payload = data;
326
+ switch (eventName) {
327
+ case constants_1.GatewayEvent.CHANNEL_CREATED: {
328
+ const channel = payload;
329
+ if (channel.id && channel.tavernId) {
330
+ this.channels.set(channel.id, channel);
331
+ const tavern = this.taverns.get(channel.tavernId);
332
+ if (tavern?.channels) {
333
+ tavern.channels.push(channel);
334
+ }
335
+ }
336
+ break;
337
+ }
338
+ case constants_1.GatewayEvent.CHANNEL_UPDATED: {
339
+ const existing = this.channels.get(payload.id);
340
+ if (existing) {
341
+ Object.assign(existing, payload);
342
+ }
343
+ break;
344
+ }
345
+ case constants_1.GatewayEvent.CHANNEL_DELETED: {
346
+ this.channels.delete(payload.id);
347
+ const tavern = this.taverns.get(payload.tavernId);
348
+ if (tavern?.channels) {
349
+ tavern.channels = tavern.channels.filter((c) => c.id !== payload.id);
350
+ }
351
+ break;
352
+ }
353
+ case constants_1.GatewayEvent.TAVERN_UPDATED: {
354
+ const tavern = this.taverns.get(payload.id);
355
+ if (tavern) {
356
+ Object.assign(tavern, payload);
357
+ }
358
+ break;
359
+ }
360
+ case constants_1.GatewayEvent.BOT_INSTALLED: {
361
+ // New tavern install - add to cache
362
+ const tavernId = payload.tavernId;
363
+ if (tavernId && !this.taverns.has(tavernId)) {
364
+ this.taverns.set(tavernId, {
365
+ id: tavernId,
366
+ name: payload.tavernName || 'Unknown',
367
+ slug: '',
368
+ iconUrl: null,
369
+ memberCount: 0,
370
+ grantedPermissions: payload.grantedPermissions,
371
+ });
372
+ // Fetch full tavern data in background
373
+ this.rest.getChannels(tavernId).then((channels) => {
374
+ for (const channel of channels) {
375
+ channel.tavernId = tavernId;
376
+ this.channels.set(channel.id, channel);
377
+ }
378
+ const tavern = this.taverns.get(tavernId);
379
+ if (tavern)
380
+ tavern.channels = channels;
381
+ }).catch((err) => {
382
+ this.emit('debug', `Failed to fetch channels for new tavern ${tavernId}: ${err.message}`);
383
+ });
384
+ }
385
+ break;
386
+ }
387
+ case constants_1.GatewayEvent.BOT_REMOVED: {
388
+ const removedTavernId = payload.tavernId;
389
+ this.taverns.delete(removedTavernId);
390
+ // Remove channels for this tavern
391
+ for (const [id, channel] of this.channels) {
392
+ if (channel.tavernId === removedTavernId) {
393
+ this.channels.delete(id);
394
+ }
395
+ }
396
+ this.memberCache.delete(removedTavernId);
397
+ break;
398
+ }
399
+ case constants_1.GatewayEvent.MEMBER_JOINED: {
400
+ const tavernMembers = this.memberCache.get(payload.tavernId);
401
+ if (tavernMembers) {
402
+ const member = {
403
+ id: payload.userId,
404
+ userId: payload.userId,
405
+ tavernId: payload.tavernId,
406
+ status: 'ACTIVE',
407
+ user: {
408
+ id: payload.userId,
409
+ displayName: payload.displayName,
410
+ avatarUrl: payload.avatarUrl || null,
411
+ isBot: payload.isBot,
412
+ },
413
+ };
414
+ tavernMembers.set(member.userId, member);
415
+ }
416
+ // Update member count
417
+ const tavern = this.taverns.get(payload.tavernId);
418
+ if (tavern)
419
+ tavern.memberCount++;
420
+ break;
421
+ }
422
+ case constants_1.GatewayEvent.MEMBER_LEFT: {
423
+ const tavernMembersLeave = this.memberCache.get(payload.tavernId);
424
+ if (tavernMembersLeave) {
425
+ tavernMembersLeave.delete(payload.userId);
426
+ }
427
+ const tavernLeave = this.taverns.get(payload.tavernId);
428
+ if (tavernLeave && tavernLeave.memberCount > 0)
429
+ tavernLeave.memberCount--;
430
+ break;
431
+ }
432
+ }
433
+ }
434
+ /**
435
+ * Enrich a raw message with convenience methods like .reply(), .edit(), .delete().
436
+ */
437
+ enrichMessage(raw) {
438
+ const tavernId = this.findTavernForChannel(raw.channelId);
439
+ const message = raw;
440
+ message.tavernId = tavernId;
441
+ message.reply = async (content) => {
442
+ const opts = typeof content === 'string'
443
+ ? { content, replyToId: raw.id }
444
+ : { ...content, replyToId: raw.id };
445
+ return this.rest.sendMessage(tavernId, raw.channelId, opts);
446
+ };
447
+ message.edit = async (content) => {
448
+ return this.rest.editMessage(tavernId, raw.id, { content });
449
+ };
450
+ message.delete = async () => {
451
+ return this.rest.deleteMessage(tavernId, raw.id);
452
+ };
453
+ message.pin = async () => {
454
+ return this.rest.pinMessage(tavernId, raw.id);
455
+ };
456
+ message.unpin = async () => {
457
+ return this.rest.unpinMessage(tavernId, raw.id);
458
+ };
459
+ message.react = async (emoji) => {
460
+ return this.rest.addReaction(tavernId, raw.id, emoji);
461
+ };
462
+ return message;
463
+ }
464
+ /**
465
+ * Enrich a raw interaction with convenience methods like .reply(), .deferReply(), .followUp().
466
+ */
467
+ enrichInteraction(raw) {
468
+ const interaction = raw;
469
+ interaction.reply = async (content) => {
470
+ const data = typeof content === 'string' ? { content } : content;
471
+ return this.rest.replyToInteraction(raw.id, data);
472
+ };
473
+ interaction.deferReply = async () => {
474
+ return this.rest.deferInteraction(raw.id);
475
+ };
476
+ interaction.followUp = async (content) => {
477
+ const data = typeof content === 'string' ? { content } : content;
478
+ return this.rest.followUpInteraction(raw.id, data);
479
+ };
480
+ interaction.sendMessage = async (options) => {
481
+ const opts = typeof options === 'string' ? { content: options } : options;
482
+ return this.rest.sendMessage(raw.tavernId, raw.channelId, opts);
483
+ };
484
+ return interaction;
485
+ }
486
+ /**
487
+ * Find the tavern that owns a channel by checking the channels cache.
488
+ */
489
+ findTavernForChannel(channelId) {
490
+ const channel = this.channels.get(channelId);
491
+ if (channel?.tavernId)
492
+ return channel.tavernId;
493
+ // Fallback: search tavern channel lists
494
+ for (const [tavernId, tavern] of this.taverns) {
495
+ if (tavern.channels?.some((c) => c.id === channelId)) {
496
+ return tavernId;
497
+ }
498
+ }
499
+ // Last resort: return empty string (the API call will fail with a clear error)
500
+ return '';
501
+ }
502
+ /**
503
+ * Convert an InstalledTavern (from @me response) to the Tavern cache format.
504
+ */
505
+ installedToTavern(installed) {
506
+ return {
507
+ id: installed.id,
508
+ name: installed.name,
509
+ slug: installed.slug,
510
+ iconUrl: installed.iconUrl,
511
+ memberCount: installed.memberCount,
512
+ grantedPermissions: installed.grantedPermissions,
513
+ installedAt: installed.installedAt,
514
+ };
515
+ }
516
+ }
517
+ exports.Client = Client;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * taverns.js - Collection
3
+ *
4
+ * A utility class that extends Map with convenience methods,
5
+ * with convenience methods for working with cached data.
6
+ */
7
+ export declare class Collection<K, V> extends Map<K, V> {
8
+ /**
9
+ * Find the first value that satisfies the predicate.
10
+ */
11
+ find(fn: (value: V, key: K, collection: this) => boolean): V | undefined;
12
+ /**
13
+ * Filter the collection and return a new Collection with matching entries.
14
+ */
15
+ filter(fn: (value: V, key: K, collection: this) => boolean): Collection<K, V>;
16
+ /**
17
+ * Map each value in the collection to a new value.
18
+ */
19
+ map<T>(fn: (value: V, key: K, collection: this) => T): T[];
20
+ /**
21
+ * Check if any value satisfies the predicate.
22
+ */
23
+ some(fn: (value: V, key: K, collection: this) => boolean): boolean;
24
+ /**
25
+ * Check if every value satisfies the predicate.
26
+ */
27
+ every(fn: (value: V, key: K, collection: this) => boolean): boolean;
28
+ /**
29
+ * Reduce the collection to a single value.
30
+ */
31
+ reduce<T>(fn: (accumulator: T, value: V, key: K, collection: this) => T, initialValue: T): T;
32
+ /**
33
+ * Get the first value(s) in the collection.
34
+ */
35
+ first(): V | undefined;
36
+ first(count: number): V[];
37
+ /**
38
+ * Get the last value(s) in the collection.
39
+ */
40
+ last(): V | undefined;
41
+ last(count: number): V[];
42
+ /**
43
+ * Get a random value from the collection.
44
+ */
45
+ random(): V | undefined;
46
+ random(count: number): V[];
47
+ /**
48
+ * Return an array of all values in the collection.
49
+ */
50
+ toArray(): V[];
51
+ /**
52
+ * Return an array of all keys in the collection.
53
+ */
54
+ keyArray(): K[];
55
+ /**
56
+ * Sort the collection by a comparator and return a new Collection.
57
+ */
58
+ sort(compareFn?: (a: V, b: V, aKey: K, bKey: K) => number): Collection<K, V>;
59
+ /**
60
+ * Create a Collection from an array using a key extractor.
61
+ */
62
+ static from<K, V>(items: V[], keyFn: (item: V) => K): Collection<K, V>;
63
+ }