✨ feat(webchat): настройки оформления виджета на сервере

config.appearance подключения: цвет #RRGGBB, положение, размер 48/56/64,
форма и иконки кнопки, свой CSS до 10 КБ. Из CSS вырезаются @import,
внешние url() и expression. Публичная конфигурация отдаёт appearance,
config.accent продолжает работать; конфигурация не кэшируется, лоадер —
не дольше 5 минут.
This commit is contained in:
Andrey committed 2026-09-29 12:47:05 +03:00
1 parent 0f01a8ce47
commit 21a7626f95
11 files changed
+458 -15

No files matched your search

@@ -0,0 +1,26 @@
---
id: T-020
title: Настройки оформления на сервере и очистка своего CSS
milestone: M04
status: done
depends_on: []
order: 2
spec: "0021"
created: 2026-09-29
branch: skaro/T-020-nastroyki-oformleniya-na-serve
---
## Цель
Сервер хранит и валидирует config.appearance и отдаёт его виджету (R-1, R-6, R-7 серверная часть, R-11).
## Критерии приёмки
- [x] PATCH подключения валидирует appearance: accent #RRGGBB, позиция, размер 48/56/64, форма, URL иконок, customCss до 10 КБ
- [x] Из customCss вырезаются @import, url() с внешними адресами и expression (тест)
- [x] Публичная конфигурация отдаёт appearance; существующий config.accent продолжает работать
- [x] Кэш конфигурации и лоадера не задерживает изменения дольше 5 минут
## Итог
Сервер хранит и проверяет config.appearance веб-подключения: цвет, положение, размер, форму, иконки и свой CSS до 10 КБ. Из CSS вырезаются @import, внешние url() и expression. Публичная конфигурация отдаёт appearance целиком, config.accent продолжает работать, кэш не задерживает изменения дольше 5 минут. Ветка перенесена на свежую main, конфликты со схемой своих полей разрешены, обе части сохранены.
@@ -496,6 +496,13 @@ MESSAGES: dict[str, object] = {
"portals.widget_needs_channel": "Portal widget must be bound to a channel",
"settings.allowed_origins_list": "allowedOrigins must be a list of strings",
"settings.api_key_required": "Custom API key is required",
"settings.appearance": "Appearance settings are not recognised",
"settings.appearance_accent": "The colour is a HEX value like #1677ff",
"settings.appearance_css_too_large": "Custom CSS is limited to 10 KB",
"settings.appearance_icon": "The icon must be a link to an uploaded file",
"settings.appearance_position": "The button goes on the left or the right",
"settings.appearance_shape": "The button shape is a circle, rounded or square",
"settings.appearance_size": "The button size is 48, 56 or 64",
"settings.custom_base_url_required": "Custom Base URL is required",
"settings.custom_model_required": "Custom model is required",
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
@@ -500,6 +500,13 @@ MESSAGES: dict[str, object] = {
"portals.widget_needs_channel": "Виджет портала должен быть привязан к каналу",
"settings.allowed_origins_list": "allowedOrigins — список строк",
"settings.api_key_required": "Укажите API-ключ",
"settings.appearance": "Настройки оформления не распознаны",
"settings.appearance_accent": "Цвет — HEX вида #1677ff",
"settings.appearance_css_too_large": "Свой CSS — не больше 10 КБ",
"settings.appearance_icon": "Иконка — ссылка на загруженный файл",
"settings.appearance_position": "Кнопка — слева или справа",
"settings.appearance_shape": "Форма кнопки — круг, скруглённая или квадрат",
"settings.appearance_size": "Размер кнопки — 48, 56 или 64",
"settings.custom_base_url_required": "Укажите Base URL",
"settings.custom_model_required": "Укажите модель",
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
@@ -1,6 +1,7 @@
from urllib.parse import urlsplit, urlunsplit
from chatballs.integrations.models import Integration
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.webchat.appearance import stored_appearance
from chatballs.webchat.field_schema import fields_payload
# Пароль прокси наружу не отдаётся: в списке подключений его видел бы каждый,
@@ -86,6 +87,11 @@ def integration_payload(integration: Integration) -> dict[str, object]:
"consentVersion": integration.config.get("consent_version", ""),
# Свои поля веб-подключения (SPEC-0019).
"fields": fields_payload(integration.config.get("fields", [])),
"appearance": (
stored_appearance(integration.config)
if integration.provider == IntegrationProvider.WEB
else None
),
# Email-подключение (ADR-CHATBALLS-0035).
"email": integration.config.get("email", ""),
"imapHost": integration.config.get("imap_host", ""),
@@ -22,6 +22,7 @@ from chatballs.integrations.runtime import (
advance_revision_after_configuration_change,
)
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.appearance import normalize_appearance
from chatballs.webchat.field_schema import normalize_fields
@@ -95,6 +96,7 @@ def _normalized_config(provider: str, config: dict, previous_config: dict | None
if provider == IntegrationProvider.EMAIL:
return _email_config(config)
if provider == IntegrationProvider.WEB:
appearance = normalize_appearance(config, previous_config)
allowed = config.get("allowedOrigins", config.get("allowed_domains", []))
if not isinstance(allowed, list) or not all(isinstance(item, str) for item in allowed):
raise ValidationError({"config": t("settings.allowed_origins_list")})
@@ -106,7 +108,9 @@ def _normalized_config(provider: str, config: dict, previous_config: dict | None
return {
"allowed_domains": [item.strip() for item in allowed if item.strip()],
"title": str(config.get("title", "")).strip(),
"accent": str(config.get("accent", "")).strip(),
# Прежнее место цвета: его читают виджеты, сохранённые до appearance.
"accent": appearance["accent"],
"appearance": appearance,
"greeting": str(config.get("greeting", "")).strip(),
"quick_replies": quick_replies,
"consent_text": str(config.get("consentText", config.get("consent_text", ""))).strip(),
@@ -0,0 +1,132 @@
"""Оформление веб-виджета: ``integration.config.appearance`` (SPEC-0021 R-1).
Иконки: пустая строка — стандартный знак агента; у шапки пустая строка —
«как у кнопки», ``None`` — без иконки.
"""
import re
from urllib.parse import urlsplit
from django.core.exceptions import ValidationError
from chatballs.i18n import t
from chatballs.webchat.custom_css import sanitize_custom_css
DEFAULT_ACCENT = "#1677ff"
POSITIONS = ("left", "right")
SIZES = (48, 56, 64)
SHAPES = ("circle", "rounded", "square")
MAX_CUSTOM_CSS_BYTES = 10 * 1024
MAX_ICON_URL_LENGTH = 2048
DEFAULTS = {
"accent": "",
"launcherIcon": "",
"headerIcon": "",
"launcherPosition": "right",
"launcherSize": 56,
"launcherShape": "circle",
"customCss": "",
}
_HEX = re.compile(r"#[0-9a-f]{6}")
_URL_NOISE = re.compile(r"[\x00-\x20\x7f\\]")
def _invalid(key: str) -> ValidationError:
return ValidationError({"config": t(key)})
def _accent(value: object) -> str:
accent = str(value or "").strip().lower()
if accent and not _HEX.fullmatch(accent):
raise _invalid("settings.appearance_accent")
return accent
def _choice(value: object, allowed: tuple, error: str) -> object:
if isinstance(value, bool) or value not in allowed:
raise _invalid(error)
return allowed[allowed.index(value)]
def _icon(value: object, *, nullable: bool) -> str | None:
if value is None:
return None if nullable else ""
if not isinstance(value, str):
raise _invalid("settings.appearance_icon")
url = value.strip()
if not url:
return ""
parsed = urlsplit(url)
public = parsed.scheme in ("http", "https") and bool(parsed.netloc)
# Путь от корня — файл в хранилище на этом же сервере; «//host» — уже чужой адрес.
local = not parsed.scheme and url.startswith("/") and not url.startswith("//")
if len(url) > MAX_ICON_URL_LENGTH or _URL_NOISE.search(url) or not (public or local):
raise _invalid("settings.appearance_icon")
return url
def _custom_css(value: object) -> str:
if value is None:
return ""
if not isinstance(value, str):
raise _invalid("settings.appearance")
if len(value.encode("utf-8")) > MAX_CUSTOM_CSS_BYTES:
raise _invalid("settings.appearance_css_too_large")
return sanitize_custom_css(value).strip()
def stored_appearance(config: dict | None) -> dict:
"""Сохранённое оформление с умолчаниями; цвет — и из прежнего ``config.accent``."""
config = config if isinstance(config, dict) else {}
stored = config.get("appearance")
appearance = {**DEFAULTS, **(stored if isinstance(stored, dict) else {})}
appearance["accent"] = appearance["accent"] or str(config.get("accent") or "")
return appearance
def normalize_appearance(config: dict, previous: dict | None = None) -> dict:
"""Оформление из ``config`` запроса.
Форма подключения отправляет config целиком, но без ``appearance`` —
тогда оформление остаётся прежним, меняется только цвет из ``config.accent``.
"""
raw = config.get("appearance")
if raw is None:
appearance = stored_appearance(previous)
if "accent" in config:
appearance["accent"] = _accent(config["accent"])
return appearance
if not isinstance(raw, dict):
raise _invalid("settings.appearance")
return {
"accent": _accent(raw["accent"] if "accent" in raw else config.get("accent")),
"launcherIcon": _icon(raw.get("launcherIcon"), nullable=False),
"headerIcon": _icon(raw.get("headerIcon", ""), nullable=True),
"launcherPosition": _choice(
raw.get("launcherPosition", DEFAULTS["launcherPosition"]),
POSITIONS,
"settings.appearance_position",
),
"launcherSize": _choice(
raw.get("launcherSize", DEFAULTS["launcherSize"]),
SIZES,
"settings.appearance_size",
),
"launcherShape": _choice(
raw.get("launcherShape", DEFAULTS["launcherShape"]),
SHAPES,
"settings.appearance_shape",
),
"customCss": _custom_css(raw.get("customCss")),
}
def public_appearance(presentation: dict) -> dict:
"""Оформление для виджета: умолчания подставлены, иконка шапки разрешена."""
appearance = stored_appearance(presentation)
appearance["accent"] = appearance["accent"] or DEFAULT_ACCENT
if appearance["headerIcon"] == "":
appearance["headerIcon"] = appearance["launcherIcon"]
return appearance
@@ -0,0 +1,55 @@
"""Очистка своего CSS виджета (SPEC-0021 R-7).
CSS вставляется ``<style>`` внутри iframe чата. Сервер вырезает то, чем из
стилей можно подгрузить чужое или выполнить код: ``@import``, ``url(...)`` с
внешним адресом и ``expression(...)``. CSP страницы чата — второй рубеж, а не
единственный.
"""
import re
# Экранирование буквы (``\\69`` → «i», ``\\m`` → «m») браузер раскрывает до
# разбора, и ``@\\69mport`` работает как ``@import``. Раскрываем только буквы:
# экраны цифр и знаков (``.\\31 23``, ``.hover\\:x``) значимы для селекторов.
_HEX_ESCAPE = re.compile(r"\\([0-9a-fA-F]{1,6})[ \t\r\n\f]?")
_LETTER_ESCAPE = re.compile(r"\\([g-zG-Z])")
_IMPORT = re.compile(r"""@import(?:"[^"\n]*"?|'[^'\n]*'?|[^;{}\n"'])*;?""", re.IGNORECASE)
_EXPRESSION = re.compile(r"expression\s*\([^)]*\)?", re.IGNORECASE)
_URL = re.compile(
r"""url\(\s*("(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?|[^)]*?)\s*(?:\)|$)""",
re.IGNORECASE,
)
# Пробелы и управляющие символы парсер адресов выкидывает: «/\t/evil» — это «//evil».
_URL_NOISE = re.compile(r"[\x00-\x20\x7f]")
_SCHEME = re.compile(r"^[a-z][a-z0-9+.\-]*:", re.IGNORECASE)
def _unescape_letters(css: str) -> str:
def hex_letter(match: re.Match) -> str:
code = int(match.group(1), 16)
char = chr(code) if code < 0x80 else ""
return char if char.isalpha() else match.group(0)
return _LETTER_ESCAPE.sub(r"\1", _HEX_ESCAPE.sub(hex_letter, css))
def _is_local_url(raw: str) -> bool:
"""Адрес не уводит за пределы страницы чата: якорь, путь или data:image."""
target = _URL_NOISE.sub("", raw.strip("\"'"))
if "\\" in target:
return False
if target.startswith("//"):
return False
if _SCHEME.match(target):
return target.lower().startswith("data:image/")
return True
def sanitize_custom_css(css: str) -> str:
css = _unescape_letters(css)
css = _IMPORT.sub("", css)
css = _EXPRESSION.sub("", css)
css = _URL.sub(lambda match: match.group(0) if _is_local_url(match.group(1)) else "", css)
# Закрывающий </style> не должен выйти из тега, как бы виджет ни вставлял CSS.
return css.replace("<", "\\3c ")
+4 -3
View File
@@ -26,10 +26,9 @@ from chatballs.identity.instance_settings import default_language
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.webchat.appearance import public_appearance
from chatballs.webchat.models import WebChatWidget, WebSession
DEFAULT_ACCENT = "#1677ff"
_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"}
_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"}
@@ -105,6 +104,7 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
fallback.append({"label": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
elif sib.provider == IntegrationProvider.MAX and username:
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
appearance = public_appearance(cfg)
return {
"available": True,
"widgetKey": widget.public_key,
@@ -115,7 +115,8 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(integration),
"title": cfg.get("title") or channel.name,
"accent": cfg.get("accent") or DEFAULT_ACCENT,
"accent": appearance["accent"],
"appearance": appearance,
"greeting": cfg.get("greeting") or t("webchat.default_greeting", language=language),
"consent": {
"text": consent.get("consent_text") or t("webchat.default_consent", language=language),
@@ -0,0 +1,197 @@
from django.test import SimpleTestCase, TestCase
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
from chatballs.webchat.custom_css import sanitize_custom_css
class CustomCssSanitizingTests(SimpleTestCase):
def test_import_is_cut(self) -> None:
for css in (
'@import "https://evil.example/x.css";',
"@import url(https://evil.example/x.css) screen;",
"@IMPORT 'x.css';",
"@\\69mport 'x.css';",
):
with self.subTest(css=css):
self.assertNotIn("import", sanitize_custom_css(css + "\n.cb-header{color:red}").lower())
self.assertIn(".cb-header{color:red}", sanitize_custom_css("@import 'x.css';\n.cb-header{color:red}"))
def test_external_url_is_cut(self) -> None:
for target in (
"https://evil.example/a.png",
"'http://evil.example/a.png'",
'"//evil.example/a.png"',
"javascript:alert(1)",
'"/\t/evil.example/a.png"',
"/\\evil.example/a.png",
"https://evil.example/a.png",
):
with self.subTest(target=target):
css = f".cb-body{{background:url({target})}}"
self.assertNotIn("evil", sanitize_custom_css(css))
self.assertNotIn("javascript", sanitize_custom_css(css))
escaped = sanitize_custom_css(".cb-body{background:\\75 rl(https://evil.example/a.png)}")
self.assertNotIn("evil", escaped)
unterminated = sanitize_custom_css(".cb-body{background:url(https://evil.example/a.png")
self.assertNotIn("evil", unterminated)
def test_local_url_is_kept(self) -> None:
css = (
".a{background:url(#grad)}"
".b{background:url('/media/organizations/x/bg.png')}"
".c{background:url(data:image/png;base64,AAAA)}"
".d{background:url(img/bg.png)}"
)
self.assertEqual(sanitize_custom_css(css), css)
def test_expression_is_cut(self) -> None:
cleaned = sanitize_custom_css(".cb-header{width:expression(alert(1));color:red}")
self.assertNotIn("expression", cleaned)
self.assertIn("color:red", cleaned)
self.assertNotIn("expression", sanitize_custom_css(".x{width:e\\78pression(alert(1))}"))
def test_style_tag_cannot_be_closed(self) -> None:
cleaned = sanitize_custom_css('.x{content:"</style><script>alert(1)</script>"}')
self.assertNotIn("<", cleaned)
def test_regular_css_is_untouched_and_sanitizing_is_stable(self) -> None:
css = '.cb-header{background:#0d8a7e!important}\n.hover\\:x > .\\31 23{content:"\\2014"}'
self.assertEqual(sanitize_custom_css(css), css)
once = sanitize_custom_css('.x{content:"<"}')
self.assertEqual(sanitize_custom_css(once), once)
class WebAppearanceApiTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.context = system_tenant_context(self.organization)
self.channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт")
self.integration = create_integration(
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.WEB,
name="Виджет",
channel_id=self.channel.id,
config={"allowedOrigins": ["example.com"], "accent": "#0d8a7e"},
),
)
self.client.login(username="owner@example.com", password="temporary-password")
def _url(self) -> str:
return f"/api/v1/organizations/{self.organization.public_id}/integrations/{self.integration.id}/"
def _patch(self, config: dict):
return self.client.patch(
self._url(),
data={"config": {"allowedOrigins": ["example.com"], **config}},
content_type="application/json",
)
def _public_config(self) -> dict:
response = self.client.get(
"/api/v1/webchat/config/",
{"widgetKey": self.integration.web_chat_widget.public_key},
HTTP_ORIGIN="https://example.com",
)
self.assertEqual(response["Cache-Control"], "no-cache")
return response.json()
def test_valid_appearance_is_saved_and_published(self) -> None:
appearance = {
"accent": "#FFD400",
"launcherIcon": "/media/organizations/x/webchat/icon.svg",
"headerIcon": None,
"launcherPosition": "left",
"launcherSize": 64,
"launcherShape": "rounded",
"customCss": "@import 'x.css';\n.cb-header{background:url(https://evil.example/a.png);color:red}",
}
response = self._patch({"appearance": appearance})
self.assertEqual(response.status_code, 200, response.content)
saved = response.json()["integration"]["config"]
self.assertEqual(saved["accent"], "#ffd400")
self.assertEqual(saved["appearance"]["launcherPosition"], "left")
self.assertEqual(saved["appearance"]["launcherSize"], 64)
self.assertIsNone(saved["appearance"]["headerIcon"])
self.assertEqual(saved["appearance"]["customCss"], ".cb-header{background:;color:red}")
public = self._public_config()
self.assertEqual(public["accent"], "#ffd400")
self.assertEqual(
public["appearance"],
{
"accent": "#ffd400",
"launcherIcon": "/media/organizations/x/webchat/icon.svg",
"headerIcon": None,
"launcherPosition": "left",
"launcherSize": 64,
"launcherShape": "rounded",
"customCss": ".cb-header{background:;color:red}",
},
)
def test_invalid_appearance_is_rejected(self) -> None:
for appearance in (
{"accent": "blue"},
{"accent": "#12345"},
{"launcherPosition": "top"},
{"launcherSize": 50},
{"launcherSize": True},
{"launcherShape": "oval"},
{"launcherIcon": "javascript:alert(1)"},
{"launcherIcon": "//evil.example/icon.svg"},
{"headerIcon": "data:image/svg+xml,<svg/>"},
{"customCss": "a{}" * 4000},
):
with self.subTest(appearance=appearance):
response = self._patch({"appearance": appearance})
self.assertEqual(response.status_code, 400, response.content)
self.assertEqual(self._patch({"accent": "not-a-colour"}).status_code, 400)
self.integration.refresh_from_db()
self.assertEqual(self.integration.config["accent"], "#0d8a7e")
def test_custom_css_limit_is_10_kb(self) -> None:
exactly = "a" * (10 * 1024)
self.assertEqual(self._patch({"appearance": {"customCss": exactly}}).status_code, 200)
self.assertEqual(self._patch({"appearance": {"customCss": exactly + "a"}}).status_code, 400)
def test_legacy_accent_keeps_working(self) -> None:
# Виджет, сохранённый до appearance: цвет только в config.accent.
Integration.objects.filter(id=self.integration.id).update(
config={"allowed_domains": ["example.com"], "accent": "#be123c"}
)
widget = self.integration.web_chat_widget
widget.presentation_config = {"accent": "#be123c"}
widget.save(update_fields=["presentation_config"])
public = self._public_config()
self.assertEqual(public["accent"], "#be123c")
self.assertEqual(public["appearance"]["accent"], "#be123c")
self.assertEqual(public["appearance"]["launcherPosition"], "right")
self.assertEqual(public["appearance"]["launcherSize"], 56)
self.assertEqual(public["appearance"]["launcherShape"], "circle")
# Прежняя форма шлёт config без appearance: оформление не сбрасывается.
self._patch({"appearance": {"launcherPosition": "left", "headerIcon": "https://cdn.example/h.png"}})
response = self._patch({"accent": "#4f46e5", "title": "Поддержка"})
self.assertEqual(response.status_code, 200, response.content)
public = self._public_config()
self.assertEqual(public["accent"], "#4f46e5")
self.assertEqual(public["appearance"]["launcherPosition"], "left")
self.assertEqual(public["appearance"]["headerIcon"], "https://cdn.example/h.png")
def test_header_icon_defaults_to_launcher_icon(self) -> None:
self._patch({"appearance": {"launcherIcon": "https://cdn.example/l.svg"}})
public = self._public_config()
self.assertEqual(public["appearance"]["headerIcon"], "https://cdn.example/l.svg")
def test_loader_is_cached_no_longer_than_five_minutes(self) -> None:
response = self.client.get("/chat-widget.js")
self.assertEqual(response["Cache-Control"], "public, max-age=300")
+16 -8
View File
@@ -36,6 +36,8 @@ from chatballs.webchat.throttling import (
WebchatTrafficThrottle,
)
LOADER_MAX_AGE_SECONDS = 300
class _Public(APIView):
authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django
@@ -117,14 +119,19 @@ class WebchatConfigView(_Public):
channel_code = request.GET.get("channel", "")
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
if context is None or widget is None:
return Response({"available": False})
return Response(
services.public_config(
context=context,
widget=widget,
origin=host_origin(request),
response = Response({"available": False})
else:
response = Response(
services.public_config(
context=context,
widget=widget,
origin=host_origin(request),
)
)
)
# Оформление меняют в админке, и сайт должен увидеть его на следующей
# загрузке страницы (SPEC-0021 R-11). Ответ к тому же зависит от Origin.
response["Cache-Control"] = "no-cache"
return response
class WebchatSessionView(_Public):
@@ -309,5 +316,6 @@ class WebchatCallDeclineView(_PublicSession):
class WidgetLoaderView(View):
def get(self, request) -> HttpResponse:
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
response["Cache-Control"] = "public, max-age=300"
# Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
return response
+2 -2
View File
@@ -40,8 +40,8 @@ def ensure_widget(integration: Integration) -> WebChatWidget | None:
config = integration.config if isinstance(integration.config, dict) else {}
presentation = {
key: config[key]
for key in ("title", "accent", "greeting", "quick_replies")
if config.get(key) not in (None, "", [])
for key in ("title", "accent", "greeting", "quick_replies", "appearance")
if config.get(key) not in (None, "", [], {})
}
fields = public_fields(config.get("fields", []))
if fields: