diff --git a/.skaro/tasks/T-013-chatballs-setfields-v-loadere-i-peredach.md b/.skaro/tasks/T-013-chatballs-setfields-v-loadere-i-peredach.md new file mode 100644 index 0000000..e4f4f32 --- /dev/null +++ b/.skaro/tasks/T-013-chatballs-setfields-v-loadere-i-peredach.md @@ -0,0 +1,32 @@ +--- +id: T-013 +title: Chatballs.setFields в лоадере и передача полей из виджета +milestone: M02 +status: done +depends_on: + - T-010 +order: 5 +spec: "0019" +created: 2026-09-29 +branch: skaro/T-013-chatballs-setfields-v-loadere +--- + +## Цель + +Сайт может вызывать Chatballs.setFields() в любой момент, даже до загрузки скрипта, и значения доходят до сервера (R-5, R-6, R-7). + +## Критерии приёмки + +- [x] window.Chatballs с очередью q и методом setFields; вызовы до загрузки лоадера обрабатываются; window.ChatballsChat продолжает работать +- [x] Лоадер передаёт поля в свой iframe через postMessage({type:"chatballs-set-fields", fields}) с проверкой origin +- [x] Виджет сливает вызовы по ключам, null очищает; отправка не чаще раза в 500 мс +- [x] До старта сессии поля держатся в памяти и уходят вместе с startSession, после — POST /webchat/fields/ +- [x] Сбой отправки не ломает виджет и страницу сайта + +## Заметки + +webchat/loader.py (LOADER_JS), web-chat/src/api.ts, App.tsx. Не раздувать App.tsx — вынести в отдельный модуль/хук. + +## Итог + +Ветка перенесена через git rebase main на b160335b; конфликт loader.py разрешён с сохранением оформления кнопки и модульной структуры из main. Передача полей встроена в loader_assets, дублирующие loader_scripts удалены; итоговый коммит e4f25642, рабочее дерево чистое. После разрешения конфликта прошли сборка, 6 Vitest-тестов передачи полей, 5 Node-тестов лоадера, Ruff, Django check и браузерная проверка реального dev-контура задачи. diff --git a/README.md b/README.md index 47d9b3b..ebe0022 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,17 @@ After creating a web widget, add one tag to your site: The chat opens in an isolated window on top of the site. +Pass visitor data with `Chatballs.setFields()`. To call it before the asynchronous script loads, declare a queue before the widget script tag: + +```html + +``` + +Each call updates only the supplied keys; `null` clears a value. Add custom fields in the web integration settings first. The widget combines updates and sends them at most once every 500 ms. Values are never used to authorize a customer. + ### Calls Calls work right after the installation. Between browsers the conversation goes directly; when one side sits behind strict NAT or on a VPN it goes through the relay, which starts together with the stack on the same address. Nothing to configure: the relay addresses appear in **Settings → TURN for calls** on their own, derived from the installation address, and are only changed if you run your own server. diff --git a/README.ru.md b/README.ru.md index 11ac9c3..2cbf4b3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -130,6 +130,17 @@ docker compose up -d --wait Чат откроется в изолированном окне поверх сайта. +Данные посетителя передаются через `Chatballs.setFields()`. Чтобы вызвать его до загрузки асинхронного скрипта, объявите очередь перед тегом подключения: + +```html + +``` + +Каждый вызов обновляет только переданные ключи; `null` очищает значение. Свои поля сначала добавьте в настройках веб-подключения. Виджет объединяет обновления и отправляет их не чаще раза в 500 мс. Значения не используются для авторизации клиента. + ### Звонки Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер. diff --git a/apps/backend/chatballs/webchat/loader.py b/apps/backend/chatballs/webchat/loader.py index 8a441c0..84cd162 100644 --- a/apps/backend/chatballs/webchat/loader.py +++ b/apps/backend/chatballs/webchat/loader.py @@ -1,4 +1,4 @@ -"""Публичный автономный JS-лоадер: оформление, панель и анимация джина. +"""Публичный автономный JS-лоадер: поля сайта, оформление, панель и анимация джина. Модули собираются в одну IIFE; дополнительных запросов за JS на сайте нет. """ @@ -7,7 +7,7 @@ from pathlib import Path _ASSETS = Path(__file__).with_name("loader_assets") LOADER_JS = (_ASSETS / "runtime.js").read_text(encoding="utf-8") -for _module in ("appearance", "launcher", "genie"): +for _module in ("site_fields", "appearance", "launcher", "genie"): LOADER_JS = LOADER_JS.replace( f"/*__{_module.upper()}__*/", (_ASSETS / f"{_module}.js").read_text(encoding="utf-8"), diff --git a/apps/backend/chatballs/webchat/loader_assets/runtime.js b/apps/backend/chatballs/webchat/loader_assets/runtime.js index f9e7485..e7f556c 100644 --- a/apps/backend/chatballs/webchat/loader_assets/runtime.js +++ b/apps/backend/chatballs/webchat/loader_assets/runtime.js @@ -16,6 +16,8 @@ var api = window.ChatballsChat = window.ChatballsChat || {}; window.ChatballsChat = api; // legacy alias для уже встроенных хостов + /*__SITE_FIELDS__*/ + /*__APPEARANCE__*/ /*__LAUNCHER__*/ @@ -42,6 +44,10 @@ if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return; var d = e.data || {}; if (d.instanceId && d.instanceId !== instanceId) return; + if (d.type === "chatballs-fields-ready") { + fieldsReady = true; + sendFields(); + } if (d.type === "chatballs-chat-close") setOpen(false); if (d.type === "chatballs-chat-layout-request") notifyLayout(); if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded)); diff --git a/apps/backend/chatballs/webchat/loader_assets/site_fields.js b/apps/backend/chatballs/webchat/loader_assets/site_fields.js new file mode 100644 index 0000000..0ffcf4c --- /dev/null +++ b/apps/backend/chatballs/webchat/loader_assets/site_fields.js @@ -0,0 +1,29 @@ + // До загрузки скрипта сайт может объявить: + // window.Chatballs = { q: [], setFields: function (fields) { this.q.push(fields); } }; + var fieldsApi = window.Chatballs = window.Chatballs || {}; + var queuedFields = Array.isArray(fieldsApi.q) ? fieldsApi.q.slice() : []; + var siteFields = Object.create(null), fieldsReady = false; + fieldsApi.q = []; + + function sendFields() { + if (!fieldsReady || !frame || !frame.contentWindow) return; + try { + frame.contentWindow.postMessage({ type: "chatballs-set-fields", fields: siteFields }, origin); + } catch (_) {} // Ошибка данных/окна не должна ломать страницу сайта. + } + + fieldsApi.setFields = function (fields) { + try { + if (!fields || typeof fields !== "object" || Array.isArray(fields)) return; + Object.keys(fields).forEach(function (key) { + var value = fields[key]; + if (value === null || typeof value === "string" || typeof value === "boolean" || + (typeof value === "number" && isFinite(value))) siteFields[key] = value; + }); + sendFields(); + } catch (_) {} + }; + queuedFields.forEach(function (entry) { + // Поддерживаем очередь объектов и обычный stub с q.push(["setFields", fields]). + fieldsApi.setFields(Array.isArray(entry) && entry[0] === "setFields" ? entry[1] : entry); + }); diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index aad950f..4d3f7c0 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -67,16 +67,27 @@ export async function getConfig(entry: WidgetEntry, hostOrigin: string): Promise return r.json(); } -export async function startSession(entry: WidgetEntry, hostOrigin: string): Promise { +export type SiteFields = Record; + +export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}): Promise { const r = await fetch(`${API}/session/`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...entry, hostOrigin }), + body: JSON.stringify({ ...entry, hostOrigin, fields }), }); if (!r.ok) return null; return (await r.json()).token as string; } +export async function sendSiteFields(token: string, fields: SiteFields): Promise { + const r = await fetch(`${API}/fields/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ fields }), + }); + return r.ok; +} + export async function sendMessage(token: string, text: string): Promise { const r = await fetch(`${API}/messages/`, { method: "POST", diff --git a/apps/web-chat/src/siteFields.test.ts b/apps/web-chat/src/siteFields.test.ts new file mode 100644 index 0000000..7a72986 --- /dev/null +++ b/apps/web-chat/src/siteFields.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SiteFields } from "./api"; +import { SiteFieldsSender } from "./siteFields"; + +describe("site fields delivery", () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + function setup(sendResult: () => Promise = async () => true) { + const calls: { token: string; fields: SiteFields; time: number }[] = []; + const sender = new SiteFieldsSender(async (token, fields) => { + calls.push({ token, fields, time: Date.now() }); + return sendResult(); + }); + sender.resume(); + return { sender, calls }; + } + + it("merges in memory before start and sends changes arriving during start", async () => { + const { sender, calls } = setup(); + sender.merge({ name: "Иван", status: "cooking" }); + sender.merge({ status: null, amount: 0, has_order: false }); + await vi.advanceTimersByTimeAsync(1000); + expect(calls).toEqual([]); + let resolve!: (token: string) => void; + let initial: SiteFields = {}; + const start = sender.start((fields) => { + initial = fields; + return new Promise((done) => { resolve = done; }); + }); + expect(initial).toEqual({ name: "Иван", status: null, amount: 0, has_order: false }); + sender.merge({ status: "delivered" }); + resolve("session"); + expect(await start).toBe("session"); + await vi.advanceTimersByTimeAsync(499); + expect(calls).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(calls[0]).toMatchObject({ token: "session", fields: { status: "delivered" } }); + }); + + it("coalesces updates and sends at most once every 500 ms without overlapping", async () => { + let finish!: (ok: boolean) => void; + const { sender, calls } = setup(() => new Promise((done) => { finish = done; })); + sender.setToken("restored"); + sender.merge({ status: "cooking" }); + sender.merge({ status: "on_the_way", name: "Иван" }); + await vi.advanceTimersByTimeAsync(500); + expect(calls[0].fields).toEqual({ status: "on_the_way", name: "Иван" }); + sender.merge({ status: null }); + await vi.advanceTimersByTimeAsync(1000); + expect(calls).toHaveLength(1); + finish(true); + await vi.advanceTimersByTimeAsync(500); + expect(calls[1].fields).toEqual({ status: null }); + expect(calls[1].time - calls[0].time).toBeGreaterThanOrEqual(500); + finish(true); + }); + + it.each(["rejection", "http failure"])("retains latest fields after %s and retries", async (failure) => { + let attempts = 0; + const { sender, calls } = setup(async () => { + if (++attempts === 1) { + if (failure === "rejection") throw new Error("offline"); + return false; + } + return true; + }); + sender.setToken("session"); + sender.merge({ status: "cooking", name: "Иван" }); + await vi.advanceTimersByTimeAsync(500); + sender.merge({ status: "delivered" }); + await vi.advanceTimersByTimeAsync(1000); + expect(calls[1].fields).toEqual({ status: "delivered", name: "Иван" }); + sender.merge({ status: "delivered" }); + await vi.advanceTimersByTimeAsync(500); + expect(calls).toHaveLength(2); + }); + + it("keeps fields after failed start and replays them for a new session", async () => { + const { sender } = setup(); + sender.merge({ name: "Иван" }); + const starts: number[] = []; + expect(await sender.start(async () => { starts.push(Date.now()); throw new Error("offline"); })).toBeNull(); + const retry = sender.start(async (fields) => { + starts.push(Date.now()); + expect(fields).toEqual({ name: "Иван" }); + return "session"; + }); + await vi.advanceTimersByTimeAsync(499); + expect(starts).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(await retry).toBe("session"); + expect(starts[1] - starts[0]).toBe(500); + sender.setToken(null); + const restart = sender.start(async (fields) => { + expect(fields).toEqual({ name: "Иван" }); + return "new-session"; + }); + await vi.advanceTimersByTimeAsync(500); + await restart; + }); + + it("ignores malformed fields and cancels delivery on unmount", async () => { + const { sender, calls } = setup(); + sender.setToken("session"); + sender.merge(null); + sender.merge([]); + sender.merge({ invalid: {}, amount: Infinity, constructor: "value", name: "Иван" }); + sender.pause(); + await vi.advanceTimersByTimeAsync(1000); + expect(calls).toEqual([]); + sender.resume(); + await vi.advanceTimersByTimeAsync(500); + expect(calls[0].fields).toEqual({ constructor: "value", name: "Иван" }); + }); +}); diff --git a/apps/web-chat/src/siteFields.ts b/apps/web-chat/src/siteFields.ts new file mode 100644 index 0000000..3250de9 --- /dev/null +++ b/apps/web-chat/src/siteFields.ts @@ -0,0 +1,86 @@ +import type { SiteFields } from "./api"; + +const INTERVAL_MS = 500; + +/** Поля живут в памяти; запросы сериализованы, включая старт сессии. */ +export class SiteFieldsSender { + private values: SiteFields = {}; + private pending: SiteFields = {}; + private token: string | null = null; + private timer: ReturnType | undefined; + private busy = false; + private active = false; + private lastSent = -Infinity; + private retryDelay = INTERVAL_MS; + + constructor(private readonly send: (token: string, fields: SiteFields) => Promise) {} + + resume() { this.active = true; this.schedule(); } + pause() { this.active = false; clearTimeout(this.timer); this.timer = undefined; } + + setToken(token: string | null) { + if (this.token === token) return; + this.token = token; + if (!token) this.pending = { ...this.values }; + this.schedule(); + } + + merge(fields: unknown) { + if (!fields || typeof fields !== "object" || Array.isArray(fields)) return; + for (const [key, value] of Object.entries(fields)) { + if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) continue; + if (value !== null && typeof value !== "string" && typeof value !== "boolean" + && (typeof value !== "number" || !Number.isFinite(value))) continue; + if (Object.hasOwn(this.values, key) && this.values[key] === value) continue; + this.values[key] = value; + this.pending[key] = value; + } + this.schedule(); + } + + async start(issue: (fields: SiteFields) => Promise): Promise { + if (this.busy) return null; + this.busy = true; + const wait = this.lastSent + INTERVAL_MS - Date.now(); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + const fields = { ...this.values }; + this.pending = {}; + this.lastSent = Date.now(); + try { + const token = await issue(fields); + if (token) this.token = token; + else this.pending = { ...fields, ...this.pending }; + return token; + } catch { + this.pending = { ...fields, ...this.pending }; + return null; + } finally { + this.busy = false; + this.schedule(); + } + } + + private schedule(delay = INTERVAL_MS) { + if (!this.active || !this.token || this.busy || this.timer !== undefined + || !Object.keys(this.pending).length) return; + const wait = Math.max(delay, this.lastSent + INTERVAL_MS - Date.now()); + this.timer = setTimeout(() => { this.timer = undefined; void this.flush(); }, wait); + } + + private async flush() { + if (!this.active || !this.token || this.busy) return; + const token = this.token; + const fields = this.pending; + this.pending = {}; + this.busy = true; + this.lastSent = Date.now(); + let ok = false; + try { ok = await this.send(token, fields); } catch { /* сеть не влияет на чат */ } + if (!ok && this.token === token) { + this.pending = { ...fields, ...this.pending }; + this.retryDelay = Math.min(this.retryDelay * 2, 30_000); + } else this.retryDelay = INTERVAL_MS; + this.busy = false; + this.schedule(this.retryDelay); + } +} diff --git a/apps/web-chat/src/useChatPolling.ts b/apps/web-chat/src/useChatPolling.ts new file mode 100644 index 0000000..5b6c170 --- /dev/null +++ b/apps/web-chat/src/useChatPolling.ts @@ -0,0 +1,24 @@ +import { useEffect, type RefObject } from "react"; +import { poll, SessionExpired, type Poll } from "./api"; + +export function useChatPolling(token: string | null, lastId: RefObject, ready: RefObject, ingest: (data: Poll, notify: boolean) => void, forget: () => void) { + useEffect(() => { + if (!token) return; + let alive = true; + const tick = async () => { + try { + const data = await poll(token, lastId.current); + if (alive) { + ingest(data, ready.current); + ready.current = true; + } + } catch (error) { + if (alive && error instanceof SessionExpired) forget(); + // Сеть или лимит: продолжаем опрашивать. + } + }; + void tick(); + const timer = setInterval(tick, 2500); + return () => { alive = false; clearInterval(timer); }; + }, [token]); +} diff --git a/apps/web-chat/src/useChatSession.ts b/apps/web-chat/src/useChatSession.ts index d42c526..a63aa1d 100644 --- a/apps/web-chat/src/useChatSession.ts +++ b/apps/web-chat/src/useChatSession.ts @@ -5,7 +5,6 @@ import { getConfig, openWebchatCall, poll, - SessionExpired, sendContact, sendMessage, sendFile, @@ -17,6 +16,8 @@ import { type WebMessage, } from "./api"; import { useScrollToLatest } from "./useScrollToLatest"; +import { useSiteFields } from "./useSiteFields"; +import { useChatPolling } from "./useChatPolling"; import { useVoiceRecorder } from "./useVoiceRecorder"; import { useWidgetActivity } from "./widgetActivity"; import { applyWidgetLanguage, t } from "./i18n"; @@ -32,6 +33,7 @@ export function useChatSession() { const [config, setConfig] = useState(null); const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); const [accepted, setAccepted] = useState(() => Boolean(localStorage.getItem(TOKEN_KEY))); + const siteFields = useSiteFields(token, HOST_ORIGIN); const [messages, setMessages] = useState([]); const [pending, setPending] = useState([]); const [state, setState] = useState<"ai" | "operator" | "waiting">("ai"); @@ -93,26 +95,7 @@ export function useChatSession() { setPending([]); } - useEffect(() => { - if (!accepted || !token) return; - let alive = true; - const tick = async () => { - try { - const data = await poll(token, lastId.current); - if (alive) { - ingestPoll(data, pollingReady.current); - pollingReady.current = true; - } - } catch (error) { - // Сессия истекла — иначе виджет молча висел бы с мёртвым токеном. - if (alive && error instanceof SessionExpired) forgetSession(); - /* остальное — сеть или лимит: продолжаем опрашивать */ - } - }; - void tick(); - const timer = setInterval(tick, 2500); - return () => { alive = false; clearInterval(timer); }; - }, [accepted, token]); + useChatPolling(accepted ? token : null, lastId, pollingReady, ingestPoll, forgetSession); useEffect(() => { scrollToLatest(); @@ -133,8 +116,9 @@ export function useChatSession() { } async function accept() { + if (starting) return; setStarting(true); - const nextToken = await startSession(ENTRY, HOST_ORIGIN); + const nextToken = await siteFields.start((fields) => startSession(ENTRY, HOST_ORIGIN, fields)); setStarting(false); if (!nextToken) return; localStorage.setItem(TOKEN_KEY, nextToken); diff --git a/apps/web-chat/src/useSiteFields.ts b/apps/web-chat/src/useSiteFields.ts new file mode 100644 index 0000000..1d7b3be --- /dev/null +++ b/apps/web-chat/src/useSiteFields.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; +import { sendSiteFields } from "./api"; +import { SiteFieldsSender } from "./siteFields"; + +export function useSiteFields(token: string | null, hostOrigin: string) { + const [sender] = useState(() => new SiteFieldsSender(sendSiteFields)); + useEffect(() => { sender.setToken(token); }, [sender, token]); + useEffect(() => { + function receive(event: MessageEvent) { + if (event.source !== window.parent || event.origin !== hostOrigin) return; + if (event.data?.type === "chatballs-set-fields") sender.merge(event.data.fields); + } + sender.resume(); + window.addEventListener("message", receive); + // Подписка уже установлена: лоадер может безопасно отдать накопленные поля. + window.parent.postMessage({ type: "chatballs-fields-ready" }, hostOrigin); + return () => { + window.removeEventListener("message", receive); + sender.pause(); + }; + }, [sender, hostOrigin]); + return sender; +} diff --git a/tests/webchat/loader.test.cjs b/tests/webchat/loader.test.cjs index dad1537..712a17b 100644 --- a/tests/webchat/loader.test.cjs +++ b/tests/webchat/loader.test.cjs @@ -83,7 +83,7 @@ test("genie geometry mirrors across the viewport and ends in the selected launch test("all loader modules assemble into one valid standalone script", () => { let source = readFileSync(join(assets, "runtime.js"), "utf8"); - for (const module of ["appearance", "launcher", "genie"]) { + for (const module of ["site_fields", "appearance", "launcher", "genie"]) { source = source.replace(`/*__${module.toUpperCase()}__*/`, () => readFileSync(join(assets, `${module}.js`), "utf8")); } assert.doesNotThrow(() => new vm.Script(source));