@stoatx/client 0.6.3 → 0.7.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
@@ -5,127 +5,17 @@ A high-performance, fully-typed, and memory-efficient client library for the Sto
5
5
  [![npm version](https://img.shields.io/npm/v/@stoatx/client.svg?style=flat-square)](https://www.npmjs.com/package/@stoatx/client)
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue.svg?style=flat-square)](https://www.typescriptlang.org/)
7
7
 
8
- `@stoatx/client` provides a robust object-oriented wrapper around Stoat's REST and WebSocket APIs. It features an intelligent caching system, automatic memory sweeping, and strict event typings to make building bots as frictionless as possible.
9
-
10
8
  ## Features
11
9
 
12
10
  - **Strictly Typed:** 100% TypeScript with highly accurate generic types for events and structures.
13
11
  - **Smart Caching:** Built on a unified `BaseManager` architecture that guarantees reference stability (no duplicate objects in memory).
14
12
  - **Automatic Memory Management:** Includes a built-in `SweeperManager` to prevent memory leaks in long-running applications.
15
13
  - **Extensible:** Designed to seamlessly integrate with higher-level command frameworks like the Stoatx Handler.
14
+ - **Self-hosting support:** Natively supports custom Stoat instances via root API discovery.
16
15
  - **Modern Node:** Pure ESM and CommonJS support built with `tsup`.
17
16
 
18
- ## Installation
19
-
20
- ```bash
21
- # Using pnpm (Recommended)
22
- pnpm add @stoatx/client
23
-
24
- # Using npm
25
- npm install @stoatx/client
26
-
27
- # Using yarn
28
- yarn add @stoatx/client
29
- ```
30
-
31
- ## Quick Start
32
-
33
- ```typescript
34
- import { Client } from "@stoatx/client";
35
-
36
- // Initialize the client
37
- const client = new Client({
38
- sweepers: {
39
- messages: {
40
- lifetime: 3600000, // 1 hour
41
- interval: 600000, // 10 minutes
42
- },
43
- },
44
- });
45
-
46
- // Listen to events
47
- client.on("ready", async () => {
48
- const me = await client.users.fetchMe();
49
- console.log(`Logged in successfully as ${me.username}!`);
50
- });
51
-
52
- client.on("messageCreate", async (message) => {
53
- if (message.author.bot) return;
54
-
55
- if (message.content === "!ping") {
56
- await message.channel.send("Pong! 🏓");
57
- }
58
- });
59
-
60
- // Connect to Stoat
61
- client.login("YOUR_BOT_TOKEN");
62
- ```
63
-
64
- ##️ Architecture & Managers
65
-
66
- `@stoatx/client` organizes data using a predictable **Manager** hierarchy. Managers handle fetching, caching, and editing structures.
67
-
68
- ### Global Managers
69
-
70
- Accessible directly on the `client`, managing data across the entire bot:
71
-
72
- - `client.users` (Fetches and caches users)
73
- - `client.servers` (Fetches and caches servers)
74
- - `client.channels` (The global cache for all channel types)
75
-
76
- ### Scoped Managers
77
-
78
- Accessible on specific structures (like `Server` or `Channel`), managing localized data:
79
-
80
- - `server.members` (Manages members within a specific server)
81
- - `server.roles` (Manages roles for a server)
82
- - `server.channels` (A virtual manager filtering the global channel cache)
83
- - `channel.messages` (Manages messages within a specific channel)
84
-
85
- ### Example: Editing a Server and Sending a Message
86
-
87
- ```typescript
88
- // Fetch a server and edit its name
89
- const server = await client.servers.fetch("SERVER_ID");
90
- await server.edit({ name: "My Awesome Server" });
91
-
92
- // Find a specific channel and send an embed
93
- const channel = server.channels.cache.get("CHANNEL_ID");
94
- if (channel && channel.isText()) {
95
- await channel.send({
96
- content: "Welcome to the new server!",
97
- embeds: [{ title: "Update", description: "Name changed successfully." }],
98
- });
99
- }
100
- ```
101
-
102
- ## Building Command Handlers
103
-
104
- While you can use `@stoatx/client` directly, it truly shines when paired with a command handler. Because the events are strictly typed using generics, you can easily build type-safe event registries.
105
- We recommend using the `Stoatx` for a seamless experience, but you can also create your own custom command handler leveraging the client's event system.
106
-
107
- ```typescript
108
- // Example of event listening
109
- import type { ClientEvents } from "@stoatx/client";
110
-
111
- function registerEvent<K ClientEvents extends keyof>(
112
- client: Client,
113
- event: K,
114
- handler: (...args: ClientEvents[K]) => void
115
- ) {
116
- client.on(event, handler);
117
- }
118
- ```
119
-
120
- ## Contributing
121
-
122
- Contributions are welcome! If you're using this library and find a bug or want to add a feature from the Revolt API, feel free to open a Pull Request.
123
-
124
- 1. Clone the repository.
125
- 2. Run `pnpm install`.
126
- 3. Make your changes in `packages/client`.
127
- 4. Run `pnpm build` to ensure `tsup` compiles your changes correctly.
17
+ For installation instructions, guides, and API references, visit the **[Stoatx Documentation](https://stoatx-ts.github.io/stoatx/)**.
128
18
 
129
19
  ## License
130
20
 
131
- MIT © [Stoatx / Stoatx Team]
21
+ MIT © [Stoatx / Stoatx Team]
package/dist/index.cjs CHANGED
@@ -829,6 +829,7 @@ var GatewayManager = class {
829
829
  this.client = client;
830
830
  }
831
831
  ws = null;
832
+ wsURL = "";
832
833
  pingInterval = null;
833
834
  token = null;
834
835
  reconnectAttempts = 0;
@@ -843,7 +844,7 @@ var GatewayManager = class {
843
844
  this.ws.removeAllListeners();
844
845
  this.ws = null;
845
846
  }
846
- const baseUrl = "wss://stoat.chat/events";
847
+ const baseUrl = this.wsURL ?? "wss://events.stoat.chat";
847
848
  const url = `${baseUrl}?version=1&format=json&token=${this.token}`;
848
849
  this.ws = new import_ws.default(url);
849
850
  this.ws.on("open", () => {
@@ -863,6 +864,9 @@ var GatewayManager = class {
863
864
  this.client.emit("error", error);
864
865
  });
865
866
  }
867
+ setGatewayUrl(url) {
868
+ this.wsURL = url;
869
+ }
866
870
  handleMessage(rawData) {
867
871
  const payload = JSON.parse(rawData.toString());
868
872
  const eventType = payload.type;
@@ -1359,12 +1363,19 @@ var RESTManager = class {
1359
1363
  }
1360
1364
  }, 3e5).unref();
1361
1365
  }
