Files
chatballs/apps/web-chat/src/api.ts
T
AndreyandClaude Opus 5 6c3dec7339 🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.

Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.

Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.

Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.

Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.

gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:36:52 +03:00

191 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t } from "./i18n";
const API = "/api/v1/webchat";
export type WidgetFeatures = { voiceMessages: boolean; audioCalls: boolean; videoCalls: boolean };
export type WebConfig = {
// Язык обвязки виджета: язык организации, на котором отвечают агент и
// оператор. Пусто — язык решает браузер посетителя.
language?: string;
features?: WidgetFeatures;
available: boolean;
reason?: string;
widgetKey?: string;
title?: string;
accent?: string;
greeting?: string;
consent?: { text: string; version: string };
quickReplies?: string[];
fallback?: { label: string; url: string }[];
};
// kind: "" — текст, "contact_request" — виджет рисует форму телефона,
// "contact" — клиент поделился номером, "VOICE" — голосовое (см. hasAudio).
export type WebAttachment = { name: string; contentType: string; size: number; available: boolean };
export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string; durationSeconds?: number; hasAudio?: boolean; attachment?: WebAttachment };
// Приглашение/состояние онлайн-звонка (SPEC-CHATBALLS-0013).
export type CallKind = "AUDIO" | "VIDEO";
export type CallInfo = {
callId: string;
status: string;
kind: CallKind;
expiresAt?: string;
staffName?: string;
endedBy?: string | null;
durationSeconds?: number | null;
};
export function callKindOf(call: CallInfo | null | undefined): CallKind | null {
return call?.kind === "AUDIO" || call?.kind === "VIDEO" ? call.kind : null;
}
export const isVideoCall = (call: CallInfo | null | undefined): boolean => callKindOf(call) === "VIDEO";
export type CallBootstrap = { call: CallInfo; accessToken: string; iceServers: RTCIceServer[] };
export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] };
export type Poll = { state: "ai" | "operator" | "waiting"; lifecycle: string; messages: WebMessage[]; call?: CallInfo | null };
export type WidgetEntry = { widgetKey?: string; channel?: string };
function entryQuery(entry: WidgetEntry): string {
if (entry.widgetKey) return `widgetKey=${encodeURIComponent(entry.widgetKey)}`;
return `channel=${encodeURIComponent(entry.channel ?? "")}`;
}
export async function getConfig(entry: WidgetEntry, hostOrigin: string): Promise<WebConfig> {
const r = await fetch(`${API}/config/?${entryQuery(entry)}&hostOrigin=${encodeURIComponent(hostOrigin)}`);
return r.json();
}
export async function startSession(entry: WidgetEntry, hostOrigin: string): Promise<string | null> {
const r = await fetch(`${API}/session/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...entry, hostOrigin }),
});
if (!r.ok) return null;
return (await r.json()).token as string;
}
export async function sendMessage(token: string, text: string): Promise<boolean> {
const r = await fetch(`${API}/messages/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ text }),
});
return r.ok;
}
export async function sendVoice(token: string, audio: Blob, durationSeconds: number): Promise<boolean> {
const body = new FormData();
const type = (audio.type || "audio/webm").split(";")[0];
body.append("audio", audio, `voice.${type.split("/")[1] || "webm"}`);
body.append("duration", String(Math.round(durationSeconds)));
const r = await fetch(`${API}/messages/`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body });
return r.ok;
}
export const MAX_FILE_BYTES = 20 * 1024 * 1024;
// Файл или фото из виджета: multipart file + подпись text.
export async function sendFile(token: string, file: File, caption: string): Promise<boolean> {
const body = new FormData();
body.append("file", file, file.name);
if (caption) body.append("text", caption);
const r = await fetch(`${API}/messages/`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body });
return r.ok;
}
// URL вложения: <img src>/<a href> не умеют заголовки — токен идёт параметром.
export function attachmentUrl(token: string, messageId: number, inline = false): string {
return `${API}/messages/${messageId}/attachment/?token=${encodeURIComponent(token)}${inline ? "&inline" : ""}`;
}
// URL аудио голосового: <audio src> не умеет заголовки — токен идёт параметром.
export function voiceAudioUrl(token: string, messageId: number): string {
return `${API}/messages/${messageId}/audio/?token=${encodeURIComponent(token)}`;
}
export async function sendContact(token: string, phone: string): Promise<boolean> {
const r = await fetch(`${API}/contact/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ phone }),
});
return r.ok;
}
// Сессия виджета живёт, пока ей пользуются; после долгого простоя сервер её
// не признаёт, и виджету надо не «зависнуть», а предложить начать заново.
export class SessionExpired extends Error {}
export async function poll(token: string, since: number): Promise<Poll> {
const r = await fetch(`${API}/messages/?since=${since}`, { headers: { Authorization: `Bearer ${token}` } });
// 401 (сессии нет) и 429 (лимит) отвечают {"detail": ...}, а не лентой:
// без этой проверки тело уходило бы в ingestPoll как пустой Poll.
if (r.status === 401) throw new SessionExpired("webchat session is gone");
if (!r.ok) throw new Error(`poll failed: ${r.status}`);
return r.json();
}
// --- Онлайн-звонки (SPEC-CHATBALLS-0013): доставка приглашения и клиентские действия ---
const CALLS_API = "/api/v1/calls";
// Виджет: получить call access token по session token (переход на страницу звонка).
export async function openWebchatCall(sessionToken: string): Promise<CallBootstrap | null> {
const r = await fetch(`${API}/call/open/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` },
});
if (!r.ok) return null;
return r.json();
}
// Виджет: отклонить приглашение, не открывая страницу звонка.
export async function declineWebchatCall(sessionToken: string): Promise<boolean> {
const r = await fetch(`${API}/call/decline/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` },
});
return r.ok;
}
// Страница звонка: обмен invite token из ссылки TG/MAX на access token.
export async function resolveCallInvite(inviteToken: string): Promise<CallBootstrap | null> {
const r = await fetch(`${CALLS_API}/invites/resolve/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: inviteToken }),
});
if (!r.ok) return null;
return r.json();
}
async function callAccessAction(action: "accept" | "decline" | "end", accessToken: string): Promise<CallInfo> {
const r = await fetch(`${CALLS_API}/access/${action}/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
});
if (!r.ok) {
const payload = await r.json().catch(() => ({ detail: t("call.could_not_act_on_call") })) as { detail?: string };
throw new Error(payload.detail || t("call.could_not_act_on_call"));
}
return (await r.json()).call as CallInfo;
}
export async function fetchCallState(accessToken: string): Promise<CallStateEnvelope | null> {
const r = await fetch(`${CALLS_API}/access/state/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
});
if (!r.ok) return null;
return r.json();
}
export const acceptCall = (accessToken: string) => callAccessAction("accept", accessToken);
export const declineCall = (accessToken: string) => callAccessAction("decline", accessToken);
export const endCall = (accessToken: string) => callAccessAction("end", accessToken);