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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,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 模板;支持 $content_json/$content/$title_json/$title
# CUSTOM_WEBHOOK_BODY_TEMPLATE= # 可选,全局 JSON body 模板,会覆盖 Bark/Slack/Discord 等自动 payload;推荐 $content_json/$title_json
# WEBHOOK_VERIFY_SSL=true # 默认校验。设为 false 可支持自签名证书。警告:禁用后存在 MITM 劫持风险,仅限可信内网
#
# 【方式六】Pushover 配置
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ describe('SettingsField', () => {
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
});

it('renders localized custom webhook body template guidance', () => {
const onChange = vi.fn();

render(
<SettingsField
item={{
key: 'CUSTOM_WEBHOOK_BODY_TEMPLATE',
value: '',
rawValueExists: false,
isMasked: false,
schema: {
key: 'CUSTOM_WEBHOOK_BODY_TEMPLATE',
category: 'notification',
dataType: 'string',
uiControl: 'textarea',
isSensitive: false,
isRequired: false,
isEditable: true,
options: [],
validation: {},
displayOrder: 52,
},
}}
value=""
onChange={onChange}
/>
);

expect(screen.getByLabelText('自定义 Webhook Body 模板')).toBeInTheDocument();
expect(screen.getByText(/会先于 Bark、Slack、Discord 等自动 payload 生效/)).toBeInTheDocument();
expect(screen.getByText(/裸 \$content \/ \$title 不做 JSON 转义/)).toBeInTheDocument();
});

