@scalemule/chat 0.0.1

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,741 @@
1
+ 'use strict';
2
+
3
+ // src/core/EventEmitter.ts
4
+ var EventEmitter = class {
5
+ constructor() {
6
+ this.listeners = /* @__PURE__ */ new Map();
7
+ }
8
+ on(event, callback) {
9
+ if (!this.listeners.has(event)) {
10
+ this.listeners.set(event, /* @__PURE__ */ new Set());
11
+ }
12
+ this.listeners.get(event).add(callback);
13
+ return () => this.off(event, callback);
14
+ }
15
+ off(event, callback) {
16
+ this.listeners.get(event)?.delete(callback);
17
+ }
18
+ once(event, callback) {
19
+ const wrapper = ((data) => {
20
+ this.off(event, wrapper);
21
+ callback(data);
22
+ });
23
+ return this.on(event, wrapper);
24
+ }
25
+ emit(event, ...[data]) {
26
+ const listeners = this.listeners.get(event);
27
+ if (listeners) {
28
+ for (const listener of listeners) {
29
+ try {
30
+ listener(data);
31
+ } catch (err) {
32
+ console.error(`[ScaleMuleChat] Error in ${String(event)} listener:`, err);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ removeAllListeners(event) {
38
+ if (event) {
39
+ this.listeners.delete(event);
40
+ } else {
41
+ this.listeners.clear();
42
+ }
43
+ }
44
+ };
45
+
46
+ // src/constants.ts
47
+ var DEFAULT_API_BASE_URL = "https://api.scalemule.com";
48
+ var DEFAULT_WS_RECONNECT_BASE_DELAY = 1e3;
49
+ var DEFAULT_WS_RECONNECT_MAX_DELAY = 3e4;
50
+ var DEFAULT_WS_RECONNECT_MAX_RETRIES = Infinity;
51
+ var DEFAULT_WS_HEARTBEAT_INTERVAL = 3e4;
52
+ var DEFAULT_MESSAGE_CACHE_SIZE = 200;
53
+ var DEFAULT_MAX_CONVERSATIONS_CACHED = 50;
54
+ var DEFAULT_REQUEST_TIMEOUT = 1e4;
55
+
56
+ // src/core/MessageCache.ts
57
+ var MessageCache = class {
58
+ constructor(maxMessages, maxConversations) {
59
+ this.cache = /* @__PURE__ */ new Map();
60
+ this.maxMessages = maxMessages ?? DEFAULT_MESSAGE_CACHE_SIZE;
61
+ this.maxConversations = maxConversations ?? DEFAULT_MAX_CONVERSATIONS_CACHED;
62
+ }
63
+ getMessages(conversationId) {
64
+ return this.cache.get(conversationId) ?? [];
65
+ }
66
+ setMessages(conversationId, messages) {
67
+ this.cache.set(conversationId, messages.slice(0, this.maxMessages));
68
+ this.evictOldConversations();
69
+ }
70
+ addMessage(conversationId, message) {
71
+ const messages = this.cache.get(conversationId) ?? [];
72
+ if (messages.some((m) => m.id === message.id)) return;
73
+ messages.push(message);
74
+ messages.sort((a, b) => a.created_at.localeCompare(b.created_at));
75
+ if (messages.length > this.maxMessages) {
76
+ messages.splice(0, messages.length - this.maxMessages);
77
+ }
78
+ this.cache.set(conversationId, messages);
79
+ }
80
+ updateMessage(conversationId, message) {
81
+ const messages = this.cache.get(conversationId);
82
+ if (!messages) return;
83
+ const idx = messages.findIndex((m) => m.id === message.id);
84
+ if (idx >= 0) {
85
+ messages[idx] = message;
86
+ }
87
+ }
88
+ removeMessage(conversationId, messageId) {
89
+ const messages = this.cache.get(conversationId);
90
+ if (!messages) return;
91
+ const idx = messages.findIndex((m) => m.id === messageId);
92
+ if (idx >= 0) {
93
+ messages.splice(idx, 1);
94
+ }
95
+ }
96
+ clear(conversationId) {
97
+ if (conversationId) {
98
+ this.cache.delete(conversationId);
99
+ } else {
100
+ this.cache.clear();
101
+ }
102
+ }
103
+ evictOldConversations() {
104
+ while (this.cache.size > this.maxConversations) {
105
+ const firstKey = this.cache.keys().next().value;
106
+ if (firstKey) this.cache.delete(firstKey);
107
+ }
108
+ }
109
+ };
110
+
111
+ // src/core/OfflineQueue.ts
112
+ var STORAGE_KEY = "scalemule_chat_offline_queue";
113
+ var OfflineQueue = class {
114
+ constructor(enabled = true) {
115
+ this.queue = [];
116
+ this.enabled = enabled;
117
+ if (this.enabled) {
118
+ this.load();
119
+ }
120
+ }
121
+ enqueue(conversationId, content, messageType = "text", attachments) {
122
+ if (!this.enabled) return;
123
+ this.queue.push({
124
+ conversationId,
125
+ content,
126
+ message_type: messageType,
127
+ attachments,
128
+ timestamp: Date.now()
129
+ });
130
+ this.save();
131
+ }
132
+ drain() {
133
+ const items = [...this.queue];
134
+ this.queue = [];
135
+ this.save();
136
+ return items;
137
+ }
138
+ get size() {
139
+ return this.queue.length;
140
+ }
141
+ save() {
142
+ try {
143
+ if (typeof localStorage !== "undefined") {
144
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.queue));
145
+ }
146
+ } catch {
147
+ }
148
+ }
149
+ load() {
150
+ try {
151
+ if (typeof localStorage !== "undefined") {
152
+ const stored = localStorage.getItem(STORAGE_KEY);
153
+ if (stored) {
154
+ this.queue = JSON.parse(stored);
155
+ }
156
+ }
157
+ } catch {
158
+ this.queue = [];
159
+ }
160
+ }
161
+ };
162
+
163
+ // src/transport/HttpTransport.ts
164
+ var HttpTransport = class {
165
+ constructor(config) {
166
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
167
+ this.apiKey = config.apiKey;
168
+ this.getToken = config.getToken;
169
+ this.timeout = config.timeout ?? DEFAULT_REQUEST_TIMEOUT;
170
+ }
171
+ async get(path) {
172
+ return this.request("GET", path);
173
+ }
174
+ async post(path, body) {
175
+ return this.request("POST", path, body);
176
+ }
177
+ async patch(path, body) {
178
+ return this.request("PATCH", path, body);
179
+ }
180
+ async del(path) {
181
+ return this.request("DELETE", path);
182
+ }
183
+ async request(method, path, body) {
184
+ const headers = {
185
+ "Content-Type": "application/json"
186
+ };
187
+ if (this.apiKey) {
188
+ headers["x-api-key"] = this.apiKey;
189
+ }
190
+ if (this.getToken) {
191
+ const token = await this.getToken();
192
+ if (token) {
193
+ headers["Authorization"] = `Bearer ${token}`;
194
+ }
195
+ }
196
+ const controller = new AbortController();
197
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
198
+ try {
199
+ const response = await fetch(`${this.baseUrl}${path}`, {
200
+ method,
201
+ headers,
202
+ body: body ? JSON.stringify(body) : void 0,
203
+ signal: controller.signal,
204
+ credentials: "include"
205
+ });
206
+ clearTimeout(timeoutId);
207
+ if (response.status === 204) {
208
+ return { data: null, error: null };
209
+ }
210
+ const json = await response.json().catch(() => null);
211
+ if (!response.ok) {
212
+ const error = {
213
+ code: json?.error?.code ?? json?.code ?? "unknown",
214
+ message: json?.error?.message ?? json?.message ?? response.statusText,
215
+ status: response.status,
216
+ details: json?.error?.details ?? json?.details
217
+ };
218
+ return { data: null, error };
219
+ }
220
+ const data = json?.data !== void 0 ? json.data : json;
221
+ return { data, error: null };
222
+ } catch (err) {
223
+ clearTimeout(timeoutId);
224
+ const message = err instanceof Error ? err.message : "Network error";
225
+ return {
226
+ data: null,
227
+ error: { code: "network_error", message, status: 0 }
228
+ };
229
+ }
230
+ }
231
+ };
232
+
233
+ // src/transport/WebSocketTransport.ts
234
+ var WebSocketTransport = class extends EventEmitter {
235
+ constructor(config) {
236
+ super();
237
+ this.ws = null;
238
+ this.status = "disconnected";
239
+ this.subscriptions = /* @__PURE__ */ new Set();
240
+ this.presenceChannels = /* @__PURE__ */ new Map();
241
+ this.reconnectAttempt = 0;
242
+ this.reconnectTimer = null;
243
+ this.heartbeatTimer = null;
244
+ this.config = config;
245
+ this.maxRetries = config.reconnect?.maxRetries ?? DEFAULT_WS_RECONNECT_MAX_RETRIES;
246
+ this.baseDelay = config.reconnect?.baseDelay ?? DEFAULT_WS_RECONNECT_BASE_DELAY;
247
+ this.maxDelay = config.reconnect?.maxDelay ?? DEFAULT_WS_RECONNECT_MAX_DELAY;
248
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
249
+ this.ticketUrl = `${baseUrl}/v1/realtime/ws/ticket`;
250
+ this.wsBaseUrl = baseUrl.replace(/^http/, "ws") + "/v1/realtime/ws";
251
+ }
252
+ getStatus() {
253
+ return this.status;
254
+ }
255
+ async connect() {
256
+ if (this.status === "connected" || this.status === "connecting") return;
257
+ this.setStatus("connecting");
258
+ try {
259
+ const ticket = await this.obtainTicket();
260
+ if (!ticket) {
261
+ this.setStatus("disconnected");
262
+ this.emit("error", { message: "Failed to obtain WS ticket" });
263
+ return;
264
+ }
265
+ const wsUrl = `${this.wsBaseUrl}?ticket=${encodeURIComponent(ticket)}`;
266
+ this.ws = new WebSocket(wsUrl);
267
+ this.ws.onopen = () => {
268
+ this.setStatus("connected");
269
+ this.reconnectAttempt = 0;
270
+ this.startHeartbeat();
271
+ this.resubscribeAll();
272
+ };
273
+ this.ws.onmessage = (event) => {
274
+ this.handleMessage(event.data);
275
+ };
276
+ this.ws.onclose = () => {
277
+ this.stopHeartbeat();
278
+ if (this.status !== "disconnected") {
279
+ this.scheduleReconnect();
280
+ }
281
+ };
282
+ this.ws.onerror = () => {
283
+ };
284
+ } catch {
285
+ this.setStatus("disconnected");
286
+ this.scheduleReconnect();
287
+ }
288
+ }
289
+ disconnect() {
290
+ this.setStatus("disconnected");
291
+ this.stopHeartbeat();
292
+ if (this.reconnectTimer) {
293
+ clearTimeout(this.reconnectTimer);
294
+ this.reconnectTimer = null;
295
+ }
296
+ if (this.ws) {
297
+ this.ws.onclose = null;
298
+ this.ws.close();
299
+ this.ws = null;
300
+ }
301
+ }
302
+ subscribe(channel) {
303
+ this.subscriptions.add(channel);
304
+ if (this.status === "connected") {
305
+ this.send({ type: "subscribe", channel });
306
+ } else if (this.status === "disconnected") {
307
+ this.connect();
308
+ }
309
+ return () => this.unsubscribe(channel);
310
+ }
311
+ unsubscribe(channel) {
312
+ this.subscriptions.delete(channel);
313
+ if (this.status === "connected") {
314
+ this.send({ type: "unsubscribe", channel });
315
+ }
316
+ }
317
+ publish(channel, data) {
318
+ if (this.status === "connected") {
319
+ this.send({ type: "publish", channel, data });
320
+ }
321
+ }
322
+ joinPresence(channel, userData) {
323
+ this.presenceChannels.set(channel, userData);
324
+ if (this.status === "connected") {
325
+ this.send({ type: "presence_join", channel, user_data: userData ?? {} });
326
+ }
327
+ }
328
+ leavePresence(channel) {
329
+ this.presenceChannels.delete(channel);
330
+ if (this.status === "connected") {
331
+ this.send({ type: "presence_leave", channel });
332
+ }
333
+ }
334
+ async obtainTicket() {
335
+ const headers = {
336
+ "Content-Type": "application/json"
337
+ };
338
+ if (this.config.apiKey) {
339
+ headers["x-api-key"] = this.config.apiKey;
340
+ }
341
+ if (this.config.getToken) {
342
+ const token = await this.config.getToken();
343
+ if (token) {
344
+ headers["Authorization"] = `Bearer ${token}`;
345
+ }
346
+ }
347
+ try {
348
+ const response = await fetch(this.ticketUrl, {
349
+ method: "POST",
350
+ headers,
351
+ credentials: "include"
352
+ });
353
+ if (!response.ok) return null;
354
+ const json = await response.json();
355
+ return json.ticket ?? json.data?.ticket ?? null;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+ send(data) {
361
+ if (this.ws?.readyState === WebSocket.OPEN) {
362
+ this.ws.send(JSON.stringify(data));
363
+ }
364
+ }
365
+ handleMessage(raw) {
366
+ if (raw === "pong") return;
367
+ try {
368
+ const msg = JSON.parse(raw);
369
+ switch (msg.type) {
370
+ case "auth_success":
371
+ break;
372
+ case "subscribed":
373
+ break;
374
+ case "message":
375
+ this.emit("message", { channel: msg.channel, data: msg.data });
376
+ break;
377
+ case "presence_state":
378
+ this.emit("presence:state", { channel: msg.channel, members: msg.members ?? [] });
379
+ break;
380
+ case "presence_join":
381
+ this.emit("presence:join", { channel: msg.channel, user: msg.user });
382
+ break;
383
+ case "presence_leave":
384
+ this.emit("presence:leave", { channel: msg.channel, userId: msg.user_id });
385
+ break;
386
+ case "presence_update":
387
+ this.emit("presence:update", {
388
+ channel: msg.channel,
389
+ userId: msg.user_id,
390
+ status: msg.status,
391
+ userData: msg.user_data
392
+ });
393
+ break;
394
+ case "error":
395
+ this.emit("error", { message: msg.message ?? "Unknown error" });
396
+ break;
397
+ case "token_expiring":
398
+ this.reconnectAttempt = 0;
399
+ this.scheduleReconnect();
400
+ break;
401
+ }
402
+ } catch {
403
+ }
404
+ }
405
+ resubscribeAll() {
406
+ for (const channel of this.subscriptions) {
407
+ this.send({ type: "subscribe", channel });
408
+ }
409
+ for (const [channel, userData] of this.presenceChannels) {
410
+ this.send({ type: "presence_join", channel, user_data: userData ?? {} });
411
+ }
412
+ }
413
+ startHeartbeat() {
414
+ this.heartbeatTimer = setInterval(() => {
415
+ if (this.ws?.readyState === WebSocket.OPEN) {
416
+ this.ws.send("ping");
417
+ }
418
+ }, DEFAULT_WS_HEARTBEAT_INTERVAL);
419
+ }
420
+ stopHeartbeat() {
421
+ if (this.heartbeatTimer) {
422
+ clearInterval(this.heartbeatTimer);
423
+ this.heartbeatTimer = null;
424
+ }
425
+ }
426
+ scheduleReconnect() {
427
+ if (this.reconnectAttempt >= this.maxRetries) {
428
+ this.setStatus("disconnected");
429
+ return;
430
+ }
431
+ this.setStatus("reconnecting");
432
+ this.emit("reconnecting", { attempt: this.reconnectAttempt + 1 });
433
+ const delay = Math.min(
434
+ this.baseDelay * Math.pow(2, this.reconnectAttempt) + Math.random() * this.baseDelay * 0.3,
435
+ this.maxDelay
436
+ );
437
+ this.reconnectTimer = setTimeout(() => {
438
+ this.reconnectAttempt++;
439
+ this.connect();
440
+ }, delay);
441
+ }
442
+ setStatus(status) {
443
+ if (this.status !== status) {
444
+ this.status = status;
445
+ this.emit("status", status);
446
+ }
447
+ }
448
+ };
449
+
450
+ // src/core/ChatClient.ts
451
+ var ChatClient = class extends EventEmitter {
452
+ constructor(config) {
453
+ super();
454
+ this.conversationSubs = /* @__PURE__ */ new Map();
455
+ this.conversationTypes = /* @__PURE__ */ new Map();
456
+ const baseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE_URL;
457
+ this.http = new HttpTransport({
458
+ baseUrl,
459
+ apiKey: config.apiKey,
460
+ getToken: config.getToken ?? (config.sessionToken ? () => Promise.resolve(config.sessionToken) : void 0)
461
+ });
462
+ this.ws = new WebSocketTransport({
463
+ baseUrl,
464
+ apiKey: config.apiKey,
465
+ getToken: config.getToken ?? (config.sessionToken ? () => Promise.resolve(config.sessionToken) : void 0),
466
+ reconnect: config.reconnect
467
+ });
468
+ this.cache = new MessageCache(
469
+ config.messageCache?.maxMessages,
470
+ config.messageCache?.maxConversations
471
+ );
472
+ this.offlineQueue = new OfflineQueue(config.offlineQueue ?? true);
473
+ this.ws.on("status", (status) => {
474
+ switch (status) {
475
+ case "connected":
476
+ this.emit("connected");
477
+ this.flushOfflineQueue();
478
+ break;
479
+ case "disconnected":
480
+ this.emit("disconnected");
481
+ break;
482
+ }
483
+ });
484
+ this.ws.on("reconnecting", (data) => {
485
+ this.emit("reconnecting", data);
486
+ });
487
+ this.ws.on("message", ({ channel, data }) => {
488
+ this.handleRealtimeMessage(channel, data);
489
+ });
490
+ this.ws.on("presence:state", ({ channel, members }) => {
491
+ const conversationId = channel.replace(/^conversation:(?:lr:|bc:)?/, "");
492
+ this.emit("presence:state", { conversationId, members });
493
+ });
494
+ this.ws.on("presence:join", ({ channel, user }) => {
495
+ const conversationId = channel.replace(/^conversation:(?:lr:|bc:)?/, "");
496
+ this.emit("presence:join", { userId: user.user_id, conversationId, userData: user.user_data });
497
+ });
498
+ this.ws.on("presence:leave", ({ channel, userId }) => {
499
+ const conversationId = channel.replace(/^conversation:(?:lr:|bc:)?/, "");
500
+ this.emit("presence:leave", { userId, conversationId });
501
+ });
502
+ this.ws.on("presence:update", ({ channel, userId, status, userData }) => {
503
+ const conversationId = channel.replace(/^conversation:(?:lr:|bc:)?/, "");
504
+ this.emit("presence:update", { userId, conversationId, status, userData });
505
+ });
506
+ this.ws.on("error", ({ message }) => {
507
+ this.emit("error", { code: "ws_error", message });
508
+ });
509
+ }
510
+ // ============ Connection ============
511
+ get status() {
512
+ return this.ws.getStatus();
513
+ }
514
+ connect() {
515
+ this.ws.connect();
516
+ }
517
+ disconnect() {
518
+ for (const unsub of this.conversationSubs.values()) {
519
+ unsub();
520
+ }
521
+ this.conversationSubs.clear();
522
+ this.ws.disconnect();
523
+ }
524
+ // ============ Conversations ============
525
+ async createConversation(options) {
526
+ const result = await this.http.post("/v1/chat/conversations", options);
527
+ if (result.data) this.trackConversationType(result.data);
528
+ return result;
529
+ }
530
+ async listConversations(options) {
531
+ const params = new URLSearchParams();
532
+ if (options?.page) params.set("page", String(options.page));
533
+ if (options?.per_page) params.set("per_page", String(options.per_page));
534
+ const qs = params.toString();
535
+ const result = await this.http.get(`/v1/chat/conversations${qs ? "?" + qs : ""}`);
536
+ if (result.data) result.data.forEach((c) => this.trackConversationType(c));
537
+ return result;
538
+ }
539
+ async getConversation(id) {
540
+ const result = await this.http.get(`/v1/chat/conversations/${id}`);
541
+ if (result.data) this.trackConversationType(result.data);
542
+ return result;
543
+ }
544
+ trackConversationType(conversation) {
545
+ if (conversation.conversation_type !== "direct" && conversation.conversation_type !== "group") {
546
+ this.conversationTypes.set(conversation.id, conversation.conversation_type);
547
+ }
548
+ }
549
+ // ============ Messages ============
550
+ async sendMessage(conversationId, options) {
551
+ const result = await this.http.post(
552
+ `/v1/chat/conversations/${conversationId}/messages`,
553
+ {
554
+ content: options.content,
555
+ message_type: options.message_type ?? "text",
556
+ attachments: options.attachments
557
+ }
558
+ );
559
+ if (result.data) {
560
+ this.cache.addMessage(conversationId, result.data);
561
+ } else if (result.error?.status === 0) {
562
+ this.offlineQueue.enqueue(conversationId, options.content, options.message_type ?? "text");
563
+ }
564
+ return result;
565
+ }
566
+ async getMessages(conversationId, options) {
567
+ const params = new URLSearchParams();
568
+ if (options?.limit) params.set("limit", String(options.limit));
569
+ if (options?.before) params.set("before", options.before);
570
+ if (options?.after) params.set("after", options.after);
571
+ const qs = params.toString();
572
+ const result = await this.http.get(
573
+ `/v1/chat/conversations/${conversationId}/messages${qs ? "?" + qs : ""}`
574
+ );
575
+ if (result.data?.messages) {
576
+ if (!options?.before && !options?.after) {
577
+ this.cache.setMessages(conversationId, result.data.messages);
578
+ }
579
+ }
580
+ return result;
581
+ }
582
+ async editMessage(messageId, content) {
583
+ return this.http.patch(`/v1/chat/messages/${messageId}`, { content });
584
+ }
585
+ async deleteMessage(messageId) {
586
+ return this.http.del(`/v1/chat/messages/${messageId}`);
587
+ }
588
+ getCachedMessages(conversationId) {
589
+ return this.cache.getMessages(conversationId);
590
+ }
591
+ // ============ Reactions ============
592
+ async addReaction(messageId, emoji) {
593
+ return this.http.post(`/v1/chat/messages/${messageId}/reactions`, { emoji });
594
+ }
595
+ // ============ Typing & Read Receipts ============
596
+ async sendTyping(conversationId, isTyping = true) {
597
+ await this.http.post(`/v1/chat/conversations/${conversationId}/typing`, { is_typing: isTyping });
598
+ }
599
+ async markRead(conversationId) {
600
+ await this.http.post(`/v1/chat/conversations/${conversationId}/read`);
601
+ }
602
+ async getReadStatus(conversationId) {
603
+ return this.http.get(`/v1/chat/conversations/${conversationId}/read-status`);
604
+ }
605
+ // ============ Participants ============
606
+ async addParticipant(conversationId, userId) {
607
+ return this.http.post(`/v1/chat/conversations/${conversationId}/participants`, {
608
+ user_id: userId
609
+ });
610
+ }
611
+ async removeParticipant(conversationId, userId) {
612
+ return this.http.del(`/v1/chat/conversations/${conversationId}/participants/${userId}`);
613
+ }
614
+ // ============ Presence ============
615
+ joinPresence(conversationId, userData) {
616
+ const channel = this.channelName(conversationId);
617
+ this.ws.joinPresence(channel, userData);
618
+ }
619
+ leavePresence(conversationId) {
620
+ const channel = this.channelName(conversationId);
621
+ this.ws.leavePresence(channel);
622
+ }
623
+ /** Update presence status (online/away/dnd) without leaving the channel. */
624
+ updatePresence(conversationId, status, userData) {
625
+ const channel = this.channelName(conversationId);
626
+ this.ws.send({ type: "presence_update", channel, status, user_data: userData });
627
+ }
628
+ // ============ Channel Types ============
629
+ async getChannelSettings(channelId) {
630
+ return this.http.get(`/v1/chat/channels/${channelId}/settings`);
631
+ }
632
+ /**
633
+ * Set the conversation type for channel name routing.
634
+ * Large rooms use `conversation:lr:` prefix to skip MySQL in the realtime service.
635
+ */
636
+ setConversationType(conversationId, type) {
637
+ this.conversationTypes.set(conversationId, type);
638
+ }
639
+ // ============ Realtime Subscriptions ============
640
+ subscribeToConversation(conversationId) {
641
+ if (this.conversationSubs.has(conversationId)) {
642
+ return this.conversationSubs.get(conversationId);
643
+ }
644
+ const channel = this.channelName(conversationId);
645
+ const unsub = this.ws.subscribe(channel);
646
+ this.conversationSubs.set(conversationId, unsub);
647
+ return () => {
648
+ this.conversationSubs.delete(conversationId);
649
+ unsub();
650
+ };
651
+ }
652
+ /** Build channel name with correct prefix based on conversation type. */
653
+ channelName(conversationId) {
654
+ const type = this.conversationTypes.get(conversationId);
655
+ switch (type) {
656
+ case "large_room":
657
+ return `conversation:lr:${conversationId}`;
658
+ case "broadcast":
659
+ return `conversation:bc:${conversationId}`;
660
+ default:
661
+ return `conversation:${conversationId}`;
662
+ }
663
+ }
664
+ // ============ Cleanup ============
665
+ destroy() {
666
+ this.disconnect();
667
+ this.cache.clear();
668
+ this.removeAllListeners();
669
+ }
670
+ // ============ Private ============
671
+ handleRealtimeMessage(channel, data) {
672
+ if (!channel.startsWith("conversation:")) return;
673
+ const conversationId = channel.replace(/^conversation:(?:lr:|bc:)?/, "");
674
+ const raw = data;
675
+ if (!raw) return;
676
+ const event = raw.event ?? raw.type;
677
+ const payload = raw.data ?? raw;
678
+ switch (event) {
679
+ case "new_message": {
680
+ const message = payload;
681
+ this.cache.addMessage(conversationId, message);
682
+ this.emit("message", { message, conversationId });
683
+ break;
684
+ }
685
+ case "message_edited": {
686
+ const message = payload;
687
+ this.cache.updateMessage(conversationId, message);
688
+ this.emit("message:updated", { message, conversationId });
689
+ break;
690
+ }
691
+ case "message_deleted": {
692
+ const messageId = payload.message_id ?? payload.id;
693
+ if (messageId) {
694
+ this.cache.removeMessage(conversationId, messageId);
695
+ this.emit("message:deleted", { messageId, conversationId });
696
+ }
697
+ break;
698
+ }
699
+ case "user_typing": {
700
+ const userId = payload.user_id;
701
+ if (userId) {
702
+ this.emit("typing", { userId, conversationId });
703
+ }
704
+ break;
705
+ }
706
+ case "user_stopped_typing": {
707
+ const userId = payload.user_id;
708
+ if (userId) {
709
+ this.emit("typing:stop", { userId, conversationId });
710
+ }
711
+ break;
712
+ }
713
+ case "typing_batch": {
714
+ const users = payload.users ?? [];
715
+ for (const userId of users) {
716
+ this.emit("typing", { userId, conversationId });
717
+ }
718
+ break;
719
+ }
720
+ case "messages_read": {
721
+ const userId = payload.user_id;
722
+ const lastReadAt = payload.last_read_at;
723
+ if (userId && lastReadAt) {
724
+ this.emit("read", { userId, conversationId, lastReadAt });
725
+ }
726
+ break;
727
+ }
728
+ }
729
+ }
730
+ async flushOfflineQueue() {
731
+ const queued = this.offlineQueue.drain();
732
+ for (const item of queued) {
733
+ await this.sendMessage(item.conversationId, {
734
+ content: item.content,
735
+ message_type: item.message_type
736
+ });
737
+ }
738
+ }
739
+ };
740
+
741
+ exports.ChatClient = ChatClient;