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));