✨ feat(webchat): передавать поля сайта через лоадер и виджет

This commit is contained in:
Andrey committed 2026-10-01 12:42:41 +03:00
1 parent b160335bf6
commit 29e2232fc3
13 files changed
+360 -27

No files matched your search

@@ -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-контура задачи.
+11
View File
@@ -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
<script>
window.Chatballs = window.Chatballs || { q: [], setFields: function (fields) { this.q.push(fields); } };
Chatballs.setFields({ name: "Ivan", email: "ivan@example.com" });
</script>
```
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.
+11
View File
@@ -130,6 +130,17 @@ docker compose up -d --wait
Чат откроется в изолированном окне поверх сайта.
Данные посетителя передаются через `Chatballs.setFields()`. Чтобы вызвать его до загрузки асинхронного скрипта, объявите очередь перед тегом подключения:
```html
<script>
window.Chatballs = window.Chatballs || { q: [], setFields: function (fields) { this.q.push(fields); } };
Chatballs.setFields({ name: "Иван", email: "ivan@example.com" });
</script>
```
Каждый вызов обновляет только переданные ключи; `null` очищает значение. Свои поля сначала добавьте в настройках веб-подключения. Виджет объединяет обновления и отправляет их не чаще раза в 500 мс. Значения не используются для авторизации клиента.
### Звонки
Звонки работают сразу после установки. Между браузерами разговор идёт напрямую, а если одна из сторон за строгим NAT или в VPN — через relay, который поднимается вместе со стеком на том же адресе. Настраивать нечего: адреса relay появляются в **Настройки → TURN для звонков** сами, от адреса установки, и меняются только если вы ставите свой сервер.
+2 -2
View File
@@ -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"),
@@ -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));
@@ -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);
});
+13 -2
View File
@@ -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<string | null> {
export type SiteFields = Record<string, string | number | boolean | null>;
export async function startSession(entry: WidgetEntry, hostOrigin: string, fields: SiteFields = {}): Promise<string | null> {
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<boolean> {
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<boolean> {
const r = await fetch(`${API}/messages/`, {
method: "POST",
+116
View File
@@ -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<boolean> = 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<string>((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<boolean>((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: "Иван" });
});
});
+86
View File
@@ -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<typeof setTimeout> | undefined;
private busy = false;
private active = false;
private lastSent = -Infinity;
private retryDelay = INTERVAL_MS;
constructor(private readonly send: (token: string, fields: SiteFields) => Promise<boolean>) {}
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<string | null>): Promise<string | null> {
if (this.busy) return null;
this.busy = true;
const wait = this.lastSent + INTERVAL_MS - Date.now();
if (wait > 0) await new Promise<void>((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);
}
}
+24
View File
@@ -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<number>, ready: RefObject<boolean>, 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]);
}
+6 -22
View File
@@ -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<WebConfig | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [accepted, setAccepted] = useState<boolean>(() => Boolean(localStorage.getItem(TOKEN_KEY)));
const siteFields = useSiteFields(token, HOST_ORIGIN);
const [messages, setMessages] = useState<WebMessage[]>([]);
const [pending, setPending] = useState<string[]>([]);
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);
+23
View File
@@ -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;
}
+1 -1
View File
@@ -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));