Skip to content

Commit d1ae76c

Browse files
feat(crisp,#80): expose missing $crisp commands, events, and runtime config
Vendor-doc audit fixes for Crisp: Methods (12 new): open, close, toggle (split off from show()), sendMessage, showLocalMessage, markRead, setMessageText, setUserAvatar, setSessionSegments, helpdeskSearch, helpdeskArticleOpen, runTrigger. show() no longer also fires chat:open — visibility and panel state are now independent like the vendor docs describe. Event bridge: new public on(event, handler) wraps 13 documented Crisp events (chatOpened, chatClosed, chatInitiated, messageSent, messageReceived, messageComposeSent/Received, helpdeskQueried, user:email/phone/nickname/ avatar:changed, websiteAvailabilityChanged) with a multi-listener Set pattern. Replaces the two dead-stub listeners (message:received + chat:closed empty bodies) with a real fan-out emitter. Runtime config (CrispLoadOptions): safeMode, cookieDomain, cookieExpire, sessionMerge, lockMaximized, lockFullview — merged into CRISP_RUNTIME_CONFIG before script load. Bumps crisp bundle budget 6KB → 10KB to match chatwoot (raw 9238B; gzip ≈ 1/3). Crisp's command surface is one of the largest among the providers we wrap. is:* / get:* getters left for a follow-up — the queue-array bus pattern makes synchronous reads non-trivial; that work needs interface design. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a5c726b commit d1ae76c

3 files changed

Lines changed: 301 additions & 18 deletions

File tree

scripts/bundle-budget.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const DIST = resolve(process.cwd(), "dist")
1111

1212
const BUDGETS = [
1313
{ glob: /^providers\/chatwoot\.mjs$/, max: 10240, label: "provider:chatwoot" },
14+
{ glob: /^providers\/crisp\.mjs$/, max: 10240, label: "provider:crisp" },
1415
{ glob: /^providers\/intercom\.mjs$/, max: 9216, label: "provider:intercom" },
1516
{ glob: /^providers\/helpscout\.mjs$/, max: 7168, label: "provider:helpscout" },
1617
{ glob: /^providers\/hubspot\.mjs$/, max: 7168, label: "provider:hubspot" },

src/providers/crisp.ts

Lines changed: 150 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,39 @@ function bus(): CrispArray {
3636
const store = createIdentityStore()
3737
const lifecycle = createLifecycle()
3838
const unreadListeners = new Set<(count: number) => void>()
39+
40+
export type CrispEventName =
41+
| "chatOpened"
42+
| "chatClosed"
43+
| "chatInitiated"
44+
| "messageSent"
45+
| "messageReceived"
46+
| "messageComposeSent"
47+
| "messageComposeReceived"
48+
| "helpdeskQueried"
49+
| "userEmailChanged"
50+
| "userPhoneChanged"
51+
| "userNicknameChanged"
52+
| "userAvatarChanged"
53+
| "websiteAvailabilityChanged"
54+
55+
const CRISP_EVENT_MAP: Record<string, CrispEventName> = {
56+
"chat:opened": "chatOpened",
57+
"chat:closed": "chatClosed",
58+
"chat:initiated": "chatInitiated",
59+
"message:sent": "messageSent",
60+
"message:received": "messageReceived",
61+
"message:compose:sent": "messageComposeSent",
62+
"message:compose:received": "messageComposeReceived",
63+
"helpdesk:queried": "helpdeskQueried",
64+
"user:email:changed": "userEmailChanged",
65+
"user:phone:changed": "userPhoneChanged",
66+
"user:nickname:changed": "userNicknameChanged",
67+
"user:avatar:changed": "userAvatarChanged",
68+
"website:availability:changed": "websiteAvailabilityChanged",
69+
}
70+
71+
const eventListeners = new Map<CrispEventName, Set<(payload?: unknown) => void>>()
3972
let readyPromise: Promise<void> | undefined
4073
let readyResolve: (() => void) | undefined
4174

@@ -52,6 +85,18 @@ export interface CrispLoadOptions extends LoadOptions {
5285
websiteId: string
5386
tokenId?: string
5487
locale?: string
88+
/** Suppress SDK errors instead of throwing. */
89+
safeMode?: boolean
90+
/** Restrict the chatbox cookie to this domain (e.g. ".example.com"). */
91+
cookieDomain?: string
92+
/** Custom cookie lifetime in seconds (default: ~6 months). */
93+
cookieExpire?: number
94+
/** Merge sessions across pages/tabs. */
95+
sessionMerge?: boolean
96+
/** Force the chatbox to stay maximized. */
97+
lockMaximized?: boolean
98+
/** Force the chatbox into fullview mode. */
99+
lockFullview?: boolean
55100
}
56101

57102
export async function load(options: CrispLoadOptions): Promise<void> {
@@ -70,9 +115,15 @@ export async function load(options: CrispLoadOptions): Promise<void> {
70115
bus()
71116
w().CRISP_WEBSITE_ID = options.websiteId
72117
if (options.tokenId) w().CRISP_TOKEN_ID = options.tokenId
73-
if (options.locale) {
74-
w().CRISP_RUNTIME_CONFIG = { ...w().CRISP_RUNTIME_CONFIG, locale: options.locale }
75-
}
118+
const runtime: Record<string, unknown> = { ...w().CRISP_RUNTIME_CONFIG }
119+
if (options.locale !== undefined) runtime["locale"] = options.locale
120+
if (options.safeMode !== undefined) runtime["safeMode"] = options.safeMode
121+
if (options.cookieDomain !== undefined) runtime["cookieDomain"] = options.cookieDomain
122+
if (options.cookieExpire !== undefined) runtime["cookieExpire"] = options.cookieExpire
123+
if (options.sessionMerge !== undefined) runtime["sessionMerge"] = options.sessionMerge
124+
if (options.lockMaximized !== undefined) runtime["lockMaximized"] = options.lockMaximized
125+
if (options.lockFullview !== undefined) runtime["lockFullview"] = options.lockFullview
126+
if (Object.keys(runtime).length > 0) w().CRISP_RUNTIME_CONFIG = runtime
76127
try {
77128
await injectScript({
78129
id: "ahize-crisp",
@@ -92,20 +143,18 @@ export async function load(options: CrispLoadOptions): Promise<void> {
92143
readyResolve?.()
93144
},
94145
])
95-
bus().push([
96-
"on",
97-
"message:received",
98-
() => {
99-
// unread counter triggers via message:received/chat:opened - approximation
100-
},
101-
])
102-
bus().push([
103-
"on",
104-
"chat:closed",
105-
() => {
106-
// leave hooks for facade wrappers
107-
},
108-
])
146+
// Wire every documented Crisp lifecycle event to the typed emitter.
147+
for (const [domName, mapped] of Object.entries(CRISP_EVENT_MAP)) {
148+
bus().push([
149+
"on",
150+
domName,
151+
(payload: unknown) => {
152+
const set = eventListeners.get(mapped)
153+
if (!set) return
154+
for (const l of set) l(payload)
155+
},
156+
])
157+
}
109158
lifecycle.transition("ready")
110159
}
111160

@@ -202,8 +251,8 @@ export function track<T extends EventMetadata = EventMetadata>(
202251

203252
export function show(): Promise<void> {
204253
if (!isBrowser()) return Promise.resolve()
254+
// Reveal the chat without forcibly expanding the panel.
205255
bus().push(["do", "chat:show"])
206-
bus().push(["do", "chat:open"])
207256
return Promise.resolve()
208257
}
209258

@@ -213,6 +262,88 @@ export function hide(): Promise<void> {
213262
return Promise.resolve()
214263
}
215264

265+
export function open(): Promise<void> {
266+
if (!isBrowser()) return Promise.resolve()
267+
bus().push(["do", "chat:open"])
268+
return Promise.resolve()
269+
}
270+
271+
export function close(): Promise<void> {
272+
if (!isBrowser()) return Promise.resolve()
273+
bus().push(["do", "chat:close"])
274+
return Promise.resolve()
275+
}
276+
277+
export function toggle(): Promise<void> {
278+
if (!isBrowser()) return Promise.resolve()
279+
bus().push(["do", "chat:toggle"])
280+
return Promise.resolve()
281+
}
282+
283+
export function sendMessage(text: string): Promise<void> {
284+
if (!isBrowser()) return Promise.resolve()
285+
bus().push(["do", "message:send", ["text", text]])
286+
return Promise.resolve()
287+
}
288+
289+
export function showLocalMessage(text: string): Promise<void> {
290+
if (!isBrowser()) return Promise.resolve()
291+
bus().push(["do", "message:show", ["text", text]])
292+
return Promise.resolve()
293+
}
294+
295+
export function markRead(): Promise<void> {
296+
if (!isBrowser()) return Promise.resolve()
297+
bus().push(["do", "message:read"])
298+
return Promise.resolve()
299+
}
300+
301+
export function setMessageText(text: string): Promise<void> {
302+
if (!isBrowser()) return Promise.resolve()
303+
bus().push(["set", "message:text", [text]])
304+
return Promise.resolve()
305+
}
306+
307+
export function setUserAvatar(url: string): Promise<void> {
308+
if (!isBrowser()) return Promise.resolve()
309+
bus().push(["set", "user:avatar", [url]])
310+
return Promise.resolve()
311+
}
312+
313+
export function setSessionSegments(segments: string[], overwrite = false): Promise<void> {
314+
if (!isBrowser()) return Promise.resolve()
315+
bus().push(["set", "session:segments", [segments, overwrite]])
316+
return Promise.resolve()
317+
}
318+
319+
export function helpdeskSearch(query: string): Promise<void> {
320+
if (!isBrowser()) return Promise.resolve()
321+
bus().push(["do", "helpdesk:search", [query]])
322+
return Promise.resolve()
323+
}
324+
325+
export function helpdeskArticleOpen(locale: string, slug: string): Promise<void> {
326+
if (!isBrowser()) return Promise.resolve()
327+
bus().push(["do", "helpdesk:article:open", [locale, slug]])
328+
return Promise.resolve()
329+
}
330+
331+
export function runTrigger(triggerId: string): Promise<void> {
332+
if (!isBrowser()) return Promise.resolve()
333+
bus().push(["do", "trigger:run", [triggerId]])
334+
return Promise.resolve()
335+
}
336+
337+
export function on(event: CrispEventName, listener: (payload?: unknown) => void): () => void {
338+
let set = eventListeners.get(event)
339+
if (!set) {
340+
set = new Set()
341+
eventListeners.set(event, set)
342+
}
343+
set.add(listener)
344+
return () => set?.delete(listener)
345+
}
346+
216347
export function shutdown(): Promise<void> {
217348
if (!isBrowser()) return Promise.resolve()
218349
bus().push(["do", "session:reset"])
@@ -232,6 +363,7 @@ export async function destroy(): Promise<void> {
232363
Reflect.deleteProperty(g, "CRISP_RUNTIME_CONFIG")
233364
store.reset()
234365
unreadListeners.clear()
366+
eventListeners.clear()
235367
readyPromise = undefined
236368
readyResolve = undefined
237369
lifecycle.clearConfigHash()

test/crisp.browser.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// @vitest-environment jsdom
2+
import { beforeEach, describe, expect, it, vi } from "vitest"
3+
4+
interface CrispBus extends Array<unknown[]> {
5+
push(cmd: unknown[]): number
6+
}
7+
8+
async function bootCrisp(
9+
options?: Partial<import("../src/providers/crisp.ts").CrispLoadOptions>,
10+
): Promise<{
11+
crisp: typeof import("../src/providers/crisp.ts")
12+
bus: CrispBus
13+
fire: (event: string, payload?: unknown) => void
14+
}> {
15+
const crisp = await import("../src/providers/crisp.ts")
16+
const loadPromise = crisp.load({ websiteId: "wid_xyz", ...options })
17+
await new Promise((r) => setTimeout(r, 0))
18+
const script = document.getElementById("ahize-crisp") as HTMLScriptElement
19+
expect(script).toBeTruthy()
20+
script.dispatchEvent(new Event("load"))
21+
await loadPromise
22+
23+
// biome-ignore lint/suspicious/noExplicitAny: test shim
24+
const bus = (globalThis as any).$crisp as CrispBus
25+
26+
// Fire helper: call the registered handler for a given event name.
27+
const fire = (event: string, payload?: unknown) => {
28+
for (const cmd of bus) {
29+
if (cmd[0] === "on" && cmd[1] === event && typeof cmd[2] === "function") {
30+
;(cmd[2] as (p?: unknown) => void)(payload)
31+
}
32+
}
33+
}
34+
35+
return { crisp, bus, fire }
36+
}
37+
38+
describe("crisp (browser) — vendor-doc audit fixes (#80)", () => {
39+
beforeEach(() => {
40+
vi.resetModules()
41+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
42+
delete (globalThis as any).$crisp
43+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
44+
delete (globalThis as any).CRISP_WEBSITE_ID
45+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
46+
delete (globalThis as any).CRISP_TOKEN_ID
47+
// biome-ignore lint/suspicious/noExplicitAny: test cleanup
48+
delete (globalThis as any).CRISP_RUNTIME_CONFIG
49+
const scripts = document.querySelectorAll("script")
50+
for (let i = 0; i < scripts.length; i++) {
51+
;(scripts[i] as { remove(): void } | undefined)?.remove()
52+
}
53+
})
54+
55+
it("runtime config keys (safeMode/cookieDomain/etc) populate CRISP_RUNTIME_CONFIG", async () => {
56+
const { crisp } = await bootCrisp({
57+
locale: "tr",
58+
safeMode: true,
59+
cookieDomain: ".example.com",
60+
cookieExpire: 3600,
61+
sessionMerge: true,
62+
lockMaximized: true,
63+
lockFullview: false,
64+
})
65+
// biome-ignore lint/suspicious/noExplicitAny: test inspect
66+
const cfg = (globalThis as any).CRISP_RUNTIME_CONFIG as Record<string, unknown>
67+
expect(cfg).toMatchObject({
68+
locale: "tr",
69+
safeMode: true,
70+
cookieDomain: ".example.com",
71+
cookieExpire: 3600,
72+
sessionMerge: true,
73+
lockMaximized: true,
74+
lockFullview: false,
75+
})
76+
await crisp.destroy()
77+
})
78+
79+
it("show() no longer force-opens; open()/close()/toggle() are distinct", async () => {
80+
const { crisp, bus } = await bootCrisp()
81+
const before = bus.length
82+
await crisp.show()
83+
await crisp.open()
84+
await crisp.close()
85+
await crisp.toggle()
86+
const cmds = bus
87+
.slice(before)
88+
.filter((c) => c[0] === "do")
89+
.map((c) => c[1])
90+
expect(cmds).toEqual(["chat:show", "chat:open", "chat:close", "chat:toggle"])
91+
await crisp.destroy()
92+
})
93+
94+
it("sendMessage / showLocalMessage / markRead / setMessageText push the right commands", async () => {
95+
const { crisp, bus } = await bootCrisp()
96+
const before = bus.length
97+
await crisp.sendMessage("merhaba")
98+
await crisp.showLocalMessage("agent here")
99+
await crisp.markRead()
100+
await crisp.setMessageText("draft text")
101+
const after = bus.slice(before)
102+
expect(after).toContainEqual(["do", "message:send", ["text", "merhaba"]])
103+
expect(after).toContainEqual(["do", "message:show", ["text", "agent here"]])
104+
expect(after).toContainEqual(["do", "message:read"])
105+
expect(after).toContainEqual(["set", "message:text", ["draft text"]])
106+
await crisp.destroy()
107+
})
108+
109+
it("setUserAvatar / setSessionSegments / helpdesk* / runTrigger forward correctly", async () => {
110+
const { crisp, bus } = await bootCrisp()
111+
const before = bus.length
112+
await crisp.setUserAvatar("https://x/a.png")
113+
await crisp.setSessionSegments(["beta", "vip"], true)
114+
await crisp.helpdeskSearch("refund")
115+
await crisp.helpdeskArticleOpen("en", "billing")
116+
await crisp.runTrigger("trigger_1")
117+
const after = bus.slice(before)
118+
expect(after).toContainEqual(["set", "user:avatar", ["https://x/a.png"]])
119+
expect(after).toContainEqual(["set", "session:segments", [["beta", "vip"], true]])
120+
expect(after).toContainEqual(["do", "helpdesk:search", ["refund"]])
121+
expect(after).toContainEqual(["do", "helpdesk:article:open", ["en", "billing"]])
122+
expect(after).toContainEqual(["do", "trigger:run", ["trigger_1"]])
123+
await crisp.destroy()
124+
})
125+
126+
it("on(event) bridges chatOpened/chatInitiated/messageSent/etc", async () => {
127+
const { crisp, fire } = await bootCrisp()
128+
let openedHits = 0
129+
let lastSent: unknown
130+
let availability: unknown
131+
crisp.on("chatOpened", () => {
132+
openedHits++
133+
})
134+
crisp.on("messageSent", (p) => {
135+
lastSent = p
136+
})
137+
crisp.on("websiteAvailabilityChanged", (p) => {
138+
availability = p
139+
})
140+
141+
fire("chat:opened")
142+
fire("message:sent", { content: "hi" })
143+
fire("website:availability:changed", { status: "online" })
144+
145+
expect(openedHits).toBe(1)
146+
expect(lastSent).toEqual({ content: "hi" })
147+
expect(availability).toEqual({ status: "online" })
148+
await crisp.destroy()
149+
})
150+
})

0 commit comments

Comments
 (0)