From 512cae7e7e60684c31c021384a3cd6d01acd1152 Mon Sep 17 00:00:00 2001 From: AutoCode Bot Date: Sun, 21 Jun 2026 23:36:45 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(issue-1739):=20[bug]-docker=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E9=83=A8=E7=BD=B2=E6=97=B6=EF=BC=8C.env=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=87=8C=E7=9A=84=E9=80=9A=E7=9F=A5=E5=8F=98=E9=87=8F?= =?UTF-8?q?$content=5Fj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + apps/dsa-web/src/locales/settingsHelp.ts | 7 ++- docs/CHANGELOG.md | 1 + docs/full-guide.md | 2 + docs/full-guide_EN.md | 5 ++ docs/notifications.md | 2 + src/config.py | 8 ++- src/core/config_manager.py | 52 +++++++++++++++-- tests/test_config_env_compat.py | 27 +++++++++ tests/test_config_manager.py | 72 ++++++++++++++++++++++++ tests/test_system_config_api.py | 36 ++++++++++++ tests/test_system_config_service.py | 26 +++++++++ 12 files changed, 230 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 4f3975eb4e..e4ac3d199d 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 d6b9c57417..e3a069dd06 100644 --- a/apps/dsa-web/src/locales/settingsHelp.ts +++ b/apps/dsa-web/src/locales/settingsHelp.ts @@ -387,6 +387,7 @@ const settingsHelpZhCN: SettingsHelpMap = { valueNotes: [ '模板必须渲染为 JSON object。', '推荐使用 $content_json、$title_json 避免换行和引号破坏 JSON。', + 'Docker 部署中保存到 .env 时会自动写成 $$content_json、$$title_json;运行时仍会还原为单个 $ 占位符。', ], impact: ['影响 AstrBot、NapCat、自建服务等自定义推送。'], notes: ['先用一个 Webhook 验证成功,再扩展到多个目标。'], @@ -1383,7 +1384,11 @@ 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.', + ], 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 20a5ccac43..f5529398ab 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 5974ffdc6f..77810b4b79 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 a571e63825..0bdb81f618 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 e21ba2696a..e55fbed6a2 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -140,6 +140,8 @@ Web 设置页的“通知渠道”分类提供单渠道测试入口。测试会 - `$title_json`:JSON 转义后的通知标题,推荐默认使用。 - `$content` / `$title`:原始字符串,不做 JSON 转义。正文含双引号、反斜杠或换行时可能导致 JSON 无效并触发 fallback。 +Docker Compose 部署中,Web 设置页保存该模板到 `.env` 时会自动把应用占位符写成 `$$content_json`、`$$title_json`、`$$content`、`$$title`,避免 Compose 将其当作宿主环境变量展开为空;应用运行时会还原为单个 `$` 占位符。若手工编辑 Docker 使用的 `.env`,也请按 `$$content_json` 形式保存。 + 通用 webhook 示例: ```env diff --git a/src/config.py b/src/config.py index 9276221a00..c7fcfd69e4 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, @@ -1618,7 +1619,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 +2173,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 cc20fb48fb..db422467a7 100644 --- a/src/core/config_manager.py +++ b/src/core/config_manager.py @@ -17,10 +17,31 @@ _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 + return _APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN.sub(r"$$\1", 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 + return _ESCAPED_APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN.sub(r"$\1", value) + + @dataclass class ConfigLineEntry: """Structured representation of a single `.env` line.""" @@ -79,16 +100,28 @@ 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 - } + 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 +151,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 +164,12 @@ 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 stored_value == canonical_stored_value: continue mutable_updates[key_upper] = value @@ -147,6 +186,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/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index d239e393a1..cede069dc3 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -561,6 +561,33 @@ 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}', + ) + 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 6b0d064c45..d706447abc 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -86,6 +86,78 @@ 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_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", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_system_config_api.py b/tests/test_system_config_api.py index 72018e8b8b..ab51689975 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 8666277959..6c4d1b2238 100644 --- a/tests/test_system_config_service.py +++ b/tests/test_system_config_service.py @@ -2613,6 +2613,32 @@ 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) + def test_update_raises_conflict_for_stale_version(self) -> None: with self.assertRaises(ConfigConflictError): self.service.update( From e1f00f412859fa5523a223576d1d0a393468ef1a Mon Sep 17 00:00:00 2001 From: AutoCode Bot Date: Sun, 21 Jun 2026 23:56:21 +0800 Subject: [PATCH 2/5] fix(review-feedback-1741): escape/unescape the braced forms too, or reject them before persisting --- src/config.py | 19 +++++++++++++++++ src/core/config_manager.py | 32 ++++++++++++++++++++++++----- tests/test_config_env_compat.py | 24 ++++++++++++++++++++++ tests/test_config_manager.py | 20 ++++++++++++++++++ tests/test_system_config_service.py | 26 +++++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/config.py b/src/config.py index c7fcfd69e4..6a8394f6fb 100644 --- a/src/config.py +++ b/src/config.py @@ -605,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 diff --git a/src/core/config_manager.py b/src/core/config_manager.py index db422467a7..4e5c309400 100644 --- a/src/core/config_manager.py +++ b/src/core/config_manager.py @@ -19,10 +19,16 @@ _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 - return _APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN.sub(r"$$\1", 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 - return _ESCAPED_APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN.sub(r"$\1", 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 @@ -108,7 +130,7 @@ def _read_config_map(self, *, normalize_values: bool) -> Dict[str, str]: if not self._env_path.exists(): return {} - values = dotenv_values(self._env_path) + values = dotenv_values(self._env_path, interpolate=False) config_map: Dict[str, str] = {} for key, value in values.items(): if key is None: diff --git a/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index cede069dc3..b086047276 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -588,6 +588,30 @@ def test_custom_webhook_template_unescapes_compose_saved_placeholders( '{"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}}', + ) + 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 d706447abc..4e19ada871 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -106,6 +106,26 @@ def test_custom_webhook_template_placeholders_are_escaped_for_compose(self) -> N 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', diff --git a/tests/test_system_config_service.py b/tests/test_system_config_service.py index 6c4d1b2238..344229b758 100644 --- a/tests/test_system_config_service.py +++ b/tests/test_system_config_service.py @@ -2639,6 +2639,32 @@ def test_update_escapes_custom_webhook_template_and_runtime_reads_literals( } 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( From c51e807ce041a58d117334ae123c92e71cfabd9c Mon Sep 17 00:00:00 2001 From: AutoCode Bot Date: Mon, 22 Jun 2026 00:13:10 +0800 Subject: [PATCH 3/5] fix(review-feedback-1741): preserve braced templates during env import --- src/services/system_config_service.py | 4 ++-- tests/test_system_config_service.py | 34 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/services/system_config_service.py b/src/services/system_config_service.py index 0dfee32d5f..48e53cd071 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_system_config_service.py b/tests/test_system_config_service.py index 344229b758..e7234e7d63 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( From ee3c7c47e87da1aa5fa6859af4fbdf6dd3f1f238 Mon Sep 17 00:00:00 2001 From: AutoCode Bot Date: Mon, 22 Jun 2026 00:39:35 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(review-feedback-1741):=20=E6=BE=84?= =?UTF-8?q?=E6=B8=85=E5=A4=96=E9=83=A8=E6=A8=A1=E5=9E=8B/API=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A3=8E=E9=99=A9=E6=8F=90=E7=A4=BA=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E7=9C=9F=E5=AE=9E=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dsa-web/src/locales/settingsHelp.ts | 2 ++ docs/notifications.md | 2 ++ tests/test_config_env_compat.py | 40 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/apps/dsa-web/src/locales/settingsHelp.ts b/apps/dsa-web/src/locales/settingsHelp.ts index e3a069dd06..2fe72fbd57 100644 --- a/apps/dsa-web/src/locales/settingsHelp.ts +++ b/apps/dsa-web/src/locales/settingsHelp.ts @@ -388,6 +388,7 @@ const settingsHelpZhCN: SettingsHelpMap = { '模板必须渲染为 JSON object。', '推荐使用 $content_json、$title_json 避免换行和引号破坏 JSON。', 'Docker 部署中保存到 .env 时会自动写成 $$content_json、$$title_json;运行时仍会还原为单个 $ 占位符。', + '本字段仅影响自定义 Webhook 的 payload 行为,不会改写 LLM provider、模型名、Base URL 或迁移优先级。', ], impact: ['影响 AstrBot、NapCat、自建服务等自定义推送。'], notes: ['先用一个 Webhook 验证成功,再扩展到多个目标。'], @@ -1388,6 +1389,7 @@ const settingsHelpEnUS: SettingsHelpMap = { '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/notifications.md b/docs/notifications.md index e55fbed6a2..3bc55f6332 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -142,6 +142,8 @@ Web 设置页的“通知渠道”分类提供单渠道测试入口。测试会 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/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index b086047276..6be3d1e0cc 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -612,6 +612,46 @@ def test_custom_webhook_template_unescapes_compose_saved_braced_placeholders( '{"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" From 3270d4d05c9b22b56458a9e250ee72a65aba35d4 Mon Sep 17 00:00:00 2001 From: AutoCode Bot Date: Mon, 22 Jun 2026 01:07:07 +0800 Subject: [PATCH 5/5] fix(review-feedback-1741): limit raw parsing to CUSTOM WEBHOOK BODY TEMPLATE or re-apply the --- src/core/config_manager.py | 17 +++++++++++++++-- tests/test_config_manager.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/core/config_manager.py b/src/core/config_manager.py index 4e5c309400..b7060c3924 100644 --- a/src/core/config_manager.py +++ b/src/core/config_manager.py @@ -130,7 +130,17 @@ def _read_config_map(self, *, normalize_values: bool) -> Dict[str, str]: if not self._env_path.exists(): return {} - values = dotenv_values(self._env_path, interpolate=False) + 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: @@ -191,7 +201,10 @@ def apply_updates( key_upper, value.replace("\n", ""), ) - if current_value == value and stored_value == canonical_stored_value: + 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 diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 4e19ada871..feac872a54 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -178,6 +178,39 @@ def test_custom_webhook_template_plain_json_is_not_changed(self) -> None: 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()