privage.js 0.21.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/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/Client.d.ts +192 -0
- package/dist/Client.js +904 -0
- package/dist/attachments.d.ts +41 -0
- package/dist/attachments.js +63 -0
- package/dist/collectors.d.ts +41 -0
- package/dist/collectors.js +52 -0
- package/dist/commands.d.ts +108 -0
- package/dist/commands.js +216 -0
- package/dist/compat.d.ts +72 -0
- package/dist/compat.js +75 -0
- package/dist/components.d.ts +137 -0
- package/dist/components.js +226 -0
- package/dist/embeds.d.ts +97 -0
- package/dist/embeds.js +125 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.js +48 -0
- package/dist/formatters.d.ts +16 -0
- package/dist/formatters.js +43 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +101 -0
- package/dist/intents.d.ts +28 -0
- package/dist/intents.js +33 -0
- package/dist/modals.d.ts +96 -0
- package/dist/modals.js +162 -0
- package/dist/rest.d.ts +208 -0
- package/dist/rest.js +356 -0
- package/dist/structures/Channel.d.ts +25 -0
- package/dist/structures/Channel.js +34 -0
- package/dist/structures/Interaction.d.ts +149 -0
- package/dist/structures/Interaction.js +170 -0
- package/dist/structures/Member.d.ts +35 -0
- package/dist/structures/Member.js +55 -0
- package/dist/structures/Message.d.ts +181 -0
- package/dist/structures/Message.js +191 -0
- package/dist/structures/Role.d.ts +17 -0
- package/dist/structures/Role.js +20 -0
- package/dist/structures/Server.d.ts +29 -0
- package/dist/structures/Server.js +36 -0
- package/dist/structures/ServerChannels.d.ts +24 -0
- package/dist/structures/ServerChannels.js +50 -0
- package/dist/types.d.ts +195 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
package/dist/Client.js
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Client = void 0;
|
|
7
|
+
const events_1 = require("events");
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const phoenix_1 = require("phoenix");
|
|
10
|
+
const ws_1 = __importDefault(require("ws"));
|
|
11
|
+
const rest_1 = require("./rest");
|
|
12
|
+
const Interaction_1 = require("./structures/Interaction");
|
|
13
|
+
const commands_1 = require("./commands");
|
|
14
|
+
const collectors_1 = require("./collectors");
|
|
15
|
+
const Message_1 = require("./structures/Message");
|
|
16
|
+
const Member_1 = require("./structures/Member");
|
|
17
|
+
const Server_1 = require("./structures/Server");
|
|
18
|
+
const Channel_1 = require("./structures/Channel");
|
|
19
|
+
const Role_1 = require("./structures/Role");
|
|
20
|
+
const intents_1 = require("./intents");
|
|
21
|
+
const errors_1 = require("./errors");
|
|
22
|
+
const RECONNECT_BASE_MS = 3000;
|
|
23
|
+
const RECONNECT_MAX_MS = 60000;
|
|
24
|
+
const DEDUPE_CACHE = 1000;
|
|
25
|
+
const RPC_TIMEOUT_MS = 10000;
|
|
26
|
+
const STREAM_SEQ_RE = /^\d+-\d+$/;
|
|
27
|
+
// Bound the server/channel name caches so a bot in a huge number of servers
|
|
28
|
+
// can't grow them without limit — oldest entries evict first (insertion order).
|
|
29
|
+
const SERVER_CACHE_CAP = 10000;
|
|
30
|
+
const CHANNEL_CACHE_CAP = 10000;
|
|
31
|
+
// Channel-list hydration fans out one REST call per server; cap concurrency
|
|
32
|
+
// so a bot in hundreds of servers doesn't burst the API on connect.
|
|
33
|
+
const CHANNEL_HYDRATE_CONCURRENCY = 5;
|
|
34
|
+
class Client extends events_1.EventEmitter {
|
|
35
|
+
constructor(options) {
|
|
36
|
+
super();
|
|
37
|
+
/** The bot's own identity, populated once connected. */
|
|
38
|
+
this.user = null;
|
|
39
|
+
/**
|
|
40
|
+
* Slash-command registry — `client.commands.set([...])` bulk-overwrites,
|
|
41
|
+
* `client.commands.fetch()` lists. Use after `login()`. Invocations arrive
|
|
42
|
+
* as `interaction` events with `kind: 'command'`.
|
|
43
|
+
*/
|
|
44
|
+
this.commands = new commands_1.CommandsManager(this);
|
|
45
|
+
/**
|
|
46
|
+
* Server name cache — hydrated from `GET /servers` on connect, kept fresh
|
|
47
|
+
* by `server-meta-updated`/`server-deleted` (requires `Intents.Servers`).
|
|
48
|
+
* Capped at {@link SERVER_CACHE_CAP}, oldest entries evict first.
|
|
49
|
+
*/
|
|
50
|
+
this.servers = new Map();
|
|
51
|
+
/**
|
|
52
|
+
* Channel name cache — hydrated per-server in the background after connect,
|
|
53
|
+
* kept fresh by `channel-created`/`channel-updated`/`channel-deleted`
|
|
54
|
+
* (requires `Intents.Servers`), and lazily filled on cache miss (see
|
|
55
|
+
* `ensureChannel`). Capped at {@link CHANNEL_CACHE_CAP}, oldest entries evict first.
|
|
56
|
+
*/
|
|
57
|
+
this.channels = new Map();
|
|
58
|
+
this.pendingChannelFetches = new Set();
|
|
59
|
+
this.pendingServerFetches = new Set();
|
|
60
|
+
this.token = null;
|
|
61
|
+
this.socket = null;
|
|
62
|
+
this.reconnectAttempts = 0;
|
|
63
|
+
this.reconnectPending = false;
|
|
64
|
+
this.destroyed = false;
|
|
65
|
+
this.connectedOnce = false;
|
|
66
|
+
this.readyAtMs = null;
|
|
67
|
+
// Resume state (survives reconnects).
|
|
68
|
+
this.streamToken = null;
|
|
69
|
+
this.resumeCursor = 0; // highest `ss` seen on the current stream
|
|
70
|
+
this.topicCursors = new Map(); // topic -> last `s` (for REST catchup)
|
|
71
|
+
// Dedupe (at-least-once delivery + resume/catchup overlap).
|
|
72
|
+
this.seen = [];
|
|
73
|
+
this.seenSet = new Set();
|
|
74
|
+
// Message -> channel cache: reaction-update payloads omit the channel id
|
|
75
|
+
// (it only lives in the wire topic), so we resolve it from recently-seen
|
|
76
|
+
// messages. Cache miss (e.g. a reaction on a pre-connect message) -> null.
|
|
77
|
+
this.channelByMessage = new Map();
|
|
78
|
+
this.channelByMessageKeys = [];
|
|
79
|
+
// Defaults target production so a fresh install Just Works. A custom api
|
|
80
|
+
// (option or $PRIVAGE_API) keeps the derive-ws-from-api behavior for
|
|
81
|
+
// self-hosted/local setups; production's ws lives on its own host, so it
|
|
82
|
+
// can't be derived.
|
|
83
|
+
const customApi = options.api || process.env.PRIVAGE_API;
|
|
84
|
+
this.api = (customApi || 'https://api.privage.xyz').replace(/\/$/, '');
|
|
85
|
+
this.wsUrl =
|
|
86
|
+
options.ws ||
|
|
87
|
+
process.env.PRIVAGE_WS ||
|
|
88
|
+
(customApi
|
|
89
|
+
? this.api.replace(/^http/, 'ws').replace(/:\d+$/, ':4000') + '/socket'
|
|
90
|
+
: 'wss://ws.privage.xyz/socket');
|
|
91
|
+
this.intents = (0, intents_1.resolveIntents)(options.intents || []);
|
|
92
|
+
this.debugMode = !!options.debug;
|
|
93
|
+
this.streamId = `privage-bot-${(0, crypto_1.randomUUID)()}`;
|
|
94
|
+
}
|
|
95
|
+
/** Authenticate and connect. Resolves once the session is open (`ready`); rejects on a fatal auth error. */
|
|
96
|
+
login(token) {
|
|
97
|
+
// Fail loud and local: without this guard a missing token ships
|
|
98
|
+
// `Authorization: Bot undefined` and surfaces as a cryptic 401 from the
|
|
99
|
+
// server. (Common trip: passing the token to the constructor,
|
|
100
|
+
// discord.js-style — the constructor takes options, login takes the token.)
|
|
101
|
+
if (typeof token !== 'string' || !token.trim()) {
|
|
102
|
+
return Promise.reject(new errors_1.PrivageAuthError('No token provided — pass your bot token to login(token).'));
|
|
103
|
+
}
|
|
104
|
+
this.token = token;
|
|
105
|
+
if (this.intents.length === 0) {
|
|
106
|
+
this.warn('No intents declared — the gateway will deliver ALL events. Declare intents to scope your bot.');
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
this.loginResolve = resolve;
|
|
110
|
+
this.loginReject = reject;
|
|
111
|
+
void this.connect();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* True once `ready` has fired. Stays true across reconnects — it means
|
|
116
|
+
* "ready happened", not "currently connected" (discord.js semantics).
|
|
117
|
+
*/
|
|
118
|
+
isReady() {
|
|
119
|
+
return this.connectedOnce;
|
|
120
|
+
}
|
|
121
|
+
/** When `ready` fired, or `null` before login completes. */
|
|
122
|
+
get readyAt() {
|
|
123
|
+
return this.readyAtMs !== null ? new Date(this.readyAtMs) : null;
|
|
124
|
+
}
|
|
125
|
+
/** Epoch-ms twin of {@link readyAt}. */
|
|
126
|
+
get readyTimestamp() {
|
|
127
|
+
return this.readyAtMs;
|
|
128
|
+
}
|
|
129
|
+
/** Milliseconds since `ready`, or `null` before login completes. */
|
|
130
|
+
get uptime() {
|
|
131
|
+
return this.readyAtMs !== null ? Date.now() - this.readyAtMs : null;
|
|
132
|
+
}
|
|
133
|
+
/** Disconnect and stop reconnecting. */
|
|
134
|
+
destroy() {
|
|
135
|
+
this.destroyed = true;
|
|
136
|
+
if (this.socket) {
|
|
137
|
+
try {
|
|
138
|
+
this.socket.disconnect();
|
|
139
|
+
}
|
|
140
|
+
catch { /* ignore */ }
|
|
141
|
+
this.socket = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Grant a role to a member. Requires the bot to have MANAGE_ROLES and the role to sit below the bot's highest. */
|
|
145
|
+
addRole(serverId, userId, roleId) {
|
|
146
|
+
return this.rest.addRole(serverId, userId, roleId);
|
|
147
|
+
}
|
|
148
|
+
/** Remove a role from a member. */
|
|
149
|
+
removeRole(serverId, userId, roleId) {
|
|
150
|
+
return this.rest.removeRole(serverId, userId, roleId);
|
|
151
|
+
}
|
|
152
|
+
/** Kick a member (needs KICK_MEMBERS). */
|
|
153
|
+
kick(serverId, userId, opts) {
|
|
154
|
+
return this.rest.kickMember(serverId, userId, opts);
|
|
155
|
+
}
|
|
156
|
+
/** Ban a member (needs BAN_MEMBERS). */
|
|
157
|
+
ban(serverId, userId, opts) {
|
|
158
|
+
return this.rest.banMember(serverId, userId, opts);
|
|
159
|
+
}
|
|
160
|
+
/** Lift a ban. */
|
|
161
|
+
unban(serverId, userId) {
|
|
162
|
+
return this.rest.unbanMember(serverId, userId);
|
|
163
|
+
}
|
|
164
|
+
/** Time out a member (needs TIMEOUT_MEMBERS). */
|
|
165
|
+
timeout(serverId, userId, opts) {
|
|
166
|
+
return this.rest.timeoutMember(serverId, userId, opts);
|
|
167
|
+
}
|
|
168
|
+
/** Remove a member's timeout. */
|
|
169
|
+
removeTimeout(serverId, userId) {
|
|
170
|
+
return this.rest.removeTimeout(serverId, userId);
|
|
171
|
+
}
|
|
172
|
+
/** Delete a message (needs MANAGE_MESSAGES, or the bot's own message). */
|
|
173
|
+
deleteMessage(messageId) {
|
|
174
|
+
return this.rest.deleteMessage(messageId);
|
|
175
|
+
}
|
|
176
|
+
/** Send a message to a channel by id — no server id needed. */
|
|
177
|
+
send(channelId, content) {
|
|
178
|
+
const data = typeof content === 'string' ? { content } : content;
|
|
179
|
+
return this.rest.sendMessage(channelId, data);
|
|
180
|
+
}
|
|
181
|
+
/** React to a message. */
|
|
182
|
+
react(messageId, emoji) {
|
|
183
|
+
return this.rest.react(messageId, emoji);
|
|
184
|
+
}
|
|
185
|
+
/** Remove the bot's own reaction from a message. */
|
|
186
|
+
removeReaction(messageId, emoji) {
|
|
187
|
+
return this.rest.removeReaction(messageId, emoji);
|
|
188
|
+
}
|
|
189
|
+
/** Create a poll in a channel (needs send permission there). Limits: question ≤500, 2–6 unique options ≤200, deadline ≤7 days. */
|
|
190
|
+
createPoll(channelId, opts) {
|
|
191
|
+
return this.rest.createPoll(channelId, opts);
|
|
192
|
+
}
|
|
193
|
+
/** Close a poll early (creator or MANAGE_MESSAGES). A `pollClose` event follows. */
|
|
194
|
+
closePoll(pollId) {
|
|
195
|
+
return this.rest.closePoll(pollId);
|
|
196
|
+
}
|
|
197
|
+
/** Create a webhook on a text/announcement channel (needs MANAGE_WEBHOOKS). Response carries the webhook token. */
|
|
198
|
+
createWebhook(channelId, opts) {
|
|
199
|
+
return this.rest.createWebhook(channelId, opts);
|
|
200
|
+
}
|
|
201
|
+
/** List a channel's webhooks. */
|
|
202
|
+
fetchChannelWebhooks(channelId) {
|
|
203
|
+
return this.rest.fetchChannelWebhooks(channelId);
|
|
204
|
+
}
|
|
205
|
+
/** List a server's webhooks. */
|
|
206
|
+
fetchServerWebhooks(serverId) {
|
|
207
|
+
return this.rest.fetchServerWebhooks(serverId);
|
|
208
|
+
}
|
|
209
|
+
/** Rename a webhook / change its avatar (`avatarUrl: null` clears). */
|
|
210
|
+
editWebhook(webhookId, opts) {
|
|
211
|
+
return this.rest.editWebhook(webhookId, opts);
|
|
212
|
+
}
|
|
213
|
+
/** Delete a webhook. */
|
|
214
|
+
deleteWebhook(webhookId) {
|
|
215
|
+
return this.rest.deleteWebhook(webhookId);
|
|
216
|
+
}
|
|
217
|
+
/** Rotate a webhook's token — the old one stops working immediately. */
|
|
218
|
+
rotateWebhookToken(webhookId) {
|
|
219
|
+
return this.rest.rotateWebhookToken(webhookId);
|
|
220
|
+
}
|
|
221
|
+
/** Collect messages matching a filter until a count/time limit — see MessageCollector. */
|
|
222
|
+
createMessageCollector(options) {
|
|
223
|
+
return new collectors_1.MessageCollector(this, options);
|
|
224
|
+
}
|
|
225
|
+
/** Promise form of a collector: resolves with collected messages (default max 1, 60s). */
|
|
226
|
+
awaitMessages(options) {
|
|
227
|
+
return (0, collectors_1.awaitMessages)(this, options);
|
|
228
|
+
}
|
|
229
|
+
/** Set the bot's presence status ('online' | 'idle' | 'dnd' | 'offline'). */
|
|
230
|
+
setStatus(status) {
|
|
231
|
+
return this.rest.setStatus(status);
|
|
232
|
+
}
|
|
233
|
+
/** Set the bot's rich-presence activity (shows on the profile/member list). */
|
|
234
|
+
setActivity(activity) {
|
|
235
|
+
return this.rest.setActivity(activity);
|
|
236
|
+
}
|
|
237
|
+
/** Clear the bot's rich-presence activity. */
|
|
238
|
+
clearActivity() {
|
|
239
|
+
return this.rest.clearActivity();
|
|
240
|
+
}
|
|
241
|
+
/** Edit a message's content. Own messages only (403 otherwise). */
|
|
242
|
+
editMessage(messageId, content) {
|
|
243
|
+
return this.rest.editMessage(messageId, content);
|
|
244
|
+
}
|
|
245
|
+
/** Pin a message. */
|
|
246
|
+
pinMessage(messageId) {
|
|
247
|
+
return this.rest.pinMessage(messageId);
|
|
248
|
+
}
|
|
249
|
+
/** Unpin a message. */
|
|
250
|
+
unpinMessage(messageId) {
|
|
251
|
+
return this.rest.unpinMessage(messageId);
|
|
252
|
+
}
|
|
253
|
+
/** Fetch a channel's message history. */
|
|
254
|
+
async fetchMessages(channelId, opts) {
|
|
255
|
+
const res = await this.rest.fetchMessages(channelId, opts);
|
|
256
|
+
return (res?.messages ?? []).map((m) => new Message_1.Message(this, m));
|
|
257
|
+
}
|
|
258
|
+
/** List a server's members. */
|
|
259
|
+
async fetchMembers(serverId, opts) {
|
|
260
|
+
const res = await this.rest.fetchMembers(serverId, opts);
|
|
261
|
+
return (res?.members ?? []).map((m) => new Member_1.Member(this, { ...m, server_id: serverId }));
|
|
262
|
+
}
|
|
263
|
+
/** List a server's roles. */
|
|
264
|
+
async fetchRoles(serverId) {
|
|
265
|
+
const res = await this.rest.fetchRoles(serverId);
|
|
266
|
+
return (res?.roles ?? []).map((r) => new Role_1.Role(r));
|
|
267
|
+
}
|
|
268
|
+
/** Set (or clear, with `null`) a member's nickname. Others need MANAGE_NICKNAMES. */
|
|
269
|
+
setNickname(serverId, userId, nickname) {
|
|
270
|
+
return this.rest.setNickname(serverId, userId, nickname);
|
|
271
|
+
}
|
|
272
|
+
// ── connection lifecycle ──────────────────────────────────────────────
|
|
273
|
+
debug(msg) {
|
|
274
|
+
if (this.debugMode)
|
|
275
|
+
this.emit('debug', msg);
|
|
276
|
+
}
|
|
277
|
+
warn(msg) {
|
|
278
|
+
this.emit('warn', msg);
|
|
279
|
+
}
|
|
280
|
+
isFatal(err) {
|
|
281
|
+
return err instanceof errors_1.PrivageAuthError || err instanceof errors_1.PrivagePermissionError;
|
|
282
|
+
}
|
|
283
|
+
scheduleReconnect(reason) {
|
|
284
|
+
if (this.destroyed || this.reconnectPending)
|
|
285
|
+
return;
|
|
286
|
+
this.reconnectPending = true;
|
|
287
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
|
|
288
|
+
this.reconnectAttempts += 1;
|
|
289
|
+
this.emit('connectionError', new errors_1.PrivageConnectionError(`${reason} — reconnecting in ${Math.round(delay / 1000)}s`));
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
this.reconnectPending = false;
|
|
292
|
+
void this.connect();
|
|
293
|
+
}, delay);
|
|
294
|
+
}
|
|
295
|
+
async connect() {
|
|
296
|
+
if (this.destroyed)
|
|
297
|
+
return;
|
|
298
|
+
const previous = this.socket;
|
|
299
|
+
this.socket = null;
|
|
300
|
+
if (previous) {
|
|
301
|
+
try {
|
|
302
|
+
previous.disconnect();
|
|
303
|
+
}
|
|
304
|
+
catch { /* ignore */ }
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
await this.handshake();
|
|
308
|
+
this.reconnectAttempts = 0;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (this.isFatal(err)) {
|
|
312
|
+
const fatal = new errors_1.PrivageAuthError(err.message || String(err));
|
|
313
|
+
if (this.loginReject) {
|
|
314
|
+
this.loginReject(fatal);
|
|
315
|
+
this.loginReject = undefined;
|
|
316
|
+
this.loginResolve = undefined;
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
this.scheduleReconnect(`Connect failed (${err?.message || err})`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async handshake() {
|
|
324
|
+
this.rest = new rest_1.Rest({ api: this.api, token: this.token });
|
|
325
|
+
const me = await this.rest.get('/auth/me');
|
|
326
|
+
const botId = String(me.user?.id ?? me.user?._id ?? '');
|
|
327
|
+
this.user = { id: botId, username: me.user?.username ?? botId };
|
|
328
|
+
this.debug(`authenticated as ${this.user.username} (${botId})`);
|
|
329
|
+
const { token: ticket } = await this.rest.get('/gateway/ticket');
|
|
330
|
+
const socket = new phoenix_1.Socket(this.wsUrl, {
|
|
331
|
+
transport: ws_1.default,
|
|
332
|
+
params: { token: ticket, stream_id: this.streamId },
|
|
333
|
+
reconnectAfterMs: () => 3600000, // we own reconnection
|
|
334
|
+
heartbeatIntervalMs: 30000,
|
|
335
|
+
});
|
|
336
|
+
this.socket = socket;
|
|
337
|
+
socket.onClose(() => {
|
|
338
|
+
if (socket !== this.socket)
|
|
339
|
+
return;
|
|
340
|
+
this.scheduleReconnect('Socket closed');
|
|
341
|
+
});
|
|
342
|
+
await new Promise((resolve, reject) => {
|
|
343
|
+
socket.onOpen(resolve);
|
|
344
|
+
socket.onError((e) => reject(new errors_1.PrivageConnectionError(`socket error: ${e?.message || e}`)));
|
|
345
|
+
socket.connect();
|
|
346
|
+
});
|
|
347
|
+
this.debug('socket connected');
|
|
348
|
+
const personal = await this.join(socket, `user:${botId}`);
|
|
349
|
+
await this.join(socket, `bot:${botId}`, (envelope) => this.handleEvent(envelope));
|
|
350
|
+
if (this.streamToken) {
|
|
351
|
+
// Reconnect: try to resume the delivery stream; on any gap, fill via REST.
|
|
352
|
+
const resumed = await this.sessionResume(personal).catch(() => null);
|
|
353
|
+
if (resumed && resumed.resumed) {
|
|
354
|
+
if (resumed.catchupRequired)
|
|
355
|
+
await this.restCatchup();
|
|
356
|
+
this.debug('session resumed');
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// Stream expired/rejected — fresh session, then backfill everything missed.
|
|
360
|
+
await this.sessionOpen(personal);
|
|
361
|
+
await this.restCatchup();
|
|
362
|
+
this.debug('session re-opened (resume unavailable)');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
await this.sessionOpen(personal);
|
|
367
|
+
this.debug(`session open (intents: ${this.intents.join(', ') || 'ALL'})`);
|
|
368
|
+
}
|
|
369
|
+
if (!this.connectedOnce) {
|
|
370
|
+
this.connectedOnce = true;
|
|
371
|
+
this.readyAtMs = Date.now();
|
|
372
|
+
// Servers first, blocking `ready` — cheap (one call) and most bots key
|
|
373
|
+
// off server identity immediately. Channels are hydrated in the
|
|
374
|
+
// background below: a bot in many servers shouldn't have a slow `ready`.
|
|
375
|
+
await this.hydrateServers();
|
|
376
|
+
if (this.loginResolve) {
|
|
377
|
+
this.loginResolve();
|
|
378
|
+
this.loginResolve = undefined;
|
|
379
|
+
this.loginReject = undefined;
|
|
380
|
+
}
|
|
381
|
+
this.emit('ready', { user: this.user });
|
|
382
|
+
void this.hydrateChannels();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ── server/channel name cache ─────────────────────────────────────────
|
|
386
|
+
/** `GET /servers` → populate the server cache. Best-effort — never fatal. */
|
|
387
|
+
async hydrateServers() {
|
|
388
|
+
try {
|
|
389
|
+
const resp = await this.rest.get('/servers');
|
|
390
|
+
for (const row of resp?.servers ?? []) {
|
|
391
|
+
const server = new Server_1.Server(this, row);
|
|
392
|
+
if (server.id)
|
|
393
|
+
this.cacheSet(this.servers, server.id, server, SERVER_CACHE_CAP);
|
|
394
|
+
}
|
|
395
|
+
this.debug(`hydrated ${this.servers.size} server(s)`);
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
this.warn(`server hydration failed: ${e?.message || e}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Per-server channel list, concurrency-limited and run in the background
|
|
403
|
+
* (never awaited by `handshake`). Best-effort — a slow or failing server's
|
|
404
|
+
* channel list never breaks the connection or delays other servers.
|
|
405
|
+
*/
|
|
406
|
+
async hydrateChannels() {
|
|
407
|
+
try {
|
|
408
|
+
const serverIds = Array.from(this.servers.keys());
|
|
409
|
+
let cursor = 0;
|
|
410
|
+
let count = 0;
|
|
411
|
+
const worker = async () => {
|
|
412
|
+
for (;;) {
|
|
413
|
+
const i = cursor++;
|
|
414
|
+
if (i >= serverIds.length)
|
|
415
|
+
return;
|
|
416
|
+
const serverId = serverIds[i];
|
|
417
|
+
try {
|
|
418
|
+
const resp = await this.rest.get(`/channels/server/${serverId}`);
|
|
419
|
+
for (const row of resp?.channels ?? []) {
|
|
420
|
+
const channel = new Channel_1.Channel(this, row);
|
|
421
|
+
if (channel.id) {
|
|
422
|
+
this.cacheSet(this.channels, channel.id, channel, CHANNEL_CACHE_CAP);
|
|
423
|
+
count += 1;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
this.warn(`channel hydration failed for server ${serverId}: ${e?.message || e}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const workers = Array.from({ length: Math.min(CHANNEL_HYDRATE_CONCURRENCY, serverIds.length) }, () => worker());
|
|
433
|
+
await Promise.all(workers);
|
|
434
|
+
this.debug(`hydrated ${count} channel(s) across ${serverIds.length} server(s)`);
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
this.warn(`channel hydration failed: ${e?.message || e}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Lazily resolve an uncached channel over REST (deduped per id, fire-and-forget).
|
|
442
|
+
* Called automatically on a cache-miss message so the *next* message in that
|
|
443
|
+
* channel resolves — useful for brand-new channels or bots that didn't
|
|
444
|
+
* declare `Intents.Servers`. Safe to call redundantly; a no-op if already
|
|
445
|
+
* cached or already in flight.
|
|
446
|
+
*/
|
|
447
|
+
ensureChannel(channelId, serverId) {
|
|
448
|
+
if (!channelId || this.channels.has(channelId) || this.pendingChannelFetches.has(channelId))
|
|
449
|
+
return;
|
|
450
|
+
this.pendingChannelFetches.add(channelId);
|
|
451
|
+
this.rest
|
|
452
|
+
.get(`/channels/${channelId}`)
|
|
453
|
+
.then((resp) => {
|
|
454
|
+
const row = resp?.channel;
|
|
455
|
+
if (!row)
|
|
456
|
+
return;
|
|
457
|
+
const channel = new Channel_1.Channel(this, row);
|
|
458
|
+
if (!channel.id)
|
|
459
|
+
return;
|
|
460
|
+
this.cacheSet(this.channels, channel.id, channel, CHANNEL_CACHE_CAP);
|
|
461
|
+
const sid = channel.serverId || serverId || null;
|
|
462
|
+
if (sid)
|
|
463
|
+
this.ensureServer(sid);
|
|
464
|
+
})
|
|
465
|
+
.catch((e) => this.debug(`ensureChannel(${channelId}) failed: ${e?.message || e}`))
|
|
466
|
+
.finally(() => this.pendingChannelFetches.delete(channelId));
|
|
467
|
+
}
|
|
468
|
+
/** Best-effort fill for a server discovered via a lazily-resolved channel. */
|
|
469
|
+
ensureServer(serverId) {
|
|
470
|
+
if (!serverId || this.servers.has(serverId) || this.pendingServerFetches.has(serverId))
|
|
471
|
+
return;
|
|
472
|
+
this.pendingServerFetches.add(serverId);
|
|
473
|
+
this.rest
|
|
474
|
+
.get(`/servers/${serverId}`)
|
|
475
|
+
.then((resp) => {
|
|
476
|
+
const row = resp?.server;
|
|
477
|
+
if (!row)
|
|
478
|
+
return;
|
|
479
|
+
const server = new Server_1.Server(this, row);
|
|
480
|
+
if (server.id)
|
|
481
|
+
this.cacheSet(this.servers, server.id, server, SERVER_CACHE_CAP);
|
|
482
|
+
})
|
|
483
|
+
.catch((e) => this.debug(`ensureServer(${serverId}) failed: ${e?.message || e}`))
|
|
484
|
+
.finally(() => this.pendingServerFetches.delete(serverId));
|
|
485
|
+
}
|
|
486
|
+
/** Cache-maintenance for structure events. Cache-only — never emits new SDK events. */
|
|
487
|
+
updateCaches(type, d) {
|
|
488
|
+
switch (type) {
|
|
489
|
+
case 'channel-created':
|
|
490
|
+
case 'channel-updated': {
|
|
491
|
+
if (d?.channel) {
|
|
492
|
+
const channel = new Channel_1.Channel(this, d.channel);
|
|
493
|
+
if (channel.id)
|
|
494
|
+
this.cacheSet(this.channels, channel.id, channel, CHANNEL_CACHE_CAP);
|
|
495
|
+
}
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case 'channel-deleted': {
|
|
499
|
+
const channelId = d?.channelId != null ? String(d.channelId) : d?.channel_id != null ? String(d.channel_id) : null;
|
|
500
|
+
if (channelId)
|
|
501
|
+
this.channels.delete(channelId);
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case 'server-meta-updated': {
|
|
505
|
+
// Sparse patch — icon/banner-only updates omit `name` etc. Merge over
|
|
506
|
+
// the cached raw row so untouched fields survive.
|
|
507
|
+
const serverId = d?.serverId != null ? String(d.serverId) : d?.server_id != null ? String(d.server_id) : null;
|
|
508
|
+
if (serverId) {
|
|
509
|
+
const existing = this.servers.get(serverId);
|
|
510
|
+
const merged = { ...(existing?.raw ?? { id: serverId }), ...d };
|
|
511
|
+
this.cacheSet(this.servers, serverId, new Server_1.Server(this, merged), SERVER_CACHE_CAP);
|
|
512
|
+
}
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case 'server-deleted': {
|
|
516
|
+
const serverId = d?.serverId != null ? String(d.serverId) : d?.server_id != null ? String(d.server_id) : null;
|
|
517
|
+
if (serverId) {
|
|
518
|
+
this.servers.delete(serverId);
|
|
519
|
+
for (const [channelId, channel] of this.channels) {
|
|
520
|
+
if (channel.serverId === serverId)
|
|
521
|
+
this.channels.delete(channelId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
default:
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** Insert, evicting the oldest entry (by insertion order) once at capacity. */
|
|
531
|
+
cacheSet(map, key, value, cap) {
|
|
532
|
+
if (!map.has(key) && map.size >= cap) {
|
|
533
|
+
const oldest = map.keys().next().value;
|
|
534
|
+
if (oldest !== undefined)
|
|
535
|
+
map.delete(oldest);
|
|
536
|
+
}
|
|
537
|
+
map.set(key, value);
|
|
538
|
+
}
|
|
539
|
+
join(socket, topic, onBroadcast) {
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
const channel = socket.channel(topic, {});
|
|
542
|
+
if (onBroadcast)
|
|
543
|
+
channel.on('broadcast', onBroadcast);
|
|
544
|
+
channel
|
|
545
|
+
.join()
|
|
546
|
+
.receive('ok', () => resolve(channel))
|
|
547
|
+
.receive('error', (e) => reject(new errors_1.PrivageConnectionError(`join ${topic} denied: ${JSON.stringify(e)}`)))
|
|
548
|
+
.receive('timeout', () => reject(new errors_1.PrivageConnectionError(`join ${topic} timeout`)));
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
sessionOpen(personal) {
|
|
552
|
+
return this.rpc(personal, 'session.open', {
|
|
553
|
+
schema_version: 1,
|
|
554
|
+
gateway_protocol: { min: 2, max: 2 },
|
|
555
|
+
stream_id: this.streamId,
|
|
556
|
+
intents: this.intents,
|
|
557
|
+
client: { platform: 'bot', build: null },
|
|
558
|
+
device: { foreground: true, focused: true },
|
|
559
|
+
}).then((d) => {
|
|
560
|
+
// A fresh stream — reset the resume cursor to its baseline.
|
|
561
|
+
this.streamToken = d?.stream_token ?? this.streamToken;
|
|
562
|
+
this.resumeCursor = typeof d?.latest_ss === 'number' ? d.latest_ss : 0;
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
sessionResume(personal) {
|
|
566
|
+
return this.rpc(personal, 'session.resume', {
|
|
567
|
+
stream_id: this.streamId,
|
|
568
|
+
stream_token: this.streamToken,
|
|
569
|
+
resume_seq: this.resumeCursor,
|
|
570
|
+
}).then((d) => {
|
|
571
|
+
this.streamToken = d?.stream_token ?? this.streamToken;
|
|
572
|
+
// Replay buffered events through the normal path (dedupe + cursor advance + emit).
|
|
573
|
+
for (const e of d?.events ?? [])
|
|
574
|
+
this.handleEvent(e);
|
|
575
|
+
return { resumed: !!d?.resumed, catchupRequired: !!d?.catchup_required };
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/** Send a `session.*` RPC and resolve its response `d` (rejects on `error`/timeout). */
|
|
579
|
+
rpc(personal, type, d) {
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const rid = (0, crypto_1.randomUUID)();
|
|
582
|
+
const timer = setTimeout(() => reject(new errors_1.PrivageConnectionError(`${type} timeout`)), RPC_TIMEOUT_MS);
|
|
583
|
+
const onReply = (env) => {
|
|
584
|
+
if (env?.rid !== rid || env?.t !== type)
|
|
585
|
+
return;
|
|
586
|
+
clearTimeout(timer);
|
|
587
|
+
if (env.k === 'error')
|
|
588
|
+
reject(new errors_1.PrivageConnectionError(`${type} rejected: ${JSON.stringify(env.d)}`));
|
|
589
|
+
else
|
|
590
|
+
resolve(env.d ?? {});
|
|
591
|
+
};
|
|
592
|
+
personal.on('broadcast', onReply);
|
|
593
|
+
personal.push(type, { k: 'request', t: type, v: 2, rid, d });
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
/** Backfill per-topic gaps the resume buffer couldn't cover. Best-effort — never fatal. */
|
|
597
|
+
async restCatchup() {
|
|
598
|
+
if (this.topicCursors.size === 0)
|
|
599
|
+
return;
|
|
600
|
+
try {
|
|
601
|
+
const resp = await this.rest.request('/gateway/catchup', {
|
|
602
|
+
method: 'POST',
|
|
603
|
+
body: { topics: Object.fromEntries(this.topicCursors) },
|
|
604
|
+
});
|
|
605
|
+
let replayed = 0;
|
|
606
|
+
let stale = 0;
|
|
607
|
+
for (const res of Object.values(resp?.topics ?? {})) {
|
|
608
|
+
if (Array.isArray(res?.events)) {
|
|
609
|
+
for (const e of res.events) {
|
|
610
|
+
this.handleEvent(e);
|
|
611
|
+
replayed += 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
else if (res?.resync || res?.too_many) {
|
|
615
|
+
stale += 1;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
for (const [topic, head] of Object.entries(resp?.heads ?? {})) {
|
|
619
|
+
if (typeof head === 'string' && STREAM_SEQ_RE.test(head))
|
|
620
|
+
this.topicCursors.set(topic, head);
|
|
621
|
+
}
|
|
622
|
+
this.debug(`catchup replayed ${replayed} event(s)`);
|
|
623
|
+
if (stale) {
|
|
624
|
+
this.warn(`catchup: ${stale} topic(s) had too large a gap to backfill — some state may be stale; re-fetch over REST if it matters`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
this.warn(`catchup failed: ${e?.message || e}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// ── event routing ─────────────────────────────────────────────────────
|
|
632
|
+
handleEvent(envelope) {
|
|
633
|
+
this.advanceCursors(envelope);
|
|
634
|
+
const dk = envelope?.dk;
|
|
635
|
+
if (dk) {
|
|
636
|
+
if (this.seenSet.has(dk))
|
|
637
|
+
return; // at-least-once delivery — dedupe by dk
|
|
638
|
+
this.remember(dk);
|
|
639
|
+
}
|
|
640
|
+
const type = envelope?.t || envelope?.d?.type || 'unknown';
|
|
641
|
+
const d = envelope?.d && typeof envelope.d === 'object' ? envelope.d : envelope;
|
|
642
|
+
// Cache maintenance must never block event dispatch — a bad payload here
|
|
643
|
+
// shouldn't drop the message/reaction/member routing below.
|
|
644
|
+
try {
|
|
645
|
+
this.updateCaches(type, d);
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
this.debug(`cache update failed for ${type}: ${e?.message || e}`);
|
|
649
|
+
}
|
|
650
|
+
switch (type) {
|
|
651
|
+
case 'new-message':
|
|
652
|
+
case 'message-created':
|
|
653
|
+
case 'thread-message':
|
|
654
|
+
this.dispatchMessage('messageCreate', d);
|
|
655
|
+
break;
|
|
656
|
+
case 'new-message-batch':
|
|
657
|
+
for (const m of d.messages || []) {
|
|
658
|
+
if (m)
|
|
659
|
+
this.dispatchMessage('messageCreate', { ...m, channel_id: m.channel_id || d.channel_id });
|
|
660
|
+
}
|
|
661
|
+
break;
|
|
662
|
+
case 'message-edited':
|
|
663
|
+
this.dispatchMessage('messageUpdate', d);
|
|
664
|
+
break;
|
|
665
|
+
case 'interaction':
|
|
666
|
+
this.safeEmit('interaction', new Interaction_1.Interaction(this, d));
|
|
667
|
+
break;
|
|
668
|
+
case 'message-deleted':
|
|
669
|
+
this.safeEmit('messageDelete', {
|
|
670
|
+
id: String(d.id ?? d.message_id ?? ''),
|
|
671
|
+
channelId: String(d.channel_id ?? ''),
|
|
672
|
+
serverId: d.server_id != null ? String(d.server_id) : null,
|
|
673
|
+
});
|
|
674
|
+
break;
|
|
675
|
+
case 'bulk-messages-deleted':
|
|
676
|
+
this.safeEmit('messageDeleteBulk', {
|
|
677
|
+
ids: (d.ids || d.message_ids || d.messageIds || []).map(String),
|
|
678
|
+
channelId: String(d.channel_id ?? ''),
|
|
679
|
+
serverId: d.server_id != null ? String(d.server_id) : null,
|
|
680
|
+
});
|
|
681
|
+
break;
|
|
682
|
+
case 'reaction-update': {
|
|
683
|
+
const messageId = String(d.messageId ?? d.message_id ?? '');
|
|
684
|
+
const channelId = d.channel_id != null ? String(d.channel_id)
|
|
685
|
+
: d.channelId != null ? String(d.channelId)
|
|
686
|
+
: this.channelByMessage.get(messageId) ?? null;
|
|
687
|
+
this.safeEmit('reactionUpdate', {
|
|
688
|
+
messageId,
|
|
689
|
+
channelId,
|
|
690
|
+
reactions: Array.isArray(d.reactions) ? d.reactions : [],
|
|
691
|
+
});
|
|
692
|
+
// Granular add/remove — present on single-user toggles (not bulk clears).
|
|
693
|
+
if (d.actor_id != null && d.emoji != null) {
|
|
694
|
+
const action = { messageId, channelId, emoji: String(d.emoji), userId: String(d.actor_id) };
|
|
695
|
+
this.safeEmit(d.added ? 'reactionAdd' : 'reactionRemove', action);
|
|
696
|
+
}
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case 'member-joined':
|
|
700
|
+
this.safeEmit('memberJoin', new Member_1.Member(this, d));
|
|
701
|
+
break;
|
|
702
|
+
case 'member-left':
|
|
703
|
+
this.safeEmit('memberLeave', {
|
|
704
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
705
|
+
serverId: d.serverId != null ? String(d.serverId) : (d.server_id != null ? String(d.server_id) : null),
|
|
706
|
+
});
|
|
707
|
+
break;
|
|
708
|
+
case 'member-role-added':
|
|
709
|
+
case 'member-role-removed': {
|
|
710
|
+
const change = {
|
|
711
|
+
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
712
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
713
|
+
roleId: String(d.roleId ?? d.role_id ?? ''),
|
|
714
|
+
};
|
|
715
|
+
this.safeEmit(type === 'member-role-added' ? 'roleAdd' : 'roleRemove', change);
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
case 'member-updated':
|
|
719
|
+
case 'nickname-updated': {
|
|
720
|
+
const update = {
|
|
721
|
+
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
722
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
723
|
+
};
|
|
724
|
+
if (d.display_name != null)
|
|
725
|
+
update.displayName = d.display_name;
|
|
726
|
+
if (d.avatar_url != null)
|
|
727
|
+
update.avatarUrl = d.avatar_url;
|
|
728
|
+
if (d.nickname != null)
|
|
729
|
+
update.nickname = d.nickname;
|
|
730
|
+
this.safeEmit('memberUpdate', update);
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case 'member-banned':
|
|
734
|
+
case 'member-unbanned':
|
|
735
|
+
this.safeEmit(type === 'member-banned' ? 'memberBan' : 'memberUnban', {
|
|
736
|
+
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
737
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
738
|
+
username: d.username ?? '',
|
|
739
|
+
displayName: d.displayName ?? d.display_name ?? '',
|
|
740
|
+
});
|
|
741
|
+
break;
|
|
742
|
+
case 'member-timeout': {
|
|
743
|
+
const until = d.timeoutUntil ?? d.timeout_until;
|
|
744
|
+
this.safeEmit('memberTimeout', {
|
|
745
|
+
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
746
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
747
|
+
until: until ? new Date(until) : null,
|
|
748
|
+
moderatorId: d.moderatorId != null ? String(d.moderatorId) : (d.moderator_id != null ? String(d.moderator_id) : null),
|
|
749
|
+
reason: d.reason ?? null,
|
|
750
|
+
});
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case 'member-untimeout':
|
|
754
|
+
this.safeEmit('memberUntimeout', {
|
|
755
|
+
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
756
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
757
|
+
});
|
|
758
|
+
break;
|
|
759
|
+
case 'message-pinned':
|
|
760
|
+
case 'message-unpinned':
|
|
761
|
+
this.safeEmit(type === 'message-pinned' ? 'messagePin' : 'messageUnpin', {
|
|
762
|
+
messageId: String(d.messageId ?? d.message_id ?? ''),
|
|
763
|
+
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
764
|
+
});
|
|
765
|
+
break;
|
|
766
|
+
case 'typing-start':
|
|
767
|
+
this.safeEmit('typingStart', {
|
|
768
|
+
userId: String(d.user?.id ?? d.user?.user_id ?? d.userId ?? d.user_id ?? ''),
|
|
769
|
+
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
770
|
+
});
|
|
771
|
+
break;
|
|
772
|
+
case 'typing-stop':
|
|
773
|
+
this.safeEmit('typingStop', {
|
|
774
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
775
|
+
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
776
|
+
});
|
|
777
|
+
break;
|
|
778
|
+
case 'channel-created':
|
|
779
|
+
if (d?.channel)
|
|
780
|
+
this.safeEmit('channelCreate', new Channel_1.Channel(this, d.channel));
|
|
781
|
+
break;
|
|
782
|
+
case 'channel-updated':
|
|
783
|
+
if (d?.channel)
|
|
784
|
+
this.safeEmit('channelUpdate', new Channel_1.Channel(this, d.channel));
|
|
785
|
+
break;
|
|
786
|
+
case 'channel-deleted':
|
|
787
|
+
this.safeEmit('channelDelete', {
|
|
788
|
+
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
789
|
+
serverId: d.serverId != null ? String(d.serverId) : (d.server_id != null ? String(d.server_id) : null),
|
|
790
|
+
});
|
|
791
|
+
break;
|
|
792
|
+
case 'role-created':
|
|
793
|
+
if (d?.role)
|
|
794
|
+
this.safeEmit('roleCreate', new Role_1.Role(d.role));
|
|
795
|
+
break;
|
|
796
|
+
case 'role-updated':
|
|
797
|
+
if (d?.role)
|
|
798
|
+
this.safeEmit('roleUpdate', new Role_1.Role(d.role));
|
|
799
|
+
break;
|
|
800
|
+
case 'role-deleted':
|
|
801
|
+
this.safeEmit('roleDelete', {
|
|
802
|
+
roleId: String(d.roleId ?? d.role_id ?? ''),
|
|
803
|
+
serverId: d.serverId != null ? String(d.serverId) : (d.server_id != null ? String(d.server_id) : null),
|
|
804
|
+
});
|
|
805
|
+
break;
|
|
806
|
+
case 'poll-voted':
|
|
807
|
+
this.safeEmit('pollVote', {
|
|
808
|
+
pollId: String(d.pollId ?? ''),
|
|
809
|
+
messageId: String(d.messageId ?? ''),
|
|
810
|
+
voterId: String(d.voterId ?? ''),
|
|
811
|
+
optionIdx: typeof d.optionIdx === 'number' ? d.optionIdx : Number(d.optionIdx ?? 0),
|
|
812
|
+
totalVotes: typeof d.totalVotes === 'number' ? d.totalVotes : Number(d.totalVotes ?? 0),
|
|
813
|
+
});
|
|
814
|
+
break;
|
|
815
|
+
case 'poll-closed':
|
|
816
|
+
this.safeEmit('pollClose', {
|
|
817
|
+
pollId: String(d.pollId ?? ''),
|
|
818
|
+
messageId: String(d.messageId ?? ''),
|
|
819
|
+
totalVotes: typeof d.totalVotes === 'number' ? d.totalVotes : Number(d.totalVotes ?? 0),
|
|
820
|
+
});
|
|
821
|
+
break;
|
|
822
|
+
default:
|
|
823
|
+
this.debug(`unhandled event: ${type}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
advanceCursors(envelope) {
|
|
827
|
+
const ss = typeof envelope?.ss === 'number' ? envelope.ss : null;
|
|
828
|
+
if (ss != null && ss > this.resumeCursor)
|
|
829
|
+
this.resumeCursor = ss;
|
|
830
|
+
const s = envelope?.s;
|
|
831
|
+
if (typeof s === 'string' && STREAM_SEQ_RE.test(s)) {
|
|
832
|
+
const d = envelope?.d && typeof envelope.d === 'object' ? envelope.d : envelope;
|
|
833
|
+
const topic = this.topicOf(d);
|
|
834
|
+
if (topic)
|
|
835
|
+
this.topicCursors.set(topic, s);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
topicOf(d) {
|
|
839
|
+
const ch = d?.channel_id ?? d?.channelId;
|
|
840
|
+
if (ch != null)
|
|
841
|
+
return `channel:${ch}`;
|
|
842
|
+
const sv = d?.server_id ?? d?.serverId;
|
|
843
|
+
if (sv != null)
|
|
844
|
+
return `server:${sv}`;
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
dispatchMessage(event, d) {
|
|
848
|
+
let msg;
|
|
849
|
+
try {
|
|
850
|
+
msg = new Message_1.Message(this, d);
|
|
851
|
+
}
|
|
852
|
+
catch (e) {
|
|
853
|
+
this.warn(`failed to build ${event}: ${e?.message || e}`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (msg.id && msg.channelId)
|
|
857
|
+
this.rememberChannel(msg.id, msg.channelId);
|
|
858
|
+
// Emit first — delivery is the priority and must never be blocked by the
|
|
859
|
+
// secondary name-cache fill below.
|
|
860
|
+
try {
|
|
861
|
+
this.emit(event, msg);
|
|
862
|
+
}
|
|
863
|
+
catch (e) {
|
|
864
|
+
this.warn(`${event} handler error: ${e?.message || e}`);
|
|
865
|
+
}
|
|
866
|
+
if (msg.channelId && !this.channels.has(msg.channelId)) {
|
|
867
|
+
try {
|
|
868
|
+
this.ensureChannel(msg.channelId, msg.serverId ?? undefined);
|
|
869
|
+
}
|
|
870
|
+
catch (e) {
|
|
871
|
+
this.debug(`ensureChannel failed: ${e?.message || e}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
rememberChannel(messageId, channelId) {
|
|
876
|
+
if (this.channelByMessage.has(messageId))
|
|
877
|
+
return;
|
|
878
|
+
this.channelByMessage.set(messageId, channelId);
|
|
879
|
+
this.channelByMessageKeys.push(messageId);
|
|
880
|
+
if (this.channelByMessageKeys.length > DEDUPE_CACHE) {
|
|
881
|
+
const oldest = this.channelByMessageKeys.shift();
|
|
882
|
+
if (oldest)
|
|
883
|
+
this.channelByMessage.delete(oldest);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
safeEmit(event, ...args) {
|
|
887
|
+
try {
|
|
888
|
+
this.emit(event, ...args);
|
|
889
|
+
}
|
|
890
|
+
catch (e) {
|
|
891
|
+
this.warn(`failed to dispatch ${String(event)}: ${e?.message || e}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
remember(dk) {
|
|
895
|
+
this.seenSet.add(dk);
|
|
896
|
+
this.seen.push(dk);
|
|
897
|
+
if (this.seen.length > DEDUPE_CACHE) {
|
|
898
|
+
const oldest = this.seen.shift();
|
|
899
|
+
if (oldest)
|
|
900
|
+
this.seenSet.delete(oldest);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
exports.Client = Client;
|