@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.
- package/LICENSE +21 -0
- package/README.ko.md +86 -0
- package/README.md +86 -0
- package/dist/index.cjs +1737 -0
- package/dist/index.d.cts +1151 -0
- package/dist/index.d.ts +1151 -0
- package/dist/index.js +1707 -0
- package/package.json +43 -0
- package/src/client.ts +733 -0
- package/src/errors.ts +59 -0
- package/src/features/room.ts +1292 -0
- package/src/generated/README.ko.md +15 -0
- package/src/generated/README.md +15 -0
- package/src/generated/wire.ts +224 -0
- package/src/index.ts +27 -0
- package/src/internal/event-bus.ts +42 -0
- package/src/internal/temp-id.ts +10 -0
- package/src/transport/http.ts +81 -0
- package/src/transport/ws.ts +200 -0
- package/src/types.ts +375 -0
- package/src/utils/debounce.ts +14 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ErrorCode = {
|
|
3
|
+
INTERNAL: "NC-GEN-001",
|
|
4
|
+
NOT_FOUND: "NC-GEN-002",
|
|
5
|
+
VALIDATION: "NC-GEN-003",
|
|
6
|
+
FORBIDDEN: "NC-GEN-004",
|
|
7
|
+
UNAUTHORIZED: "NC-AUTH-001",
|
|
8
|
+
TOKEN_EXPIRED: "NC-AUTH-002",
|
|
9
|
+
TOKEN_INVALID: "NC-AUTH-003",
|
|
10
|
+
TOKEN_REVOKED: "NC-AUTH-004",
|
|
11
|
+
APP_INACTIVE: "NC-APP-001",
|
|
12
|
+
APP_NOT_FOUND: "NC-APP-002",
|
|
13
|
+
USER_BANNED: "NC-USER-001",
|
|
14
|
+
USER_NOT_FOUND: "NC-USER-002",
|
|
15
|
+
ROOM_NOT_FOUND: "NC-ROOM-001",
|
|
16
|
+
ROOM_FROZEN: "NC-ROOM-002",
|
|
17
|
+
NOT_A_MEMBER: "NC-ROOM-003",
|
|
18
|
+
MESSAGE_TOO_LARGE: "NC-MSG-001",
|
|
19
|
+
MESSAGE_NOT_FOUND: "NC-MSG-002",
|
|
20
|
+
MESSAGE_EDIT_FORBID: "NC-MSG-003",
|
|
21
|
+
RATE_LIMITED: "NC-RATE-001"
|
|
22
|
+
};
|
|
23
|
+
var NoveraChatError = class _NoveraChatError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
status;
|
|
26
|
+
details;
|
|
27
|
+
constructor(message, code = ErrorCode.INTERNAL, status = 0, details) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "NoveraChatError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.status = status;
|
|
32
|
+
if (details !== void 0) this.details = details;
|
|
33
|
+
}
|
|
34
|
+
static fromResponse(status, body) {
|
|
35
|
+
const rec = body;
|
|
36
|
+
const code = rec?.error?.code ?? ErrorCode.INTERNAL;
|
|
37
|
+
const msg = rec?.error?.message ?? `HTTP ${status}`;
|
|
38
|
+
const details = rec?.error?.details;
|
|
39
|
+
return details !== void 0 ? new _NoveraChatError(msg, code, status, details) : new _NoveraChatError(msg, code, status);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// src/transport/http.ts
|
|
44
|
+
var HttpClient = class {
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
this.opts = opts;
|
|
47
|
+
}
|
|
48
|
+
opts;
|
|
49
|
+
async request(method, path, body, query) {
|
|
50
|
+
const token = await this.opts.tokenProvider();
|
|
51
|
+
const url = this.buildUrl(path, query);
|
|
52
|
+
const init = {
|
|
53
|
+
method,
|
|
54
|
+
headers: {
|
|
55
|
+
"Authorization": `Bearer ${token}`,
|
|
56
|
+
"X-Noverachat-App-Id": this.opts.appId,
|
|
57
|
+
...body != null ? { "Content-Type": "application/json" } : {}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
if (body != null) init.body = JSON.stringify(body);
|
|
61
|
+
const fetchFn = this.opts.fetch ?? globalThis.fetch;
|
|
62
|
+
this.opts.logger?.(
|
|
63
|
+
"debug",
|
|
64
|
+
`rest_req ${method} ${path}`,
|
|
65
|
+
body != null ? { body, query } : { query }
|
|
66
|
+
);
|
|
67
|
+
const startedAt = Date.now();
|
|
68
|
+
const res = await fetchFn(url, init);
|
|
69
|
+
const elapsedMs = Date.now() - startedAt;
|
|
70
|
+
if (res.status === 204) {
|
|
71
|
+
this.opts.logger?.(
|
|
72
|
+
"debug",
|
|
73
|
+
`rest_res ${method} ${path} 204 ${elapsedMs}ms`
|
|
74
|
+
);
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
79
|
+
this.opts.logger?.(
|
|
80
|
+
"debug",
|
|
81
|
+
`rest_res ${method} ${path} ${res.status} ${elapsedMs}ms`,
|
|
82
|
+
parsed
|
|
83
|
+
);
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
throw NoveraChatError.fromResponse(res.status, parsed);
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
buildUrl(path, query) {
|
|
90
|
+
const base = this.opts.baseUrl.replace(/\/+$/, "");
|
|
91
|
+
const url = new URL(base + path);
|
|
92
|
+
if (query) {
|
|
93
|
+
for (const [k, v] of Object.entries(query)) {
|
|
94
|
+
if (v !== void 0 && v !== null) url.searchParams.set(k, String(v));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return url.toString();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/transport/ws.ts
|
|
102
|
+
var NO_RECONNECT = /* @__PURE__ */ new Set([1008, 4001, 4002, 4003]);
|
|
103
|
+
var ReconnectingWs = class _ReconnectingWs {
|
|
104
|
+
constructor(opts) {
|
|
105
|
+
this.opts = opts;
|
|
106
|
+
}
|
|
107
|
+
opts;
|
|
108
|
+
ws = null;
|
|
109
|
+
state = "idle";
|
|
110
|
+
attempt = 0;
|
|
111
|
+
pingTimer = null;
|
|
112
|
+
explicitClose = false;
|
|
113
|
+
// Outbound buffer used while the socket is transiently down (reconnect
|
|
114
|
+
// window between a server-side close and the next successful open). FIFO.
|
|
115
|
+
// Capped to avoid an unbounded queue if the server is hard-down — chat_send
|
|
116
|
+
// bursts past the cap are dropped (caller sees `send -> false` the usual
|
|
117
|
+
// way). Pings and typing frames are NOT queued: pings are stateless
|
|
118
|
+
// heartbeats and a stale typing event is worse than no event.
|
|
119
|
+
outboundQueue = [];
|
|
120
|
+
static OUTBOUND_QUEUE_CAP = 200;
|
|
121
|
+
get currentState() {
|
|
122
|
+
return this.state;
|
|
123
|
+
}
|
|
124
|
+
async connect() {
|
|
125
|
+
this.explicitClose = false;
|
|
126
|
+
await this.openOnce();
|
|
127
|
+
}
|
|
128
|
+
close() {
|
|
129
|
+
this.explicitClose = true;
|
|
130
|
+
this.setState("closed");
|
|
131
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
132
|
+
this.pingTimer = null;
|
|
133
|
+
this.outboundQueue = [];
|
|
134
|
+
this.ws?.close(1e3, "client close");
|
|
135
|
+
this.ws = null;
|
|
136
|
+
}
|
|
137
|
+
send(msg) {
|
|
138
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
139
|
+
this.ws.send(JSON.stringify(msg));
|
|
140
|
+
this.opts.logger?.("debug", `ws_send ${msg.type}`, msg);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (msg.type === "ping" || msg.type === "typing") return false;
|
|
144
|
+
if (this.explicitClose || this.state === "closed") return false;
|
|
145
|
+
if (this.outboundQueue.length >= _ReconnectingWs.OUTBOUND_QUEUE_CAP) {
|
|
146
|
+
this.opts.logger?.(
|
|
147
|
+
"warn",
|
|
148
|
+
"ws_outbound_queue_full",
|
|
149
|
+
{ cap: _ReconnectingWs.OUTBOUND_QUEUE_CAP, dropping: msg.type }
|
|
150
|
+
);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
this.outboundQueue.push(msg);
|
|
154
|
+
this.opts.logger?.(
|
|
155
|
+
"debug",
|
|
156
|
+
`ws_send_queued ${msg.type}`,
|
|
157
|
+
{ queueLen: this.outboundQueue.length }
|
|
158
|
+
);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
async openOnce() {
|
|
162
|
+
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
163
|
+
const WSImpl = this.opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
164
|
+
if (!WSImpl) throw new Error("No WebSocket implementation available");
|
|
165
|
+
let url;
|
|
166
|
+
try {
|
|
167
|
+
url = await this.opts.urlBuilder();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
this.opts.logger?.("error", "url_builder_failed", err);
|
|
170
|
+
this.scheduleReconnect();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const ws = new WSImpl(url);
|
|
174
|
+
this.ws = ws;
|
|
175
|
+
ws.onopen = () => {
|
|
176
|
+
this.attempt = 0;
|
|
177
|
+
this.setState("open");
|
|
178
|
+
this.startPings();
|
|
179
|
+
if (this.outboundQueue.length > 0) {
|
|
180
|
+
const drained = this.outboundQueue;
|
|
181
|
+
this.outboundQueue = [];
|
|
182
|
+
this.opts.logger?.("info", "ws_outbound_drained", { count: drained.length });
|
|
183
|
+
for (const m of drained) {
|
|
184
|
+
try {
|
|
185
|
+
ws.send(JSON.stringify(m));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this.opts.logger?.("warn", "ws_outbound_drain_failed", err);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
this.opts.onOpen?.();
|
|
192
|
+
};
|
|
193
|
+
ws.onmessage = (ev) => {
|
|
194
|
+
try {
|
|
195
|
+
const data = typeof ev.data === "string" ? ev.data : ev.data.toString();
|
|
196
|
+
const parsed = JSON.parse(data);
|
|
197
|
+
this.opts.logger?.(
|
|
198
|
+
"debug",
|
|
199
|
+
`ws_recv ${parsed.type ?? "?"}`,
|
|
200
|
+
parsed
|
|
201
|
+
);
|
|
202
|
+
this.opts.onMessage(parsed);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
this.opts.logger?.("warn", "ws_parse_failed", err);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
ws.onerror = (ev) => {
|
|
208
|
+
this.opts.logger?.("warn", "ws_error", ev);
|
|
209
|
+
};
|
|
210
|
+
ws.onclose = (ev) => {
|
|
211
|
+
this.stopPings();
|
|
212
|
+
this.ws = null;
|
|
213
|
+
if (this.explicitClose) {
|
|
214
|
+
this.setState("closed");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (NO_RECONNECT.has(ev.code)) {
|
|
218
|
+
this.opts.logger?.("warn", "ws_fatal_close", { code: ev.code, reason: ev.reason });
|
|
219
|
+
this.setState("closed");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (this.opts.autoReconnect) this.scheduleReconnect();
|
|
223
|
+
else this.setState("closed");
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
scheduleReconnect() {
|
|
227
|
+
this.attempt += 1;
|
|
228
|
+
const base = 500;
|
|
229
|
+
const cap = this.opts.maxReconnectDelayMs;
|
|
230
|
+
const exp = Math.min(cap, base * 2 ** (this.attempt - 1));
|
|
231
|
+
const jitter = Math.floor(Math.random() * base);
|
|
232
|
+
const delay = exp + jitter;
|
|
233
|
+
this.opts.logger?.("info", "ws_reconnect_scheduled", { attempt: this.attempt, delay });
|
|
234
|
+
this.setState("reconnecting");
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
void this.openOnce();
|
|
237
|
+
}, delay);
|
|
238
|
+
}
|
|
239
|
+
startPings() {
|
|
240
|
+
this.stopPings();
|
|
241
|
+
this.pingTimer = setInterval(() => {
|
|
242
|
+
this.send({ type: "ping", client_ts: Date.now() });
|
|
243
|
+
}, this.opts.pingIntervalMs);
|
|
244
|
+
}
|
|
245
|
+
stopPings() {
|
|
246
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
247
|
+
this.pingTimer = null;
|
|
248
|
+
}
|
|
249
|
+
setState(s) {
|
|
250
|
+
if (s === this.state) return;
|
|
251
|
+
this.state = s;
|
|
252
|
+
this.opts.onStateChange?.(s);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/internal/event-bus.ts
|
|
257
|
+
var EventBus = class {
|
|
258
|
+
handlers = {};
|
|
259
|
+
on(event, fn) {
|
|
260
|
+
let set = this.handlers[event];
|
|
261
|
+
if (!set) {
|
|
262
|
+
set = /* @__PURE__ */ new Set();
|
|
263
|
+
this.handlers[event] = set;
|
|
264
|
+
}
|
|
265
|
+
set.add(fn);
|
|
266
|
+
return () => this.off(event, fn);
|
|
267
|
+
}
|
|
268
|
+
off(event, fn) {
|
|
269
|
+
this.handlers[event]?.delete(fn);
|
|
270
|
+
}
|
|
271
|
+
emit(event, payload) {
|
|
272
|
+
const set = this.handlers[event];
|
|
273
|
+
if (!set) return;
|
|
274
|
+
for (const h of set) {
|
|
275
|
+
try {
|
|
276
|
+
h(payload);
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
removeAll() {
|
|
282
|
+
this.handlers = {};
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// src/internal/temp-id.ts
|
|
287
|
+
function tempId() {
|
|
288
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
289
|
+
return `tmp_${crypto.randomUUID()}`;
|
|
290
|
+
}
|
|
291
|
+
const bytes = Array.from({ length: 16 }, () => Math.floor(Math.random() * 256));
|
|
292
|
+
return "tmp_" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/features/room.ts
|
|
296
|
+
function toMessageOut(r) {
|
|
297
|
+
const dataObj = r.data ?? null;
|
|
298
|
+
const customMeta = dataObj && typeof dataObj === "object" && "_customMeta" in dataObj ? dataObj._customMeta : null;
|
|
299
|
+
return {
|
|
300
|
+
appId: r.app_id,
|
|
301
|
+
roomId: r.room_id,
|
|
302
|
+
messageId: r.message_id,
|
|
303
|
+
sender: r.sender ? {
|
|
304
|
+
userId: r.sender.user_id,
|
|
305
|
+
nickname: r.sender.nickname ?? null,
|
|
306
|
+
profileImageUrl: r.sender.profile_image_url ?? null
|
|
307
|
+
} : null,
|
|
308
|
+
senderId: r.sender_id ?? null,
|
|
309
|
+
messageType: r.message_type,
|
|
310
|
+
customType: r.custom_type ?? null,
|
|
311
|
+
content: r.content ?? null,
|
|
312
|
+
data: dataObj,
|
|
313
|
+
meta: r.meta ?? null,
|
|
314
|
+
customMeta,
|
|
315
|
+
fileId: r.file_id ?? null,
|
|
316
|
+
file: r.file ? {
|
|
317
|
+
id: r.file.id,
|
|
318
|
+
kind: r.file.kind,
|
|
319
|
+
mime: r.file.mime,
|
|
320
|
+
size: r.file.size,
|
|
321
|
+
name: r.file.name ?? null,
|
|
322
|
+
width: r.file.width ?? null,
|
|
323
|
+
height: r.file.height ?? null,
|
|
324
|
+
durationSec: r.file.duration_sec ?? null,
|
|
325
|
+
thumbnailStatus: r.file.thumbnail_status,
|
|
326
|
+
forwardBlocked: Boolean(r.file.forward_blocked)
|
|
327
|
+
} : null,
|
|
328
|
+
files: r.files && r.files.length > 0 ? r.files.map((f) => ({
|
|
329
|
+
id: f.id,
|
|
330
|
+
kind: f.kind,
|
|
331
|
+
mime: f.mime,
|
|
332
|
+
size: f.size,
|
|
333
|
+
name: f.name ?? null,
|
|
334
|
+
width: f.width ?? null,
|
|
335
|
+
height: f.height ?? null,
|
|
336
|
+
durationSec: f.duration_sec ?? null,
|
|
337
|
+
thumbnailStatus: f.thumbnail_status,
|
|
338
|
+
forwardBlocked: Boolean(f.forward_blocked)
|
|
339
|
+
})) : null,
|
|
340
|
+
replyToId: r.reply_to_id ?? null,
|
|
341
|
+
reactions: (r.reactions ?? []).map((x) => ({
|
|
342
|
+
key: x.key,
|
|
343
|
+
userIds: x.user_ids,
|
|
344
|
+
updatedAt: x.updated_at
|
|
345
|
+
})),
|
|
346
|
+
createdAt: r.created_at,
|
|
347
|
+
createdAtMs: r.created_at_ms,
|
|
348
|
+
updatedAt: r.updated_at ?? null,
|
|
349
|
+
updatedAtMs: r.updated_at_ms ?? null,
|
|
350
|
+
deletedAt: r.deleted_at ?? null,
|
|
351
|
+
deletedByUserId: r.deleted_by_user_id ?? null,
|
|
352
|
+
deleteScope: r.delete_scope ?? null,
|
|
353
|
+
editedCount: r.edited_count ?? 0
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
var Room = class _Room {
|
|
357
|
+
constructor(roomId, http, ws, readDebounceMs) {
|
|
358
|
+
this.roomId = roomId;
|
|
359
|
+
this.http = http;
|
|
360
|
+
this.ws = ws;
|
|
361
|
+
this.readDebounceMs = readDebounceMs;
|
|
362
|
+
}
|
|
363
|
+
roomId;
|
|
364
|
+
http;
|
|
365
|
+
ws;
|
|
366
|
+
bus = new EventBus();
|
|
367
|
+
// tempId → resolver pair. We keep `reject` alongside `resolve` so an
|
|
368
|
+
// incoming `error` frame carrying the offending temp_id (e.g.
|
|
369
|
+
// NC-RATE-001) can fail the send immediately rather than letting the
|
|
370
|
+
// 15s ack timeout fire — without correlation, a user hitting the
|
|
371
|
+
// per-tenant rate limit sees the chat hang on "PENDING…" forever.
|
|
372
|
+
//
|
|
373
|
+
// `payload` and `retries` enable transparent auto-retry of rate-limited
|
|
374
|
+
// sends: client.ts re-fires the same WS frame (same temp_id) with
|
|
375
|
+
// exponential backoff up to `MAX_RETRIES` instead of immediately failing
|
|
376
|
+
// the user's promise. Same temp_id keeps self-echo dedup correct in
|
|
377
|
+
// case the server actually processed the first attempt.
|
|
378
|
+
pendingAcks = /* @__PURE__ */ new Map();
|
|
379
|
+
// Retry policy for rate-limited (NC-RATE-001) sends. We cap at 1
|
|
380
|
+
// automatic retry — enough to swallow the normal jitter-induced spillover
|
|
381
|
+
// at the per-second token bucket boundary, but anything beyond that is
|
|
382
|
+
// a sustained rate-limit problem that the SDK shouldn't silently paper
|
|
383
|
+
// over. Subsequent failures bubble up to the caller so the UI can offer
|
|
384
|
+
// an explicit "Retry" button — manual retry is the right UX once we
|
|
385
|
+
// know the auto path didn't fix it.
|
|
386
|
+
// Backoff: ~300ms — long enough to land in a fresh 1s token bucket.
|
|
387
|
+
static MAX_RATE_LIMIT_RETRIES = 1;
|
|
388
|
+
static RATE_LIMIT_BACKOFF_MS = [300];
|
|
389
|
+
// Highest id we've sent via `read_watermark`. Used to drop redundant
|
|
390
|
+
// frames inside the debounce window. Compared via BigInt.
|
|
391
|
+
lastReadSent = null;
|
|
392
|
+
// Pending read_watermark — set by markRead, flushed after readDebounceMs
|
|
393
|
+
// OR immediately on flushMarkRead().
|
|
394
|
+
pendingReadMid = null;
|
|
395
|
+
readDebounceTimer = null;
|
|
396
|
+
readDebounceMs;
|
|
397
|
+
/**
|
|
398
|
+
* Every temp_id we've sent this session. The server echoes the same
|
|
399
|
+
* temp_id back on `chat_receive` for the sender's own sessions, so the
|
|
400
|
+
* client can dedup against its optimistic bubble regardless of whether
|
|
401
|
+
* the `ack` or the `chat_receive` arrives first. Unbounded growth is
|
|
402
|
+
* fine in practice (small strings, cleared on `chat.disconnect()`).
|
|
403
|
+
*/
|
|
404
|
+
mySentTempIds = /* @__PURE__ */ new Set();
|
|
405
|
+
_sendPendingMarkRead() {
|
|
406
|
+
const mid = this.pendingReadMid;
|
|
407
|
+
if (!mid) return;
|
|
408
|
+
if (this.lastReadSent !== null) {
|
|
409
|
+
try {
|
|
410
|
+
if (BigInt(mid) <= BigInt(this.lastReadSent)) {
|
|
411
|
+
this.pendingReadMid = null;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
if (mid <= this.lastReadSent) {
|
|
416
|
+
this.pendingReadMid = null;
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const ok = this.ws.send({
|
|
422
|
+
type: "read_watermark",
|
|
423
|
+
room_id: this.roomId,
|
|
424
|
+
last_read_message_id: mid
|
|
425
|
+
});
|
|
426
|
+
if (ok) {
|
|
427
|
+
this.lastReadSent = mid;
|
|
428
|
+
this.pendingReadMid = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// -------- outgoing --------
|
|
432
|
+
async send(params) {
|
|
433
|
+
const p = typeof params === "string" ? { content: params } : params;
|
|
434
|
+
const tempId2 = tempId();
|
|
435
|
+
const hasBundle = Array.isArray(p.fileIds) && p.fileIds.length > 0;
|
|
436
|
+
const messageType = p.messageType ?? (hasBundle || p.fileId ? "FILE" : "TEXT");
|
|
437
|
+
const baseData = p.data ?? {};
|
|
438
|
+
let outData = hasBundle ? {
|
|
439
|
+
...baseData,
|
|
440
|
+
kind: "file_group",
|
|
441
|
+
file_ids: p.fileIds,
|
|
442
|
+
forward_blocked: Boolean(p.forwardBlocked)
|
|
443
|
+
} : p.data ?? null;
|
|
444
|
+
if (p.customMeta) {
|
|
445
|
+
outData = { ...outData ?? {}, _customMeta: p.customMeta };
|
|
446
|
+
}
|
|
447
|
+
const payload = {
|
|
448
|
+
type: "chat_send",
|
|
449
|
+
room_id: this.roomId,
|
|
450
|
+
message_type: messageType,
|
|
451
|
+
content: p.content ?? null,
|
|
452
|
+
custom_type: p.customType ?? null,
|
|
453
|
+
data: outData,
|
|
454
|
+
meta: p.meta ?? null,
|
|
455
|
+
// Bundle path sends file_id=null; the ids live in data.file_ids.
|
|
456
|
+
file_id: hasBundle ? null : p.fileId ?? null,
|
|
457
|
+
reply_to_id: p.replyToId ?? null,
|
|
458
|
+
forward_of_message_id: p.forwardOfMessageId ?? null,
|
|
459
|
+
temp_id: tempId2,
|
|
460
|
+
client_ts: Date.now()
|
|
461
|
+
};
|
|
462
|
+
this.mySentTempIds.add(tempId2);
|
|
463
|
+
const messageId = new Promise((resolve, reject) => {
|
|
464
|
+
const timer = setTimeout(() => {
|
|
465
|
+
this.pendingAcks.delete(tempId2);
|
|
466
|
+
this.mySentTempIds.delete(tempId2);
|
|
467
|
+
reject(new Error("ack timeout"));
|
|
468
|
+
}, 15e3);
|
|
469
|
+
this.pendingAcks.set(tempId2, {
|
|
470
|
+
resolve: (id) => {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
resolve(id);
|
|
473
|
+
},
|
|
474
|
+
reject: (err) => {
|
|
475
|
+
clearTimeout(timer);
|
|
476
|
+
reject(err);
|
|
477
|
+
},
|
|
478
|
+
payload,
|
|
479
|
+
retries: 0,
|
|
480
|
+
timer
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
const sent = this.ws.send(payload);
|
|
484
|
+
if (!sent) {
|
|
485
|
+
this.pendingAcks.delete(tempId2);
|
|
486
|
+
this.mySentTempIds.delete(tempId2);
|
|
487
|
+
throw new Error("ws not open");
|
|
488
|
+
}
|
|
489
|
+
return { tempId: tempId2, messageId };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Send an admin-uploaded emoticon as a STICKER-like message.
|
|
493
|
+
*
|
|
494
|
+
* Thin wrapper over ``send()``: stamps ``messageType: "EMOTICON"`` and
|
|
495
|
+
* fills ``data`` with the pack / item identifiers + a stable URL so the
|
|
496
|
+
* receiving client can render the bubble without a separate fetch.
|
|
497
|
+
*
|
|
498
|
+
* Pass either an ``EmoticonItem``-shaped object (matching the response
|
|
499
|
+
* of ``GET /v1/emoticon-packs``) or the raw ids + url. ``caption`` is
|
|
500
|
+
* optional — KakaoTalk-style sticker bubbles render fine without one.
|
|
501
|
+
*/
|
|
502
|
+
async sendEmoticon(item, caption) {
|
|
503
|
+
return this.send({
|
|
504
|
+
content: caption ?? "",
|
|
505
|
+
messageType: "EMOTICON",
|
|
506
|
+
data: {
|
|
507
|
+
source: "emoticon",
|
|
508
|
+
pack_id: item.packId,
|
|
509
|
+
item_id: item.itemId,
|
|
510
|
+
file_id: item.fileId ?? "",
|
|
511
|
+
url: item.url
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Send a file as a message. Wraps the three-step file pipeline:
|
|
517
|
+
*
|
|
518
|
+
* 1. ``POST /files`` to presign an S3 PUT
|
|
519
|
+
* 2. ``PUT`` the bytes direct to S3 (XHR — so we can report
|
|
520
|
+
* progress; ``fetch`` has no upload progress event)
|
|
521
|
+
* 3. ``POST /files/{id}/commit`` to flip the row to ``committed``
|
|
522
|
+
* and enqueue the thumbnail worker
|
|
523
|
+
* 4. ``room.send({ fileId, messageType: "FILE" })`` to broadcast
|
|
524
|
+
* the message to the room
|
|
525
|
+
*
|
|
526
|
+
* Returns the same ``{ tempId, messageId }`` shape as ``send()`` —
|
|
527
|
+
* callers reconcile the optimistic bubble against the eventual ack
|
|
528
|
+
* the same way text messages do.
|
|
529
|
+
*
|
|
530
|
+
* On any error before the final ``send()`` (upload aborted, S3
|
|
531
|
+
* rejected, commit failed) the row in ``files`` stays at
|
|
532
|
+
* ``upload_status=pending`` and the background cleanup worker
|
|
533
|
+
* reclaims it within ``FILE_PENDING_UPLOAD_TTL_SEC`` (24h default)
|
|
534
|
+
* — no client-side cleanup needed.
|
|
535
|
+
*/
|
|
536
|
+
async sendFile(file, opts = {}) {
|
|
537
|
+
const kind = opts.kind ?? (file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : "file");
|
|
538
|
+
const name = opts.name ?? file.name;
|
|
539
|
+
const presign = await this.http.request("POST", "/api/v1/files", {
|
|
540
|
+
kind,
|
|
541
|
+
mime: file.type || "application/octet-stream",
|
|
542
|
+
size: file.size,
|
|
543
|
+
original_name: name,
|
|
544
|
+
// 파일 전달 제한 — uploader-set at presign time. When true,
|
|
545
|
+
// other users can't forward this file to a different room.
|
|
546
|
+
forward_blocked: Boolean(opts.forwardBlocked)
|
|
547
|
+
});
|
|
548
|
+
const presigned = {
|
|
549
|
+
fileId: presign.file_id,
|
|
550
|
+
uploadUrl: presign.upload_url,
|
|
551
|
+
headers: presign.headers,
|
|
552
|
+
expiresAtMs: presign.expires_at_ms
|
|
553
|
+
};
|
|
554
|
+
await new Promise((resolve, reject) => {
|
|
555
|
+
const xhr = new XMLHttpRequest();
|
|
556
|
+
xhr.open("PUT", presigned.uploadUrl);
|
|
557
|
+
for (const [k, v] of Object.entries(presigned.headers)) {
|
|
558
|
+
xhr.setRequestHeader(k, v);
|
|
559
|
+
}
|
|
560
|
+
if (opts.onProgress) {
|
|
561
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
562
|
+
if (!e.lengthComputable) return;
|
|
563
|
+
opts.onProgress({
|
|
564
|
+
loaded: e.loaded,
|
|
565
|
+
total: e.total,
|
|
566
|
+
ratio: e.total > 0 ? e.loaded / e.total : 0
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
xhr.onload = () => {
|
|
571
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
572
|
+
opts.onProgress?.({ loaded: file.size, total: file.size, ratio: 1 });
|
|
573
|
+
resolve();
|
|
574
|
+
} else {
|
|
575
|
+
reject(new Error(`S3 PUT failed: ${xhr.status} ${xhr.statusText}`));
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
xhr.onerror = () => reject(new Error("S3 PUT network error"));
|
|
579
|
+
xhr.onabort = () => reject(new Error("S3 PUT aborted"));
|
|
580
|
+
xhr.send(file);
|
|
581
|
+
});
|
|
582
|
+
await this.http.request(
|
|
583
|
+
"POST",
|
|
584
|
+
`/api/v1/files/${presigned.fileId}/commit`,
|
|
585
|
+
{}
|
|
586
|
+
);
|
|
587
|
+
const sendResult = await this.send({
|
|
588
|
+
fileId: presigned.fileId,
|
|
589
|
+
messageType: "FILE",
|
|
590
|
+
...opts.customMeta ? { customMeta: opts.customMeta } : {}
|
|
591
|
+
});
|
|
592
|
+
return { ...sendResult, fileId: presigned.fileId };
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Upload a file (presign → S3 PUT → commit) WITHOUT emitting a chat
|
|
596
|
+
* message. Returns the committed ``file_id`` so callers can gather
|
|
597
|
+
* several ids and bundle them into ONE ``room.send({ fileIds: [...] })``
|
|
598
|
+
* for the multi-file "file_group" message pattern.
|
|
599
|
+
*
|
|
600
|
+
* Same 3-step pipeline as ``sendFile()`` minus step 4. Errors before
|
|
601
|
+
* commit leave the file at ``upload_status=pending`` — the cleanup
|
|
602
|
+
* worker reclaims it within ``FILE_PENDING_UPLOAD_TTL_SEC``.
|
|
603
|
+
*/
|
|
604
|
+
async uploadFileOnly(file, opts = {}) {
|
|
605
|
+
const kind = opts.kind ?? (file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : "file");
|
|
606
|
+
const name = opts.name ?? file.name;
|
|
607
|
+
const presign = await this.http.request("POST", "/api/v1/files", {
|
|
608
|
+
kind,
|
|
609
|
+
mime: file.type || "application/octet-stream",
|
|
610
|
+
size: file.size,
|
|
611
|
+
original_name: name,
|
|
612
|
+
forward_blocked: Boolean(opts.forwardBlocked)
|
|
613
|
+
});
|
|
614
|
+
await new Promise((resolve, reject) => {
|
|
615
|
+
const xhr = new XMLHttpRequest();
|
|
616
|
+
xhr.open("PUT", presign.upload_url);
|
|
617
|
+
for (const [k, v] of Object.entries(presign.headers)) {
|
|
618
|
+
xhr.setRequestHeader(k, v);
|
|
619
|
+
}
|
|
620
|
+
if (opts.onProgress) {
|
|
621
|
+
xhr.upload.addEventListener("progress", (e) => {
|
|
622
|
+
if (!e.lengthComputable) return;
|
|
623
|
+
opts.onProgress({
|
|
624
|
+
loaded: e.loaded,
|
|
625
|
+
total: e.total,
|
|
626
|
+
ratio: e.total > 0 ? e.loaded / e.total : 0
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
xhr.onload = () => {
|
|
631
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
632
|
+
opts.onProgress?.({ loaded: file.size, total: file.size, ratio: 1 });
|
|
633
|
+
resolve();
|
|
634
|
+
} else {
|
|
635
|
+
reject(new Error(`S3 PUT failed: ${xhr.status} ${xhr.statusText}`));
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
xhr.onerror = () => reject(new Error("S3 PUT network error"));
|
|
639
|
+
xhr.onabort = () => reject(new Error("S3 PUT aborted"));
|
|
640
|
+
xhr.send(file);
|
|
641
|
+
});
|
|
642
|
+
await this.http.request(
|
|
643
|
+
"POST",
|
|
644
|
+
`/api/v1/files/${presign.file_id}/commit`,
|
|
645
|
+
{}
|
|
646
|
+
);
|
|
647
|
+
return presign.file_id;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* List files shared in this room — newest-first, paginated.
|
|
651
|
+
*
|
|
652
|
+
* One entry per non-deleted message that carries a ``file_id`` —
|
|
653
|
+
* forwarded files (same file_id, multiple messages) appear once
|
|
654
|
+
* per occurrence. The history-visibility floor is applied so files
|
|
655
|
+
* attached to messages older than the caller's ``last_cleared_at``
|
|
656
|
+
* (or the post-rejoin cutoff for grouprooms) are filtered out.
|
|
657
|
+
*
|
|
658
|
+
* Use ``before`` with the smallest ``messageId`` of the previous
|
|
659
|
+
* page to paginate.
|
|
660
|
+
*/
|
|
661
|
+
async listFiles(opts = {}) {
|
|
662
|
+
const raw = await this.http.request(
|
|
663
|
+
"GET",
|
|
664
|
+
`/api/v1/rooms/${encodeURIComponent(this.roomId)}/files`,
|
|
665
|
+
void 0,
|
|
666
|
+
{
|
|
667
|
+
limit: opts.limit ?? 50,
|
|
668
|
+
...opts.before ? { before: opts.before } : {}
|
|
669
|
+
}
|
|
670
|
+
);
|
|
671
|
+
return {
|
|
672
|
+
items: raw.items.map((r) => ({
|
|
673
|
+
messageId: r.message_id,
|
|
674
|
+
senderId: r.sender_id,
|
|
675
|
+
createdAtMs: r.created_at_ms,
|
|
676
|
+
file: {
|
|
677
|
+
id: r.file.id,
|
|
678
|
+
kind: r.file.kind,
|
|
679
|
+
mime: r.file.mime,
|
|
680
|
+
size: r.file.size_bytes,
|
|
681
|
+
name: r.file.original_name ?? null,
|
|
682
|
+
width: r.file.width ?? null,
|
|
683
|
+
height: r.file.height ?? null,
|
|
684
|
+
durationSec: r.file.duration_sec ?? null,
|
|
685
|
+
thumbnailStatus: r.file.thumbnail_status,
|
|
686
|
+
forwardBlocked: Boolean(r.file.forward_blocked)
|
|
687
|
+
}
|
|
688
|
+
})),
|
|
689
|
+
nextCursor: raw.next_cursor,
|
|
690
|
+
hasMore: raw.has_more
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
markRead(messageId) {
|
|
694
|
+
if (this.pendingReadMid !== null) {
|
|
695
|
+
try {
|
|
696
|
+
if (BigInt(messageId) <= BigInt(this.pendingReadMid)) return;
|
|
697
|
+
} catch {
|
|
698
|
+
if (messageId <= this.pendingReadMid) return;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
this.pendingReadMid = messageId;
|
|
702
|
+
if (this.readDebounceTimer) return;
|
|
703
|
+
this.readDebounceTimer = setTimeout(() => {
|
|
704
|
+
this.readDebounceTimer = null;
|
|
705
|
+
this._sendPendingMarkRead();
|
|
706
|
+
}, this.readDebounceMs);
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Bypass the read_watermark debounce and send the pending watermark
|
|
710
|
+
* immediately. Use on critical UX boundaries (user leaves room,
|
|
711
|
+
* client disconnects, page unload) so the server's per-user unread
|
|
712
|
+
* count is correct by the time any other device / admin / push
|
|
713
|
+
* service queries it. No-op when nothing is pending.
|
|
714
|
+
*/
|
|
715
|
+
flushMarkRead() {
|
|
716
|
+
if (this.readDebounceTimer) {
|
|
717
|
+
clearTimeout(this.readDebounceTimer);
|
|
718
|
+
this.readDebounceTimer = null;
|
|
719
|
+
}
|
|
720
|
+
this._sendPendingMarkRead();
|
|
721
|
+
}
|
|
722
|
+
setTyping(isTyping) {
|
|
723
|
+
this.ws.send({ type: "typing", room_id: this.roomId, is_typing: isTyping });
|
|
724
|
+
}
|
|
725
|
+
async edit(messageId, patch) {
|
|
726
|
+
return this.http.request(
|
|
727
|
+
"PUT",
|
|
728
|
+
`/api/v1/rooms/${this.roomId}/messages/${messageId}`,
|
|
729
|
+
patch
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
async delete(messageId, scope = "ALL") {
|
|
733
|
+
await this.http.request(
|
|
734
|
+
"DELETE",
|
|
735
|
+
`/api/v1/rooms/${this.roomId}/messages/${messageId}`,
|
|
736
|
+
void 0,
|
|
737
|
+
{ scope }
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
/** Per-user "Clear chat". Hides every existing message in this room
|
|
741
|
+
* from the calling user (via `room_members.last_cleared_at = now()`).
|
|
742
|
+
* Other members are unaffected — this is local history hiding, not
|
|
743
|
+
* a destructive bulk delete. Irreversible from the caller's side. */
|
|
744
|
+
async clearHistory() {
|
|
745
|
+
await this.http.request(
|
|
746
|
+
"POST",
|
|
747
|
+
`/api/v1/rooms/${this.roomId}/clear`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
/** Leave this room (self). Deletes the caller's membership row — the room
|
|
751
|
+
* drops out of `unreadSummary()` and they stop receiving its messages.
|
|
752
|
+
* Other members get a `MEMBER_LEFT` [`roomEvent`](../reference/noverachat.md#events).
|
|
753
|
+
* Idempotent if already a non-member; throws 409 if the caller was KICKED
|
|
754
|
+
* (ask an operator to restore first). For GROUP/SUPER_GROUP, re-entry needs
|
|
755
|
+
* an operator add; for ONE, re-creating the 1:1 rejoins with history kept. */
|
|
756
|
+
async leave() {
|
|
757
|
+
await this.http.request(
|
|
758
|
+
"POST",
|
|
759
|
+
`/api/v1/rooms/${this.roomId}/leave`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Set the caller's per-room push preference — like long-pressing a
|
|
764
|
+
* chat and tapping "notifications off".
|
|
765
|
+
* "ALL" — push for every message (default)
|
|
766
|
+
* "MENTION_ONLY" — push only when the message @-mentions the caller
|
|
767
|
+
* "OFF" — mute this room's push entirely
|
|
768
|
+
* Scoped to the calling user; throws if they're not a room member.
|
|
769
|
+
*/
|
|
770
|
+
async setPushTrigger(trigger) {
|
|
771
|
+
await this.http.request(
|
|
772
|
+
"PATCH",
|
|
773
|
+
`/api/v1/rooms/${this.roomId}/push-trigger`,
|
|
774
|
+
{ push_trigger: trigger }
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* **DESTRUCTIVE, operator-only.** Soft-deletes every currently-undeleted
|
|
779
|
+
* message in the room — visible to all members instantly via a
|
|
780
|
+
* `messagesCleared` event (subscribe via `room.on("messagesCleared", ...)`).
|
|
781
|
+
* Server enforces the operator check; throws 403 for regular members.
|
|
782
|
+
* Returns the cleared count plus the cutoff `message_id` so callers can
|
|
783
|
+
* hide rendered messages up to (and including) that id.
|
|
784
|
+
*/
|
|
785
|
+
async deleteAllMessages() {
|
|
786
|
+
const raw = await this.http.request("DELETE", `/api/v1/rooms/${this.roomId}/messages`);
|
|
787
|
+
return {
|
|
788
|
+
cleared: raw.cleared,
|
|
789
|
+
upToMessageId: raw.up_to_message_id
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
async react(messageId, key) {
|
|
793
|
+
await this.http.request(
|
|
794
|
+
"POST",
|
|
795
|
+
`/api/v1/rooms/${this.roomId}/messages/${messageId}/reactions`,
|
|
796
|
+
{ key }
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
async unreact(messageId, key) {
|
|
800
|
+
await this.http.request(
|
|
801
|
+
"DELETE",
|
|
802
|
+
`/api/v1/rooms/${this.roomId}/messages/${messageId}/reactions/${encodeURIComponent(key)}`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
async listSince(sinceMessageId, limit = 100) {
|
|
806
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, {
|
|
807
|
+
since: String(sinceMessageId),
|
|
808
|
+
limit
|
|
809
|
+
});
|
|
810
|
+
return {
|
|
811
|
+
items: raw.items.map(toMessageOut),
|
|
812
|
+
nextCursor: raw.next_cursor,
|
|
813
|
+
hasMore: raw.has_more
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Load the latest `limit` messages of the room — what to render when the
|
|
818
|
+
* user just opens the chat. Returns ASC (oldest → newest within the
|
|
819
|
+
* page) so callers don't need to reverse. Use `room.listBefore(cursor)`
|
|
820
|
+
* to paginate further back.
|
|
821
|
+
*/
|
|
822
|
+
async listRecent(limit = 50) {
|
|
823
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, { limit });
|
|
824
|
+
return {
|
|
825
|
+
items: raw.items.map(toMessageOut),
|
|
826
|
+
nextCursor: raw.next_cursor,
|
|
827
|
+
hasMore: raw.has_more
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Scrollback. Fetches the page of `limit` messages immediately preceding
|
|
832
|
+
* `beforeMessageId` (exclusive), in ASC order. The returned
|
|
833
|
+
* `nextCursor` is the oldest id in this page — feed it back to keep
|
|
834
|
+
* scrolling up.
|
|
835
|
+
*/
|
|
836
|
+
async listBefore(beforeMessageId, limit = 50) {
|
|
837
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, {
|
|
838
|
+
before: beforeMessageId,
|
|
839
|
+
limit
|
|
840
|
+
});
|
|
841
|
+
return {
|
|
842
|
+
items: raw.items.map(toMessageOut),
|
|
843
|
+
nextCursor: raw.next_cursor,
|
|
844
|
+
hasMore: raw.has_more
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/** Phase D #2 — 방 내 메시지 검색.
|
|
848
|
+
*
|
|
849
|
+
* ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
|
|
850
|
+
* 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
|
|
851
|
+
* 커서로 뒤로 페이지네이션. 스티커/파일/이모티콘 등 ``content`` 가 없는
|
|
852
|
+
* 메시지는 검색 대상 X.
|
|
853
|
+
*
|
|
854
|
+
* @param q 검색어 (필수, 1~200자, whitespace-only 는 서버가 빈 결과 반환)
|
|
855
|
+
* @param opts.before 이전 페이지 마지막 message_id (다음 페이지 커서)
|
|
856
|
+
* @param opts.limit 페이지 크기 (기본 30, 최대 100)
|
|
857
|
+
*/
|
|
858
|
+
async search(q, opts) {
|
|
859
|
+
const params = {
|
|
860
|
+
q,
|
|
861
|
+
limit: opts?.limit ?? 30
|
|
862
|
+
};
|
|
863
|
+
if (opts?.before) params.before = opts.before;
|
|
864
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages/search`, void 0, params);
|
|
865
|
+
return {
|
|
866
|
+
items: raw.items.map(toMessageOut),
|
|
867
|
+
nextCursor: raw.next_cursor,
|
|
868
|
+
hasMore: raw.has_more
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/** List this room's members (with each one's role). Handy for UIs that
|
|
872
|
+
* want to conditionally enable destructive operator-only actions like
|
|
873
|
+
* `room.deleteAllMessages()`. Server gates by membership for non-public
|
|
874
|
+
* rooms, so the caller must already be a member to read this.
|
|
875
|
+
*/
|
|
876
|
+
async listMembers() {
|
|
877
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/members`);
|
|
878
|
+
return raw.map((m) => ({
|
|
879
|
+
userId: m.user_id,
|
|
880
|
+
nickname: m.nickname ?? null,
|
|
881
|
+
role: m.role,
|
|
882
|
+
pushTrigger: m.push_trigger,
|
|
883
|
+
lastReadMessageId: m.last_read_message_id,
|
|
884
|
+
joinedAt: m.joined_at,
|
|
885
|
+
isOnline: m.is_online ?? null,
|
|
886
|
+
mutedUntil: m.muted_until ?? null
|
|
887
|
+
}));
|
|
888
|
+
}
|
|
889
|
+
// -------- mute --------
|
|
890
|
+
/** OPERATOR-only: mute a member so they can't send messages in this
|
|
891
|
+
* room. `durationMinutes=null` → indefinite (until an explicit unmute).
|
|
892
|
+
* Emits a `MEMBER_MUTED` [`roomEvent`](../reference/noverachat.md#events)
|
|
893
|
+
* + a SYSTEM chat message. Non-operator callers get 403. Server also
|
|
894
|
+
* refuses self-mute and muting another OPERATOR. */
|
|
895
|
+
async muteMember(userId, durationMinutes) {
|
|
896
|
+
await this.http.request(
|
|
897
|
+
"POST",
|
|
898
|
+
`/api/v1/rooms/${this.roomId}/members/${encodeURIComponent(userId)}/mute`,
|
|
899
|
+
{ duration_minutes: durationMinutes }
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
/** OPERATOR-only: clear a member's mute. Idempotent — no-op + no
|
|
903
|
+
* broadcast when the target wasn't muted. Emits a `MEMBER_UNMUTED`
|
|
904
|
+
* roomEvent + SYSTEM chat message otherwise. */
|
|
905
|
+
async unmuteMember(userId) {
|
|
906
|
+
await this.http.request(
|
|
907
|
+
"POST",
|
|
908
|
+
`/api/v1/rooms/${this.roomId}/members/${encodeURIComponent(userId)}/unmute`
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
/** Server-side unread state for this room: exact COUNT(*) of messages
|
|
912
|
+
* newer than the caller's `read_watermark`, plus the room's
|
|
913
|
+
* `last_message_id` so the UI can show a "•" dot regardless of count. */
|
|
914
|
+
async unread() {
|
|
915
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/unread`);
|
|
916
|
+
return {
|
|
917
|
+
roomId: raw.room_id,
|
|
918
|
+
unreadCount: raw.unread_count,
|
|
919
|
+
lastReadMessageId: raw.last_read_message_id,
|
|
920
|
+
lastMessageId: raw.last_message_id
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
// -------- freeze --------
|
|
924
|
+
/** OPERATOR-only moderation: freeze this room. Non-admin messages are
|
|
925
|
+
* blocked server-side with NC-ROOM-FROZEN while frozen. All members'
|
|
926
|
+
* clients receive a `roomEvent` with `event: "ROOM_FROZEN"` in real
|
|
927
|
+
* time, plus a SYSTEM chat message "방이 얼려졌습니다."
|
|
928
|
+
* Idempotent — no-op if already frozen. Non-operator callers get 403. */
|
|
929
|
+
async freeze() {
|
|
930
|
+
await this.http.request(
|
|
931
|
+
"POST",
|
|
932
|
+
`/api/v1/rooms/${this.roomId}/freeze`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
/** Mirror of `freeze()` — reopens the room to chat_send. Emits a
|
|
936
|
+
* `ROOM_UNFROZEN` roomEvent + a "방이 녹여졌습니다." SYSTEM message. */
|
|
937
|
+
async unfreeze() {
|
|
938
|
+
await this.http.request(
|
|
939
|
+
"POST",
|
|
940
|
+
`/api/v1/rooms/${this.roomId}/unfreeze`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
/** OPERATOR-only: toggle room visibility. `true` → 공개방 (open chat,
|
|
944
|
+
* anyone can join). `false` → 비공개방 (invite-only, join requires
|
|
945
|
+
* approval). Auto-inserts a SYSTEM chat message and broadcasts a
|
|
946
|
+
* `ROOM_VISIBILITY_CHANGED` roomEvent so peers update in real time. */
|
|
947
|
+
async setPublic(isPublic) {
|
|
948
|
+
await this.http.request(
|
|
949
|
+
"PATCH",
|
|
950
|
+
`/api/v1/rooms/${this.roomId}/visibility`,
|
|
951
|
+
{ is_public: isPublic }
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
// -------- announcements --------
|
|
955
|
+
async listAnnouncements() {
|
|
956
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/announcements`);
|
|
957
|
+
return raw.map((a) => ({
|
|
958
|
+
id: a.id,
|
|
959
|
+
roomId: a.room_id,
|
|
960
|
+
content: a.content,
|
|
961
|
+
createdBy: a.created_by,
|
|
962
|
+
createdAt: a.created_at,
|
|
963
|
+
updatedAt: a.updated_at
|
|
964
|
+
}));
|
|
965
|
+
}
|
|
966
|
+
async getCurrentAnnouncement() {
|
|
967
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/announcements/current`);
|
|
968
|
+
if (!raw) return null;
|
|
969
|
+
return {
|
|
970
|
+
id: raw.id,
|
|
971
|
+
roomId: raw.room_id,
|
|
972
|
+
content: raw.content,
|
|
973
|
+
createdBy: raw.created_by,
|
|
974
|
+
createdAt: raw.created_at,
|
|
975
|
+
updatedAt: raw.updated_at
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
async postAnnouncement(content) {
|
|
979
|
+
const raw = await this.http.request("POST", `/api/v1/rooms/${this.roomId}/announcements`, { content });
|
|
980
|
+
return {
|
|
981
|
+
id: raw.id,
|
|
982
|
+
roomId: raw.room_id,
|
|
983
|
+
content: raw.content,
|
|
984
|
+
createdBy: raw.created_by,
|
|
985
|
+
createdAt: raw.created_at,
|
|
986
|
+
updatedAt: raw.updated_at
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
async updateAnnouncement(id, content) {
|
|
990
|
+
const raw = await this.http.request("PATCH", `/api/v1/rooms/${this.roomId}/announcements/${id}`, { content });
|
|
991
|
+
return {
|
|
992
|
+
id: raw.id,
|
|
993
|
+
roomId: raw.room_id,
|
|
994
|
+
content: raw.content,
|
|
995
|
+
createdBy: raw.created_by,
|
|
996
|
+
createdAt: raw.created_at,
|
|
997
|
+
updatedAt: raw.updated_at
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
async deleteAnnouncement(id) {
|
|
1001
|
+
await this.http.request(
|
|
1002
|
+
"DELETE",
|
|
1003
|
+
`/api/v1/rooms/${this.roomId}/announcements/${id}`
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
// -------- invite links --------
|
|
1007
|
+
/**
|
|
1008
|
+
* OPERATOR-only: mint a new invite link for this room. Default:
|
|
1009
|
+
* expires in 7 days, unlimited uses. Pass `expiresInHours: null`
|
|
1010
|
+
* for a link that never expires, or `maxUses: 1` for a one-shot.
|
|
1011
|
+
*
|
|
1012
|
+
* Returns the raw `InviteToken` — build the shareable URL yourself
|
|
1013
|
+
* (e.g. `${location.origin}/#invite/${token.token}`) so you control
|
|
1014
|
+
* the format your app uses to consume it.
|
|
1015
|
+
*/
|
|
1016
|
+
async createInvite(opts) {
|
|
1017
|
+
const body = {};
|
|
1018
|
+
if (opts && "expiresInHours" in opts) body.expires_in_hours = opts.expiresInHours;
|
|
1019
|
+
if (opts && "maxUses" in opts) body.max_uses = opts.maxUses;
|
|
1020
|
+
const raw = await this.http.request("POST", `/api/v1/rooms/${this.roomId}/invites`, body);
|
|
1021
|
+
return {
|
|
1022
|
+
token: raw.token,
|
|
1023
|
+
roomId: raw.room_id,
|
|
1024
|
+
createdBy: raw.created_by,
|
|
1025
|
+
createdAt: raw.created_at,
|
|
1026
|
+
expiresAt: raw.expires_at,
|
|
1027
|
+
maxUses: raw.max_uses,
|
|
1028
|
+
usedCount: raw.used_count,
|
|
1029
|
+
revokedAt: raw.revoked_at
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
/** OPERATOR-only: list all invite tokens for this room (including
|
|
1033
|
+
* revoked / expired / exhausted), newest first. */
|
|
1034
|
+
async listInvites() {
|
|
1035
|
+
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/invites`);
|
|
1036
|
+
return raw.map((r) => ({
|
|
1037
|
+
token: r.token,
|
|
1038
|
+
roomId: r.room_id,
|
|
1039
|
+
createdBy: r.created_by,
|
|
1040
|
+
createdAt: r.created_at,
|
|
1041
|
+
expiresAt: r.expires_at,
|
|
1042
|
+
maxUses: r.max_uses,
|
|
1043
|
+
usedCount: r.used_count,
|
|
1044
|
+
revokedAt: r.revoked_at
|
|
1045
|
+
}));
|
|
1046
|
+
}
|
|
1047
|
+
/** OPERATOR-only: revoke a token immediately. Idempotent — revoking
|
|
1048
|
+
* an already-revoked token returns 204 as well. */
|
|
1049
|
+
async revokeInvite(token) {
|
|
1050
|
+
await this.http.request(
|
|
1051
|
+
"DELETE",
|
|
1052
|
+
`/api/v1/rooms/${this.roomId}/invites/${encodeURIComponent(token)}`
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
// -------- internal dispatch --------
|
|
1056
|
+
on(event, fn) {
|
|
1057
|
+
return this.bus.on(event, fn);
|
|
1058
|
+
}
|
|
1059
|
+
off(event, fn) {
|
|
1060
|
+
this.bus.off(event, fn);
|
|
1061
|
+
}
|
|
1062
|
+
_dispatchAnnouncement(msg) {
|
|
1063
|
+
this.bus.emit("announcement", msg);
|
|
1064
|
+
}
|
|
1065
|
+
_dispatchAck(tempId2, messageId) {
|
|
1066
|
+
if (!tempId2) return;
|
|
1067
|
+
const entry = this.pendingAcks.get(tempId2);
|
|
1068
|
+
if (entry) {
|
|
1069
|
+
entry.resolve(messageId);
|
|
1070
|
+
this.pendingAcks.delete(tempId2);
|
|
1071
|
+
this.mySentTempIds.delete(tempId2);
|
|
1072
|
+
this.bus.emit("ack", { tempId: tempId2, messageId });
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
/** True iff this room has a still-pending send with this temp_id. */
|
|
1076
|
+
_hasPendingTempId(tempId2) {
|
|
1077
|
+
return this.pendingAcks.has(tempId2);
|
|
1078
|
+
}
|
|
1079
|
+
/** Reject the pending send for tempId with the given error. Used by the
|
|
1080
|
+
* client when a server `error` frame echoes back our temp_id so we can
|
|
1081
|
+
* fail the matching outbound promise immediately instead of waiting
|
|
1082
|
+
* for the 15s ack timeout. */
|
|
1083
|
+
_rejectPending(tempId2, err) {
|
|
1084
|
+
const entry = this.pendingAcks.get(tempId2);
|
|
1085
|
+
if (!entry) return;
|
|
1086
|
+
entry.reject(err);
|
|
1087
|
+
this.pendingAcks.delete(tempId2);
|
|
1088
|
+
this.mySentTempIds.delete(tempId2);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Try to re-send a pending frame after a transient server reject
|
|
1092
|
+
* (currently only NC-RATE-001 — the tenant's per-second token bucket
|
|
1093
|
+
* spilled). Reuses the same temp_id so:
|
|
1094
|
+
* 1. ack matching on success still works,
|
|
1095
|
+
* 2. self-echo dedup still recognizes the frame as ours,
|
|
1096
|
+
* 3. *if* the server's first attempt secretly succeeded (we got an
|
|
1097
|
+
* error frame but the message was already persisted) the second
|
|
1098
|
+
* attempt's `chat_receive` self-echo collapses cleanly.
|
|
1099
|
+
*
|
|
1100
|
+
* Returns true if a retry was scheduled; false if we've already used
|
|
1101
|
+
* up the retry budget and the caller should fall back to rejecting
|
|
1102
|
+
* the promise.
|
|
1103
|
+
*/
|
|
1104
|
+
_retryPending(tempId2) {
|
|
1105
|
+
const entry = this.pendingAcks.get(tempId2);
|
|
1106
|
+
if (!entry) return false;
|
|
1107
|
+
if (entry.retries >= _Room.MAX_RATE_LIMIT_RETRIES) return false;
|
|
1108
|
+
const idx = entry.retries;
|
|
1109
|
+
entry.retries += 1;
|
|
1110
|
+
const delay = _Room.RATE_LIMIT_BACKOFF_MS[idx] ?? 1200;
|
|
1111
|
+
setTimeout(() => {
|
|
1112
|
+
if (!this.pendingAcks.has(tempId2)) return;
|
|
1113
|
+
const ok = this.ws.send(entry.payload);
|
|
1114
|
+
if (!ok) {
|
|
1115
|
+
this._rejectPending(tempId2, new Error("ws closed during retry"));
|
|
1116
|
+
}
|
|
1117
|
+
}, delay);
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
/** True if this room originated a `chat_send` with the given temp_id
|
|
1121
|
+
* during the current session. Used by the client dispatcher to detect
|
|
1122
|
+
* self-echo chat_receive frames. */
|
|
1123
|
+
_isMySentTempId(tempId2) {
|
|
1124
|
+
return this.mySentTempIds.has(tempId2);
|
|
1125
|
+
}
|
|
1126
|
+
/** Forget a self-sent temp_id once both the ack and the echo are reconciled. */
|
|
1127
|
+
_forgetMySentTempId(tempId2) {
|
|
1128
|
+
this.mySentTempIds.delete(tempId2);
|
|
1129
|
+
}
|
|
1130
|
+
_dispatchInbound(msg) {
|
|
1131
|
+
switch (msg.type) {
|
|
1132
|
+
case "chat_receive":
|
|
1133
|
+
this.bus.emit("message", msg);
|
|
1134
|
+
return;
|
|
1135
|
+
case "message_updated":
|
|
1136
|
+
this.bus.emit("messageUpdated", msg);
|
|
1137
|
+
return;
|
|
1138
|
+
case "message_deleted":
|
|
1139
|
+
this.bus.emit("messageDeleted", msg);
|
|
1140
|
+
return;
|
|
1141
|
+
case "messages_cleared":
|
|
1142
|
+
this.bus.emit("messagesCleared", msg);
|
|
1143
|
+
return;
|
|
1144
|
+
case "reaction_added":
|
|
1145
|
+
this.bus.emit("reactionAdded", msg);
|
|
1146
|
+
return;
|
|
1147
|
+
case "reaction_removed":
|
|
1148
|
+
this.bus.emit("reactionRemoved", msg);
|
|
1149
|
+
return;
|
|
1150
|
+
case "sync_tick":
|
|
1151
|
+
this.bus.emit("syncTick", msg);
|
|
1152
|
+
return;
|
|
1153
|
+
case "typing":
|
|
1154
|
+
this.bus.emit("typing", msg);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
// src/client.ts
|
|
1161
|
+
function idGreater(a, b) {
|
|
1162
|
+
if (!a) return false;
|
|
1163
|
+
if (!b) return true;
|
|
1164
|
+
try {
|
|
1165
|
+
return BigInt(a) > BigInt(b);
|
|
1166
|
+
} catch {
|
|
1167
|
+
return a > b;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
var NoveraChat = class {
|
|
1171
|
+
constructor(opts) {
|
|
1172
|
+
this.opts = opts;
|
|
1173
|
+
if (!opts.appId) throw new Error("appId required");
|
|
1174
|
+
if (!opts.endpoint) throw new Error("endpoint required");
|
|
1175
|
+
if (!opts.tokenProvider) throw new Error("tokenProvider required");
|
|
1176
|
+
this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
|
|
1177
|
+
this.logger = opts.logger;
|
|
1178
|
+
if (opts.initialLastMessageIds) {
|
|
1179
|
+
for (const [roomId, id] of Object.entries(opts.initialLastMessageIds)) {
|
|
1180
|
+
const sid = typeof id === "number" ? String(id) : id;
|
|
1181
|
+
if (sid && sid !== "0") {
|
|
1182
|
+
this.lastMessageIdByRoom.set(roomId, sid);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
this.http = new HttpClient({
|
|
1187
|
+
baseUrl: opts.endpoint,
|
|
1188
|
+
appId: opts.appId,
|
|
1189
|
+
tokenProvider: opts.tokenProvider,
|
|
1190
|
+
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
1191
|
+
// Same structured sink as the WS transport — the SDK is one source
|
|
1192
|
+
// of truth for both pipes' traces. Debug-level lines (rest_req /
|
|
1193
|
+
// rest_res) are off by default at the consumer side (e.g. the demo
|
|
1194
|
+
// filters them behind a "Verbose" toggle).
|
|
1195
|
+
...this.logger ? { logger: this.logger } : {}
|
|
1196
|
+
});
|
|
1197
|
+
const wsBase = opts.wsEndpoint ?? opts.endpoint.replace(/^http/, "ws");
|
|
1198
|
+
this.ws = new ReconnectingWs({
|
|
1199
|
+
urlBuilder: async () => {
|
|
1200
|
+
const token = await opts.tokenProvider();
|
|
1201
|
+
const since = this.highestKnownMessageId();
|
|
1202
|
+
const u = new URL(wsBase.replace(/\/+$/, "") + `/ws/${opts.appId}`);
|
|
1203
|
+
u.searchParams.set("token", token);
|
|
1204
|
+
if (since) u.searchParams.set("since", since);
|
|
1205
|
+
return u.toString();
|
|
1206
|
+
},
|
|
1207
|
+
onMessage: (msg) => this.dispatch(msg),
|
|
1208
|
+
onOpen: () => {
|
|
1209
|
+
if (this.connectedOnce) {
|
|
1210
|
+
void this.backfillAllRooms();
|
|
1211
|
+
} else {
|
|
1212
|
+
this.connectedOnce = true;
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
onStateChange: (s) => this.logger?.("info", `ws_state=${s}`),
|
|
1216
|
+
autoReconnect: opts.autoReconnect ?? true,
|
|
1217
|
+
pingIntervalMs: opts.pingIntervalMs ?? 2e4,
|
|
1218
|
+
maxReconnectDelayMs: opts.maxReconnectDelayMs ?? 3e4,
|
|
1219
|
+
...opts.WebSocketImpl ? { WebSocketImpl: opts.WebSocketImpl } : {},
|
|
1220
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
opts;
|
|
1224
|
+
http;
|
|
1225
|
+
ws;
|
|
1226
|
+
rooms = /* @__PURE__ */ new Map();
|
|
1227
|
+
bus = new EventBus();
|
|
1228
|
+
readDebounceMs;
|
|
1229
|
+
logger;
|
|
1230
|
+
connectedOnce = false;
|
|
1231
|
+
lastMessageIdByRoom = /* @__PURE__ */ new Map();
|
|
1232
|
+
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
1233
|
+
* Used to drop the N-1 shared-room copies of a single online/offline
|
|
1234
|
+
* transition — the server fans out one publish per room, and we don't
|
|
1235
|
+
* want the caller's listener firing N times for one UX event. */
|
|
1236
|
+
lastPresenceKey = null;
|
|
1237
|
+
async connect() {
|
|
1238
|
+
await this.ws.connect();
|
|
1239
|
+
}
|
|
1240
|
+
disconnect() {
|
|
1241
|
+
this.ws.close();
|
|
1242
|
+
this.rooms.clear();
|
|
1243
|
+
}
|
|
1244
|
+
room(roomId) {
|
|
1245
|
+
let r = this.rooms.get(roomId);
|
|
1246
|
+
if (!r) {
|
|
1247
|
+
r = new Room(roomId, this.http, this.ws, this.readDebounceMs);
|
|
1248
|
+
this.rooms.set(roomId, r);
|
|
1249
|
+
}
|
|
1250
|
+
return r;
|
|
1251
|
+
}
|
|
1252
|
+
/** Subscribe to client-level events. Currently `roomEvent` — membership /
|
|
1253
|
+
* room-lifecycle changes for ANY room (added, kicked, restored, frozen,
|
|
1254
|
+
* …), including rooms the caller hasn't opened. Returns an unsubscribe fn.
|
|
1255
|
+
* Use this to refresh a channel list the instant you're added to a new
|
|
1256
|
+
* room, rather than waiting for a poll. */
|
|
1257
|
+
on(event, fn) {
|
|
1258
|
+
return this.bus.on(event, fn);
|
|
1259
|
+
}
|
|
1260
|
+
off(event, fn) {
|
|
1261
|
+
this.bus.off(event, fn);
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Manually fetch all messages newer than the highest known `message_id`
|
|
1265
|
+
* for each room and surface them as regular `room.on("message", ...)`
|
|
1266
|
+
* events. Useful at cold start (after `chat.connect()`) to pull the
|
|
1267
|
+
* "unread since last session" tail — the WS reconnect path calls this
|
|
1268
|
+
* automatically, but the first connect does not.
|
|
1269
|
+
*
|
|
1270
|
+
* No-op for rooms with no seeded id.
|
|
1271
|
+
*/
|
|
1272
|
+
async backfill() {
|
|
1273
|
+
await this.backfillAllRooms();
|
|
1274
|
+
}
|
|
1275
|
+
/** Highest `message_id` the client has seen for a given room, or null. */
|
|
1276
|
+
lastSeenMessageId(roomId) {
|
|
1277
|
+
return this.lastMessageIdByRoom.get(roomId) ?? null;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Monotonically advance the per-room "highest-seen message_id" anchor.
|
|
1281
|
+
* Used by callers that learn a newer watermark from out-of-band sources —
|
|
1282
|
+
* typically the server's own `last_read_message_id` (via the unread
|
|
1283
|
+
* endpoint) so the next `backfill()` doesn't re-pull messages the user
|
|
1284
|
+
* already marked read on another device. Never moves the value backward;
|
|
1285
|
+
* a smaller `messageId` is silently ignored. Accepts either the string
|
|
1286
|
+
* form (preferred) or a number (legacy / convenience).
|
|
1287
|
+
*/
|
|
1288
|
+
setLastSeen(roomId, messageId) {
|
|
1289
|
+
const sid = typeof messageId === "number" ? String(messageId) : messageId;
|
|
1290
|
+
if (!sid || sid === "0") return;
|
|
1291
|
+
const cur = this.lastMessageIdByRoom.get(roomId);
|
|
1292
|
+
if (idGreater(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
|
|
1293
|
+
}
|
|
1294
|
+
/** Server-side aggregate unread state: total count + per-room breakdown
|
|
1295
|
+
* for the authenticated user. Single REST call; cheap enough to poll
|
|
1296
|
+
* every few seconds for a badge, or just refresh on focus + after
|
|
1297
|
+
* receiving new messages. */
|
|
1298
|
+
/** Client-facing feature flags for the caller's app. Cheap (one row
|
|
1299
|
+
* read on the server) — fetch once on connect to decide which
|
|
1300
|
+
* destructive buttons to expose. */
|
|
1301
|
+
async appPolicy() {
|
|
1302
|
+
const raw = await this.http.request("GET", "/api/v1/app/policy");
|
|
1303
|
+
return {
|
|
1304
|
+
appId: raw.app_id,
|
|
1305
|
+
allowMemberBulkDelete: raw.allow_member_bulk_delete,
|
|
1306
|
+
readReceiptsEnabled: raw.read_receipts_enabled ?? true,
|
|
1307
|
+
typingIndicatorsEnabled: raw.typing_indicators_enabled ?? true,
|
|
1308
|
+
deleteAnyMessageEnabled: raw.allow_member_delete_any_message ?? false
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
// ---- device tokens (push) ----
|
|
1312
|
+
/**
|
|
1313
|
+
* Register (or refresh) this device's push token. Call after login and
|
|
1314
|
+
* whenever the platform hands you a rotated FCM/APNS token. Idempotent
|
|
1315
|
+
* on `deviceId` — the server upserts, so re-registering is cheap and
|
|
1316
|
+
* safe. Returns the stored device record (token shown only as a prefix).
|
|
1317
|
+
*/
|
|
1318
|
+
async registerDevice(params) {
|
|
1319
|
+
const raw = await this.http.request("POST", "/api/v1/users/me/devices", {
|
|
1320
|
+
device_id: params.deviceId,
|
|
1321
|
+
token: params.token,
|
|
1322
|
+
token_type: params.tokenType,
|
|
1323
|
+
os: params.os ?? null,
|
|
1324
|
+
app_version: params.appVersion ?? null
|
|
1325
|
+
});
|
|
1326
|
+
return this._toDeviceInfo(raw);
|
|
1327
|
+
}
|
|
1328
|
+
/** List the devices registered for the authenticated user. */
|
|
1329
|
+
async listDevices() {
|
|
1330
|
+
const raw = await this.http.request("GET", "/api/v1/users/me/devices");
|
|
1331
|
+
return raw.items.map((d) => this._toDeviceInfo(d));
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Turn push delivery on/off for one device without unregistering it —
|
|
1335
|
+
* the token stays stored, the push pipeline just skips it. Use for an
|
|
1336
|
+
* in-app "notifications" toggle.
|
|
1337
|
+
*/
|
|
1338
|
+
async setDevicePushEnabled(deviceId, enabled) {
|
|
1339
|
+
await this.http.request(
|
|
1340
|
+
"PATCH",
|
|
1341
|
+
`/api/v1/users/me/devices/${encodeURIComponent(deviceId)}`,
|
|
1342
|
+
{ push_enabled: enabled }
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
/** Unregister a device — call on logout so the user stops receiving
|
|
1346
|
+
* push on a device they're no longer signed into. */
|
|
1347
|
+
async unregisterDevice(deviceId) {
|
|
1348
|
+
await this.http.request(
|
|
1349
|
+
"DELETE",
|
|
1350
|
+
`/api/v1/users/me/devices/${encodeURIComponent(deviceId)}`
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
// ---- global push preference (user-level kill switch) ----
|
|
1354
|
+
/**
|
|
1355
|
+
* Read the caller's GLOBAL push preference. When `pushEnabled` is
|
|
1356
|
+
* false the push worker skips this user entirely — every per-room
|
|
1357
|
+
* `push_trigger` setting is ignored. Fail-open: an unknown user
|
|
1358
|
+
* comes back as `pushEnabled: true`.
|
|
1359
|
+
*
|
|
1360
|
+
* Two levels of push control:
|
|
1361
|
+
* 1. **Per-room** — `room.setPushTrigger("ALL" | "MENTION_ONLY" | "OFF")`.
|
|
1362
|
+
* 2. **Global (this)** — overrides all rooms when off.
|
|
1363
|
+
*/
|
|
1364
|
+
async getPushPreferences() {
|
|
1365
|
+
const raw = await this.http.request(
|
|
1366
|
+
"GET",
|
|
1367
|
+
"/api/v1/users/me/push-preferences"
|
|
1368
|
+
);
|
|
1369
|
+
return { pushEnabled: Boolean(raw.push_enabled) };
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Flip the caller's global push kill switch. Pass `false` to mute
|
|
1373
|
+
* every push notification for this user across every room; pass
|
|
1374
|
+
* `true` to restore normal delivery. Idempotent.
|
|
1375
|
+
*/
|
|
1376
|
+
async setPushEnabled(enabled) {
|
|
1377
|
+
const raw = await this.http.request(
|
|
1378
|
+
"PATCH",
|
|
1379
|
+
"/api/v1/users/me/push-preferences",
|
|
1380
|
+
{ push_enabled: enabled }
|
|
1381
|
+
);
|
|
1382
|
+
return { pushEnabled: Boolean(raw.push_enabled) };
|
|
1383
|
+
}
|
|
1384
|
+
// -------------------------------------------------------------------------
|
|
1385
|
+
// User blocks — KakaoTalk-style one-way block.
|
|
1386
|
+
//
|
|
1387
|
+
// The block is directional: `chat.blockUser(otherId)` stops `otherId`'s
|
|
1388
|
+
// messages from reaching the caller. The blocked side is NEVER notified;
|
|
1389
|
+
// their sends still ack. There's no admin escape hatch or "who blocked
|
|
1390
|
+
// me" query — that's the whole point of a one-way block.
|
|
1391
|
+
// -------------------------------------------------------------------------
|
|
1392
|
+
/**
|
|
1393
|
+
* Block a user (one-way). Idempotent — repeated calls return the
|
|
1394
|
+
* existing row without changing `createdAt` or `reason`.
|
|
1395
|
+
*
|
|
1396
|
+
* @param userId The user to block. `chat.blockUser(myUserId)` throws
|
|
1397
|
+
* a validation error server-side.
|
|
1398
|
+
* @param reason Optional local note ≤500 chars.
|
|
1399
|
+
*/
|
|
1400
|
+
async blockUser(userId, reason) {
|
|
1401
|
+
const raw = await this.http.request(
|
|
1402
|
+
"POST",
|
|
1403
|
+
`/api/v1/users/me/blocks/${encodeURIComponent(userId)}`,
|
|
1404
|
+
{ reason: reason ?? null }
|
|
1405
|
+
);
|
|
1406
|
+
return {
|
|
1407
|
+
blockedUserId: raw.blocked_user_id,
|
|
1408
|
+
reason: raw.reason,
|
|
1409
|
+
createdAt: raw.created_at
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
/** Unblock a user. Idempotent — resolves either way. */
|
|
1413
|
+
async unblockUser(userId) {
|
|
1414
|
+
await this.http.request(
|
|
1415
|
+
"DELETE",
|
|
1416
|
+
`/api/v1/users/me/blocks/${encodeURIComponent(userId)}`
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
/** List every user the caller has blocked, newest first. */
|
|
1420
|
+
async listBlocks() {
|
|
1421
|
+
const raw = await this.http.request("GET", "/api/v1/users/me/blocks");
|
|
1422
|
+
return raw.map((r) => ({
|
|
1423
|
+
blockedUserId: r.blocked_user_id,
|
|
1424
|
+
reason: r.reason,
|
|
1425
|
+
createdAt: r.created_at
|
|
1426
|
+
}));
|
|
1427
|
+
}
|
|
1428
|
+
_toDeviceInfo(raw) {
|
|
1429
|
+
return {
|
|
1430
|
+
deviceId: raw.device_id,
|
|
1431
|
+
tokenType: raw.token_type,
|
|
1432
|
+
tokenPreview: raw.token_preview,
|
|
1433
|
+
os: raw.os,
|
|
1434
|
+
appVersion: raw.app_version,
|
|
1435
|
+
pushEnabled: raw.push_enabled,
|
|
1436
|
+
lastActiveAt: raw.last_active_at,
|
|
1437
|
+
createdAt: raw.created_at
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Authorization-gated URL for downloading a file attachment.
|
|
1442
|
+
*
|
|
1443
|
+
* The backend runs the request through the authorization gate (caller
|
|
1444
|
+
* must be a non-KICKED member of some room with a non-deleted,
|
|
1445
|
+
* post-cutoff message referencing this file) and then 302-redirects
|
|
1446
|
+
* to a short-lived signed S3 URL. ``<img src=...>`` / ``<video
|
|
1447
|
+
* src=...>`` / ``<a href=...>`` consumers transparently follow the
|
|
1448
|
+
* redirect.
|
|
1449
|
+
*
|
|
1450
|
+
* Why the optional ``token`` argument? Browsers don't let
|
|
1451
|
+
* ``<img>``/``<video>`` tags carry custom Authorization headers —
|
|
1452
|
+
* so URLs destined for those elements have to embed credentials in
|
|
1453
|
+
* the URL itself. The backend's ``require_user`` dep accepts
|
|
1454
|
+
* ``?token=...&app_id=...`` query params as a fallback for exactly
|
|
1455
|
+
* this case (it still prefers headers when present). Pass the same
|
|
1456
|
+
* JWT the SDK was constructed with; ``app_id`` is filled in
|
|
1457
|
+
* automatically from the client options. Omit the token when you're
|
|
1458
|
+
* going to ``fetch()`` the URL yourself with the Authorization
|
|
1459
|
+
* header set (e.g. server-side preview).
|
|
1460
|
+
*
|
|
1461
|
+
* Note: each call returns a fresh URL but the URL embeds a JWT — its
|
|
1462
|
+
* lifetime is the JWT's TTL (~24h by default), not the few-seconds
|
|
1463
|
+
* window of the S3 signed URL the backend redirects to. Don't log it.
|
|
1464
|
+
*/
|
|
1465
|
+
fileUrl(fileId, token) {
|
|
1466
|
+
const base = this.opts.endpoint.replace(/\/+$/, "");
|
|
1467
|
+
const u = new URL(`${base}/api/v1/files/${encodeURIComponent(fileId)}`);
|
|
1468
|
+
if (token) {
|
|
1469
|
+
u.searchParams.set("token", token);
|
|
1470
|
+
u.searchParams.set("app_id", this.opts.appId);
|
|
1471
|
+
}
|
|
1472
|
+
return u.toString();
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Thumbnail URL for an image / video attachment. Returns 404 when
|
|
1476
|
+
* the thumbnail isn't ready yet (poll ``getFileMeta`` then re-render
|
|
1477
|
+
* once ``thumbnailStatus === "ready"``). Same token-in-query rule
|
|
1478
|
+
* as ``fileUrl()``.
|
|
1479
|
+
*/
|
|
1480
|
+
fileThumbnailUrl(fileId, token) {
|
|
1481
|
+
const base = this.opts.endpoint.replace(/\/+$/, "");
|
|
1482
|
+
const u = new URL(`${base}/api/v1/files/${encodeURIComponent(fileId)}/thumbnail`);
|
|
1483
|
+
if (token) {
|
|
1484
|
+
u.searchParams.set("token", token);
|
|
1485
|
+
u.searchParams.set("app_id", this.opts.appId);
|
|
1486
|
+
}
|
|
1487
|
+
return u.toString();
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Fetch a file's metadata without committing to a download. Useful
|
|
1491
|
+
* when ``Message.file`` (the inline copy) is missing — e.g. for
|
|
1492
|
+
* messages loaded from REST history that predate the inline-meta
|
|
1493
|
+
* shipping date.
|
|
1494
|
+
*/
|
|
1495
|
+
async getFileMeta(fileId) {
|
|
1496
|
+
const raw = await this.http.request("GET", `/api/v1/files/${encodeURIComponent(fileId)}/meta`);
|
|
1497
|
+
return {
|
|
1498
|
+
id: raw.id,
|
|
1499
|
+
kind: raw.kind,
|
|
1500
|
+
mime: raw.mime,
|
|
1501
|
+
size: raw.size_bytes,
|
|
1502
|
+
name: raw.original_name ?? null,
|
|
1503
|
+
width: raw.width ?? null,
|
|
1504
|
+
height: raw.height ?? null,
|
|
1505
|
+
durationSec: raw.duration_sec ?? null,
|
|
1506
|
+
thumbnailStatus: raw.thumbnail_status,
|
|
1507
|
+
forwardBlocked: Boolean(raw.forward_blocked)
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
async unreadSummary() {
|
|
1511
|
+
const raw = await this.http.request("GET", "/api/v1/users/me/unread");
|
|
1512
|
+
return {
|
|
1513
|
+
total: raw.total,
|
|
1514
|
+
rooms: raw.rooms.map((r) => ({
|
|
1515
|
+
roomId: r.room_id,
|
|
1516
|
+
name: r.name,
|
|
1517
|
+
roomType: r.room_type ?? null,
|
|
1518
|
+
unreadCount: r.unread_count,
|
|
1519
|
+
lastMessageId: r.last_message_id,
|
|
1520
|
+
lastMessagePreview: r.last_message_preview ?? null,
|
|
1521
|
+
lastReadMessageId: r.last_read_message_id,
|
|
1522
|
+
memberCount: r.member_count ?? 0,
|
|
1523
|
+
members: (r.members_preview ?? []).map((m) => ({
|
|
1524
|
+
userId: m.user_id,
|
|
1525
|
+
nickname: m.nickname ?? null,
|
|
1526
|
+
profileImageUrl: m.profile_image_url ?? null,
|
|
1527
|
+
isOnline: m.is_online ?? null
|
|
1528
|
+
}))
|
|
1529
|
+
}))
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Accept an invite link — call this in flows where you receive a
|
|
1534
|
+
* shareable URL (e.g. after parsing a deep link like
|
|
1535
|
+
* `#invite/<token>`). Consumes one use of the token and joins the
|
|
1536
|
+
* caller to the target room; peers already in the room receive a
|
|
1537
|
+
* `MEMBER_JOINED` roomEvent and a SYSTEM chat message.
|
|
1538
|
+
*
|
|
1539
|
+
* Idempotent: if the caller is already an active member the call
|
|
1540
|
+
* succeeds with `wasAlreadyMember: true` and does NOT decrement the
|
|
1541
|
+
* token's remaining uses — safe to call again on every app open.
|
|
1542
|
+
*
|
|
1543
|
+
* Throws (via HttpClient's `NoveraChatError`) when the token is
|
|
1544
|
+
* invalid, expired, revoked, exhausted, or when the caller has been
|
|
1545
|
+
* kicked from the target room.
|
|
1546
|
+
*/
|
|
1547
|
+
async acceptInvite(token) {
|
|
1548
|
+
const raw = await this.http.request("POST", `/api/v1/invites/${encodeURIComponent(token)}/accept`);
|
|
1549
|
+
return {
|
|
1550
|
+
roomId: raw.room_id,
|
|
1551
|
+
roomName: raw.room_name,
|
|
1552
|
+
wasAlreadyMember: raw.was_already_member
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
// ---- internal ----
|
|
1556
|
+
dispatch(msg) {
|
|
1557
|
+
switch (msg.type) {
|
|
1558
|
+
case "connected":
|
|
1559
|
+
return;
|
|
1560
|
+
case "pong":
|
|
1561
|
+
return;
|
|
1562
|
+
case "error": {
|
|
1563
|
+
this.logger?.("warn", "ws_error_frame", msg);
|
|
1564
|
+
const tempId2 = msg.details && typeof msg.details === "object" ? msg.details.temp_id : void 0;
|
|
1565
|
+
if (typeof tempId2 === "string" && tempId2.length > 0) {
|
|
1566
|
+
let target = null;
|
|
1567
|
+
for (const room of this.rooms.values()) {
|
|
1568
|
+
if (room._hasPendingTempId(tempId2)) {
|
|
1569
|
+
target = room;
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (target) {
|
|
1574
|
+
const code = msg.code || "WS_ERROR";
|
|
1575
|
+
if (code === "NC-RATE-001" && target._retryPending(tempId2)) {
|
|
1576
|
+
this.logger?.("info", "ws_send_retry_scheduled", {
|
|
1577
|
+
temp_id: tempId2,
|
|
1578
|
+
code
|
|
1579
|
+
});
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const details = msg.details ?? void 0;
|
|
1583
|
+
const err = details !== void 0 ? new NoveraChatError(
|
|
1584
|
+
msg.message || "server rejected frame",
|
|
1585
|
+
code,
|
|
1586
|
+
0,
|
|
1587
|
+
details
|
|
1588
|
+
) : new NoveraChatError(
|
|
1589
|
+
msg.message || "server rejected frame",
|
|
1590
|
+
code
|
|
1591
|
+
);
|
|
1592
|
+
target._rejectPending(tempId2, err);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
case "close_notice":
|
|
1598
|
+
this.logger?.("warn", "ws_close_notice", msg);
|
|
1599
|
+
return;
|
|
1600
|
+
case "room_event": {
|
|
1601
|
+
this.bus.emit("roomEvent", msg);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
case "presence": {
|
|
1605
|
+
const key = `${msg.user_id}:${msg.at}:${msg.online ? 1 : 0}`;
|
|
1606
|
+
if (this.lastPresenceKey === key) return;
|
|
1607
|
+
this.lastPresenceKey = key;
|
|
1608
|
+
this.bus.emit("presence", msg);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
case "ack": {
|
|
1612
|
+
if (idGreater(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
|
|
1613
|
+
this.lastMessageIdByRoom.set(msg.room_id, msg.message_id);
|
|
1614
|
+
}
|
|
1615
|
+
const room = this.rooms.get(msg.room_id);
|
|
1616
|
+
room?._dispatchAck(msg.temp_id, msg.message_id);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
case "announcement_event": {
|
|
1620
|
+
const room = this.rooms.get(msg.room_id);
|
|
1621
|
+
room?._dispatchAnnouncement(msg);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
default: {
|
|
1625
|
+
const roomId = "room_id" in msg ? msg.room_id : void 0;
|
|
1626
|
+
if (!roomId) return;
|
|
1627
|
+
const room = this.rooms.get(roomId);
|
|
1628
|
+
if (!room) return;
|
|
1629
|
+
if (msg.type === "chat_receive" && idGreater(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
|
|
1630
|
+
this.lastMessageIdByRoom.set(roomId, msg.message_id);
|
|
1631
|
+
}
|
|
1632
|
+
if (msg.type === "chat_receive") {
|
|
1633
|
+
const tid = msg.temp_id ?? null;
|
|
1634
|
+
if (tid && room._isMySentTempId(tid)) {
|
|
1635
|
+
room._dispatchAck(tid, msg.message_id);
|
|
1636
|
+
room._forgetMySentTempId(tid);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
room._dispatchInbound(msg);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
highestKnownMessageId() {
|
|
1645
|
+
let max = null;
|
|
1646
|
+
for (const v of this.lastMessageIdByRoom.values()) {
|
|
1647
|
+
if (idGreater(v, max)) max = v;
|
|
1648
|
+
}
|
|
1649
|
+
return max;
|
|
1650
|
+
}
|
|
1651
|
+
async backfillAllRooms() {
|
|
1652
|
+
for (const [roomId, lastSeen] of this.lastMessageIdByRoom) {
|
|
1653
|
+
try {
|
|
1654
|
+
const room = this.rooms.get(roomId);
|
|
1655
|
+
if (!room) continue;
|
|
1656
|
+
const { items } = await room.listSince(lastSeen, 200);
|
|
1657
|
+
for (const m of items) {
|
|
1658
|
+
room._dispatchInbound({
|
|
1659
|
+
type: "chat_receive",
|
|
1660
|
+
room_id: roomId,
|
|
1661
|
+
message_id: m.messageId,
|
|
1662
|
+
sender_id: m.senderId ?? null,
|
|
1663
|
+
message_type: m.messageType,
|
|
1664
|
+
custom_type: m.customType ?? null,
|
|
1665
|
+
content: m.content ?? null,
|
|
1666
|
+
data: m.data ?? null,
|
|
1667
|
+
meta: m.meta ?? null,
|
|
1668
|
+
file_id: m.fileId ?? null,
|
|
1669
|
+
// Inline file metadata loaded via REST has the same shape as
|
|
1670
|
+
// the WS frame's `file` field — pass through if the history
|
|
1671
|
+
// entry carries it; otherwise null and the UI calls
|
|
1672
|
+
// ``chat.getFileMeta(fileId)`` to fetch on demand.
|
|
1673
|
+
file: m.file ? {
|
|
1674
|
+
id: m.file.id,
|
|
1675
|
+
kind: m.file.kind,
|
|
1676
|
+
mime: m.file.mime,
|
|
1677
|
+
size: m.file.size,
|
|
1678
|
+
name: m.file.name ?? null,
|
|
1679
|
+
width: m.file.width ?? null,
|
|
1680
|
+
height: m.file.height ?? null,
|
|
1681
|
+
duration_sec: m.file.durationSec ?? null,
|
|
1682
|
+
thumbnail_status: m.file.thumbnailStatus
|
|
1683
|
+
} : null,
|
|
1684
|
+
reply_to_id: m.replyToId ?? null,
|
|
1685
|
+
created_at: m.createdAtMs,
|
|
1686
|
+
server_ts: Date.now()
|
|
1687
|
+
});
|
|
1688
|
+
if (idGreater(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
|
|
1689
|
+
this.lastMessageIdByRoom.set(roomId, m.messageId);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
} catch (err) {
|
|
1693
|
+
if (err instanceof NoveraChatError) {
|
|
1694
|
+
this.logger?.("warn", `backfill_failed room=${roomId}`, err);
|
|
1695
|
+
} else {
|
|
1696
|
+
throw err;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
export {
|
|
1703
|
+
ErrorCode,
|
|
1704
|
+
NoveraChat,
|
|
1705
|
+
NoveraChatError,
|
|
1706
|
+
Room
|
|
1707
|
+
};
|