diff --git a/.changeset/bright-delays-open.md b/.changeset/bright-delays-open.md new file mode 100644 index 00000000..165640d7 --- /dev/null +++ b/.changeset/bright-delays-open.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +Ignore script-tag auto-open delays outside the supported timer range. diff --git a/.changeset/bright-retries-retry-after.md b/.changeset/bright-retries-retry-after.md new file mode 100644 index 00000000..8347ecce --- /dev/null +++ b/.changeset/bright-retries-retry-after.md @@ -0,0 +1,5 @@ +--- +"@chatcops/server": patch +--- + +Honor HTTP-date values in webhook Retry-After headers. diff --git a/.changeset/calm-cats-validate-enums.md b/.changeset/calm-cats-validate-enums.md new file mode 100644 index 00000000..f3263bf7 --- /dev/null +++ b/.changeset/calm-cats-validate-enums.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +Ignore invalid enum values in widget script-tag attributes. diff --git a/.changeset/strict-consent-attributes.md b/.changeset/strict-consent-attributes.md new file mode 100644 index 00000000..9dd57c89 --- /dev/null +++ b/.changeset/strict-consent-attributes.md @@ -0,0 +1,5 @@ +--- +'@chatcops/widget': patch +--- + +Ignore invalid `data-wait-for-consent` values in script-tag configuration. diff --git a/.changeset/swift-bodies-retry.md b/.changeset/swift-bodies-retry.md new file mode 100644 index 00000000..6636e1a4 --- /dev/null +++ b/.changeset/swift-bodies-retry.md @@ -0,0 +1,5 @@ +--- +'@chatcops/core': patch +--- + +Cancel retryable HTTP response bodies before waiting and retrying. diff --git a/.changeset/tidy-messages-persist.md b/.changeset/tidy-messages-persist.md new file mode 100644 index 00000000..25d2e560 --- /dev/null +++ b/.changeset/tidy-messages-persist.md @@ -0,0 +1,5 @@ +--- +'@chatcops/widget': patch +--- + +Validate `maxMessages` before saving conversation history, and default invalid values to 50. diff --git a/.changeset/webhook-retry-after-numbers.md b/.changeset/webhook-retry-after-numbers.md new file mode 100644 index 00000000..67dd136a --- /dev/null +++ b/.changeset/webhook-retry-after-numbers.md @@ -0,0 +1,5 @@ +--- +'@chatcops/server': patch +--- + +A webhook `Retry-After` of `-1`, or numbers with no day or month name, falls back to the usual backoff again instead of retrying at once. `Date.parse` read them as dates long past. diff --git a/.changeset/webhook-tolerance-validation.md b/.changeset/webhook-tolerance-validation.md new file mode 100644 index 00000000..83137bda --- /dev/null +++ b/.changeset/webhook-tolerance-validation.md @@ -0,0 +1,5 @@ +--- +"@chatcops/server": patch +--- + +`verifyWebhook()` now checks `toleranceSec`: it must be a whole number of seconds, 0 or more, or the call throws a `RangeError`. Before, `NaN` and `Infinity` made the age check never reject, so a stale signature passed. The default of 300 seconds and `0` (the same second only) work as before. diff --git a/.changeset/widget-timer-delay-range.md b/.changeset/widget-timer-delay-range.md new file mode 100644 index 00000000..f2007488 --- /dev/null +++ b/.changeset/widget-timer-delay-range.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +`autoOpen` and `welcomeBubble.delay` passed to `init()` no longer fire at once when the number is out of range. A delay past what `setTimeout` holds (2,147,483,647 ms, about 24.8 days) waits that long, `autoOpen` with `NaN`, a negative number or a non-number doesn't auto-open, and the bubble delay falls back to its default of 3000 ms. The script tag attributes were already checked. diff --git a/.changeset/wise-containers-error.md b/.changeset/wise-containers-error.md new file mode 100644 index 00000000..a2ed4331 --- /dev/null +++ b/.changeset/wise-containers-error.md @@ -0,0 +1,5 @@ +--- +'@chatcops/widget': patch +--- + +Report a clear ChatCops error for invalid inline container selectors. diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls index 42647b8e..8cd6afd4 100644 --- a/.well-known/funding-manifest-urls +++ b/.well-known/funding-manifest-urls @@ -1 +1,2 @@ https://oss.codercops.com/funding.json +https://raw.githubusercontent.com/codercops/.github/main/funding.json diff --git a/packages/cli/package.json b/packages/cli/package.json index e7409411..8a664b4d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/cli", "version": "0.3.0", - "private": true, "description": "The chatcops command: build a knowledge index from your site and search it", "keywords": [ "chatcops", diff --git a/packages/core/src/retrieval/http.ts b/packages/core/src/retrieval/http.ts index 132cf171..620f3b42 100644 --- a/packages/core/src/retrieval/http.ts +++ b/packages/core/src/retrieval/http.ts @@ -33,6 +33,11 @@ export async function postWithRetries( const detail = (await response.text().catch(() => '')).slice(0, 300); throw new Error(`${provider} ${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : ''}`); } + try { + await response.body?.cancel(); + } catch { + // A failed cleanup should not prevent the request from being retried. + } const wait = Number(response.headers.get('retry-after')); await sleep(Number.isFinite(wait) && wait > 0 ? Math.min(wait, 30) * 1000 : 1000 * attempt, signal); } diff --git a/packages/core/tests/retrieval/http.test.ts b/packages/core/tests/retrieval/http.test.ts new file mode 100644 index 00000000..ce9ac59f --- /dev/null +++ b/packages/core/tests/retrieval/http.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { postWithRetries } from '../../src/retrieval/http.js'; + +const post = { url: 'https://api.test/embeddings', headers: {}, body: {} }; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('postWithRetries response bodies', () => { + it('cancels an intermediate retryable response body before retrying', async () => { + vi.useFakeTimers(); + const events: string[] = []; + const cancel = vi.fn(() => { + events.push('cancel'); + }); + const intermediate = new Response(new ReadableStream({ cancel }), { status: 503 }); + const success = new Response('ok'); + const send = vi.fn(async () => { + if (send.mock.calls.length === 1) return intermediate; + events.push('retry'); + return success; + }); + + const pending = postWithRetries('Provider', send, post); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(pending).resolves.toBe(success); + expect(send).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledOnce(); + expect(events).toEqual(['cancel', 'retry']); + }); + + it('returns a successful response with its body available to the caller', async () => { + const success = new Response('success body'); + const send = vi.fn(async () => success); + + const response = await postWithRetries('Provider', send, post); + + expect(response).toBe(success); + expect(response.bodyUsed).toBe(false); + await expect(response.text()).resolves.toBe('success body'); + expect(send).toHaveBeenCalledOnce(); + }); + + it('reads the final retryable response body for the existing error detail', async () => { + vi.useFakeTimers(); + const cancelIntermediate = vi.fn(); + const send = vi + .fn() + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel: cancelIntermediate }), { status: 503 })) + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel: cancelIntermediate }), { status: 503 })) + .mockResolvedValueOnce(new Response('final error detail', { status: 503 })); + + const pending = expect(postWithRetries('Provider', send, post)).rejects.toThrow( + 'Provider embeddings failed with HTTP 503: final error detail' + ); + await vi.advanceTimersByTimeAsync(3_000); + await pending; + + expect(send).toHaveBeenCalledTimes(3); + expect(cancelIntermediate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/ingest/package.json b/packages/ingest/package.json index 2177e9c8..91cfa10a 100644 --- a/packages/ingest/package.json +++ b/packages/ingest/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/ingest", "version": "0.2.0", - "private": true, "description": "Turn a website into a searchable ChatCops knowledge index: discover, fetch, extract, chunk and embed", "keywords": [ "chatcops", diff --git a/packages/server/src/webhook.ts b/packages/server/src/webhook.ts index b934dde9..514aca6c 100644 --- a/packages/server/src/webhook.ts +++ b/packages/server/src/webhook.ts @@ -181,8 +181,14 @@ function validateDeliveryOptions(config: WebhookConfig): void { function parseRetryAfter(value: string | null): number | undefined { if (!value) return undefined; const seconds = Number(value); - if (!Number.isFinite(seconds) || seconds < 0) return undefined; - return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS); + if (Number.isFinite(seconds)) return seconds >= 0 ? Math.min(seconds * 1000, MAX_RETRY_AFTER_MS) : undefined; + // An HTTP-date has day and month names. Without the check, Date.parse reads + // values like "120.5" or "-1 day" as dates long past, and the retry wouldn't wait. + if (!/[a-z]/i.test(value)) return undefined; + + const retryAt = Date.parse(value); + if (Number.isNaN(retryAt)) return undefined; + return Math.min(Math.max(retryAt - Date.now(), 0), MAX_RETRY_AFTER_MS); } async function hmacHex(secret: string, message: string): Promise { @@ -207,7 +213,10 @@ export async function signWebhook(body: string, secret: string, timestampSec = M /** * Checks a delivery on the receiving side. Pass the raw request body exactly * as received (not re-serialized JSON) and the `X-ChatCops-Signature` header. - * Returns false for a bad signature or one older than `toleranceSec`. + * Returns false for a bad signature or one more than `toleranceSec` seconds + * from now, in the past or the future (default 300; 0 allows only the same + * second). Throws a RangeError when `toleranceSec` is not a whole number of + * seconds, 0 or more. */ export async function verifyWebhook(options: { body: string; @@ -217,6 +226,11 @@ export async function verifyWebhook(options: { now?: number; }): Promise { const { body, signature, secret, toleranceSec = DEFAULT_SIGNATURE_TOLERANCE_SEC } = options; + // A setting, not input: a wrong one throws instead of returning false, so it can't pass for a bad signature. + // NaN and Infinity would make the age check below never reject anything. + if (!Number.isSafeInteger(toleranceSec) || toleranceSec < 0) { + throw new RangeError(`verifyWebhook: toleranceSec must be a whole number of seconds, 0 or more (got ${String(toleranceSec)})`); + } if (!signature) return false; const parts = Object.fromEntries( diff --git a/packages/server/tests/webhook-delivery.test.ts b/packages/server/tests/webhook-delivery.test.ts index bd3d6910..2dfced6e 100644 --- a/packages/server/tests/webhook-delivery.test.ts +++ b/packages/server/tests/webhook-delivery.test.ts @@ -169,6 +169,91 @@ describe('webhook retries', () => { expect(calls).toHaveLength(3); }); + it('parses HTTP-date Retry-After values and caps their delay at 10 seconds', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-31T14:00:00.000Z')); + const calls = captureFetch([ + new Response('', { + status: 429, + headers: { 'Retry-After': new Date(Date.now() + 5_000).toUTCString() }, + }), + new Response('', { + status: 429, + headers: { 'Retry-After': new Date(Date.now() + 30_000).toUTCString() }, + }), + new Response('', { status: 200 }), + ]); + const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/date', events: ['*'] }]); + + const done = dispatcher.dispatch('lead:captured', {}); + await vi.advanceTimersByTimeAsync(4_999); + expect(calls).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(calls).toHaveLength(2); + await vi.advanceTimersByTimeAsync(9_999); + expect(calls).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(calls).toHaveLength(3); + }); + + it('waits for a fractional Retry-After in seconds', async () => { + vi.useFakeTimers(); + const calls = captureFetch([ + new Response('', { status: 429, headers: { 'Retry-After': '1.5' } }), + new Response('', { status: 200 }), + ]); + const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/fraction', events: ['*'] }]); + + const done = dispatcher.dispatch('lead:captured', {}); + await vi.advanceTimersByTimeAsync(1_499); + expect(calls).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(calls).toHaveLength(2); + }); + + it('retries immediately for a past HTTP-date Retry-After value', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-31T14:00:00.000Z')); + const calls = captureFetch([ + new Response('', { + status: 429, + headers: { 'Retry-After': 'Sat, 31 Oct 2026 13:59:59 GMT' }, + }), + new Response('', { status: 200 }), + ]); + const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/past-date', events: ['*'] }]); + + const done = dispatcher.dispatch('lead:captured', {}); + await vi.advanceTimersByTimeAsync(0); + await done; + expect(calls).toHaveLength(2); + }); + + it.each([ + ['invalid', 'not a valid HTTP date'], + ['missing', null], + // Both of these are dates long past to Date.parse, which would retry at once. + ['negative', '-1'], + ['numbers with no day or month name', '1 2'], + ])('%s Retry-After uses the exponential fallback', async (_label, retryAfter) => { + vi.useFakeTimers(); + const headers = retryAfter ? { 'Retry-After': retryAfter } : {}; + const calls = captureFetch([ + new Response('', { status: 429, headers }), + new Response('', { status: 200 }), + ]); + const dispatcher = new WebhookDispatcher([{ url: 'https://hooks.test/fallback', events: ['*'] }]); + + const done = dispatcher.dispatch('lead:captured', {}); + await vi.advanceTimersByTimeAsync(999); + expect(calls).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(calls).toHaveLength(2); + }); + it("doesn't retry other client errors, and logs the failure without the body or secret", async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}); const calls = captureFetch([new Response('', { status: 400 })]); diff --git a/packages/server/tests/webhook.test.ts b/packages/server/tests/webhook.test.ts index 06decd2b..cb928e4d 100644 --- a/packages/server/tests/webhook.test.ts +++ b/packages/server/tests/webhook.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { WebhookDispatcher } from '../src/webhook.js'; +import { DEFAULT_SIGNATURE_TOLERANCE_SEC, WebhookDispatcher, signWebhook, verifyWebhook } from '../src/webhook.js'; describe('WebhookDispatcher', () => { beforeEach(() => { @@ -110,3 +110,82 @@ describe('WebhookDispatcher', () => { expect(callCount).toBe(2); }); }); + +describe('verifyWebhook toleranceSec', () => { + const body = JSON.stringify({ id: 'evt_1', type: 'lead:captured', data: { email: 'a@b.test' } }); + const secret = 'shh'; + const nowSec = 1_800_000_000; + const now = nowSec * 1000; + const verify = (toleranceSec: number | undefined, ageSec: number) => + signWebhook(body, secret, nowSec - ageSec).then((signature) => verifyWebhook({ body, signature, secret, now, ...(toleranceSec === undefined ? {} : { toleranceSec }) })); + + it('rejects a tolerance that is not a finite, non-negative whole number, with a RangeError that says which', async () => { + const signature = await signWebhook(body, secret, nowSec); + for (const toleranceSec of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, -300, 0.5, 300.5, 2 ** 53, Number.MAX_VALUE]) { + const call = verifyWebhook({ body, signature, secret, now, toleranceSec }); + await expect(call, String(toleranceSec)).rejects.toBeInstanceOf(RangeError); + await expect(call, String(toleranceSec)).rejects.toThrow('toleranceSec must be a whole number of seconds, 0 or more'); + } + }); + + it('rejects values that are not numbers at all', async () => { + const signature = await signWebhook(body, secret, nowSec); + for (const toleranceSec of ['300', null, {}, true, [], 10n]) { + await expect(verifyWebhook({ body, signature, secret, now, toleranceSec } as never), String(toleranceSec)).rejects.toBeInstanceOf(RangeError); + } + }); + + it('says what it got in the message', async () => { + const signature = await signWebhook(body, secret, nowSec); + await expect(verifyWebhook({ body, signature, secret, now, toleranceSec: Number.NaN })).rejects.toThrow('got NaN'); + await expect(verifyWebhook({ body, signature, secret, now, toleranceSec: -5 })).rejects.toThrow('got -5'); + }); + + it('checks the tolerance before the signature, so a bad setting is loud even for a bad signature or none', async () => { + await expect(verifyWebhook({ body, signature: null, secret, now, toleranceSec: Number.NaN })).rejects.toBeInstanceOf(RangeError); + await expect(verifyWebhook({ body, signature: 'garbage', secret, now, toleranceSec: Number.POSITIVE_INFINITY })).rejects.toBeInstanceOf(RangeError); + }); + + it('keeps the default of 300 seconds', async () => { + expect(DEFAULT_SIGNATURE_TOLERANCE_SEC).toBe(300); + expect(await verify(undefined, 0)).toBe(true); + expect(await verify(undefined, 300)).toBe(true); + expect(await verify(undefined, 301)).toBe(false); + expect(await verify(undefined, -300)).toBe(true); + expect(await verify(undefined, -301)).toBe(false); + }); + + it('allows 0, which takes only the signature made in the same second', async () => { + expect(await verify(0, 0)).toBe(true); + expect(await verify(0, 1)).toBe(false); + expect(await verify(0, -1)).toBe(false); + }); + + it('uses a valid custom tolerance, in both directions', async () => { + expect(await verify(60, 60)).toBe(true); + expect(await verify(60, 61)).toBe(false); + expect(await verify(60, -60)).toBe(true); + expect(await verify(60, -61)).toBe(false); + expect(await verify(3600, 3600)).toBe(true); + expect(await verify(3600, 3601)).toBe(false); + }); + + it('accepts the largest whole number it can count', async () => { + expect(await verify(Number.MAX_SAFE_INTEGER, 1_000_000)).toBe(true); + }); + + it('still rejects expired and future-dated signatures outside the tolerance, and a bad signature inside it', async () => { + expect(await verify(300, 3600)).toBe(false); + expect(await verify(300, -3600)).toBe(false); + const signature = await signWebhook(body, secret, nowSec); + expect(await verifyWebhook({ body, signature, secret: 'other', now, toleranceSec: 300 })).toBe(false); + expect(await verifyWebhook({ body: `${body} `, signature, secret, now, toleranceSec: 300 })).toBe(false); + }); + + it('no longer lets NaN or Infinity switch the freshness check off', async () => { + const stale = await signWebhook(body, secret, nowSec - 86_400); + for (const toleranceSec of [Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(verifyWebhook({ body, signature: stale, secret, now, toleranceSec })).rejects.toBeInstanceOf(RangeError); + } + }); +}); diff --git a/packages/store-cloudflare/package.json b/packages/store-cloudflare/package.json index e98ae91c..2f7f386d 100644 --- a/packages/store-cloudflare/package.json +++ b/packages/store-cloudflare/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/store-cloudflare", "version": "0.3.0", - "private": true, "description": "Cloudflare D1, KV and Durable Objects storage for ChatCops", "keywords": [ "chatcops", diff --git a/packages/store-postgres/package.json b/packages/store-postgres/package.json index f285ff58..27f8eba7 100644 --- a/packages/store-postgres/package.json +++ b/packages/store-postgres/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/store-postgres", "version": "0.3.0", - "private": true, "description": "Postgres storage for ChatCops: conversations, counters, keys and analytics events", "keywords": [ "chatcops", diff --git a/packages/store-redis/package.json b/packages/store-redis/package.json index ce01ca00..293d8a3d 100644 --- a/packages/store-redis/package.json +++ b/packages/store-redis/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/store-redis", "version": "0.3.0", - "private": true, "description": "Redis and Upstash storage for ChatCops", "keywords": [ "chatcops", diff --git a/packages/store-sqlite/package.json b/packages/store-sqlite/package.json index 3d2a3dfc..b4ac0e78 100644 --- a/packages/store-sqlite/package.json +++ b/packages/store-sqlite/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/store-sqlite", "version": "0.3.0", - "private": true, "description": "SQLite storage for ChatCops single-server deployments", "keywords": [ "chatcops", diff --git a/packages/store-upstash-vector/package.json b/packages/store-upstash-vector/package.json index 3199e4e5..c0b76f1a 100644 --- a/packages/store-upstash-vector/package.json +++ b/packages/store-upstash-vector/package.json @@ -1,7 +1,6 @@ { "name": "@chatcops/store-upstash-vector", "version": "0.2.0", - "private": true, "description": "Upstash Vector storage for ChatCops", "keywords": [ "chatcops", diff --git a/packages/widget/README.md b/packages/widget/README.md index a3bd1853..b6f0ebd7 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -101,7 +101,7 @@ ChatCops.init({ // Behavior persistence: 'local', // 'local' | 'session' | 'none', default: 'local' historyTtlDays: 30, // drop saved chats older than this - maxMessages: 50, // max persisted messages + maxMessages: 50, // non-negative safe integer; invalid values default to 50 pageContext: true, // send page URL/title to API autoOpen: 5000, // auto-open after ms (popup only) locale: 'en', @@ -115,6 +115,8 @@ ChatCops.init({ }); ``` +`maxMessages` limits the number of transcript messages saved (the default is `50`). Set it to a positive safe integer to save only the latest messages, or to `0` to save none; the chat remains usable in memory. Negative numbers, fractions, `NaN`, `Infinity`, and other values that are not non-negative safe integers fall back to `50`. + ## API ```text diff --git a/packages/widget/src/dom/bubble.ts b/packages/widget/src/dom/bubble.ts index 153d66ca..7179edac 100644 --- a/packages/widget/src/dom/bubble.ts +++ b/packages/widget/src/dom/bubble.ts @@ -1,4 +1,5 @@ import type { KeyStore } from '../storage.js'; +import { timerDelay } from '../timer-delay.js'; export interface BubbleOptions { text: string; @@ -57,7 +58,7 @@ export class WelcomeBubble { } this.el.classList.add('cc-visible'); this.setHidden(false); - }, options.delay ?? 3000); + }, timerDelay(options.delay) ?? 3000); } hide(): void { diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index 3e19074f..ca146a9f 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -5,6 +5,7 @@ import { applyTheme } from '../theme.js'; import type { WidgetLocaleStrings } from '../i18n.js'; import type { Widget, WidgetConfig, WidgetEventHandler, WidgetEventType, WidgetUpdate } from '../widget.js'; import type { UserToken } from '../api/client.js'; +import { timerDelay } from '../timer-delay.js'; import { VERSION } from '../version.js'; export interface PanelModule { @@ -230,13 +231,12 @@ export function createLoader( return; } showLauncher(current); - if (current.autoOpen !== undefined && current.autoOpen !== false) { - timers.push(setTimeout(() => api.open(), current.autoOpen === true ? 0 : current.autoOpen)); - } + const autoOpen = current.autoOpen === true ? 0 : timerDelay(current.autoOpen); + if (autoOpen !== undefined) timers.push(setTimeout(() => api.open(), autoOpen)); if (current.welcomeBubble) { // The chat loads when the bubble is due, and shows it right away. const bubble = current.welcomeBubble; - timers.push(setTimeout(() => void mount({ welcomeBubble: { ...bubble, delay: 0 } }), bubble.delay ?? DEFAULT_BUBBLE_DELAY)); + timers.push(setTimeout(() => void mount({ welcomeBubble: { ...bubble, delay: 0 } }), timerDelay(bubble.delay) ?? DEFAULT_BUBBLE_DELAY)); } }, diff --git a/packages/widget/src/script-config.ts b/packages/widget/src/script-config.ts index 2b837708..31d35ee3 100644 --- a/packages/widget/src/script-config.ts +++ b/packages/widget/src/script-config.ts @@ -1,19 +1,34 @@ import type { WidgetConfig } from './widget.js'; +import { MAX_TIMER_DELAY_MS } from './timer-delay.js'; + export function parseAutoOpen(value?: string): boolean | number | undefined { if (value === undefined) return undefined; if (value === '' || value === 'true') return true; if (value === 'false') return false; - if (/^\d+$/.test(value)) return Number(value); + if (/^\d+$/.test(value)) { + const delay = Number(value); + if (Number.isSafeInteger(delay) && delay <= MAX_TIMER_DELAY_MS) return delay; + } return undefined; } +function parseOptionalBoolean(value?: string): boolean | undefined { + if (value === undefined) return undefined; + if (value === '' || value === 'true') return true; + if (value === 'false') return false; + console.warn( + `[chatcops] data-wait-for-consent="${value}" isn't true or false, so the chat waits for consent. Use "true" or "false".`, + ); + return true; +} + /** The widget config from a script tag's `data-*` attributes, or `undefined` without `data-api-url`. */ export function configFromScript(script: HTMLScriptElement): WidgetConfig | undefined { const apiUrl = script.dataset.apiUrl; if (!apiUrl) return undefined; - const mode = script.dataset.mode as 'popup' | 'inline' | undefined; + const mode = (['popup', 'inline'] as const).find((value) => value === script.dataset.mode); const container = script.dataset.container; return { @@ -21,17 +36,17 @@ export function configFromScript(script: HTMLScriptElement): WidgetConfig | unde nonce: script.nonce || undefined, turnstileSiteKey: script.dataset.turnstileSiteKey, userToken: script.dataset.userToken, - mode: mode || undefined, + mode, container: container || undefined, theme: { - mode: (script.dataset.themeMode as 'light' | 'dark' | 'auto') || undefined, + mode: (['light', 'dark', 'auto'] as const).find((value) => value === script.dataset.themeMode), accent: script.dataset.accent, accentDark: script.dataset.accentDark, textColor: script.dataset.textColor, bgColor: script.dataset.bgColor, fontFamily: script.dataset.fontFamily, borderRadius: script.dataset.borderRadius, - position: (script.dataset.position as 'bottom-right' | 'bottom-left') || undefined, + position: (['bottom-right', 'bottom-left'] as const).find((value) => value === script.dataset.position), }, branding: { name: script.dataset.brandName, @@ -55,7 +70,7 @@ export function configFromScript(script: HTMLScriptElement): WidgetConfig | unde dir: (['ltr', 'rtl', 'auto'] as const).find((dir) => dir === script.dataset.dir), persistence: (['local', 'session', 'none'] as const).find((mode) => mode === script.dataset.persistence), historyTtlDays: /^\d+$/.test(script.dataset.historyTtlDays ?? '') ? Number(script.dataset.historyTtlDays) : undefined, - waitForConsent: script.dataset.waitForConsent === undefined ? undefined : script.dataset.waitForConsent !== 'false', + waitForConsent: parseOptionalBoolean(script.dataset.waitForConsent), privacyUrl: script.dataset.privacyUrl, }; } diff --git a/packages/widget/src/timer-delay.ts b/packages/widget/src/timer-delay.ts new file mode 100644 index 00000000..0721ce3e --- /dev/null +++ b/packages/widget/src/timer-delay.ts @@ -0,0 +1,11 @@ +/** The longest delay `setTimeout` holds (2^31 - 1 ms, about 24.8 days); a longer one fires at once. */ +export const MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** + * A delay for `setTimeout`: the number itself, the maximum for anything longer + * (a huge delay means "much later", not "now"), and `undefined` for what is not + * a delay: `NaN`, a negative number, or not a number at all. + */ +export function timerDelay(value: unknown): number | undefined { + return typeof value === 'number' && value >= 0 ? Math.min(value, MAX_TIMER_DELAY_MS) : undefined; +} diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 650dd8bd..1f99e656 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -3,6 +3,7 @@ import { FAB } from './dom/fab.js'; import { Panel, type PanelLabels } from './dom/panel.js'; import { BUBBLE_SHOWN_KEY, WelcomeBubble } from './dom/bubble.js'; import { ChatClient, type UserToken } from './api/client.js'; +import { timerDelay } from './timer-delay.js'; import { ConversationStorage, type Persistence } from './storage.js'; import { claimNamespace, type ClaimedNamespace } from './namespace.js'; import { applyTheme, type ThemeConfig } from './theme.js'; @@ -95,6 +96,7 @@ export interface WidgetConfig { persistence?: Persistence; /** Saved conversations older than this are dropped. Default 30 days. */ historyTtlDays?: number; + /** Maximum transcript messages to save. A non-negative safe integer; invalid values default to 50. */ maxMessages?: number; pageContext?: boolean; autoOpen?: boolean | number; @@ -237,7 +239,12 @@ export class Widget { throw new Error('ChatCops: "container" is required when mode is "inline"'); } if (typeof this.config.container === 'string') { - const el = document.querySelector(this.config.container); + let el: Element | null; + try { + el = document.querySelector(this.config.container); + } catch { + throw new Error(`ChatCops: invalid container selector: ${this.config.container}`); + } if (!el) { throw new Error(`ChatCops: container element not found: ${this.config.container}`); } @@ -314,9 +321,9 @@ export class Widget { } // Auto-open — popup mode only - if (!this.isInline && this.config.autoOpen !== undefined && this.config.autoOpen !== false) { - const delay = typeof this.config.autoOpen === 'number' ? this.config.autoOpen : 0; - this.autoOpenTimer = setTimeout(() => this.open(), delay); + const autoOpen = this.config.autoOpen === true ? 0 : timerDelay(this.config.autoOpen); + if (!this.isInline && autoOpen !== undefined) { + this.autoOpenTimer = setTimeout(() => this.open(), autoOpen); } } @@ -1118,8 +1125,14 @@ export class Widget { } private saveHistory(): void { - const maxMessages = this.config.maxMessages ?? 50; - const toSave = this.messages.filter((m) => m.id !== 'welcome').slice(-maxMessages); + const configuredMaxMessages = this.config.maxMessages; + const maxMessages = configuredMaxMessages !== undefined && + Number.isSafeInteger(configuredMaxMessages) && + configuredMaxMessages >= 0 + ? configuredMaxMessages + : 50; + const transcript = this.messages.filter((m) => m.id !== 'welcome'); + const toSave = maxMessages === 0 ? [] : transcript.slice(-maxMessages); this.storage.save({ id: this.conversationId, diff --git a/packages/widget/tests/consent.test.ts b/packages/widget/tests/consent.test.ts index 8b248c05..8b52ef92 100644 --- a/packages/widget/tests/consent.test.ts +++ b/packages/widget/tests/consent.test.ts @@ -458,12 +458,20 @@ describe('data-wait-for-consent', () => { }; it.each([ - [{ waitForConsent: '' }, true], - [{ waitForConsent: 'true' }, true], - [{ waitForConsent: 'false' }, false], - [{}, undefined], - ])('reads %j as %s', (data, expected) => { + [{ waitForConsent: '' }, true, undefined], + [{ waitForConsent: 'true' }, true, undefined], + [{ waitForConsent: 'false' }, false, undefined], + [{ waitForConsent: 'flase' }, true, 'flase'], + [{ waitForConsent: 'yes' }, true, 'yes'], + [{}, undefined, undefined], + ])('reads %j as %s and warns for invalid input', (data, expected, invalidValue) => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); expect(configFromScript(script(data))?.waitForConsent).toBe(expected); + if (invalidValue) { + expect(warning).toHaveBeenCalledWith(expect.stringContaining(`data-wait-for-consent="${invalidValue}"`)); + } else { + expect(warning).not.toHaveBeenCalled(); + } }); }); diff --git a/packages/widget/tests/loader.test.ts b/packages/widget/tests/loader.test.ts index ad611c59..97969740 100644 --- a/packages/widget/tests/loader.test.ts +++ b/packages/widget/tests/loader.test.ts @@ -227,6 +227,88 @@ describe('the script tag loader', () => { loader.destroy(); }); + describe('a delay that setTimeout cannot hold', () => { + const MAX = 2_147_483_647; + const DAY = 86_400_000; + + it.each([3_000_000_000, Number.POSITIVE_INFINITY])('does not run autoOpen at once for %s, and waits for the longest delay there is', async (autoOpen) => { + vi.useFakeTimers(); + const { load } = panel(); + const loader = createLoader(load); + const opened = vi.fn(); + loader.on('open', opened); + loader.init({ apiUrl: 'https://api.test/chat', autoOpen }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(DAY); + expect(opened).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(MAX - DAY); + expect(opened).toHaveBeenCalledTimes(1); + loader.destroy(); + }); + + it.each([Number.NaN, -1, -3000, Number.NEGATIVE_INFINITY, '500', null, false])('never runs autoOpen for %j', async (autoOpen) => { + vi.useFakeTimers(); + const { load } = panel(); + const loader = createLoader(load); + const opened = vi.fn(); + loader.on('open', opened); + loader.init({ apiUrl: 'https://api.test/chat', autoOpen: autoOpen as never }); + + await vi.advanceTimersByTimeAsync(MAX); + expect(opened).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + loader.destroy(); + }); + + it('still runs autoOpen at once for true and for 0', async () => { + vi.useFakeTimers(); + for (const autoOpen of [true, 0]) { + const { load } = panel(); + const loader = createLoader(load); + const opened = vi.fn(); + loader.on('open', opened); + loader.init({ apiUrl: 'https://api.test/chat', autoOpen }); + await vi.advanceTimersByTimeAsync(0); + expect(opened, String(autoOpen)).toHaveBeenCalledTimes(1); + loader.destroy(); + } + }); + + it.each([Number.NaN, -1, -3000, Number.NEGATIVE_INFINITY, '2000', null])( + 'loads the chat for the bubble after the default 3000 ms when its delay is %j', + async (delay) => { + vi.useFakeTimers(); + const { load } = panel(); + const loader = createLoader(load); + loader.init({ apiUrl: 'https://api.test/chat', welcomeBubble: { text: 'Need help?', delay: delay as never } }); + + await vi.advanceTimersByTimeAsync(0); + expect(load).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2998); + expect(load).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2); + expect(load).toHaveBeenCalledTimes(1); + loader.destroy(); + }, + ); + + it.each([3_000_000_000, Number.POSITIVE_INFINITY])('does not load the chat at once for a bubble delay of %s', async (delay) => { + vi.useFakeTimers(); + const { load } = panel(); + const loader = createLoader(load); + loader.init({ apiUrl: 'https://api.test/chat', welcomeBubble: { text: 'Need help?', delay } }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(DAY); + expect(load).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(MAX - DAY); + expect(load).toHaveBeenCalledTimes(1); + loader.destroy(); + }); + }); + it('loads the chat when the welcome bubble is due, and shows the bubble then', async () => { vi.useFakeTimers(); const { load } = panel(); diff --git a/packages/widget/tests/script-config.test.ts b/packages/widget/tests/script-config.test.ts index 57afb520..a7e205db 100644 --- a/packages/widget/tests/script-config.test.ts +++ b/packages/widget/tests/script-config.test.ts @@ -63,12 +63,54 @@ describe('configFromScript', () => { }); }); + it.each([ + ['mode', 'popup'], + ['mode', 'inline'], + ['themeMode', 'light'], + ['themeMode', 'dark'], + ['themeMode', 'auto'], + ['position', 'bottom-right'], + ['position', 'bottom-left'], + ])('accepts documented %s value %s', (attribute, value) => { + const config = configFromScript(script({ apiUrl: '/api/chat', [attribute]: value })); + + if (attribute === 'mode') expect(config?.mode).toBe(value); + if (attribute === 'themeMode') expect(config?.theme?.mode).toBe(value); + if (attribute === 'position') expect(config?.theme?.position).toBe(value); + }); + + it.each([ + ['mode', 'modal'], + ['themeMode', 'system'], + ['position', 'center'], + ])('ignores invalid %s value %s and leaves the documented default unset', (attribute, value) => { + const config = configFromScript(script({ apiUrl: '/api/chat', [attribute]: value })); + + if (attribute === 'mode') expect(config?.mode).toBeUndefined(); + if (attribute === 'themeMode') expect(config?.theme?.mode).toBeUndefined(); + if (attribute === 'position') expect(config?.theme?.position).toBeUndefined(); + }); + it('ignores a persistence mode or history length it does not know', () => { const config = configFromScript(script({ apiUrl: '/api/chat', persistence: 'cookie', historyTtlDays: 'forever' })); expect(config?.persistence).toBeUndefined(); expect(config?.historyTtlDays).toBeUndefined(); }); + it.each([ + ['true', true], + ['false', false], + ['0', 0], + ['2000', 2000], + ['2147483647', 2_147_483_647], + ['2147483648', undefined], + ['999999999999999999999999999999', undefined], + ['1.5', undefined], + ['', true], + ])('parses data-auto-open="%s" as %s', (autoOpen, expected) => { + expect(configFromScript(script({ apiUrl: '/api/chat', autoOpen }))?.autoOpen).toBe(expected); + }); + it.each([ ['3000', 3000], ['3s', undefined], diff --git a/packages/widget/tests/timer-delay.test.ts b/packages/widget/tests/timer-delay.test.ts new file mode 100644 index 00000000..d15293ab --- /dev/null +++ b/packages/widget/tests/timer-delay.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_TIMER_DELAY_MS, timerDelay } from '../src/timer-delay.js'; + +describe('timerDelay', () => { + it('knows the longest delay setTimeout holds', () => { + expect(MAX_TIMER_DELAY_MS).toBe(2_147_483_647); + }); + + it.each([0, 1, 500, 3000, 86_400_000, MAX_TIMER_DELAY_MS])('returns %s as it is', (value) => { + expect(timerDelay(value)).toBe(value); + }); + + it.each([MAX_TIMER_DELAY_MS + 1, 3_000_000_000, Number.MAX_SAFE_INTEGER, Number.MAX_VALUE, Number.POSITIVE_INFINITY])( + 'clamps %s to the maximum, since a delay that long means much later and not now', + (value) => { + expect(timerDelay(value)).toBe(MAX_TIMER_DELAY_MS); + }, + ); + + it.each([Number.NaN, -1, -0.5, -3000, Number.NEGATIVE_INFINITY])('has no delay for %s', (value) => { + expect(timerDelay(value)).toBeUndefined(); + }); + + it.each(['500', '', 'soon', null, undefined, true, false, {}, [], [5], BigInt(10)])('has no delay for %s, which is not a number', (value) => { + expect(timerDelay(value)).toBeUndefined(); + }); + + it('keeps a fraction, which setTimeout rounds down itself', () => { + expect(timerDelay(2.5)).toBe(2.5); + }); + + it('treats -0 as 0', () => { + expect(Object.is(timerDelay(-0), 0) || timerDelay(-0) === 0).toBe(true); + }); +}); diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index b51d240b..b0aa8db6 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -15,6 +15,7 @@ type WidgetInternals = { status?: 'streaming' | 'complete' | 'error'; errorType?: 'rate_limit' | 'network' | 'provider_error' | 'timeout'; }>; + saveHistory(): void; isStreaming: boolean; userData?: Record; showChatView(): void; @@ -83,12 +84,109 @@ describe('Widget', () => { widget.destroy(); }); + it('reports malformed inline container selectors with a ChatCops error', () => { + const widget = new Widget({ + apiUrl: 'https://api.test/chat', + mode: 'inline', + container: '[', + siteId: 'invalid-container-selector', + }); + + expect(() => widget.init()).toThrowError('ChatCops: invalid container selector: ['); + }); + + it('keeps the existing error when an inline container selector has no match', () => { + const widget = new Widget({ + apiUrl: 'https://api.test/chat', + mode: 'inline', + container: '#missing-container', + siteId: 'missing-container-selector', + }); + + expect(() => widget.init()).toThrowError('ChatCops: container element not found: #missing-container'); + }); + + it('resolves a valid inline container selector', () => { + const container = document.createElement('div'); + container.id = 'inline-container'; + document.body.appendChild(container); + const widget = new Widget({ apiUrl: 'https://api.test/chat', mode: 'inline', container: '#inline-container' }); + + widget.init(); + + expect(container.querySelector('.chatcops-root')).not.toBeNull(); + widget.destroy(); + }); + afterEach(() => { document.documentElement.removeAttribute('style'); vi.useRealTimers(); vi.restoreAllMocks(); }); + describe('maxMessages', () => { + const makeMessages = (count: number) => + Array.from({ length: count }, (_, index) => ({ + id: `message-${index}`, + role: index % 2 === 0 ? 'user' as const : 'assistant' as const, + content: `Message ${index}`, + })); + + it('defaults to saving the latest 50 transcript messages', () => { + const widget = new Widget({ apiUrl: '/chat', persistence: 'local' }); + widget.init(); + getInternals(widget).messages = makeMessages(55); + + getInternals(widget).saveHistory(); + + const saved = JSON.parse(localStorage.getItem('chatcops_conversation') ?? '{}'); + expect(saved.messages).toHaveLength(50); + expect(saved.messages[0].id).toBe('message-5'); + widget.destroy(); + }); + + it('saves only the latest messages for a positive safe-integer limit', () => { + const widget = new Widget({ apiUrl: '/chat', persistence: 'local', maxMessages: 2 }); + widget.init(); + getInternals(widget).messages = makeMessages(5); + + getInternals(widget).saveHistory(); + + const saved = JSON.parse(localStorage.getItem('chatcops_conversation') ?? '{}'); + expect(saved.messages.map((message: { id: string }) => message.id)).toEqual(['message-3', 'message-4']); + widget.destroy(); + }); + + it('saves no transcript messages at zero while keeping in-memory chat messages', () => { + const widget = new Widget({ apiUrl: '/chat', persistence: 'local', maxMessages: 0 }); + widget.init(); + getInternals(widget).messages = makeMessages(3); + + getInternals(widget).saveHistory(); + + const saved = JSON.parse(localStorage.getItem('chatcops_conversation') ?? '{}'); + expect(saved.messages).toEqual([]); + expect(getInternals(widget).messages).toHaveLength(3); + widget.destroy(); + }); + + it.each([-1, -50, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'falls back to 50 for invalid maxMessages value %s', + (maxMessages) => { + const widget = new Widget({ apiUrl: '/chat', persistence: 'local', maxMessages }); + widget.init(); + getInternals(widget).messages = makeMessages(55); + + getInternals(widget).saveHistory(); + + const saved = JSON.parse(localStorage.getItem('chatcops_conversation') ?? '{}'); + expect(saved.messages).toHaveLength(50); + expect(saved.messages[0].id).toBe('message-5'); + widget.destroy(); + } + ); + }); + it('moves to the conversation id the server gives with a session token, and saves history under it', async () => { const reply = [ 'data: {"type":"start","protocol":1,"conversationId":"server-id","messageId":"m1"}', @@ -123,6 +221,98 @@ describe('Widget', () => { expect(onOpen).not.toHaveBeenCalled(); }); + describe('auto-open and the bubble delay', () => { + const MAX = 2_147_483_647; + const DAY = 86_400_000; + const start = (config: Record) => { + const onOpen = vi.fn(); + const widget = new Widget({ apiUrl: 'https://api.test/chat', onOpen, ...config }); + widget.init(); + return { widget, onOpen }; + }; + const bubbleOf = (widget: Widget) => getInternals(widget).shadow.querySelector('.cc-bubble') as HTMLElement; + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens at once for true and for 0, and after a valid delay', () => { + for (const [autoOpen, at] of [[true, 0], [0, 0], [1000, 1000], [DAY, DAY], [MAX, MAX]] as const) { + const { widget, onOpen } = start({ autoOpen }); + if (at > 0) { + vi.advanceTimersByTime(at - 1); + expect(onOpen, String(autoOpen)).not.toHaveBeenCalled(); + } + vi.advanceTimersByTime(at === 0 ? 0 : 1); + expect(onOpen, String(autoOpen)).toHaveBeenCalledTimes(1); + widget.destroy(); + } + }); + + it.each([3_000_000_000, Number.MAX_SAFE_INTEGER, Number.POSITIVE_INFINITY])( + 'does not open at once for an autoOpen delay of %s, which is past what setTimeout holds', + (autoOpen) => { + const { widget, onOpen } = start({ autoOpen }); + vi.advanceTimersByTime(0); + vi.advanceTimersByTime(DAY); + expect(onOpen).not.toHaveBeenCalled(); + // It waits for the longest delay there is. + vi.advanceTimersByTime(MAX - DAY); + expect(onOpen).toHaveBeenCalledTimes(1); + widget.destroy(); + }, + ); + + it.each([Number.NaN, -1, -3000, Number.NEGATIVE_INFINITY, '500', null, false])('never auto-opens for autoOpen %j', (autoOpen) => { + const { widget, onOpen } = start({ autoOpen }); + vi.advanceTimersByTime(MAX); + expect(onOpen).not.toHaveBeenCalled(); + widget.destroy(); + }); + + it('shows the bubble after a valid delay, and at once for 0', () => { + for (const delay of [0, 1500]) { + // The bubble shows once per browser, so each one starts clean. + localStorage.clear(); + const { widget } = start({ welcomeBubble: { text: 'Hi', delay } }); + if (delay) { + vi.advanceTimersByTime(delay - 1); + expect(bubbleOf(widget).classList.contains('cc-visible'), String(delay)).toBe(false); + } + vi.advanceTimersByTime(delay ? 1 : 0); + expect(bubbleOf(widget).classList.contains('cc-visible'), String(delay)).toBe(true); + widget.destroy(); + } + }); + + it.each([Number.NaN, -1, -3000, Number.NEGATIVE_INFINITY, '2000', null])( + 'uses the default of 3000 ms when the bubble delay is %j', + (delay) => { + const { widget } = start({ welcomeBubble: { text: 'Hi', delay } }); + vi.advanceTimersByTime(0); + expect(bubbleOf(widget).classList.contains('cc-visible')).toBe(false); + vi.advanceTimersByTime(2998); + expect(bubbleOf(widget).classList.contains('cc-visible')).toBe(false); + vi.advanceTimersByTime(2); + expect(bubbleOf(widget).classList.contains('cc-visible')).toBe(true); + widget.destroy(); + }, + ); + + it.each([3_000_000_000, Number.POSITIVE_INFINITY])('does not show the bubble at once for a delay of %s', (delay) => { + const { widget } = start({ welcomeBubble: { text: 'Hi', delay } }); + vi.advanceTimersByTime(0); + vi.advanceTimersByTime(DAY); + expect(bubbleOf(widget).classList.contains('cc-visible')).toBe(false); + vi.advanceTimersByTime(MAX - DAY); + expect(bubbleOf(widget).classList.contains('cc-visible')).toBe(true); + widget.destroy(); + }); + }); + it.each(['bottom-right', 'bottom-left'] as const)( 'restores the %s launcher state after closing from the panel header', (position) => { diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 9f960f56..b4910bdf 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -110,6 +110,7 @@ export default defineConfig({ items: [ { label: 'Quick Start', slug: 'getting-started/quick-start' }, { label: 'Installation', slug: 'getting-started/installation' }, + { label: 'Index Your Site', slug: 'getting-started/index-your-site' }, { label: 'Upgrading from 0.5', slug: 'getting-started/upgrading' }, { label: 'Upgrading to 1.0', slug: 'getting-started/upgrading-to-1' }, ], diff --git a/website/public/.well-known/funding-manifest-urls b/website/public/.well-known/funding-manifest-urls index 42647b8e..8cd6afd4 100644 --- a/website/public/.well-known/funding-manifest-urls +++ b/website/public/.well-known/funding-manifest-urls @@ -1 +1,2 @@ https://oss.codercops.com/funding.json +https://raw.githubusercontent.com/codercops/.github/main/funding.json diff --git a/website/src/content/docs/core/knowledge-base.mdx b/website/src/content/docs/core/knowledge-base.mdx index f09b61c8..a9a1617f 100644 --- a/website/src/content/docs/core/knowledge-base.mdx +++ b/website/src/content/docs/core/knowledge-base.mdx @@ -112,6 +112,8 @@ class VectorKnowledgeSource implements KnowledgeSource { `@chatcops/ingest` fills a vector store from your site: it reads your sitemap (or a list of URLs, or a folder of Markdown and HTML files), extracts each page's readable content, splits it into chunks along headings, and embeds them. +For a complete walkthrough from choosing a store to connecting a bot, see [Index Your Site](/getting-started/index-your-site/). + ```typescript import { memoryStorage, memoryVectors, openaiEmbedder } from '@chatcops/core'; import { ingest } from '@chatcops/ingest'; diff --git a/website/src/content/docs/getting-started/index-your-site.mdx b/website/src/content/docs/getting-started/index-your-site.mdx new file mode 100644 index 00000000..6f194078 --- /dev/null +++ b/website/src/content/docs/getting-started/index-your-site.mdx @@ -0,0 +1,365 @@ +--- +title: Index Your Site +description: Build a searchable index of your site and give your ChatCops bot page citations. +--- + +Give your bot your own help pages to search. This guide takes you from choosing where to keep those pages to checking the index and connecting it to a bot. The bot can then answer from the pages it finds and cite them. + +## 1. Choose where to keep the index + +The vector store holds the searchable page chunks. Choose one that works in the same place as your server. Each config below uses a local `content` folder to make it easy to try; change it to your content folder, or use your site's sitemap: + +```typescript +const source = { type: 'sitemap', url: 'https://your-site.example/sitemap.xml' }; +``` + +For a directory source, `baseUrl` is the public URL your files correspond to. For example, `content/pricing.md` with `baseUrl: 'https://your-site.example'` is cited as `https://your-site.example/pricing`. + +### Node server with Postgres + +Start with [`@chatcops/store-postgres` setup](https://github.com/codercops/chatcops/tree/dev/packages/store-postgres#readme). It stores both vectors and the manifest (the record of which pages were indexed) in Postgres. Enable pgvector on your database. + +```bash +npm install @chatcops/core @chatcops/cli @chatcops/store-postgres postgres +``` + +Set `DATABASE_URL` and `OPENAI_API_KEY` in your environment. Before the first ingest, enable pgvector and create the tables once: + +```typescript +import { openaiEmbedder } from '@chatcops/core'; +import { fromPostgresJs, migrate, migrateVectors } from '@chatcops/store-postgres'; +import postgres from 'postgres'; + +const sql = postgres(process.env.DATABASE_URL!); +const db = fromPostgresJs(sql); +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); + +await migrate(db); +await migrateVectors(db, { dimensions: embedder.dimensions }); +await sql.end(); +``` + +Now create `chatcops.config.ts`: + +```typescript +import { openaiEmbedder } from '@chatcops/core'; +import { defineConfig } from '@chatcops/cli'; +import { fromPostgresJs, postgresStorage } from '@chatcops/store-postgres'; +import postgres from 'postgres'; + +const db = fromPostgresJs(postgres(process.env.DATABASE_URL!)); +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); + +export default defineConfig({ + embedder, + storage: postgresStorage({ db, dimensions: embedder.dimensions }), + ingest: { + sources: [{ type: 'directory', path: './content', baseUrl: 'https://your-site.example' }], + }, +}); +``` + +### Vercel with Upstash Vector + +Start with [`@chatcops/store-upstash-vector` setup](https://github.com/codercops/chatcops/tree/dev/packages/store-upstash-vector#readme). Create an Upstash **hybrid** index with cosine distance and the same dimension as the embedder. Hybrid search also needs a persistent key-value store for the ingest manifest; this example uses Upstash Redis. + +```bash +npm install @chatcops/core @chatcops/cli @chatcops/store-upstash-vector @chatcops/store-redis @upstash/vector @upstash/redis +``` + +Create `chatcops.config.ts`. Set `UPSTASH_VECTOR_REST_URL`, `UPSTASH_VECTOR_REST_TOKEN`, `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, and `OPENAI_API_KEY` in your environment: + +```typescript +import { openaiEmbedder } from '@chatcops/core'; +import { defineConfig } from '@chatcops/cli'; +import { Redis } from '@upstash/redis'; +import { Index } from '@upstash/vector'; +import { redisStorage } from '@chatcops/store-redis'; +import { upstashVectors } from '@chatcops/store-upstash-vector'; + +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); +const index = new Index({ + url: process.env.UPSTASH_VECTOR_REST_URL!, + token: process.env.UPSTASH_VECTOR_REST_TOKEN!, +}); +const vectors = upstashVectors({ index, dimensions: embedder.dimensions, keywordSearch: true }); + +export default defineConfig({ + embedder, + storage: { ...redisStorage({ redis }), vectors }, + ingest: { + sources: [{ type: 'directory', path: './content', baseUrl: 'https://your-site.example' }], + }, +}); +``` + +### Cloudflare Workers with Vectorize and D1 + +Start with [`@chatcops/store-cloudflare` setup](https://github.com/codercops/chatcops/tree/dev/packages/store-cloudflare#readme). Configure D1 and a Vectorize index in `wrangler.jsonc`; for CLI ingestion, Wrangler's platform proxy supplies those bindings to Node. Set the bindings to remote if you want CLI runs to use the same deployed D1 database and Vectorize index as the Worker. Cloudflare storage includes a D1-backed manifest, so it can be used directly by the CLI. + +```bash +npm install @chatcops/core @chatcops/cli @chatcops/store-cloudflare wrangler +``` + +Create `chatcops.config.ts` in the Worker project. Set `OPENAI_API_KEY` in the environment. The directory source lets you dry-run without fetching your live site: + +```typescript +import { openaiEmbedder } from '@chatcops/core'; +import { defineConfig } from '@chatcops/cli'; +import { cloudflareStorage, type D1Database, type VectorizeIndex } from '@chatcops/store-cloudflare'; +import { getPlatformProxy } from 'wrangler'; + +interface CliBindings { + DB: D1Database; + VECTORIZE: VectorizeIndex; +} + +const proxy = await getPlatformProxy({ configPath: './wrangler.jsonc' }); +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); +const cloudflare = cloudflareStorage({ + d1: proxy.env.DB, + vectorize: proxy.env.VECTORIZE, + vectorDimensions: embedder.dimensions, +}); + +export default defineConfig({ + embedder, + storage: { + ...cloudflare, + close: async () => { + await cloudflare.close?.(); + await proxy.dispose(); + }, + }, + ingest: { + sources: [{ type: 'directory', path: './content', baseUrl: 'https://your-site.example' }], + }, +}); +``` + +### One Node server with SQLite + +Start with [`@chatcops/store-sqlite` setup](https://github.com/codercops/chatcops/tree/dev/packages/store-sqlite#readme). SQLite keeps the vectors and manifest in one local file and is for a single Node server process. It requires Node 22.18 or later to load the TypeScript CLI config. + +```bash +npm install @chatcops/core @chatcops/cli @chatcops/store-sqlite +``` + +Create `chatcops.config.ts`: + +```typescript +import { openaiEmbedder } from '@chatcops/core'; +import { defineConfig } from '@chatcops/cli'; +import { sqliteStorage } from '@chatcops/store-sqlite'; + +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); + +export default defineConfig({ + embedder, + storage: sqliteStorage({ path: './chatcops.db', vectors: { dimensions: embedder.dimensions } }), + ingest: { + sources: [{ type: 'directory', path: './content', baseUrl: 'https://your-site.example' }], + }, +}); +``` + +## 2. Check and build the index + +From the folder with `chatcops.config.ts`, first see what would be found. This dry run discovers, reads, and chunks pages without embedding or writing them: + +```bash +npx chatcops ingest --dry-run +``` + +If the pages look right, build the index: + +```bash +npx chatcops ingest +``` + +Then check the search results before connecting the bot: + +```bash +npx chatcops search "How do I get started?" +``` + +If search finds the wrong page, adjust your sources or exclude patterns in `ingest`, ingest again, and search again. + +## 3. Connect the index to your bot + +The server needs the same vector store and the same embedder you used when ingesting. The embedder turns a visitor's question into a vector that can be compared with the indexed pages. Here's a Node and Postgres example: + +Set `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DATABASE_URL`, and `CHATCOPS_SESSION_SECRET` in the server environment. Generate a session secret with `openssl rand -base64 32`; do not put it in browser code. + +```typescript +import express from 'express'; +import { chatcopsMiddleware } from '@chatcops/server'; +import { hybridRetriever, openaiEmbedder, retrieverKnowledge } from '@chatcops/core'; +import { fromPostgresJs, postgresStorage } from '@chatcops/store-postgres'; +import postgres from 'postgres'; + +const db = fromPostgresJs(postgres(process.env.DATABASE_URL!)); +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); +const storage = postgresStorage({ db, dimensions: embedder.dimensions }); +const store = storage.vectors; + +if (!store) throw new Error('Postgres vector storage is not configured.'); + +const app = express(); +app.use(express.json()); +app.use('/chat', chatcopsMiddleware({ + provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }, + systemPrompt: 'Answer questions about our website using the indexed pages.', + sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], + storage, + knowledge: [retrieverKnowledge(hybridRetriever({ store, embedder }))], +})); + +app.listen(3001); +``` + +For a single Node server using SQLite instead, create its store from the same database file: + +```typescript +import express from 'express'; +import { chatcopsMiddleware } from '@chatcops/server'; +import { hybridRetriever, openaiEmbedder, retrieverKnowledge } from '@chatcops/core'; +import { sqliteStorage } from '@chatcops/store-sqlite'; + +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); +const storage = sqliteStorage({ + path: './chatcops.db', + vectors: { dimensions: embedder.dimensions }, +}); +const store = storage.vectors; + +if (!store) throw new Error('SQLite vector storage is not configured.'); + +const app = express(); +app.use(express.json()); +app.use('/chat', chatcopsMiddleware({ + provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }, + systemPrompt: 'Answer questions about our website using the indexed pages.', + sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], + storage, + knowledge: [retrieverKnowledge(hybridRetriever({ store, embedder }))], +})); + +app.listen(3001); +``` + +Every server handler must set `sessionSecrets`; keep that value secret and out of browser code. The adapter READMEs have more details on [Vercel](https://github.com/codercops/chatcops/tree/dev/packages/store-upstash-vector#readme) and [Cloudflare](https://github.com/codercops/chatcops/tree/dev/packages/store-cloudflare#readme) setup. + +For Vercel, use the same Upstash index and Redis database as the CLI config: + +```typescript +import { chatcopsVercelHandler } from '@chatcops/server'; +import { hybridRetriever, openaiEmbedder, retrieverKnowledge } from '@chatcops/core'; +import { Redis } from '@upstash/redis'; +import { Index } from '@upstash/vector'; +import { redisStorage } from '@chatcops/store-redis'; +import { upstashVectors } from '@chatcops/store-upstash-vector'; + +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); +const index = new Index({ + url: process.env.UPSTASH_VECTOR_REST_URL!, + token: process.env.UPSTASH_VECTOR_REST_TOKEN!, +}); +const vectors = upstashVectors({ index, dimensions: embedder.dimensions, keywordSearch: true }); + +export default chatcopsVercelHandler({ + provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! }, + systemPrompt: 'Answer questions about our website using the indexed pages.', + sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], + storage: { ...redisStorage({ redis }), vectors }, + knowledge: [retrieverKnowledge(hybridRetriever({ store: vectors, embedder }))], +}); +``` + +For a Cloudflare Worker, pass its D1 and Vectorize bindings to the storage adapter. Keep the handler between requests so the same store and embedder are reused: + +```typescript +import { chatcopsCloudflareHandler } from '@chatcops/server'; +import { hybridRetriever, openaiEmbedder, retrieverKnowledge } from '@chatcops/core'; +import { + cloudflareStorage, + type D1Database, + type VectorizeIndex, +} from '@chatcops/store-cloudflare'; + +interface WorkerEnv { + DB: D1Database; + VECTORIZE: VectorizeIndex; + OPENAI_API_KEY: string; + ANTHROPIC_API_KEY: string; + CHATCOPS_SESSION_SECRET: string; +} + +let handler: ReturnType | undefined; + +export default { + async fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext): Promise { + if (!handler) { + const embedder = openaiEmbedder({ apiKey: env.OPENAI_API_KEY }); + const storage = cloudflareStorage({ + d1: env.DB, + vectorize: env.VECTORIZE, + vectorDimensions: embedder.dimensions, + }); + const store = storage.vectors; + + if (!store) throw new Error('Cloudflare vector storage is not configured.'); + + handler = chatcopsCloudflareHandler({ + provider: { type: 'claude', apiKey: env.ANTHROPIC_API_KEY }, + systemPrompt: 'Answer questions about our website using the indexed pages.', + sessionSecrets: [env.CHATCOPS_SESSION_SECRET], + storage, + knowledge: [retrieverKnowledge(hybridRetriever({ store, embedder }))], + }); + } + + return handler(request, env, ctx); + }, +}; +``` + +To keep a small, curated set of answers alongside page search, add it as another knowledge source: + +```typescript +import { FAQKnowledgeSource, hybridRetriever, openaiEmbedder, retrieverKnowledge, type VectorStore } from '@chatcops/core'; + +declare const store: VectorStore; +const embedder = openaiEmbedder({ apiKey: process.env.OPENAI_API_KEY! }); + +const knowledge = [ + new FAQKnowledgeSource([{ question: 'What are your hours?', answer: 'We are open Monday to Friday.' }]), + retrieverKnowledge(hybridRetriever({ store, embedder })), +]; +``` + +## 4. Keep the index fresh + +Run `npx chatcops ingest` again when you deploy new content or on a schedule. The manifest remembers what was indexed, so unchanged pages are skipped and do not need new embeddings. Add `--prune` to remove pages that have disappeared from the source (first use `--dry-run --prune` to preview what would be removed): + +```bash +npx chatcops ingest --prune +``` + +## Moving from Text or FAQ sources + +You can move gradually; you do not have to replace every knowledge source at once. + +- **Keep FAQs that still work well.** `knowledge` accepts several sources, so put your existing `FAQKnowledgeSource` next to `retrieverKnowledge(...)`. The FAQ answers remain available while the retriever finds relevant pages. +- **Move long text into files.** If `TextKnowledgeSource` contains Markdown documentation, move that Markdown into a folder and add a `{ type: 'directory', path: './content', baseUrl: 'https://your-site.example' }` ingest source. The directory source chunks the files and associates them with URLs, so the bot can cite the matching pages. Keep short instructions or context that are not pages in `TextKnowledgeSource`. +- **Migrate a little at a time.** Leave the old source in `knowledge` while you confirm the index returns the right pages, then remove it when you are ready. + +See [Knowledge Base](/core/knowledge-base/) for the full ingest options, supported embedders, and how citations appear in replies. diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index 7465502d..b0a09cd4 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -356,7 +356,7 @@ Each webhook gets a POST with a JSON body for every event it subscribes to: `id` is the same on every retry, so use it to ignore duplicates; it's also in the `X-ChatCops-Event-Id` header. -**Retries.** Each webhook can set `timeoutMs` (per attempt, default `5000`) and `maxAttempts` (1 to 10, default `3`); a bad value fails at startup. Network errors, 429 and 5xx responses are retried (a 429's `Retry-After` is honored, up to 10 seconds); other 4xx responses are final, and redirects aren't followed. Between attempts it waits 1 second, then 2, then 4, doubling each time. A delivery that fails for good is logged with the event type, id and the receiver's origin, never the body. +**Retries.** Each webhook can set `timeoutMs` (per attempt, default `5000`) and `maxAttempts` (1 to 10, default `3`); a bad value fails at startup. Network errors, 429 and 5xx responses are retried; other 4xx responses are final, and redirects aren't followed. A 429's `Retry-After` accepts delay-seconds or an HTTP-date; date delays are measured from receipt and capped at 10 seconds (past dates retry immediately), while missing or invalid values use the existing exponential backoff. Between attempts it waits 1 second, then 2, then 4, doubling each time. A delivery that fails for good is logged with the event type, id and the receiver's origin, never the body. **Verifying the signature.** With a `secret`, each request has an `X-ChatCops-Signature: t=,v1=` header, an HMAC-SHA256 of `.`. Verify it against the raw body, before parsing it: @@ -379,7 +379,7 @@ app.post('/hooks/chatcops', express.text({ type: 'application/json' }), async (r }); ``` -`verifyWebhook` also rejects signatures older than 5 minutes (`toleranceSec` changes that). +`verifyWebhook` also rejects signatures more than 5 minutes (300 seconds) away from now, in the past or the future. `toleranceSec` changes that: it takes a whole number of seconds, `0` or more, where `0` accepts only a signature made in the same second. Any other value (`NaN`, `Infinity`, a negative number, a fraction) throws a `RangeError`, since it would otherwise switch the check off, and a wrong setting should be loud rather than look like a bad signature. **Allowed URLs.** Webhook URLs must use https, and hosts on loopback, private or link-local networks (`localhost`, `10.x`, `192.168.x`, `169.254.169.254` and so on) are refused when the server starts. For a receiver on your own machine during development, set `allowPrivateNetwork: true` on that webhook: diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index 441671da..f97d1132 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -126,7 +126,7 @@ ChatCops.init({ }); ``` -The `container` option accepts a CSS selector string or a direct `HTMLElement` reference. +The `container` option accepts a valid CSS selector string or a direct `HTMLElement` reference. An invalid selector throws a `ChatCops: invalid container selector` error; a valid selector that matches no element throws a `ChatCops: container element not found` error. ### More than one widget on a page @@ -178,7 +178,7 @@ The pre-chat form's answers are saved with the conversation, so they expire with ### Waiting for cookie consent -A site under a strict consent policy can hold the chat back until the visitor accepts functional cookies. Set `waitForConsent` (or `data-wait-for-consent` on the script tag) and the launcher shows as usual, but opening the chat shows a notice instead of the conversation: "Chat is available once you accept functional cookies." While it waits, the widget reads and writes no `localStorage` or `sessionStorage`, not even to check, and it makes no request to your chat server. The welcome bubble stays hidden too, since it remembers that it was shown. +A site under a strict consent policy can hold the chat back until the visitor accepts functional cookies. Set `waitForConsent` (or `data-wait-for-consent="true"` on the script tag) and the launcher shows as usual, but opening the chat shows a notice instead of the conversation: "Chat is available once you accept functional cookies." While it waits, the widget reads and writes no `localStorage` or `sessionStorage`, not even to check, and it makes no request to your chat server. The welcome bubble stays hidden too, since it remembers that it was shown. ```typescript declare const consentBanner: { open(): void }; @@ -308,7 +308,7 @@ interface WidgetConfig { suggestions?: string[]; // Suggested question buttons shown in a new conversation welcomeBubble?: { // Popup mode only text: string; // Text shown on hover bubble - delay?: number; // ms before bubble appears + delay?: number; // ms before bubble appears, 0 to 2147483647 (about 24.8 days), default: 3000. A larger number waits that long; NaN, a negative or a non-number uses the default showOnce?: boolean; // Show only once (default: true): per browser with 'local' persistence, per tab with 'session', every page load with 'none' }; placeholder?: string; // Input placeholder text @@ -318,7 +318,7 @@ interface WidgetConfig { onConsentRequest?: () => void; // The notice's button; leave it out to have no button maxMessages?: number; // Max messages to persist, default: 50 pageContext?: boolean; // Send page URL/title, default: true - autoOpen?: boolean | number; // Popup mode only. true = immediate, number = delay in ms + autoOpen?: boolean | number; // Popup mode only. true = immediate, number = delay in ms, 0 to 2147483647 (about 24.8 days). A larger number waits that long; NaN, a negative or a non-number doesn't auto-open locale?: string; // Sent to the server with each message. Only English strings are built in dir?: 'ltr' | 'rtl' | 'auto'; // Text direction; defaults from locale, then document direction strings?: Partial; // Custom i18n overrides @@ -355,16 +355,16 @@ See the [pre-chat form guide](/widget/pre-chat-form) for field behavior, validat | Attribute | Config Key | Description | |-----------|-----------|-------------| | `data-api-url` | `apiUrl` | API endpoint URL (required) | -| `data-mode` | `mode` | `'popup'` or `'inline'` | +| `data-mode` | `mode` | `'popup'` or `'inline'` (default: `'popup'`; invalid values are ignored) | | `data-container` | `container` | CSS selector for inline container | -| `data-theme-mode` | `theme.mode` | `'light'`, `'dark'` or `'auto'` | +| `data-theme-mode` | `theme.mode` | `'light'`, `'dark'` or `'auto'` (default: `'auto'`; invalid values are ignored) | | `data-accent` | `theme.accent` | Primary accent color | | `data-accent-dark` | `theme.accentDark` | Accent color in dark mode | | `data-text-color` | `theme.textColor` | Primary text color | | `data-bg-color` | `theme.bgColor` | Background color | | `data-font-family` | `theme.fontFamily` | Font family | | `data-border-radius` | `theme.borderRadius` | Border radius. A bare number is px (`12`), or use any CSS length (`1rem`) | -| `data-position` | `theme.position` | `'bottom-right'` or `'bottom-left'` | +| `data-position` | `theme.position` | `'bottom-right'` or `'bottom-left'` (default: `'bottom-right'`; invalid values are ignored) | | `data-brand-name` | `branding.name` | Header title | | `data-brand-avatar` | `branding.avatar` | Avatar image URL | | `data-brand-subtitle` | `branding.subtitle` | Subtitle text | @@ -373,14 +373,14 @@ See the [pre-chat form guide](/widget/pre-chat-form) for field behavior, validat | `data-disclosure-text` | `disclosure.text` | Wording of the AI disclosure | | `data-disclosure-url` | `disclosure.learnMoreUrl` | "Learn more" link in the AI disclosure | | `data-welcome-bubble` | `welcomeBubble.text` | Welcome bubble text | -| `data-welcome-bubble-delay` | `welcomeBubble.delay` | Bubble delay in ms | +| `data-welcome-bubble-delay` | `welcomeBubble.delay` | Bubble delay in ms (0–2147483647) | | `data-placeholder` | `placeholder` | Input placeholder | -| `data-auto-open` | `autoOpen` | Auto-open delay in ms or `true` | +| `data-auto-open` | `autoOpen` | Auto-open delay in ms (0–2147483647) or `true`; invalid values are ignored | | `data-locale` | `locale` | Language code | | `data-dir` | `dir` | Text direction: `'ltr'`, `'rtl'` or `'auto'` | | `data-persistence` | `persistence` | `'local'`, `'session'` or `'none'` | | `data-history-ttl-days` | `historyTtlDays` | Days a saved conversation is kept | -| `data-wait-for-consent` | `waitForConsent` | Wait for `ChatCops.setConsent(true)` before using storage or the API. `"false"` turns it off | +| `data-wait-for-consent` | `waitForConsent` | When present, anything except `"false"` waits for consent. Invalid non-empty values log a warning; `"true"` or an empty value enables it without a warning | | `data-privacy-url` | `privacyUrl` | Privacy policy link in header menu (https: or http: only) | | `data-turnstile-site-key` | `turnstileSiteKey` | Cloudflare Turnstile site key, when the server checks Turnstile | | `data-user-token` | `userToken` | Signed token for a signed-in visitor, when the server has `identity` set |