@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/INSTALL.md +130 -0
- package/LICENSE-LUCIDE +19 -0
- package/README.md +131 -2
- package/avatars/illustration-1.webp +0 -0
- package/avatars/illustration-2.webp +0 -0
- package/avatars/illustration-3.webp +0 -0
- package/avatars/illustration-4.webp +0 -0
- package/avatars/illustration-5.webp +0 -0
- package/avatars/photos-1.webp +0 -0
- package/avatars/photos-2.webp +0 -0
- package/avatars/photos-3.webp +0 -0
- package/avatars/photos-4.webp +0 -0
- package/avatars/photos-5.webp +0 -0
- package/avatars/sans-visage-1.svg +4 -0
- package/avatars/sans-visage-2.svg +3 -0
- package/avatars/sans-visage-3.svg +4 -0
- package/avatars/sans-visage-4.svg +4 -0
- package/avatars/sans-visage-5.svg +4 -0
- package/dist/trigger.iife.js +1 -0
- package/dist/widget.js +2 -0
- package/manifest.json +82 -0
- package/package.json +48 -4
- package/privacy/en.json +20 -0
- package/privacy/fr.json +20 -0
- package/src/Chat.astro +110 -0
- package/src/build.d.ts +30 -0
- package/src/build.js +87 -0
- package/src/chat.css +65 -0
- package/src/colors.js +104 -0
- package/src/csp.d.ts +2 -0
- package/src/csp.js +5 -0
- package/src/faces.js +24 -0
- package/src/protocol.js +53 -0
- package/src/protocol.ts +113 -0
- package/src/strings.js +114 -0
- package/src/trigger-entry.js +3 -0
- package/src/trigger.js +78 -0
- package/src/widget.css +108 -0
- package/src/widget.js +616 -0
- package/src/worker.d.ts +62 -0
- package/src/worker.js +273 -0
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
|
+
}
|
package/src/worker.d.ts
ADDED
|
@@ -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
|
+
}>
|