privage.js 0.22.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Client.d.ts +25 -12
- package/dist/Client.js +388 -173
- package/dist/index.d.ts +1 -1
- package/dist/intents.d.ts +3 -3
- package/dist/intents.js +3 -3
- package/dist/structures/Channel.d.ts +2 -2
- package/dist/structures/Channel.js +2 -2
- package/dist/structures/Interaction.d.ts +32 -7
- package/dist/structures/Interaction.js +18 -7
- package/dist/structures/Message.d.ts +21 -0
- package/dist/structures/Message.js +16 -0
- package/dist/structures/Server.d.ts +1 -1
- package/dist/structures/Server.js +1 -1
- package/dist/structures/ServerChannels.d.ts +1 -1
- package/dist/structures/ServerChannels.js +1 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +8 -0
- package/package.json +1 -1
package/dist/Client.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Role } from './structures/Role';
|
|
|
11
11
|
import type { ClientOptions, ClientEvents, BotUser } from './types';
|
|
12
12
|
/**
|
|
13
13
|
* A Privage bot connection. Owns the full lifecycle — auth, ticket, socket,
|
|
14
|
-
*
|
|
14
|
+
* the bot.v1 gateway stream, heartbeat, reconnect with
|
|
15
15
|
* backoff, dedupe, and transparent resume + REST catchup — and surfaces
|
|
16
16
|
* friendly, camelCase events. The wire protocol never leaks into the public
|
|
17
17
|
* API (see the protocol spec §12).
|
|
@@ -35,13 +35,13 @@ export declare class Client extends EventEmitter {
|
|
|
35
35
|
readonly commands: CommandsManager;
|
|
36
36
|
/**
|
|
37
37
|
* Server name cache — hydrated from `GET /servers` on connect, kept fresh
|
|
38
|
-
* by `server
|
|
38
|
+
* by `server.meta_updated`/`server.deleted` (requires `Intents.Servers`).
|
|
39
39
|
* Capped at {@link SERVER_CACHE_CAP}, oldest entries evict first.
|
|
40
40
|
*/
|
|
41
41
|
readonly servers: ReadonlyMap<string, Server>;
|
|
42
42
|
/**
|
|
43
43
|
* Channel name cache — hydrated per-server in the background after connect,
|
|
44
|
-
* kept fresh by `channel
|
|
44
|
+
* kept fresh by `channel.created`/`channel.updated`/`channel.deleted`
|
|
45
45
|
* (requires `Intents.Servers`), and lazily filled on cache miss (see
|
|
46
46
|
* `ensureChannel`). Capped at {@link CHANNEL_CACHE_CAP}, oldest entries evict first.
|
|
47
47
|
*/
|
|
@@ -56,6 +56,7 @@ export declare class Client extends EventEmitter {
|
|
|
56
56
|
private readonly streamId;
|
|
57
57
|
private socket;
|
|
58
58
|
private reconnectAttempts;
|
|
59
|
+
private lastHandshakeAt;
|
|
59
60
|
private reconnectPending;
|
|
60
61
|
private destroyed;
|
|
61
62
|
private connectedOnce;
|
|
@@ -65,6 +66,9 @@ export declare class Client extends EventEmitter {
|
|
|
65
66
|
private streamToken;
|
|
66
67
|
private resumeCursor;
|
|
67
68
|
private readonly topicCursors;
|
|
69
|
+
private readonly catchupFence;
|
|
70
|
+
private checkpointTimer;
|
|
71
|
+
private lastCheckpointedSequence;
|
|
68
72
|
private readonly seen;
|
|
69
73
|
private readonly seenSet;
|
|
70
74
|
private readonly channelByMessage;
|
|
@@ -175,16 +179,25 @@ export declare class Client extends EventEmitter {
|
|
|
175
179
|
private updateCaches;
|
|
176
180
|
/** Insert, evicting the oldest entry (by insertion order) once at capacity. */
|
|
177
181
|
private cacheSet;
|
|
178
|
-
private
|
|
179
|
-
private
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
private joinGateway;
|
|
183
|
+
private handleGatewayItems;
|
|
184
|
+
/**
|
|
185
|
+
* Coalesced checkpoint push. session.checkpoint shares the per-stream
|
|
186
|
+
* command budget with every other command; one push per events batch under
|
|
187
|
+
* load can starve it (rejections arrive without receive handlers, so the
|
|
188
|
+
* SDK would never notice). Debounced, and skipped when the cursor has not
|
|
189
|
+
* advanced past the last checkpoint. Best-effort: resume correctness rides
|
|
190
|
+
* the in-memory cursor, not the server-side checkpoint.
|
|
191
|
+
*/
|
|
192
|
+
private scheduleCheckpoint;
|
|
193
|
+
private clearCheckpointTimer;
|
|
194
|
+
private handleGatewayReset;
|
|
195
|
+
private pushGatewayCommand;
|
|
196
|
+
private forceGatewayReconnect;
|
|
197
|
+
/** Backfill per-topic gaps the resume buffer could not cover. */
|
|
184
198
|
private restCatchup;
|
|
185
|
-
private
|
|
186
|
-
private
|
|
187
|
-
private topicOf;
|
|
199
|
+
private handleBotCatchupEvent;
|
|
200
|
+
private handleCanonicalEvent;
|
|
188
201
|
private dispatchMessage;
|
|
189
202
|
private rememberChannel;
|
|
190
203
|
private safeEmit;
|
package/dist/Client.js
CHANGED
|
@@ -19,11 +19,35 @@ const Channel_1 = require("./structures/Channel");
|
|
|
19
19
|
const Role_1 = require("./structures/Role");
|
|
20
20
|
const intents_1 = require("./intents");
|
|
21
21
|
const errors_1 = require("./errors");
|
|
22
|
+
const version_1 = require("./version");
|
|
22
23
|
const RECONNECT_BASE_MS = 3000;
|
|
23
24
|
const RECONNECT_MAX_MS = 60000;
|
|
25
|
+
// A connection must survive this long after a successful handshake before a
|
|
26
|
+
// later failure resets the backoff ladder. Without it, a ready-then-fail loop
|
|
27
|
+
// (e.g. a recurring post-ready sequence gap) reconnects at the 3s floor
|
|
28
|
+
// forever because every successful handshake zeroed the attempt counter.
|
|
29
|
+
const RECONNECT_STABILITY_MS = 60000;
|
|
24
30
|
const DEDUPE_CACHE = 1000;
|
|
25
31
|
const RPC_TIMEOUT_MS = 10000;
|
|
32
|
+
const GATEWAY_READY_TIMEOUT_MS = 30000;
|
|
33
|
+
// Coalesce session.checkpoint pushes: they share the per-stream command
|
|
34
|
+
// budget with everything else, and one-per-batch under load can starve it.
|
|
35
|
+
const CHECKPOINT_DEBOUNCE_MS = 1000;
|
|
26
36
|
const STREAM_SEQ_RE = /^\d+-\d+$/;
|
|
37
|
+
// Compare two Redis stream positions ("ms-seq"). Returns <0, 0, >0.
|
|
38
|
+
function compareStreamPositions(a, b) {
|
|
39
|
+
const [aMs, aSeq] = a.split('-');
|
|
40
|
+
const [bMs, bSeq] = b.split('-');
|
|
41
|
+
if (aMs.length !== bMs.length)
|
|
42
|
+
return aMs.length - bMs.length;
|
|
43
|
+
if (aMs !== bMs)
|
|
44
|
+
return aMs < bMs ? -1 : 1;
|
|
45
|
+
if (aSeq.length !== bSeq.length)
|
|
46
|
+
return aSeq.length - bSeq.length;
|
|
47
|
+
if (aSeq !== bSeq)
|
|
48
|
+
return aSeq < bSeq ? -1 : 1;
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
27
51
|
// Bound the server/channel name caches so a bot in a huge number of servers
|
|
28
52
|
// can't grow them without limit — oldest entries evict first (insertion order).
|
|
29
53
|
const SERVER_CACHE_CAP = 10000;
|
|
@@ -44,13 +68,13 @@ class Client extends events_1.EventEmitter {
|
|
|
44
68
|
this.commands = new commands_1.CommandsManager(this);
|
|
45
69
|
/**
|
|
46
70
|
* Server name cache — hydrated from `GET /servers` on connect, kept fresh
|
|
47
|
-
* by `server
|
|
71
|
+
* by `server.meta_updated`/`server.deleted` (requires `Intents.Servers`).
|
|
48
72
|
* Capped at {@link SERVER_CACHE_CAP}, oldest entries evict first.
|
|
49
73
|
*/
|
|
50
74
|
this.servers = new Map();
|
|
51
75
|
/**
|
|
52
76
|
* Channel name cache — hydrated per-server in the background after connect,
|
|
53
|
-
* kept fresh by `channel
|
|
77
|
+
* kept fresh by `channel.created`/`channel.updated`/`channel.deleted`
|
|
54
78
|
* (requires `Intents.Servers`), and lazily filled on cache miss (see
|
|
55
79
|
* `ensureChannel`). Capped at {@link CHANNEL_CACHE_CAP}, oldest entries evict first.
|
|
56
80
|
*/
|
|
@@ -60,14 +84,24 @@ class Client extends events_1.EventEmitter {
|
|
|
60
84
|
this.token = null;
|
|
61
85
|
this.socket = null;
|
|
62
86
|
this.reconnectAttempts = 0;
|
|
87
|
+
// Set on successful handshake, consumed by scheduleReconnect: the backoff
|
|
88
|
+
// ladder only resets after RECONNECT_STABILITY_MS of post-handshake uptime.
|
|
89
|
+
this.lastHandshakeAt = 0;
|
|
63
90
|
this.reconnectPending = false;
|
|
64
91
|
this.destroyed = false;
|
|
65
92
|
this.connectedOnce = false;
|
|
66
93
|
this.readyAtMs = null;
|
|
67
94
|
// Resume state (survives reconnects).
|
|
68
95
|
this.streamToken = null;
|
|
69
|
-
this.resumeCursor = 0; // highest
|
|
70
|
-
this.topicCursors = new Map(); // topic -> last
|
|
96
|
+
this.resumeCursor = 0; // highest dense bot.v1 sequence applied on the current stream
|
|
97
|
+
this.topicCursors = new Map(); // topic -> last source position (for REST catchup)
|
|
98
|
+
// Catchup fence: after a REST catchup, heads[topic] marks how far the
|
|
99
|
+
// catchup already delivered. Live sequenced items at or below the fence are
|
|
100
|
+
// counted (sequence advances — density must hold) but not re-dispatched.
|
|
101
|
+
this.catchupFence = new Map();
|
|
102
|
+
// Coalesced checkpoint state.
|
|
103
|
+
this.checkpointTimer = null;
|
|
104
|
+
this.lastCheckpointedSequence = 0;
|
|
71
105
|
// Dedupe (at-least-once delivery + resume/catchup overlap).
|
|
72
106
|
this.seen = [];
|
|
73
107
|
this.seenSet = new Set();
|
|
@@ -133,6 +167,7 @@ class Client extends events_1.EventEmitter {
|
|
|
133
167
|
/** Disconnect and stop reconnecting. */
|
|
134
168
|
destroy() {
|
|
135
169
|
this.destroyed = true;
|
|
170
|
+
this.clearCheckpointTimer();
|
|
136
171
|
if (this.socket) {
|
|
137
172
|
try {
|
|
138
173
|
this.socket.disconnect();
|
|
@@ -284,6 +319,12 @@ class Client extends events_1.EventEmitter {
|
|
|
284
319
|
if (this.destroyed || this.reconnectPending)
|
|
285
320
|
return;
|
|
286
321
|
this.reconnectPending = true;
|
|
322
|
+
// Reset the backoff ladder only after a run of genuine stability, so a
|
|
323
|
+
// ready-then-fail loop grows its delay instead of hammering the 3s floor.
|
|
324
|
+
if (this.lastHandshakeAt && Date.now() - this.lastHandshakeAt >= RECONNECT_STABILITY_MS) {
|
|
325
|
+
this.reconnectAttempts = 0;
|
|
326
|
+
}
|
|
327
|
+
this.lastHandshakeAt = 0;
|
|
287
328
|
const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
|
|
288
329
|
this.reconnectAttempts += 1;
|
|
289
330
|
this.emit('connectionError', new errors_1.PrivageConnectionError(`${reason} — reconnecting in ${Math.round(delay / 1000)}s`));
|
|
@@ -305,7 +346,7 @@ class Client extends events_1.EventEmitter {
|
|
|
305
346
|
}
|
|
306
347
|
try {
|
|
307
348
|
await this.handshake();
|
|
308
|
-
this.
|
|
349
|
+
this.lastHandshakeAt = Date.now();
|
|
309
350
|
}
|
|
310
351
|
catch (err) {
|
|
311
352
|
if (this.isFatal(err)) {
|
|
@@ -345,27 +386,8 @@ class Client extends events_1.EventEmitter {
|
|
|
345
386
|
socket.connect();
|
|
346
387
|
});
|
|
347
388
|
this.debug('socket connected');
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
}
|
|
389
|
+
await this.joinGateway(socket);
|
|
390
|
+
this.debug(`gateway ready (profile: bot.v1, intents: ${this.intents.join(', ') || 'ALL'})`);
|
|
369
391
|
if (!this.connectedOnce) {
|
|
370
392
|
this.connectedOnce = true;
|
|
371
393
|
this.readyAtMs = Date.now();
|
|
@@ -486,8 +508,8 @@ class Client extends events_1.EventEmitter {
|
|
|
486
508
|
/** Cache-maintenance for structure events. Cache-only — never emits new SDK events. */
|
|
487
509
|
updateCaches(type, d) {
|
|
488
510
|
switch (type) {
|
|
489
|
-
case 'channel
|
|
490
|
-
case 'channel
|
|
511
|
+
case 'channel.created':
|
|
512
|
+
case 'channel.updated': {
|
|
491
513
|
if (d?.channel) {
|
|
492
514
|
const channel = new Channel_1.Channel(this, d.channel);
|
|
493
515
|
if (channel.id)
|
|
@@ -495,13 +517,13 @@ class Client extends events_1.EventEmitter {
|
|
|
495
517
|
}
|
|
496
518
|
break;
|
|
497
519
|
}
|
|
498
|
-
case 'channel
|
|
520
|
+
case 'channel.deleted': {
|
|
499
521
|
const channelId = d?.channelId != null ? String(d.channelId) : d?.channel_id != null ? String(d.channel_id) : null;
|
|
500
522
|
if (channelId)
|
|
501
523
|
this.channels.delete(channelId);
|
|
502
524
|
break;
|
|
503
525
|
}
|
|
504
|
-
case 'server
|
|
526
|
+
case 'server.meta_updated': {
|
|
505
527
|
// Sparse patch — icon/banner-only updates omit `name` etc. Merge over
|
|
506
528
|
// the cached raw row so untouched fields survive.
|
|
507
529
|
const serverId = d?.serverId != null ? String(d.serverId) : d?.server_id != null ? String(d.server_id) : null;
|
|
@@ -512,7 +534,7 @@ class Client extends events_1.EventEmitter {
|
|
|
512
534
|
}
|
|
513
535
|
break;
|
|
514
536
|
}
|
|
515
|
-
case 'server
|
|
537
|
+
case 'server.deleted': {
|
|
516
538
|
const serverId = d?.serverId != null ? String(d.serverId) : d?.server_id != null ? String(d.server_id) : null;
|
|
517
539
|
if (serverId) {
|
|
518
540
|
this.servers.delete(serverId);
|
|
@@ -536,78 +558,271 @@ class Client extends events_1.EventEmitter {
|
|
|
536
558
|
}
|
|
537
559
|
map.set(key, value);
|
|
538
560
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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,
|
|
561
|
+
async joinGateway(socket) {
|
|
562
|
+
const payload = {
|
|
563
|
+
protocol_version: 1,
|
|
564
|
+
event_profiles: ['bot.v1'],
|
|
556
565
|
intents: this.intents,
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
566
|
+
transport: { encoding: 'json', compression: 'none' },
|
|
567
|
+
client: { platform: 'bot', build: `privage.js/${version_1.SDK_VERSION}` },
|
|
568
|
+
stream: {
|
|
569
|
+
id: this.streamId,
|
|
570
|
+
...(this.streamToken
|
|
571
|
+
? { resume: { token: this.streamToken, last_applied_sequence: this.resumeCursor } }
|
|
572
|
+
: {}),
|
|
573
|
+
},
|
|
574
|
+
view: { revision: 0, contexts: [], foreground: true, focused: true },
|
|
575
|
+
cached_domains: {},
|
|
576
|
+
};
|
|
577
|
+
const channel = socket.channel('gateway', payload);
|
|
578
|
+
let settled = false;
|
|
579
|
+
let readyResolve;
|
|
580
|
+
let readyReject;
|
|
581
|
+
const ready = new Promise((resolve, reject) => {
|
|
582
|
+
readyResolve = () => { settled = true; resolve(); };
|
|
583
|
+
readyReject = (error) => { settled = true; reject(error); };
|
|
584
|
+
});
|
|
585
|
+
// A join denial can happen before this promise becomes the active await.
|
|
586
|
+
// Observe rejection immediately so an early session.reconnect/timeout
|
|
587
|
+
// cannot become an unhandled rejection while the join reply is pending.
|
|
588
|
+
void ready.catch(() => undefined);
|
|
589
|
+
const readyTimer = setTimeout(() => readyReject(new errors_1.PrivageConnectionError('gateway session.ready timeout')), GATEWAY_READY_TIMEOUT_MS);
|
|
590
|
+
channel.on('events', (batch) => this.handleGatewayItems(channel, batch, true));
|
|
591
|
+
channel.on('signals', (batch) => this.handleGatewayItems(channel, batch, false));
|
|
592
|
+
channel.on('session.reset', (reset) => {
|
|
593
|
+
void this.handleGatewayReset(channel, reset).catch((error) => {
|
|
594
|
+
const failure = error instanceof Error ? error : new errors_1.PrivageConnectionError(String(error));
|
|
595
|
+
if (!settled)
|
|
596
|
+
readyReject(failure);
|
|
597
|
+
this.forceGatewayReconnect(`reset failed (${failure.message})`);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
channel.on('session.ready', (value) => {
|
|
601
|
+
if (typeof value?.resume_token !== 'string' || !Number.isInteger(value?.sequence_base)) {
|
|
602
|
+
if (!settled)
|
|
603
|
+
readyReject(new errors_1.PrivageConnectionError('malformed session.ready'));
|
|
604
|
+
this.forceGatewayReconnect('malformed session.ready');
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
this.streamToken = value.resume_token;
|
|
608
|
+
// Unconditional: sequence_base is the stream tail at attach time (resume
|
|
609
|
+
// replay is enqueued strictly before session.ready, so an applied replay
|
|
610
|
+
// never exceeds it). Math.max here would keep a stale high cursor after a
|
|
611
|
+
// server-side reset rebased the stream (e.g. to 0), making every future
|
|
612
|
+
// item look "already seen" — the bot would go permanently deaf.
|
|
613
|
+
this.resumeCursor = value.sequence_base;
|
|
614
|
+
this.lastCheckpointedSequence = value.sequence_base;
|
|
615
|
+
clearTimeout(readyTimer);
|
|
616
|
+
if (!settled)
|
|
617
|
+
readyResolve();
|
|
618
|
+
});
|
|
619
|
+
channel.on('session.reconnect', (value) => {
|
|
620
|
+
const reason = typeof value?.reason === 'string' ? value.reason : 'server requested reconnect';
|
|
621
|
+
if (!settled)
|
|
622
|
+
readyReject(new errors_1.PrivageConnectionError(reason));
|
|
623
|
+
this.forceGatewayReconnect(reason);
|
|
624
|
+
});
|
|
625
|
+
channel.on('gateway.error', (value) => {
|
|
626
|
+
this.warn(`gateway error: ${value?.code || 'unknown'}${value?.message ? ` - ${value.message}` : ''}`);
|
|
563
627
|
});
|
|
628
|
+
// Disable phoenix.js's automatic channel rejoin. Its payload is frozen
|
|
629
|
+
// at construction time; this client must rebuild it from the latest
|
|
630
|
+
// resume token and dense sequence after every disconnect.
|
|
631
|
+
channel.rejoin = () => undefined;
|
|
632
|
+
try {
|
|
633
|
+
const reply = await new Promise((resolve, reject) => {
|
|
634
|
+
channel
|
|
635
|
+
.join()
|
|
636
|
+
.receive('ok', resolve)
|
|
637
|
+
.receive('error', (e) => reject(new errors_1.PrivageConnectionError(`join gateway denied: ${JSON.stringify(e)}`)))
|
|
638
|
+
.receive('timeout', () => reject(new errors_1.PrivageConnectionError('join gateway timeout')));
|
|
639
|
+
});
|
|
640
|
+
if (reply?.event_profile !== 'bot.v1' || reply?.stream_id !== this.streamId) {
|
|
641
|
+
throw new errors_1.PrivageConnectionError('gateway selected an incompatible stream/profile');
|
|
642
|
+
}
|
|
643
|
+
// session.ready may be queued before the join reply. Never overwrite its
|
|
644
|
+
// atomically-installed token with the earlier provisional join token.
|
|
645
|
+
if (!settled && typeof reply.resume_token === 'string') {
|
|
646
|
+
this.streamToken = reply.resume_token;
|
|
647
|
+
}
|
|
648
|
+
await ready;
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
// A denied/timed-out join never receives session.ready. Cancel its
|
|
652
|
+
// timer and close the local readiness latch as part of this attempt.
|
|
653
|
+
clearTimeout(readyTimer);
|
|
654
|
+
settled = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
handleGatewayItems(channel, batch, durable) {
|
|
658
|
+
const items = Array.isArray(batch?.items) ? batch.items : null;
|
|
659
|
+
if (!items) {
|
|
660
|
+
this.forceGatewayReconnect(`malformed ${durable ? 'events' : 'signals'} batch`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const cursorBefore = this.resumeCursor;
|
|
664
|
+
for (const item of items) {
|
|
665
|
+
if (!item ||
|
|
666
|
+
typeof item !== 'object' ||
|
|
667
|
+
typeof item.name !== 'string' ||
|
|
668
|
+
!item.name ||
|
|
669
|
+
!item.payload ||
|
|
670
|
+
typeof item.payload !== 'object' ||
|
|
671
|
+
Array.isArray(item.payload)) {
|
|
672
|
+
this.forceGatewayReconnect('malformed gateway item');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const source = item.source;
|
|
676
|
+
const partitionKey = typeof source?.partition_key === 'string' ? source.partition_key : null;
|
|
677
|
+
const position = typeof source?.position === 'string' && STREAM_SEQ_RE.test(source.position)
|
|
678
|
+
? source.position
|
|
679
|
+
: null;
|
|
680
|
+
if (durable) {
|
|
681
|
+
// schema_version rides only on durable event items — the wire protocol
|
|
682
|
+
// omits it on signal items ({name, scope, payload}), so enforcing it
|
|
683
|
+
// there would tear the session down on the first typing/interaction
|
|
684
|
+
// signal delivered.
|
|
685
|
+
const sequence = item.sequence;
|
|
686
|
+
if (!Number.isInteger(sequence) || sequence < 0
|
|
687
|
+
|| typeof item.event_id !== 'string' || !item.event_id
|
|
688
|
+
|| item.schema_version !== 1) {
|
|
689
|
+
this.forceGatewayReconnect('malformed gateway sequence');
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (sequence <= this.resumeCursor)
|
|
693
|
+
continue;
|
|
694
|
+
if (sequence !== this.resumeCursor + 1) {
|
|
695
|
+
this.forceGatewayReconnect(`gateway sequence gap: expected ${this.resumeCursor + 1}, got ${sequence}`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// Catchup and live projection identities are intentionally independent,
|
|
699
|
+
// so the source-position fence pairs their copies. An item at or below the
|
|
700
|
+
// catchup head for its topic was already dispatched by restCatchup —
|
|
701
|
+
// count it (sequence density must hold) but do not re-dispatch it.
|
|
702
|
+
if (partitionKey && position) {
|
|
703
|
+
const fence = this.catchupFence.get(partitionKey);
|
|
704
|
+
if (fence) {
|
|
705
|
+
if (compareStreamPositions(position, fence) <= 0) {
|
|
706
|
+
this.resumeCursor = sequence;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
// First item past the fence: the catchup window for this topic is
|
|
710
|
+
// exhausted; drop the entry so the map stays bounded.
|
|
711
|
+
this.catchupFence.delete(partitionKey);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const data = item.payload;
|
|
716
|
+
// Sequence ownership stays entirely in bot.v1. Advance before user
|
|
717
|
+
// handlers run so a throwing handler cannot create a replay gap.
|
|
718
|
+
if (durable)
|
|
719
|
+
this.resumeCursor = item.sequence;
|
|
720
|
+
this.handleCanonicalEvent({
|
|
721
|
+
name: item.name,
|
|
722
|
+
payload: data,
|
|
723
|
+
eventId: durable ? item.event_id : null,
|
|
724
|
+
});
|
|
725
|
+
if (durable && partitionKey && position) {
|
|
726
|
+
this.topicCursors.set(partitionKey, position);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (durable && this.resumeCursor > cursorBefore) {
|
|
730
|
+
this.scheduleCheckpoint(channel);
|
|
731
|
+
}
|
|
564
732
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
return
|
|
733
|
+
/**
|
|
734
|
+
* Coalesced checkpoint push. session.checkpoint shares the per-stream
|
|
735
|
+
* command budget with every other command; one push per events batch under
|
|
736
|
+
* load can starve it (rejections arrive without receive handlers, so the
|
|
737
|
+
* SDK would never notice). Debounced, and skipped when the cursor has not
|
|
738
|
+
* advanced past the last checkpoint. Best-effort: resume correctness rides
|
|
739
|
+
* the in-memory cursor, not the server-side checkpoint.
|
|
740
|
+
*/
|
|
741
|
+
scheduleCheckpoint(channel) {
|
|
742
|
+
if (this.checkpointTimer)
|
|
743
|
+
return;
|
|
744
|
+
this.checkpointTimer = setTimeout(() => {
|
|
745
|
+
this.checkpointTimer = null;
|
|
746
|
+
if (this.destroyed)
|
|
747
|
+
return;
|
|
748
|
+
if (this.resumeCursor <= this.lastCheckpointedSequence)
|
|
749
|
+
return;
|
|
750
|
+
this.lastCheckpointedSequence = this.resumeCursor;
|
|
751
|
+
try {
|
|
752
|
+
channel.push('session.checkpoint', {
|
|
753
|
+
last_applied_sequence: this.resumeCursor,
|
|
754
|
+
view_revision: 0,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
catch { /* channel torn down — the next session re-baselines */ }
|
|
758
|
+
}, CHECKPOINT_DEBOUNCE_MS);
|
|
759
|
+
}
|
|
760
|
+
clearCheckpointTimer() {
|
|
761
|
+
if (this.checkpointTimer) {
|
|
762
|
+
clearTimeout(this.checkpointTimer);
|
|
763
|
+
this.checkpointTimer = null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async handleGatewayReset(channel, reset) {
|
|
767
|
+
if (typeof reset?.sync_id !== 'string' || !Array.isArray(reset?.required_domains)) {
|
|
768
|
+
throw new errors_1.PrivageConnectionError('malformed session.reset');
|
|
769
|
+
}
|
|
770
|
+
if (reset.required_domains.length > 0 || reset.refetch_active_conversation === true) {
|
|
771
|
+
throw new errors_1.PrivageConnectionError('bot.v1 received human recovery domains');
|
|
772
|
+
}
|
|
773
|
+
await this.restCatchup();
|
|
774
|
+
await this.pushGatewayCommand(channel, 'sync.confirm', {
|
|
775
|
+
sync_id: reset.sync_id,
|
|
776
|
+
applied_domains: {},
|
|
777
|
+
active_conversation_refetched: false,
|
|
576
778
|
});
|
|
577
779
|
}
|
|
578
|
-
|
|
579
|
-
rpc(personal, type, d) {
|
|
780
|
+
pushGatewayCommand(channel, event, payload) {
|
|
580
781
|
return new Promise((resolve, reject) => {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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 });
|
|
782
|
+
channel
|
|
783
|
+
.push(event, payload, RPC_TIMEOUT_MS)
|
|
784
|
+
.receive('ok', resolve)
|
|
785
|
+
.receive('error', (error) => reject(new errors_1.PrivageConnectionError(`${event} rejected: ${JSON.stringify(error)}`)))
|
|
786
|
+
.receive('timeout', () => reject(new errors_1.PrivageConnectionError(`${event} timeout`)));
|
|
594
787
|
});
|
|
595
788
|
}
|
|
596
|
-
|
|
789
|
+
forceGatewayReconnect(reason) {
|
|
790
|
+
if (this.destroyed)
|
|
791
|
+
return;
|
|
792
|
+
this.clearCheckpointTimer();
|
|
793
|
+
const socket = this.socket;
|
|
794
|
+
this.scheduleReconnect(reason);
|
|
795
|
+
if (socket) {
|
|
796
|
+
try {
|
|
797
|
+
socket.disconnect();
|
|
798
|
+
}
|
|
799
|
+
catch { /* ignore */ }
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** Backfill per-topic gaps the resume buffer could not cover. */
|
|
597
803
|
async restCatchup() {
|
|
598
804
|
if (this.topicCursors.size === 0)
|
|
599
805
|
return;
|
|
600
806
|
try {
|
|
601
807
|
const resp = await this.rest.request('/gateway/catchup', {
|
|
602
808
|
method: 'POST',
|
|
603
|
-
body: {
|
|
809
|
+
body: {
|
|
810
|
+
event_profile: 'bot.v1',
|
|
811
|
+
intents: this.intents,
|
|
812
|
+
topics: Object.fromEntries(this.topicCursors),
|
|
813
|
+
},
|
|
604
814
|
});
|
|
815
|
+
if (resp?.event_profile !== 'bot.v1') {
|
|
816
|
+
throw new errors_1.PrivageConnectionError('catchup selected an incompatible event profile');
|
|
817
|
+
}
|
|
605
818
|
let replayed = 0;
|
|
606
819
|
let stale = 0;
|
|
607
|
-
|
|
820
|
+
const deliveredTopics = new Set();
|
|
821
|
+
for (const [topic, res] of Object.entries(resp?.topics ?? {})) {
|
|
608
822
|
if (Array.isArray(res?.events)) {
|
|
609
|
-
|
|
610
|
-
|
|
823
|
+
deliveredTopics.add(topic);
|
|
824
|
+
for (const event of res.events) {
|
|
825
|
+
this.handleBotCatchupEvent(topic, event);
|
|
611
826
|
replayed += 1;
|
|
612
827
|
}
|
|
613
828
|
}
|
|
@@ -616,8 +831,14 @@ class Client extends events_1.EventEmitter {
|
|
|
616
831
|
}
|
|
617
832
|
}
|
|
618
833
|
for (const [topic, head] of Object.entries(resp?.heads ?? {})) {
|
|
619
|
-
if (typeof head
|
|
620
|
-
|
|
834
|
+
if (typeof head !== 'string' || !STREAM_SEQ_RE.test(head))
|
|
835
|
+
continue;
|
|
836
|
+
this.topicCursors.set(topic, head);
|
|
837
|
+
// Fence live re-delivery ONLY where catchup actually delivered the
|
|
838
|
+
// window. A resync/too_many topic's retained live events are the only
|
|
839
|
+
// copy the bot will ever see — fencing them would silently drop them.
|
|
840
|
+
if (deliveredTopics.has(topic))
|
|
841
|
+
this.catchupFence.set(topic, head);
|
|
621
842
|
}
|
|
622
843
|
this.debug(`catchup replayed ${replayed} event(s)`);
|
|
623
844
|
if (stale) {
|
|
@@ -625,20 +846,35 @@ class Client extends events_1.EventEmitter {
|
|
|
625
846
|
}
|
|
626
847
|
}
|
|
627
848
|
catch (e) {
|
|
628
|
-
|
|
849
|
+
throw e instanceof Error ? e : new errors_1.PrivageConnectionError(String(e));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
handleBotCatchupEvent(topic, event) {
|
|
853
|
+
const source = event?.source;
|
|
854
|
+
if (typeof event?.name !== 'string' ||
|
|
855
|
+
typeof event?.event_id !== 'string' ||
|
|
856
|
+
event?.schema_version !== 1 ||
|
|
857
|
+
!event?.payload ||
|
|
858
|
+
typeof event.payload !== 'object' ||
|
|
859
|
+
source?.partition_key !== topic ||
|
|
860
|
+
typeof source?.position !== 'string' ||
|
|
861
|
+
!STREAM_SEQ_RE.test(source.position)) {
|
|
862
|
+
throw new errors_1.PrivageConnectionError('malformed bot.v1 catchup event');
|
|
629
863
|
}
|
|
864
|
+
this.handleCanonicalEvent({
|
|
865
|
+
name: event.name,
|
|
866
|
+
payload: event.payload,
|
|
867
|
+
eventId: event.event_id,
|
|
868
|
+
});
|
|
630
869
|
}
|
|
631
870
|
// ── event routing ─────────────────────────────────────────────────────
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
const type = envelope?.t || envelope?.d?.type || 'unknown';
|
|
641
|
-
const d = envelope?.d && typeof envelope.d === 'object' ? envelope.d : envelope;
|
|
871
|
+
handleCanonicalEvent(event) {
|
|
872
|
+
if (event.eventId) {
|
|
873
|
+
if (this.seenSet.has(event.eventId))
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const type = event.name;
|
|
877
|
+
const d = event.payload;
|
|
642
878
|
// Cache maintenance must never block event dispatch — a bad payload here
|
|
643
879
|
// shouldn't drop the message/reaction/member routing below.
|
|
644
880
|
try {
|
|
@@ -648,38 +884,31 @@ class Client extends events_1.EventEmitter {
|
|
|
648
884
|
this.debug(`cache update failed for ${type}: ${e?.message || e}`);
|
|
649
885
|
}
|
|
650
886
|
switch (type) {
|
|
651
|
-
case '
|
|
652
|
-
case '
|
|
653
|
-
case 'thread-message':
|
|
887
|
+
case 'message.created':
|
|
888
|
+
case 'thread.message_created':
|
|
654
889
|
this.dispatchMessage('messageCreate', d);
|
|
655
890
|
break;
|
|
656
|
-
case '
|
|
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':
|
|
891
|
+
case 'message.edited':
|
|
663
892
|
this.dispatchMessage('messageUpdate', d);
|
|
664
893
|
break;
|
|
665
|
-
case 'interaction':
|
|
894
|
+
case 'interaction.created':
|
|
666
895
|
this.safeEmit('interaction', new Interaction_1.Interaction(this, d));
|
|
667
896
|
break;
|
|
668
|
-
case 'message
|
|
897
|
+
case 'message.deleted':
|
|
669
898
|
this.safeEmit('messageDelete', {
|
|
670
899
|
id: String(d.id ?? d.message_id ?? ''),
|
|
671
900
|
channelId: String(d.channel_id ?? ''),
|
|
672
901
|
serverId: d.server_id != null ? String(d.server_id) : null,
|
|
673
902
|
});
|
|
674
903
|
break;
|
|
675
|
-
case '
|
|
904
|
+
case 'message.bulk_deleted':
|
|
676
905
|
this.safeEmit('messageDeleteBulk', {
|
|
677
906
|
ids: (d.ids || d.message_ids || d.messageIds || []).map(String),
|
|
678
907
|
channelId: String(d.channel_id ?? ''),
|
|
679
908
|
serverId: d.server_id != null ? String(d.server_id) : null,
|
|
680
909
|
});
|
|
681
910
|
break;
|
|
682
|
-
case 'reaction
|
|
911
|
+
case 'reaction.updated': {
|
|
683
912
|
const messageId = String(d.messageId ?? d.message_id ?? '');
|
|
684
913
|
const channelId = d.channel_id != null ? String(d.channel_id)
|
|
685
914
|
: d.channelId != null ? String(d.channelId)
|
|
@@ -696,27 +925,37 @@ class Client extends events_1.EventEmitter {
|
|
|
696
925
|
}
|
|
697
926
|
break;
|
|
698
927
|
}
|
|
699
|
-
case 'member
|
|
928
|
+
case 'member.joined':
|
|
700
929
|
this.safeEmit('memberJoin', new Member_1.Member(this, d));
|
|
701
930
|
break;
|
|
702
|
-
case 'member
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
931
|
+
case 'member.removed':
|
|
932
|
+
if (d.reason === 'banned') {
|
|
933
|
+
this.safeEmit('memberBan', {
|
|
934
|
+
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
935
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
936
|
+
username: d.username ?? '',
|
|
937
|
+
displayName: d.displayName ?? d.display_name ?? '',
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
this.safeEmit('memberLeave', {
|
|
942
|
+
userId: String(d.userId ?? d.user_id ?? ''),
|
|
943
|
+
serverId: d.serverId != null ? String(d.serverId) : (d.server_id != null ? String(d.server_id) : null),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
707
946
|
break;
|
|
708
|
-
case 'member
|
|
709
|
-
case 'member
|
|
947
|
+
case 'member.role_added':
|
|
948
|
+
case 'member.role_removed': {
|
|
710
949
|
const change = {
|
|
711
950
|
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
712
951
|
userId: String(d.userId ?? d.user_id ?? ''),
|
|
713
952
|
roleId: String(d.roleId ?? d.role_id ?? ''),
|
|
714
953
|
};
|
|
715
|
-
this.safeEmit(type === 'member
|
|
954
|
+
this.safeEmit(type === 'member.role_added' ? 'roleAdd' : 'roleRemove', change);
|
|
716
955
|
break;
|
|
717
956
|
}
|
|
718
|
-
case 'member
|
|
719
|
-
case '
|
|
957
|
+
case 'member.updated':
|
|
958
|
+
case 'member.nickname_updated': {
|
|
720
959
|
const update = {
|
|
721
960
|
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
722
961
|
userId: String(d.userId ?? d.user_id ?? ''),
|
|
@@ -730,16 +969,15 @@ class Client extends events_1.EventEmitter {
|
|
|
730
969
|
this.safeEmit('memberUpdate', update);
|
|
731
970
|
break;
|
|
732
971
|
}
|
|
733
|
-
case 'member
|
|
734
|
-
|
|
735
|
-
this.safeEmit(type === 'member-banned' ? 'memberBan' : 'memberUnban', {
|
|
972
|
+
case 'member.unbanned':
|
|
973
|
+
this.safeEmit('memberUnban', {
|
|
736
974
|
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
737
975
|
userId: String(d.userId ?? d.user_id ?? ''),
|
|
738
976
|
username: d.username ?? '',
|
|
739
977
|
displayName: d.displayName ?? d.display_name ?? '',
|
|
740
978
|
});
|
|
741
979
|
break;
|
|
742
|
-
case 'member
|
|
980
|
+
case 'member.timed_out': {
|
|
743
981
|
const until = d.timeoutUntil ?? d.timeout_until;
|
|
744
982
|
this.safeEmit('memberTimeout', {
|
|
745
983
|
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
@@ -750,60 +988,54 @@ class Client extends events_1.EventEmitter {
|
|
|
750
988
|
});
|
|
751
989
|
break;
|
|
752
990
|
}
|
|
753
|
-
case 'member
|
|
991
|
+
case 'member.timeout_cleared':
|
|
754
992
|
this.safeEmit('memberUntimeout', {
|
|
755
993
|
serverId: String(d.serverId ?? d.server_id ?? ''),
|
|
756
994
|
userId: String(d.userId ?? d.user_id ?? ''),
|
|
757
995
|
});
|
|
758
996
|
break;
|
|
759
|
-
case 'message
|
|
760
|
-
case 'message
|
|
761
|
-
this.safeEmit(type === 'message
|
|
997
|
+
case 'message.pinned':
|
|
998
|
+
case 'message.unpinned':
|
|
999
|
+
this.safeEmit(type === 'message.pinned' ? 'messagePin' : 'messageUnpin', {
|
|
762
1000
|
messageId: String(d.messageId ?? d.message_id ?? ''),
|
|
763
1001
|
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
764
1002
|
});
|
|
765
1003
|
break;
|
|
766
|
-
case 'typing
|
|
767
|
-
this.safeEmit('typingStart', {
|
|
1004
|
+
case 'typing.changed':
|
|
1005
|
+
this.safeEmit(d.active === true ? 'typingStart' : 'typingStop', {
|
|
768
1006
|
userId: String(d.user?.id ?? d.user?.user_id ?? d.userId ?? d.user_id ?? ''),
|
|
769
1007
|
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
770
1008
|
});
|
|
771
1009
|
break;
|
|
772
|
-
case '
|
|
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':
|
|
1010
|
+
case 'channel.created':
|
|
779
1011
|
if (d?.channel)
|
|
780
1012
|
this.safeEmit('channelCreate', new Channel_1.Channel(this, d.channel));
|
|
781
1013
|
break;
|
|
782
|
-
case 'channel
|
|
1014
|
+
case 'channel.updated':
|
|
783
1015
|
if (d?.channel)
|
|
784
1016
|
this.safeEmit('channelUpdate', new Channel_1.Channel(this, d.channel));
|
|
785
1017
|
break;
|
|
786
|
-
case 'channel
|
|
1018
|
+
case 'channel.deleted':
|
|
787
1019
|
this.safeEmit('channelDelete', {
|
|
788
1020
|
channelId: String(d.channelId ?? d.channel_id ?? ''),
|
|
789
1021
|
serverId: d.serverId != null ? String(d.serverId) : (d.server_id != null ? String(d.server_id) : null),
|
|
790
1022
|
});
|
|
791
1023
|
break;
|
|
792
|
-
case 'role
|
|
1024
|
+
case 'role.created':
|
|
793
1025
|
if (d?.role)
|
|
794
1026
|
this.safeEmit('roleCreate', new Role_1.Role(d.role));
|
|
795
1027
|
break;
|
|
796
|
-
case 'role
|
|
1028
|
+
case 'role.updated':
|
|
797
1029
|
if (d?.role)
|
|
798
1030
|
this.safeEmit('roleUpdate', new Role_1.Role(d.role));
|
|
799
1031
|
break;
|
|
800
|
-
case 'role
|
|
1032
|
+
case 'role.deleted':
|
|
801
1033
|
this.safeEmit('roleDelete', {
|
|
802
1034
|
roleId: String(d.roleId ?? d.role_id ?? ''),
|
|
803
1035
|
serverId: d.serverId != null ? String(d.serverId) : (d.server_id != null ? String(d.server_id) : null),
|
|
804
1036
|
});
|
|
805
1037
|
break;
|
|
806
|
-
case 'poll
|
|
1038
|
+
case 'poll.voted':
|
|
807
1039
|
this.safeEmit('pollVote', {
|
|
808
1040
|
pollId: String(d.pollId ?? ''),
|
|
809
1041
|
messageId: String(d.messageId ?? ''),
|
|
@@ -812,7 +1044,7 @@ class Client extends events_1.EventEmitter {
|
|
|
812
1044
|
totalVotes: typeof d.totalVotes === 'number' ? d.totalVotes : Number(d.totalVotes ?? 0),
|
|
813
1045
|
});
|
|
814
1046
|
break;
|
|
815
|
-
case 'poll
|
|
1047
|
+
case 'poll.closed':
|
|
816
1048
|
this.safeEmit('pollClose', {
|
|
817
1049
|
pollId: String(d.pollId ?? ''),
|
|
818
1050
|
messageId: String(d.messageId ?? ''),
|
|
@@ -820,29 +1052,12 @@ class Client extends events_1.EventEmitter {
|
|
|
820
1052
|
});
|
|
821
1053
|
break;
|
|
822
1054
|
default:
|
|
823
|
-
this.debug(`unhandled event: ${type}`);
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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;
|
|
1055
|
+
this.debug(`unhandled bot.v1 event: ${type}`);
|
|
1056
|
+
}
|
|
1057
|
+
// Record only after internal construction/routing succeeds. If a malformed
|
|
1058
|
+
// payload throws before dispatch, reset/catchup can retry the same fact.
|
|
1059
|
+
if (event.eventId)
|
|
1060
|
+
this.remember(event.eventId);
|
|
846
1061
|
}
|
|
847
1062
|
dispatchMessage(event, d) {
|
|
848
1063
|
let msg;
|
|
@@ -891,9 +1106,9 @@ class Client extends events_1.EventEmitter {
|
|
|
891
1106
|
this.warn(`failed to dispatch ${String(event)}: ${e?.message || e}`);
|
|
892
1107
|
}
|
|
893
1108
|
}
|
|
894
|
-
remember(
|
|
895
|
-
this.seenSet.add(
|
|
896
|
-
this.seen.push(
|
|
1109
|
+
remember(eventId) {
|
|
1110
|
+
this.seenSet.add(eventId);
|
|
1111
|
+
this.seen.push(eventId);
|
|
897
1112
|
if (this.seen.length > DEDUPE_CACHE) {
|
|
898
1113
|
const oldest = this.seen.shift();
|
|
899
1114
|
if (oldest)
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { Client } from './Client';
|
|
|
2
2
|
export { Intents } from './intents';
|
|
3
3
|
export type { Intent } from './intents';
|
|
4
4
|
export { Message, TextChannel } from './structures/Message';
|
|
5
|
-
export type { MessageThread, MessageReference, ReferencedMessage, MessageAttachment, MessageEmbed, } from './structures/Message';
|
|
5
|
+
export type { MessageThread, MessageReference, ReferencedMessage, MessageAttachment, MessageEmbed, MessageInteraction, } from './structures/Message';
|
|
6
6
|
export { Member } from './structures/Member';
|
|
7
7
|
export { Server } from './structures/Server';
|
|
8
8
|
export { Channel } from './structures/Channel';
|
package/dist/intents.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public, curated intents. The wire gateway
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Public, curated bot intents. The wire gateway accepts a broader vocabulary
|
|
3
|
+
* for shared human/legacy surfaces; this SDK exposes only categories with a
|
|
4
|
+
* bot.v1 product-event contract. Each maps to a wire intent string.
|
|
5
5
|
*
|
|
6
6
|
* IMPORTANT: an empty intent list means the gateway delivers ALL events
|
|
7
7
|
* (firehose). The SDK always sends the explicit resolved list — declare what
|
package/dist/intents.js
CHANGED
|
@@ -3,9 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Intents = void 0;
|
|
4
4
|
exports.resolveIntents = resolveIntents;
|
|
5
5
|
/**
|
|
6
|
-
* Public, curated intents. The wire gateway
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Public, curated bot intents. The wire gateway accepts a broader vocabulary
|
|
7
|
+
* for shared human/legacy surfaces; this SDK exposes only categories with a
|
|
8
|
+
* bot.v1 product-event contract. Each maps to a wire intent string.
|
|
9
9
|
*
|
|
10
10
|
* IMPORTANT: an empty intent list means the gateway delivers ALL events
|
|
11
11
|
* (firehose). The SDK always sends the explicit resolved list — declare what
|
|
@@ -3,8 +3,8 @@ import type { SendMessageOptions } from '../rest';
|
|
|
3
3
|
type Sendable = string | Omit<SendMessageOptions, 'replyTo'>;
|
|
4
4
|
/**
|
|
5
5
|
* A channel the bot can see. `name`/`type`/`categoryId` come from the SDK's
|
|
6
|
-
* name cache — hydrated on connect, kept fresh via `channel
|
|
7
|
-
* `channel
|
|
6
|
+
* name cache — hydrated on connect, kept fresh via `channel.created`/
|
|
7
|
+
* `channel.updated`/`channel.deleted` events (`Intents.Servers`), and
|
|
8
8
|
* lazily filled on cache miss (see `Client#ensureChannel`). A channel handed
|
|
9
9
|
* out before it's resolved has `name: null`; it still works for `send`.
|
|
10
10
|
*/
|
|
@@ -3,8 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.TextChannel = exports.Channel = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* A channel the bot can see. `name`/`type`/`categoryId` come from the SDK's
|
|
6
|
-
* name cache — hydrated on connect, kept fresh via `channel
|
|
7
|
-
* `channel
|
|
6
|
+
* name cache — hydrated on connect, kept fresh via `channel.created`/
|
|
7
|
+
* `channel.updated`/`channel.deleted` events (`Intents.Servers`), and
|
|
8
8
|
* lazily filled on cache miss (see `Client#ensureChannel`). A channel handed
|
|
9
9
|
* out before it's resolved has `name: null`; it still works for `send`.
|
|
10
10
|
*/
|
|
@@ -20,7 +20,12 @@ export interface InteractionReplyData {
|
|
|
20
20
|
* persisted, so there is nowhere for the bytes to live).
|
|
21
21
|
*/
|
|
22
22
|
ephemeral?: boolean;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Components on the reply — ephemeral replies only. They are fully
|
|
25
|
+
* interactive: clicks arrive as fresh `interaction` events carrying
|
|
26
|
+
* `ephemeralMessageId`, recipient-locked to the user the ephemeral was
|
|
27
|
+
* delivered to, for 15 minutes after sending.
|
|
28
|
+
*/
|
|
24
29
|
components?: (ActionRow | ActionRowBuilder | Record<string, unknown>)[];
|
|
25
30
|
/**
|
|
26
31
|
* File attachments on the reply — `AttachmentBuilder`s, paths, raw bytes,
|
|
@@ -85,8 +90,18 @@ export declare class Interaction {
|
|
|
85
90
|
readonly command: InteractionCommand | null;
|
|
86
91
|
/** Submitted modal fields — `kind: 'modal_submit'` only, `null` otherwise. */
|
|
87
92
|
readonly fields: ModalSubmitField[] | null;
|
|
88
|
-
/** The source message. `null` for commands
|
|
93
|
+
/** The source message. `null` for commands, command-born modal submits, and ephemeral-born interactions — `update()` is invalid then. */
|
|
89
94
|
readonly messageId: string | null;
|
|
95
|
+
/**
|
|
96
|
+
* Set when this interaction came from a component on an **ephemeral
|
|
97
|
+
* reply** — the synthetic ephemeral message's id. Recipient-locked:
|
|
98
|
+
* only the ephemeral's recipient can click, within the panel's
|
|
99
|
+
* 15-minute window (every click also re-checks membership/timeout).
|
|
100
|
+
* Respond with `update()` to morph the panel in place (the wizard
|
|
101
|
+
* pattern — updating refreshes the window), or `reply()` to post a
|
|
102
|
+
* new message.
|
|
103
|
+
*/
|
|
104
|
+
readonly ephemeralMessageId: string | null;
|
|
90
105
|
readonly channelId: string;
|
|
91
106
|
readonly serverId: string | null;
|
|
92
107
|
readonly user: {
|
|
@@ -115,10 +130,15 @@ export declare class Interaction {
|
|
|
115
130
|
/** `kind === 'modal_submit'`. */
|
|
116
131
|
isModalSubmit(): boolean;
|
|
117
132
|
/**
|
|
118
|
-
* Terminal: edit the
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
133
|
+
* Terminal: edit the message this interaction came from
|
|
134
|
+
* (content/embeds/components). On a persisted message that's a normal
|
|
135
|
+
* edit broadcast. On an interaction born from an **ephemeral reply**
|
|
136
|
+
* (`ephemeralMessageId` set) it morphs the ephemeral panel in place —
|
|
137
|
+
* patch semantics (only the fields you send change), refreshing the
|
|
138
|
+
* panel's 15-minute window; clearing `components: []` ends the panel,
|
|
139
|
+
* and an expired panel rejects with a 404. Throws client-side for
|
|
140
|
+
* `kind: 'command'` and command-born modal submits (no source at all —
|
|
141
|
+
* the server would 400).
|
|
122
142
|
*/
|
|
123
143
|
update(data: InteractionUpdateData): Promise<any>;
|
|
124
144
|
/**
|
|
@@ -140,7 +160,12 @@ export declare class Interaction {
|
|
|
140
160
|
* deferred reply and replying are the same terminal callback.
|
|
141
161
|
*/
|
|
142
162
|
editReply(data: string | InteractionReplyData): Promise<any>;
|
|
143
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Extend the response window to 15 minutes; must still be followed by
|
|
165
|
+
* `update()` or `reply()`. The invoker's client is told the bot needs
|
|
166
|
+
* more time, so a command's "thinking" row waits patiently instead of
|
|
167
|
+
* timing out at 15 seconds.
|
|
168
|
+
*/
|
|
144
169
|
defer(): Promise<any>;
|
|
145
170
|
/**
|
|
146
171
|
* Open a modal dialog for the interacting user; their submission arrives
|
|
@@ -49,6 +49,7 @@ class Interaction {
|
|
|
49
49
|
? data.modal.fields.map((f) => ({ customId: String(f?.custom_id ?? ''), value: String(f?.value ?? '') }))
|
|
50
50
|
: null;
|
|
51
51
|
this.messageId = data.message_id != null ? String(data.message_id) : null;
|
|
52
|
+
this.ephemeralMessageId = data.ephemeral_message_id != null ? String(data.ephemeral_message_id) : null;
|
|
52
53
|
this.channelId = String(data.channel_id ?? '');
|
|
53
54
|
this.serverId = data.server_id != null ? String(data.server_id) : null;
|
|
54
55
|
this.user = {
|
|
@@ -87,17 +88,22 @@ class Interaction {
|
|
|
87
88
|
return this.kind === 'modal_submit';
|
|
88
89
|
}
|
|
89
90
|
/**
|
|
90
|
-
* Terminal: edit the
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* Terminal: edit the message this interaction came from
|
|
92
|
+
* (content/embeds/components). On a persisted message that's a normal
|
|
93
|
+
* edit broadcast. On an interaction born from an **ephemeral reply**
|
|
94
|
+
* (`ephemeralMessageId` set) it morphs the ephemeral panel in place —
|
|
95
|
+
* patch semantics (only the fields you send change), refreshing the
|
|
96
|
+
* panel's 15-minute window; clearing `components: []` ends the panel,
|
|
97
|
+
* and an expired panel rejects with a 404. Throws client-side for
|
|
98
|
+
* `kind: 'command'` and command-born modal submits (no source at all —
|
|
99
|
+
* the server would 400).
|
|
94
100
|
*/
|
|
95
101
|
update(data) {
|
|
96
102
|
if (this.kind === 'command') {
|
|
97
103
|
throw new RangeError('update() is not valid for command interactions — there is no source message. Use reply() instead.');
|
|
98
104
|
}
|
|
99
|
-
if (this.
|
|
100
|
-
throw new RangeError('update() is not valid for this
|
|
105
|
+
if (!this.messageId && !this.ephemeralMessageId) {
|
|
106
|
+
throw new RangeError('update() is not valid for this interaction — it has no source message. Use reply() instead.');
|
|
101
107
|
}
|
|
102
108
|
return this.transition('responded', () => this.client.rest.interactionCallback(this.id, this.token, 'update', data));
|
|
103
109
|
}
|
|
@@ -126,7 +132,12 @@ class Interaction {
|
|
|
126
132
|
editReply(data) {
|
|
127
133
|
return this.reply(data);
|
|
128
134
|
}
|
|
129
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Extend the response window to 15 minutes; must still be followed by
|
|
137
|
+
* `update()` or `reply()`. The invoker's client is told the bot needs
|
|
138
|
+
* more time, so a command's "thinking" row waits patiently instead of
|
|
139
|
+
* timing out at 15 seconds.
|
|
140
|
+
*/
|
|
130
141
|
defer() {
|
|
131
142
|
if (this.state !== 'pending') {
|
|
132
143
|
return this.client.rest.interactionCallback(this.id, this.token, 'defer');
|
|
@@ -76,6 +76,25 @@ export interface MessageEmbed {
|
|
|
76
76
|
timestamp: string | null;
|
|
77
77
|
createdAt: Date | null;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Server-verified attribution on an interaction-born bot reply — who
|
|
81
|
+
* invoked the command/component that produced this message ("X used
|
|
82
|
+
* /price"). `user` is a frozen snapshot from interaction time; later
|
|
83
|
+
* renames never rewrite it. Ordinary messages carry `interaction: null`.
|
|
84
|
+
*/
|
|
85
|
+
export interface MessageInteraction {
|
|
86
|
+
kind: 'command' | 'button' | 'select' | 'modal_submit';
|
|
87
|
+
/** Command name — `kind: 'command'` only, `null` otherwise. */
|
|
88
|
+
name: string | null;
|
|
89
|
+
/** Component/modal custom_id — component-born interactions only, `null` otherwise. */
|
|
90
|
+
customId: string | null;
|
|
91
|
+
/** The invoking user, snapshotted at interaction time. */
|
|
92
|
+
user: {
|
|
93
|
+
id: string;
|
|
94
|
+
username: string | null;
|
|
95
|
+
displayName: string | null;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
79
98
|
/** A frozen or live preview of the message a reference points at. */
|
|
80
99
|
export interface ReferencedMessage {
|
|
81
100
|
id: string;
|
|
@@ -144,6 +163,8 @@ export declare class Message {
|
|
|
144
163
|
readonly reference: MessageReference | null;
|
|
145
164
|
/** Thread badge — `null` unless this message has (or had) a thread. */
|
|
146
165
|
readonly thread: MessageThread | null;
|
|
166
|
+
/** Attribution for interaction-born bot replies, or `null` (see {@link MessageInteraction}). */
|
|
167
|
+
readonly interaction: MessageInteraction | null;
|
|
147
168
|
/** The raw wire payload, for anything the typed surface doesn't expose yet. */
|
|
148
169
|
readonly raw: any;
|
|
149
170
|
constructor(client: Client, data: any);
|
|
@@ -123,6 +123,22 @@ class Message {
|
|
|
123
123
|
deletedAt: t.deleted_at ? new Date(t.deleted_at) : null,
|
|
124
124
|
}
|
|
125
125
|
: null;
|
|
126
|
+
const ix = data.interaction;
|
|
127
|
+
this.interaction =
|
|
128
|
+
ix && typeof ix === 'object'
|
|
129
|
+
? {
|
|
130
|
+
kind: ix.kind === 'select' || ix.kind === 'command' || ix.kind === 'modal_submit'
|
|
131
|
+
? ix.kind
|
|
132
|
+
: 'button',
|
|
133
|
+
name: ix.name != null ? String(ix.name) : null,
|
|
134
|
+
customId: ix.custom_id != null ? String(ix.custom_id) : null,
|
|
135
|
+
user: {
|
|
136
|
+
id: String(ix.user?.id ?? ''),
|
|
137
|
+
username: ix.user?.username ?? null,
|
|
138
|
+
displayName: ix.user?.display_name ?? null,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: null;
|
|
126
142
|
}
|
|
127
143
|
/**
|
|
128
144
|
* Compat alias (discord.js-style): the id of the message this one replies
|
|
@@ -5,7 +5,7 @@ import type { FetchMembersOptions } from '../rest';
|
|
|
5
5
|
import { ServerChannels } from './ServerChannels';
|
|
6
6
|
/**
|
|
7
7
|
* A server (guild) the bot belongs to. Populated from `GET /servers` on
|
|
8
|
-
* connect and kept fresh by `server
|
|
8
|
+
* connect and kept fresh by `server.meta_updated`/`server.deleted` events
|
|
9
9
|
* (requires `Intents.Servers`) — see `Client`'s server/channel name cache.
|
|
10
10
|
*/
|
|
11
11
|
export declare class Server {
|
|
@@ -4,7 +4,7 @@ exports.Server = void 0;
|
|
|
4
4
|
const ServerChannels_1 = require("./ServerChannels");
|
|
5
5
|
/**
|
|
6
6
|
* A server (guild) the bot belongs to. Populated from `GET /servers` on
|
|
7
|
-
* connect and kept fresh by `server
|
|
7
|
+
* connect and kept fresh by `server.meta_updated`/`server.deleted` events
|
|
8
8
|
* (requires `Intents.Servers`) — see `Client`'s server/channel name cache.
|
|
9
9
|
*/
|
|
10
10
|
class Server {
|
|
@@ -4,7 +4,7 @@ import type { Channel } from './Channel';
|
|
|
4
4
|
* A lightweight, read-only view over the SDK's channel name cache
|
|
5
5
|
* (`Client#channels`), scoped to a single server. Backed entirely by
|
|
6
6
|
* whatever's currently cached — hydrated on connect and kept fresh via
|
|
7
|
-
* `channel
|
|
7
|
+
* `channel.created`/`channel.updated`/`channel.deleted` events (see
|
|
8
8
|
* `Client#hydrateChannels`/`ensureChannel`) — **not** an authoritative,
|
|
9
9
|
* always-up-to-date fetch. A brand-new channel may be briefly absent until
|
|
10
10
|
* the cache catches up.
|
|
@@ -5,7 +5,7 @@ exports.ServerChannels = void 0;
|
|
|
5
5
|
* A lightweight, read-only view over the SDK's channel name cache
|
|
6
6
|
* (`Client#channels`), scoped to a single server. Backed entirely by
|
|
7
7
|
* whatever's currently cached — hydrated on connect and kept fresh via
|
|
8
|
-
* `channel
|
|
8
|
+
* `channel.created`/`channel.updated`/`channel.deleted` events (see
|
|
9
9
|
* `Client#hydrateChannels`/`ensureChannel`) — **not** an authoritative,
|
|
10
10
|
* always-up-to-date fetch. A brand-new channel may be briefly absent until
|
|
11
11
|
* the cache catches up.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SDK_VERSION = "0.26.0";
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SDK_VERSION = void 0;
|
|
4
|
+
// Single source of truth for the SDK's self-reported version.
|
|
5
|
+
// A test asserts this matches package.json so a bump can't drift silently
|
|
6
|
+
// (the build is plain tsc with no JSON module resolution, so we can't
|
|
7
|
+
// import package.json here directly).
|
|
8
|
+
exports.SDK_VERSION = '0.26.0';
|