Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -82,4 +82,37 @@ describe('SettingsField', () => {
expect(screen.getAllByRole('button', { name: '显示内容' })).toHaveLength(2);
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();
});
});
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 @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修正 LLM 渠道测试中 `Model disabled` 被误报为网络异常的问题,并在失败提示中展示本次实际测试模型。
- [chore] 清理仓库根目录:移除误入库的 `.codex`、`review.md` 跟踪记录,将 smoke 测试入口迁移到 `scripts/`、环境检查脚本迁移为 `scripts/check_env.py`,并将 LiteLLM YAML 示例迁移到 `docs/examples/`。
- [新功能] Web 设置页新增通知渠道一键测试,支持临时配置、耗时与脱敏 attempts 展示。
- [改进] 产品化自定义 Webhook Body 模板说明,明确全局模板优先级、JSON 转义建议和 Bark / NapCat 适配示例。

## [3.15.0] - 2026-05-05

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 @@ -853,8 +853,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