@wolfstar/plugin-gateway
v0.14.0
Published
Gateway (WebSocket) support for @wolfstar/http-framework: a GatewayClient emitting Structures, backed by a pluggable cache
Downloads
10,539
Maintainers
Readme
Gateway events, Discord structures, and API managers for @wolfstar/http-framework.
Description
@wolfstar/plugin-gateway extends the
@wolfstar/http-framework client with
Discord gateway events and API access. GatewayClient.start() loads the framework's pieces, starts
its HTTP interaction endpoint, and connects the gateway shards in one call. Commands and HTTP
interactions continue to use the framework's existing client.
The gateway connection uses @discordjs/ws for
sharding, reconnects, and session resumes. Actions turn dispatches into events containing structures
such as Message, User, and Guild; EventGatewayListener lets pieces in the listeners
directory handle those events. Managers such as client.users and client.guilds read through
manager.cache, in memory by default, optionally backed by an @wolfstar/plugin-cache
store, and fetch missing data through @discordjs/core. The same core API is available as client.api.
[!NOTE] A gateway connection is long-lived: a
GatewayClientneeds a persistent process, unlike a bot only serving HTTP interactions.
Installation
pnpm add @wolfstar/plugin-gateway @wolfstar/plugin-cacheUsage
import { GatewayClient } from "@wolfstar/plugin-gateway";
import { GatewayIntentBits } from "discord-api-types/v10";
const client = new GatewayClient({
intents:
GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.MessageContent,
});
client.on("messageCreate", (message) => {
const guild = message.guildId ? client.guilds.cache.get(message.guildId) : undefined;
console.log(`${message.author.username} in ${guild?.name ?? "a DM"}: ${message.content}`);
});
await client.start({ listen: { port: 8080 } }); // loads pieces, starts HTTP, connects the gatewayOptions
On top of the Client options:
| Option | Default | Description |
| --------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| intents | — | The gateway intents. |
| cacheConstructor | CollectionCache | Builds manager.cache, the cache of structure instances each manager reads through, see Cache. |
| cacheOptions | undefined | Per-entity options of those caches, e.g. { messages: { maxSize: 1_000 } }, see Bounding memory. |
| sweepers | undefined | Periodically evicts entries from those caches, e.g. DefaultSweeperSettings, see Sweepers. |
| cache | undefined | A @wolfstar/plugin-cache cache whose raw stores back the managers instead, see Cache. |
| shardCount | null | Total shards across every process, null for Discord's recommendation. |
| shardIds | null | The shards this client runs, as an array or a { start, end } range. null for all. |
| gateway | {} | Extra @discordjs/ws WebSocketManager options (compression, initialPresence, ...). |
| cacheFailure | "skip" | On a cache read/write failure, "skip" drops the event, "emitUncached" emits it from the payload. |
| dispatchTimeout | 30_000 | Milliseconds after which a dispatch still processing is reported as a DispatchTimeoutError. null disables it. |
| sessionStore | undefined | A GatewaySessionStore keeping the shards' sessions across restarts, see Resuming sessions. |
| sessionStoreTimeout | 5_000 | Milliseconds a shard waits for sessionStore to read its session before identifying. null waits forever. |
| partials | [] | The structures to build partially for uncached entities, see Partials. |
| waitGuildTimeout | 15_000 | Milliseconds clientReady waits for initially unavailable guilds before emitting anyway, see Events. |
client.gateway exposes the underlying WebSocketManager, e.g. to send presence updates.
Partials
Like discord.js's partials, Partials lists the structures the client builds from the IDs a dispatch carries when
an event concerns an entity it has not cached: User, Channel (direct messages only), GuildMember, Message,
Reaction, GuildScheduledEvent, ThreadMember, Poll, PollAnswer, and SoundboardSound.
Unlike discord.js, events are emitted either way: without the partial, the uncached entity is null as usual. With
it, it is a structure whose partial is true. Only its IDs are reliable, and fetch() completes it. Partial
structures are never written to the cache. The reactions and poll answers of uncached messages are always partial, so
Partials.Reaction and Partials.PollAnswer change nothing and exist for parity.
const client = new GatewayClient({ intents, partials: [Partials.Message, Partials.User] });
client.on("messageDelete", async (message) => {
if (message?.partial)
console.log(`Uncached message ${message.id} deleted in ${message.channelId}`);
});Events
GatewayEvents mirrors the keys of the table below, like @wolfstar/http-framework's own Events: each member's
value is the plain event name, so it is interchangeable with the string literal.
client.on(GatewayEvents.MessageCreate, (message) => console.log(message.content));| Event | Arguments |
| ---------------------------------------------------------- | ------------------------------------------------------- |
| raw | payload, shardId — every dispatch |
| shardReady | shardId, user |
| clientReady | client — once, see below |
| shardResume / shardClose / shardError | shardId / shardId, code / error, shardId |
| guildCreate | guild |
| guildUpdate | oldGuild \| null, newGuild |
| guildDelete | guild \| null, data |
| channelCreate / channelDelete | channel |
| channelUpdate | oldChannel \| null, newChannel |
| threadCreate / threadUpdate / threadDelete | same shapes as channels |
| threadListSync | threads, members, data |
| threadMemberUpdate | oldMember \| null, newMember |
| threadMembersUpdate | added, removed, thread \| null, data |
| messageCreate | message |
| messageUpdate | oldMessage \| null, newMessage |
| messageDelete | message \| null, data |
| messageDeleteBulk | messages, data |
| messageReactionAdd / messageReactionRemove | reaction, user \| null, details |
| messageReactionRemoveAll | message \| null, reactions (a Collection), data |
| messageReactionRemoveEmoji | reaction |
| messagePollVoteAdd / messagePollVoteRemove | answer, userId |
| guildMemberAdd | member |
| guildMemberUpdate | oldMember \| null, newMember |
| guildMemberRemove | member \| null, data |
| guildRoleCreate / guildRoleUpdate / guildRoleDelete | same shapes as members |
| guildMembersChunk | members, guild \| null, data |
| userUpdate | oldUser \| null, newUser |
| emojiCreate / emojiDelete | emoji |
| emojiUpdate | oldEmoji, newEmoji |
| stickerCreate / stickerDelete | sticker |
| stickerUpdate | oldSticker, newSticker |
| inviteCreate | invite |
| inviteDelete | invite \| null, data |
| voiceStateUpdate | oldState \| null, newState |
| voiceChannelStatusUpdate / voiceChannelStartTimeUpdate | oldChannel, newChannel |
| channelInfo | channels, guild \| null |
| presenceUpdate | oldPresence \| null, newPresence |
| guildScheduledEventCreate / guildScheduledEventDelete | event |
| guildScheduledEventUpdate | oldEvent \| null, newEvent |
| guildScheduledEventUserAdd / ...UserRemove | event \| null, user \| null, data |
| stageInstanceCreate / stageInstanceDelete | stageInstance |
| stageInstanceUpdate | oldStageInstance \| null, newStageInstance |
| guildSoundboardSoundCreate | sound |
| guildSoundboardSoundUpdate | oldSound \| null, newSound |
| guildSoundboardSoundDelete | sound \| null, data |
| guildSoundboardSoundsUpdate / soundboardSounds | sounds, guildId |
| guildBanAdd / guildBanRemove | ban |
| guildAuditLogEntryCreate | entry |
| autoModerationRuleCreate / autoModerationRuleDelete | rule |
| autoModerationRuleUpdate | oldRule \| null, newRule |
| autoModerationActionExecution | execution |
| guildIntegrationsUpdate | guild \| null, data |
| integrationCreate | integration |
| integrationUpdate | oldIntegration \| null, newIntegration |
| integrationDelete | integration \| null, data |
The previous state of update events and the entity of delete events come from the cache, and are
null when it was not cached (or when the client has no cache). data is the raw dispatch data,
which always identifies the deleted entity.
clientReady is emitted once, like discord.js's Client#clientReady: after every shard this client manages has
connected, and every guild READY listed as initially unavailable became available (or waitGuildTimeout, 15_000
by default, elapsed — that timeout only bounds the wait on guilds, never on shards connecting).
client.isClientReady() and client.clientReadyAt report it after the fact.
client.on(GatewayEvents.ClientReady, (client) =>
console.log(`Logged in as ${client.user!.username}`),
);client.actions holds an Action for each handled gateway dispatch. Each action captures the
previous state, then builds and emits events after the cache has been updated. The built-in actions
use the DispatchHandlers and MultiDispatchHandlers tables. Dispatches they do not cover are
still written to the cache and emitted as raw. INTERACTION_CREATE is handled by the HTTP endpoint.
REST operations use client.api from @discordjs/core. A few endpoints without a matching
core method (cursor-based message pins, guild creation from a template, and thread member queries
with extra parameters) use the same core client's underlying REST transport.
Dispatches of the same guild (or direct message channel) are processed in order, so an
asynchronous cache never reorders them, while different guilds proceed concurrently: a slow guild
does not hold the others back. Dispatches that belong to no guild, such as READY or USER_UPDATE,
wait for everything queued before them on their shard, and everything after them waits for them.
client.queueStats reports the pending dispatches, and one still running after dispatchTimeout
is reported as a DispatchTimeoutError through the error event, without being cancelled.
A cache failure (Redis down, corrupt value) is always reported through error. With the default
cacheFailure: "skip" the event is dropped, so listeners never see state the cache does not hold;
with "emitUncached" it is emitted anyway, built from the payload, with null as previous state. READY is the
exception: it is always emitted, since it sets client.user from the payload alone.
On READY, the cached guilds of that shard which READY no longer lists are dropped and emitted as
guildDelete: the bot left them while disconnected, or while the process was down with a
persistent cache, and Discord does not replay those removals. This is best effort: a failure (cache unreachable,
unknown shard count) is reported through error and keeps the remaining guilds.
Replaying dispatches on another process
A client that never connects to Discord can still emit the events of another process's client, as long as both share a cache: the connected client handles each dispatch and writes it to the cache, and the other one replays it without touching the cache again.
- The connected client emits
dispatchafter the cache write, withpayload,shardIdand a trailingstate: what the dispatch's handler read before the write (the cached message aMESSAGE_UPDATEreplaces, the member aGUILD_MEMBER_REMOVEdrops, …),undefinedwhen the type keeps none or it was not cached. client.serializeDispatchState(type, state)turns thatstateinto plain, JSON-safe API data, andclient.reviveDispatchState(type, serialized, data)rebuilds its structures on the receiving client. Relations (author, guild, …) resolve from the receiving client's cache as it is when the dispatch is replayed, so they can be newer than the dispatch.DispatchStateCodecsis the table behind both, one codec per type that keeps a state.client.replayDispatch({ t, d, s? }, shardId, state?)emitsrawand the matching event, with the same Structures andoldarguments the connected client emitted. It never reads or writes the cache, and never emitsdispatch. Dispatches of a guild replay in order, like on a connected client.client.replayDispatchTypeslists the types it handles:READYandINTERACTION_CREATEare not replayed, soclient.userstaysnulluntil something sets it (andclient.applicationpartial untilfetch()), and shard lifecycle events never fire. The promise rejects when a listener or the handler throws, which lets the caller retry the dispatch; async listeners are awaited.
// Connected client: ship each dispatch with its previous state.
connected.on("dispatch", async (payload, shardId, state) => {
const serialized = connected.serializeDispatchState(payload.t, state);
await transport.send({ payload, shardId, state: serialized });
});
// Worker: same cache, never connected.
transport.receive(async ({ payload, shardId, state }) => {
const revived = await worker.reviveDispatchState(payload.t, state, payload.d);
await worker.replayDispatch(payload, shardId, revived);
});@wolfstar/plugin-broker wires this up over Redis Streams with
forwardGatewayDispatches and replayGatewayDispatches.
Listeners
Since GatewayClient emits on the client itself, gateway events are handled by regular listener
pieces. EventGatewayListener pins the emitter to the client and types run after the event:
// listeners/log-messages.ts
import { EventGatewayListener, type Message } from "@wolfstar/plugin-gateway";
export class LogMessagesListener extends EventGatewayListener<"messageCreate"> {
public constructor(context: EventGatewayListener.LoaderContext) {
super(context, { event: "messageCreate" });
}
public override run(message: Message) {
console.log(`${message.author.username}: ${message.content}`);
}
}Or, without a constructor, with RegisterAsGatewayListener (implemented like
@wolfstar/plugin-subcommands-advanced's RegisterAsSubcommand):
import {
EventGatewayListener,
RegisterAsGatewayListener,
type Message,
} from "@wolfstar/plugin-gateway";
@RegisterAsGatewayListener("messageCreate", { once: false })
export class LogMessagesListener extends EventGatewayListener<"messageCreate"> {
public override run(message: Message) {
console.log(`${message.author.username}: ${message.content}`);
}
}With once: true, the listener unloads itself after its first run.
Any piece (listener, command, interaction handler) reaches the client through
this.container.gatewayClient, typed as GatewayClient, so its managers need no cast:
import { Command } from "@wolfstar/http-framework";
export class GuildNameCommand extends Command {
public override async chatInputRun(interaction: Command.ChatInputInteraction) {
const guild = await this.container.gatewayClient.guilds.fetch(interaction.guildId!);
return interaction.reply({ content: guild.name });
}
}GatewayClient registers itself as container.gatewayClient on construction, next to the
framework's container.client. The latter stays typed as the base Client: a module augmentation
cannot redeclare it with another type (TypeScript reports TS2717, or silently keeps Client under
skipLibCheck).
Cache
Every manager exposes its cache as manager.cache, the Cache of the discord.js RFC:
const user = client.users.cache.get(userId);
const member = guild.members.cache.get(userId);
client.users.cache.has(userId);
client.users.cache.getSize();By default, entities are kept in memory by CollectionCache, a Collection of structure instances: updates patch
the cached instance in place, as in discord.js, and every method is synchronous. Its relations (e.g. member.voice)
are re-resolved on every cache.get; instances reached by iterating the collection itself (find, filter, map,
...) carry the relations of their last get.
import { createRedisCache } from "@wolfstar/plugin-cache";
new GatewayClient({ intents }); // CollectionCache, in memory
new GatewayClient({ intents, cache: createRedisCache(redis) }); // raw data in Redis
new GatewayClient({ intents, cache: null }); // nothing is cached
new GatewayClient({ intents, cacheConstructor: MyCache }); // your own Cache, see "Custom caches"[!IMPORTANT] The default keeps every entity the gateway sends in memory until a dispatch removes it (
MESSAGE_DELETE,GUILD_DELETE, ...): nothing expires, so messages, users, and presences grow for as long as the process runs. See Bounding memory.
With a @wolfstar/plugin-cache store, the cache holds raw API data and builds a structure on every read: the
methods return promises when the store is remote (await works with every cache, manager.cache.synchronous tells
them apart, see Synchronous reads), and two reads return two objects. policies apply to
every write, in every mode (dispatches and manager writes alike); their ttl only applies to plugin-cache stores,
see Store-backed caches.
manager.fetch(...) reads the cache first and falls back to the REST API. manager.cache is keyed by a single ID
for most managers, and by manager.resolveKey(...) for the ones taking more than one argument:
| Manager | resolveKey / fetch / refresh arguments |
| ----------------- | -------------------------------------------- |
| client.users | userId |
| client.guilds | guildId |
| client.channels | channelId (threads included) |
| client.threads | threadId |
| client.messages | channelId, messageId |
| client.members | guildId, userId |
| client.roles | guildId, roleId |
cache.getonly reads the cache, resolving toundefinedon a miss;fetchreads the cache, falling back to the REST API (and caching the result). Pass{ force: true }after the IDs to always hit the API,{ cache: false }not to store the result:client.messages.fetch(channelId, messageId, { force: true });refreshisfetchwith{ force: true };resolvetakes a structure (returned as is) or a cache key, like discord.js'sresolve.
The managers taking more than one argument are also handed out for one guild, channel, or thread, like
discord.js's: the first ID is filled in, and cache takes the ID of the entity alone.
const member = await guild.members.cache.get(userId);
// instead of client.members.cache.get(client.members.resolveKey(guildId, userId))
await guild.roles.cache.has(roleId);
await channel.messages.cache.get(messageId);
await guild.members.kick(userId, "spam"); // client.members.kick(guildId, userId, "spam")| Manager | From the client | cache is keyed by |
| ------------------- | ------------------------------------ | ------------------- |
| guild.members | client.guilds.members(guildId) | userId |
| guild.roles | client.guilds.roles(guildId) | roleId |
| guild.voiceStates | client.guilds.voiceStates(guildId) | userId |
| guild.presences | client.guilds.presences(guildId) | userId |
| channel.messages | new ChannelMessageManager(…) | messageId |
| thread.members | new ThreadChannelMemberManager(…) | userId |
The ones of a guild are the client's managers themselves, built for that guild (guild.members is a
GuildMemberManager<true>): their cache is a Cache like the client's, counting and clearing the entries of the
guild alone, and synchronous when the client's is. The cache of channel.messages and thread.members has get,
has, and delete.
Every API payload is written to the cache: a cached entry is patched with it (the fields a partial payload lacks
keep their cached value) and the patched instance, or the newly built structure, is returned. Relations are
resolved from the cache too: message.author is the entry of client.users, message.member the one of
client.members, and the same goes for member.user, emoji.author, sticker.user, and invite.inviter. Every
structure of a guild (channels, threads, members, roles, messages, emojis, stickers, invites) has guild, built
from the cached guild, and messages have channel. These are null when the entity is not cached; fetchGuild()
and fetchChannel() always get it. A structure built by hand, with new Message(data), falls back to the copy
embedded in its payload.
Which object a relation is depends on the relation. With the default cache, message.author and message.channel
are the cached instances, the very objects client.users.cache.get(id) and client.channels.cache.get(id) return.
guild is not: it is a shallow copy of the cached guild, holding the same data at the time the structure was read,
but message.guild !== client.guilds.cache.get(message.guildId). Compare guilds by id, and read
client.guilds.cache.get(id) when you need the instance that later updates patch.
The same goes for what the client hands out outside of manager.cache: the structures delivered by events (the
message of messageCreate, the new of update events such as guildMemberUpdate, ...) and the ones listCached
returns are freshly built from the cache, they are not the cached instances and later dispatches do not patch them.
The previous state of update and delete events is a copy of the cached instance taken before the write; with the
default cache it carries the relations of the entity's last read rather than re-resolving them.
Only manager.cache.get (and fetch, resolve, which read it) returns the cached instance.
Bounding memory
With the default cache, nothing is evicted unless a dispatch removes it. There are four ways to bound it:
cacheOptionssets amaxSizeper entity: once reached, the oldest entry is evicted for each new one, and0holds nothing. It is passed to thecacheConstructor, the defaultCollectionCacheincluded. The bound is per entity, not per channel: unlike discord.js, a busy channel can evict the messages of a quiet one.client.channels.cachespans the channel and the thread caches, so it is aCachebut not aCollection: it has nosize,filter,findor iteration, andmaxSizeapplies to channels and threads separately.// The 1000 most recent messages, and no presence. new GatewayClient({ intents, cacheOptions: { messages: { maxSize: 1_000 }, presences: { maxSize: 0 } }, });policies.filterdecides entry by entry: a rejected entry is not cached, and the entry already cached under its key is deleted.// No bot user, and no message written by a bot. new GatewayClient({ intents, policies: { users: { filter: (user) => !user.bot }, messages: { filter: (message) => !message.author.bot }, }, });cache: nullcaches nothing at all:cache.getresolves toundefinedandfetchalways hits the API.new GatewayClient({ intents, cache: null });sweepersevict entries on a timer, e.g. messages nobody touched for a while.
cacheOptions also takes a keepOverLimit(value, key, cache), like discord.js's LimitedCollection: once maxSize
is reached, the oldest entry it answers false for is evicted, and the cache grows past maxSize if it keeps them
all. cacheWithLimits builds cacheOptions from plain numbers:
import { cacheWithLimits } from "@wolfstar/plugin-gateway";
new GatewayClient({
intents,
// 200 messages, no presences, and up to 1000 members plus the ones in a voice channel.
cacheOptions: cacheWithLimits({
messages: 200,
presences: 0,
members: { maxSize: 1_000, keepOverLimit: (member) => member.voice?.channelId != null },
}),
});cacheOptions only applies to caches built by a constructor: like cacheConstructor, combining it with cache or
makeCache throws (those stores have their own bounds), and it is ignored with cache: null.
Sweepers
Like discord.js's, sweepers evict the entries of a cache every interval seconds. Each entity takes either a
lifetime (in seconds) or a filter factory that returns the predicate of that sweep, or null to skip it. Only
invites, messages and threads take a lifetime: a message is swept when it was last edited or created longer
ago than that, a thread when it has been archived for longer, an invite when it has expired for longer.
import { GatewayClient, Sweepers } from "@wolfstar/plugin-gateway";
const client = new GatewayClient({
intents,
sweepers: {
messages: { interval: 3_600, lifetime: 1_800 },
users: { interval: 3_600, filter: () => (user) => user.bot },
// Evict what Sweepers.filterByLifetime selects, counted from a timestamp of your choosing.
members: {
interval: 3_600,
filter: Sweepers.filterByLifetime({
lifetime: 7_200,
getComparisonTimestamp: (member) => member.joinedTimestamp,
excludeFromSweep: (member) => member.voice?.channelId != null,
}),
},
},
});
// Or on demand, which returns how many entries were evicted.
client.sweepers.sweepMessages(600);
client.sweepers.sweepUsers(() => (user) => user.bot);DefaultSweeperSettings sweeps messages untouched for 30 minutes and threads archived for four hours, every hour.
Unlike discord.js's, it is not empty. Every sweep emits cacheSweep(entity, swept), and a failing filter emits
cacheError with operation: "sweep" instead of throwing out of the timer. client.destroy() stops the timers.
Sweepers walk the caches of structure instances, so combining them with cache or makeCache throws: set the ttl
of their policies instead.
Custom caches
cacheConstructor takes a class implementing Cache, instantiated once per entity with
(creator, name, options):
creatorbuilds a structure out of raw data: it is the cache'sconstruct;nameis the entity's name, e.g."users";options(CacheConstructorOptions) carries:keyOf(data), the cache key of raw data.addmust key its entries with it: most entities are not keyed byid(a member is keyed by guild and user, a message by channel and ID, ...);refresh(value), which resolves the relations of an instance again and returns it. Call it on whatgetandaddhand out, or long-lived instances keep the relations of the day they were built (member.voicewould not followVOICE_STATE_UPDATE);- the entity's
cacheOptions, i.e.maxSize.
The recommended way is to extend CollectionCache, which does all of this:
import {
CollectionCache,
type CacheConstructorOptions,
type RawAPIType,
type StructureCreator,
type StructureMixin,
} from "@wolfstar/plugin-gateway";
import type { CacheEntityName } from "@wolfstar/plugin-cache";
class BoundedCache<
Value extends StructureMixin<object>,
Raw extends RawAPIType<Value> = RawAPIType<Value>,
> extends CollectionCache<Value, Raw> {
public constructor(
creator: StructureCreator<Value, Raw>,
name: CacheEntityName,
options: CacheConstructorOptions<Value, Raw>,
) {
// Forward `keyOf` and `refresh`, with a default bound for the entities `cacheOptions` does not set.
super(creator, name, { ...options, maxSize: options.maxSize ?? 10_000 });
}
}
new GatewayClient({ intents, cacheConstructor: BoundedCache });- The cache must be synchronous: structures are built synchronously from it.
- A cache that is not a
Mapcannot be enumerated, so what needs to list its entries does not work with it: the dispatch cascades (GUILD_DELETEandCHANNEL_DELETEleave the guild's or channel's entries behind), the reconciliation of guilds left while offline onREADY, the granular emoji and sticker diff events, andlistCached. cacheConstructorcannot be combined withcacheormakeCache(it throws). Withcache: null,nullwins: nothing is cached and the class is never instantiated.
Store-backed caches
cache and makeCache (@wolfstar/plugin-cache) keep the managers backed by raw stores, in memory or Redis,
instead of CollectionCache: a structure is built on every read. makeCache takes precedence over cache, and is
called once per entity kind, null not to cache that kind. cacheConstructor cannot be combined with either.
import { MemoryEntityCache } from "@wolfstar/plugin-cache";
const client = new GatewayClient({
intents,
// Called once per entity kind: `null` not to cache it.
makeCache: (entity) =>
["guilds", "channels", "roles"].includes(entity) ? new MemoryEntityCache() : null,
// Entry by entry, for dispatches and managers alike; `ttl` only applies to these stores.
policies: { users: { filter: (user) => !user.bot }, messages: { ttl: () => 3_600_000 } },
// A failing store (e.g. Redis down) is a cache miss, reported through `cacheError`.
cacheErrors: "miss",
});Swapping createInMemoryCache() for createRedisCache({ redis }) changes nothing else, see
@wolfstar/plugin-cache. Every feature still works without a store for an entity kind (or with
cache: null, for every entity), following the
discord.js RFC #11426: every event is still emitted, and
the previous state of update and delete events is null (or a partial, see Partials). What else needs
a store:
| Without the store of… | What happens |
| ---------------------------- | -------------------------------------------------------------------------------- |
| any entity | cache.get resolves to undefined, fetch hits the API, previous state null |
| emojis / stickers | only guildEmojisUpdate / guildStickersUpdate, no granular diff events |
| guilds (able to enumerate) | guilds left while offline are not reconciled on READY |
| presences | presences.fetch rejects, presences.resolve answers null: gateway only |
| roles | overwrite types and member roles are read from one GET /guilds/:id/roles |
| threadMembers | thread.joined is null unless the payload carries the bot's member |
| a relation's entity | the relation getter (message.guild, member.voice, ...) returns null |
listCached resolves to [] without a store, and throws a TypeError for a store that cannot
enumerate its entries. With cacheErrors: "throw", a failing store rejects instead of missing.
Synchronous reads
manager.cache's methods are Awaitable: synchronous with the default CollectionCache, a promise with a
@wolfstar/plugin-cache store, unless every store the entity's relations are read from is synchronous too.
manager.cache.synchronous tells them apart, so a hot path such as a message filter can skip await when it can:
const user = client.users.cache.synchronous
? client.users.cache.get(userId)
: await client.users.cache.get(userId);await works either way, since a non-promise value resolves to itself:
client.on("messageCreate", async (message) => {
const key = message.guildId && client.members.resolveKey(message.guildId, message.author.id);
const member = key ? await client.members.cache.get(key) : undefined;
if (member?.roleIds.includes(mutedRoleId)) return;
// ...
});Resuming sessions across restarts
@discordjs/ws resumes a shard's session after a dropped connection, but only within the process:
every deploy or crash identifies every shard again, spending identify quota, missing the events sent
meanwhile, and triggering a full GUILD_CREATE burst. A sessionStore keeps the sessions outside
the process, so the next one resumes them and Discord replays what it missed:
import { createRedisCache, createRedisSessionStore } from "@wolfstar/plugin-cache";
import { GatewayClient } from "@wolfstar/plugin-gateway";
import { GatewayIntentBits } from "discord-api-types/v10";
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const client = new GatewayClient({
intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
cache: createRedisCache({ redis }),
sessionStore: createRedisSessionStore({ redis }),
});
process.once("SIGTERM", async () => {
await client.destroy({ resumable: true }); // the next process resumes the sessions
process.exit(0);
});- Pair it with a persistent cache. A resumed session only replays the missed events, not the
guilds: with an in-memory cache, the default
CollectionCacheorcreateInMemoryCache(), a restarted process resumes with an empty cache thatGUILD_CREATEnever refills. - Shut down with
destroy({ resumable: true }). By defaultdestroy()closes the connections with code1000, which makes Discord invalidate the sessions, and@discordjs/wsdrops them from the store. Withresumable, the shards close with code4200and their sessions stay stored. Both wait for the pending session writes. - The store is read once per shard, when it first connects, and mirrored in memory from then on
(
@discordjs/wsreads the session on every dispatch and heartbeat). A read failing, or taking longer thansessionStoreTimeout, is reported as aGatewaySessionStoreErrorthrougherrorand the shard identifies, it never stalls. - Writes run in the background.
@discordjs/wsupdates the session on every dispatch; the client never holds a dispatch back for it, and writes one session per shard at a time, collapsing the updates received meanwhile into a single write of the latest. On a busy shard that is still about one write per store round trip. After a crash, the stored sequence may be a few dispatches behind: the session is still resumable, and Discord replays those dispatches, which listeners then see twice. A failed write is reported througherrortoo. - A stale session is harmless: when Discord refuses to resume it,
@discordjs/wsidentifies. A session stored with another shard count (e.g. after resharding) is not resumed at all.
sessionStore replaces the gateway.retrieveSessionInfo and gateway.updateSessionInfo options,
passing either alongside it throws. destroy({ resumable: true }) also works with a
gateway.updateSessionInfo of your own, which it does not tell to drop the sessions.
Structures
Structures extend the ones
@discordjs/structures
already ships (User, Message, Attachment, Embed, Reaction, Poll, Emoji, Invite,
Presence, Activity, VoiceState, Webhook, Sticker, StickerPack, SoundboardSound,
StageInstance, AutoModerationRule, and every channel type), adding relations resolved from the
cache, CDN URLs, and actions through the client. The ones it has no counterpart for yet (Guild,
GuildMember, Role, ThreadMember, GuildScheduledEvent, ...) extend its base Structure.
They live in one folder per domain, each with an index.ts, mirroring @discordjs/structures:
applications/, automoderation/, channels/ (and channels/mixins/), emojis/, guilds/, invites/,
messages/, polls/, presences/, soundboards/, stageInstances/, stickers/, users/,
voice/, and webhooks/.
Where a getter of ours has stricter semantics (e.g. null or a default instead of undefined),
it overrides @discordjs/structures' one, which keeps typing it.
Channels get one class per type (TextChannel, VoiceChannel, ForumChannel,
PublicThreadChannel, DMChannel, ...), each extending @discordjs/structures' own and composed
from mixins (GuildChannelMixin, ChannelTopicMixin, ThreadChannelMixin, ...). instanceof
Channel matches any of them. ChannelManager picks the class matching the channel type,
BaseChannel covers the unknown ones. Channel mixins can supply a DataTemplate, an
optimizeData hook, and an enrichToJSON hook. Construction and patches optimize timestamps
across channels, messages, members, invites, events, templates, and voice states, as well as role
and overwrite permission bits. toJSON() retains the original API fields.
client.on("channelCreate", (channel) => {
if (channel instanceof TextChannel) console.log(channel.name, channel.topic);
});Structures never hold a reference to the client. Every channel has fetch() and delete()
(from BaseChannelMixin), which use @discordjs/core for API calls.
Structure, StructureMixin, initStructure, Mixin, MixinTypes, and the kData, kPatch,
and kClone symbols are exported, so structures can be subclassed and new mixins written:
import { Mixin, TextChannel, kData } from "@wolfstar/plugin-gateway";
class MyTextChannel extends TextChannel {}
Mixin(MyTextChannel, [MyMixin]);To extend another @discordjs/structures class the same way, mix StructureMixin in and call
initStructure from the constructor:
import { User as BaseUser } from "@discordjs/structures";
import { initStructure, Mixin, StructureMixin } from "@wolfstar/plugin-gateway";
export interface MyUser extends StructureMixin<APIUser> {}
export class MyUser extends BaseUser {
public constructor(data: APIUser, relations: object = {}) {
super(data);
initStructure(this, data, relations);
}
}
Mixin(MyUser, [StructureMixin]);[!NOTE]
@discordjs/structuresdoes not export the symbols keying a structure's data and its patch/clone methods, but creates them withSymbol.for, sokData,kPatch, andkCloneare the very same symbols, re-exported for subclasses and mixins. It is only published asdevsnapshots requiring Node.js 24.17 (hence this package'sengines).
Guilds, emojis, stickers and invites
Guild has every field of the API, its CDN URLs, and discord.js's editing methods (edit,
setName, setIcon, setSystemChannel, ..., disableInvites, setIncidentActions, leave,
delete), plus fetchOwner, fetchPreview, fetchVanityData, and fetchVoiceRegions. Its
emojis, stickers, and invites have their own managers, reachable from the guild or the client:
const guild = await client.guilds.fetch(guildId);
const emoji = await guild.emojis.create({ attachment: "data:image/png;base64,...", name: "howl" });
await emoji.roles.add(roleId);
await client.guilds
.stickers(guildId)
.create({ file: { name: "wolf.png", data }, name: "wolf", tags: "wolf" });
const invite = await guild.invites.create(channelId, { maxAge: 3600 });
const fetched = await client.fetchInvite("https://discord.gg/wolves");The client also has discord.js's fetchSticker, fetchStickerPacks, and fetchVoiceRegions.
emojiCreate/Update/Delete and the sticker events come from diffing GUILD_EMOJIS_UPDATE and
GUILD_STICKERS_UPDATE against the cache, so a client without cache only gets them through raw.
Users, members and roles
They follow discord.js's API. client.users.cache, client.members.cache, and client.roles.cache
read synchronously with the default CollectionCache, like discord.js; only a @wolfstar/plugin-cache
store that is not synchronous (e.g. Redis) makes them asynchronous, see
Synchronous reads.
const member = await client.members.fetch(guildId, userId);
await member.roles.add(roleId, "verified");
await member.timeout(10 * 60_000, "spam");
if (member.permissions.has("BanMembers")) await member.ban(); // read from the cache, like discord.js
if (member.kickable) await member.kick();
const me = guild.members.me; // like discord.js, null when not cached; client.members.me(guildId) without a guild
const cached = await member.roles.cache; // a Collection of the cached roles, @everyone included
const highest = await member.roles.highest; // read from the cache, like discord.js
await highest?.setColors({ primaryColor: 0xff0000 });
await client.user?.setActivity("with wolves", { type: ActivityType.Competing });
await client.users.send(member, "Welcome!"); // a user, a member, a message (its author), or an IDmember.roles and emoji.roles are discord.js's GuildMemberRoleManager and
GuildEmojiRoleManager: add, remove and set take a Role, an ID, an array or a Collection,
and resolve to the updated member or emoji. Their cache and getters (highest, hoist, color,
icon, ...) are the one difference: they are Awaitable, synchronous with the default cache and a
promise with an asynchronous store, so await them. highest is null when no role is cached.
discord.js's derived getters are there too: member.permissions, permissionsIn(channel),
manageable, kickable, bannable, moderatable, displayColor, role.editable,
message.deletable, channel.permissionsFor(member), ... They read the cache alone, never the API.
With an entity they need missing from the cache they throw GuildUncached, GuildUncachedMe,
ChannelUncached, or GuildMemberUncached, as discord.js throws GuildUncachedMe.
Each of them also has a fetch* twin (fetchKickable(), fetchDeletable(), fetchEditable(),
...) that asks the API for what is not cached. Most twins are deprecated: the getter is the API, as
in discord.js. They will be removed in a later release.
The getters skip a role that is not in the cache, without an error: member.roles.highest answers
the highest role the cache holds and member.permissions the permissions of the roles it holds, so
a role-hierarchy or permission check can under-report. These three twins are not deprecated, because
they are the single call that stays right on a cache miss: member.roles.fetchHighest(),
member.fetchPermissions() and member.fetchPermissionsIn(channel). For manageable, kickable,
bannable and moderatable, await member.roles.fetch() (and the bot's) before reading the getter.
// Right even when a role of the author is not cached:
const [mine, theirs] = await Promise.all([me.roles.fetchHighest(), author.roles.fetchHighest()]);
const canManageRoles = (await me.fetchPermissions()).has("ManageRoles");When the cache may lack an entity (a size-limited or filtered cache, or a plugin-broker worker
that only receives some dispatches), fetch what is missing yourself, then read the getter:
await client.guilds.fetch(guildId); // the guild
const me = await client.members.fetchMe(guildId); // the bot's member
await me.roles.fetch(); // and the roles it needs
if (await member.kickable) await member.kick();The entity fetches (fetchGuild, fetchChannel, fetchMember, fetchMe, roles.fetch(), ...)
stay. So does member.fetchPresence(), which is not a twin of member.presence: the getter is
null with an asynchronous cache.
With an asynchronous store (Redis) the same getters answer a promise, still read from the cache
alone, so await them. Declare the cache asynchronous once, and they are typed as promises:
declare module "@wolfstar/plugin-gateway" {
interface GatewayCacheConfig {
asynchronous: true;
}
}
if ((await member.permissions).has("BanMembers")) await member.ban();
if (await member.kickable) await member.kick();Do declare it: without the declaration the types still say boolean, and a promise is always
truthy, so if (member.kickable) would pass for everyone.
client.users.createDM(user) returns the cached direct message channel, unless force is set;
client.users.dmChannel(user) (or user.dmChannel) reads it, and deleteDM throws
UserNoDMChannel without one. The cached channel is found by scanning the channel cache, which is
only done on a synchronous cache that can enumerate its entries: with cache: null or a Redis store,
dmChannel is null and createDM always asks Discord, which answers with the existing channel.
client.user is a ClientUser, which edits the bot's profile and sets its presence on every shard.
client.members also lists, searches, adds (OAuth2), edits, kicks, bans and prunes members;
client.roles creates, edits, moves and deletes roles, and fetches all of a guild's roles or their
member counts. Permissions are PermissionsBitFields, computed like Discord does: owner and
administrators get everything, everyone else @everyone plus their roles. Channel overwrites
apply through member.permissionsIn(channel), see below.
client.members.request(guildId, options) (or guild.requestMembers(options), discord.js:
guild.members.fetch()) asks the guild's shard for its members over the gateway instead of REST:
every member by default, those matching a query (with a limit), or up to 100 userIds, with
their presences if asked. It resolves with the GuildMembers once Discord's last
GUILD_MEMBERS_CHUNK for its nonce is cached, so client.members.cache.get sees them, and rejects
with a GuildMembersTimeoutError when no chunk arrives for time milliseconds (120 seconds by
default), or a GuildMembersRateLimitError when Discord answers with RATE_LIMITED. Every chunk
is also emitted as guildMembersChunk, whose data.not_found lists the requested IDs that are
not members. Requesting every member or a query needs the GuildMembers intent, and presences the
GuildPresences one. @discordjs/ws keeps the requests within the gateway's rate limit.
const members = await client.members.request(guildId); // every member
const [wolf] = await client.members.request(guildId, { userIds: [userId], presences: true });Voice channel status and start time
VoiceChannel has status (a string, or null), voiceStartTimestamp (milliseconds) and
voiceStartAt (a Date), plus setStatus(status, reason?) (needs SetVoiceChannelStatus). They
are null until Discord sends them: the voiceChannelStatusUpdate and voiceChannelStartTimeUpdate
events keep them current for cached channels, and client.channels.requestInfo(guildId, { fields })
(or guild.requestChannelInfo({ fields })) asks the guild's shard for them over the gateway. It
resolves with the cached VoiceChannels Discord sent info for, once they are updated (the reply is
also emitted as channelInfo), and rejects with a GuildChannelInfoTimeoutError after time
milliseconds (10 seconds by default). The reply carries no nonce, so requests for one guild run one
after the other, and a reply arriving after its request timed out resolves the next queued one,
with info that may lack its fields. Only the process that sent a request resolves it (like
members.request, this matters with plugin-broker). A GUILD_CREATE replaces the channel and resets both fields to null.
The gateway API is new, so discord.js may rename these members when it ships its own.
await client.channels.requestInfo(guildId, { fields: ["status", "voice_start_time"] });
const channel = await client.channels.cache.get(channelId);
if (channel instanceof VoiceChannel) console.log(channel.status, channel.voiceStartAt);Channels and permissions
Guild channels follow discord.js: edit, setName, clone, delete, and the setters of each
type (setTopic, setRateLimitPerUser, setBitrate, setUserLimit, setAvailableTags, ...).
setParent and lockPermissions copy the category's overwrites. channel.permissionOverwrites
creates, edits (keeping the permissions you do not pass), and deletes overwrites, and
guild.channels creates, lists, and moves channels:
const channel = await guild.channels.create({ name: "den", type: ChannelType.GuildText });
await channel.permissionOverwrites.edit(guild.id, { SendMessages: false });
await channel.permissionOverwrites.edit(roleId, { SendMessages: true });
channel.permissionsFor(member).has("SendMessages"); // read from the cache, like discord.js
member.permissionsIn(channel).has("SendMessages");Permissions are computed like Discord does: guild permissions, then the @everyone overwrite, the
roles' overwrites, and the member's. Threads use their parent's overwrites. The message
fetch*able() checks use them too.
Threads
Text, announcement, forum, and media channels have threads: create (a post with message in
forums), fetchActive, and fetchArchived. Threads have setArchived, setLocked,
setInvitable, setAutoArchiveDuration, setAppliedTags, join, leave,
fetchStarterMessage, fetchOwner, and members, backed by client.threadMembers and its
ThreadMembers. threadListSync, threadMemberUpdate, and threadMembersUpdate are emitted.
const thread = await channel.threads.create({ name: "hunt", type: ChannelType.PrivateThread });
await thread.members.add(userId);
await thread.setArchived(true);
const post = await forum.threads.create({
name: "Pack news",
message: "Awoo",
appliedTags: [tagId],
});Voice states and presences
client.voiceStates and client.presences read the voice states and presences the gateway sends
(with the GuildVoiceStates and GuildPresences intents). VoiceState mutes, deafens, moves,
and disconnects members, and handles stage channels (setSuppressed, setRequestToSpeak).
Presence has the status and Activitys, with their RichPresenceAssets URLs. Members have
fetchVoiceState() and fetchPresence() (discord.js: member.voice, member.presence).
guild.voiceStates and guild.presences read them by user ID alone, like discord.js, and so do
client.guilds.voiceStates(guildId) and client.guilds.presences(guildId) when you only hold the guild's ID:
const presence = await guild.presences.cache.get(userId);
const voiceState = await guild.voiceStates.cache.get(userId);
await guild.presences.resolve(member); // null on a miss
await guild.presences.listCached();client.on("voiceStateUpdate", (oldState, newState) => {
if (!oldState?.channelId && newState.channelId)
console.log(newState.member?.displayName, "joined");
});
const voice = await member.fetchVoiceState();
await voice?.setChannel(afkChannelId, "idle");Moderation
guild.bans lists, fetches (with the reason, which the gateway does not send), creates, and
removes bans. guild.fetchAuditLogs() returns a page of GuildAuditLogsEntrys with their
executors, and guild.autoModerationRules manages AutoModerationRules, whose setters
(setKeywordFilter, setAllowList, ...) keep the rest of the trigger:
const { entries } = await guild.fetchAuditLogs({ type: AuditLogEvent.MemberBanAdd, limit: 10 });
for (const entry of entries) console.log(entry.executor?.username, entry.targetId, entry.reason);
const rule = await guild.autoModerationRules.fetch(ruleId);
await rule.setKeywordFilter(["awoo"]);Scheduled events, stages, and soundboard
guild.scheduledEvents creates, edits, and deletes GuildScheduledEvents and fetches their
subscribers. Stage channels have createStageInstance and fetchStageInstance (a
StageInstance, managed by guild.stageInstances). guild.soundboardSounds uploads and edits
SoundboardSounds, client.fetchDefaultSoundboardSounds() lists Discord's own, and voice channels
play them with sendSoundboardSound.
const event = await guild.scheduledEvents.create({
name: "Full moon",
scheduledStartTime: Date.now() + 86_400_000,
scheduledEndTime: Date.now() + 90_000_000,
privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly,
entityType: GuildScheduledEventEntityType.External,
entityMetadata: { location: "The den" },
});
await event.setStatus(GuildScheduledEventStatus.Active);Integrations, templates, welcome screen, widget, and onboarding
guild.integrations lists and removes Integrations, cached from the INTEGRATION_* dispatches.
client.templates manages GuildTemplates (guild.fetchTemplates(), guild.createTemplate(),
client.fetchGuildTemplate(code), template.sync(), template.createGuild()). Guilds fetch and
edit their WelcomeScreen, their widget settings (setWidgetSettings patches widgetEnabled and
widgetChannelId), and their GuildOnboarding, whose new prompts and options get placeholder IDs
like discord.js's. client.fetchGuildWidget(guildId) returns the public Widget. Apart from
integrations, Discord sends none of these over the gateway, so they are not cached.
await guild.editWelcomeScreen({
enabled: true,
welcomeChannels: [{ channel: rulesId, description: "Read me", emoji: "🐺" }],
});
const widget = await client.fetchGuildWidget(guild.id);
console.log(widget.presenceCount, widget.imageURL(GuildWidgetStyle.Banner2));Webhooks
client.webhooks fetches, creates, edits, and deletes webhooks, and posts with their token, without
the bot's authorization. Text, announcement, voice, stage, forum, and media channels have
fetchWebhooks and createWebhook, guilds fetchWebhooks, and announcement channels
addFollower. Webhooks are not cached: Discord only says that they changed (webhooksUpdate).
const webhook = await channel.createWebhook({ name: "Howler" });
const message = await webhook.send({ content: "Awoo", username: "Pack" });
await webhook.editMessage(message.id, "Awoo!");
const fetched = await client.fetchWebhook(webhookId, token); // no bot authorization neededApplication
client.application is a ClientApplication, the entry point for what the application owns rather than a guild.
Unlike discord.js, where it is null until READY, it is never null: the client builds it from clientId, so it
exists before READY and without a gateway connection (a client replaying another process's dispatches never sees
READY, and keeps it that way). Until then it is partial: only id is known, READY patches the very same
instance with the flags, and fetch() with everything else. It lives on the client, not in the cache.
client.application.partial; // true until fetch() or edit() resolves
await client.application.fetch(); // GET /applications/@me
client.application.name; // "Wolf"
client.application.owner; // a Team, a User, or null
client.application.iconURL();
await client.application.edit({ description: "Awoo", tags: ["wolf"] });
await client.application.editRoleConnectionMetadataRecords(records);owner is the Team (with its members, each a TeamMember) when a team owns the application, the owner User
otherwise. application.commands, application.emojis and the entitlements will be added to ClientApplication
by their own features, there are no placeholders for them.
Messages
Message follows discord.js: attachments, embeds, mentions (MessageMentions), reactions
(ReactionManager), poll (Poll), flags, cleanContent, and the actions reply, edit,
delete, forward, pin, react, crosspost, startThread, suppressEmbeds. message.guild
and message.channel read the cache, like every relation getter: null when the entity is not
cached or the cache is asynchronous, in which case fetchChannel, fetchGuild, and
fetchReference get it. editable, deletable, bulkDeletable, pinnable, and crosspostable
are discord.js's getters (promises with an asynchronous cache, see above). Text channels
get messages, send, and sendTyping. Guild text-based channels (not direct messages, like in
discord.js) also get bulkDelete, which takes messages, their IDs, a Collection, or a count, and
resolves to a Collection of the deleted messages by ID: the cached Message, else a partial one
with Partials.Message, else undefined. Narrow a Message | PartialMessage with partial.
Message is generic like discord.js's: Message<true> has a guildId string and guild-text-based channels, and
inGuild() narrows to it. reply, edit, and the other actions resolve to messages whose channel is never a group
DM (OmitPartialGroupDMChannel), fetch(force) answers from the cache when force is false, forward takes a
channel or its ID, and messageSnapshots hold MessageSnapshots. sharedClientTheme, resolveComponent(customId),
and fetchWebhook() are discord.js's.
Files to send are AttachmentBuilders, as in discord.js: new AttachmentBuilder(file, { name, description }) takes
a buffer, a path, a URL, a stream, or a blob, and setFile, setName, setDescription, setSpoiler (which adds or removes
the SPOILER_ prefix), setTitle, setDuration, and setWaveform (for voice messages) chain. Pass them in the files of a
message, and copy a builder, a payload, or a received Attachment (whose attachment is its URL) with AttachmentBuilder.from.
const file = new AttachmentBuilder("./howl.ogg").setDescription("A howl").setSpoiler();
await channel.send({ content: "Awoo", files: [file] });As in discord.js, attachments, stickers, messageSnapshots, and reactions.cache are
Collections keyed by ID (reactions by the ID of a custom emoji, the name of a Unicode one), while
embeds and components are arrays. react() resolves to the MessageReaction, counting the bot.
partial is true for a message lacking its content or its author. Every structure is valued by
its ID (valueOf()), like discord.js's Base:
const channel = await client.channels.fetch(channelId);
if (channel instanceof TextChannel) {
await channel.sendTyping();
const message = await channel.send({
content: "Awoo",
poll: { question: { text: "Best pack?" }, answers },
});
const reaction = await message.react("🐺");
console.log(reaction.count, message.attachments.first()?.url);
const voters = await message.poll?.answers[0]?.fetchVoters();
await channel.bulkDelete(10, true);
}
const { items } = await client.messages.fetchPins(channelId);
const users = await message.reactions.resolve("🐺")?.users.fetch();Errors
Like discord.js's DiscordjsError, every error the package throws or emits carries a code from
GatewayErrorCodes, and its message comes from GatewayErrorMessages. GatewayError,
GatewayTypeError, and GatewayRangeError extend Error, TypeError, and RangeError, and are
named after their code (GatewayError [WebhookTokenUnavailable]). The errors with extra data
(DispatchTimeoutError, GuildMembersTimeoutError, GuildChannelInfoTimeoutError,
GuildMembersRateLimitError, and GatewaySessionStoreError) extend GatewayError.
import { GatewayError, GatewayErrorCodes } from "@wolfstar/plugin-gateway";
try {
await webhook.send("Awoo");
} catch (error) {
if (error instanceof GatewayError && error.code === GatewayErrorCodes.WebhookTokenUnavailable) {
// The webhook was fetched without its token.
}
}Limitations
- A
GatewayClientconnects its gateway shards from a single process (@discordjs/ws'sWorkerShardingStrategycan be set throughgateway.buildStrategy). To spread them across processes, use@wolfstar/plugin-sharderand spreadshardClient.gatewayOptionsinto the client's options. - Interaction payloads keep being handled as today, they do not read through
client.users&