it('opens detailed field help when help metadata is available', () => {
render(
<SettingsField
Expand Down
2 changes: 2 additions & 0 deletions apps/dsa-web/src/utils/systemConfigI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const fieldTitleMap: Record<string, string> = {
FEISHU_APP_SECRET: '飞书应用 App Secret',
DINGTALK_APP_KEY: '钉钉 App Key',
DINGTALK_APP_SECRET: '钉钉 App Secret',
CUSTOM_WEBHOOK_BODY_TEMPLATE: '自定义 Webhook Body 模板',
PUSHPLUS_TOKEN: 'PushPlus Token',
REPORT_SUMMARY_ONLY: '仅分析结果摘要',
MAX_WORKERS: '最大并发线程数',
Expand Down Expand Up @@ -127,6 +128,7 @@ const fieldDescriptionMap: Record<string, string> = {
FEISHU_APP_SECRET: '仅用于飞书应用机器人 / Stream Bot / 云文档等应用模式,不会直接开启群 Webhook 推送。',
DINGTALK_APP_KEY: '钉钉应用模式 App Key。',
DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',
CUSTOM_WEBHOOK_BODY_TEMPLATE: '全局 JSON body 模板,会先于 Bark、Slack、Discord 等自动 payload 生效;推荐使用 $content_json / $title_json,裸 $content / $title 不做 JSON 转义。',
PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',
REPORT_SUMMARY_ONLY: '仅推送分析结果摘要,不包含个股详情。多股时适合快速浏览。',
MAX_WORKERS: '异步任务队列最大并发数。配置保存后,队列空闲时会自动应用;繁忙时延后生效。',
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修正 LLM 渠道测试中 `Your request was blocked` 等服务商或网关拦截错误被误报为网络异常的问题。
- [chore] 清理仓库根目录:移除误入库的 `.codex`、`review.md` 跟踪记录,将 smoke 测试入口迁移到 `scripts/`、环境检查脚本迁移为 `scripts/check_env.py`,并将 LiteLLM YAML 示例迁移到 `docs/examples/`。
- [新功能] Web 设置页新增通知渠道一键测试,支持临时配置、耗时与脱敏 attempts 展示。
- [改进] 产品化自定义 Webhook Body 模板说明,明确全局模板优先级、JSON 转义建议和 Bark / NapCat 适配示例。
- [新功能] 系统设置页新增配置项帮助入口与多语言帮助文案基础设施,首批覆盖自选股、LLM 主模型、LLM 渠道、飞书 Webhook 与 WebUI 监听地址。
- [改进] 设置项帮助窗口支持键盘焦点限制、Esc 关闭和关闭后焦点恢复,并移除短描述重复 hover tooltip。
- [文档] 新增设置页配置帮助维护说明,明确帮助元数据字段、首批覆盖范围、事实源和多语言文案同步规则。
Expand Down
16 changes: 14 additions & 2 deletions docs/full-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -743,13 +743,25 @@ EMAIL_GROUP_2=user2@example.com

设置 `CUSTOM_WEBHOOK_URLS`,多个用逗号分隔。

如需适配 AstrBot、NapCat 或自建服务的特殊 body,可设置 `CUSTOM_WEBHOOK_BODY_TEMPLATE`。该值必须渲染为 JSON object,推荐使用 `$content_json` 避免换行和引号破坏 JSON:
如需适配 AstrBot、NapCat 或自建服务的特殊 body,可设置 `CUSTOM_WEBHOOK_BODY_TEMPLATE`。这是全局模板,会先于 Bark、Slack、Discord 等 URL 自动识别 payload 生效;如果渲染后不是 JSON object,系统会回退默认 payload。推荐使用 `$content_json` / `$title_json` 避免换行和引号破坏 JSON:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"msg_type":"text","content":$content_json}
```

可用占位符:`$content_json`、`$content`、`$title_json`、`$title`。
可用占位符:`$content_json`、`$content`、`$title_json`、`$title`。其中 `$content` / `$title` 是裸字符串,不做 JSON 转义;正文含双引号或换行时可能触发 fallback。

Bark 使用全局模板时需显式写出 Bark body:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$title_json,"body":$content_json,"group":"stock"}
```

NapCat / OneBot 示例需按实际 endpoint、`user_id` 或 `group_id` 调整:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"user_id":123456,"message":$content_json}
```

### Discord

Expand Down
21 changes: 19 additions & 2 deletions docs/full-guide_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,14 +643,31 @@ Supports any POST JSON Webhook, including:
Set `CUSTOM_WEBHOOK_URLS`, separate multiple with commas.

If AstrBot, NapCat, or a self-hosted service requires a custom request body, set
`CUSTOM_WEBHOOK_BODY_TEMPLATE`. The rendered value must be a JSON object. Prefer
`$content_json` so newlines and quotes stay valid JSON:
`CUSTOM_WEBHOOK_BODY_TEMPLATE`. This is a global template and is rendered before
URL auto-detected payloads such as Bark, Slack, or Discord. If the rendered value
is not a JSON object, DSA falls back to the default payload. Prefer
`$content_json` / `$title_json` so newlines and quotes stay valid JSON:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"msg_type":"text","content":$content_json}
```

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.

When using Bark with a global template, include the Bark body explicitly:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$title_json,"body":$content_json,"group":"stock"}
```

NapCat / OneBot examples must be adjusted for your actual endpoint, `user_id`,
or `group_id`:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"user_id":123456,"message":$content_json}
```

### Discord

Expand Down
44 changes: 42 additions & 2 deletions docs/notifications.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 通知能力基线

本文档记录通知能力 P0/P1 基线:渠道、配置 key、GitHub Actions 映射、Web 设置元数据、CLI 诊断口径和 Web 一键测试。P0 只做基线与只读诊断;P1 增加 Web 单渠道真实测试,不包含渠道路由、降噪、模板产品化等后续 Phase 能力
本文档记录通知能力 P0-P2 基线:渠道、配置 key、GitHub Actions 映射、Web 设置元数据、CLI 诊断口径、Web 一键测试和自定义 Webhook Body 模板语义。P0 只做基线与只读诊断;P1 增加 Web 单渠道真实测试;P2 只产品化现有 Body 模板,不包含渠道路由、降噪、per-URL 模板或新增一等渠道

## 渠道基线

Expand All @@ -25,7 +25,7 @@

- Minimal key:足以启用一个通知渠道的最小配置。
- Advanced key:只影响认证、安全、格式、线程、群组、证书校验或展示行为,不能单独启用渠道。
- P0 不新增路由、降噪或发送策略语义;相关配置如未来引入,应先更新本文档、`.env.example`、Web 元数据与回归测试。
- P0-P2 不新增路由、降噪或发送策略语义;相关配置如未来引入,应先更新本文档、`.env.example`、Web 元数据与回归测试。

## GitHub Actions 映射

Expand Down Expand Up @@ -60,6 +60,46 @@ Web 设置页的“通知渠道”分类提供单渠道测试入口。测试会
- 返回结果会脱敏 token、secret、password、Bearer、完整 webhook query 和疑似 path token。
- 配置缺失或发送失败返回 `success=false`,不会影响已保存配置和默认分析流程。

## 自定义 Webhook Body 模板

`CUSTOM_WEBHOOK_BODY_TEMPLATE` 是自定义 Webhook 的全局 JSON body 模板。配置后,它会先于 URL 自动识别生效,因此会覆盖 Bark、Slack、Discord、钉钉等自动 payload。未配置时仍使用原有 URL 自动识别;渲染后不是合法 JSON object 时会记录错误并回退默认 payload,不中断主通知流程。

可用占位符:

- `$content_json`:JSON 转义后的通知正文,推荐默认使用。
- `$title_json`:JSON 转义后的通知标题,推荐默认使用。
- `$content` / `$title`:原始字符串,不做 JSON 转义。正文含双引号、反斜杠或换行时可能导致 JSON 无效并触发 fallback。

通用 webhook 示例:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$title_json,"content":$content_json}
```

Bark 通过 custom webhook 使用时,默认会按 `api.day.app` 自动生成 `title` / `body` / `group`。如果配置全局模板,需要自己写出 Bark body:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"title":$title_json,"body":$content_json,"group":"stock"}
```

AstrBot 已是一等通知渠道,优先使用 `ASTRBOT_URL` 和可选的 `ASTRBOT_TOKEN`。只有需要把 AstrBot 兼容端点放入 `CUSTOM_WEBHOOK_URLS` 时,才使用 custom webhook 模板,例如:

```env
CUSTOM_WEBHOOK_BODY_TEMPLATE={"content":$content_json}
```

NapCat / OneBot HTTP API 需要按实际 endpoint 和目标类型调整。下面只是常见 body 形态示例,`user_id`、`group_id`、URL 路径和鉴权方式都应以你的 NapCat 配置为准:

```env
# 私聊:CUSTOM_WEBHOOK_URLS=http://127.0.0.1:3000/send_private_msg
CUSTOM_WEBHOOK_BODY_TEMPLATE={"user_id":123456,"message":$content_json}
```

```env
# 群聊:CUSTOM_WEBHOOK_URLS=http://127.0.0.1:3000/send_group_msg
CUSTOM_WEBHOOK_BODY_TEMPLATE={"group_id":123456789,"message":$content_json}
```

## 场景占位

- Local:优先使用 `.env`,可用 `python main.py --check-notify` 做本地诊断。
Expand Down
6 changes: 4 additions & 2 deletions src/core/config_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,8 +904,10 @@
"CUSTOM_WEBHOOK_BODY_TEMPLATE": {
"title": "Custom Webhook Body Template",
"description": (
"Optional JSON body template for custom webhooks. Supports $content_json, "
"$content, $title_json, and $title placeholders."
"Optional global JSON body template for custom webhooks. It is rendered before "
"URL auto-detected payloads such as Bark, Slack, or Discord, and must render to a "
"JSON object. Prefer $content_json and $title_json; raw $content and $title are "
"not JSON-escaped and can make the template invalid."
),
"category": "notification",
"data_type": "string",
Expand Down
41 changes: 41 additions & 0 deletions tests/test_notification_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,47 @@ def test_bark_payload_truncates_long_content(self):
self.assertEqual(len(payload["body"]), 4000)
self.assertEqual(payload["body"], "x" * 4000)

def test_custom_body_template_overrides_bark_auto_payload(self):
cfg = _config(
custom_webhook_body_template=(
'{"title":$title_json,"body":$content_json,"sound":"bell"}'
),
)
sender = CustomWebhookSender(cfg)

payload = sender._build_custom_webhook_payload("https://api.day.app/key", "hello")

self.assertEqual(
payload,
{
"title": "股票分析报告",
"body": "hello",
"sound": "bell",
},
)
self.assertNotIn("group", payload)

def test_custom_body_template_json_placeholders_escape_content(self):
cfg = _config(
custom_webhook_body_template=(
'{"title":$title_json,"content":$content_json}'
),
)
sender = CustomWebhookSender(cfg)

payload = sender._build_custom_webhook_payload(
"https://example.com/webhook",
'line 1\nline "2"',
)

self.assertEqual(
payload,
{
"title": "股票分析报告",
"content": 'line 1\nline "2"',
},
)

@mock.patch("src.notification_sender.custom_webhook_sender.requests.post")
def test_send_uses_custom_body_template(self, mock_post):
mock_post.return_value = _response(200)
Expand Down
Loading