@noverachat/sdk-web 0.0.3

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.
@@ -0,0 +1,1292 @@
1
+ /**
2
+ * Room — feature-level facade bound to a single room_id.
3
+ *
4
+ * Responsibilities:
5
+ * - Optimistic send with temp_id swap on ack
6
+ * - Read-watermark debouncing
7
+ * - Typed emit of server events filtered to this room
8
+ * - Gap-fill on demand via REST
9
+ */
10
+
11
+ import type { HttpClient } from "../transport/http.js";
12
+ import type { ReconnectingWs } from "../transport/ws.js";
13
+ import type {
14
+ Announcement,
15
+ DeleteScope,
16
+ FileKind,
17
+ FileMeta,
18
+ FilePresignResponse,
19
+ InviteToken,
20
+ MessageIdStr,
21
+ MessageOut,
22
+ RoomUnread,
23
+ SendFileOptions,
24
+ SendParams,
25
+ WsAnnouncementEvent,
26
+ WsChatReceive,
27
+ WsMessageDeleted,
28
+ WsMessagesCleared,
29
+ WsMessageUpdated,
30
+ WsReactionAdded,
31
+ WsReactionRemoved,
32
+ WsSyncTick,
33
+ WsTypingBroadcast,
34
+ } from "../types.js";
35
+ import { EventBus } from "../internal/event-bus.js";
36
+ import { tempId as newTempId } from "../internal/temp-id.js";
37
+ // Note: previously used `utils/debounce`, replaced with a hand-rolled
38
+ // timer in Room so we can also expose `flushMarkRead()` — the util has
39
+ // no flush primitive.
40
+
41
+ // Shape coming off the wire from /api/v1/rooms/{id}/messages — backend is
42
+ // Pydantic v2 with snake_case AND serializes 63-bit ids as JSON strings
43
+ // (see `MsgId` on the server). Kept private to this module; callers see
44
+ // the camelCase MessageOut exported from types.ts with string ids.
45
+ interface RawMessageOut {
46
+ app_id: string;
47
+ room_id: string;
48
+ message_id: string;
49
+ sender: { user_id: string; nickname?: string | null; profile_image_url?: string | null } | null;
50
+ sender_id: string | null;
51
+ message_type: MessageOut["messageType"];
52
+ custom_type?: string | null;
53
+ content?: string | null;
54
+ data?: Record<string, unknown> | null;
55
+ meta?: Record<string, unknown> | null;
56
+ file_id?: string | null;
57
+ file?: {
58
+ id: string;
59
+ kind: "image" | "video" | "file";
60
+ mime: string;
61
+ size: number;
62
+ name?: string | null;
63
+ width?: number | null;
64
+ height?: number | null;
65
+ duration_sec?: number | null;
66
+ thumbnail_status: "pending" | "ready" | "failed" | "skipped";
67
+ forward_blocked?: boolean;
68
+ } | null;
69
+ files?: Array<{
70
+ id: string;
71
+ kind: "image" | "video" | "file";
72
+ mime: string;
73
+ size: number;
74
+ name?: string | null;
75
+ width?: number | null;
76
+ height?: number | null;
77
+ duration_sec?: number | null;
78
+ thumbnail_status: "pending" | "ready" | "failed" | "skipped";
79
+ forward_blocked?: boolean;
80
+ }> | null;
81
+ reply_to_id?: string | null;
82
+ reactions?: Array<{ key: string; user_ids: string[]; updated_at: number }>;
83
+ created_at: string;
84
+ created_at_ms: number;
85
+ updated_at?: string | null;
86
+ updated_at_ms?: number | null;
87
+ deleted_at?: string | null;
88
+ deleted_by_user_id?: string | null;
89
+ delete_scope?: MessageOut["deleteScope"] | null;
90
+ edited_count?: number;
91
+ }
92
+
93
+ function toMessageOut(r: RawMessageOut): MessageOut {
94
+ // Phase D #11 — extract sender-attached `_customMeta` from `data`.
95
+ // Backend stores the whole `data` blob untouched, so both self-sent
96
+ // and received messages surface `customMeta` here for the caller.
97
+ const dataObj = r.data ?? null;
98
+ const customMeta =
99
+ dataObj && typeof dataObj === "object" && "_customMeta" in dataObj
100
+ ? ((dataObj as Record<string, unknown>)._customMeta as
101
+ | Record<string, unknown>
102
+ | null)
103
+ : null;
104
+ return {
105
+ appId: r.app_id,
106
+ roomId: r.room_id,
107
+ messageId: r.message_id,
108
+ sender: r.sender
109
+ ? {
110
+ userId: r.sender.user_id,
111
+ nickname: r.sender.nickname ?? null,
112
+ profileImageUrl: r.sender.profile_image_url ?? null,
113
+ }
114
+ : null,
115
+ senderId: r.sender_id ?? null,
116
+ messageType: r.message_type,
117
+ customType: r.custom_type ?? null,
118
+ content: r.content ?? null,
119
+ data: dataObj,
120
+ meta: r.meta ?? null,
121
+ customMeta,
122
+ fileId: r.file_id ?? null,
123
+ file: r.file
124
+ ? {
125
+ id: r.file.id,
126
+ kind: r.file.kind,
127
+ mime: r.file.mime,
128
+ size: r.file.size,
129
+ name: r.file.name ?? null,
130
+ width: r.file.width ?? null,
131
+ height: r.file.height ?? null,
132
+ durationSec: r.file.duration_sec ?? null,
133
+ thumbnailStatus: r.file.thumbnail_status,
134
+ forwardBlocked: Boolean(r.file.forward_blocked),
135
+ }
136
+ : null,
137
+ files: r.files && r.files.length > 0
138
+ ? r.files.map((f) => ({
139
+ id: f.id,
140
+ kind: f.kind,
141
+ mime: f.mime,
142
+ size: f.size,
143
+ name: f.name ?? null,
144
+ width: f.width ?? null,
145
+ height: f.height ?? null,
146
+ durationSec: f.duration_sec ?? null,
147
+ thumbnailStatus: f.thumbnail_status,
148
+ forwardBlocked: Boolean(f.forward_blocked),
149
+ }))
150
+ : null,
151
+ replyToId: r.reply_to_id ?? null,
152
+ reactions: (r.reactions ?? []).map((x) => ({
153
+ key: x.key,
154
+ userIds: x.user_ids,
155
+ updatedAt: x.updated_at,
156
+ })),
157
+ createdAt: r.created_at,
158
+ createdAtMs: r.created_at_ms,
159
+ updatedAt: r.updated_at ?? null,
160
+ updatedAtMs: r.updated_at_ms ?? null,
161
+ deletedAt: r.deleted_at ?? null,
162
+ deletedByUserId: r.deleted_by_user_id ?? null,
163
+ deleteScope: r.delete_scope ?? null,
164
+ editedCount: r.edited_count ?? 0,
165
+ };
166
+ }
167
+
168
+ // On-wire shape of an outbound chat_send. Kept private to this module —
169
+ // callers use `Room.send({...})` which builds this for them. We store the
170
+ // built payload in pendingAcks so an auto-retry on NC-RATE-001 can reuse
171
+ // the exact same frame (same temp_id, same client_ts) without the caller
172
+ // having to provide it again.
173
+ interface ChatSendPayload {
174
+ type: "chat_send";
175
+ room_id: string;
176
+ message_type: "TEXT" | "FILE" | "EMOTICON";
177
+ content: string | null;
178
+ custom_type: string | null;
179
+ data: Record<string, unknown> | null;
180
+ meta: Record<string, unknown> | null;
181
+ file_id: MessageIdStr | null;
182
+ reply_to_id: MessageIdStr | null;
183
+ forward_of_message_id: MessageIdStr | null;
184
+ temp_id: string;
185
+ client_ts: number;
186
+ }
187
+
188
+ export interface RoomEvents {
189
+ message: WsChatReceive;
190
+ messageUpdated: WsMessageUpdated;
191
+ messageDeleted: WsMessageDeleted;
192
+ /** Operator (or super-admin) wiped the room. Hide every locally-rendered
193
+ * message with `message_id <= up_to_message_id`. */
194
+ messagesCleared: WsMessagesCleared;
195
+ reactionAdded: WsReactionAdded;
196
+ reactionRemoved: WsReactionRemoved;
197
+ syncTick: WsSyncTick;
198
+ typing: WsTypingBroadcast;
199
+ ack: { tempId: string; messageId: MessageIdStr };
200
+ announcement: WsAnnouncementEvent;
201
+ }
202
+
203
+ export class Room {
204
+ private bus = new EventBus<RoomEvents>();
205
+ // tempId → resolver pair. We keep `reject` alongside `resolve` so an
206
+ // incoming `error` frame carrying the offending temp_id (e.g.
207
+ // NC-RATE-001) can fail the send immediately rather than letting the
208
+ // 15s ack timeout fire — without correlation, a user hitting the
209
+ // per-tenant rate limit sees the chat hang on "PENDING…" forever.
210
+ //
211
+ // `payload` and `retries` enable transparent auto-retry of rate-limited
212
+ // sends: client.ts re-fires the same WS frame (same temp_id) with
213
+ // exponential backoff up to `MAX_RETRIES` instead of immediately failing
214
+ // the user's promise. Same temp_id keeps self-echo dedup correct in
215
+ // case the server actually processed the first attempt.
216
+ private pendingAcks = new Map<string, {
217
+ resolve: (messageId: MessageIdStr) => void;
218
+ reject: (err: Error) => void;
219
+ payload: ChatSendPayload;
220
+ retries: number;
221
+ timer: ReturnType<typeof setTimeout>; // 15s ack-timeout handle
222
+ }>();
223
+ // Retry policy for rate-limited (NC-RATE-001) sends. We cap at 1
224
+ // automatic retry — enough to swallow the normal jitter-induced spillover
225
+ // at the per-second token bucket boundary, but anything beyond that is
226
+ // a sustained rate-limit problem that the SDK shouldn't silently paper
227
+ // over. Subsequent failures bubble up to the caller so the UI can offer
228
+ // an explicit "Retry" button — manual retry is the right UX once we
229
+ // know the auto path didn't fix it.
230
+ // Backoff: ~300ms — long enough to land in a fresh 1s token bucket.
231
+ private static readonly MAX_RATE_LIMIT_RETRIES = 1;
232
+ private static readonly RATE_LIMIT_BACKOFF_MS = [300];
233
+ // Highest id we've sent via `read_watermark`. Used to drop redundant
234
+ // frames inside the debounce window. Compared via BigInt.
235
+ private lastReadSent: MessageIdStr | null = null;
236
+ // Pending read_watermark — set by markRead, flushed after readDebounceMs
237
+ // OR immediately on flushMarkRead().
238
+ private pendingReadMid: MessageIdStr | null = null;
239
+ private readDebounceTimer: ReturnType<typeof setTimeout> | null = null;
240
+ private readDebounceMs: number;
241
+ /**
242
+ * Every temp_id we've sent this session. The server echoes the same
243
+ * temp_id back on `chat_receive` for the sender's own sessions, so the
244
+ * client can dedup against its optimistic bubble regardless of whether
245
+ * the `ack` or the `chat_receive` arrives first. Unbounded growth is
246
+ * fine in practice (small strings, cleared on `chat.disconnect()`).
247
+ */
248
+ private mySentTempIds = new Set<string>();
249
+
250
+ constructor(
251
+ public readonly roomId: string,
252
+ private http: HttpClient,
253
+ private ws: ReconnectingWs,
254
+ readDebounceMs: number,
255
+ ) {
256
+ this.readDebounceMs = readDebounceMs;
257
+ }
258
+
259
+ private _sendPendingMarkRead(): void {
260
+ const mid = this.pendingReadMid;
261
+ if (!mid) return;
262
+ // BigInt monotonic guard — `lastReadSent` may be a 19-digit string,
263
+ // numeric `>` is wrong (lexicographic for unequal lengths).
264
+ if (this.lastReadSent !== null) {
265
+ try {
266
+ if (BigInt(mid) <= BigInt(this.lastReadSent)) {
267
+ this.pendingReadMid = null;
268
+ return;
269
+ }
270
+ } catch {
271
+ if (mid <= this.lastReadSent) {
272
+ this.pendingReadMid = null;
273
+ return;
274
+ }
275
+ }
276
+ }
277
+ const ok = this.ws.send({
278
+ type: "read_watermark",
279
+ room_id: this.roomId,
280
+ last_read_message_id: mid,
281
+ });
282
+ if (ok) {
283
+ this.lastReadSent = mid;
284
+ this.pendingReadMid = null;
285
+ }
286
+ }
287
+
288
+ // -------- outgoing --------
289
+
290
+ async send(
291
+ params: string | SendParams,
292
+ ): Promise<{ tempId: string; messageId: Promise<MessageIdStr> }> {
293
+ const p: SendParams = typeof params === "string" ? { content: params } : params;
294
+ const tempId = newTempId();
295
+ // Multi-file bundle path — encode the list of file ids inside
296
+ // ``data.file_ids`` (with ``kind = "file_group"``) so the whole
297
+ // send stays ONE message. Auto-promotes messageType to FILE. When
298
+ // both ``fileIds`` and ``fileId`` are provided the bundle wins.
299
+ const hasBundle = Array.isArray(p.fileIds) && p.fileIds.length > 0;
300
+ // Auto-promote to FILE message type when a fileId or bundle is
301
+ // attached — the caller no longer has to remember the messageType.
302
+ const messageType: "TEXT" | "FILE" | "EMOTICON" =
303
+ p.messageType ?? (hasBundle || p.fileId ? "FILE" : "TEXT");
304
+ const baseData: Record<string, unknown> =
305
+ (p.data as Record<string, unknown> | undefined) ?? {};
306
+ let outData: Record<string, unknown> | null = hasBundle
307
+ ? {
308
+ ...baseData,
309
+ kind: "file_group",
310
+ file_ids: p.fileIds as MessageIdStr[],
311
+ forward_blocked: Boolean(p.forwardBlocked),
312
+ }
313
+ : (p.data ?? null);
314
+ // Phase D #11 — merge client-supplied custom metadata under a reserved
315
+ // `_customMeta` key in `data`. Backend passes the whole `data` blob
316
+ // through untouched, so receiving clients read it back via
317
+ // `MessageOut.customMeta` (see `toMessageOut`).
318
+ if (p.customMeta) {
319
+ outData = { ...(outData ?? {}), _customMeta: p.customMeta };
320
+ }
321
+ const payload = {
322
+ type: "chat_send" as const,
323
+ room_id: this.roomId,
324
+ message_type: messageType,
325
+ content: p.content ?? null,
326
+ custom_type: p.customType ?? null,
327
+ data: outData,
328
+ meta: p.meta ?? null,
329
+ // Bundle path sends file_id=null; the ids live in data.file_ids.
330
+ file_id: hasBundle ? null : (p.fileId ?? null),
331
+ reply_to_id: p.replyToId ?? null,
332
+ forward_of_message_id: p.forwardOfMessageId ?? null,
333
+ temp_id: tempId,
334
+ client_ts: Date.now(),
335
+ };
336
+ this.mySentTempIds.add(tempId);
337
+ const messageId = new Promise<MessageIdStr>((resolve, reject) => {
338
+ const timer = setTimeout(() => {
339
+ this.pendingAcks.delete(tempId);
340
+ this.mySentTempIds.delete(tempId);
341
+ reject(new Error("ack timeout"));
342
+ }, 15_000);
343
+ this.pendingAcks.set(tempId, {
344
+ resolve: (id: MessageIdStr) => {
345
+ clearTimeout(timer);
346
+ resolve(id);
347
+ },
348
+ reject: (err: Error) => {
349
+ clearTimeout(timer);
350
+ reject(err);
351
+ },
352
+ payload,
353
+ retries: 0,
354
+ timer,
355
+ });
356
+ });
357
+ const sent = this.ws.send(payload);
358
+ if (!sent) {
359
+ this.pendingAcks.delete(tempId);
360
+ this.mySentTempIds.delete(tempId);
361
+ throw new Error("ws not open");
362
+ }
363
+ return { tempId, messageId };
364
+ }
365
+
366
+ /**
367
+ * Send an admin-uploaded emoticon as a STICKER-like message.
368
+ *
369
+ * Thin wrapper over ``send()``: stamps ``messageType: "EMOTICON"`` and
370
+ * fills ``data`` with the pack / item identifiers + a stable URL so the
371
+ * receiving client can render the bubble without a separate fetch.
372
+ *
373
+ * Pass either an ``EmoticonItem``-shaped object (matching the response
374
+ * of ``GET /v1/emoticon-packs``) or the raw ids + url. ``caption`` is
375
+ * optional — KakaoTalk-style sticker bubbles render fine without one.
376
+ */
377
+ async sendEmoticon(
378
+ item: {
379
+ packId: string;
380
+ itemId: string;
381
+ fileId?: string;
382
+ url: string;
383
+ },
384
+ caption?: string,
385
+ ): Promise<{ tempId: string; messageId: Promise<MessageIdStr> }> {
386
+ return this.send({
387
+ content: caption ?? "",
388
+ messageType: "EMOTICON",
389
+ data: {
390
+ source: "emoticon",
391
+ pack_id: item.packId,
392
+ item_id: item.itemId,
393
+ file_id: item.fileId ?? "",
394
+ url: item.url,
395
+ },
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Send a file as a message. Wraps the three-step file pipeline:
401
+ *
402
+ * 1. ``POST /files`` to presign an S3 PUT
403
+ * 2. ``PUT`` the bytes direct to S3 (XHR — so we can report
404
+ * progress; ``fetch`` has no upload progress event)
405
+ * 3. ``POST /files/{id}/commit`` to flip the row to ``committed``
406
+ * and enqueue the thumbnail worker
407
+ * 4. ``room.send({ fileId, messageType: "FILE" })`` to broadcast
408
+ * the message to the room
409
+ *
410
+ * Returns the same ``{ tempId, messageId }`` shape as ``send()`` —
411
+ * callers reconcile the optimistic bubble against the eventual ack
412
+ * the same way text messages do.
413
+ *
414
+ * On any error before the final ``send()`` (upload aborted, S3
415
+ * rejected, commit failed) the row in ``files`` stays at
416
+ * ``upload_status=pending`` and the background cleanup worker
417
+ * reclaims it within ``FILE_PENDING_UPLOAD_TTL_SEC`` (24h default)
418
+ * — no client-side cleanup needed.
419
+ */
420
+ async sendFile(
421
+ file: File,
422
+ opts: SendFileOptions = {},
423
+ ): Promise<{
424
+ tempId: string;
425
+ messageId: Promise<MessageIdStr>;
426
+ /** Snowflake id of the just-committed file row. Useful when the
427
+ * caller renders an optimistic bubble with a placeholder and
428
+ * wants to swap in the real thumbnail / download URL as soon as
429
+ * the upload finishes (without waiting for the chat_receive
430
+ * echo, which the SDK suppresses for self-sent messages). */
431
+ fileId: MessageIdStr;
432
+ }> {
433
+ // Derive kind from MIME if the caller didn't pin one. Anything that
434
+ // isn't recognisably image/video falls into the generic "file"
435
+ // bucket — the server-side allowlist is what actually enforces
436
+ // what's renderable.
437
+ const kind: FileKind =
438
+ opts.kind
439
+ ?? (file.type.startsWith("image/")
440
+ ? "image"
441
+ : file.type.startsWith("video/")
442
+ ? "video"
443
+ : "file");
444
+ const name = opts.name ?? file.name;
445
+
446
+ // Step 1 — presign. The backend assigns the file_id and the S3 key
447
+ // and returns the URL we should PUT to. ``Content-Type`` is bound
448
+ // into the signature, so the PUT must echo it.
449
+ const presign = await this.http.request<{
450
+ file_id: MessageIdStr;
451
+ upload_url: string;
452
+ headers: Record<string, string>;
453
+ expires_at_ms: number;
454
+ }>("POST", "/api/v1/files", {
455
+ kind,
456
+ mime: file.type || "application/octet-stream",
457
+ size: file.size,
458
+ original_name: name,
459
+ // 파일 전달 제한 — uploader-set at presign time. When true,
460
+ // other users can't forward this file to a different room.
461
+ forward_blocked: Boolean(opts.forwardBlocked),
462
+ });
463
+ const presigned: FilePresignResponse = {
464
+ fileId: presign.file_id,
465
+ uploadUrl: presign.upload_url,
466
+ headers: presign.headers,
467
+ expiresAtMs: presign.expires_at_ms,
468
+ };
469
+
470
+ // Step 2 — S3 PUT via XHR so the caller can show a progress bar.
471
+ // We deliberately don't go through HttpClient here: that one
472
+ // injects ``Authorization`` and ``X-Noverachat-App-Id``, which S3
473
+ // would reject as unsigned headers.
474
+ await new Promise<void>((resolve, reject) => {
475
+ const xhr = new XMLHttpRequest();
476
+ xhr.open("PUT", presigned.uploadUrl);
477
+ for (const [k, v] of Object.entries(presigned.headers)) {
478
+ xhr.setRequestHeader(k, v);
479
+ }
480
+ if (opts.onProgress) {
481
+ xhr.upload.addEventListener("progress", (e) => {
482
+ if (!e.lengthComputable) return;
483
+ opts.onProgress!({
484
+ loaded: e.loaded,
485
+ total: e.total,
486
+ ratio: e.total > 0 ? e.loaded / e.total : 0,
487
+ });
488
+ });
489
+ }
490
+ xhr.onload = () => {
491
+ if (xhr.status >= 200 && xhr.status < 300) {
492
+ // Synthesise a final ratio=1 tick so progress UIs reach 100%
493
+ // even when the upload finishes between two progress events.
494
+ opts.onProgress?.({ loaded: file.size, total: file.size, ratio: 1 });
495
+ resolve();
496
+ } else {
497
+ reject(new Error(`S3 PUT failed: ${xhr.status} ${xhr.statusText}`));
498
+ }
499
+ };
500
+ xhr.onerror = () => reject(new Error("S3 PUT network error"));
501
+ xhr.onabort = () => reject(new Error("S3 PUT aborted"));
502
+ xhr.send(file);
503
+ });
504
+
505
+ // Step 3 — commit. The server HEADs S3 to verify size/existence
506
+ // before flipping the row. After this returns the file is safe to
507
+ // reference from a chat_send.
508
+ await this.http.request<FileMeta>(
509
+ "POST", `/api/v1/files/${presigned.fileId}/commit`, {},
510
+ );
511
+
512
+ // Step 4 — send the chat message carrying just the file_id. Server
513
+ // joins the files row and embeds inline metadata for receivers.
514
+ const sendResult = await this.send({
515
+ fileId: presigned.fileId,
516
+ messageType: "FILE",
517
+ ...(opts.customMeta ? { customMeta: opts.customMeta } : {}),
518
+ });
519
+ return { ...sendResult, fileId: presigned.fileId };
520
+ }
521
+
522
+ /**
523
+ * Upload a file (presign → S3 PUT → commit) WITHOUT emitting a chat
524
+ * message. Returns the committed ``file_id`` so callers can gather
525
+ * several ids and bundle them into ONE ``room.send({ fileIds: [...] })``
526
+ * for the multi-file "file_group" message pattern.
527
+ *
528
+ * Same 3-step pipeline as ``sendFile()`` minus step 4. Errors before
529
+ * commit leave the file at ``upload_status=pending`` — the cleanup
530
+ * worker reclaims it within ``FILE_PENDING_UPLOAD_TTL_SEC``.
531
+ */
532
+ async uploadFileOnly(
533
+ file: File,
534
+ opts: SendFileOptions = {},
535
+ ): Promise<MessageIdStr> {
536
+ const kind: FileKind =
537
+ opts.kind
538
+ ?? (file.type.startsWith("image/")
539
+ ? "image"
540
+ : file.type.startsWith("video/")
541
+ ? "video"
542
+ : "file");
543
+ const name = opts.name ?? file.name;
544
+
545
+ const presign = await this.http.request<{
546
+ file_id: MessageIdStr;
547
+ upload_url: string;
548
+ headers: Record<string, string>;
549
+ expires_at_ms: number;
550
+ }>("POST", "/api/v1/files", {
551
+ kind,
552
+ mime: file.type || "application/octet-stream",
553
+ size: file.size,
554
+ original_name: name,
555
+ forward_blocked: Boolean(opts.forwardBlocked),
556
+ });
557
+
558
+ await new Promise<void>((resolve, reject) => {
559
+ const xhr = new XMLHttpRequest();
560
+ xhr.open("PUT", presign.upload_url);
561
+ for (const [k, v] of Object.entries(presign.headers)) {
562
+ xhr.setRequestHeader(k, v);
563
+ }
564
+ if (opts.onProgress) {
565
+ xhr.upload.addEventListener("progress", (e) => {
566
+ if (!e.lengthComputable) return;
567
+ opts.onProgress!({
568
+ loaded: e.loaded,
569
+ total: e.total,
570
+ ratio: e.total > 0 ? e.loaded / e.total : 0,
571
+ });
572
+ });
573
+ }
574
+ xhr.onload = () => {
575
+ if (xhr.status >= 200 && xhr.status < 300) {
576
+ opts.onProgress?.({ loaded: file.size, total: file.size, ratio: 1 });
577
+ resolve();
578
+ } else {
579
+ reject(new Error(`S3 PUT failed: ${xhr.status} ${xhr.statusText}`));
580
+ }
581
+ };
582
+ xhr.onerror = () => reject(new Error("S3 PUT network error"));
583
+ xhr.onabort = () => reject(new Error("S3 PUT aborted"));
584
+ xhr.send(file);
585
+ });
586
+
587
+ await this.http.request<FileMeta>(
588
+ "POST", `/api/v1/files/${presign.file_id}/commit`, {},
589
+ );
590
+ return presign.file_id;
591
+ }
592
+
593
+ /**
594
+ * List files shared in this room — newest-first, paginated.
595
+ *
596
+ * One entry per non-deleted message that carries a ``file_id`` —
597
+ * forwarded files (same file_id, multiple messages) appear once
598
+ * per occurrence. The history-visibility floor is applied so files
599
+ * attached to messages older than the caller's ``last_cleared_at``
600
+ * (or the post-rejoin cutoff for grouprooms) are filtered out.
601
+ *
602
+ * Use ``before`` with the smallest ``messageId`` of the previous
603
+ * page to paginate.
604
+ */
605
+ async listFiles(
606
+ opts: { limit?: number; before?: MessageIdStr } = {},
607
+ ): Promise<{
608
+ items: Array<{
609
+ messageId: MessageIdStr;
610
+ senderId: string | null;
611
+ createdAtMs: number;
612
+ file: FileMeta;
613
+ }>;
614
+ nextCursor: MessageIdStr | null;
615
+ hasMore: boolean;
616
+ }> {
617
+ const raw = await this.http.request<{
618
+ items: Array<{
619
+ message_id: MessageIdStr;
620
+ sender_id: string | null;
621
+ created_at_ms: number;
622
+ file: {
623
+ id: MessageIdStr;
624
+ kind: FileKind;
625
+ mime: string;
626
+ size_bytes: number;
627
+ original_name: string | null;
628
+ width: number | null;
629
+ height: number | null;
630
+ duration_sec: number | null;
631
+ thumbnail_status: FileMeta["thumbnailStatus"];
632
+ forward_blocked?: boolean;
633
+ };
634
+ }>;
635
+ next_cursor: MessageIdStr | null;
636
+ has_more: boolean;
637
+ }>(
638
+ "GET",
639
+ `/api/v1/rooms/${encodeURIComponent(this.roomId)}/files`,
640
+ undefined,
641
+ {
642
+ limit: opts.limit ?? 50,
643
+ ...(opts.before ? { before: opts.before } : {}),
644
+ },
645
+ );
646
+ return {
647
+ items: raw.items.map((r) => ({
648
+ messageId: r.message_id,
649
+ senderId: r.sender_id,
650
+ createdAtMs: r.created_at_ms,
651
+ file: {
652
+ id: r.file.id,
653
+ kind: r.file.kind,
654
+ mime: r.file.mime,
655
+ size: r.file.size_bytes,
656
+ name: r.file.original_name ?? null,
657
+ width: r.file.width ?? null,
658
+ height: r.file.height ?? null,
659
+ durationSec: r.file.duration_sec ?? null,
660
+ thumbnailStatus: r.file.thumbnail_status,
661
+ forwardBlocked: Boolean(r.file.forward_blocked),
662
+ },
663
+ })),
664
+ nextCursor: raw.next_cursor,
665
+ hasMore: raw.has_more,
666
+ };
667
+ }
668
+
669
+ markRead(messageId: MessageIdStr): void {
670
+ // Monotonic: keep only the highest pending mid.
671
+ if (this.pendingReadMid !== null) {
672
+ try {
673
+ if (BigInt(messageId) <= BigInt(this.pendingReadMid)) return;
674
+ } catch {
675
+ if (messageId <= this.pendingReadMid) return;
676
+ }
677
+ }
678
+ this.pendingReadMid = messageId;
679
+ if (this.readDebounceTimer) return; // already scheduled
680
+ this.readDebounceTimer = setTimeout(() => {
681
+ this.readDebounceTimer = null;
682
+ this._sendPendingMarkRead();
683
+ }, this.readDebounceMs);
684
+ }
685
+
686
+ /**
687
+ * Bypass the read_watermark debounce and send the pending watermark
688
+ * immediately. Use on critical UX boundaries (user leaves room,
689
+ * client disconnects, page unload) so the server's per-user unread
690
+ * count is correct by the time any other device / admin / push
691
+ * service queries it. No-op when nothing is pending.
692
+ */
693
+ flushMarkRead(): void {
694
+ if (this.readDebounceTimer) {
695
+ clearTimeout(this.readDebounceTimer);
696
+ this.readDebounceTimer = null;
697
+ }
698
+ this._sendPendingMarkRead();
699
+ }
700
+
701
+ setTyping(isTyping: boolean): void {
702
+ this.ws.send({ type: "typing", room_id: this.roomId, is_typing: isTyping });
703
+ }
704
+
705
+ async edit(
706
+ messageId: MessageIdStr,
707
+ patch: { content?: string; data?: Record<string, unknown>; meta?: Record<string, unknown> },
708
+ ): Promise<MessageOut> {
709
+ return this.http.request<MessageOut>(
710
+ "PUT", `/api/v1/rooms/${this.roomId}/messages/${messageId}`, patch,
711
+ );
712
+ }
713
+
714
+ async delete(messageId: MessageIdStr, scope: DeleteScope = "ALL"): Promise<void> {
715
+ await this.http.request<void>(
716
+ "DELETE", `/api/v1/rooms/${this.roomId}/messages/${messageId}`,
717
+ undefined, { scope },
718
+ );
719
+ }
720
+
721
+ /** Per-user "Clear chat". Hides every existing message in this room
722
+ * from the calling user (via `room_members.last_cleared_at = now()`).
723
+ * Other members are unaffected — this is local history hiding, not
724
+ * a destructive bulk delete. Irreversible from the caller's side. */
725
+ async clearHistory(): Promise<void> {
726
+ await this.http.request<void>(
727
+ "POST", `/api/v1/rooms/${this.roomId}/clear`,
728
+ );
729
+ }
730
+
731
+ /** Leave this room (self). Deletes the caller's membership row — the room
732
+ * drops out of `unreadSummary()` and they stop receiving its messages.
733
+ * Other members get a `MEMBER_LEFT` [`roomEvent`](../reference/noverachat.md#events).
734
+ * Idempotent if already a non-member; throws 409 if the caller was KICKED
735
+ * (ask an operator to restore first). For GROUP/SUPER_GROUP, re-entry needs
736
+ * an operator add; for ONE, re-creating the 1:1 rejoins with history kept. */
737
+ async leave(): Promise<void> {
738
+ await this.http.request<void>(
739
+ "POST", `/api/v1/rooms/${this.roomId}/leave`,
740
+ );
741
+ }
742
+
743
+ /**
744
+ * Set the caller's per-room push preference — like long-pressing a
745
+ * chat and tapping "notifications off".
746
+ * "ALL" — push for every message (default)
747
+ * "MENTION_ONLY" — push only when the message @-mentions the caller
748
+ * "OFF" — mute this room's push entirely
749
+ * Scoped to the calling user; throws if they're not a room member.
750
+ */
751
+ async setPushTrigger(
752
+ trigger: "ALL" | "MENTION_ONLY" | "OFF",
753
+ ): Promise<void> {
754
+ await this.http.request<void>(
755
+ "PATCH", `/api/v1/rooms/${this.roomId}/push-trigger`,
756
+ { push_trigger: trigger },
757
+ );
758
+ }
759
+
760
+ /**
761
+ * **DESTRUCTIVE, operator-only.** Soft-deletes every currently-undeleted
762
+ * message in the room — visible to all members instantly via a
763
+ * `messagesCleared` event (subscribe via `room.on("messagesCleared", ...)`).
764
+ * Server enforces the operator check; throws 403 for regular members.
765
+ * Returns the cleared count plus the cutoff `message_id` so callers can
766
+ * hide rendered messages up to (and including) that id.
767
+ */
768
+ async deleteAllMessages(): Promise<{
769
+ cleared: number;
770
+ upToMessageId: MessageIdStr | null;
771
+ }> {
772
+ const raw = await this.http.request<{
773
+ cleared: number;
774
+ up_to_message_id: string | null;
775
+ }>("DELETE", `/api/v1/rooms/${this.roomId}/messages`);
776
+ return {
777
+ cleared: raw.cleared,
778
+ upToMessageId: raw.up_to_message_id,
779
+ };
780
+ }
781
+
782
+ async react(messageId: MessageIdStr, key: string): Promise<void> {
783
+ await this.http.request<void>(
784
+ "POST", `/api/v1/rooms/${this.roomId}/messages/${messageId}/reactions`,
785
+ { key },
786
+ );
787
+ }
788
+
789
+ async unreact(messageId: MessageIdStr, key: string): Promise<void> {
790
+ await this.http.request<void>(
791
+ "DELETE", `/api/v1/rooms/${this.roomId}/messages/${messageId}/reactions/${encodeURIComponent(key)}`,
792
+ );
793
+ }
794
+
795
+ async listSince(sinceMessageId: MessageIdStr | number, limit = 100): Promise<{
796
+ items: MessageOut[];
797
+ nextCursor: MessageIdStr | null;
798
+ hasMore: boolean;
799
+ }> {
800
+ // Backend response is snake_case Pydantic — convert to the SDK's
801
+ // camelCase contract here so callers (and the demo's renderMessage)
802
+ // don't see `undefined` on `messageId` / `senderId` / etc.
803
+ const raw = await this.http.request<{
804
+ items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
805
+ }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, {
806
+ since: String(sinceMessageId), limit,
807
+ });
808
+ return {
809
+ items: raw.items.map(toMessageOut),
810
+ nextCursor: raw.next_cursor,
811
+ hasMore: raw.has_more,
812
+ };
813
+ }
814
+
815
+ /**
816
+ * Load the latest `limit` messages of the room — what to render when the
817
+ * user just opens the chat. Returns ASC (oldest → newest within the
818
+ * page) so callers don't need to reverse. Use `room.listBefore(cursor)`
819
+ * to paginate further back.
820
+ */
821
+ async listRecent(limit = 50): Promise<{
822
+ items: MessageOut[];
823
+ nextCursor: MessageIdStr | null;
824
+ hasMore: boolean;
825
+ }> {
826
+ const raw = await this.http.request<{
827
+ items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
828
+ }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
829
+ return {
830
+ items: raw.items.map(toMessageOut),
831
+ nextCursor: raw.next_cursor,
832
+ hasMore: raw.has_more,
833
+ };
834
+ }
835
+
836
+ /**
837
+ * Scrollback. Fetches the page of `limit` messages immediately preceding
838
+ * `beforeMessageId` (exclusive), in ASC order. The returned
839
+ * `nextCursor` is the oldest id in this page — feed it back to keep
840
+ * scrolling up.
841
+ */
842
+ async listBefore(beforeMessageId: MessageIdStr, limit = 50): Promise<{
843
+ items: MessageOut[];
844
+ nextCursor: MessageIdStr | null;
845
+ hasMore: boolean;
846
+ }> {
847
+ const raw = await this.http.request<{
848
+ items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
849
+ }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, {
850
+ before: beforeMessageId, limit,
851
+ });
852
+ return {
853
+ items: raw.items.map(toMessageOut),
854
+ nextCursor: raw.next_cursor,
855
+ hasMore: raw.has_more,
856
+ };
857
+ }
858
+
859
+ /** Phase D #2 — 방 내 메시지 검색.
860
+ *
861
+ * ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
862
+ * 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
863
+ * 커서로 뒤로 페이지네이션. 스티커/파일/이모티콘 등 ``content`` 가 없는
864
+ * 메시지는 검색 대상 X.
865
+ *
866
+ * @param q 검색어 (필수, 1~200자, whitespace-only 는 서버가 빈 결과 반환)
867
+ * @param opts.before 이전 페이지 마지막 message_id (다음 페이지 커서)
868
+ * @param opts.limit 페이지 크기 (기본 30, 최대 100)
869
+ */
870
+ async search(
871
+ q: string,
872
+ opts?: { before?: MessageIdStr; limit?: number },
873
+ ): Promise<{
874
+ items: MessageOut[];
875
+ nextCursor: MessageIdStr | null;
876
+ hasMore: boolean;
877
+ }> {
878
+ const params: Record<string, string | number> = {
879
+ q,
880
+ limit: opts?.limit ?? 30,
881
+ };
882
+ if (opts?.before) params.before = opts.before;
883
+ const raw = await this.http.request<{
884
+ items: RawMessageOut[]; next_cursor: string | null; has_more: boolean;
885
+ }>("GET", `/api/v1/rooms/${this.roomId}/messages/search`, undefined, params);
886
+ return {
887
+ items: raw.items.map(toMessageOut),
888
+ nextCursor: raw.next_cursor,
889
+ hasMore: raw.has_more,
890
+ };
891
+ }
892
+
893
+ /** List this room's members (with each one's role). Handy for UIs that
894
+ * want to conditionally enable destructive operator-only actions like
895
+ * `room.deleteAllMessages()`. Server gates by membership for non-public
896
+ * rooms, so the caller must already be a member to read this.
897
+ */
898
+ async listMembers(): Promise<Array<{
899
+ userId: string;
900
+ nickname: string | null;
901
+ role: "OPERATOR" | "MEMBER" | "KICKED";
902
+ pushTrigger: "ALL" | "MENTION" | "OFF";
903
+ lastReadMessageId: MessageIdStr | null;
904
+ joinedAt: string;
905
+ /** Cluster-wide presence dot. `null` when the server didn't resolve it. */
906
+ isOnline: boolean | null;
907
+ /** Per-room mute expiry. `null` = not muted. A far-future sentinel
908
+ * (year 9999) means the operator picked "indefinite" — clients
909
+ * should render that as "무기한" instead of a real timestamp. */
910
+ mutedUntil: string | null;
911
+ }>> {
912
+ const raw = await this.http.request<Array<{
913
+ user_id: string;
914
+ nickname?: string | null;
915
+ role: "OPERATOR" | "MEMBER" | "KICKED";
916
+ push_trigger: "ALL" | "MENTION" | "OFF";
917
+ last_read_message_id: string | null;
918
+ joined_at: string;
919
+ is_online?: boolean | null;
920
+ muted_until?: string | null;
921
+ }>>("GET", `/api/v1/rooms/${this.roomId}/members`);
922
+ return raw.map((m) => ({
923
+ userId: m.user_id,
924
+ nickname: m.nickname ?? null,
925
+ role: m.role,
926
+ pushTrigger: m.push_trigger,
927
+ lastReadMessageId: m.last_read_message_id,
928
+ joinedAt: m.joined_at,
929
+ isOnline: m.is_online ?? null,
930
+ mutedUntil: m.muted_until ?? null,
931
+ }));
932
+ }
933
+
934
+ // -------- mute --------
935
+
936
+ /** OPERATOR-only: mute a member so they can't send messages in this
937
+ * room. `durationMinutes=null` → indefinite (until an explicit unmute).
938
+ * Emits a `MEMBER_MUTED` [`roomEvent`](../reference/noverachat.md#events)
939
+ * + a SYSTEM chat message. Non-operator callers get 403. Server also
940
+ * refuses self-mute and muting another OPERATOR. */
941
+ async muteMember(userId: string, durationMinutes: number | null): Promise<void> {
942
+ await this.http.request<void>(
943
+ "POST", `/api/v1/rooms/${this.roomId}/members/${encodeURIComponent(userId)}/mute`,
944
+ { duration_minutes: durationMinutes },
945
+ );
946
+ }
947
+
948
+ /** OPERATOR-only: clear a member's mute. Idempotent — no-op + no
949
+ * broadcast when the target wasn't muted. Emits a `MEMBER_UNMUTED`
950
+ * roomEvent + SYSTEM chat message otherwise. */
951
+ async unmuteMember(userId: string): Promise<void> {
952
+ await this.http.request<void>(
953
+ "POST", `/api/v1/rooms/${this.roomId}/members/${encodeURIComponent(userId)}/unmute`,
954
+ );
955
+ }
956
+
957
+ /** Server-side unread state for this room: exact COUNT(*) of messages
958
+ * newer than the caller's `read_watermark`, plus the room's
959
+ * `last_message_id` so the UI can show a "•" dot regardless of count. */
960
+ async unread(): Promise<RoomUnread> {
961
+ const raw = await this.http.request<{
962
+ room_id: string;
963
+ unread_count: number;
964
+ last_read_message_id: string | null;
965
+ last_message_id: string | null;
966
+ }>("GET", `/api/v1/rooms/${this.roomId}/unread`);
967
+ return {
968
+ roomId: raw.room_id,
969
+ unreadCount: raw.unread_count,
970
+ lastReadMessageId: raw.last_read_message_id,
971
+ lastMessageId: raw.last_message_id,
972
+ };
973
+ }
974
+
975
+ // -------- freeze --------
976
+
977
+ /** OPERATOR-only moderation: freeze this room. Non-admin messages are
978
+ * blocked server-side with NC-ROOM-FROZEN while frozen. All members'
979
+ * clients receive a `roomEvent` with `event: "ROOM_FROZEN"` in real
980
+ * time, plus a SYSTEM chat message "방이 얼려졌습니다."
981
+ * Idempotent — no-op if already frozen. Non-operator callers get 403. */
982
+ async freeze(): Promise<void> {
983
+ await this.http.request<void>(
984
+ "POST", `/api/v1/rooms/${this.roomId}/freeze`,
985
+ );
986
+ }
987
+
988
+ /** Mirror of `freeze()` — reopens the room to chat_send. Emits a
989
+ * `ROOM_UNFROZEN` roomEvent + a "방이 녹여졌습니다." SYSTEM message. */
990
+ async unfreeze(): Promise<void> {
991
+ await this.http.request<void>(
992
+ "POST", `/api/v1/rooms/${this.roomId}/unfreeze`,
993
+ );
994
+ }
995
+
996
+ /** OPERATOR-only: toggle room visibility. `true` → 공개방 (open chat,
997
+ * anyone can join). `false` → 비공개방 (invite-only, join requires
998
+ * approval). Auto-inserts a SYSTEM chat message and broadcasts a
999
+ * `ROOM_VISIBILITY_CHANGED` roomEvent so peers update in real time. */
1000
+ async setPublic(isPublic: boolean): Promise<void> {
1001
+ await this.http.request<void>(
1002
+ "PATCH", `/api/v1/rooms/${this.roomId}/visibility`,
1003
+ { is_public: isPublic },
1004
+ );
1005
+ }
1006
+
1007
+ // -------- announcements --------
1008
+
1009
+ async listAnnouncements(): Promise<Announcement[]> {
1010
+ const raw = await this.http.request<Array<{
1011
+ id: number;
1012
+ room_id: string;
1013
+ content: string;
1014
+ created_by: string;
1015
+ created_at: string;
1016
+ updated_at: string;
1017
+ }>>("GET", `/api/v1/rooms/${this.roomId}/announcements`);
1018
+ return raw.map((a) => ({
1019
+ id: a.id,
1020
+ roomId: a.room_id,
1021
+ content: a.content,
1022
+ createdBy: a.created_by,
1023
+ createdAt: a.created_at,
1024
+ updatedAt: a.updated_at,
1025
+ }));
1026
+ }
1027
+
1028
+ async getCurrentAnnouncement(): Promise<Announcement | null> {
1029
+ const raw = await this.http.request<{
1030
+ id: number;
1031
+ room_id: string;
1032
+ content: string;
1033
+ created_by: string;
1034
+ created_at: string;
1035
+ updated_at: string;
1036
+ } | null>("GET", `/api/v1/rooms/${this.roomId}/announcements/current`);
1037
+ if (!raw) return null;
1038
+ return {
1039
+ id: raw.id,
1040
+ roomId: raw.room_id,
1041
+ content: raw.content,
1042
+ createdBy: raw.created_by,
1043
+ createdAt: raw.created_at,
1044
+ updatedAt: raw.updated_at,
1045
+ };
1046
+ }
1047
+
1048
+ async postAnnouncement(content: string): Promise<Announcement> {
1049
+ const raw = await this.http.request<{
1050
+ id: number;
1051
+ room_id: string;
1052
+ content: string;
1053
+ created_by: string;
1054
+ created_at: string;
1055
+ updated_at: string;
1056
+ }>("POST", `/api/v1/rooms/${this.roomId}/announcements`, { content });
1057
+ return {
1058
+ id: raw.id,
1059
+ roomId: raw.room_id,
1060
+ content: raw.content,
1061
+ createdBy: raw.created_by,
1062
+ createdAt: raw.created_at,
1063
+ updatedAt: raw.updated_at,
1064
+ };
1065
+ }
1066
+
1067
+ async updateAnnouncement(id: number, content: string): Promise<Announcement> {
1068
+ const raw = await this.http.request<{
1069
+ id: number;
1070
+ room_id: string;
1071
+ content: string;
1072
+ created_by: string;
1073
+ created_at: string;
1074
+ updated_at: string;
1075
+ }>("PATCH", `/api/v1/rooms/${this.roomId}/announcements/${id}`, { content });
1076
+ return {
1077
+ id: raw.id,
1078
+ roomId: raw.room_id,
1079
+ content: raw.content,
1080
+ createdBy: raw.created_by,
1081
+ createdAt: raw.created_at,
1082
+ updatedAt: raw.updated_at,
1083
+ };
1084
+ }
1085
+
1086
+ async deleteAnnouncement(id: number): Promise<void> {
1087
+ await this.http.request<void>(
1088
+ "DELETE", `/api/v1/rooms/${this.roomId}/announcements/${id}`,
1089
+ );
1090
+ }
1091
+
1092
+ // -------- invite links --------
1093
+
1094
+ /**
1095
+ * OPERATOR-only: mint a new invite link for this room. Default:
1096
+ * expires in 7 days, unlimited uses. Pass `expiresInHours: null`
1097
+ * for a link that never expires, or `maxUses: 1` for a one-shot.
1098
+ *
1099
+ * Returns the raw `InviteToken` — build the shareable URL yourself
1100
+ * (e.g. `${location.origin}/#invite/${token.token}`) so you control
1101
+ * the format your app uses to consume it.
1102
+ */
1103
+ async createInvite(opts?: {
1104
+ /** Hours until the link stops working. Default 168 (7 days). Pass
1105
+ * `null` for no expiry. */
1106
+ expiresInHours?: number | null;
1107
+ /** Max successful `acceptInvite` calls before the link
1108
+ * auto-exhausts. Default null (unlimited). */
1109
+ maxUses?: number | null;
1110
+ }): Promise<InviteToken> {
1111
+ const body: Record<string, unknown> = {};
1112
+ if (opts && "expiresInHours" in opts) body.expires_in_hours = opts.expiresInHours;
1113
+ if (opts && "maxUses" in opts) body.max_uses = opts.maxUses;
1114
+ const raw = await this.http.request<{
1115
+ token: string;
1116
+ room_id: string;
1117
+ created_by: string;
1118
+ created_at: string;
1119
+ expires_at: string | null;
1120
+ max_uses: number | null;
1121
+ used_count: number;
1122
+ revoked_at: string | null;
1123
+ }>("POST", `/api/v1/rooms/${this.roomId}/invites`, body);
1124
+ return {
1125
+ token: raw.token,
1126
+ roomId: raw.room_id,
1127
+ createdBy: raw.created_by,
1128
+ createdAt: raw.created_at,
1129
+ expiresAt: raw.expires_at,
1130
+ maxUses: raw.max_uses,
1131
+ usedCount: raw.used_count,
1132
+ revokedAt: raw.revoked_at,
1133
+ };
1134
+ }
1135
+
1136
+ /** OPERATOR-only: list all invite tokens for this room (including
1137
+ * revoked / expired / exhausted), newest first. */
1138
+ async listInvites(): Promise<InviteToken[]> {
1139
+ const raw = await this.http.request<Array<{
1140
+ token: string;
1141
+ room_id: string;
1142
+ created_by: string;
1143
+ created_at: string;
1144
+ expires_at: string | null;
1145
+ max_uses: number | null;
1146
+ used_count: number;
1147
+ revoked_at: string | null;
1148
+ }>>("GET", `/api/v1/rooms/${this.roomId}/invites`);
1149
+ return raw.map((r) => ({
1150
+ token: r.token,
1151
+ roomId: r.room_id,
1152
+ createdBy: r.created_by,
1153
+ createdAt: r.created_at,
1154
+ expiresAt: r.expires_at,
1155
+ maxUses: r.max_uses,
1156
+ usedCount: r.used_count,
1157
+ revokedAt: r.revoked_at,
1158
+ }));
1159
+ }
1160
+
1161
+ /** OPERATOR-only: revoke a token immediately. Idempotent — revoking
1162
+ * an already-revoked token returns 204 as well. */
1163
+ async revokeInvite(token: string): Promise<void> {
1164
+ await this.http.request<void>(
1165
+ "DELETE",
1166
+ `/api/v1/rooms/${this.roomId}/invites/${encodeURIComponent(token)}`,
1167
+ );
1168
+ }
1169
+
1170
+ // -------- internal dispatch --------
1171
+
1172
+ on<K extends keyof RoomEvents>(event: K, fn: (p: RoomEvents[K]) => void): () => void {
1173
+ return this.bus.on(event, fn);
1174
+ }
1175
+
1176
+ off<K extends keyof RoomEvents>(event: K, fn: (p: RoomEvents[K]) => void): void {
1177
+ this.bus.off(event, fn);
1178
+ }
1179
+
1180
+ _dispatchAnnouncement(msg: WsAnnouncementEvent): void {
1181
+ this.bus.emit("announcement", msg);
1182
+ }
1183
+
1184
+ _dispatchAck(tempId: string | null, messageId: MessageIdStr): void {
1185
+ if (!tempId) return;
1186
+ const entry = this.pendingAcks.get(tempId);
1187
+ if (entry) {
1188
+ entry.resolve(messageId);
1189
+ this.pendingAcks.delete(tempId);
1190
+ this.mySentTempIds.delete(tempId);
1191
+ this.bus.emit("ack", { tempId, messageId });
1192
+ }
1193
+ }
1194
+
1195
+ /** True iff this room has a still-pending send with this temp_id. */
1196
+ _hasPendingTempId(tempId: string): boolean {
1197
+ return this.pendingAcks.has(tempId);
1198
+ }
1199
+
1200
+ /** Reject the pending send for tempId with the given error. Used by the
1201
+ * client when a server `error` frame echoes back our temp_id so we can
1202
+ * fail the matching outbound promise immediately instead of waiting
1203
+ * for the 15s ack timeout. */
1204
+ _rejectPending(tempId: string, err: Error): void {
1205
+ const entry = this.pendingAcks.get(tempId);
1206
+ if (!entry) return;
1207
+ entry.reject(err);
1208
+ this.pendingAcks.delete(tempId);
1209
+ this.mySentTempIds.delete(tempId);
1210
+ }
1211
+
1212
+ /**
1213
+ * Try to re-send a pending frame after a transient server reject
1214
+ * (currently only NC-RATE-001 — the tenant's per-second token bucket
1215
+ * spilled). Reuses the same temp_id so:
1216
+ * 1. ack matching on success still works,
1217
+ * 2. self-echo dedup still recognizes the frame as ours,
1218
+ * 3. *if* the server's first attempt secretly succeeded (we got an
1219
+ * error frame but the message was already persisted) the second
1220
+ * attempt's `chat_receive` self-echo collapses cleanly.
1221
+ *
1222
+ * Returns true if a retry was scheduled; false if we've already used
1223
+ * up the retry budget and the caller should fall back to rejecting
1224
+ * the promise.
1225
+ */
1226
+ _retryPending(tempId: string): boolean {
1227
+ const entry = this.pendingAcks.get(tempId);
1228
+ if (!entry) return false;
1229
+ if (entry.retries >= Room.MAX_RATE_LIMIT_RETRIES) return false;
1230
+ const idx = entry.retries;
1231
+ entry.retries += 1;
1232
+ const delay = Room.RATE_LIMIT_BACKOFF_MS[idx] ?? 1200;
1233
+ setTimeout(() => {
1234
+ // The entry may have been resolved/rejected in the meantime (ack
1235
+ // arrived late, or an explicit disconnect cleared the map). Bail
1236
+ // if so — the resend would create a frame the server can't pair
1237
+ // to any outbound promise.
1238
+ if (!this.pendingAcks.has(tempId)) return;
1239
+ const ok = this.ws.send(entry.payload);
1240
+ if (!ok) {
1241
+ // ws.send already buffered to outboundQueue if appropriate; only
1242
+ // returns false on explicit close. Treat as terminal failure.
1243
+ this._rejectPending(tempId, new Error("ws closed during retry"));
1244
+ }
1245
+ }, delay);
1246
+ return true;
1247
+ }
1248
+
1249
+ /** True if this room originated a `chat_send` with the given temp_id
1250
+ * during the current session. Used by the client dispatcher to detect
1251
+ * self-echo chat_receive frames. */
1252
+ _isMySentTempId(tempId: string): boolean {
1253
+ return this.mySentTempIds.has(tempId);
1254
+ }
1255
+
1256
+ /** Forget a self-sent temp_id once both the ack and the echo are reconciled. */
1257
+ _forgetMySentTempId(tempId: string): void {
1258
+ this.mySentTempIds.delete(tempId);
1259
+ }
1260
+
1261
+ _dispatchInbound(msg: Extract<
1262
+ import("../types.js").WsInbound,
1263
+ { room_id?: string } | { type: string }
1264
+ >): void {
1265
+ switch (msg.type) {
1266
+ case "chat_receive":
1267
+ this.bus.emit("message", msg);
1268
+ return;
1269
+ case "message_updated":
1270
+ this.bus.emit("messageUpdated", msg);
1271
+ return;
1272
+ case "message_deleted":
1273
+ this.bus.emit("messageDeleted", msg);
1274
+ return;
1275
+ case "messages_cleared":
1276
+ this.bus.emit("messagesCleared", msg);
1277
+ return;
1278
+ case "reaction_added":
1279
+ this.bus.emit("reactionAdded", msg);
1280
+ return;
1281
+ case "reaction_removed":
1282
+ this.bus.emit("reactionRemoved", msg);
1283
+ return;
1284
+ case "sync_tick":
1285
+ this.bus.emit("syncTick", msg);
1286
+ return;
1287
+ case "typing":
1288
+ this.bus.emit("typing", msg);
1289
+ return;
1290
+ }
1291
+ }
1292
+ }