1362
- baseURL = "https://stoat.chat/api";
1366
+ baseURL = "";
1367
+ cdnURL = "";
1363
1368
  token = null;
1364
1369
  buckets = /* @__PURE__ */ new Map();
1365
1370
  setToken(token) {
1366
1371
  this.token = token;
1367
1372
  }
1373
+ setBaseURL(baseURL) {
1374
+ this.baseURL = baseURL.replace(/\/+$/, "");
1375
+ }
1376
+ setCDNURL(cdnURL) {
1377
+ this.cdnURL = cdnURL.replace(/\/+$/, "");
1378
+ }
1368
1379
  /**
1369
1380
  * Generates a local identifier for the bucket based on method and path.
1370
1381
  */
@@ -1417,13 +1428,6 @@ var RESTManager = class {
1417
1428
  this.client.emit("debug", `Bucket [${method}:${endpoint}] exhausted. Waiting ${waitTime}ms proactively...`);
1418
1429
  await sleep(waitTime);
1419
1430
  }
1420
- const headers = {
1421
- "X-Bot-Token": this.token
1422
- };
1423
- if (body) {
1424
- headers["Content-Type"] = "application/json";
1425
- }
1426
- console.log("FETCH", method.toUpperCase(), url, JSON.stringify(body));
1427
1431
  const response = await fetch(url, {
1428
1432
  method: method.toUpperCase(),
1429
1433
  headers: {
@@ -1463,7 +1467,7 @@ var RESTManager = class {
1463
1467
  */
1464
1468
  async uploadFile(tag, fileBuffer, filename) {
1465
1469
  if (!this.token) throw new Error("REST_NOT_READY: No token available.");
1466
- const url = `https://cdn.stoatusercontent.com/${tag}`;
1470
+ const url = `${this.cdnURL}/${tag}`;
1467
1471
  const formData = new FormData();
1468
1472
  formData.append("file", new Blob([fileBuffer]), filename);
1469
1473
  const response = await fetch(url, {
@@ -3859,6 +3863,50 @@ var Emoji = class extends Base {
3859
3863
  if (data.animated !== void 0) this.animated = data.animated;
3860
3864
  if (data.nsfw !== void 0) this.nsfw = data.nsfw;
3861
3865
  }
3866
+ /**
3867
+ * Fetch this emoji from the API or resolves it from the local cache.
3868
+ * @param force Whether to skip the cache check and force a direct API request. Defaults to false.
3869
+ * @returns A promise that resolves to the fetched {@link Emoji} object.
3870
+ * @throws {Error} If the API request fails or if the emoji is detached.
3871
+ * @example
3872
+ * // Force fetch emoji to update its data
3873
+ * await emoji.fetch(true);
3874
+ */
3875
+ async fetch(force) {
3876
+ const server = this.client.servers.cache.get(this.parent.type === "Server" ? this.parent.id : "");
3877
+ if (!server) throw new Error("No server registered in EmojiManager");
3878
+ if (this.parent.type === "Detached") throw new Error("Cannot fetch a detached emoji");
3879
+ return await server.emojis.fetch(this, force);
3880
+ }
3881
+ /**
3882
+ * Deletes this emoji from the server.
3883
+ * @returns A promise that resolves when the emoji has been deleted.
3884
+ * @throws {Error} If the API request fails or if the emoji is detached.
3885
+ * @example
3886
+ * // Delete an emoji from the server
3887
+ * await emoji.delete();
3888
+ */
3889
+ async delete() {
3890
+ const server = this.client.servers.cache.get(this.parent.type === "Server" ? this.parent.id : "");
3891
+ if (!server) throw new Error("No server registered in EmojiManager");
3892
+ if (this.parent.type === "Detached") throw new Error("Emoji is already detached");
3893
+ await server.emojis.delete(this);
3894
+ }
3895
+ /**
3896
+ * Edits this emoji's properties.
3897
+ * @param options The options to edit the emoji with.
3898
+ * @returns A promise that resolves to the edited {@link Emoji} object.
3899
+ * @throws {Error} If the API request fails or if the emoji is detached.
3900
+ * @example
3901
+ * // Edit an emoji's name
3902
+ * await emoji.edit({ name: "new_name" });
3903
+ */
3904
+ async edit(options) {
3905
+ const server = this.client.servers.cache.get(this.parent.type === "Server" ? this.parent.id : "");
3906
+ if (!server) throw new Error("No server registered in EmojiManager");
3907
+ if (this.parent.type === "Detached") throw new Error("Cannot edit a detached emoji");
3908
+ return await server.emojis.edit(this, options);
3909
+ }
3862
3910
  };
3863
3911
 
3864
3912
  // src/managers/EmojiManager.ts
@@ -3872,7 +3920,7 @@ var EmojiManager = class extends BaseManager {
3872
3920
  * Tell BaseManager how to find the ID for Emojis
3873
3921
  */
3874
3922
  extractId(data) {
3875
- return data._id ?? data.id;
3923
+ return data._id;
3876
3924
  }
3877
3925
  /**
3878
3926
  * Tell BaseManager how to build an Emoji
@@ -3880,10 +3928,104 @@ var EmojiManager = class extends BaseManager {
3880
3928
  construct(data) {
3881
3929
  return new Emoji(this.client, data);
3882
3930
  }
3883
- async fetch(id) {
3931
+ /**
3932
+ * Fetch an Emoji from the API or resolves it from the local cache.
3933
+ * @param emoji The ID, mention, or {@link Emoji} object to fetch
3934
+ * @param force Whether to skip the cache check and force a direct API request. Defaults to false.
3935
+ * @returns A promise that resolves to the fetched {@link Emoji} object.
3936
+ * @throws {TypeError} If an invalid {@link EmojiResolvable} is provided.
3937
+ * @throws {Error} If the API request fails.
3938
+ * @example
3939
+ * // Fetch a channel, bypassing cache
3940
+ * const channel = await client.channels.fetch("01H...", true);
3941
+ */
3942
+ async fetch(emoji, force = false) {
3943
+ if (!force) {
3944
+ const cached = this.resolve(emoji);
3945
+ if (cached) return cached;
3946
+ }
3947
+ const id = this.resolveId(emoji);
3884
3948
  const data = await this.client.rest.get(`/custom/emoji/${id}`);
3885
3949
  return this._add(data);
3886
3950
  }
3951
+ /**
3952
+ * Resolves a {@link EmojiResolvable} to a {@link Emoji} object from the cache.
3953
+ * @param emoji The {@link EmojiResolvable} to resolve.
3954
+ * @returns The resolved {@link Emoji} object, or undefined if not found.
3955
+ */
3956
+ resolve(emoji) {
3957
+ if (emoji instanceof Emoji) return emoji;
3958
+ if (typeof emoji === "string") {
3959
+ const id = emoji.replace(/[<:>]/g, "");
3960
+ return this.cache.get(id);
3961
+ }
3962
+ return void 0;
3963
+ }
3964
+ /**
3965
+ * Extracts ID from a {@link EmojiResolvable}.
3966
+ * @param emoji The {@link EmojiResolvable} to extract the ID from.
3967
+ * @returns The extracted {@link Emoji} ID.
3968
+ * @throws {TypeError} If an invalid type is provided.
3969
+ */
3970
+ resolveId(emoji) {
3971
+ if (emoji instanceof Emoji) return emoji.id;
3972
+ if (typeof emoji === "string") {
3973
+ return emoji.replace(/:/g, "");
3974
+ }
3975
+ throw new Error("Invalid EmojiResolvable");
3976
+ }
3977
+ /**
3978
+ * Create a new emoji
3979
+ * @param options The options for creating the emoji
3980
+ * @returns A promise that resolves to the created {@link Emoji} object.
3981
+ * @throws {Error} If no server is registered in the {@link EmojiManager} or if the emoji attachment cannot be resolved.
3982
+ * @example
3983
+ * const emoji = await client.emojis.create(server, { emoji: "path/to/emoji.png", name: "myEmoji" });
3984
+ */
3985
+ async crate(options) {
3986
+ if (!this.server) throw new Error("No server registered in EmojiManager");
3987
+ const resolvedId = await resolveAttachment(this.client.rest, options.emoji, "emojis");
3988
+ if (!resolvedId) throw new Error("Failed to resolve emoji attachment");
3989
+ const payload = {
3990
+ name: options.name,
3991
+ parent: {
3992
+ type: "Server",
3993
+ id: this.server.id
3994
+ }
3995
+ };
3996
+ if (options.nsfw) payload.nsfw = options.nsfw;
3997
+ const data = await this.client.rest.put(`/custom/emoji/${resolvedId}`, payload);
3998
+ return this._add(data);
3999
+ }
4000
+ /**
4001
+ * Delete an emoji
4002
+ * @param emoji The {@link EmojiResolvable} to delete
4003
+ * @throws {Error} If no server is registered in the {@link EmojiManager}.
4004
+ * @example
4005
+ * await client.emojis.delete(emoji);
4006
+ */
4007
+ async delete(emoji) {
4008
+ const id = this.resolveId(emoji);
4009
+ await this.client.rest.delete(`/custom/emoji/${id}`);
4010
+ this.cache.delete(id);
4011
+ }
4012
+ /**
4013
+ * Edit an emoji
4014
+ * @param emoji The {@link EmojiResolvable} to edit
4015
+ * @param options The options to edit the emoji with
4016
+ * @returns A promise that resolves to the edited {@link Emoji} object.
4017
+ * @throws {Error} If no server is registered in the {@link EmojiManager}.
4018
+ * @example
4019
+ * const editedEmoji = await client.emojis.edit(emoji, { name: "newName" });
4020
+ */
4021
+ async edit(emoji, options) {
4022
+ const id = this.resolveId(emoji);
4023
+ const payload = {
4024
+ name: options.name
4025
+ };
4026
+ const data = await this.client.rest.patch(`/custom/emoji/${id}`, payload);
4027
+ return this._add(data);
4028
+ }
3887
4029
  [util11.inspect.custom]() {
3888
4030
  return this.cache;
3889
4031
  }
@@ -4289,6 +4431,15 @@ var SweeperManager = class {
4289
4431
 
4290
4432
  // src/client/Client.ts
4291
4433
  var Client = class extends import_events2.EventEmitter {
4434
+ rest;
4435
+ gateway;
4436
+ channels;
4437
+ servers;
4438
+ users;
4439
+ sweepers;
4440
+ emojis;
4441
+ user = null;
4442
+ options;
4292
4443
  constructor(options = {}) {
4293
4444
  super({ captureRejections: true });
4294
4445
  this.options = options;
@@ -4300,23 +4451,40 @@ var Client = class extends import_events2.EventEmitter {
4300
4451
  this.emojis = new EmojiManager(this, void 0, options.cacheLimits?.emojis);
4301
4452
  this.sweepers = new SweeperManager(this, options.sweepers ?? {});
4302
4453
  }
4303
- rest;
4304
- gateway;
4305
- channels;
4306
- servers;
4307
- users;
4308
- sweepers;
4309
- emojis;
4310
- user = null;
4311
4454
  /**
4312
4455
  * Connects the bot to the Stoat Gateway
4313
4456
  */
4314
4457
  async login(token) {
4315
4458
  if (!token) throw new Error("A valid token must be provided.");
4316
- this.sweepers.start();
4459
+ const rootApiUrl = this.options.apiURL ?? "https://api.stoat.chat";
4460
+ this.rest.setBaseURL(rootApiUrl);
4317
4461
  this.rest.setToken(token);
4462
+ const configData = await this.fetchConfig(rootApiUrl);
4463
+ const finalWsUrl = this.options.overrides?.wsURL ?? configData.ws;
4464
+ if (!finalWsUrl) {
4465
+ throw new Error(
4466
+ `[Stoat Misconfiguration] The server at '${rootApiUrl}' is running, but it has no WebSocket URL configured. The server administrator needs to set their gateway config, or you must bypass it using 'options.overrides.wsURL'.`
4467
+ );
4468
+ }
4469
+ this.gateway.setGatewayUrl(finalWsUrl);
4470
+ const finalCdnUrl = this.options.overrides?.cdnURL ?? configData.features.autumn.url;
4471
+ if (!finalCdnUrl) {
4472
+ throw new Error(
4473
+ `[Stoat Misconfiguration] The server at '${rootApiUrl}' is running, but it has no CDN URL configured. The server administrator needs to set their cdn config, or you must bypass it using 'options.overrides.cdnURL'.`
4474
+ );
4475
+ }
4476
+ this.rest.setCDNURL(finalCdnUrl);
4477
+ this.sweepers.start();
4318
4478
  return this.gateway.connect(token);
4319
4479
  }
4480
+ async fetchConfig(baseURL) {
4481
+ try {
4482
+ const response = await fetch(baseURL);
4483
+ return await response.json();
4484
+ } catch (error) {
4485
+ throw new Error(`Failed to fetch ${baseURL}, make sure the instance is running.`);
4486
+ }
4487
+ }
4320
4488
  [/* @__PURE__ */ Symbol.for("nodejs.rejection")](error) {
4321
4489
  this.emit("error", error);
4322
4490
  }