mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🐛 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:
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 аргумента.
|
||||
|
||||
Reference in new issue
Block a user