opendesk-react-native 0.1.3 → 0.1.7

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/src/client.ts CHANGED
@@ -1,659 +1,680 @@
1
- import { OpenDeskApi, type Fetcher } from './api';
2
- import { OpenDeskError } from './errors';
3
- import { OpenDeskRealtime, phoenixSocketFactory, type SocketFactory } from './realtime';
4
- import { memoryStorage, type TokenStorage } from './storage';
5
- import type {
6
- Attachment,
7
- BotRef,
8
- Conversation,
9
- Identity,
10
- LiveEvent,
11
- Message,
12
- RealtimeEvent,
13
- SendMessageInput,
14
- StartConversationInput,
15
- UploadFile,
16
- WidgetConfig,
17
- WidgetSession,
18
- } from './types';
19
-
20
- export interface OpenDeskOptions {
21
- /** The openDesk origin, e.g. `https://desk.example.com`. */
22
- baseUrl: string;
23
- /** The chat widget's public key (`wk_…`). */
24
- widgetKey: string;
25
- /** Persists the visitor token so conversations survive restarts. Defaults to memory. */
26
- storage?: TokenStorage;
27
- fetcher?: Fetcher;
28
- socketFactory?: SocketFactory;
29
- }
30
-
31
- /** An AI answer being streamed before it lands as a message. */
32
- export interface AIReply {
33
- id: string;
34
- conversationId: string;
35
- text: string;
36
- phase: 'typing' | 'streaming' | 'done';
37
- tools: { name: string; status: 'running' | 'done' | 'error' }[];
38
- bot: BotRef | null;
39
- messageId: string | null;
40
- handoff: boolean;
41
- actionId: string | null;
42
- updatedAt: number;
43
- }
44
-
45
- export interface OpenDeskState {
46
- status: 'idle' | 'booting' | 'ready' | 'error';
47
- error: string | null;
48
- config: WidgetConfig | null;
49
- session: WidgetSession | null;
50
- connected: boolean;
51
- conversations: Conversation[];
52
- messages: Record<string, Message[]>;
53
- /** Conversation id → name of the agent currently typing. */
54
- typing: Record<string, string>;
55
- aiReplies: Record<string, AIReply>;
56
- /** Conversation id → agent replies received while that conversation was not open. */
57
- unread: Record<string, number>;
58
- activeConversationId: string | null;
59
- }
60
-
61
- export interface IncomingMessage {
62
- conversationId: string;
63
- message: Message;
64
- from: string;
65
- }
66
-
67
- const TYPING_TTL_MS = 6000;
68
- const AI_REPLY_TTL_MS = 60000;
69
-
70
- const initialState: OpenDeskState = {
71
- status: 'idle',
72
- error: null,
73
- config: null,
74
- session: null,
75
- connected: false,
76
- conversations: [],
77
- messages: {},
78
- typing: {},
79
- aiReplies: {},
80
- unread: {},
81
- activeConversationId: null,
82
- };
83
-
84
- let localIds = 0;
85
-
86
- /**
87
- * Headless openDesk chat client: owns the visitor session, the realtime socket and the
88
- * conversation state. Subscribe with `subscribe` (or the React hooks) to render it.
89
- */
90
- export class OpenDesk {
91
- readonly api: OpenDeskApi;
92
- private state: OpenDeskState = initialState;
93
- private identity: Identity = {};
94
- private realtime: OpenDeskRealtime | null = null;
95
- private bootPromise: Promise<void> | null = null;
96
- private readonly storage: TokenStorage;
97
- private readonly storageKey: string;
98
- private readonly socketFactory: SocketFactory;
99
- private readonly subscribers = new Set<() => void>();
100
- private readonly messageListeners = new Set<(incoming: IncomingMessage) => void>();
101
- private readonly typingTimers: Record<string, ReturnType<typeof setTimeout>> = {};
102
- /** Files uploaded this session, so a message can show its attachments before the server answers. */
103
- private readonly uploads = new Map<string, Attachment>();
104
-
105
- constructor(options: OpenDeskOptions) {
106
- this.api = new OpenDeskApi(options);
107
- this.storage = options.storage ?? memoryStorage();
108
- this.storageKey = `opendesk.visitor.${options.widgetKey}`;
109
- this.socketFactory = options.socketFactory ?? phoenixSocketFactory;
110
- }
111
-
112
- getState = (): OpenDeskState => this.state;
113
-
114
- subscribe = (listener: () => void): (() => void) => {
115
- this.subscribers.add(listener);
116
-
117
- return () => this.subscribers.delete(listener);
118
- };
119
-
120
- /** Called for every agent or AI reply, e.g. to show an in-app banner. */
121
- onMessage(listener: (incoming: IncomingMessage) => void): () => void {
122
- this.messageListeners.add(listener);
123
-
124
- return () => this.messageListeners.delete(listener);
125
- }
126
-
127
- get unreadCount(): number {
128
- return Object.values(this.state.unread).reduce((total, count) => total + count, 0);
129
- }
130
-
131
- /** Loads the widget config and starts (or resumes) the visitor session. Safe to call repeatedly. */
132
- boot(): Promise<void> {
133
- if (!this.bootPromise) {
134
- this.bootPromise = this.runBoot().catch((error: unknown) => {
135
- this.bootPromise = null;
136
- this.set({ status: 'error', error: errorMessage(error) });
137
- throw error;
138
- });
139
- }
140
-
141
- return this.bootPromise;
142
- }
143
-
144
- /**
145
- * Attaches the signed-in user. A different `user_id` from the one before drops the stored
146
- * visitor token, so one user never resumes another's conversations on a shared device.
147
- */
148
- async identify(identity: Identity): Promise<void> {
149
- const switchedUser = Boolean(this.identity.user_id) && this.identity.user_id !== identity.user_id;
150
- this.identity = { ...(switchedUser ? {} : this.identity), ...identity };
151
-
152
- if (switchedUser) {
153
- this.resetConversations();
154
- await this.forgetToken();
155
- }
156
-
157
- if (this.bootPromise) {
158
- await this.bootPromise.catch(() => undefined);
159
- if (this.state.status === 'ready') {
160
- await this.startSession();
161
- }
162
- }
163
- }
164
-
165
- /** Ends the session and forgets the visitor. Call it when the user signs out. */
166
- async shutdown(): Promise<void> {
167
- this.realtime?.stop();
168
- this.realtime = null;
169
- Object.values(this.typingTimers).forEach(clearTimeout);
170
- await this.api.endSession().catch(() => undefined);
171
- await this.forgetToken();
172
- this.identity = {};
173
- this.bootPromise = null;
174
- this.state = { ...initialState, config: this.state.config };
175
- this.emitChange();
176
- }
177
-
178
- loadConversations(): Promise<Conversation[]> {
179
- return this.authorized(() => this.fetchConversations());
180
- }
181
-
182
- /** Marks a conversation as on screen: loads its messages and clears its unread count. */
183
- async openConversation(conversationId: string): Promise<Message[]> {
184
- this.set((state) => ({
185
- activeConversationId: conversationId,
186
- unread: withoutKey(state.unread, conversationId),
187
- }));
188
- const messages = await this.refreshMessages(conversationId);
189
- this.api.markSeen(conversationId).catch(() => undefined);
190
-
191
- return messages;
192
- }
193
-
194
- closeConversation(): void {
195
- this.realtime?.typingDone();
196
- this.set({ activeConversationId: null });
197
- }
198
-
199
- async refreshMessages(conversationId: string): Promise<Message[]> {
200
- const messages = await this.authorized(() => this.api.messages(conversationId));
201
- this.applyMessages(conversationId, messages, true);
202
-
203
- return this.state.messages[conversationId] ?? [];
204
- }
205
-
206
- async startConversation(input: StartConversationInput): Promise<Conversation> {
207
- const { conversation, message } = await this.authorized(() => this.api.startConversation(input));
208
- this.set((state) => ({
209
- conversations: [conversation, ...state.conversations.filter((c) => c.id !== conversation.id)],
210
- activeConversationId: conversation.id,
211
- }));
212
- if (message) {
213
- this.applyMessages(conversation.id, [message], false);
214
- }
215
-
216
- return conversation;
217
- }
218
-
219
- /** Sends a reply, showing it at once and swapping in the server copy when it returns. */
220
- async sendMessage(conversationId: string, input: SendMessageInput): Promise<Message> {
221
- const localId = `local-${++localIds}`;
222
- const local: Message = {
223
- id: localId,
224
- ticket_id: conversationId,
225
- kind: 'reply',
226
- direction: 'inbound',
227
- author: { type: 'contact', contact: this.state.session?.contact ?? null },
228
- body_text: input.body ?? '',
229
- body_html: '',
230
- attachments: (input.attachment_ids ?? []).flatMap((id) => this.uploads.get(id) ?? []),
231
- created_at: new Date().toISOString(),
232
- };
233
- this.applyMessages(conversationId, [local], false);
234
- this.realtime?.typingDone();
235
-
236
- try {
237
- const message = await this.authorized(() => this.api.sendMessage(conversationId, input));
238
- this.applyMessages(conversationId, [message], false, localId);
239
- this.touchConversation(conversationId, message);
240
-
241
- return message;
242
- } catch (error) {
243
- this.set((state) => ({
244
- messages: {
245
- ...state.messages,
246
- [conversationId]: (state.messages[conversationId] ?? []).filter((m) => m.id !== localId),
247
- },
248
- }));
249
- throw error;
250
- }
251
- }
252
-
253
- async upload(file: UploadFile): Promise<Attachment> {
254
- const attachment = await this.authorized(() => this.api.upload(file));
255
- this.uploads.set(attachment.id, attachment);
256
-
257
- return attachment;
258
- }
259
-
260
- typing(conversationId: string): void {
261
- this.realtime?.typing(conversationId);
262
- }
263
-
264
- async requestHuman(conversationId: string): Promise<void> {
265
- await this.authorized(() => this.api.handoff(conversationId));
266
- this.set((state) => ({
267
- conversations: state.conversations.map((c) => (c.id === conversationId ? { ...c, ai_handoff: true } : c)),
268
- }));
269
- }
270
-
271
- decideAction(conversationId: string, actionId: string, decision: 'confirm' | 'decline'): Promise<void> {
272
- return this.authorized(() => this.api.decideAction(conversationId, actionId, decision));
273
- }
274
-
275
- rateMessage(conversationId: string, messageId: string, rating: 'up' | 'down'): Promise<void> {
276
- return this.authorized(() => this.api.rateMessage(conversationId, messageId, rating));
277
- }
278
-
279
- private async runBoot(): Promise<void> {
280
- this.set({ status: 'booting', error: null });
281
- if (!this.api.visitorToken) {
282
- this.api.visitorToken = await this.storage.getItem(this.storageKey).catch(() => null);
283
- }
284
- const config = await this.api.config();
285
- this.set({ config });
286
- await this.startSession();
287
- this.set({ status: 'ready' });
288
- }
289
-
290
- private async startSession(): Promise<void> {
291
- const session = await this.openSession();
292
- this.set({ session, error: null });
293
-
294
- this.realtime?.stop();
295
- this.realtime = new OpenDeskRealtime(
296
- this.socketFactory,
297
- session.gateway_url,
298
- session.ws_token,
299
- session.contact.id,
300
- async () => (await this.openSession()).ws_token,
301
- {
302
- onEvent: (event) => this.onRealtimeEvent(event),
303
- onLive: (event) => this.onLiveEvent(event),
304
- onTyping: (event) => this.onAgentTyping(event.ticket_id, event.user?.name || 'Support'),
305
- onStatus: (connected) => this.set({ connected }),
306
- onReconnect: () => void this.catchUp(),
307
- },
308
- );
309
- this.realtime.start();
310
-
311
- await this.fetchConversations();
312
- }
313
-
314
- private async fetchConversations(): Promise<Conversation[]> {
315
- const conversations = await this.api.conversations();
316
- conversations.sort((a, b) => Date.parse(b.last_message_at) - Date.parse(a.last_message_at));
317
- this.set({ conversations });
318
-
319
- return conversations;
320
- }
321
-
322
- /** Resumes the stored session; a revoked or expired token falls back to a new one. */
323
- private async openSession(): Promise<WidgetSession> {
324
- let session: WidgetSession;
325
- try {
326
- session = await this.api.session(this.identity);
327
- } catch (error) {
328
- if (!(error instanceof OpenDeskError) || !this.api.visitorToken || error.status < 400 || error.status >= 500) {
329
- throw error;
330
- }
331
- this.api.visitorToken = null;
332
- session = await this.api.session(this.identity);
333
- }
334
- await this.storage.setItem(this.storageKey, session.visitor_token).catch(() => undefined);
335
-
336
- return session;
337
- }
338
-
339
- /** Runs a visitor request, re-opening the session once if the token was revoked. */
340
- private async authorized<T>(request: () => Promise<T>): Promise<T> {
341
- await this.boot();
342
- try {
343
- return await request();
344
- } catch (error) {
345
- if (!(error instanceof OpenDeskError) || !error.isUnauthorized) {
346
- throw error;
347
- }
348
- this.api.visitorToken = null;
349
- await this.startSession();
350
-
351
- return request();
352
- }
353
- }
354
-
355
- private async catchUp(): Promise<void> {
356
- await this.loadConversations().catch(() => undefined);
357
- const active = this.state.activeConversationId;
358
- if (active) {
359
- await this.refreshMessages(active).catch(() => undefined);
360
- }
361
- }
362
-
363
- private onRealtimeEvent(event: RealtimeEvent): void {
364
- const conversationId = event.data.ticket_id ?? event.data.message?.ticket_id;
365
-
366
- if (event.type === 'message.withdrawn') {
367
- const messageId = event.data.message_id;
368
- if (conversationId && messageId) {
369
- this.set((state) => ({
370
- messages: {
371
- ...state.messages,
372
- [conversationId]: (state.messages[conversationId] ?? []).filter((m) => m.id !== messageId),
373
- },
374
- }));
375
- }
376
-
377
- return;
378
- }
379
-
380
- if (event.type !== 'message.created' && event.type !== 'message.updated') {
381
- return;
382
- }
383
-
384
- const message = event.data.message;
385
- if (!message || !conversationId || message.kind === 'note') {
386
- return;
387
- }
388
-
389
- this.applyMessages(conversationId, [message], false);
390
- if (event.type === 'message.updated') {
391
- return;
392
- }
393
-
394
- if (this.state.conversations.some((c) => c.id === conversationId)) {
395
- this.touchConversation(conversationId, message);
396
- } else {
397
- void this.loadConversations().catch(() => undefined);
398
- }
399
-
400
- if (message.direction !== 'outbound') {
401
- return;
402
- }
403
-
404
- this.clearTyping(conversationId);
405
- if (this.state.activeConversationId === conversationId) {
406
- this.api.markSeen(conversationId).catch(() => undefined);
407
- } else {
408
- this.set((state) => ({
409
- unread: { ...state.unread, [conversationId]: (state.unread[conversationId] ?? 0) + 1 },
410
- }));
411
- }
412
-
413
- const from =
414
- message.author.user?.name || message.author.bot?.name || this.state.config?.workspace_name || 'Support';
415
- this.messageListeners.forEach((listener) => {
416
- try {
417
- listener({ conversationId, message, from });
418
- } catch {
419
- // A listener's failure must not break the stream.
420
- }
421
- });
422
- }
423
-
424
- private onLiveEvent(event: LiveEvent): void {
425
- const { reply_id: replyId, ticket_id: conversationId } = event.data;
426
- if (typeof replyId !== 'string' || typeof conversationId !== 'string') {
427
- return;
428
- }
429
-
430
- const now = Date.now();
431
- this.set((state) => {
432
- const replies = pruneReplies(state.aiReplies, now);
433
- const current: AIReply = replies[replyId] ?? {
434
- id: replyId,
435
- conversationId,
436
- text: '',
437
- phase: 'typing',
438
- tools: [],
439
- bot: null,
440
- messageId: null,
441
- handoff: false,
442
- actionId: null,
443
- updatedAt: now,
444
- };
445
-
446
- switch (event.type) {
447
- case 'ai.reply.started':
448
- return { aiReplies: { ...replies, [replyId]: { ...current, bot: event.data.bot ?? current.bot, updatedAt: now } } };
449
- case 'ai.reply.delta':
450
- if (!event.data.text) {
451
- return {};
452
- }
453
-
454
- return {
455
- aiReplies: {
456
- ...replies,
457
- [replyId]: { ...current, text: current.text + event.data.text, phase: 'streaming', updatedAt: now },
458
- },
459
- };
460
- case 'ai.reply.tool': {
461
- const name = event.data.tool;
462
- if (!name) {
463
- return {};
464
- }
465
- const status = event.data.status === 'done' || event.data.status === 'error' ? event.data.status : 'running';
466
- const tools = [...current.tools];
467
- const running = tools.map((t) => t.name === name && t.status === 'running').lastIndexOf(true);
468
- if (running >= 0) {
469
- tools[running] = { name, status };
470
- } else {
471
- tools.push({ name, status });
472
- }
473
-
474
- return { aiReplies: { ...replies, [replyId]: { ...current, tools, updatedAt: now } } };
475
- }
476
- case 'ai.reply.ended': {
477
- const messageId = event.data.message_id || null;
478
- const landed =
479
- !messageId || (state.messages[conversationId] ?? []).some((m) => m.id === messageId);
480
- if (landed) {
481
- return { aiReplies: withoutKey(replies, replyId) };
482
- }
483
-
484
- return {
485
- aiReplies: {
486
- ...replies,
487
- [replyId]: {
488
- ...current,
489
- phase: 'done',
490
- tools: current.tools.map((t) => (t.status === 'running' ? { ...t, status: 'done' as const } : t)),
491
- messageId,
492
- handoff: event.data.handoff === true,
493
- actionId: event.data.action_id ?? null,
494
- updatedAt: now,
495
- },
496
- },
497
- };
498
- }
499
- default:
500
- return {};
501
- }
502
- });
503
- }
504
-
505
- private onAgentTyping(conversationId: string, name: string): void {
506
- this.set((state) => ({ typing: { ...state.typing, [conversationId]: name } }));
507
- clearTimeout(this.typingTimers[conversationId]);
508
- this.typingTimers[conversationId] = setTimeout(() => this.clearTyping(conversationId), TYPING_TTL_MS);
509
- }
510
-
511
- private clearTyping(conversationId: string): void {
512
- clearTimeout(this.typingTimers[conversationId]);
513
- delete this.typingTimers[conversationId];
514
- if (conversationId in this.state.typing) {
515
- this.set((state) => ({ typing: withoutKey(state.typing, conversationId) }));
516
- }
517
- }
518
-
519
- /**
520
- * Merges messages by id in time order; `replace` makes the list exactly the server's copy.
521
- * A stored message takes over the render key of the optimistic copy (`replacesLocalId`, or
522
- * a pending copy with the same text) or of the AI stream it finishes, so its row never remounts.
523
- */
524
- private applyMessages(conversationId: string, incoming: Message[], replace: boolean, replacesLocalId?: string): void {
525
- this.set((state) => {
526
- const existing = state.messages[conversationId] ?? [];
527
- const existingById = new Map(existing.map((m) => [m.id, m]));
528
- const pending = existing.filter((m) => m.id.startsWith('local-'));
529
- const usedLocalIds = new Set<string>();
530
- const finishedReplyIds = new Set<string>();
531
-
532
- const keyed = incoming.map((message): Message => {
533
- if (message.client_id) {
534
- return message;
535
- }
536
- const previous = existingById.get(message.id);
537
- if (previous?.client_id) {
538
- return { ...message, client_id: previous.client_id };
539
- }
540
- if (message.direction === 'inbound' && !message.id.startsWith('local-')) {
541
- const local =
542
- pending.find((m) => m.id === replacesLocalId) ??
543
- pending.find((m) => !usedLocalIds.has(m.id) && (m.body_text ?? '') === (message.body_text ?? ''));
544
- if (local) {
545
- usedLocalIds.add(local.id);
546
-
547
- return { ...message, client_id: local.client_id ?? local.id };
548
- }
549
- }
550
- if (message.ai && message.direction === 'outbound') {
551
- const reply = Object.values(state.aiReplies).find(
552
- (r) =>
553
- !finishedReplyIds.has(r.id) &&
554
- (r.messageId === message.id || (r.conversationId === conversationId && r.phase !== 'done')),
555
- );
556
- if (reply) {
557
- finishedReplyIds.add(reply.id);
558
-
559
- return { ...message, client_id: `ai-${reply.id}` };
560
- }
561
- }
562
-
563
- return message;
564
- });
565
-
566
- const base = (replace ? pending : existing).filter((m) => !usedLocalIds.has(m.id));
567
- const byId = new Map(base.map((m) => [m.id, m]));
568
- keyed.forEach((m) => byId.set(m.id, m));
569
- const merged = [...byId.values()].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
570
-
571
- const landedIds = new Set(incoming.map((m) => m.id));
572
- const aiReplies = Object.fromEntries(
573
- Object.entries(state.aiReplies).filter(
574
- ([id, reply]) => !finishedReplyIds.has(id) && !(reply.messageId && landedIds.has(reply.messageId)),
575
- ),
576
- );
577
-
578
- return { messages: { ...state.messages, [conversationId]: merged }, aiReplies };
579
- });
580
- }
581
-
582
- private touchConversation(conversationId: string, message: Message): void {
583
- this.set((state) => {
584
- const conversation = state.conversations.find((c) => c.id === conversationId);
585
- if (!conversation) {
586
- return {};
587
- }
588
-
589
- return {
590
- conversations: [
591
- {
592
- ...conversation,
593
- last_message_at: message.created_at,
594
- last_message_preview: previewOf(message),
595
- },
596
- ...state.conversations.filter((c) => c.id !== conversationId),
597
- ],
598
- };
599
- });
600
- }
601
-
602
- private resetConversations(): void {
603
- this.realtime?.stop();
604
- this.realtime = null;
605
- this.set({
606
- session: null,
607
- connected: false,
608
- conversations: [],
609
- messages: {},
610
- typing: {},
611
- aiReplies: {},
612
- unread: {},
613
- activeConversationId: null,
614
- });
615
- }
616
-
617
- private async forgetToken(): Promise<void> {
618
- this.api.visitorToken = null;
619
- await this.storage.removeItem(this.storageKey).catch(() => undefined);
620
- }
621
-
622
- private set(patch: Partial<OpenDeskState> | ((state: OpenDeskState) => Partial<OpenDeskState>)): void {
623
- const next = typeof patch === 'function' ? patch(this.state) : patch;
624
- if (Object.keys(next).length === 0) {
625
- return;
626
- }
627
- this.state = { ...this.state, ...next };
628
- this.emitChange();
629
- }
630
-
631
- private emitChange(): void {
632
- this.subscribers.forEach((listener) => listener());
633
- }
634
- }
635
-
636
- function withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
637
- if (!(key in record)) {
638
- return record;
639
- }
640
- const copy = { ...record };
641
- delete copy[key];
642
-
643
- return copy;
644
- }
645
-
646
- function pruneReplies(replies: Record<string, AIReply>, now: number): Record<string, AIReply> {
647
- return Object.fromEntries(Object.entries(replies).filter(([, reply]) => now - reply.updatedAt < AI_REPLY_TTL_MS));
648
- }
649
-
650
- /** A list preview for a message; an attachment-only message shows its file names. */
651
- function previewOf(message: Message): string {
652
- const text = (message.body_text ?? '').trim();
653
-
654
- return (text || (message.attachments ?? []).map((a) => a.filename).join(', ')).slice(0, 140);
655
- }
656
-
657
- function errorMessage(error: unknown): string {
658
- return error instanceof Error ? error.message : 'Support is unavailable right now';
659
- }
1
+ import { OpenDeskApi, type Fetcher } from './api';
2
+ import { OpenDeskError } from './errors';
3
+ import { OpenDeskRealtime, phoenixSocketFactory, type SocketFactory } from './realtime';
4
+ import { memoryStorage, type TokenStorage } from './storage';
5
+ import type {
6
+ Attachment,
7
+ BotRef,
8
+ Conversation,
9
+ Identity,
10
+ LiveEvent,
11
+ Message,
12
+ RealtimeEvent,
13
+ SendMessageInput,
14
+ StartConversationInput,
15
+ UploadFile,
16
+ WidgetConfig,
17
+ WidgetSession,
18
+ } from './types';
19
+
20
+ export interface OpenDeskOptions {
21
+ /** The openDesk origin, e.g. `https://desk.example.com`. */
22
+ baseUrl: string;
23
+ /** The chat widget's public key (`wk_…`). */
24
+ widgetKey: string;
25
+ /** Persists the visitor token so conversations survive restarts. Defaults to memory. */
26
+ storage?: TokenStorage;
27
+ /** Replaces `fetch`. Native `{ uri }` uploads still go over XMLHttpRequest, which can read the file. */
28
+ fetcher?: Fetcher;
29
+ socketFactory?: SocketFactory;
30
+ /**
31
+ * Played when an agent or AI reply arrives in a conversation that isn't open, if the widget
32
+ * has sound on. `expoAudioSound(ExpoAudio)` plays the web widget's chime.
33
+ */
34
+ sound?: () => void;
35
+ }
36
+
37
+ /** An AI answer being streamed before it lands as a message. */
38
+ export interface AIReply {
39
+ id: string;
40
+ conversationId: string;
41
+ text: string;
42
+ phase: 'typing' | 'streaming' | 'done';
43
+ tools: { name: string; status: 'running' | 'done' | 'error' }[];
44
+ bot: BotRef | null;
45
+ messageId: string | null;
46
+ handoff: boolean;
47
+ actionId: string | null;
48
+ updatedAt: number;
49
+ }
50
+
51
+ export interface OpenDeskState {
52
+ status: 'idle' | 'booting' | 'ready' | 'error';
53
+ error: string | null;
54
+ config: WidgetConfig | null;
55
+ session: WidgetSession | null;
56
+ connected: boolean;
57
+ conversations: Conversation[];
58
+ messages: Record<string, Message[]>;
59
+ /** Conversation id → name of the agent currently typing. */
60
+ typing: Record<string, string>;
61
+ aiReplies: Record<string, AIReply>;
62
+ /** Conversation id → agent replies received while that conversation was not open. */
63
+ unread: Record<string, number>;
64
+ activeConversationId: string | null;
65
+ }
66
+
67
+ export interface IncomingMessage {
68
+ conversationId: string;
69
+ message: Message;
70
+ from: string;
71
+ }
72
+
73
+ const TYPING_TTL_MS = 6000;
74
+ const AI_REPLY_TTL_MS = 60000;
75
+
76
+ const initialState: OpenDeskState = {
77
+ status: 'idle',
78
+ error: null,
79
+ config: null,
80
+ session: null,
81
+ connected: false,
82
+ conversations: [],
83
+ messages: {},
84
+ typing: {},
85
+ aiReplies: {},
86
+ unread: {},
87
+ activeConversationId: null,
88
+ };
89
+
90
+ let localIds = 0;
91
+
92
+ /**
93
+ * Headless openDesk chat client: owns the visitor session, the realtime socket and the
94
+ * conversation state. Subscribe with `subscribe` (or the React hooks) to render it.
95
+ */
96
+ export class OpenDesk {
97
+ readonly api: OpenDeskApi;
98
+ private state: OpenDeskState = initialState;
99
+ private identity: Identity = {};
100
+ private realtime: OpenDeskRealtime | null = null;
101
+ private bootPromise: Promise<void> | null = null;
102
+ private readonly storage: TokenStorage;
103
+ private readonly storageKey: string;
104
+ private readonly socketFactory: SocketFactory;
105
+ private readonly sound: (() => void) | null;
106
+ private readonly subscribers = new Set<() => void>();
107
+ private readonly messageListeners = new Set<(incoming: IncomingMessage) => void>();
108
+ private readonly typingTimers: Record<string, ReturnType<typeof setTimeout>> = {};
109
+ /** Files uploaded this session, so a message can show its attachments before the server answers. */
110
+ private readonly uploads = new Map<string, Attachment>();
111
+
112
+ constructor(options: OpenDeskOptions) {
113
+ this.api = new OpenDeskApi(options);
114
+ this.storage = options.storage ?? memoryStorage();
115
+ this.storageKey = `opendesk.visitor.${options.widgetKey}`;
116
+ this.socketFactory = options.socketFactory ?? phoenixSocketFactory;
117
+ this.sound = options.sound ?? null;
118
+ }
119
+
120
+ getState = (): OpenDeskState => this.state;
121
+
122
+ subscribe = (listener: () => void): (() => void) => {
123
+ this.subscribers.add(listener);
124
+
125
+ return () => this.subscribers.delete(listener);
126
+ };
127
+
128
+ /** Called for every agent or AI reply, e.g. to show an in-app banner. */
129
+ onMessage(listener: (incoming: IncomingMessage) => void): () => void {
130
+ this.messageListeners.add(listener);
131
+
132
+ return () => this.messageListeners.delete(listener);
133
+ }
134
+
135
+ get unreadCount(): number {
136
+ return Object.values(this.state.unread).reduce((total, count) => total + count, 0);
137
+ }
138
+
139
+ /** Loads the widget config and starts (or resumes) the visitor session. Safe to call repeatedly. */
140
+ boot(): Promise<void> {
141
+ if (!this.bootPromise) {
142
+ this.bootPromise = this.runBoot().catch((error: unknown) => {
143
+ this.bootPromise = null;
144
+ this.set({ status: 'error', error: errorMessage(error) });
145
+ throw error;
146
+ });
147
+ }
148
+
149
+ return this.bootPromise;
150
+ }
151
+
152
+ /**
153
+ * Attaches the signed-in user. A different `user_id` from the one before drops the stored
154
+ * visitor token, so one user never resumes another's conversations on a shared device.
155
+ */
156
+ async identify(identity: Identity): Promise<void> {
157
+ const switchedUser = Boolean(this.identity.user_id) && this.identity.user_id !== identity.user_id;
158
+ this.identity = { ...(switchedUser ? {} : this.identity), ...identity };
159
+
160
+ if (switchedUser) {
161
+ this.resetConversations();
162
+ await this.forgetToken();
163
+ }
164
+
165
+ if (this.bootPromise) {
166
+ await this.bootPromise.catch(() => undefined);
167
+ if (this.state.status === 'ready') {
168
+ await this.startSession();
169
+ }
170
+ }
171
+ }
172
+
173
+ /** Ends the session and forgets the visitor. Call it when the user signs out. */
174
+ async shutdown(): Promise<void> {
175
+ this.realtime?.stop();
176
+ this.realtime = null;
177
+ Object.values(this.typingTimers).forEach(clearTimeout);
178
+ await this.api.endSession().catch(() => undefined);
179
+ await this.forgetToken();
180
+ this.identity = {};
181
+ this.bootPromise = null;
182
+ this.state = { ...initialState, config: this.state.config };
183
+ this.emitChange();
184
+ }
185
+
186
+ loadConversations(): Promise<Conversation[]> {
187
+ return this.authorized(() => this.fetchConversations());
188
+ }
189
+
190
+ /** Marks a conversation as on screen: loads its messages and clears its unread count. */
191
+ async openConversation(conversationId: string): Promise<Message[]> {
192
+ this.set((state) => ({
193
+ activeConversationId: conversationId,
194
+ unread: withoutKey(state.unread, conversationId),
195
+ }));
196
+ const messages = await this.refreshMessages(conversationId);
197
+ this.api.markSeen(conversationId).catch(() => undefined);
198
+
199
+ return messages;
200
+ }
201
+
202
+ closeConversation(): void {
203
+ this.realtime?.typingDone();
204
+ this.set({ activeConversationId: null });
205
+ }
206
+
207
+ async refreshMessages(conversationId: string): Promise<Message[]> {
208
+ const messages = await this.authorized(() => this.api.messages(conversationId));
209
+ this.applyMessages(conversationId, messages, true);
210
+
211
+ return this.state.messages[conversationId] ?? [];
212
+ }
213
+
214
+ async startConversation(input: StartConversationInput): Promise<Conversation> {
215
+ const { conversation, message } = await this.authorized(() => this.api.startConversation(input));
216
+ this.set((state) => ({
217
+ conversations: [conversation, ...state.conversations.filter((c) => c.id !== conversation.id)],
218
+ activeConversationId: conversation.id,
219
+ }));
220
+ if (message) {
221
+ this.applyMessages(conversation.id, [message], false);
222
+ }
223
+
224
+ return conversation;
225
+ }
226
+
227
+ /** Sends a reply, showing it at once and swapping in the server copy when it returns. */
228
+ async sendMessage(conversationId: string, input: SendMessageInput): Promise<Message> {
229
+ const localId = `local-${++localIds}`;
230
+ const local: Message = {
231
+ id: localId,
232
+ ticket_id: conversationId,
233
+ kind: 'reply',
234
+ direction: 'inbound',
235
+ author: { type: 'contact', contact: this.state.session?.contact ?? null },
236
+ body_text: input.body ?? '',
237
+ body_html: '',
238
+ attachments: (input.attachment_ids ?? []).flatMap((id) => this.uploads.get(id) ?? []),
239
+ created_at: new Date().toISOString(),
240
+ };
241
+ this.applyMessages(conversationId, [local], false);
242
+ this.realtime?.typingDone();
243
+
244
+ try {
245
+ const message = await this.authorized(() => this.api.sendMessage(conversationId, input));
246
+ this.applyMessages(conversationId, [message], false, localId);
247
+ this.touchConversation(conversationId, message);
248
+
249
+ return message;
250
+ } catch (error) {
251
+ this.set((state) => ({
252
+ messages: {
253
+ ...state.messages,
254
+ [conversationId]: (state.messages[conversationId] ?? []).filter((m) => m.id !== localId),
255
+ },
256
+ }));
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ async upload(file: UploadFile): Promise<Attachment> {
262
+ const attachment = await this.authorized(() => this.api.upload(file));
263
+ this.uploads.set(attachment.id, attachment);
264
+
265
+ return attachment;
266
+ }
267
+
268
+ typing(conversationId: string): void {
269
+ this.realtime?.typing(conversationId);
270
+ }
271
+
272
+ async requestHuman(conversationId: string): Promise<void> {
273
+ await this.authorized(() => this.api.handoff(conversationId));
274
+ this.set((state) => ({
275
+ conversations: state.conversations.map((c) => (c.id === conversationId ? { ...c, ai_handoff: true } : c)),
276
+ }));
277
+ }
278
+
279
+ decideAction(conversationId: string, actionId: string, decision: 'confirm' | 'decline'): Promise<void> {
280
+ return this.authorized(() => this.api.decideAction(conversationId, actionId, decision));
281
+ }
282
+
283
+ rateMessage(conversationId: string, messageId: string, rating: 'up' | 'down'): Promise<void> {
284
+ return this.authorized(() => this.api.rateMessage(conversationId, messageId, rating));
285
+ }
286
+
287
+ private async runBoot(): Promise<void> {
288
+ this.set({ status: 'booting', error: null });
289
+ if (!this.api.visitorToken) {
290
+ this.api.visitorToken = await this.storage.getItem(this.storageKey).catch(() => null);
291
+ }
292
+ const config = await this.api.config();
293
+ this.set({ config });
294
+ await this.startSession();
295
+ this.set({ status: 'ready' });
296
+ }
297
+
298
+ private async startSession(): Promise<void> {
299
+ const session = await this.openSession();
300
+ this.set({ session, error: null });
301
+
302
+ this.realtime?.stop();
303
+ this.realtime = new OpenDeskRealtime(
304
+ this.socketFactory,
305
+ session.gateway_url,
306
+ session.ws_token,
307
+ session.contact.id,
308
+ async () => (await this.openSession()).ws_token,
309
+ {
310
+ onEvent: (event) => this.onRealtimeEvent(event),
311
+ onLive: (event) => this.onLiveEvent(event),
312
+ onTyping: (event) => this.onAgentTyping(event.ticket_id, event.user?.name || 'Support'),
313
+ onStatus: (connected) => this.set({ connected }),
314
+ onReconnect: () => void this.catchUp(),
315
+ },
316
+ );
317
+ this.realtime.start();
318
+
319
+ await this.fetchConversations();
320
+ }
321
+
322
+ private async fetchConversations(): Promise<Conversation[]> {
323
+ const conversations = await this.api.conversations();
324
+ conversations.sort((a, b) => Date.parse(b.last_message_at) - Date.parse(a.last_message_at));
325
+ this.set({ conversations });
326
+
327
+ return conversations;
328
+ }
329
+
330
+ /** Resumes the stored session; a revoked or expired token falls back to a new one. */
331
+ private async openSession(): Promise<WidgetSession> {
332
+ let session: WidgetSession;
333
+ try {
334
+ session = await this.api.session(this.identity);
335
+ } catch (error) {
336
+ if (!(error instanceof OpenDeskError) || !this.api.visitorToken || error.status < 400 || error.status >= 500) {
337
+ throw error;
338
+ }
339
+ this.api.visitorToken = null;
340
+ session = await this.api.session(this.identity);
341
+ }
342
+ await this.storage.setItem(this.storageKey, session.visitor_token).catch(() => undefined);
343
+
344
+ return session;
345
+ }
346
+
347
+ /** Runs a visitor request, re-opening the session once if the token was revoked. */
348
+ private async authorized<T>(request: () => Promise<T>): Promise<T> {
349
+ await this.boot();
350
+ try {
351
+ return await request();
352
+ } catch (error) {
353
+ if (!(error instanceof OpenDeskError) || !error.isUnauthorized) {
354
+ throw error;
355
+ }
356
+ this.api.visitorToken = null;
357
+ await this.startSession();
358
+
359
+ return request();
360
+ }
361
+ }
362
+
363
+ private async catchUp(): Promise<void> {
364
+ await this.loadConversations().catch(() => undefined);
365
+ const active = this.state.activeConversationId;
366
+ if (active) {
367
+ await this.refreshMessages(active).catch(() => undefined);
368
+ }
369
+ }
370
+
371
+ private onRealtimeEvent(event: RealtimeEvent): void {
372
+ const conversationId = event.data.ticket_id ?? event.data.message?.ticket_id;
373
+
374
+ if (event.type === 'message.withdrawn') {
375
+ const messageId = event.data.message_id;
376
+ if (conversationId && messageId) {
377
+ this.set((state) => ({
378
+ messages: {
379
+ ...state.messages,
380
+ [conversationId]: (state.messages[conversationId] ?? []).filter((m) => m.id !== messageId),
381
+ },
382
+ }));
383
+ }
384
+
385
+ return;
386
+ }
387
+
388
+ if (event.type !== 'message.created' && event.type !== 'message.updated') {
389
+ return;
390
+ }
391
+
392
+ const message = event.data.message;
393
+ if (!message || !conversationId || message.kind === 'note') {
394
+ return;
395
+ }
396
+
397
+ this.applyMessages(conversationId, [message], false);
398
+ if (event.type === 'message.updated') {
399
+ return;
400
+ }
401
+
402
+ if (this.state.conversations.some((c) => c.id === conversationId)) {
403
+ this.touchConversation(conversationId, message);
404
+ } else {
405
+ void this.loadConversations().catch(() => undefined);
406
+ }
407
+
408
+ if (message.direction !== 'outbound') {
409
+ return;
410
+ }
411
+
412
+ this.clearTyping(conversationId);
413
+ if (this.state.activeConversationId === conversationId) {
414
+ this.api.markSeen(conversationId).catch(() => undefined);
415
+ } else {
416
+ this.set((state) => ({
417
+ unread: { ...state.unread, [conversationId]: (state.unread[conversationId] ?? 0) + 1 },
418
+ }));
419
+ this.playSound();
420
+ }
421
+
422
+ const from =
423
+ message.author.user?.name || message.author.bot?.name || this.state.config?.workspace_name || 'Support';
424
+ this.messageListeners.forEach((listener) => {
425
+ try {
426
+ listener({ conversationId, message, from });
427
+ } catch {
428
+ // A listener's failure must not break the stream.
429
+ }
430
+ });
431
+ }
432
+
433
+ /** The widget's sound setting decides, as on the web; the app decides what plays. */
434
+ private playSound(): void {
435
+ if (!this.sound || this.state.config?.sound !== true) {
436
+ return;
437
+ }
438
+ try {
439
+ this.sound();
440
+ } catch {
441
+ // A sound that fails must not break the stream.
442
+ }
443
+ }
444
+
445
+ private onLiveEvent(event: LiveEvent): void {
446
+ const { reply_id: replyId, ticket_id: conversationId } = event.data;
447
+ if (typeof replyId !== 'string' || typeof conversationId !== 'string') {
448
+ return;
449
+ }
450
+
451
+ const now = Date.now();
452
+ this.set((state) => {
453
+ const replies = pruneReplies(state.aiReplies, now);
454
+ const current: AIReply = replies[replyId] ?? {
455
+ id: replyId,
456
+ conversationId,
457
+ text: '',
458
+ phase: 'typing',
459
+ tools: [],
460
+ bot: null,
461
+ messageId: null,
462
+ handoff: false,
463
+ actionId: null,
464
+ updatedAt: now,
465
+ };
466
+
467
+ switch (event.type) {
468
+ case 'ai.reply.started':
469
+ return { aiReplies: { ...replies, [replyId]: { ...current, bot: event.data.bot ?? current.bot, updatedAt: now } } };
470
+ case 'ai.reply.delta':
471
+ if (!event.data.text) {
472
+ return {};
473
+ }
474
+
475
+ return {
476
+ aiReplies: {
477
+ ...replies,
478
+ [replyId]: { ...current, text: current.text + event.data.text, phase: 'streaming', updatedAt: now },
479
+ },
480
+ };
481
+ case 'ai.reply.tool': {
482
+ const name = event.data.tool;
483
+ if (!name) {
484
+ return {};
485
+ }
486
+ const status = event.data.status === 'done' || event.data.status === 'error' ? event.data.status : 'running';
487
+ const tools = [...current.tools];
488
+ const running = tools.map((t) => t.name === name && t.status === 'running').lastIndexOf(true);
489
+ if (running >= 0) {
490
+ tools[running] = { name, status };
491
+ } else {
492
+ tools.push({ name, status });
493
+ }
494
+
495
+ return { aiReplies: { ...replies, [replyId]: { ...current, tools, updatedAt: now } } };
496
+ }
497
+ case 'ai.reply.ended': {
498
+ const messageId = event.data.message_id || null;
499
+ const landed =
500
+ !messageId || (state.messages[conversationId] ?? []).some((m) => m.id === messageId);
501
+ if (landed) {
502
+ return { aiReplies: withoutKey(replies, replyId) };
503
+ }
504
+
505
+ return {
506
+ aiReplies: {
507
+ ...replies,
508
+ [replyId]: {
509
+ ...current,
510
+ phase: 'done',
511
+ tools: current.tools.map((t) => (t.status === 'running' ? { ...t, status: 'done' as const } : t)),
512
+ messageId,
513
+ handoff: event.data.handoff === true,
514
+ actionId: event.data.action_id ?? null,
515
+ updatedAt: now,
516
+ },
517
+ },
518
+ };
519
+ }
520
+ default:
521
+ return {};
522
+ }
523
+ });
524
+ }
525
+
526
+ private onAgentTyping(conversationId: string, name: string): void {
527
+ this.set((state) => ({ typing: { ...state.typing, [conversationId]: name } }));
528
+ clearTimeout(this.typingTimers[conversationId]);
529
+ this.typingTimers[conversationId] = setTimeout(() => this.clearTyping(conversationId), TYPING_TTL_MS);
530
+ }
531
+
532
+ private clearTyping(conversationId: string): void {
533
+ clearTimeout(this.typingTimers[conversationId]);
534
+ delete this.typingTimers[conversationId];
535
+ if (conversationId in this.state.typing) {
536
+ this.set((state) => ({ typing: withoutKey(state.typing, conversationId) }));
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Merges messages by id in time order; `replace` makes the list exactly the server's copy.
542
+ * A stored message takes over the render key of the optimistic copy (`replacesLocalId`, or
543
+ * a pending copy with the same text) or of the AI stream it finishes, so its row never remounts.
544
+ */
545
+ private applyMessages(conversationId: string, incoming: Message[], replace: boolean, replacesLocalId?: string): void {
546
+ this.set((state) => {
547
+ const existing = state.messages[conversationId] ?? [];
548
+ const existingById = new Map(existing.map((m) => [m.id, m]));
549
+ const pending = existing.filter((m) => m.id.startsWith('local-'));
550
+ const usedLocalIds = new Set<string>();
551
+ const finishedReplyIds = new Set<string>();
552
+
553
+ const keyed = incoming.map((message): Message => {
554
+ if (message.client_id) {
555
+ return message;
556
+ }
557
+ const previous = existingById.get(message.id);
558
+ if (previous?.client_id) {
559
+ return { ...message, client_id: previous.client_id };
560
+ }
561
+ if (message.direction === 'inbound' && !message.id.startsWith('local-')) {
562
+ const local =
563
+ pending.find((m) => m.id === replacesLocalId) ??
564
+ pending.find((m) => !usedLocalIds.has(m.id) && (m.body_text ?? '') === (message.body_text ?? ''));
565
+ if (local) {
566
+ usedLocalIds.add(local.id);
567
+
568
+ return { ...message, client_id: local.client_id ?? local.id };
569
+ }
570
+ }
571
+ if (message.ai && message.direction === 'outbound') {
572
+ const reply = Object.values(state.aiReplies).find(
573
+ (r) =>
574
+ !finishedReplyIds.has(r.id) &&
575
+ (r.messageId === message.id || (r.conversationId === conversationId && r.phase !== 'done')),
576
+ );
577
+ if (reply) {
578
+ finishedReplyIds.add(reply.id);
579
+
580
+ return { ...message, client_id: `ai-${reply.id}` };
581
+ }
582
+ }
583
+
584
+ return message;
585
+ });
586
+
587
+ const base = (replace ? pending : existing).filter((m) => !usedLocalIds.has(m.id));
588
+ const byId = new Map(base.map((m) => [m.id, m]));
589
+ keyed.forEach((m) => byId.set(m.id, m));
590
+ const merged = [...byId.values()].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
591
+
592
+ const landedIds = new Set(incoming.map((m) => m.id));
593
+ const aiReplies = Object.fromEntries(
594
+ Object.entries(state.aiReplies).filter(
595
+ ([id, reply]) => !finishedReplyIds.has(id) && !(reply.messageId && landedIds.has(reply.messageId)),
596
+ ),
597
+ );
598
+
599
+ return { messages: { ...state.messages, [conversationId]: merged }, aiReplies };
600
+ });
601
+ }
602
+
603
+ private touchConversation(conversationId: string, message: Message): void {
604
+ this.set((state) => {
605
+ const conversation = state.conversations.find((c) => c.id === conversationId);
606
+ if (!conversation) {
607
+ return {};
608
+ }
609
+
610
+ return {
611
+ conversations: [
612
+ {
613
+ ...conversation,
614
+ last_message_at: message.created_at,
615
+ last_message_preview: previewOf(message),
616
+ },
617
+ ...state.conversations.filter((c) => c.id !== conversationId),
618
+ ],
619
+ };
620
+ });
621
+ }
622
+
623
+ private resetConversations(): void {
624
+ this.realtime?.stop();
625
+ this.realtime = null;
626
+ this.set({
627
+ session: null,
628
+ connected: false,
629
+ conversations: [],
630
+ messages: {},
631
+ typing: {},
632
+ aiReplies: {},
633
+ unread: {},
634
+ activeConversationId: null,
635
+ });
636
+ }
637
+
638
+ private async forgetToken(): Promise<void> {
639
+ this.api.visitorToken = null;
640
+ await this.storage.removeItem(this.storageKey).catch(() => undefined);
641
+ }
642
+
643
+ private set(patch: Partial<OpenDeskState> | ((state: OpenDeskState) => Partial<OpenDeskState>)): void {
644
+ const next = typeof patch === 'function' ? patch(this.state) : patch;
645
+ if (Object.keys(next).length === 0) {
646
+ return;
647
+ }
648
+ this.state = { ...this.state, ...next };
649
+ this.emitChange();
650
+ }
651
+
652
+ private emitChange(): void {
653
+ this.subscribers.forEach((listener) => listener());
654
+ }
655
+ }
656
+
657
+ function withoutKey<T>(record: Record<string, T>, key: string): Record<string, T> {
658
+ if (!(key in record)) {
659
+ return record;
660
+ }
661
+ const copy = { ...record };
662
+ delete copy[key];
663
+
664
+ return copy;
665
+ }
666
+
667
+ function pruneReplies(replies: Record<string, AIReply>, now: number): Record<string, AIReply> {
668
+ return Object.fromEntries(Object.entries(replies).filter(([, reply]) => now - reply.updatedAt < AI_REPLY_TTL_MS));
669
+ }
670
+
671
+ /** A list preview for a message; an attachment-only message shows its file names. */
672
+ function previewOf(message: Message): string {
673
+ const text = (message.body_text ?? '').trim();
674
+
675
+ return (text || (message.attachments ?? []).map((a) => a.filename).join(', ')).slice(0, 140);
676
+ }
677
+
678
+ function errorMessage(error: unknown): string {
679
+ return error instanceof Error ? error.message : 'Support is unavailable right now';
680
+ }