diff --git a/.skaro/tasks/T-020-nastroyki-oformleniya-na-servere-i-ochis.md b/.skaro/tasks/T-020-nastroyki-oformleniya-na-servere-i-ochis.md new file mode 100644 index 0000000..438d340 --- /dev/null +++ b/.skaro/tasks/T-020-nastroyki-oformleniya-na-servere-i-ochis.md @@ -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, конфликты со схемой своих полей разрешены, обе части сохранены. diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index c9cf46b..e72ee32 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -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", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 5e9a7c7..defab7a 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -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-сервер", diff --git a/apps/backend/chatballs/integrations/serializers.py b/apps/backend/chatballs/integrations/serializers.py index 3c10a4c..eafacb7 100644 --- a/apps/backend/chatballs/integrations/serializers.py +++ b/apps/backend/chatballs/integrations/serializers.py @@ -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", ""), diff --git a/apps/backend/chatballs/integrations/services.py b/apps/backend/chatballs/integrations/services.py index e59c903..e1c91df 100644 --- a/apps/backend/chatballs/integrations/services.py +++ b/apps/backend/chatballs/integrations/services.py @@ -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(), diff --git a/apps/backend/chatballs/webchat/appearance.py b/apps/backend/chatballs/webchat/appearance.py new file mode 100644 index 0000000..1f5419c --- /dev/null +++ b/apps/backend/chatballs/webchat/appearance.py @@ -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 diff --git a/apps/backend/chatballs/webchat/custom_css.py b/apps/backend/chatballs/webchat/custom_css.py new file mode 100644 index 0000000..2a87d8e --- /dev/null +++ b/apps/backend/chatballs/webchat/custom_css.py @@ -0,0 +1,55 @@ +"""Очистка своего CSS виджета (SPEC-0021 R-7). + +CSS вставляется `` не должен выйти из тега, как бы виджет ни вставлял CSS. + return css.replace("<", "\\3c ") diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index 5823658..185af7b 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -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), diff --git a/apps/backend/chatballs/webchat/test_appearance.py b/apps/backend/chatballs/webchat/test_appearance.py new file mode 100644 index 0000000..78a96ec --- /dev/null +++ b/apps/backend/chatballs/webchat/test_appearance.py @@ -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:""}') + 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,"}, + {"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") diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index ee619b0..6174fb2 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -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 diff --git a/apps/backend/chatballs/webchat/widgets.py b/apps/backend/chatballs/webchat/widgets.py index 1adeff0..b2462b9 100644 --- a/apps/backend/chatballs/webchat/widgets.py +++ b/apps/backend/chatballs/webchat/widgets.py @@ -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: