feat: 新增 SafeW 出站通知渠道

通过 Bot API sendMessage 投递渲染后的通知,MarkdownV2 自动转义标题与正文。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-14 00:58:44 +08:00
parent 39f3774940
commit 0d0cd0c510
7 changed files with 1003 additions and 3 deletions
@@ -0,0 +1,122 @@
# SafeW 渠道设计
**Date:** 2026-08-14
**Status:** Approved
## Goal
新增出站渠道 `safew`:规则命中并渲染模板后,通过 SafeW Bot API `sendMessage` 把通知发到指定会话。
## Non-goals
- 接收 SafeW webhook / 入站更新
- 全局 bot token`config.yaml` 不加 safew 段)
- 自定义 API base URL
- 钉钉式按分钟限流排队
- 远程 e2e 真实发送(无现成测试 bot 凭据)
- 管理 API 对 channel `type` 做白名单校验(现有渠道也没有)
## Architecture
沿用现有扩展点:实现 `adapter.ChannelSender`,在 `adapter.NewSender` 注册 `"safew"`
不改表结构。`channel.type` 为字符串,`channel.config` 为 JSON。
发送仍走:
```
POST /api/v1/notify
→ 匹配规则、渲染模板
→ Router 按 ch.Type 取 sender
→ 异步 Send(title, content, config)
→ 失败重试 1s / 5s / 30s
→ 写入 message_log
```
`title` 沿用现有值:`{source.name}: {event}``content` 为模板渲染结果。
## Channel config
创建 channel 时:
```json
{
"name": "safew-ops",
"type": "safew",
"config": {
"token": "<bot token>",
"chat_id": "123456789"
},
"status": 1
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `token` | string | 是 | SafeW Bot Token,每个 channel 独立 |
| `chat_id` | string 或 number | 是 | 目标会话。JSON 里数字 ID(`123`)或字符串(`"123"` / `"@name"`)都接受,sender 归一成字符串再发给 SafeW |
`token` 或空 `chat_id`:立即返回 error,不发 HTTP。`token` / `chat_id` 读写前 `strings.TrimSpace`
## Send contract
- URL`POST https://api.safew.bot/bot{token}/sendMessage`
- Header`Content-Type: application/json`
- Body
```json
{
"chat_id": "<chat_id>",
"text": "*<escaped title>*\n<escaped content>",
"parse_mode": "MarkdownV2"
}
```
### MarkdownV2 转义
标题和正文都自动转义后再组装。模板里的 markdown 会变成字面量,避免特殊字符导致 400。
转义字符:`_ * [ ] ( ) ~ \` > # + - = | { } . !`
规则:每个字符前加 `\`。
组装:`text = "*" + escape(title) + "*\n" + escape(content)`
## Error handling
| 情况 | 行为 |
|------|------|
| config JSON 非法、缺 `token` / `chat_id` | 返回 error(router 仍会按现有策略重试,每次同样失败) |
| 网络错误、HTTP status ≥ 400 | 返回 error,触发重试 |
| HTTP 2xx 但 body `{"ok":false}` | 视为失败;error 带上 SafeW 的 `description` |
| HTTP 2xx 且 `ok: true` | 成功 |
不单独处理 429。不引入新的限流器。
## Components
| 文件 | 改动 |
|------|------|
| `internal/adapter/safew.go` | 新增 `SafeWSender` |
| `internal/adapter/safew_test.go` | httptest mock 单元测试 |
| `internal/adapter/adapter.go` | `NewSender` 增加 `case "safew"` |
| `README.md` | 渠道列表与创建示例 |
| `docs/httpie/curls.md` | 创建 SafeW channel 的 curl |
## Tests
`internal/adapter/safew_test.go``httptest.Server` 模拟 SafeW):
1. 转义:`hello_world.``hello\_world\.`;标题包在 `*...*`
2. 成功请求:URL 含 `/bot{token}/sendMessage`body 含 `chat_id``parse_mode=MarkdownV2`、转义后的 `text`
3. `ok: false` 或 HTTP 4xx → error,且含 `description`(若有)
4.`token` / `chat_id` → 失败且不发 HTTP
5. `chat_id` 为 JSON number`123456789`)时仍能发出,请求里是字符串 `"123456789"`
不修改 `test/e2e/`。手工验证用 `docs/httpie/curls.md` 里的创建示例。
## Success criteria
- 可创建 `type=safew` 的 channel,并绑到规则
- 命中规则后异步调用 SafeW `sendMessage`
- 含 MarkdownV2 特殊字符的标题/正文能发出(已转义)
- SafeW 业务失败(`ok: false`)记入失败日志,而不是当成成功