diff --git a/.env.example b/.env.example index 4f3975eb4e9..e4ac3d199df 100644 --- a/.env.example +++ b/.env.example @@ -527,6 +527,7 @@ AGENT_SKILLS= # CUSTOM_WEBHOOK_URLS=https://oapi.dingtalk.com/robot/send?access_token=xxx,https://hooks.slack.com/services/xxx # CUSTOM_WEBHOOK_BEARER_TOKEN= # 可选,用于需要认证的 Webhook (Header Authorization: Bearer ) # CUSTOM_WEBHOOK_BODY_TEMPLATE= # 可选,全局 JSON body 模板,会覆盖 Bark/Slack/Discord 等自动 payload;推荐 $content_json/$title_json +# Docker Compose 手写 .env 时请写成 $$content_json/$$title_json;Web 设置页会自动转义,运行时仍还原为单个 $ # WEBHOOK_VERIFY_SSL=true # 默认校验;影响读取该配置的 webhook-style HTTPS 通知请求。设为 false 可支持自签名证书。警告:禁用后存在 MITM 劫持风险,仅限可信内网 # # 【方式六】Pushover 配置 diff --git a/apps/dsa-web/src/locales/settingsHelp.ts b/apps/dsa-web/src/locales/settingsHelp.ts index d6b9c57417c..2fe72fbd577 100644 --- a/apps/dsa-web/src/locales/settingsHelp.ts +++ b/apps/dsa-web/src/locales/settingsHelp.ts @@ -387,6 +387,8 @@ const settingsHelpZhCN: SettingsHelpMap = { valueNotes: [ '模板必须渲染为 JSON object。', '推荐使用 $content_json、$title_json 避免换行和引号破坏 JSON。', + 'Docker 部署中保存到 .env 时会自动写成 $$content_json、$$title_json;运行时仍会还原为单个 $ 占位符。', + '本字段仅影响自定义 Webhook 的 payload 行为,不会改写 LLM provider、模型名、Base URL 或迁移优先级。', ], impact: ['影响 AstrBot、NapCat、自建服务等自定义推送。'], notes: ['先用一个 Webhook 验证成功,再扩展到多个目标。'], @@ -1383,7 +1385,12 @@ const settingsHelpEnUS: SettingsHelpMap = { title: 'Custom Webhooks', summary: 'Pushes reports to any service that accepts POST JSON.', usage: 'Use comma-separated URLs. CUSTOM_WEBHOOK_BODY_TEMPLATE can customize the JSON body.', - valueNotes: ['The template must render to a JSON object.', 'Prefer $content_json and $title_json to avoid invalid JSON.'], + valueNotes: [ + 'The template must render to a JSON object.', + 'Prefer $content_json and $title_json to avoid invalid JSON.', + 'Docker saves these placeholders as $$content_json / $$title_json in .env, and runtime restores the single-$ form.', + 'This setting only affects custom webhook payload behavior and does not alter LLM provider/model/Base URL or runtime routing priority.', + ], impact: ['Affects AstrBot, NapCat, or self-hosted push integrations.'], notes: ['Validate one webhook before adding multiple targets.'], }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 20a5ccac43c..f5529398abe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [修复] 避免 runtime scheduler 重建定时任务时重复立即运行事件监控,减少重复告警和后台任务状态丢失。 - [修复] Web/API runtime scheduler 接管 `--serve --schedule` 后保留 `--dry-run`、`--no-notify` 等启动参数语义。 - [改进] Web 历史报告详情不再内嵌展示 AI 建议卡片,结构化决策信号集中在 AI 建议页查询,并保留按来源报告 ID 筛选或 URL 参数精确定位入口。 +- [修复] Docker 部署中 Web 设置页保存自定义 Webhook 模板时自动转义 `$content_json` 等应用占位符,并在运行时还原,避免 Compose 重新部署将其展开为空。 ## [3.23.0] - 2026-06-20 diff --git a/docs/full-guide.md b/docs/full-guide.md index 5974ffdc6f3..77810b4b79b 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -994,6 +994,8 @@ CUSTOM_WEBHOOK_BODY_TEMPLATE={"msg_type":"text","content":$content_json} 可用占位符:`$content_json`、`$content`、`$title_json`、`$title`。其中 `$content` / `$title` 是裸字符串,不做 JSON 转义;正文含双引号或换行时可能触发 fallback。 +Docker Compose 部署中,通过 Web 设置页保存时会把这些应用占位符写成 `$$content_json` / `$$title_json` 等形式,避免 Compose 重新部署时将其展开为空;应用运行时会还原为单个 `$`。如果手动编辑 Docker 使用的 `.env`,请同样使用 `$$content_json` 这类写法。 + Bark 使用全局模板时需显式写出 Bark body: ```env diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index a571e638250..0bdb81f618a 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -863,6 +863,11 @@ Available placeholders: `$content_json`, `$content`, `$title_json`, `$title`. Raw `$content` / `$title` are not JSON-escaped, so quotes or newlines can make the template invalid and trigger fallback. +In Docker Compose deployments, saving this value from Web Settings writes these +app placeholders as `$$content_json` / `$$title_json` and restores the single +`$` form at runtime, preventing Compose from expanding them to empty values. If +you edit the Docker `.env` manually, use the same `$$content_json` style. + Bark stays on the custom webhook baseline; no `BARK_*` settings are required. Set the Bark endpoint in `CUSTOM_WEBHOOK_URLS`. When using Bark with a global template, include the Bark body explicitly: diff --git a/docs/notifications.md b/docs/notifications.md index e21ba2696a2..3bc55f6332b 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -140,6 +140,10 @@ Web 设置页的“通知渠道”分类提供单渠道测试入口。测试会 - `$title_json`:JSON 转义后的通知标题,推荐默认使用。 - `$content` / `$title`:原始字符串,不做 JSON 转义。正文含双引号、反斜杠或换行时可能导致 JSON 无效并触发 fallback。 +Docker Compose 部署中,Web 设置页保存该模板到 `.env` 时会自动把应用占位符写成 `$$content_json`、`$$title_json`、`$$content`、`$$title`,避免 Compose 将其当作宿主环境变量展开为空;应用运行时会还原为单个 `$` 占位符。若手工编辑 Docker 使用的 `.env`,也请按 `$$content_json` 形式保存。 + +该特性仅影响通知体渲染,不涉及 LLM `provider` / `model` / `base URL` / LiteLLM 路由的保存、迁移或清理语义;若某次结构化扫描出现 provider/API 兼容语义命中,命中范围应退回到本文件的报告模型展示与通知配置分离说明,而不是本次 webhook 修复链路本身。 + 通用 webhook 示例: ```env diff --git a/src/config.py b/src/config.py index 9276221a000..6a8394f6fb1 100644 --- a/src/config.py +++ b/src/config.py @@ -20,6 +20,7 @@ from dotenv import load_dotenv, dotenv_values from dataclasses import dataclass, field +from src.core.config_manager import unescape_compose_sensitive_env_value from src.report_language import ( is_supported_report_language_value, normalize_report_language, @@ -604,7 +605,26 @@ def setup_env(override: bool = False): env_path = Path(env_file) else: env_path = Path(__file__).parent.parent / '.env' + compose_sensitive_keys = ("CUSTOM_WEBHOOK_BODY_TEMPLATE",) + preexisting_compose_sensitive_keys = { + key for key in compose_sensitive_keys if key in os.environ + } load_dotenv(dotenv_path=env_path, override=override) + try: + raw_env_values = dotenv_values(env_path, interpolate=False) + except Exception as exc: # pragma: no cover - defensive branch + logger.warning("Failed to read raw .env values from %s: %s", env_path, exc) + return + + key = "CUSTOM_WEBHOOK_BODY_TEMPLATE" + if key in raw_env_values and ( + override or key not in preexisting_compose_sensitive_keys + ): + raw_value = raw_env_values.get(key) + os.environ[key] = unescape_compose_sensitive_env_value( + key, + "" if raw_value is None else str(raw_value), + ) @dataclass @@ -1618,7 +1638,10 @@ def _load_from_env(cls) -> 'Config': serverchan3_sendkey=os.getenv('SERVERCHAN3_SENDKEY'), custom_webhook_urls=[u.strip() for u in os.getenv('CUSTOM_WEBHOOK_URLS', '').split(',') if u.strip()], custom_webhook_bearer_token=os.getenv('CUSTOM_WEBHOOK_BEARER_TOKEN'), - custom_webhook_body_template=os.getenv('CUSTOM_WEBHOOK_BODY_TEMPLATE'), + custom_webhook_body_template=unescape_compose_sensitive_env_value( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE', + os.getenv('CUSTOM_WEBHOOK_BODY_TEMPLATE') or '', + ) or None, webhook_verify_ssl=os.getenv('WEBHOOK_VERIFY_SSL', 'true').lower() == 'true', discord_bot_token=os.getenv('DISCORD_BOT_TOKEN'), discord_main_channel_id=( @@ -2169,7 +2192,7 @@ def _get_env_file_value(cls, key: str) -> Optional[str]: value = env_values.get(key) if value is None: return None - return str(value) + return unescape_compose_sensitive_env_value(key, str(value)) @classmethod def _resolve_env_value( diff --git a/src/core/config_manager.py b/src/core/config_manager.py index cc20fb48fb5..b7060c3924c 100644 --- a/src/core/config_manager.py +++ b/src/core/config_manager.py @@ -17,10 +17,53 @@ _ASSIGNMENT_PATTERN = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$") _FALLBACK_REWRITE_ERRNOS = {errno.EBUSY, errno.EXDEV} +_COMPOSE_ESCAPED_ENV_VALUE_KEYS = frozenset({"CUSTOM_WEBHOOK_BODY_TEMPLATE"}) +_APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN = re.compile( + r"(? str: + """Escape app template placeholders that Docker Compose would interpolate.""" + if key.upper() not in _COMPOSE_ESCAPED_ENV_VALUE_KEYS: + return value + + def _replace(match: re.Match[str]) -> str: + braced_name = match.group(1) + plain_name = match.group(2) + if braced_name is not None: + return f"$${{{braced_name}}}" + return f"$${plain_name}" + + return _APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN.sub(_replace, value) + + +def unescape_compose_sensitive_env_value(key: str, value: str) -> str: + """Restore app template placeholders escaped for Docker Compose storage.""" + if key.upper() not in _COMPOSE_ESCAPED_ENV_VALUE_KEYS: + return value + + def _replace(match: re.Match[str]) -> str: + braced_name = match.group(1) + plain_name = match.group(2) + if braced_name is not None: + return f"${{{braced_name}}}" + return f"${plain_name}" + + return _ESCAPED_APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN.sub(_replace, value) + + @dataclass class ConfigLineEntry: """Structured representation of a single `.env` line.""" @@ -79,16 +122,38 @@ def env_path(self) -> Path: return self._env_path def read_config_map(self) -> Dict[str, str]: + """Read key-value mapping from `.env` file.""" + return self._read_config_map(normalize_values=True) + + def _read_config_map(self, *, normalize_values: bool) -> Dict[str, str]: """Read key-value mapping from `.env` file.""" if not self._env_path.exists(): return {} - values = dotenv_values(self._env_path) - return { - str(key): "" if value is None else str(value) - for key, value in values.items() - if key is not None - } + raw_values = dotenv_values(self._env_path, interpolate=False) + if normalize_values: + values = dotenv_values(self._env_path) + for raw_key, raw_value in raw_values.items(): + if ( + raw_key is not None + and str(raw_key).upper() in _COMPOSE_ESCAPED_ENV_VALUE_KEYS + ): + values[raw_key] = raw_value + else: + values = raw_values + config_map: Dict[str, str] = {} + for key, value in values.items(): + if key is None: + continue + normalized_key = str(key) + normalized_value = "" if value is None else str(value) + if normalize_values: + normalized_value = unescape_compose_sensitive_env_value( + normalized_key, + normalized_value, + ) + config_map[normalized_key] = normalized_value + return config_map def get_config_version(self) -> str: """Return deterministic version string based on file state.""" @@ -118,6 +183,7 @@ def apply_updates( """Apply updates into `.env` file using atomic replace when possible.""" with self._lock: current_values = self.read_config_map() + stored_values = self._read_config_map(normalize_values=False) mutable_updates: Dict[str, str] = {} skipped_masked: List[str] = [] @@ -130,7 +196,15 @@ def apply_updates( skipped_masked.append(key_upper) continue - if current_value == value: + stored_value = stored_values.get(key_upper) + canonical_stored_value = escape_compose_sensitive_env_value( + key_upper, + value.replace("\n", ""), + ) + if current_value == value and ( + key_upper not in _COMPOSE_ESCAPED_ENV_VALUE_KEYS + or stored_value == canonical_stored_value + ): continue mutable_updates[key_upper] = value @@ -147,6 +221,7 @@ def _atomic_upsert(self, updates: Dict[str, str]) -> None: for key, value in updates.items(): line_value = value.replace("\n", "") + line_value = escape_compose_sensitive_env_value(key, line_value) if key in key_to_index: entries[key_to_index[key]] = ConfigLineEntry.assignment(key, line_value) else: diff --git a/src/services/system_config_service.py b/src/services/system_config_service.py index 0dfee32d5f3..48e53cd071a 100644 --- a/src/services/system_config_service.py +++ b/src/services/system_config_service.py @@ -1671,14 +1671,14 @@ def apply_simple_updates( @staticmethod def _parse_imported_env_content(content: str) -> List[Dict[str, str]]: - """Parse raw `.env` text into update items using current dotenv semantics.""" + """Parse raw `.env` text into update items without expanding app templates.""" normalized_content = content.replace("\ufeff", "") if not normalized_content.strip(): raise ConfigImportError("未识别到有效 .env 配置") from dotenv import dotenv_values - parsed = dotenv_values(stream=io.StringIO(normalized_content)) + parsed = dotenv_values(stream=io.StringIO(normalized_content), interpolate=False) updates: List[Dict[str, str]] = [] for key, value in parsed.items(): if key is None: diff --git a/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index d239e393a1a..6be3d1e0cc1 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -561,6 +561,97 @@ def test_runtime_mutable_keys_use_process_env_when_absent_from_file( self.assertEqual(config.stock_list, ["600519", "000001"]) + @patch.object(Config, "_parse_litellm_yaml", return_value=[]) + def test_custom_webhook_template_unescapes_compose_saved_placeholders( + self, + _mock_parse_yaml, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + env_path = Path(temp_dir) / ".env" + env_path.write_text( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$$title_json,"content":$$content_json}\n', + encoding="utf-8", + ) + + with patch.dict( + os.environ, + { + "ENV_FILE": str(env_path), + "CUSTOM_WEBHOOK_BODY_TEMPLATE": '{"title":$$title_json,"content":$$content_json}', + }, + clear=True, + ): + config = Config._load_from_env() + + self.assertEqual( + config.custom_webhook_body_template, + '{"title":$title_json,"content":$content_json}', + ) + + @patch.object(Config, "_parse_litellm_yaml", return_value=[]) + def test_custom_webhook_template_unescapes_compose_saved_braced_placeholders( + self, + _mock_parse_yaml, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + env_path = Path(temp_dir) / ".env" + env_path.write_text( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$${content_json}}\n', + encoding="utf-8", + ) + + with patch.dict( + os.environ, + {"ENV_FILE": str(env_path)}, + clear=True, + ): + config = Config._load_from_env() + + self.assertEqual( + config.custom_webhook_body_template, + '{"content":${content_json}}', + ) + + @patch.object(Config, "_parse_litellm_yaml", return_value=[]) + def test_custom_webhook_template_does_not_affect_llm_contract( + self, + _mock_parse_litellm_yaml: object, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + env_path = Path(temp_dir) / ".env" + env_path.write_text( + "\n".join( + [ + "STOCK_LIST=600519", + "LITELLM_MODEL=openai/gpt-5.5", + "OPENAI_MODEL=gpt-5.5", + "OPENAI_API_KEY=runtime-openai-key", + "OPENAI_BASE_URL=https://openai.example/v1", + "CUSTOM_WEBHOOK_BODY_TEMPLATE={\"title\":$$title_json,\"content\":$$content_json}", + ] + ) + + "\n", + encoding="utf-8", + ) + + with patch.dict( + os.environ, + { + "ENV_FILE": str(env_path), + }, + clear=True, + ): + config = Config._load_from_env() + + self.assertEqual(config.litellm_model, "openai/gpt-5.5") + self.assertEqual(config.openai_model, "gpt-5.5") + self.assertEqual(config.openai_api_key, "runtime-openai-key") + self.assertEqual(config.openai_base_url, "https://openai.example/v1") + self.assertEqual( + config.custom_webhook_body_template, + '{"title":$title_json,"content":$content_json}', + ) + def test_refresh_stock_list_preserves_empty_required_config(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: env_path = Path(temp_dir) / ".env" diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 6b0d064c45c..feac872a54b 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -86,6 +86,131 @@ def test_apply_updates_falls_back_to_in_place_rewrite(self) -> None: self.assertEqual(self.env_path.read_text(encoding="utf-8"), "STOCK_LIST=000001\n") + def test_custom_webhook_template_placeholders_are_escaped_for_compose(self) -> None: + template = '{"title":$title_json,"content":$content_json,"raw":$content,"name":"$OTHER"}' + + self.manager.apply_updates( + updates=[("CUSTOM_WEBHOOK_BODY_TEMPLATE", template)], + sensitive_keys=set(), + mask_token="******", + ) + + env_content = self.env_path.read_text(encoding="utf-8") + self.assertIn( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$$title_json,"content":$$content_json,' + '"raw":$$content,"name":"$OTHER"}', + env_content, + ) + self.assertEqual( + self.manager.read_config_map()["CUSTOM_WEBHOOK_BODY_TEMPLATE"], + template, + ) + + def test_custom_webhook_template_braced_placeholders_are_escaped_for_compose(self) -> None: + template = '{"title":${title_json},"content":${content_json},"name":"${OTHER}"}' + + self.manager.apply_updates( + updates=[("CUSTOM_WEBHOOK_BODY_TEMPLATE", template)], + sensitive_keys=set(), + mask_token="******", + ) + + env_content = self.env_path.read_text(encoding="utf-8") + self.assertIn( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$${title_json},' + '"content":$${content_json},"name":"${OTHER}"}', + env_content, + ) + self.assertEqual( + self.manager.read_config_map()["CUSTOM_WEBHOOK_BODY_TEMPLATE"], + template, + ) + + def test_custom_webhook_template_canonicalizes_unescaped_existing_value(self) -> None: + self.env_path.write_text( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$content_json}\n', + encoding="utf-8", + ) + + self.manager.apply_updates( + updates=[("CUSTOM_WEBHOOK_BODY_TEMPLATE", '{"content":$content_json}')], + sensitive_keys=set(), + mask_token="******", + ) + + self.assertEqual( + self.env_path.read_text(encoding="utf-8"), + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$$content_json}\n', + ) + + def test_custom_webhook_template_does_not_double_escape_existing_value(self) -> None: + self.env_path.write_text( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$$content_json}\n', + encoding="utf-8", + ) + + self.manager.apply_updates( + updates=[("CUSTOM_WEBHOOK_BODY_TEMPLATE", '{"content":$content_json}')], + sensitive_keys=set(), + mask_token="******", + ) + + self.assertEqual( + self.env_path.read_text(encoding="utf-8"), + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$$content_json}\n', + ) + self.assertEqual( + self.manager.read_config_map()["CUSTOM_WEBHOOK_BODY_TEMPLATE"], + '{"content":$content_json}', + ) + + def test_custom_webhook_template_plain_json_is_not_changed(self) -> None: + template = '{"content":"plain json string"}' + + self.manager.apply_updates( + updates=[("CUSTOM_WEBHOOK_BODY_TEMPLATE", template)], + sensitive_keys=set(), + mask_token="******", + ) + + self.assertEqual( + self.env_path.read_text(encoding="utf-8"), + f"CUSTOM_WEBHOOK_BODY_TEMPLATE={template}\n", + ) + + def test_non_template_settings_keep_dotenv_interpolation_semantics(self) -> None: + self.env_path.write_text( + "\n".join( + [ + "API_PORT=8000", + "WEBUI_PORT=${API_PORT}", + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$${content_json}}', + ] + ) + + "\n", + encoding="utf-8", + ) + + config_map = self.manager.read_config_map() + + self.assertEqual(config_map["API_PORT"], "8000") + self.assertEqual(config_map["WEBUI_PORT"], "8000") + self.assertEqual( + config_map["CUSTOM_WEBHOOK_BODY_TEMPLATE"], + '{"content":${content_json}}', + ) + + self.manager.apply_updates( + updates=[("WEBUI_PORT", config_map["WEBUI_PORT"])], + sensitive_keys=set(), + mask_token="******", + ) + + self.assertIn( + "WEBUI_PORT=${API_PORT}\n", + self.env_path.read_text(encoding="utf-8"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_system_config_api.py b/tests/test_system_config_api.py index 72018e8b8bd..ab516899750 100644 --- a/tests/test_system_config_api.py +++ b/tests/test_system_config_api.py @@ -196,6 +196,42 @@ def test_put_config_updates_secret_and_plain_field(self) -> None: self.assertIn("STOCK_LIST=600519,300750", env_content) self.assertIn("GEMINI_API_KEY=new-secret-value", env_content) + def test_put_config_escapes_custom_webhook_template_placeholders(self) -> None: + template = '{"title":$title_json,"content":$content_json}' + current = system_config.get_system_config( + include_schema=False, + service=self.service, + ).model_dump() + + payload = system_config.update_system_config( + request=UpdateSystemConfigRequest( + config_version=current["config_version"], + mask_token="******", + reload_now=False, + items=[ + { + "key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", + "value": template, + }, + ], + ), + service=self.service, + ).model_dump() + + self.assertEqual(payload["applied_count"], 1) + self.assertIn( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$$title_json,"content":$$content_json}\n', + self.env_path.read_text(encoding="utf-8"), + ) + item_map = { + item["key"]: item + for item in system_config.get_system_config( + include_schema=True, + service=self.service, + ).model_dump(by_alias=True)["items"] + } + self.assertEqual(item_map["CUSTOM_WEBHOOK_BODY_TEMPLATE"]["value"], template) + def test_put_config_returns_conflict_when_version_is_stale(self) -> None: with self.assertRaises(HTTPException) as context: system_config.update_system_config( diff --git a/tests/test_system_config_service.py b/tests/test_system_config_service.py index 8666277959d..e7234e7d638 100644 --- a/tests/test_system_config_service.py +++ b/tests/test_system_config_service.py @@ -698,6 +698,40 @@ def test_import_desktop_env_allows_empty_assignment(self) -> None: current_map = self.manager.read_config_map() self.assertEqual(current_map["LOG_LEVEL"], "") + def test_import_desktop_env_preserves_exported_braced_webhook_template(self) -> None: + template = '{"content":${content_json}}' + + save_payload = self.service.update( + config_version=self.manager.get_config_version(), + items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": template}], + reload_now=False, + ) + self.assertTrue(save_payload["success"]) + backup_content = self.service.export_desktop_env()["content"] + self.assertIn( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$${content_json}}\n', + backup_content, + ) + + clear_payload = self.service.update( + config_version=self.manager.get_config_version(), + items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": ""}], + reload_now=False, + ) + self.assertTrue(clear_payload["success"]) + + restore_payload = self.service.import_desktop_env( + config_version=self.manager.get_config_version(), + content=backup_content, + reload_now=False, + ) + + self.assertTrue(restore_payload["success"]) + self.assertEqual( + self.manager.read_config_map()["CUSTOM_WEBHOOK_BODY_TEMPLATE"], + template, + ) + def test_import_desktop_env_rejects_empty_or_comment_only_content(self) -> None: with self.assertRaises(ConfigImportError): self.service.import_desktop_env( @@ -2613,6 +2647,58 @@ def test_update_with_reload_applies_updated_env_file_when_process_env_is_stale(s self.assertTrue(response["success"]) self.assertEqual(Config.get_instance().stock_list, ["300750", "TSLA"]) + @patch.object(SystemConfigService, "_reload_runtime_singletons") + def test_update_escapes_custom_webhook_template_and_runtime_reads_literals( + self, + _mock_reload_runtime_singletons, + ) -> None: + template = '{"title":$title_json,"content":$content_json}' + + response = self.service.update( + config_version=self.manager.get_config_version(), + items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": template}], + reload_now=True, + ) + + self.assertTrue(response["success"]) + self.assertIn( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$$title_json,"content":$$content_json}\n', + self.env_path.read_text(encoding="utf-8"), + ) + self.assertEqual(Config.get_instance().custom_webhook_body_template, template) + + items = { + item["key"]: item + for item in self.service.get_config(include_schema=True)["items"] + } + self.assertEqual(items["CUSTOM_WEBHOOK_BODY_TEMPLATE"]["value"], template) + + @patch.object(SystemConfigService, "_reload_runtime_singletons") + def test_update_escapes_braced_custom_webhook_template_and_runtime_reads_literals( + self, + _mock_reload_runtime_singletons, + ) -> None: + template = '{"content":${content_json}}' + + response = self.service.update( + config_version=self.manager.get_config_version(), + items=[{"key": "CUSTOM_WEBHOOK_BODY_TEMPLATE", "value": template}], + reload_now=True, + ) + + self.assertTrue(response["success"]) + self.assertIn( + 'CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$${content_json}}\n', + self.env_path.read_text(encoding="utf-8"), + ) + self.assertEqual(Config.get_instance().custom_webhook_body_template, template) + + items = { + item["key"]: item + for item in self.service.get_config(include_schema=True)["items"] + } + self.assertEqual(items["CUSTOM_WEBHOOK_BODY_TEMPLATE"]["value"], template) + def test_update_raises_conflict_for_stale_version(self) -> None: with self.assertRaises(ConfigConflictError): self.service.update(