Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>)
# 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 配置
Expand Down
9 changes: 8 additions & 1 deletion apps/dsa-web/src/locales/settingsHelp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 验证成功,再扩展到多个目标。'],
Expand Down Expand Up @@ -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.'],
},
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/full-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/full-guide_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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(
Expand Down
89 changes: 82 additions & 7 deletions src/core/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\$)\$(?:"
r"\{(content_json|title_json|content|title)\}"
r"|(content_json|title_json|content|title)\b"
r")"
)
_ESCAPED_APPLICATION_TEMPLATE_PLACEHOLDER_PATTERN = re.compile(
r"\$\$(?:"
r"\{(content_json|title_json|content|title)\}"
r"|(content_json|title_json|content|title)\b"
r")"
)

logger = logging.getLogger(__name__)


def escape_compose_sensitive_env_value(key: str, value: str) -> 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}}}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve braced templates during env import

When a user saves the valid braced string.Template form, this line persists it as $${content_json} in the raw .env. The Settings export/import path exports that raw value, but SystemConfigService._parse_imported_env_content still parses imports with dotenv_values(stream=...) interpolation enabled, which turns $${content_json} into $ (or $<env value>) before ConfigManager can unescape it. In the export-then-import/restore flow, the restored webhook template becomes invalid instead of ${content_json}; parse imported env content with interpolate=False or use an import-safe escape for the braced form.

Useful? React with 👍 / 👎.

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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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] = []

Expand All @@ -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
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/services/system_config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore interpolation for imported non-template keys

When an imported/exported .env contains ordinary dotenv references such as API_PORT=8000 and WEBUI_PORT=${API_PORT}, parsing the entire import with interpolate=False leaves WEBUI_PORT as the literal ${API_PORT}. That value then goes through update() and _validate_value(), where WEBUI_PORT is an integer field, so a valid backup that previously restored as 8000 is rejected; for string fields it can also persist unresolved expressions that no longer match the interpolated runtime view. Please bypass interpolation only for CUSTOM_WEBHOOK_BODY_TEMPLATE or re-apply interpolation semantics to non-template keys after preserving the webhook template.

Useful? React with 👍 / 👎.

updates: List[Dict[str, str]] = []
for key, value in parsed.items():
if key is None:
Expand Down
91 changes: 91 additions & 0 deletions tests/test_config_env_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading