feat: 新增 SafeW 出站通知渠道
通过 Bot API sendMessage 投递渲染后的通知,MarkdownV2 自动转义标题与正文。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`)记入失败日志,而不是当成成功
|
||||
Reference in New Issue
Block a user