🐛 fix(webchat): чинит молчаливое «Чат временно недоступен» у Web-виджета

Разрешённые домены виджета нельзя было задать из интерфейса: поля не было, а
показанное для WEB поле «Base URL» в конфиг Web-виджета не уходило. Любое новое
подключение сохранялось с пустым allowed_origins, а пустой список в проде
запрещает вообще все origin'ы (origin_allowed) — виджет на сайте молча отвечал
«Чат временно недоступен», при зелёном статусе подключения в CRM.

- поле «Разрешённые домены» в форме подключения: разбор через запятую/перенос,
  дедупликация и валидация трёх форм, которые понимает origin_allowed
  (example.com, *.example.com, https://example.com:8443); сохранение
  заблокировано, пока не введён хотя бы один валидный домен
- мёртвое поле «Base URL» для WEB убрано
- «Проверить» больше не даёт зелёный статус при пустом списке доменов, а пишет
  причину в строку подключения
- проверка Web-виджета запускается сразу после сохранения: раньше любая правка
  роняла подключение в UNCHECKED, а виджет в DRAFT, и чат на сайте умирал до
  ручного нажатия «Проверить» (раздача требует PUBLISHED + OK)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-08-30 12:57:13 +03:00
1 parent e149c5d387
commit e567e076ae
5 files changed
+178 -12

No files matched your search

@@ -126,6 +126,19 @@ def _normalized_config(provider: str, config: dict) -> dict:
return result
def _publish_web_widget(*, context: TenantContext, integration: Integration) -> None:
"""Web-виджет проверяется локально, без внешнего API, — поэтому проверяем сразу
после сохранения. Иначе любая правка подключения оставляла бы виджет в DRAFT, а
подключение в UNCHECKED; раздача требует PUBLISHED + OK (webchat.views), и чат на
сайте молча падал бы в «Чат временно недоступен» до ручного «Проверить»."""
from hub_platform.webchat.widgets import ensure_widget
# Отдельным вызовом — чтобы конфликт смены режима остался 400 на сохранении;
# внутри проверки та же ошибка превратилась бы в статус ERROR.
ensure_widget(integration)
test_integration(context=context, integration=integration)
def _validate_provider(provider: str) -> str:
if provider not in IntegrationProvider.values:
raise ValidationError({"provider": "Unknown provider"})
@@ -157,9 +170,7 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int
integration.full_clean(exclude=["secret"])
integration.save()
if integration.provider == IntegrationProvider.WEB:
from hub_platform.webchat.widgets import ensure_widget
ensure_widget(integration)
_publish_web_widget(context=context, integration=integration)
record_usage(
context=context,
quota_key=QuotaKey.CLIENT_CONNECTIONS,
@@ -196,9 +207,7 @@ def update_integration(
integration.full_clean(exclude=["secret"])
integration.save()
if integration.provider == IntegrationProvider.WEB:
from hub_platform.webchat.widgets import ensure_widget
ensure_widget(integration)
_publish_web_widget(context=context, integration=integration)
return integration
@@ -229,6 +238,11 @@ def _check_web(context: TenantContext, integration: Integration) -> tuple[bool,
return False, "; ".join(error.messages), {}
if widget is None:
return False, "Конфигурация Web-виджета не создана", {}
# Пустой allowed_origins в проде запрещает вообще все домены (webchat.services.
# origin_allowed), и на сайте виджет молча показывает «Чат временно недоступен».
# Проверка обязана падать здесь, а не оставлять зелёный статус при мёртвом чате.
if not widget.allowed_origins:
return False, "Не заданы разрешённые домены — виджет будет недоступен на сайте", {}
return True, f"Web-виджет активен · канал «{integration.channel.name}»", {}
@@ -38,10 +38,15 @@ class WebIntegrationCheckTests(TestCase):
self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
def _web(self, name: str, channel=None) -> Integration:
def _web(self, name: str, channel=None, origins=("edevs.tech",)) -> Integration:
return create_integration(
context=self.context,
data=IntegrationInput(provider=IntegrationProvider.WEB, name=name, channel_id=channel.id if channel else None),
data=IntegrationInput(
provider=IntegrationProvider.WEB,
name=name,
channel_id=channel.id if channel else None,
config={"allowedOrigins": list(origins)},
),
)
def test_web_without_channel_fails(self) -> None:
@@ -75,6 +80,55 @@ class WebIntegrationCheckTests(TestCase):
second.web_chat_widget.public_key,
)
def test_web_without_allowed_origins_fails(self) -> None:
"""Пустой allowed_origins в проде запрещает все домены, и виджет молча
показывает «Чат временно недоступен» — проверка обязана это ловить."""
integration = run_integration_test(
context=self.context,
integration=self._web("Виджет", channel=self.channel, origins=()),
)
self.assertEqual(integration.status, IntegrationStatus.ERROR)
self.assertIn("Не заданы разрешённые домены", integration.last_error)
self.assertEqual(integration.web_chat_widget.status, "DRAFT")
def test_allowed_origins_reach_the_widget(self) -> None:
integration = run_integration_test(
context=self.context,
integration=self._web(
"Виджет", channel=self.channel, origins=("edevs.tech", "*.edevs.tech")
),
)
self.assertEqual(integration.status, IntegrationStatus.OK)
self.assertEqual(
integration.web_chat_widget.allowed_origins, ["edevs.tech", "*.edevs.tech"]
)
self.assertEqual(
integration_payload(integration)["config"]["allowedOrigins"],
["edevs.tech", "*.edevs.tech"],
)
def test_saving_connection_keeps_the_widget_published(self) -> None:
"""Регрессия: раздача виджета требует PUBLISHED + OK (webchat.views), а правка
подключения не должна ни ронять чат на сайте до ручного «Проверить», ни
обнулять домены — именно так виджеты уходили в «Чат временно недоступен»."""
integration = self._web("Виджет", channel=self.channel)
self.assertEqual(integration.status, IntegrationStatus.OK)
self.assertEqual(integration.web_chat_widget.status, "PUBLISHED")
renamed = update_integration(
context=self.context,
integration=integration,
data=IntegrationInput(
provider=IntegrationProvider.WEB,
name="Виджет · переименован",
channel_id=self.channel.id,
config={"allowedOrigins": ["edevs.tech"]},
),
)
self.assertEqual(renamed.status, IntegrationStatus.OK)
self.assertEqual(renamed.web_chat_widget.status, "PUBLISHED")
self.assertEqual(renamed.config["allowed_domains"], ["edevs.tech"])
self.assertEqual(renamed.web_chat_widget.allowed_origins, ["edevs.tech"])
class ProxyConfigTests(TestCase):
def setUp(self) -> None:
@@ -6,7 +6,18 @@ import { Icon } from "../../shared/icons";
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { EMAIL_CONFIG_DEFAULTS, EmailFields, emailConfigFromIntegration, emailConfigPayload } from "./EmailFields";
import { fetchChannels, PROVIDERS, webWidgetSnippet, type ChannelOption, type Integration, type IntegrationKind, type IntegrationProvider } from "./model";
import {
fetchChannels,
formatAllowedOrigins,
invalidAllowedOrigin,
parseAllowedOrigins,
PROVIDERS,
webWidgetSnippet,
type ChannelOption,
type Integration,
type IntegrationKind,
type IntegrationProvider,
} from "./model";
// Селектор «Тип» показывает только провайдеров рода активного таба (SPEC-HUB-0025 §2.2).
function providerOptions(kind: IntegrationKind): Array<[string, string]> {
@@ -24,6 +35,7 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
const [baseUrl, setBaseUrl] = useState(initial?.config.baseUrl ?? "");
const [defaultModel, setDefaultModel] = useState(initial?.config.defaultModel ?? "");
const [proxyUrl, setProxyUrl] = useState(initial?.config.proxyUrl ?? "");
const [allowedOrigins, setAllowedOrigins] = useState(formatAllowedOrigins(initial?.config.allowedOrigins ?? []));
const [emailConfig, setEmailConfig] = useState(initial ? emailConfigFromIntegration(initial.config) : EMAIL_CONFIG_DEFAULTS);
const [channelId, setChannelId] = useState(initial?.channel ? String(initial.channel.id) : "");
const [isNotifier, setIsNotifier] = useState(initial?.config.purpose === "notifications");
@@ -55,7 +67,12 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
}, [isMessenger]);
const customReady = provider !== "CUSTOM" || (baseUrl.trim().length > 0 && defaultModel.trim().length > 0);
const emailReady = !isEmail || Boolean(emailConfig.email.trim() && emailConfig.imapHost.trim() && emailConfig.smtpHost.trim());
const ready = name.trim().length > 0 && customReady && emailReady && (isEdit || !meta.testable || secret.trim().length > 0);
// Без доменов виджет сохранится, но на сайте покажет «Чат временно недоступен»:
// origin_allowed на пустом списке запрещает всё. Поэтому поле обязательное.
const originList = parseAllowedOrigins(allowedOrigins);
const badOrigin = isWeb ? invalidAllowedOrigin(originList) : undefined;
const webReady = !isWeb || (originList.length > 0 && !badOrigin);
const ready = name.trim().length > 0 && customReady && emailReady && webReady && (isEdit || !meta.testable || secret.trim().length > 0);
async function submit() {
if (!ready) return;
@@ -65,7 +82,7 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
? emailConfigPayload(emailConfig)
: isWeb
? {
allowedOrigins: initial?.config.allowedOrigins ?? [],
allowedOrigins: originList,
title: initial?.config.title ?? "",
accent: initial?.config.accent ?? "",
greeting: initial?.config.greeting ?? "",
@@ -118,7 +135,19 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial:
{isEmail && !isEdit && (
<div className="integration-form-hint">Для Gmail и Яндекс используйте пароль приложения, не основной пароль аккаунта</div>
)}
{!isEmail && <FormField label="Base URL" value={baseUrl} onChange={setBaseUrl} placeholder={meta.defaultBaseUrl || "—"} />}
{!isEmail && !isWeb && <FormField label="Base URL" value={baseUrl} onChange={setBaseUrl} placeholder={meta.defaultBaseUrl || "—"} />}
{isWeb && (
<>
<FormField
label="Разрешённые домены"
value={allowedOrigins}
onChange={setAllowedOrigins}
error={badOrigin && `Непонятный домен: ${badOrigin}`}
placeholder="example.com, *.example.com — через запятую"
/>
<div className="integration-form-hint">Сайты, на которых виджету разрешено открываться: домен, поддомены через «*.» или полный origin с портом. На остальных чат ответит «Чат временно недоступен»</div>
</>
)}
{!isWeb && !isEmail && (
<FormField label="Прокси" value={proxyUrl} onChange={setProxyUrl} placeholder="http://host:port или socks5://user:pass@host:port — пусто, если без прокси" />
)}
@@ -37,6 +37,49 @@ describe("webWidgetSnippet", () => {
});
});
describe("parseAllowedOrigins", () => {
it("splits on commas, semicolons and newlines and trims each entry", async () => {
const { parseAllowedOrigins } = await import("./model");
expect(parseAllowedOrigins(` edevs.tech ,
*.edevs.tech; foxray.pro `)).toEqual([
"edevs.tech",
"*.edevs.tech",
"foxray.pro",
]);
});
it("drops empty entries, trailing slashes and case-insensitive duplicates", async () => {
const { parseAllowedOrigins } = await import("./model");
expect(parseAllowedOrigins("edevs.tech/, ,, EDEVS.TECH, edevs.tech")).toEqual(["edevs.tech"]);
});
it("returns an empty list for blank input so the form can require a domain", async () => {
const { parseAllowedOrigins } = await import("./model");
expect(parseAllowedOrigins(`
`)).toEqual([]);
});
});
describe("invalidAllowedOrigin", () => {
it("accepts the three forms origin_allowed understands", async () => {
const { invalidAllowedOrigin } = await import("./model");
expect(
invalidAllowedOrigin(["edevs.tech", "*.edevs.tech", "https://app.custocrm.ru", "localhost:5173"]),
).toBeUndefined();
});
it("reports the first entry that is not a domain", async () => {
const { invalidAllowedOrigin } = await import("./model");
expect(invalidAllowedOrigin(["edevs.tech", "https://edevs.tech/chat"])).toBe("https://edevs.tech/chat");
expect(invalidAllowedOrigin(["не домен"])).toBe("не домен");
});
});
// Гарантия отсутствия build-time привязки: сниппет выводится от текущего origin в
// рантайме, поэтому один frontend-образ работает на любом домене без пересборки
// (ADR-HUB-0028 §10).
@@ -98,6 +98,32 @@ export const fetchLlmProviders = () =>
response.items.filter((item) => item.kind === "LLM_PROVIDER")
);
// Разрешённые домены Web-виджета (SPEC-HUB-0010 §7.1). Пустой список в проде
// запрещает все origin'ы, поэтому домены вводятся руками и обязательны.
// Принимаем три формы, которые понимает backend (webchat.services.origin_allowed):
// `example.com`, `*.example.com` и `https://example.com:8443`; `localhost` — для разработки.
const ORIGIN_RULE = /^(?:https?:\/\/)?(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i;
// Ввод — свободный текст: домены разделяются запятой, точкой с запятой или переносом.
export function parseAllowedOrigins(input: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const raw of input.split(/[\s,;]+/)) {
const item = raw.trim().replace(/\/+$/, "");
const key = item.toLowerCase();
if (!item || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export const formatAllowedOrigins = (origins: string[]): string => origins.join(", ");
// Возвращает первый непонятный домен — форма показывает его в ошибке поля.
export const invalidAllowedOrigin = (origins: string[]): string | undefined =>
origins.find((item) => !ORIGIN_RULE.test(item));
// Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3).
// Один frontend-образ работает на любом домене (ADR-HUB-0028 §runtime frontend):
// сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента.