0d0cd0c510
通过 Bot API sendMessage 投递渲染后的通知,MarkdownV2 自动转义标题与正文。 Co-authored-by: Cursor <cursoragent@cursor.com>
123 lines
3.7 KiB
Markdown
123 lines
3.7 KiB
Markdown
# 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`)记入失败日志,而不是当成成功
|