Skip to content

Commit 700e203

Browse files
feat(jivochat,#87): fix rate limit, add 14 methods, 9 event hooks, pageView
Vendor-doc audit fixes for JivoChat: Bug fixes: - Rate limit moved off setContactInfo (not throttled per docs) onto setClientAttributes (vendor's documented 10/hr cap). Bucket size also bumped from 9 to 10 to match the actual limit. identify() is no longer artificially throttled. - shutdown() no longer calls jivo_api.clearHistory(); that's a destructive history wipe, not a logout. clearHistory() is now an explicit method callers opt into. Methods (14): setClientAttributes (rate-limited), setUserToken (called inside identify when verification.userToken is present), setCustomData, setWidgetColor, sendOfflineMessage, showProactiveInvitation, startCall, clearHistory (explicit), plus sync getters chatMode, getUnreadMessagesCount, getUtm, getContactInfo, and async getVisitorNumber. show({start}) accepts the documented deep-link parameter. pageView({path}) is no longer a no-op — it forwards to sendPageTitle(). Event bridge: on(event, handler) gains messageReceived, stateChange, clientStartChat, introduction, accept, callStart, callEnd, resize, widgetDestroy. Existing open/close/messageSent kept. New onUnreadCountChange() listener for parity with other providers. Bumps jivochat bundle budget 6KB → 8KB (raw 7156B; gzip ≈ 1/3). NOTE: track() is still a documented no-op — JivoChat has no client-side event API; the audit confirms there's no documented surface to wire it through without polluting setContactInfo.description. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 733323a commit 700e203

3 files changed

Lines changed: 404 additions & 48 deletions

File tree

scripts/bundle-budget.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const BUDGETS = [
1919
{ glob: /^providers\/tawk\.mjs$/, max: 9216, label: "provider:tawk" },
2020
{ glob: /^providers\/zendesk\.mjs$/, max: 7168, label: "provider:zendesk" },
2121
{ glob: /^providers\/olark\.mjs$/, max: 7168, label: "provider:olark" },
22+
{ glob: /^providers\/jivochat\.mjs$/, max: 8192, label: "provider:jivochat" },
2223
{ glob: /^providers\/.+\.mjs$/, max: 6144, label: "provider" },
2324
{ glob: /^index\.mjs$/, max: 4096, label: "core" },
2425
{ glob: /^facade\.mjs$/, max: 3072, label: "facade" },

src/providers/jivochat.ts

Lines changed: 207 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,32 @@ interface JivoAPI {
1818
phone?: string
1919
description?: string
2020
}): void
21-
open(): void
21+
setUserToken?(token: string): void
22+
setClientAttributes?(attrs: Record<string, unknown>): void
23+
setCustomData?(data: Array<{ title: string; content: string; link?: string }>): void
24+
getContactInfo?(): unknown
25+
setRules?(rules: unknown): void
26+
startCall?(phone: string): void
27+
sendOfflineMessage?(payload: {
28+
name?: string
29+
email?: string
30+
phone?: string
31+
description?: string
32+
message?: string
33+
}): void
34+
showProactiveInvitation?(text: string, departmentId?: string | number): void
35+
setWidgetColor?(color: string, color2?: string): void
36+
chatMode?(): "online" | "offline"
37+
sendPageTitle?(title: string, fromApi?: boolean, url?: string): void
38+
getUnreadMessagesCount?(): number
39+
getUtm?(): Record<string, string> | undefined
40+
getVisitorNumber?(cb: (n: number) => void): void
41+
open(params?: { start?: "chat" | "call" | "menu" }): void
2242
close(): void
2343
clearHistory(): void
44+
// Top-level lifecycle helpers documented under jivo_ namespace.
45+
jivo_destroy?: () => void
46+
jivo_init?: () => void
2447
}
2548

2649
interface JivoWindow {
@@ -29,6 +52,17 @@ interface JivoWindow {
2952
jivo_onOpen?: () => void
3053
jivo_onClose?: () => void
3154
jivo_onMessageSent?: (msg: unknown) => void
55+
jivo_onMessageReceived?: (msg: unknown) => void
56+
jivo_onChangeState?: (state: unknown) => void
57+
jivo_onClientStartChat?: () => void
58+
jivo_onIntroduction?: (data: unknown) => void
59+
jivo_onAccept?: () => void
60+
jivo_onCallStart?: () => void
61+
jivo_onCallEnd?: (result: "ok" | "fail") => void
62+
jivo_onResizeCallback?: (size: unknown) => void
63+
jivo_onWidgetDestroy?: () => void
64+
jivo_destroy?: () => void
65+
jivo_init?: () => void
3266
}
3367

3468
function w(): JivoWindow {
@@ -40,19 +74,35 @@ const store = createIdentityStore()
4074
const lifecycle = createLifecycle()
4175
let readyPromise: Promise<void> | undefined
4276
let readyResolve: (() => void) | undefined
43-
const openListeners = new Set<() => void>()
44-
const closeListeners = new Set<() => void>()
45-
const messageListeners = new Set<(msg: unknown) => void>()
4677

47-
// Token bucket: 9 calls per hour to stay under JivoChat's 10/hr limit.
48-
const RATE_LIMIT_MAX = 9
78+
export type JivoEventName =
79+
| "open"
80+
| "close"
81+
| "messageSent"
82+
| "messageReceived"
83+
| "stateChange"
84+
| "clientStartChat"
85+
| "introduction"
86+
| "accept"
87+
| "callStart"
88+
| "callEnd"
89+
| "resize"
90+
| "widgetDestroy"
91+
92+
const eventListeners = new Map<JivoEventName, Set<(payload?: unknown) => void>>()
93+
const unreadListeners = new Set<(count: number) => void>()
94+
95+
// Token bucket: 10 calls per hour for setClientAttributes (vendor's 10/hr limit
96+
// applies to setClientAttributes; the previous wrapper applied it to
97+
// setContactInfo, which is unthrottled per the docs).
98+
const CLIENT_ATTR_LIMIT = 10
4999
const RATE_WINDOW_MS = 60 * 60 * 1000
50-
let bucket: number[] = []
51-
function takeContactInfoToken(): boolean {
100+
let clientAttrBucket: number[] = []
101+
function takeClientAttrToken(): boolean {
52102
const now = Date.now()
53-
bucket = bucket.filter((ts) => now - ts < RATE_WINDOW_MS)
54-
if (bucket.length >= RATE_LIMIT_MAX) return false
55-
bucket.push(now)
103+
clientAttrBucket = clientAttrBucket.filter((ts) => now - ts < RATE_WINDOW_MS)
104+
if (clientAttrBucket.length >= CLIENT_ATTR_LIMIT) return false
105+
clientAttrBucket.push(now)
56106
return true
57107
}
58108

@@ -76,15 +126,24 @@ export async function load(options: JivoChatLoadOptions): Promise<void> {
76126

77127
// Multi-listener bridge — JivoChat allows only one global callback per event.
78128
w().jivo_onLoadCallback = () => readyResolve?.()
79-
w().jivo_onOpen = () => {
80-
for (const l of openListeners) l()
81-
}
82-
w().jivo_onClose = () => {
83-
for (const l of closeListeners) l()
84-
}
85-
w().jivo_onMessageSent = (msg) => {
86-
for (const l of messageListeners) l(msg)
87-
}
129+
const fanOut =
130+
(event: JivoEventName) =>
131+
(payload?: unknown): void => {
132+
const set = eventListeners.get(event)
133+
if (set) for (const l of set) l(payload)
134+
}
135+
w().jivo_onOpen = fanOut("open")
136+
w().jivo_onClose = fanOut("close")
137+
w().jivo_onMessageSent = fanOut("messageSent")
138+
w().jivo_onMessageReceived = fanOut("messageReceived")
139+
w().jivo_onChangeState = fanOut("stateChange")
140+
w().jivo_onClientStartChat = fanOut("clientStartChat")
141+
w().jivo_onIntroduction = fanOut("introduction")
142+
w().jivo_onAccept = fanOut("accept")
143+
w().jivo_onCallStart = fanOut("callStart")
144+
w().jivo_onCallEnd = fanOut("callEnd") as (result: "ok" | "fail") => void
145+
w().jivo_onResizeCallback = fanOut("resize")
146+
w().jivo_onWidgetDestroy = fanOut("widgetDestroy")
88147

89148
try {
90149
await injectScript({
@@ -116,22 +175,31 @@ export function ready(): Promise<void> {
116175

117176
export function identify(identity: Identity): Promise<void> {
118177
if (!isBrowser()) return Promise.resolve()
119-
if (!takeContactInfoToken()) {
120-
console.warn(
121-
"[ahize/jivochat] setContactInfo throttled (>9 calls/hour); skipped to stay under JivoChat's rate limit.",
122-
)
123-
return Promise.resolve()
124-
}
125178
store.identify(identity)
126179
return queue.enqueue((api) => {
127180
api.setContactInfo({
128181
name: identity.name,
129182
email: identity.email,
130183
phone: identity.phone,
131184
})
185+
if (identity.verification && "userToken" in identity.verification) {
186+
const token = (identity.verification as { userToken?: string }).userToken
187+
if (token) api.setUserToken?.(token)
188+
}
132189
})
133190
}
134191

192+
export function setClientAttributes(attrs: Record<string, unknown>): Promise<void> {
193+
if (!isBrowser()) return Promise.resolve()
194+
if (!takeClientAttrToken()) {
195+
console.warn(
196+
"[ahize/jivochat] setClientAttributes throttled (>10 calls/hour) per JivoChat's documented rate limit.",
197+
)
198+
return Promise.resolve()
199+
}
200+
return queue.enqueue((api) => api.setClientAttributes?.(attrs))
201+
}
202+
135203
export function track<T extends EventMetadata = EventMetadata>(
136204
_event: string,
137205
_metadata?: T,
@@ -140,38 +208,121 @@ export function track<T extends EventMetadata = EventMetadata>(
140208
return Promise.resolve()
141209
}
142210

143-
export function pageView(_info?: { path?: string; locale?: string }): Promise<void> {
144-
return Promise.resolve()
211+
export function pageView(info?: { path?: string; locale?: string }): Promise<void> {
212+
if (!isBrowser()) return Promise.resolve()
213+
return queue.enqueue((api) => {
214+
if (info?.path && api.sendPageTitle) {
215+
api.sendPageTitle(typeof document === "undefined" ? "" : "", true, info.path)
216+
}
217+
})
145218
}
146219

147-
export function show(): Promise<void> {
220+
export function show(params?: { start?: "chat" | "call" | "menu" }): Promise<void> {
148221
if (!isBrowser()) return Promise.resolve()
149-
return queue.enqueue((api) => api.open())
222+
return queue.enqueue((api) => (params ? api.open(params) : api.open()))
150223
}
151224

152225
export function hide(): Promise<void> {
153226
if (!isBrowser()) return Promise.resolve()
154227
return queue.enqueue((api) => api.close())
155228
}
156229

157-
export function on(
158-
event: "open" | "close" | "message",
159-
listener: (payload?: unknown) => void,
160-
): () => void {
161-
const set =
162-
event === "open" ? openListeners : event === "close" ? closeListeners : messageListeners
163-
set.add(listener as () => void)
164-
return () => set.delete(listener as () => void)
230+
export function setCustomData(
231+
data: Array<{ title: string; content: string; link?: string }>,
232+
): Promise<void> {
233+
if (!isBrowser()) return Promise.resolve()
234+
return queue.enqueue((api) => api.setCustomData?.(data))
235+
}
236+
237+
export function startCall(phone: string): Promise<void> {
238+
if (!isBrowser()) return Promise.resolve()
239+
return queue.enqueue((api) => api.startCall?.(phone))
240+
}
241+
242+
export function sendOfflineMessage(payload: {
243+
name?: string
244+
email?: string
245+
phone?: string
246+
description?: string
247+
message?: string
248+
}): Promise<void> {
249+
if (!isBrowser()) return Promise.resolve()
250+
return queue.enqueue((api) => api.sendOfflineMessage?.(payload))
251+
}
252+
253+
export function showProactiveInvitation(
254+
text: string,
255+
departmentId?: string | number,
256+
): Promise<void> {
257+
if (!isBrowser()) return Promise.resolve()
258+
return queue.enqueue((api) => api.showProactiveInvitation?.(text, departmentId))
259+
}
260+
261+
export function setWidgetColor(color: string, color2?: string): Promise<void> {
262+
if (!isBrowser()) return Promise.resolve()
263+
return queue.enqueue((api) => api.setWidgetColor?.(color, color2))
264+
}
265+
266+
export function clearHistory(): Promise<void> {
267+
if (!isBrowser()) return Promise.resolve()
268+
return queue.enqueue((api) => api.clearHistory())
269+
}
270+
271+
export function chatMode(): "online" | "offline" | undefined {
272+
if (!isBrowser()) return undefined
273+
return w().jivo_api?.chatMode?.()
274+
}
275+
276+
export function getUnreadMessagesCount(): number | undefined {
277+
if (!isBrowser()) return undefined
278+
return w().jivo_api?.getUnreadMessagesCount?.()
279+
}
280+
281+
export function getUtm(): Record<string, string> | undefined {
282+
if (!isBrowser()) return undefined
283+
return w().jivo_api?.getUtm?.()
284+
}
285+
286+
export function getContactInfo(): unknown {
287+
if (!isBrowser()) return undefined
288+
return w().jivo_api?.getContactInfo?.()
289+
}
290+
291+
export function getVisitorNumber(): Promise<number | undefined> {
292+
if (!isBrowser()) return Promise.resolve(undefined)
293+
return new Promise((resolve) => {
294+
const api = w().jivo_api
295+
if (!api?.getVisitorNumber) {
296+
resolve(undefined)
297+
return
298+
}
299+
api.getVisitorNumber((n: number) => resolve(n))
300+
})
301+
}
302+
303+
export function on(event: JivoEventName, listener: (payload?: unknown) => void): () => void {
304+
let set = eventListeners.get(event)
305+
if (!set) {
306+
set = new Set()
307+
eventListeners.set(event, set)
308+
}
309+
set.add(listener)
310+
return () => set?.delete(listener)
311+
}
312+
313+
export function onUnreadCountChange(listener: (count: number) => void): () => void {
314+
unreadListeners.add(listener)
315+
return () => unreadListeners.delete(listener)
165316
}
166317

167318
export function shutdown(): Promise<void> {
168319
if (!isBrowser()) return Promise.resolve()
169-
return queue
170-
.enqueue((api) => api.clearHistory())
171-
.then(() => {
172-
store.reset()
173-
lifecycle.transition("shutdown")
174-
})
320+
// Vendor doesn't expose a logout/end-session method; just reset our local
321+
// state and let the snippet keep its session. Use clearHistory() explicitly
322+
// if the caller wants to wipe browser-side history.
323+
store.reset()
324+
lifecycle.transition("shutdown")
325+
return Promise.resolve()
175326
}
176327

177328
export async function destroy(): Promise<void> {
@@ -185,15 +336,23 @@ export async function destroy(): Promise<void> {
185336
"jivo_onOpen",
186337
"jivo_onClose",
187338
"jivo_onMessageSent",
339+
"jivo_onMessageReceived",
340+
"jivo_onChangeState",
341+
"jivo_onClientStartChat",
342+
"jivo_onIntroduction",
343+
"jivo_onAccept",
344+
"jivo_onCallStart",
345+
"jivo_onCallEnd",
346+
"jivo_onResizeCallback",
347+
"jivo_onWidgetDestroy",
188348
]) {
189349
Reflect.deleteProperty(g, k)
190350
}
191351
queue.reset()
192352
store.reset()
193-
openListeners.clear()
194-
closeListeners.clear()
195-
messageListeners.clear()
196-
bucket = []
353+
eventListeners.clear()
354+
unreadListeners.clear()
355+
clientAttrBucket = []
197356
readyPromise = undefined
198357
readyResolve = undefined
199358
lifecycle.clearConfigHash()

0 commit comments

Comments
 (0)