@pmeweb/plugin-chat 0.0.0-stage → 1.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/src/widget.js ADDED
@@ -0,0 +1,616 @@
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
+ // The demo's name is the portal preview's, from the fragment (phase 4 spec 2.4); never on a real host.
231
+ const fragmentName = () => new URLSearchParams(win.location.hash.slice(1)).get('pmw-chat-botName')?.trim() || null
232
+ let demoName = demo ? fragmentName() : null
233
+ const botName = () => (demo ? demoName : session?.botName || null)
234
+ function header() {
235
+ const human = mode === 'human'
236
+ name.textContent = human ? (ownerName || s.assistant) : (botName() || s.assistant)
237
+ role.textContent = human ? s.live : (botName() ? s.assistant : '')
238
+ role.hidden = role.textContent === ''
239
+ dot.hidden = !human
240
+ panel.dataset.mode = mode
241
+ }
242
+ header()
243
+ const showState = (text) => { state.textContent = text ?? ''; state.hidden = !text }
244
+ showState('')
245
+
246
+ // ---- messages ---------------------------------------------------------------------------
247
+ const speaker = (m) => (m.role === 'visitor' ? s.you : m.role === 'owner' ? (m.by || ownerName || s.assistant) : (botName() || s.assistant))
248
+ function line(id, roleName) {
249
+ const li = el(doc, 'li', { class: `pmw-chat__msg pmw-chat__msg--${roleName}`, 'data-id': id })
250
+ const who = el(doc, 'span', { class: 'pmw-chat__sr' })
251
+ const text = el(doc, 'span', { class: 'pmw-chat__text' })
252
+ li.append(who, text)
253
+ log.insertBefore(li, typing.parentNode === log ? typing : null)
254
+ return li
255
+ }
256
+ const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
257
+ const setText = (li, m, text) => {
258
+ li.firstChild.textContent = fill(s.said, { name: speaker(m), text: '' })
259
+ // A photo with no local copy (an earlier visit of the tab) says so; the image is never fetched back.
260
+ li.lastChild.textContent = text === '' && m.photos?.length && !li.querySelector('img') ? s.photoSent : text
261
+ }
262
+ function hideTyping() {
263
+ typing.hidden = true
264
+ win.clearTimeout(typingTimer)
265
+ }
266
+ function render(m, { announce }) {
267
+ if (seen.has(m.seq)) return
268
+ seen.add(m.seq)
269
+ lastSeq = Math.max(lastSeq, m.seq)
270
+ let li = find(m.id)
271
+ if (!li) li = line(m.id, m.role)
272
+ li.classList.remove('is-streaming', 'is-pending', 'is-failed')
273
+ li.querySelector('.pmw-chat__retry')?.remove()
274
+ setText(li, m, m.text)
275
+ if (m.role === 'visitor') {
276
+ pending.delete(m.id)
277
+ if (m.photos?.length) photosSent.add(m.id)
278
+ }
279
+ else {
280
+ hideTyping()
281
+ if (announce) announcer.textContent = fill(s.said, { name: speaker(m), text: m.text })
282
+ if (panel.hidden) root.setAttribute('data-unread', '')
283
+ }
284
+ scroll()
285
+ }
286
+ function delta(id, text) {
287
+ hideTyping()
288
+ let li = find(id)
289
+ if (!li) {
290
+ li = line(id, 'bot')
291
+ li.classList.add('is-streaming')
292
+ setText(li, { role: 'bot' }, '')
293
+ }
294
+ li.lastChild.textContent += text
295
+ scroll()
296
+ }
297
+ const scroll = () => { log.scrollTop = log.scrollHeight }
298
+ if (demo) {
299
+ setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
300
+ const answer = line('demo-answer', 'bot')
301
+ setText(answer, { role: 'bot' }, s.demoAnswer)
302
+ win.addEventListener('hashchange', () => {
303
+ demoName = fragmentName()
304
+ header()
305
+ setText(answer, { role: 'bot' }, s.demoAnswer)
306
+ })
307
+ }
308
+
309
+ function failed(entry) {
310
+ entry.failed = true
311
+ entry.li.classList.remove('is-pending')
312
+ entry.li.classList.add('is-failed')
313
+ if (entry.li.querySelector('.pmw-chat__retry')) return
314
+ const note = el(doc, 'span', { class: 'pmw-chat__retry' }, `${s.notSent} `)
315
+ const btn = el(doc, 'button', { type: 'button' }, s.retry)
316
+ btn.addEventListener('click', () => {
317
+ entry.failed = false
318
+ entry.tries = 0
319
+ note.remove()
320
+ entry.li.classList.replace('is-failed', 'is-pending')
321
+ if (!session) begin()
322
+ else if (isOpen()) transmit(entry)
323
+ else connect()
324
+ })
325
+ note.append(btn)
326
+ entry.li.append(note)
327
+ }
328
+ const isOpen = () => !!ws && ws.readyState === 1
329
+ function transmit(entry) {
330
+ if (entry.failed || !isOpen()) return
331
+ if (entry.tries >= MAX_TRIES) return failed(entry)
332
+ entry.tries++
333
+ ws.send(JSON.stringify({ type: 'message', id: entry.id, text: entry.text }))
334
+ }
335
+
336
+ // ---- the socket -------------------------------------------------------------------------
337
+ function connect() {
338
+ if (!session || over || ws) return
339
+ win.clearTimeout(retryTimer)
340
+ const proto = win.location.protocol === 'https:' ? 'wss:' : 'ws:'
341
+ const q = new URLSearchParams({ c: session.c, k: session.k })
342
+ const sock = new WS(`${proto}//${win.location.host}/api/chat/ws?${q}`)
343
+ ws = sock
344
+ sock.onopen = () => {
345
+ attempt = 0
346
+ showState('')
347
+ sock.send(JSON.stringify({ type: 'hello', since: lastSeq }))
348
+ for (const entry of pending.values()) transmit(entry)
349
+ }
350
+ sock.onmessage = (e) => {
351
+ let m
352
+ try { m = JSON.parse(e.data) } catch { return }
353
+ frame(m)
354
+ }
355
+ sock.onclose = (e) => {
356
+ if (ws === sock) ws = null
357
+ for (const entry of pending.values()) if (entry.tries >= MAX_TRIES) failed(entry)
358
+ if (e.code >= 4000 && e.code < 5000) return refused(e.code)
359
+ if (over) return
360
+ showState(s.reconnecting)
361
+ const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
362
+ attempt++
363
+ retryTimer = win.setTimeout(connect, wait)
364
+ }
365
+ }
366
+ function refused(code) {
367
+ if (code === CLOSE.rpc) return end(s.reload, false)
368
+ end(s.ended, true)
369
+ }
370
+ function end(text, canRestart) {
371
+ over = true
372
+ win.clearTimeout(retryTimer)
373
+ showState('')
374
+ endedText.textContent = text
375
+ restart.hidden = !canRestart
376
+ ended.hidden = false
377
+ composer.hidden = true
378
+ for (const entry of pending.values()) failed(entry)
379
+ }
380
+ function status(m) {
381
+ mode = m.mode === 'human' ? 'human' : 'bot'
382
+ ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
383
+ header()
384
+ if (m.closed) end(s.ended, true)
385
+ }
386
+ function frame(m) {
387
+ switch (m?.type) {
388
+ case 'history':
389
+ for (const x of m.messages ?? []) render(x, { announce: false })
390
+ return status(m)
391
+ case 'message':
392
+ return render(m.msg, { announce: m.msg?.role !== 'visitor' })
393
+ case 'delta':
394
+ return delta(m.id, m.text)
395
+ case 'typing':
396
+ if (m.role === 'visitor') return
397
+ typing.hidden = false
398
+ log.append(typing)
399
+ win.clearTimeout(typingTimer)
400
+ typingTimer = win.setTimeout(hideTyping, TYPING_SHOW_MS)
401
+ return scroll()
402
+ case 'status':
403
+ return status(m)
404
+ case 'fallback':
405
+ return showFallback(m.reason === 'cap' ? s.fallbackCap : s.fallbackDown)
406
+ case 'lead':
407
+ return lead()
408
+ case 'error':
409
+ if (m.code === 'closed' || m.code === 'deleted') end(s.ended, true)
410
+ }
411
+ }
412
+ // Once per conversation: the flag rides in the session, so a reload does not count it again.
413
+ let fired = false
414
+ function lead() {
415
+ if (fired || session?.lead) return
416
+ fired = true
417
+ if (session) { session.lead = true; save() }
418
+ try { win.pmw?.event(GOAL) } catch { /* the beacon never breaks the chat */ }
419
+ }
420
+
421
+ // ---- starting a conversation ------------------------------------------------------------
422
+ async function post(form, extra) {
423
+ let token = ''
424
+ try { token = await turnstile.token(form) } catch { token = '' }
425
+ let res
426
+ let data = null
427
+ try {
428
+ res = await fetchImpl('/api/chat/start', {
429
+ method: 'POST',
430
+ headers: { 'content-type': 'application/json' },
431
+ body: JSON.stringify({ token, lang, page: win.location.pathname, ...extra }),
432
+ })
433
+ data = await res.json()
434
+ } catch {
435
+ res = null
436
+ }
437
+ try { turnstile.reset(form) } catch { /* a token is single-use; the next post asks again */ }
438
+ if (res?.ok && data) return { ok: true, data }
439
+ const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
440
+ return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
441
+ }
442
+ function begin() {
443
+ if (starting || session) return starting
444
+ showState(s.connecting)
445
+ starting = post(composer, {}).then((r) => {
446
+ starting = null
447
+ if (r.ok && r.data.conversationId && r.data.visitorKey) {
448
+ session = { c: r.data.conversationId, k: r.data.visitorKey, botName: typeof r.data.botName === 'string' ? r.data.botName : null }
449
+ save()
450
+ header()
451
+ connect()
452
+ return
453
+ }
454
+ for (const entry of pending.values()) failed(entry)
455
+ if (r.error) return showState(r.error)
456
+ showState('')
457
+ // 409 cap: the visitor's conversations for the day are used up (pmeweb-chat counts them per IP).
458
+ showFallback(r.cap ? s.fallbackCap : s.unavailable)
459
+ })
460
+ return starting
461
+ }
462
+ function showFallback(text) {
463
+ fbIntro.textContent = text
464
+ fallback.hidden = false
465
+ composer.hidden = true
466
+ fallback.querySelector('input')?.focus()
467
+ }
468
+ fallback.addEventListener('submit', async (e) => {
469
+ e.preventDefault()
470
+ const v = (k) => fallback.elements[k].value.trim()
471
+ const fields = { name: v('name'), contact: v('contact'), need: v('need') }
472
+ if (!fields.name || !fields.contact || !fields.need) {
473
+ fbError.textContent = s.fallbackMissing
474
+ return
475
+ }
476
+ fbError.textContent = ''
477
+ const r = await post(fallback, { fallbackLead: fields })
478
+ if (!r.ok) {
479
+ fbError.textContent = r.error ?? s.unavailable
480
+ return
481
+ }
482
+ fallback.replaceChildren(el(doc, 'p', { class: 'pmw-chat__fb-done', role: 'status' }, s.fallbackSent))
483
+ lead()
484
+ })
485
+
486
+ // ---- photos -------------------------------------------------------------------------------
487
+ const PHOTO_ERRORS = { 413: 'photoTooLarge', 415: 'photoUnreadable', 409: 'photoLimit', 429: 'tooMany' }
488
+ async function sendPhoto(file) {
489
+ let blob
490
+ let src
491
+ try {
492
+ blob = await shrink(file)
493
+ src = await preview(blob)
494
+ } catch {
495
+ photoError.textContent = s.photoUnreadable
496
+ return
497
+ }
498
+ const id = uuid()
499
+ const li = line(id, 'visitor')
500
+ li.classList.add('is-pending')
501
+ li.insertBefore(el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt }), li.lastChild)
502
+ setText(li, { role: 'visitor' }, '')
503
+ scroll()
504
+ if (!session) await begin()
505
+ let res = null
506
+ if (session) {
507
+ const q = new URLSearchParams({ c: session.c, k: session.k, id })
508
+ try {
509
+ res = await fetchImpl(`/api/chat/photo?${q}`, { method: 'POST', headers: { 'content-type': 'image/jpeg' }, body: blob })
510
+ } catch {
511
+ res = null
512
+ }
513
+ }
514
+ if (res?.ok) {
515
+ li.classList.remove('is-pending')
516
+ photosSent.add(id)
517
+ if (!ws) connect()
518
+ return
519
+ }
520
+ li.remove()
521
+ photoError.textContent = s[PHOTO_ERRORS[res?.status]] ?? s.photoFailed
522
+ }
523
+ picker?.addEventListener('change', async () => {
524
+ const files = [...(picker.files ?? [])]
525
+ picker.value = ''
526
+ photoError.textContent = ''
527
+ if (over || demo || files.length === 0) return
528
+ const room = MAX_PHOTOS - photosSent.size - photosBusy
529
+ if (files.length > room) photoError.textContent = s.photoLimit
530
+ const take = files.slice(0, Math.max(0, room))
531
+ photosBusy += take.length
532
+ // One at a time: the first may have to start the conversation the others then use.
533
+ for (const f of take) {
534
+ try { await sendPhoto(f) } finally { photosBusy-- }
535
+ }
536
+ })
537
+
538
+ // ---- the composer -----------------------------------------------------------------------
539
+ function submit() {
540
+ const text = input.value.trim()
541
+ if (!text || over || demo) return
542
+ input.value = ''
543
+ const entry = { id: uuid(), text: text.slice(0, MAX_TEXT), tries: 0, failed: false }
544
+ entry.li = line(entry.id, 'visitor')
545
+ entry.li.classList.add('is-pending')
546
+ setText(entry.li, { role: 'visitor' }, entry.text)
547
+ pending.set(entry.id, entry)
548
+ scroll()
549
+ if (!session) begin()
550
+ else if (isOpen()) transmit(entry)
551
+ else connect()
552
+ }
553
+ composer.addEventListener('submit', (e) => { e.preventDefault(); submit() })
554
+ input.addEventListener('keydown', (e) => {
555
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit() }
556
+ })
557
+ input.addEventListener('input', () => {
558
+ const now = Date.now()
559
+ if (isOpen() && now - lastTyping > TYPING_SEND_MS) {
560
+ lastTyping = now
561
+ ws.send(JSON.stringify({ type: 'typing' }))
562
+ }
563
+ })
564
+ restart.addEventListener('click', () => {
565
+ session = null
566
+ try { win.sessionStorage.removeItem(SESSION_KEY) } catch { /* nothing kept */ }
567
+ try { ws?.close(1000) } catch { /* already closed */ }
568
+ ws = null
569
+ over = false
570
+ lastSeq = 0
571
+ seen.clear()
572
+ pending.clear()
573
+ photosSent.clear()
574
+ photoError.textContent = ''
575
+ mode = 'bot'
576
+ ownerName = null
577
+ header()
578
+ for (const li of [...log.querySelectorAll('li[data-id]')]) li.remove()
579
+ ended.hidden = true
580
+ composer.hidden = false
581
+ input.focus()
582
+ })
583
+
584
+ // ---- the dialog -------------------------------------------------------------------------
585
+ const focusables = () => [...panel.querySelectorAll('a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])')]
586
+ .filter((x) => !x.disabled && !x.closest('[hidden]'))
587
+ panel.addEventListener('keydown', (e) => {
588
+ if (e.key === 'Escape') { e.preventDefault(); close(); return }
589
+ if (e.key !== 'Tab') return
590
+ const f = focusables()
591
+ if (f.length === 0) return
592
+ const first = f[0]
593
+ const last = f[f.length - 1]
594
+ if (e.shiftKey && doc.activeElement === first) { e.preventDefault(); last.focus() }
595
+ else if (!e.shiftKey && doc.activeElement === last) { e.preventDefault(); first.focus() }
596
+ })
597
+ closeBtn.addEventListener('click', () => close())
598
+
599
+ function show() {
600
+ if (!demo) turnstile.load()
601
+ panel.hidden = false
602
+ root.setAttribute('data-open', '')
603
+ root.removeAttribute('data-unread')
604
+ if (session) connect()
605
+ ;(demo ? closeBtn : composer.hidden ? (fallback.hidden ? restart : fallback.querySelector('input')) : input)?.focus()
606
+ scroll()
607
+ }
608
+ function close() {
609
+ panel.hidden = true
610
+ root.removeAttribute('data-open')
611
+ bubble?.focus()
612
+ }
613
+
614
+ root.pmwChat = { open: show, close, panel }
615
+ return root.pmwChat
616
+ }
@@ -0,0 +1,62 @@
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
+ /** The conversation-start limit (3 a minute), keyed `chat-start:<ip>`; absent, starts use CHAT_LIMIT. */
29
+ CHAT_START_LIMIT?: { limit(o: { key: string }): Promise<{ success: boolean }> }
30
+ }
31
+
32
+ /**
33
+ * Route every `/api/chat/*` request here, before the assets (`start`, `ws`, and `photo` when the
34
+ * config says `photos: true`). Never throws. Off `hosts` (the site's
35
+ * real hosts, src/data/hosts.ts) every route answers 403 `disabled`.
36
+ */
37
+ export declare function handleChat(
38
+ request: Request,
39
+ env: ChatEnv,
40
+ ctx: { waitUntil(p: Promise<unknown>): void },
41
+ options: { hosts: string[]; config?: ChatConfig | Record<string, unknown>; lang?: 'fr' | 'en' | string }
42
+ ): Promise<Response>
43
+
44
+ /** The largest photo the site forwards (pmeweb-chat refuses bigger ones too). */
45
+ export declare const MAX_PHOTO_BYTES: number
46
+
47
+ /** This package's version. */
48
+ export declare const VERSION: string
49
+ /** The ChatApi interface version this package speaks. */
50
+ export declare const RPC_VERSION: number
51
+
52
+ /**
53
+ * For the site's plugin health route: booleans only. `host` is the site's canonical host
54
+ * (`CANONICAL_HOSTS[0]`), which pmeweb-chat needs to find the client's settings; `config` is
55
+ * `plugins.chat`, switched off it answers `{ disabled: true }` only:
56
+ * `pluginHealth(request, env, { chat: (e) => health(e, { host: CANONICAL_HOSTS[0], config: plugins.chat }) })`.
57
+ */
58
+ export declare function health(env: Partial<ChatEnv>, opts?: { host?: string; config?: ChatConfig | Record<string, unknown> }): Promise<{
59
+ version: string
60
+ rpc: number
61
+ checks: { secret: boolean; binding: boolean; limit: boolean; startLimit: boolean; rpc: boolean; settings: boolean } | { disabled: true }
62
+ }>