@pmeweb/plugin-chat 0.0.0-stage → 1.0.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/src/widget.js ADDED
@@ -0,0 +1,606 @@
1
+ // The chat panel, the lazy part (dist/widget.js). The inline trigger imports it on the first touch
2
+ // of the bubble and calls `mountChat(root)`, then `open()` on a click. Everything with a cost lives
3
+ // here: the panel, Turnstile, the POST that opens a conversation, the socket.
4
+ //
5
+ // THE PANEL is a dialog (spec § 3.1 « Accessibility »): aria-modal, labelled by the name in its
6
+ // header, focus kept inside while open, closed by Escape and its close button and NEVER by a click
7
+ // outside, focus back on the bubble. A finished answer is announced once in a polite live region,
8
+ // never each streamed fragment.
9
+ //
10
+ // THE CONVERSATION starts on the first message: a Turnstile token (action `chat`), POST
11
+ // /api/chat/start, the ids kept in sessionStorage (they survive a reload of the tab, not a new
12
+ // tab), then the socket and `hello { since }`. Every message carries a client-made id, so a resend
13
+ // after a reconnect is stored once (pmeweb-chat dedups by id).
14
+ //
15
+ // CLOSE CODES (spike-findings.md): any close outside 4000-4999 (1006 when pmeweb-chat is force
16
+ // deployed, a network drop) reconnects with backoff 1, 2, 4 ... 30 s and asks `since` the last seq
17
+ // seen. A 44xx code means « do not reconnect »: 4426 (the page is older than the service) asks for a
18
+ // reload, 4404/4410/4401 end the conversation calmly. A message typed while the socket is down is
19
+ // QUEUED and sent on open (the spike's client dropped one); after 3 sends without an echo it shows
20
+ // « Non envoyé » with « Réessayer ».
21
+ //
22
+ // The mode comes from the service: `human` shows the owner's name, « Vous répond en direct » and the
23
+ // green dot; `bot` shows « Assistant virtuel » and no dot (spec § 6: a dot says a person is here).
24
+ // `fallback` (daily cap, model down) and a refused start show the contact form, which posts the
25
+ // same lead through /api/chat/start. The `lead` frame fires window.pmw?.event('chat-lead') once.
26
+ //
27
+ // PHOTOS (config `photos: true`, spec § 3.1): up to 3 per conversation, shrunk HERE in a canvas to a
28
+ // 1600 px JPEG (a canvas keeps no EXIF, so the GPS position a phone writes goes with it), posted to
29
+ // /api/chat/photo with the message's client id, shown from the local copy (never fetched back). A
30
+ // photo from an earlier visit of the tab shows « Photo envoyée ». The picker is a real file input
31
+ // inside its label, visually hidden but focusable; refusals are read out in a role=alert line.
32
+ //
33
+ // THE DEMO (manifest preview: test). Off the site's real hosts (apercu.pmeweb.ca, workers.dev,
34
+ // astro dev, or a page without the #pmw-hosts tag: fail closed) the panel never calls /api/chat/*,
35
+ // loads no Turnstile, opens no socket and reads no stored conversation: it shows the page's opener,
36
+ // a visitor's question drawn from it (demoQuestion) and a canned answer, with the composer disabled
37
+ // and a hint saying why. The client sees their face and colours in the « Contenu » preview.
38
+ import css from './widget.css?inline'
39
+ import { isCanonical } from '@pmeweb/loader'
40
+ import { addScript as loadTurnstile, resetToken, tokenReady } from '@pmeweb/turnstile'
41
+ import { CLOSE, MAX_TEXT } from './protocol.js'
42
+ import { fill, langOf, stringsFor } from './strings.js'
43
+
44
+ export const SESSION_KEY = 'pmw-chat'
45
+ export const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000]
46
+ export const MAX_TRIES = 3
47
+ export const GOAL = 'chat-lead'
48
+ export const MAX_PHOTOS = 3
49
+ export const PHOTO_EDGE = 1600
50
+ export const PHOTO_QUALITY = 0.85
51
+ const TYPING_SEND_MS = 3000
52
+ const TYPING_SHOW_MS = 8000
53
+
54
+ const CAMERA_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>'
55
+
56
+ /** The longest edge to PHOTO_EDGE, re-encoded as JPEG: the canvas drops every EXIF field. */
57
+ async function shrinkPhoto(win, file) {
58
+ const bmp = await win.createImageBitmap(file, { imageOrientation: 'from-image' })
59
+ const scale = Math.min(1, PHOTO_EDGE / Math.max(bmp.width, bmp.height))
60
+ const w = Math.max(1, Math.round(bmp.width * scale))
61
+ const h = Math.max(1, Math.round(bmp.height * scale))
62
+ const canvas = win.document.createElement('canvas')
63
+ canvas.width = w
64
+ canvas.height = h
65
+ canvas.getContext('2d').drawImage(bmp, 0, 0, w, h)
66
+ bmp.close?.()
67
+ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('encode'))), 'image/jpeg', PHOTO_QUALITY))
68
+ }
69
+
70
+ const OURS = { fr: { nos: 'vos', notre: 'votre', nous: 'vous' }, en: { our: 'your', us: 'you', we: 'you' } }
71
+ const SUBJECT = { fr: /^une question sur (.+?)\s*\?$/i, en: /^(?:a |any )?questions? (?:about|on) (.+?)\s*\?$/i }
72
+
73
+ /** The demo's visitor question: the opener's subject from the visitor's side, else a generic one. */
74
+ export function demoQuestion(opener, lang) {
75
+ const l = langOf(lang)
76
+ const s = stringsFor(l)
77
+ const m = String(opener ?? '').trim().match(SUBJECT[l])
78
+ if (!m) return s.demoQuestion
79
+ const topic = m[1].replace(/[\p{L}’']+/gu, (w) => OURS[l][w.toLowerCase()] ?? w)
80
+ return fill(s.demoAsk, { topic })
81
+ }
82
+
83
+ const X_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>'
84
+
85
+ function el(doc, tag, attrs = {}, text) {
86
+ const e = doc.createElement(tag)
87
+ for (const [k, v] of Object.entries(attrs)) if (v !== null && v !== undefined && v !== false) e.setAttribute(k, v === true ? '' : v)
88
+ if (text !== undefined) e.textContent = text
89
+ return e
90
+ }
91
+
92
+ const defaultTurnstile = (win) => ({
93
+ load: () => loadTurnstile(win.document),
94
+ async token(form) {
95
+ await tokenReady(form, { win })
96
+ return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
97
+ },
98
+ reset: (form) => resetToken(form, { win }),
99
+ })
100
+
101
+ export function mountChat(root, deps = {}) {
102
+ if (root.pmwChat) return root.pmwChat
103
+ const win = deps.win ?? window
104
+ const doc = win.document
105
+ const WS = deps.WebSocket ?? win.WebSocket
106
+ const fetchImpl = deps.fetch ?? ((...a) => win.fetch(...a))
107
+ const turnstile = deps.turnstile ?? defaultTurnstile(win)
108
+ const uuid = deps.uuid ?? (() => win.crypto.randomUUID())
109
+ const shrink = deps.shrink ?? ((file) => shrinkPhoto(win, file))
110
+ // A data: URL, not a blob: one: site CSPs allow `img-src data:` (boilerplate worker), not blob:.
111
+ const preview = deps.preview ?? ((blob) => new Promise((resolve, reject) => {
112
+ const r = new win.FileReader()
113
+ r.onload = () => resolve(String(r.result))
114
+ r.onerror = () => reject(r.error)
115
+ r.readAsDataURL(blob)
116
+ }))
117
+ let cfg = {}
118
+ try { cfg = JSON.parse(root.querySelector('[data-pmw-chat-config]')?.textContent ?? '{}') } catch { /* defaults */ }
119
+ const lang = langOf(cfg.lang)
120
+ const s = stringsFor(lang, cfg.strings)
121
+ const opener = root.dataset.pmwChatOpener ?? ''
122
+ const bubble = root.querySelector('.pmw-chat__bubble')
123
+ // cfg.demo: Chat.astro switched off, rendered only for a « Contenu » preview build.
124
+ const demo = deps.demo ?? (cfg.demo === true || !isCanonical({ doc, hostname: win.location.hostname }))
125
+
126
+ if (!doc.getElementById('pmw-chat-style')) doc.head.append(el(doc, 'style', { id: 'pmw-chat-style' }, css))
127
+
128
+ // ---- the panel --------------------------------------------------------------------------
129
+ const panel = el(doc, 'div', { class: 'pmw-chat__panel', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'pmw-chat-name', hidden: true })
130
+ const head = el(doc, 'div', { class: 'pmw-chat__head' })
131
+ const avatar = root.querySelector('.pmw-chat__avatar')?.cloneNode(true) ?? el(doc, 'span', { class: 'pmw-chat__avatar', 'aria-hidden': 'true' })
132
+ // An icon may hold a <mask id>: the clone gets its own ids, since the bubble (and the original
133
+ // mask with it) is display:none while the panel is open.
134
+ for (const x of avatar.querySelectorAll('[id]')) {
135
+ const id = x.id
136
+ x.id = `${id}-head`
137
+ for (const u of avatar.querySelectorAll(`[mask="url(#${id})"]`)) u.setAttribute('mask', `url(#${id}-head)`)
138
+ }
139
+ const who = el(doc, 'div', { class: 'pmw-chat__who' })
140
+ const name = el(doc, 'p', { class: 'pmw-chat__name', id: 'pmw-chat-name' })
141
+ const role = el(doc, 'p', { class: 'pmw-chat__role' })
142
+ const dot = el(doc, 'span', { class: 'pmw-chat__dot', 'aria-hidden': 'true', hidden: true })
143
+ const closeBtn = el(doc, 'button', { type: 'button', class: 'pmw-chat__close', 'aria-label': s.close })
144
+ closeBtn.innerHTML = X_ICON
145
+ who.append(name, role)
146
+ head.append(avatar, who, dot, closeBtn)
147
+
148
+ const notice = el(doc, 'p', { class: 'pmw-chat__notice' }, s.automated)
149
+ if (cfg.privacyHref) {
150
+ notice.append(' ')
151
+ notice.append(el(doc, 'a', { href: cfg.privacyHref }, s.privacy))
152
+ }
153
+ const log = el(doc, 'ol', { class: 'pmw-chat__log' })
154
+ const greeting = el(doc, 'li', { class: 'pmw-chat__msg pmw-chat__msg--bot pmw-chat__msg--greeting' }, opener)
155
+ const typing = el(doc, 'li', { class: 'pmw-chat__typing', 'aria-hidden': 'true', hidden: true })
156
+ typing.append(el(doc, 'span'), el(doc, 'span'), el(doc, 'span'))
157
+ log.append(greeting)
158
+ const state = el(doc, 'p', { class: 'pmw-chat__state', role: 'status' })
159
+ const announcer = el(doc, 'p', { class: 'pmw-chat__sr', 'aria-live': 'polite' })
160
+
161
+ const widget = (form) => el(doc, 'div', {
162
+ class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
163
+ 'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
164
+ })
165
+ const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
166
+ const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '2', maxlength: String(MAX_TEXT), autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
167
+ const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
168
+ composer.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
169
+ if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
170
+ else composer.append(widget(composer))
171
+ let picker = null
172
+ const photoError = el(doc, 'p', { class: 'pmw-chat__error pmw-chat__photo-error', role: 'alert' })
173
+ if (cfg.photos === true) {
174
+ const attach = el(doc, 'label', { class: 'pmw-chat__attach' })
175
+ picker = el(doc, 'input', { type: 'file', class: 'pmw-chat__sr', accept: 'image/*', multiple: true, 'aria-describedby': 'pmw-chat-photo-hint', disabled: demo })
176
+ attach.innerHTML = CAMERA_ICON
177
+ attach.prepend(picker)
178
+ attach.append(el(doc, 'span', { class: 'pmw-chat__sr' }, s.attachPhoto))
179
+ composer.insertBefore(attach, input)
180
+ composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-photo-hint' }, s.photoHint), photoError)
181
+ }
182
+
183
+ const ended = el(doc, 'div', { class: 'pmw-chat__ended', hidden: true })
184
+ const endedText = el(doc, 'p', {}, s.ended)
185
+ const restart = el(doc, 'button', { type: 'button', class: 'pmw-chat__restart' }, s.newConversation)
186
+ ended.append(endedText, restart)
187
+
188
+ const fallback = el(doc, 'form', { class: 'pmw-chat__fallback', novalidate: true, hidden: true, 'aria-labelledby': 'pmw-chat-fb-title' })
189
+ const fbIntro = el(doc, 'p', { class: 'pmw-chat__fb-intro' })
190
+ const field = (key, tag, attrs) => {
191
+ const id = `pmw-chat-fb-${key}`
192
+ const wrap = el(doc, 'div', { class: 'pmw-chat__field' })
193
+ wrap.append(el(doc, 'label', { for: id }, s[key]), el(doc, tag, { id, name: key, required: true, ...attrs }))
194
+ return wrap
195
+ }
196
+ const fbError = el(doc, 'p', { class: 'pmw-chat__error', role: 'alert' })
197
+ fallback.append(
198
+ el(doc, 'p', { class: 'pmw-chat__fb-title', id: 'pmw-chat-fb-title' }, s.fallbackTitle), fbIntro,
199
+ field('name', 'input', { autocomplete: 'name', maxlength: '100' }),
200
+ field('contact', 'input', { autocomplete: 'on', maxlength: '200' }),
201
+ field('need', 'textarea', { rows: '3', maxlength: '2000' }),
202
+ fbError, el(doc, 'button', { type: 'submit', class: 'pmw-chat__send' }, s.fallbackSend),
203
+ )
204
+ if (!demo) fallback.append(widget(fallback))
205
+ const body = el(doc, 'div', { class: 'pmw-chat__body' })
206
+ body.append(notice, log, state, ended, fallback)
207
+ panel.append(head, body, composer, announcer)
208
+ root.append(panel)
209
+
210
+ // ---- state ------------------------------------------------------------------------------
211
+ let session = null
212
+ if (!demo) try { session = JSON.parse(win.sessionStorage.getItem(SESSION_KEY) ?? 'null') } catch { session = null }
213
+ const save = () => { try { win.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)) } catch { /* this tab only */ } }
214
+ let ws = null
215
+ let attempt = 0
216
+ let retryTimer = null
217
+ let starting = null
218
+ let lastSeq = 0
219
+ let mode = 'bot'
220
+ let ownerName = null
221
+ let over = false
222
+ let lastTyping = 0
223
+ let typingTimer = null
224
+ const seen = new Set()
225
+ const pending = new Map()
226
+ /** Ids of photo messages in this conversation (sent or echoed), and uploads under way. */
227
+ const photosSent = new Set()
228
+ let photosBusy = 0
229
+
230
+ const botName = () => session?.botName || null
231
+ function header() {
232
+ const human = mode === 'human'
233
+ name.textContent = human ? (ownerName || s.assistant) : (botName() || s.assistant)
234
+ role.textContent = human ? s.live : (botName() ? s.assistant : '')
235
+ role.hidden = role.textContent === ''
236
+ dot.hidden = !human
237
+ panel.dataset.mode = mode
238
+ }
239
+ header()
240
+ const showState = (text) => { state.textContent = text ?? ''; state.hidden = !text }
241
+ showState('')
242
+
243
+ // ---- messages ---------------------------------------------------------------------------
244
+ const speaker = (m) => (m.role === 'visitor' ? s.you : m.role === 'owner' ? (m.by || ownerName || s.assistant) : (botName() || s.assistant))
245
+ function line(id, roleName) {
246
+ const li = el(doc, 'li', { class: `pmw-chat__msg pmw-chat__msg--${roleName}`, 'data-id': id })
247
+ const who = el(doc, 'span', { class: 'pmw-chat__sr' })
248
+ const text = el(doc, 'span', { class: 'pmw-chat__text' })
249
+ li.append(who, text)
250
+ log.insertBefore(li, typing.parentNode === log ? typing : null)
251
+ return li
252
+ }
253
+ const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
254
+ const setText = (li, m, text) => {
255
+ li.firstChild.textContent = fill(s.said, { name: speaker(m), text: '' })
256
+ // A photo with no local copy (an earlier visit of the tab) says so; the image is never fetched back.
257
+ li.lastChild.textContent = text === '' && m.photos?.length && !li.querySelector('img') ? s.photoSent : text
258
+ }
259
+ function hideTyping() {
260
+ typing.hidden = true
261
+ win.clearTimeout(typingTimer)
262
+ }
263
+ function render(m, { announce }) {
264
+ if (seen.has(m.seq)) return
265
+ seen.add(m.seq)
266
+ lastSeq = Math.max(lastSeq, m.seq)
267
+ let li = find(m.id)
268
+ if (!li) li = line(m.id, m.role)
269
+ li.classList.remove('is-streaming', 'is-pending', 'is-failed')
270
+ li.querySelector('.pmw-chat__retry')?.remove()
271
+ setText(li, m, m.text)
272
+ if (m.role === 'visitor') {
273
+ pending.delete(m.id)
274
+ if (m.photos?.length) photosSent.add(m.id)
275
+ }
276
+ else {
277
+ hideTyping()
278
+ if (announce) announcer.textContent = fill(s.said, { name: speaker(m), text: m.text })
279
+ if (panel.hidden) root.setAttribute('data-unread', '')
280
+ }
281
+ scroll()
282
+ }
283
+ function delta(id, text) {
284
+ hideTyping()
285
+ let li = find(id)
286
+ if (!li) {
287
+ li = line(id, 'bot')
288
+ li.classList.add('is-streaming')
289
+ setText(li, { role: 'bot' }, '')
290
+ }
291
+ li.lastChild.textContent += text
292
+ scroll()
293
+ }
294
+ const scroll = () => { log.scrollTop = log.scrollHeight }
295
+ if (demo) {
296
+ setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
297
+ setText(line('demo-answer', 'bot'), { role: 'bot' }, s.demoAnswer)
298
+ }
299
+
300
+ function failed(entry) {
301
+ entry.failed = true
302
+ entry.li.classList.remove('is-pending')
303
+ entry.li.classList.add('is-failed')
304
+ if (entry.li.querySelector('.pmw-chat__retry')) return
305
+ const note = el(doc, 'span', { class: 'pmw-chat__retry' }, `${s.notSent} `)
306
+ const btn = el(doc, 'button', { type: 'button' }, s.retry)
307
+ btn.addEventListener('click', () => {
308
+ entry.failed = false
309
+ entry.tries = 0
310
+ note.remove()
311
+ entry.li.classList.replace('is-failed', 'is-pending')
312
+ if (!session) begin()
313
+ else if (isOpen()) transmit(entry)
314
+ else connect()
315
+ })
316
+ note.append(btn)
317
+ entry.li.append(note)
318
+ }
319
+ const isOpen = () => !!ws && ws.readyState === 1
320
+ function transmit(entry) {
321
+ if (entry.failed || !isOpen()) return
322
+ if (entry.tries >= MAX_TRIES) return failed(entry)
323
+ entry.tries++
324
+ ws.send(JSON.stringify({ type: 'message', id: entry.id, text: entry.text }))
325
+ }
326
+
327
+ // ---- the socket -------------------------------------------------------------------------
328
+ function connect() {
329
+ if (!session || over || ws) return
330
+ win.clearTimeout(retryTimer)
331
+ const proto = win.location.protocol === 'https:' ? 'wss:' : 'ws:'
332
+ const q = new URLSearchParams({ c: session.c, k: session.k })
333
+ const sock = new WS(`${proto}//${win.location.host}/api/chat/ws?${q}`)
334
+ ws = sock
335
+ sock.onopen = () => {
336
+ attempt = 0
337
+ showState('')
338
+ sock.send(JSON.stringify({ type: 'hello', since: lastSeq }))
339
+ for (const entry of pending.values()) transmit(entry)
340
+ }
341
+ sock.onmessage = (e) => {
342
+ let m
343
+ try { m = JSON.parse(e.data) } catch { return }
344
+ frame(m)
345
+ }
346
+ sock.onclose = (e) => {
347
+ if (ws === sock) ws = null
348
+ for (const entry of pending.values()) if (entry.tries >= MAX_TRIES) failed(entry)
349
+ if (e.code >= 4000 && e.code < 5000) return refused(e.code)
350
+ if (over) return
351
+ showState(s.reconnecting)
352
+ const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
353
+ attempt++
354
+ retryTimer = win.setTimeout(connect, wait)
355
+ }
356
+ }
357
+ function refused(code) {
358
+ if (code === CLOSE.rpc) return end(s.reload, false)
359
+ end(s.ended, true)
360
+ }
361
+ function end(text, canRestart) {
362
+ over = true
363
+ win.clearTimeout(retryTimer)
364
+ showState('')
365
+ endedText.textContent = text
366
+ restart.hidden = !canRestart
367
+ ended.hidden = false
368
+ composer.hidden = true
369
+ for (const entry of pending.values()) failed(entry)
370
+ }
371
+ function status(m) {
372
+ mode = m.mode === 'human' ? 'human' : 'bot'
373
+ ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
374
+ header()
375
+ if (m.closed) end(s.ended, true)
376
+ }
377
+ function frame(m) {
378
+ switch (m?.type) {
379
+ case 'history':
380
+ for (const x of m.messages ?? []) render(x, { announce: false })
381
+ return status(m)
382
+ case 'message':
383
+ return render(m.msg, { announce: m.msg?.role !== 'visitor' })
384
+ case 'delta':
385
+ return delta(m.id, m.text)
386
+ case 'typing':
387
+ if (m.role === 'visitor') return
388
+ typing.hidden = false
389
+ log.append(typing)
390
+ win.clearTimeout(typingTimer)
391
+ typingTimer = win.setTimeout(hideTyping, TYPING_SHOW_MS)
392
+ return scroll()
393
+ case 'status':
394
+ return status(m)
395
+ case 'fallback':
396
+ return showFallback(m.reason === 'cap' ? s.fallbackCap : s.fallbackDown)
397
+ case 'lead':
398
+ return lead()
399
+ case 'error':
400
+ if (m.code === 'closed' || m.code === 'deleted') end(s.ended, true)
401
+ }
402
+ }
403
+ // Once per conversation: the flag rides in the session, so a reload does not count it again.
404
+ let fired = false
405
+ function lead() {
406
+ if (fired || session?.lead) return
407
+ fired = true
408
+ if (session) { session.lead = true; save() }
409
+ try { win.pmw?.event(GOAL) } catch { /* the beacon never breaks the chat */ }
410
+ }
411
+
412
+ // ---- starting a conversation ------------------------------------------------------------
413
+ async function post(form, extra) {
414
+ let token = ''
415
+ try { token = await turnstile.token(form) } catch { token = '' }
416
+ let res
417
+ let data = null
418
+ try {
419
+ res = await fetchImpl('/api/chat/start', {
420
+ method: 'POST',
421
+ headers: { 'content-type': 'application/json' },
422
+ body: JSON.stringify({ token, lang, page: win.location.pathname, ...extra }),
423
+ })
424
+ data = await res.json()
425
+ } catch {
426
+ res = null
427
+ }
428
+ try { turnstile.reset(form) } catch { /* a token is single-use; the next post asks again */ }
429
+ if (res?.ok && data) return { ok: true, data }
430
+ const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
431
+ return { ok: false, error }
432
+ }
433
+ function begin() {
434
+ if (starting || session) return starting
435
+ showState(s.connecting)
436
+ starting = post(composer, {}).then((r) => {
437
+ starting = null
438
+ if (r.ok && r.data.conversationId && r.data.visitorKey) {
439
+ session = { c: r.data.conversationId, k: r.data.visitorKey, botName: typeof r.data.botName === 'string' ? r.data.botName : null }
440
+ save()
441
+ header()
442
+ connect()
443
+ return
444
+ }
445
+ for (const entry of pending.values()) failed(entry)
446
+ if (r.error) return showState(r.error)
447
+ showState('')
448
+ showFallback(s.unavailable)
449
+ })
450
+ return starting
451
+ }
452
+ function showFallback(text) {
453
+ fbIntro.textContent = text
454
+ fallback.hidden = false
455
+ composer.hidden = true
456
+ fallback.querySelector('input')?.focus()
457
+ }
458
+ fallback.addEventListener('submit', async (e) => {
459
+ e.preventDefault()
460
+ const v = (k) => fallback.elements[k].value.trim()
461
+ const fields = { name: v('name'), contact: v('contact'), need: v('need') }
462
+ if (!fields.name || !fields.contact || !fields.need) {
463
+ fbError.textContent = s.fallbackMissing
464
+ return
465
+ }
466
+ fbError.textContent = ''
467
+ const r = await post(fallback, { fallbackLead: fields })
468
+ if (!r.ok) {
469
+ fbError.textContent = r.error ?? s.unavailable
470
+ return
471
+ }
472
+ fallback.replaceChildren(el(doc, 'p', { class: 'pmw-chat__fb-done', role: 'status' }, s.fallbackSent))
473
+ lead()
474
+ })
475
+
476
+ // ---- photos -------------------------------------------------------------------------------
477
+ const PHOTO_ERRORS = { 413: 'photoTooLarge', 415: 'photoUnreadable', 409: 'photoLimit', 429: 'tooMany' }
478
+ async function sendPhoto(file) {
479
+ let blob
480
+ let src
481
+ try {
482
+ blob = await shrink(file)
483
+ src = await preview(blob)
484
+ } catch {
485
+ photoError.textContent = s.photoUnreadable
486
+ return
487
+ }
488
+ const id = uuid()
489
+ const li = line(id, 'visitor')
490
+ li.classList.add('is-pending')
491
+ li.insertBefore(el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt }), li.lastChild)
492
+ setText(li, { role: 'visitor' }, '')
493
+ scroll()
494
+ if (!session) await begin()
495
+ let res = null
496
+ if (session) {
497
+ const q = new URLSearchParams({ c: session.c, k: session.k, id })
498
+ try {
499
+ res = await fetchImpl(`/api/chat/photo?${q}`, { method: 'POST', headers: { 'content-type': 'image/jpeg' }, body: blob })
500
+ } catch {
501
+ res = null
502
+ }
503
+ }
504
+ if (res?.ok) {
505
+ li.classList.remove('is-pending')
506
+ photosSent.add(id)
507
+ if (!ws) connect()
508
+ return
509
+ }
510
+ li.remove()
511
+ photoError.textContent = s[PHOTO_ERRORS[res?.status]] ?? s.photoFailed
512
+ }
513
+ picker?.addEventListener('change', async () => {
514
+ const files = [...(picker.files ?? [])]
515
+ picker.value = ''
516
+ photoError.textContent = ''
517
+ if (over || demo || files.length === 0) return
518
+ const room = MAX_PHOTOS - photosSent.size - photosBusy
519
+ if (files.length > room) photoError.textContent = s.photoLimit
520
+ const take = files.slice(0, Math.max(0, room))
521
+ photosBusy += take.length
522
+ // One at a time: the first may have to start the conversation the others then use.
523
+ for (const f of take) {
524
+ try { await sendPhoto(f) } finally { photosBusy-- }
525
+ }
526
+ })
527
+
528
+ // ---- the composer -----------------------------------------------------------------------
529
+ function submit() {
530
+ const text = input.value.trim()
531
+ if (!text || over || demo) return
532
+ input.value = ''
533
+ const entry = { id: uuid(), text: text.slice(0, MAX_TEXT), tries: 0, failed: false }
534
+ entry.li = line(entry.id, 'visitor')
535
+ entry.li.classList.add('is-pending')
536
+ setText(entry.li, { role: 'visitor' }, entry.text)
537
+ pending.set(entry.id, entry)
538
+ scroll()
539
+ if (!session) begin()
540
+ else if (isOpen()) transmit(entry)
541
+ else connect()
542
+ }
543
+ composer.addEventListener('submit', (e) => { e.preventDefault(); submit() })
544
+ input.addEventListener('keydown', (e) => {
545
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit() }
546
+ })
547
+ input.addEventListener('input', () => {
548
+ const now = Date.now()
549
+ if (isOpen() && now - lastTyping > TYPING_SEND_MS) {
550
+ lastTyping = now
551
+ ws.send(JSON.stringify({ type: 'typing' }))
552
+ }
553
+ })
554
+ restart.addEventListener('click', () => {
555
+ session = null
556
+ try { win.sessionStorage.removeItem(SESSION_KEY) } catch { /* nothing kept */ }
557
+ try { ws?.close(1000) } catch { /* already closed */ }
558
+ ws = null
559
+ over = false
560
+ lastSeq = 0
561
+ seen.clear()
562
+ pending.clear()
563
+ photosSent.clear()
564
+ photoError.textContent = ''
565
+ mode = 'bot'
566
+ ownerName = null
567
+ header()
568
+ for (const li of [...log.querySelectorAll('li[data-id]')]) li.remove()
569
+ ended.hidden = true
570
+ composer.hidden = false
571
+ input.focus()
572
+ })
573
+
574
+ // ---- the dialog -------------------------------------------------------------------------
575
+ const focusables = () => [...panel.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')]
576
+ .filter((x) => !x.disabled && !x.closest('[hidden]'))
577
+ panel.addEventListener('keydown', (e) => {
578
+ if (e.key === 'Escape') { e.preventDefault(); close(); return }
579
+ if (e.key !== 'Tab') return
580
+ const f = focusables()
581
+ if (f.length === 0) return
582
+ const first = f[0]
583
+ const last = f[f.length - 1]
584
+ if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus() }
585
+ else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus() }
586
+ })
587
+ closeBtn.addEventListener('click', () => close())
588
+
589
+ function show() {
590
+ if (!demo) turnstile.load()
591
+ panel.hidden = false
592
+ root.setAttribute('data-open', '')
593
+ root.removeAttribute('data-unread')
594
+ if (session) connect()
595
+ ;(demo ? closeBtn : composer.hidden ? (fallback.hidden ? restart : fallback.querySelector('input')) : input)?.focus()
596
+ scroll()
597
+ }
598
+ function close() {
599
+ panel.hidden = true
600
+ root.removeAttribute('data-open')
601
+ bubble?.focus()
602
+ }
603
+
604
+ root.pmwChat = { open: show, close, panel }
605
+ return root.pmwChat
606
+ }
@@ -0,0 +1,60 @@
1
+ // Types for the worker half of @pmeweb/plugin-chat.
2
+
3
+ /** pmeweb.json `plugins.chat` (manifest `config`). Every value is public. */
4
+ export interface ChatConfig {
5
+ /** The client's on/off switch. Anything but `true` is off: nothing on the site, every route 403. */
6
+ enabled?: boolean
7
+ provider?: 'workers-ai'
8
+ sms?: 'voipms'
9
+ photos?: boolean
10
+ avatar?: '1' | '2' | '3' | '4' | '5' | '6' | 'icon' | 'logo'
11
+ }
12
+
13
+ /**
14
+ * The CHAT service binding: pmeweb-chat's `ChatApi` named entrypoint. `fetch` carries
15
+ * `POST /start` and the `/ws` upgrade; `ping` is for the health route.
16
+ */
17
+ export interface ChatBinding {
18
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
19
+ ping?(host: string): Promise<{ ok: boolean; accepts: readonly number[]; configured: boolean; missing: string[]; error?: string }>
20
+ }
21
+
22
+ export interface ChatEnv {
23
+ CHAT: ChatBinding
24
+ /** The site's Turnstile secret. Absent = every start is refused (503). */
25
+ TURNSTILE_SECRET?: string
26
+ /** A Workers Rate Limiting binding (`ratelimits` in wrangler.jsonc), keyed `chat:<ip>`. */
27
+ CHAT_LIMIT?: { limit(o: { key: string }): Promise<{ success: boolean }> }
28
+ }
29
+
30
+ /**
31
+ * Route every `/api/chat/*` request here, before the assets (`start`, `ws`, and `photo` when the
32
+ * config says `photos: true`). Never throws. Off `hosts` (the site's
33
+ * real hosts, src/data/hosts.ts) every route answers 403 `disabled`.
34
+ */
35
+ export declare function handleChat(
36
+ request: Request,
37
+ env: ChatEnv,
38
+ ctx: { waitUntil(p: Promise<unknown>): void },
39
+ options: { hosts: string[]; config?: ChatConfig | Record<string, unknown>; lang?: 'fr' | 'en' | string }
40
+ ): Promise<Response>
41
+
42
+ /** The largest photo the site forwards (pmeweb-chat refuses bigger ones too). */
43
+ export declare const MAX_PHOTO_BYTES: number
44
+
45
+ /** This package's version. */
46
+ export declare const VERSION: string
47
+ /** The ChatApi interface version this package speaks. */
48
+ export declare const RPC_VERSION: number
49
+
50
+ /**
51
+ * For the site's plugin health route: booleans only. `host` is the site's canonical host
52
+ * (`CANONICAL_HOSTS[0]`), which pmeweb-chat needs to find the client's settings; `config` is
53
+ * `plugins.chat`, switched off it answers `{ disabled: true }` only:
54
+ * `pluginHealth(request, env, { chat: (e) => health(e, { host: CANONICAL_HOSTS[0], config: plugins.chat }) })`.
55
+ */
56
+ export declare function health(env: Partial<ChatEnv>, opts?: { host?: string; config?: ChatConfig | Record<string, unknown> }): Promise<{
57
+ version: string
58
+ rpc: number
59
+ checks: { secret: boolean; binding: boolean; limit: boolean; rpc: boolean; settings: boolean } | { disabled: true }
60
+ }>