opendesk-react-native 0.1.2 → 0.1.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/README.md CHANGED
@@ -1,133 +1,133 @@
1
- # opendesk-react-native
2
-
3
- React Native SDK for [openDesk](https://opendesk.sh) live chat. It uses the same visitor API and realtime gateway as openDesk's web widget (`/widget/v1.js`), with no WebView:
4
-
5
- - `OpenDesk`: a headless client for the session, conversations, messages, attachments, AI streaming, handoff and unread counts
6
- - `OpenDeskProvider` and hooks: `useConversations`, `useConversation`, `useUnreadCount` and `useOpenDeskState`
7
- - `OpenDeskChat` and `OpenDeskLauncher`: drop-in UI that mirrors the web widget. It reads the brand colour, radius, theme, bot, team and copy from your widget's settings, and is built only from React Native core components.
8
-
9
- ## Install
10
-
11
- ```bash
12
- npm install opendesk-react-native expo-secure-store
13
- ```
14
-
15
- ## Set up
16
-
17
- ```tsx
18
- import * as SecureStore from 'expo-secure-store';
19
- import { OpenDesk, OpenDeskProvider, secureStoreStorage } from 'opendesk-react-native';
20
-
21
- export const support = new OpenDesk({
22
- baseUrl: 'https://desk.example.com', // your openDesk origin (https://app.opendesk.sh on cloud)
23
- widgetKey: 'wk_…', // Settings → Brands and widgets → Chat widgets
24
- storage: secureStoreStorage(SecureStore), // keeps the visitor's conversations across launches
25
- });
26
-
27
- export function App() {
28
- const identity = useSupportIdentity(); // see below
29
- return (
30
- <OpenDeskProvider client={support} identity={identity}>
31
- <Navigation />
32
- </OpenDeskProvider>
33
- );
34
- }
35
- ```
36
-
37
- ## Where the visitor token is kept
38
-
39
- The SDK stores one value: the visitor token (`od_vis_…`). It is a bearer credential, so anyone holding it can read that visitor's conversations and post as them. Keep it in the iOS Keychain or Android Keystore with one of the two adapters. Each adapter takes the native module as an argument, so the SDK itself depends on neither:
40
-
41
- | Your app uses | Install | Pass as `storage` |
42
- | --- | --- | --- |
43
- | Expo | `npx expo install expo-secure-store` | `secureStoreStorage(SecureStore)` (`import * as SecureStore from 'expo-secure-store'`) |
44
- | Bare React Native | `npm install react-native-keychain`, then `pod install` | `keychainStorage(Keychain)` (`import * as Keychain from 'react-native-keychain'`) |
45
-
46
- Both adapters store the token as *after first unlock, this device only*. It's readable in the background after the first unlock, and it's left out of iCloud and device-transfer backups.
47
-
48
- Leave `storage` out and the token lives in memory only, so every launch starts a new visitor. Any `{ getItem, setItem, removeItem }` object also works. AsyncStorage fits that shape, but it writes plain text, so don't use it for this.
49
-
50
- ## Identify the signed-in user
51
-
52
- To attach chats to a known contact, pass `{ user_id, user_hash, email, name }` as `identity`. `user_hash` is `hex(HMAC-SHA256(widget_secret, user_id))`, as described in [Identity verification](https://docs.opendesk.sh/guides/live-chat/identity-verification/). Compute it on your server, never in the app, and serve it from an authenticated endpoint of your own:
53
-
54
- ```tsx
55
- function useSupportIdentity() {
56
- const { user } = useAuth();
57
- const [identity, setIdentity] = useState<Identity | null>(null);
58
-
59
- useEffect(() => {
60
- if (!user) return setIdentity(null);
61
- // Your backend returns { user_id, user_hash, email, name } for the signed-in user.
62
- fetchMySupportIdentity().then(setIdentity).catch(() => {});
63
- }, [user?.id]);
64
-
65
- return identity;
66
- }
67
- ```
68
-
69
- When the user signs out, pass `null` (or call `support.shutdown()`) so the next person on the device never sees their conversations. If a different `user_id` signs in, the SDK drops the previous visitor by itself. Leave `identity` undefined for anonymous chat.
70
-
71
- ## Show the chat
72
-
73
- ```tsx
74
- import * as DocumentPicker from 'expo-document-picker';
75
- import { OpenDeskChat, OpenDeskLauncher } from 'opendesk-react-native';
76
-
77
- <OpenDeskChat
78
- onClose={() => navigation.goBack()}
79
- keyboardVerticalOffset={headerHeight}
80
- pickAttachment={async () => {
81
- const result = await DocumentPicker.getDocumentAsync({ type: ['image/*', 'application/pdf'] });
82
- const file = result.assets?.[0];
83
- return file ? { uri: file.uri, name: file.name, type: file.mimeType ?? 'application/octet-stream', size: file.size } : null;
84
- }}
85
- />
86
-
87
- <OpenDeskLauncher onPress={() => navigation.navigate('Support')} style={{ position: 'absolute', right: 20, bottom: 20 }} />
88
- ```
89
-
90
- `OpenDeskChat` also takes these props:
91
-
92
- - `theme`: `'light' | 'dark' | 'auto'`, which overrides the widget setting
93
- - `accentColor`
94
- - `strings`: replaces any of the built-in English copy (`defaultStrings` lists every key)
95
- - `initialConversationId`
96
-
97
- For an in-app banner while the chat is closed, use `support.onMessage(({ conversationId, from, message }) => …)`.
98
-
99
- ## Build your own UI
100
-
101
- ```tsx
102
- const { conversations } = useConversations();
103
- const { messages, aiReplies, typing } = useConversation(conversationId); // marks it read while mounted
104
- const client = useOpenDesk();
105
-
106
- await client.startConversation({ body, topic_id });
107
- await client.sendMessage(conversationId, { body, attachment_ids });
108
- const attachment = await client.upload({ uri, name, type }); // or a File/Blob on the web
109
- client.typing(conversationId);
110
- await client.requestHuman(conversationId); // after an AI answer
111
- await client.decideAction(conversationId, actionId, 'confirm');
112
- await client.rateMessage(conversationId, messageId, 'up');
113
- ```
114
-
115
- Render messages with `message.client_id ?? message.id` as the key. That keeps a bubble mounted when its optimistic copy or streamed AI answer is replaced by the stored message.
116
-
117
- ## Preview
118
-
119
- ```bash
120
- npm run example
121
- ```
122
-
123
- This runs `OpenDeskChat` in the browser on port 8774, through react-native-web, against a mock openDesk (`example/mockDesk.ts`). The mock covers AI streaming, tool checks, an AI action to confirm, agent typing, handoff, attachments and unread badges, and needs no server.
124
-
125
- ## Notes
126
-
127
- - Realtime uses Phoenix Channels (the `phoenix` package) on `<gateway_url>` and the topic `contact:<wid>:<contact_id>`. The 5-minute `ws_token` is refreshed before every reconnect, and missed messages are re-fetched over REST after a drop.
128
- - If **Allowed sites** is set on the widget, openDesk checks the `Origin` header. Native requests send none, so check that your app can still reach the API.
129
- - The widget API has no push notifications for replies sent while the app is closed. Use openDesk webhooks (`message.created`) to your backend for those.
130
-
131
- ## License
132
-
133
- MIT
1
+ # opendesk-react-native
2
+
3
+ React Native SDK for [openDesk](https://opendesk.sh) live chat. It uses the same visitor API and realtime gateway as openDesk's web widget (`/widget/v1.js`), with no WebView:
4
+
5
+ - `OpenDesk`: a headless client for the session, conversations, messages, attachments, AI streaming, handoff and unread counts
6
+ - `OpenDeskProvider` and hooks: `useConversations`, `useConversation`, `useUnreadCount` and `useOpenDeskState`
7
+ - `OpenDeskChat` and `OpenDeskLauncher`: drop-in UI that mirrors the web widget. It reads the brand colour, radius, theme, bot, team and copy from your widget's settings, and is built only from React Native core components.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install opendesk-react-native expo-secure-store
13
+ ```
14
+
15
+ ## Set up
16
+
17
+ ```tsx
18
+ import * as SecureStore from 'expo-secure-store';
19
+ import { OpenDesk, OpenDeskProvider, secureStoreStorage } from 'opendesk-react-native';
20
+
21
+ export const support = new OpenDesk({
22
+ baseUrl: 'https://desk.example.com', // your openDesk origin (https://app.opendesk.sh on cloud)
23
+ widgetKey: 'wk_…', // Settings → Brands and widgets → Chat widgets
24
+ storage: secureStoreStorage(SecureStore), // keeps the visitor's conversations across launches
25
+ });
26
+
27
+ export function App() {
28
+ const identity = useSupportIdentity(); // see below
29
+ return (
30
+ <OpenDeskProvider client={support} identity={identity}>
31
+ <Navigation />
32
+ </OpenDeskProvider>
33
+ );
34
+ }
35
+ ```
36
+
37
+ ## Where the visitor token is kept
38
+
39
+ The SDK stores one value: the visitor token (`od_vis_…`). It is a bearer credential, so anyone holding it can read that visitor's conversations and post as them. Keep it in the iOS Keychain or Android Keystore with one of the two adapters. Each adapter takes the native module as an argument, so the SDK itself depends on neither:
40
+
41
+ | Your app uses | Install | Pass as `storage` |
42
+ | --- | --- | --- |
43
+ | Expo | `npx expo install expo-secure-store` | `secureStoreStorage(SecureStore)` (`import * as SecureStore from 'expo-secure-store'`) |
44
+ | Bare React Native | `npm install react-native-keychain`, then `pod install` | `keychainStorage(Keychain)` (`import * as Keychain from 'react-native-keychain'`) |
45
+
46
+ Both adapters store the token as *after first unlock, this device only*. It's readable in the background after the first unlock, and it's left out of iCloud and device-transfer backups.
47
+
48
+ Leave `storage` out and the token lives in memory only, so every launch starts a new visitor. Any `{ getItem, setItem, removeItem }` object also works. AsyncStorage fits that shape, but it writes plain text, so don't use it for this.
49
+
50
+ ## Identify the signed-in user
51
+
52
+ To attach chats to a known contact, pass `{ user_id, user_hash, email, name }` as `identity`. `user_hash` is `hex(HMAC-SHA256(widget_secret, user_id))`, as described in [Identity verification](https://docs.opendesk.sh/guides/live-chat/identity-verification/). Compute it on your server, never in the app, and serve it from an authenticated endpoint of your own:
53
+
54
+ ```tsx
55
+ function useSupportIdentity() {
56
+ const { user } = useAuth();
57
+ const [identity, setIdentity] = useState<Identity | null>(null);
58
+
59
+ useEffect(() => {
60
+ if (!user) return setIdentity(null);
61
+ // Your backend returns { user_id, user_hash, email, name } for the signed-in user.
62
+ fetchMySupportIdentity().then(setIdentity).catch(() => {});
63
+ }, [user?.id]);
64
+
65
+ return identity;
66
+ }
67
+ ```
68
+
69
+ When the user signs out, pass `null` (or call `support.shutdown()`) so the next person on the device never sees their conversations. If a different `user_id` signs in, the SDK drops the previous visitor by itself. Leave `identity` undefined for anonymous chat.
70
+
71
+ ## Show the chat
72
+
73
+ ```tsx
74
+ import * as DocumentPicker from 'expo-document-picker';
75
+ import { OpenDeskChat, OpenDeskLauncher } from 'opendesk-react-native';
76
+
77
+ <OpenDeskChat
78
+ onClose={() => navigation.goBack()}
79
+ keyboardVerticalOffset={headerHeight}
80
+ pickAttachment={async () => {
81
+ const result = await DocumentPicker.getDocumentAsync({ type: ['image/*', 'application/pdf'] });
82
+ const file = result.assets?.[0];
83
+ return file ? { uri: file.uri, name: file.name, type: file.mimeType ?? 'application/octet-stream', size: file.size } : null;
84
+ }}
85
+ />
86
+
87
+ <OpenDeskLauncher onPress={() => navigation.navigate('Support')} style={{ position: 'absolute', right: 20, bottom: 20 }} />
88
+ ```
89
+
90
+ `OpenDeskChat` also takes these props:
91
+
92
+ - `theme`: `'light' | 'dark' | 'auto'`, which overrides the widget setting
93
+ - `accentColor`
94
+ - `strings`: replaces any of the built-in English copy (`defaultStrings` lists every key)
95
+ - `initialConversationId`
96
+
97
+ For an in-app banner while the chat is closed, use `support.onMessage(({ conversationId, from, message }) => …)`.
98
+
99
+ ## Build your own UI
100
+
101
+ ```tsx
102
+ const { conversations } = useConversations();
103
+ const { messages, aiReplies, typing } = useConversation(conversationId); // marks it read while mounted
104
+ const client = useOpenDesk();
105
+
106
+ await client.startConversation({ body, topic_id });
107
+ await client.sendMessage(conversationId, { body, attachment_ids });
108
+ const attachment = await client.upload({ uri, name, type }); // or a File/Blob on the web
109
+ client.typing(conversationId);
110
+ await client.requestHuman(conversationId); // after an AI answer
111
+ await client.decideAction(conversationId, actionId, 'confirm');
112
+ await client.rateMessage(conversationId, messageId, 'up');
113
+ ```
114
+
115
+ Render messages with `message.client_id ?? message.id` as the key. That keeps a bubble mounted when its optimistic copy or streamed AI answer is replaced by the stored message.
116
+
117
+ ## Preview
118
+
119
+ ```bash
120
+ npm run example
121
+ ```
122
+
123
+ This runs `OpenDeskChat` in the browser on port 8774, through react-native-web, against a mock openDesk (`example/mockDesk.ts`). The mock covers AI streaming, tool checks, an AI action to confirm, agent typing, handoff, attachments and unread badges, and needs no server.
124
+
125
+ ## Notes
126
+
127
+ - Realtime uses Phoenix Channels (the `phoenix` package, 1.8+) on `<gateway_url>` and the topic `contact:<wid>:<contact_id>`. The 5-minute `ws_token` goes in Phoenix's `authToken` (the `Sec-WebSocket-Protocol` header; the gateway refuses it in the URL), is refreshed before every reconnect, and missed messages are re-fetched over REST after a drop.
128
+ - If **Allowed sites** is set on the widget, openDesk checks the `Origin` header. Native requests send none, so check that your app can still reach the API.
129
+ - The widget API has no push notifications for replies sent while the app is closed. Use openDesk webhooks (`message.created`) to your backend for those.
130
+
131
+ ## License
132
+
133
+ MIT
package/lib/realtime.d.ts CHANGED
@@ -17,6 +17,11 @@ export interface RealtimeSocket {
17
17
  export type SocketFactory = (url: string, params: () => {
18
18
  token: string;
19
19
  }) => RealtimeSocket;
20
+ /**
21
+ * The gateway reads the token from Phoenix's `authToken` (sent in the `Sec-WebSocket-Protocol`
22
+ * header) and refuses a connect that carries it in the query string. Phoenix calls the function
23
+ * on every connect, so a refreshed token is used on the next attempt.
24
+ */
20
25
  export declare const phoenixSocketFactory: SocketFactory;
21
26
  export interface RealtimeHandlers {
22
27
  onEvent(event: RealtimeEvent): void;
package/lib/realtime.js CHANGED
@@ -3,7 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenDeskRealtime = exports.phoenixSocketFactory = void 0;
4
4
  exports.jwtClaim = jwtClaim;
5
5
  const phoenix_1 = require("phoenix");
6
- const phoenixSocketFactory = (url, params) => new phoenix_1.Socket(url, { params, reconnectAfterMs: (tries) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000 });
6
+ /**
7
+ * The gateway reads the token from Phoenix's `authToken` (sent in the `Sec-WebSocket-Protocol`
8
+ * header) and refuses a connect that carries it in the query string. Phoenix calls the function
9
+ * on every connect, so a refreshed token is used on the next attempt.
10
+ */
11
+ const phoenixSocketFactory = (url, params) => new phoenix_1.Socket(url, {
12
+ authToken: () => params().token,
13
+ reconnectAfterMs: (tries) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000,
14
+ });
7
15
  exports.phoenixSocketFactory = phoenixSocketFactory;
8
16
  const TYPING_THROTTLE_MS = 3000;
9
17
  /**
package/package.json CHANGED
@@ -1,45 +1,45 @@
1
- {
2
- "name": "opendesk-react-native",
3
- "version": "0.1.2",
4
- "description": "React Native SDK for openDesk live chat: headless client, realtime, hooks and a drop-in chat screen.",
5
- "main": "lib/index.js",
6
- "types": "lib/index.d.ts",
7
- "files": [
8
- "lib",
9
- "src",
10
- "!src/**/__tests__"
11
- ],
12
- "scripts": {
13
- "build": "tsc -p tsconfig.build.json",
14
- "typecheck": "tsc --noEmit",
15
- "test": "vitest run",
16
- "prepare": "npm run build",
17
- "example": "vite --config example/vite.config.ts"
18
- },
19
- "keywords": [
20
- "opendesk",
21
- "react-native",
22
- "live-chat",
23
- "support"
24
- ],
25
- "license": "MIT",
26
- "dependencies": {
27
- "phoenix": "^1.7.21"
28
- },
29
- "peerDependencies": {
30
- "react": ">=18",
31
- "react-native": ">=0.72"
32
- },
33
- "devDependencies": {
34
- "@types/phoenix": "^1.6.6",
35
- "@types/react": "^19.1.0",
36
- "@vitejs/plugin-react": "^5.2.0",
37
- "react": "^19.1.0",
38
- "react-dom": "^19.3.1",
39
- "react-native": "^0.81.0",
40
- "react-native-web": "^0.21.4",
41
- "typescript": "^5.9.2",
42
- "vite": "^7.3.7",
43
- "vitest": "^3.2.4"
44
- }
45
- }
1
+ {
2
+ "name": "opendesk-react-native",
3
+ "version": "0.1.3",
4
+ "description": "React Native SDK for openDesk live chat: headless client, realtime, hooks and a drop-in chat screen.",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "src",
10
+ "!src/**/__tests__"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "vitest run",
16
+ "prepare": "npm run build",
17
+ "example": "vite --config example/vite.config.ts"
18
+ },
19
+ "keywords": [
20
+ "opendesk",
21
+ "react-native",
22
+ "live-chat",
23
+ "support"
24
+ ],
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "phoenix": "^1.8.0"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18",
31
+ "react-native": ">=0.72"
32
+ },
33
+ "devDependencies": {
34
+ "@types/phoenix": "^1.6.6",
35
+ "@types/react": "^19.1.0",
36
+ "@vitejs/plugin-react": "^5.2.0",
37
+ "react": "^19.1.0",
38
+ "react-dom": "^19.3.1",
39
+ "react-native": "^0.81.0",
40
+ "react-native-web": "^0.21.4",
41
+ "typescript": "^5.9.2",
42
+ "vite": "^7.3.7",
43
+ "vitest": "^3.2.4"
44
+ }
45
+ }
package/src/realtime.ts CHANGED
@@ -1,166 +1,174 @@
1
- import { Socket } from 'phoenix';
2
- import type { LiveEvent, RealtimeEvent, TypingEvent } from './types';
3
-
4
- /** The parts of a Phoenix socket and channel the SDK uses, so tests can swap them. */
5
- export interface RealtimeChannel {
6
- on(event: string, callback: (payload: any) => void): unknown;
7
- join(): unknown;
8
- leave(): unknown;
9
- push(event: string, payload: object): unknown;
10
- }
11
-
12
- export interface RealtimeSocket {
13
- onOpen(callback: () => void): unknown;
14
- onClose(callback: () => void): unknown;
15
- onError(callback: () => void): unknown;
16
- connect(): void;
17
- disconnect(): void;
18
- channel(topic: string, params?: object): RealtimeChannel;
19
- }
20
-
21
- export type SocketFactory = (url: string, params: () => { token: string }) => RealtimeSocket;
22
-
23
- export const phoenixSocketFactory: SocketFactory = (url, params) =>
24
- new Socket(url, { params, reconnectAfterMs: (tries: number) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000 });
25
-
26
- export interface RealtimeHandlers {
27
- onEvent(event: RealtimeEvent): void;
28
- onLive(event: LiveEvent): void;
29
- onTyping(event: TypingEvent): void;
30
- onStatus(connected: boolean): void;
31
- /** Fired when the socket comes back after a drop, so missed events can be fetched over REST. */
32
- onReconnect(): void;
33
- }
34
-
35
- const TYPING_THROTTLE_MS = 3000;
36
-
37
- /**
38
- * Joins `contact:<wid>:<contact_id>` on the widget gateway. The `ws_token` lasts five minutes
39
- * and is checked only on connect, so every drop fetches a fresh one before the next attempt.
40
- */
41
- export class OpenDeskRealtime {
42
- private socket: RealtimeSocket | null = null;
43
- private channel: RealtimeChannel | null = null;
44
- private refreshing = false;
45
- private lastTypingAt = 0;
46
-
47
- constructor(
48
- private readonly factory: SocketFactory,
49
- private readonly gatewayUrl: string,
50
- private token: string,
51
- private readonly contactId: string,
52
- private readonly refreshToken: () => Promise<string>,
53
- private readonly handlers: RealtimeHandlers,
54
- ) {}
55
-
56
- start(): void {
57
- const workspaceId = jwtClaim(this.token, 'wid');
58
- if (!workspaceId || !this.gatewayUrl) {
59
- return;
60
- }
61
-
62
- const socket = this.factory(this.gatewayUrl, () => ({ token: this.token }));
63
- let dropped = false;
64
-
65
- socket.onOpen(() => {
66
- this.handlers.onStatus(true);
67
- if (dropped) {
68
- this.handlers.onReconnect();
69
- }
70
- dropped = false;
71
- });
72
-
73
- const onDrop = (): void => {
74
- dropped = true;
75
- this.handlers.onStatus(false);
76
- if (this.refreshing) {
77
- return;
78
- }
79
- this.refreshing = true;
80
- this.refreshToken()
81
- .then((token) => {
82
- this.token = token;
83
- })
84
- .catch(() => undefined)
85
- .finally(() => {
86
- this.refreshing = false;
87
- });
88
- };
89
- socket.onError(onDrop);
90
- socket.onClose(onDrop);
91
- socket.connect();
92
-
93
- const channel = socket.channel(`contact:${workspaceId}:${this.contactId}`, {});
94
- channel.on('event', (payload: RealtimeEvent) => this.handlers.onEvent(payload));
95
- channel.on('typing', (payload: TypingEvent) => this.handlers.onTyping(payload));
96
- channel.on('live', (payload: LiveEvent) => {
97
- if (payload && typeof payload.type === 'string' && payload.data) {
98
- this.handlers.onLive(payload);
99
- }
100
- });
101
- channel.join();
102
-
103
- this.socket = socket;
104
- this.channel = channel;
105
- }
106
-
107
- /** Tells agents the visitor is typing in a conversation; throttled to one push per 3s. */
108
- typing(conversationId: string): void {
109
- const now = Date.now();
110
- if (!this.channel || now - this.lastTypingAt < TYPING_THROTTLE_MS) {
111
- return;
112
- }
113
- this.lastTypingAt = now;
114
- this.channel.push('typing', { ticket_id: conversationId });
115
- }
116
-
117
- typingDone(): void {
118
- this.lastTypingAt = 0;
119
- }
120
-
121
- stop(): void {
122
- this.channel?.leave();
123
- this.socket?.disconnect();
124
- this.channel = null;
125
- this.socket = null;
126
- }
127
- }
128
-
129
- export function jwtClaim(token: string, claim: string): string | null {
130
- try {
131
- const segment = token.split('.')[1];
132
- if (!segment) {
133
- return null;
134
- }
135
- const value = JSON.parse(decodeBase64Url(segment))[claim];
136
-
137
- return typeof value === 'string' ? value : null;
138
- } catch {
139
- return null;
140
- }
141
- }
142
-
143
- const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
144
-
145
- /** Decodes base64url to a UTF-8 string without relying on `atob`, which older Hermes lacks. */
146
- function decodeBase64Url(input: string): string {
147
- const clean = input.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
148
- const bytes: number[] = [];
149
- let buffer = 0;
150
- let bits = 0;
151
-
152
- for (const char of clean) {
153
- const index = BASE64.indexOf(char);
154
- if (index < 0) {
155
- throw new Error('Invalid base64');
156
- }
157
- buffer = (buffer << 6) | index;
158
- bits += 6;
159
- if (bits >= 8) {
160
- bits -= 8;
161
- bytes.push((buffer >> bits) & 0xff);
162
- }
163
- }
164
-
165
- return decodeURIComponent(bytes.map((byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
166
- }
1
+ import { Socket } from 'phoenix';
2
+ import type { LiveEvent, RealtimeEvent, TypingEvent } from './types';
3
+
4
+ /** The parts of a Phoenix socket and channel the SDK uses, so tests can swap them. */
5
+ export interface RealtimeChannel {
6
+ on(event: string, callback: (payload: any) => void): unknown;
7
+ join(): unknown;
8
+ leave(): unknown;
9
+ push(event: string, payload: object): unknown;
10
+ }
11
+
12
+ export interface RealtimeSocket {
13
+ onOpen(callback: () => void): unknown;
14
+ onClose(callback: () => void): unknown;
15
+ onError(callback: () => void): unknown;
16
+ connect(): void;
17
+ disconnect(): void;
18
+ channel(topic: string, params?: object): RealtimeChannel;
19
+ }
20
+
21
+ export type SocketFactory = (url: string, params: () => { token: string }) => RealtimeSocket;
22
+
23
+ /**
24
+ * The gateway reads the token from Phoenix's `authToken` (sent in the `Sec-WebSocket-Protocol`
25
+ * header) and refuses a connect that carries it in the query string. Phoenix calls the function
26
+ * on every connect, so a refreshed token is used on the next attempt.
27
+ */
28
+ export const phoenixSocketFactory: SocketFactory = (url, params) =>
29
+ new Socket(url, {
30
+ authToken: () => params().token,
31
+ reconnectAfterMs: (tries: number) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000,
32
+ });
33
+
34
+ export interface RealtimeHandlers {
35
+ onEvent(event: RealtimeEvent): void;
36
+ onLive(event: LiveEvent): void;
37
+ onTyping(event: TypingEvent): void;
38
+ onStatus(connected: boolean): void;
39
+ /** Fired when the socket comes back after a drop, so missed events can be fetched over REST. */
40
+ onReconnect(): void;
41
+ }
42
+
43
+ const TYPING_THROTTLE_MS = 3000;
44
+
45
+ /**
46
+ * Joins `contact:<wid>:<contact_id>` on the widget gateway. The `ws_token` lasts five minutes
47
+ * and is checked only on connect, so every drop fetches a fresh one before the next attempt.
48
+ */
49
+ export class OpenDeskRealtime {
50
+ private socket: RealtimeSocket | null = null;
51
+ private channel: RealtimeChannel | null = null;
52
+ private refreshing = false;
53
+ private lastTypingAt = 0;
54
+
55
+ constructor(
56
+ private readonly factory: SocketFactory,
57
+ private readonly gatewayUrl: string,
58
+ private token: string,
59
+ private readonly contactId: string,
60
+ private readonly refreshToken: () => Promise<string>,
61
+ private readonly handlers: RealtimeHandlers,
62
+ ) {}
63
+
64
+ start(): void {
65
+ const workspaceId = jwtClaim(this.token, 'wid');
66
+ if (!workspaceId || !this.gatewayUrl) {
67
+ return;
68
+ }
69
+
70
+ const socket = this.factory(this.gatewayUrl, () => ({ token: this.token }));
71
+ let dropped = false;
72
+
73
+ socket.onOpen(() => {
74
+ this.handlers.onStatus(true);
75
+ if (dropped) {
76
+ this.handlers.onReconnect();
77
+ }
78
+ dropped = false;
79
+ });
80
+
81
+ const onDrop = (): void => {
82
+ dropped = true;
83
+ this.handlers.onStatus(false);
84
+ if (this.refreshing) {
85
+ return;
86
+ }
87
+ this.refreshing = true;
88
+ this.refreshToken()
89
+ .then((token) => {
90
+ this.token = token;
91
+ })
92
+ .catch(() => undefined)
93
+ .finally(() => {
94
+ this.refreshing = false;
95
+ });
96
+ };
97
+ socket.onError(onDrop);
98
+ socket.onClose(onDrop);
99
+ socket.connect();
100
+
101
+ const channel = socket.channel(`contact:${workspaceId}:${this.contactId}`, {});
102
+ channel.on('event', (payload: RealtimeEvent) => this.handlers.onEvent(payload));
103
+ channel.on('typing', (payload: TypingEvent) => this.handlers.onTyping(payload));
104
+ channel.on('live', (payload: LiveEvent) => {
105
+ if (payload && typeof payload.type === 'string' && payload.data) {
106
+ this.handlers.onLive(payload);
107
+ }
108
+ });
109
+ channel.join();
110
+
111
+ this.socket = socket;
112
+ this.channel = channel;
113
+ }
114
+
115
+ /** Tells agents the visitor is typing in a conversation; throttled to one push per 3s. */
116
+ typing(conversationId: string): void {
117
+ const now = Date.now();
118
+ if (!this.channel || now - this.lastTypingAt < TYPING_THROTTLE_MS) {
119
+ return;
120
+ }
121
+ this.lastTypingAt = now;
122
+ this.channel.push('typing', { ticket_id: conversationId });
123
+ }
124
+
125
+ typingDone(): void {
126
+ this.lastTypingAt = 0;
127
+ }
128
+
129
+ stop(): void {
130
+ this.channel?.leave();
131
+ this.socket?.disconnect();
132
+ this.channel = null;
133
+ this.socket = null;
134
+ }
135
+ }
136
+
137
+ export function jwtClaim(token: string, claim: string): string | null {
138
+ try {
139
+ const segment = token.split('.')[1];
140
+ if (!segment) {
141
+ return null;
142
+ }
143
+ const value = JSON.parse(decodeBase64Url(segment))[claim];
144
+
145
+ return typeof value === 'string' ? value : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
152
+
153
+ /** Decodes base64url to a UTF-8 string without relying on `atob`, which older Hermes lacks. */
154
+ function decodeBase64Url(input: string): string {
155
+ const clean = input.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
156
+ const bytes: number[] = [];
157
+ let buffer = 0;
158
+ let bits = 0;
159
+
160
+ for (const char of clean) {
161
+ const index = BASE64.indexOf(char);
162
+ if (index < 0) {
163
+ throw new Error('Invalid base64');
164
+ }
165
+ buffer = (buffer << 6) | index;
166
+ bits += 6;
167
+ if (bits >= 8) {
168
+ bits -= 8;
169
+ bytes.push((buffer >> bits) & 0xff);
170
+ }
171
+ }
172
+
173
+ return decodeURIComponent(bytes.map((byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
174
+ }