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 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
- * topic joins, `session.open`/`session.resume`, heartbeat, reconnect with
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-meta-updated`/`server-deleted` (requires `Intents.Servers`).
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-created`/`channel-updated`/`channel-deleted`
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 join;
179
- private sessionOpen;
180
- private sessionResume;
181
- /** Send a `session.*` RPC and resolve its response `d` (rejects on `error`/timeout). */
182
- private rpc;
183
- /** Backfill per-topic gaps the resume buffer couldn't cover. Best-effort never fatal. */
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 handleEvent;
186
- private advanceCursors;
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-meta-updated`/`server-deleted` (requires `Intents.Servers`).
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-created`/`channel-updated`/`channel-deleted`
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 `ss` seen on the current stream
70
- this.topicCursors = new Map(); // topic -> last `s` (for REST catchup)
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.reconnectAttempts = 0;
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
- 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
- }
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-created':
490
- case 'channel-updated': {
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-deleted': {
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-meta-updated': {
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-deleted': {
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
- 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,
561
+ async joinGateway(socket) {
562
+ const payload = {
563
+ protocol_version: 1,
564
+ event_profiles: ['bot.v1'],
556
565
  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;
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
- 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 };
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
- /** Send a `session.*` RPC and resolve its response `d` (rejects on `error`/timeout). */
579
- rpc(personal, type, d) {
780
+ pushGatewayCommand(channel, event, payload) {
580
781
  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 });
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
- /** Backfill per-topic gaps the resume buffer couldn't cover. Best-effort — never fatal. */
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: { topics: Object.fromEntries(this.topicCursors) },
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
- for (const res of Object.values(resp?.topics ?? {})) {
820
+ const deliveredTopics = new Set();
821
+ for (const [topic, res] of Object.entries(resp?.topics ?? {})) {
608
822
  if (Array.isArray(res?.events)) {
609
- for (const e of res.events) {
610
- this.handleEvent(e);
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 === 'string' && STREAM_SEQ_RE.test(head))
620
- this.topicCursors.set(topic, head);
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
- this.warn(`catchup failed: ${e?.message || e}`);
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
- 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;
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 'new-message':
652
- case 'message-created':
653
- case 'thread-message':
887
+ case 'message.created':
888
+ case 'thread.message_created':
654
889
  this.dispatchMessage('messageCreate', d);
655
890
  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':
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-deleted':
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 'bulk-messages-deleted':
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-update': {
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-joined':
928
+ case 'member.joined':
700
929
  this.safeEmit('memberJoin', new Member_1.Member(this, d));
701
930
  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
- });
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-role-added':
709
- case 'member-role-removed': {
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-role-added' ? 'roleAdd' : 'roleRemove', change);
954
+ this.safeEmit(type === 'member.role_added' ? 'roleAdd' : 'roleRemove', change);
716
955
  break;
717
956
  }
718
- case 'member-updated':
719
- case 'nickname-updated': {
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-banned':
734
- case 'member-unbanned':
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-timeout': {
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-untimeout':
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-pinned':
760
- case 'message-unpinned':
761
- this.safeEmit(type === 'message-pinned' ? 'messagePin' : 'messageUnpin', {
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-start':
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 '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':
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-updated':
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-deleted':
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-created':
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-updated':
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-deleted':
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-voted':
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-closed':
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
- 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;
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(dk) {
895
- this.seenSet.add(dk);
896
- this.seen.push(dk);
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 has 16 intents, several of which
3
- * are dead or no-ops in v1 (see protocol spec §6/§11); the SDK exposes only
4
- * the ones that actually work. Each maps to a wire intent string.
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 has 16 intents, several of which
7
- * are dead or no-ops in v1 (see protocol spec §6/§11); the SDK exposes only
8
- * the ones that actually work. Each maps to a wire intent string.
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-created`/
7
- * `channel-updated`/`channel-deleted` events (`Intents.Servers`), and
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-created`/
7
- * `channel-updated`/`channel-deleted` events (`Intents.Servers`), and
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
- /** Components on the reply — ephemeral replies only (they render, but aren't clickable v1). */
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 (and command-born modal submits) — `update()` is invalid then. */
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 original message (content/embeds/components).
119
- * Broadcasts a normal edit. Only valid when the interaction HAS a source
120
- * message throws client-side for `kind: 'command'` and for modal submits
121
- * born from a command (the server would 400).
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
- /** Extend the response window to 15 minutes; must still be followed by `update()` or `reply()`. */
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 original message (content/embeds/components).
91
- * Broadcasts a normal edit. Only valid when the interaction HAS a source
92
- * message throws client-side for `kind: 'command'` and for modal submits
93
- * born from a command (the server would 400).
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.kind === 'modal_submit' && !this.messageId) {
100
- throw new RangeError('update() is not valid for this modal submit its parent interaction had no source message. Use reply() instead.');
105
+ if (!this.messageId && !this.ephemeralMessageId) {
106
+ throw new RangeError('update() is not valid for this interactionit 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
- /** Extend the response window to 15 minutes; must still be followed by `update()` or `reply()`. */
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-meta-updated`/`server-deleted` events
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-meta-updated`/`server-deleted` events
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-created`/`channel-updated`/`channel-deleted` events (see
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-created`/`channel-updated`/`channel-deleted` events (see
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";
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privage.js",
3
- "version": "0.22.0",
3
+ "version": "0.26.0",
4
4
  "description": "Official SDK for building Privage bots.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",