@paramms/chat-widget 0.1.0
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 +23 -0
- package/build-preview.js +136 -0
- package/index.html +37 -0
- package/package.json +44 -0
- package/src/__tests__/chatlist.test.ts +133 -0
- package/src/__tests__/connection.test.ts +163 -0
- package/src/__tests__/crypto.test.ts +28 -0
- package/src/__tests__/history.test.ts +91 -0
- package/src/__tests__/ime.test.ts +93 -0
- package/src/__tests__/render.test.ts +58 -0
- package/src/__tests__/render_new.test.ts +441 -0
- package/src/__tests__/store.test.ts +86 -0
- package/src/__tests__/x3dh.test.ts +204 -0
- package/src/connection.ts +133 -0
- package/src/crypto.ts +252 -0
- package/src/e2e.ts +161 -0
- package/src/history.ts +43 -0
- package/src/index.ts +380 -0
- package/src/outbox.ts +58 -0
- package/src/protocol/actions.ts +114 -0
- package/src/protocol/codec.ts +35 -0
- package/src/protocol/entities.ts +104 -0
- package/src/protocol/frames.ts +86 -0
- package/src/protocol/ids.ts +27 -0
- package/src/protocol/index.ts +5 -0
- package/src/react.tsx +37 -0
- package/src/renderer.ts +906 -0
- package/src/store.ts +207 -0
- package/tsconfig.json +33 -0
- package/vercel.json +22 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +2 -0
package/src/e2e.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { MessageContent, UserId } from './protocol/index.js'
|
|
2
|
+
import type { ServerFrame } from './protocol/index.js'
|
|
3
|
+
import {
|
|
4
|
+
type KeyPair, loadOrCreateKeyPair, exportPublicKey, deriveSharedKey, encrypt, decrypt,
|
|
5
|
+
generateKeyPair, signPrekey, x3dhSend, x3dhReceive, type X3DHBundle,
|
|
6
|
+
loadOrCreateIdentityKeyPair, type IdentityKeyPair,
|
|
7
|
+
} from './crypto.js'
|
|
8
|
+
|
|
9
|
+
// Number of one-time prekeys to generate per upload batch.
|
|
10
|
+
const OTP_BATCH_SIZE = 20
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-user E2E session. Supports two modes:
|
|
14
|
+
*
|
|
15
|
+
* LIVE (original): Both parties are online. ECDH P-256 key exchange via the
|
|
16
|
+
* `pubkey`/`peerkey` frames. Instant but requires both parties to be connected.
|
|
17
|
+
*
|
|
18
|
+
* ASYNC (X3DH): The sender encrypts to the recipient's prekey bundle while the
|
|
19
|
+
* recipient is offline. Uses X3DH (Extended Triple DH) with identity keys,
|
|
20
|
+
* signed prekeys, and one-time prekeys. The recipient derives the same shared
|
|
21
|
+
* key from the init message when they come online.
|
|
22
|
+
*
|
|
23
|
+
* Both modes produce an AES-GCM 256 shared key for message encryption.
|
|
24
|
+
*/
|
|
25
|
+
export class E2ESession {
|
|
26
|
+
// Live ECDH mode state
|
|
27
|
+
private kp?: KeyPair
|
|
28
|
+
private shared?: CryptoKey
|
|
29
|
+
|
|
30
|
+
// X3DH async mode state
|
|
31
|
+
private identityKP: IdentityKeyPair | undefined = undefined
|
|
32
|
+
private signedPreKP: KeyPair | undefined = undefined
|
|
33
|
+
private signedPrekeyId: string | undefined = undefined
|
|
34
|
+
private readonly otpKeys: KeyPair[] = [] // one-time prekeys awaiting matching
|
|
35
|
+
private x3dhShared?: CryptoKey
|
|
36
|
+
// Queued init messages arriving before we could derive (shouldn't happen, but safe)
|
|
37
|
+
private pendingX3DH: { senderIK: string; ephemeralKey: string; spkId: string } | undefined = undefined
|
|
38
|
+
|
|
39
|
+
constructor(private readonly storageKey: string) {}
|
|
40
|
+
|
|
41
|
+
get ready(): boolean { return !!(this.shared ?? this.x3dhShared) }
|
|
42
|
+
|
|
43
|
+
/** Live ECDH mode: Generate/restore our keypair and return our public key to publish. */
|
|
44
|
+
async begin(): Promise<string> {
|
|
45
|
+
this.kp = await loadOrCreateKeyPair(this.storageKey)
|
|
46
|
+
return exportPublicKey(this.kp.publicKey)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Live ECDH mode: A peer published their key — derive the shared secret. */
|
|
50
|
+
async onPeerKey(peerKeyB64: string): Promise<void> {
|
|
51
|
+
if (!this.kp) return
|
|
52
|
+
this.shared = await deriveSharedKey(this.kp.privateKey, peerKeyB64)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── X3DH async mode ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** X3DH: Generate identity key, signed prekey, and OTP prekeys.
|
|
58
|
+
* Returns the upload frame payload the caller should send to the server. */
|
|
59
|
+
async initX3DH(): Promise<{
|
|
60
|
+
identityKey: string; signedPrekey: string; signedPrekeyId: string;
|
|
61
|
+
signature: string; oneTimePrekeys: string[]
|
|
62
|
+
}> {
|
|
63
|
+
// Restore or generate persistent identity keypair (ECDH + ECDSA).
|
|
64
|
+
this.identityKP = await loadOrCreateIdentityKeyPair(this.storageKey)
|
|
65
|
+
// Always generate a fresh signed prekey (rotation).
|
|
66
|
+
this.signedPreKP = await generateKeyPair()
|
|
67
|
+
this.signedPrekeyId = `spk-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
68
|
+
// Batch of one-time prekeys.
|
|
69
|
+
for (let i = 0; i < OTP_BATCH_SIZE; i++) this.otpKeys.push(await generateKeyPair())
|
|
70
|
+
|
|
71
|
+
const signedPrekeyPub = await exportPublicKey(this.signedPreKP.publicKey)
|
|
72
|
+
const signature = await signPrekey(this.identityKP.ecdsaKP.privateKey, this.signedPreKP.publicKey)
|
|
73
|
+
const oneTimePrekeys = await Promise.all(this.otpKeys.map(kp => exportPublicKey(kp.publicKey)))
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
identityKey: this.identityKP.publicKeyB64,
|
|
77
|
+
signedPrekey: signedPrekeyPub,
|
|
78
|
+
signedPrekeyId: this.signedPrekeyId,
|
|
79
|
+
signature,
|
|
80
|
+
oneTimePrekeys,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** X3DH sender: given a recipient's prekey bundle, derive the shared key and
|
|
85
|
+
* return the init message fields to embed in the first encrypted message. */
|
|
86
|
+
async x3dhSendTo(bundle: X3DHBundle): Promise<{ ephemeralKey: string; spkId: string; usedOTP: boolean; senderIK: string }> {
|
|
87
|
+
if (!this.identityKP) this.identityKP = await loadOrCreateIdentityKeyPair(this.storageKey)
|
|
88
|
+
const { sharedKey, ephemeralPublicKey } = await x3dhSend(this.identityKP.ecdhKP, bundle)
|
|
89
|
+
this.x3dhShared = sharedKey
|
|
90
|
+
return { ephemeralKey: ephemeralPublicKey, spkId: bundle.signedPrekeyId, usedOTP: !!bundle.oneTimePrekey, senderIK: this.identityKP.publicKeyB64 }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** X3DH recipient: given an init message's sender IK + EK + SPK ID, derive the shared key. */
|
|
94
|
+
async x3dhReceiveFrom(senderIKb64: string, ephemeralKeyB64: string, spkId: string): Promise<void> {
|
|
95
|
+
if (!this.identityKP || !this.signedPreKP) {
|
|
96
|
+
// Keys not yet initialised — queue for when initX3DH completes.
|
|
97
|
+
this.pendingX3DH = { senderIK: senderIKb64, ephemeralKey: ephemeralKeyB64, spkId }
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
// Find OTP key if used (we pop the first available)
|
|
101
|
+
const otp = this.otpKeys.shift()
|
|
102
|
+
this.x3dhShared = await x3dhReceive(this.identityKP.ecdhKP, this.signedPreKP, senderIKb64, ephemeralKeyB64, otp)
|
|
103
|
+
void spkId // we matched by position; full impl would look up by ID
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Flush pending X3DH derivation after initX3DH() completes. */
|
|
107
|
+
async flushPendingX3DH(): Promise<void> {
|
|
108
|
+
if (!this.pendingX3DH) return
|
|
109
|
+
const { senderIK, ephemeralKey, spkId } = this.pendingX3DH
|
|
110
|
+
this.pendingX3DH = undefined
|
|
111
|
+
await this.x3dhReceiveFrom(senderIK, ephemeralKey, spkId)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Encrypt outgoing text into a wire content object. For X3DH init messages,
|
|
115
|
+
* the caller should pass x3dhInit fields to embed in the content. */
|
|
116
|
+
async sealText(text: string, x3dhInit?: { ephemeralKey: string; spkId: string; senderIK: string }): Promise<MessageContent> {
|
|
117
|
+
const key = this.x3dhShared ?? this.shared
|
|
118
|
+
if (!key) throw new Error('secure channel not ready')
|
|
119
|
+
const { ct, iv } = await encrypt(key, text)
|
|
120
|
+
return {
|
|
121
|
+
kind: 'text', text: ct, enc: true, iv,
|
|
122
|
+
...(x3dhInit ? { x3dhEK: x3dhInit.ephemeralKey, x3dhSPK: x3dhInit.spkId, x3dhIK: x3dhInit.senderIK } as never : {}),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Decrypt one content object if it is encrypted (otherwise pass through). */
|
|
127
|
+
private async openContent(content: MessageContent): Promise<MessageContent> {
|
|
128
|
+
if (content.kind !== 'text' || !content.enc || !content.iv) return content
|
|
129
|
+
const key = this.x3dhShared ?? this.shared
|
|
130
|
+
if (!key) return { kind: 'text', text: '🔒 encrypted' }
|
|
131
|
+
try { return { kind: 'text', text: await decrypt(key, content.text, content.iv) } }
|
|
132
|
+
catch { return { kind: 'text', text: '🔒 unable to decrypt' } }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Decrypt any encrypted message content carried by an incoming frame, in place. */
|
|
136
|
+
async openFrame(frame: ServerFrame): Promise<void> {
|
|
137
|
+
if (frame.type === 'message') frame.message.content = await this.openContent(frame.message.content)
|
|
138
|
+
else if (frame.type === 'sync') {
|
|
139
|
+
for (const m of frame.messages) m.content = await this.openContent(m.content)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** X3DH init fields embedded in a text MessageContent (as extra properties).
|
|
145
|
+
* Present only on the very first message from a sender to an offline peer. */
|
|
146
|
+
export interface X3DHInitFields {
|
|
147
|
+
x3dhEK: string // sender's ephemeral public key (base64)
|
|
148
|
+
x3dhSPK: string // recipient's signed prekey ID used
|
|
149
|
+
x3dhIK: string // sender's identity public key (base64)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function extractX3DHInit(content: MessageContent): X3DHInitFields | null {
|
|
153
|
+
if (content.kind !== 'text' || !content.enc) return null
|
|
154
|
+
const c = content as MessageContent & Partial<X3DHInitFields>
|
|
155
|
+
if (!c.x3dhEK || !c.x3dhSPK || !c.x3dhIK) return null
|
|
156
|
+
return { x3dhEK: c.x3dhEK, x3dhSPK: c.x3dhSPK, x3dhIK: c.x3dhIK }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { type X3DHBundle } from './crypto.js'
|
|
160
|
+
export { type UserId }
|
|
161
|
+
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// history.ts — shared REST history-fetch logic, used by both the guest widget
|
|
2
|
+
// (index.ts) and the agent dashboard (chat-dashboard/src/operate.ts) so there
|
|
3
|
+
// is exactly one implementation of "restore chat history on open" rather than
|
|
4
|
+
// two copies that can silently drift apart.
|
|
5
|
+
import type { ChatStore } from './store.js'
|
|
6
|
+
import type { Message, ConversationId } from './protocol/index.js'
|
|
7
|
+
import type { Renderer } from './renderer.js'
|
|
8
|
+
|
|
9
|
+
/** Derive the HTTP(S) base origin from a ws(s):// URL that may or may not
|
|
10
|
+
* already end in /ws. Examples:
|
|
11
|
+
* wss://api.paramms.com/ws → https://api.paramms.com
|
|
12
|
+
* ws://localhost:3000/ws → http://localhost:3000
|
|
13
|
+
* wss://api.paramms.com → https://api.paramms.com (no /ws suffix) */
|
|
14
|
+
export function httpBaseFromWsUrl(wsUrl: string): string {
|
|
15
|
+
return wsUrl.replace(/^ws/, 'http').replace(/\/ws\/?$/, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Fetch full message history for a conversation via REST and apply it to
|
|
19
|
+
* the store, then trigger a render. Best-effort: network/auth failures are
|
|
20
|
+
* swallowed since the live WS sync still covers reconnects — this is a
|
|
21
|
+
* belt-and-suspenders restore for "did my history come back after reload",
|
|
22
|
+
* not the only path messages can arrive through. */
|
|
23
|
+
export async function restoreHistory(
|
|
24
|
+
wsUrl: string,
|
|
25
|
+
token: string,
|
|
26
|
+
conversationId: ConversationId,
|
|
27
|
+
store: ChatStore,
|
|
28
|
+
renderer: Renderer,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const httpBase = httpBaseFromWsUrl(wsUrl)
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${httpBase}/conversations/${conversationId}/history`, {
|
|
33
|
+
headers: { authorization: `Bearer ${token}` },
|
|
34
|
+
})
|
|
35
|
+
if (!res.ok) return
|
|
36
|
+
const data = await res.json() as { messages?: Message[] }
|
|
37
|
+
if (!data.messages?.length) return
|
|
38
|
+
store.apply({ type: 'sync', conversationId, messages: data.messages })
|
|
39
|
+
renderer.render(store)
|
|
40
|
+
} catch {
|
|
41
|
+
// best-effort — WS sync still covers reconnects
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asConversationId,
|
|
3
|
+
type ClientFrame, type ConversationId,
|
|
4
|
+
} from './protocol/index.js'
|
|
5
|
+
import { ChatStore } from './store.js'
|
|
6
|
+
import { ConnectionManager } from './connection.js'
|
|
7
|
+
import { PersistentOutbox } from './outbox.js'
|
|
8
|
+
import { E2ESession, extractX3DHInit } from './e2e.js'
|
|
9
|
+
import { Renderer, type WidgetConfig, type ChatListEntry } from './renderer.js'
|
|
10
|
+
import { restoreHistory, httpBaseFromWsUrl } from './history.js'
|
|
11
|
+
|
|
12
|
+
export interface MountOptions {
|
|
13
|
+
el: HTMLElement
|
|
14
|
+
url: string
|
|
15
|
+
profileId: string
|
|
16
|
+
subjectId?: string
|
|
17
|
+
token?: string
|
|
18
|
+
subject?: WidgetConfig['subject']
|
|
19
|
+
quickReplies?: string[]
|
|
20
|
+
accent?: string
|
|
21
|
+
/** If set, shows a 🌐 translate button on incoming messages that translates
|
|
22
|
+
* them into this language (ISO code or language name) via the server's
|
|
23
|
+
* /translate endpoint. Omit to disable the feature. */
|
|
24
|
+
translateLang?: string
|
|
25
|
+
/** If true, mount as a floating launcher button that opens/closes the chat */
|
|
26
|
+
launcher?: boolean
|
|
27
|
+
/** Position of the launcher button: default 'bottom-right' */
|
|
28
|
+
position?: 'bottom-right' | 'bottom-left'
|
|
29
|
+
/** If true, shows a 💬 button in the header that opens a list of the
|
|
30
|
+
* guest's other conversations (e.g. a marketplace where each item gets
|
|
31
|
+
* its own subjectId/thread) and lets them switch between them.
|
|
32
|
+
* Off by default — single-conversation embeds don't need this. */
|
|
33
|
+
showChatList?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WidgetHandle { close(): void }
|
|
37
|
+
|
|
38
|
+
/** Persistent anonymous identity, reused across reloads (per the old widget's
|
|
39
|
+
* oc_uid pattern). */
|
|
40
|
+
function persistentUid(): string {
|
|
41
|
+
const key = 'oc_uid'
|
|
42
|
+
try {
|
|
43
|
+
const existing = localStorage.getItem(key)
|
|
44
|
+
if (existing) return existing
|
|
45
|
+
const id = `g_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
|
|
46
|
+
localStorage.setItem(key, id)
|
|
47
|
+
return id
|
|
48
|
+
} catch {
|
|
49
|
+
return `g_${Math.random().toString(36).slice(2)}`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mount(opts: MountOptions): WidgetHandle {
|
|
54
|
+
const token = opts.token ?? persistentUid()
|
|
55
|
+
const store = new ChatStore(token as never)
|
|
56
|
+
const outbox = new PersistentOutbox(token)
|
|
57
|
+
let cid: ConversationId | undefined
|
|
58
|
+
let outboxRestored = false
|
|
59
|
+
// Self-reference so onSwitchChat can close this mount and replace it with
|
|
60
|
+
// a fresh one for the newly-selected conversation, without the embedder
|
|
61
|
+
// needing to manage that lifecycle themselves.
|
|
62
|
+
let currentHandle: WidgetHandle | null = null
|
|
63
|
+
|
|
64
|
+
// Give the host element a sane default size if the embedder didn't set one
|
|
65
|
+
// (e.g. a bare `<div id="chat"></div>` with no CSS). Without this the
|
|
66
|
+
// widget's internal `height:100%` collapses to near-zero. Only applies
|
|
67
|
+
// when the element truly has no height set — explicit CSS always wins.
|
|
68
|
+
if (!opts.launcher && !opts.el.style.height && opts.el.clientHeight === 0) {
|
|
69
|
+
opts.el.style.width = opts.el.style.width || '100%'
|
|
70
|
+
opts.el.style.height = '600px'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Restore any messages that were queued but not yet acknowledged before the
|
|
74
|
+
// last reload — show them as 'pending' immediately, matching the WhatsApp
|
|
75
|
+
// pattern of never silently dropping a typed message.
|
|
76
|
+
for (const item of outbox.load()) store.addOptimistic(item.clientMsgId, item.content)
|
|
77
|
+
|
|
78
|
+
// ── Launcher mode ─────────────────────────────────────────────────────────
|
|
79
|
+
let launcherEl: HTMLElement | null = null
|
|
80
|
+
let badgeEl: HTMLElement | null = null
|
|
81
|
+
let unread = 0
|
|
82
|
+
let open = !opts.launcher // start open when not in launcher mode
|
|
83
|
+
|
|
84
|
+
if (opts.launcher) {
|
|
85
|
+
const pos = opts.position ?? 'bottom-right'
|
|
86
|
+
launcherEl = document.createElement('div')
|
|
87
|
+
launcherEl.style.cssText = `position:fixed;${pos.includes('right') ? 'right:20px' : 'left:20px'};bottom:20px;z-index:9999`
|
|
88
|
+
const btn = document.createElement('button')
|
|
89
|
+
btn.style.cssText = `width:56px;height:56px;border-radius:50%;background:${opts.accent ?? '#4F63F5'};color:#fff;border:none;font-size:24px;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.2);position:relative`
|
|
90
|
+
btn.textContent = '💬'
|
|
91
|
+
badgeEl = document.createElement('span')
|
|
92
|
+
badgeEl.style.cssText = `position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;border-radius:50%;width:20px;height:20px;font-size:11px;font-weight:700;display:none;align-items:center;justify-content:center`
|
|
93
|
+
btn.append(badgeEl)
|
|
94
|
+
launcherEl.append(btn)
|
|
95
|
+
document.body.append(launcherEl)
|
|
96
|
+
opts.el.style.display = 'none'
|
|
97
|
+
btn.addEventListener('click', () => {
|
|
98
|
+
open = !open
|
|
99
|
+
opts.el.style.display = open ? 'block' : 'none'
|
|
100
|
+
btn.textContent = open ? '✕' : '💬'
|
|
101
|
+
if (open) { unread = 0; if (badgeEl) badgeEl.style.display = 'none' }
|
|
102
|
+
btn.append(badgeEl!)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const addUnread = () => {
|
|
107
|
+
if (open) return
|
|
108
|
+
unread++
|
|
109
|
+
if (badgeEl) { badgeEl.textContent = String(unread); badgeEl.style.display = 'flex' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Notification sound ────────────────────────────────────────────────────
|
|
113
|
+
const playSound = () => {
|
|
114
|
+
try {
|
|
115
|
+
const ctx = new AudioContext()
|
|
116
|
+
const osc = ctx.createOscillator(); const gain = ctx.createGain()
|
|
117
|
+
osc.connect(gain); gain.connect(ctx.destination)
|
|
118
|
+
osc.frequency.setValueAtTime(880, ctx.currentTime)
|
|
119
|
+
osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.15)
|
|
120
|
+
gain.gain.setValueAtTime(0.3, ctx.currentTime)
|
|
121
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
|
|
122
|
+
osc.start(); osc.stop(ctx.currentTime + 0.3)
|
|
123
|
+
} catch { /* audio not available */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let conn: ConnectionManager
|
|
127
|
+
const e2e = new E2ESession(`ocw-e2e-${opts.profileId}`)
|
|
128
|
+
let e2eStarted = false
|
|
129
|
+
// Live ECDH pending (peer not yet online)
|
|
130
|
+
const pending: { clientMsgId: string; text: string }[] = []
|
|
131
|
+
// X3DH async: pending send awaiting the peer's prekey bundle
|
|
132
|
+
const x3dhPending: { clientMsgId: string; text: string }[] = []
|
|
133
|
+
let x3dhBundleFetched = false
|
|
134
|
+
|
|
135
|
+
const sendSealed = (clientMsgId: string, text: string): void => {
|
|
136
|
+
void e2e.sealText(text).then((content) => {
|
|
137
|
+
if (cid) conn.send({ type: 'send', conversationId: cid, clientMsgId, content })
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const sendSealedX3DH = (clientMsgId: string, text: string, x3dhInit: { ephemeralKey: string; spkId: string; senderIK: string }): void => {
|
|
142
|
+
void e2e.sealText(text, x3dhInit).then((content) => {
|
|
143
|
+
if (cid) conn.send({ type: 'send', conversationId: cid, clientMsgId, content })
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const flushPending = (): void => {
|
|
148
|
+
while (pending.length) { const p = pending.shift()!; sendSealed(p.clientMsgId, p.text) }
|
|
149
|
+
while (x3dhPending.length) { const p = x3dhPending.shift()!; sendSealed(p.clientMsgId, p.text) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Fetch the peer's prekey bundle and perform X3DH sender init. */
|
|
153
|
+
const fetchAndX3DH = (targetUserId: string): void => {
|
|
154
|
+
conn.send({ type: 'fetchPrekey', targetUserId: targetUserId as never })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const renderer = new Renderer(opts.el, token, {
|
|
158
|
+
onSend(text) {
|
|
159
|
+
if (!cid) return
|
|
160
|
+
const clientMsgId = `cm_${Math.random().toString(36).slice(2)}`
|
|
161
|
+
store.addOptimistic(clientMsgId, { kind: 'text', text })
|
|
162
|
+
renderer.render(store)
|
|
163
|
+
if (store.e2e) {
|
|
164
|
+
if (e2e.ready) {
|
|
165
|
+
sendSealed(clientMsgId, text)
|
|
166
|
+
} else if (x3dhBundleFetched) {
|
|
167
|
+
x3dhPending.push({ clientMsgId, text })
|
|
168
|
+
} else {
|
|
169
|
+
pending.push({ clientMsgId, text })
|
|
170
|
+
if (store.assignedAgentId) fetchAndX3DH(store.assignedAgentId)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
outbox.add({ clientMsgId, content: { kind: 'text', text }, ts: Date.now() })
|
|
174
|
+
conn.send({ type: 'send', conversationId: cid, clientMsgId, content: { kind: 'text', text } })
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
async onAttach(file: File) {
|
|
178
|
+
if (!cid) return
|
|
179
|
+
const wsUrl = opts.url // derive HTTP upload URL from WS URL
|
|
180
|
+
const httpUrl = wsUrl.replace(/^ws/, 'http').replace(/\/ws$/, '')
|
|
181
|
+
const uploadUrl = `${httpUrl}/upload?name=${encodeURIComponent(file.name)}`
|
|
182
|
+
const clientMsgId = `cm_${Math.random().toString(36).slice(2)}`
|
|
183
|
+
// Optimistic: show uploading state
|
|
184
|
+
store.addOptimistic(clientMsgId, { kind: 'text', text: `📎 Uploading ${file.name}…` })
|
|
185
|
+
renderer.render(store)
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetch(uploadUrl, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': file.type, ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
|
|
190
|
+
body: file,
|
|
191
|
+
})
|
|
192
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
|
|
193
|
+
const { url, name, mime, size } = await res.json() as { url: string; name: string; mime: string; size: number }
|
|
194
|
+
conn.send({ type: 'send', conversationId: cid, clientMsgId, content: { kind: 'attachment', url, name, mime, size } })
|
|
195
|
+
} catch (e) {
|
|
196
|
+
store.addOptimistic(clientMsgId, { kind: 'text', text: `⚠️ Upload failed: ${(e as Error).message}` })
|
|
197
|
+
renderer.render(store)
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
onInvoke(actionId, inputs) {
|
|
201
|
+
if (!cid) return
|
|
202
|
+
conn.send({ type: 'invoke', conversationId: cid, actionId, clientInvokeId: `iv_${Math.random().toString(36).slice(2)}`, ...(inputs ? { inputs } : {}) })
|
|
203
|
+
},
|
|
204
|
+
onTyping(isTyping) { if (cid) conn.send({ type: 'typing', conversationId: cid, isTyping }) },
|
|
205
|
+
onReadUpTo(seq) { if (cid) conn.send({ type: 'read', conversationId: cid, seq }) },
|
|
206
|
+
onLoadMore() {
|
|
207
|
+
if (!cid) return
|
|
208
|
+
const oldest = store.messages()[0]
|
|
209
|
+
if (oldest) conn.send({ type: 'history', conversationId: cid, beforeSeq: oldest.seq, limit: 30 })
|
|
210
|
+
},
|
|
211
|
+
onEdit(messageId, newText) {
|
|
212
|
+
if (cid) conn.send({ type: 'edit', conversationId: cid, messageId: messageId as never, content: { kind: 'text', text: newText } })
|
|
213
|
+
},
|
|
214
|
+
onDelete(messageId) {
|
|
215
|
+
if (cid) conn.send({ type: 'delete', conversationId: cid, messageId: messageId as never })
|
|
216
|
+
},
|
|
217
|
+
onReact(messageId, emoji, remove) {
|
|
218
|
+
if (!cid) return
|
|
219
|
+
conn.send({ type: 'react', conversationId: cid, messageId: messageId as never, emoji, remove })
|
|
220
|
+
},
|
|
221
|
+
onCsat(score) {
|
|
222
|
+
if (!cid) return
|
|
223
|
+
// POST CSAT via fetch to the control-plane (the WS server also listens on HTTP).
|
|
224
|
+
// We extract the base URL from the WS URL best-effort.
|
|
225
|
+
const base = opts.url.replace(/^ws/, 'http').replace(/\/+$/, '').replace(/:\d+$/, m => m) // keep port
|
|
226
|
+
// Use the conversation id once it's known — post to /conversations/:id/csat
|
|
227
|
+
fetch(`${base}/conversations/${cid}/csat`, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
230
|
+
body: JSON.stringify({ score }),
|
|
231
|
+
}).catch(() => {}) // best-effort
|
|
232
|
+
},
|
|
233
|
+
onAnnotate(stroke) {
|
|
234
|
+
if (cid) conn.send({ type: 'annotate', conversationId: cid, stroke })
|
|
235
|
+
},
|
|
236
|
+
onAnnotateClear() {
|
|
237
|
+
if (cid) conn.send({ type: 'annotate_clear', conversationId: cid })
|
|
238
|
+
},
|
|
239
|
+
...(opts.translateLang ? {
|
|
240
|
+
async onTranslate(text: string) {
|
|
241
|
+
const base = opts.url.replace(/^ws/, 'http').replace(/\/+$/, '')
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch(`${base}/translate`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
246
|
+
body: JSON.stringify({ text, targetLang: opts.translateLang }),
|
|
247
|
+
})
|
|
248
|
+
if (!res.ok) return null
|
|
249
|
+
const { translated } = await res.json() as { translated: string | null }
|
|
250
|
+
return translated
|
|
251
|
+
} catch { return null }
|
|
252
|
+
},
|
|
253
|
+
} : {}),
|
|
254
|
+
...(opts.showChatList ? {
|
|
255
|
+
async onListChats() {
|
|
256
|
+
const base = httpBaseFromWsUrl(opts.url)
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch(`${base}/conversations/mine?profileId=${encodeURIComponent(opts.profileId)}`, {
|
|
259
|
+
headers: { authorization: `Bearer ${token}` },
|
|
260
|
+
})
|
|
261
|
+
if (!res.ok) return []
|
|
262
|
+
const data = await res.json() as { conversations?: ChatListEntry[] }
|
|
263
|
+
return data.conversations ?? []
|
|
264
|
+
} catch { return [] }
|
|
265
|
+
},
|
|
266
|
+
onSwitchChat(entry: ChatListEntry) {
|
|
267
|
+
currentHandle?.close()
|
|
268
|
+
opts.el.replaceChildren() // Renderer appends fresh DOM into the same host element
|
|
269
|
+
const { subjectId: _drop, ...rest } = opts
|
|
270
|
+
currentHandle = mount({ ...rest, token, ...(entry.subjectId ? { subjectId: entry.subjectId } : {}) })
|
|
271
|
+
},
|
|
272
|
+
} : {}),
|
|
273
|
+
}, {
|
|
274
|
+
...(opts.subject ? { subject: opts.subject } : {}),
|
|
275
|
+
...(opts.quickReplies ? { quickReplies: opts.quickReplies } : {}),
|
|
276
|
+
...(opts.accent ? { accent: opts.accent } : {}),
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const openFrame: Extract<ClientFrame, { type: 'open' }> = {
|
|
280
|
+
type: 'open', profileId: opts.profileId as never,
|
|
281
|
+
...(opts.subjectId ? { subjectId: opts.subjectId as never } : {}),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
conn = new ConnectionManager({
|
|
285
|
+
url: opts.url, token, open: openFrame,
|
|
286
|
+
getCursor: () => store.highestSeq(),
|
|
287
|
+
onStatusChange: (s) => renderer.setConnStatus(s),
|
|
288
|
+
onFrame(frame) {
|
|
289
|
+
if (frame.type === 'opened') {
|
|
290
|
+
const isFirstOpen = cid === undefined
|
|
291
|
+
cid = frame.conversation.id
|
|
292
|
+
if (!outboxRestored) {
|
|
293
|
+
outboxRestored = true
|
|
294
|
+
// Re-send anything that didn't get acknowledged before a reload/drop.
|
|
295
|
+
// Each item is also already showing as a 'pending' bubble (restored below).
|
|
296
|
+
for (const item of outbox.load()) {
|
|
297
|
+
conn.send({ type: 'send', conversationId: cid, clientMsgId: item.clientMsgId, content: item.content })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Restore chat history via REST, independent of the WS sync/history
|
|
301
|
+
// frames. Shared implementation with the dashboard's agent view —
|
|
302
|
+
// see history.ts.
|
|
303
|
+
if (isFirstOpen) {
|
|
304
|
+
void restoreHistory(opts.url, token, cid, store, renderer)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (frame.type === 'ack') outbox.remove(frame.clientMsgId)
|
|
309
|
+
|
|
310
|
+
// X3DH: handle incoming prekey bundle response
|
|
311
|
+
if (frame.type === 'prekeyBundle') {
|
|
312
|
+
if (frame.bundle) {
|
|
313
|
+
x3dhBundleFetched = true
|
|
314
|
+
void e2e.x3dhSendTo(frame.bundle).then((x3dhInit) => {
|
|
315
|
+
// Drain any X3DH-pending messages with the derived key.
|
|
316
|
+
const toSend = [...pending.splice(0), ...x3dhPending.splice(0)]
|
|
317
|
+
for (const p of toSend) sendSealedX3DH(p.clientMsgId, p.text, x3dhInit)
|
|
318
|
+
renderer.render(store)
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
// If no bundle, peer has no prekeys; fall back to live ECDH queue
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// X3DH recipient: detect init message in incoming encrypted messages
|
|
326
|
+
if (frame.type === 'message' && store.e2e) {
|
|
327
|
+
const x3dh = extractX3DHInit(frame.message.content)
|
|
328
|
+
if (x3dh && !e2e.ready) {
|
|
329
|
+
void e2e.x3dhReceiveFrom(x3dh.x3dhIK, x3dh.x3dhEK, x3dh.x3dhSPK).then(async () => {
|
|
330
|
+
// Now decrypt the message that carried the init
|
|
331
|
+
await e2e.openFrame(frame)
|
|
332
|
+
store.apply(frame)
|
|
333
|
+
renderer.render(store)
|
|
334
|
+
})
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (frame.type === 'peerkey') {
|
|
340
|
+
void e2e.onPeerKey(frame.key).then(() => { flushPending(); renderer.render(store) })
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
void (async () => {
|
|
344
|
+
if (store.e2e) await e2e.openFrame(frame)
|
|
345
|
+
store.apply(frame)
|
|
346
|
+
// Trigger badge + sound for new messages from others
|
|
347
|
+
if (frame.type === 'message' && frame.message.senderId !== (token as never) && !frame.message.internal) {
|
|
348
|
+
addUnread()
|
|
349
|
+
playSound()
|
|
350
|
+
}
|
|
351
|
+
// Once we learn the room is E2E, run the key handshake exactly once.
|
|
352
|
+
if (store.e2e && cid && !e2eStarted) {
|
|
353
|
+
e2eStarted = true
|
|
354
|
+
// Upload our prekey bundle for async E2E support.
|
|
355
|
+
const prekeyPayload = await e2e.initX3DH()
|
|
356
|
+
conn.send({ type: 'uploadPrekeys', ...prekeyPayload })
|
|
357
|
+
// Also do live ECDH handshake in case peer is already online.
|
|
358
|
+
const liveKey = await e2e.begin()
|
|
359
|
+
conn.send({ type: 'pubkey', conversationId: cid, key: liveKey })
|
|
360
|
+
}
|
|
361
|
+
renderer.render(store)
|
|
362
|
+
})()
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
conn.connect()
|
|
366
|
+
// Show restored 'pending' bubbles (if any) immediately, before the socket opens.
|
|
367
|
+
renderer.render(store)
|
|
368
|
+
|
|
369
|
+
currentHandle = { close: () => { conn.close(); launcherEl?.remove() } }
|
|
370
|
+
return currentHandle
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export { ChatStore } from './store.js'
|
|
374
|
+
export { ConnectionManager } from './connection.js'
|
|
375
|
+
export { Renderer, type ChatListEntry } from './renderer.js'
|
|
376
|
+
export { asConversationId }
|
|
377
|
+
export { E2ESession, extractX3DHInit, type X3DHBundle } from './e2e.js'
|
|
378
|
+
export { PersistentOutbox, type OutboxItem } from './outbox.js'
|
|
379
|
+
export { restoreHistory, httpBaseFromWsUrl } from './history.js'
|
|
380
|
+
|
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { MessageContent } from './protocol/index.js'
|
|
2
|
+
|
|
3
|
+
export interface OutboxItem {
|
|
4
|
+
clientMsgId: string
|
|
5
|
+
content: MessageContent
|
|
6
|
+
ts: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MAX_ITEMS = 200 // cap so a long outage can't grow storage unboundedly
|
|
10
|
+
const MAX_AGE_MS = 7 * 86_400_000 // drop anything older than 7 days on load
|
|
11
|
+
|
|
12
|
+
/** Persists not-yet-acknowledged outgoing messages to localStorage, keyed by
|
|
13
|
+
* guest token, so a page reload during a connectivity drop doesn't silently
|
|
14
|
+
* lose what the user typed (the "WhatsApp" guarantee: your message is queued
|
|
15
|
+
* until it's confirmed sent, even across app restarts). */
|
|
16
|
+
export class PersistentOutbox {
|
|
17
|
+
private readonly key: string
|
|
18
|
+
|
|
19
|
+
constructor(token: string) {
|
|
20
|
+
this.key = `ocw_outbox_${token}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** All pending items, oldest first, with stale (>7d) entries dropped. */
|
|
24
|
+
load(): OutboxItem[] {
|
|
25
|
+
try {
|
|
26
|
+
const raw = localStorage.getItem(this.key)
|
|
27
|
+
if (!raw) return []
|
|
28
|
+
const items = JSON.parse(raw) as OutboxItem[]
|
|
29
|
+
const cutoff = Date.now() - MAX_AGE_MS
|
|
30
|
+
const fresh = items.filter(i => i.ts >= cutoff)
|
|
31
|
+
if (fresh.length !== items.length) this.save(fresh)
|
|
32
|
+
return fresh
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
add(item: OutboxItem): void {
|
|
39
|
+
try {
|
|
40
|
+
const items = this.load()
|
|
41
|
+
items.push(item)
|
|
42
|
+
// Evict oldest when full — matches the in-memory ConnectionManager outbox policy.
|
|
43
|
+
this.save(items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items)
|
|
44
|
+
} catch { /* localStorage unavailable (private mode, quota) — best-effort only */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Remove an item once it's been acknowledged by the server. */
|
|
48
|
+
remove(clientMsgId: string): void {
|
|
49
|
+
try {
|
|
50
|
+
const items = this.load().filter(i => i.clientMsgId !== clientMsgId)
|
|
51
|
+
this.save(items)
|
|
52
|
+
} catch { /* best-effort */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private save(items: OutboxItem[]): void {
|
|
56
|
+
try { localStorage.setItem(this.key, JSON.stringify(items)) } catch { /* quota exceeded — drop silently */ }
|
|
57
|
+
}
|
|
58
|
+
}
|