feat: 新增 SafeW 出站通知渠道
通过 Bot API sendMessage 投递渲染后的通知,MarkdownV2 自动转义标题与正文。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# AIAA Notification Service
|
||||
|
||||
纯 Go 实现的多渠道通知服务。上游系统通过 Webhook 推送消息,服务按来源解析、匹配规则、条件过滤、渲染模板后,异步分发到钉钉 / 企业微信 / 邮件 / Bark。
|
||||
纯 Go 实现的多渠道通知服务。上游系统通过 Webhook 推送消息,服务按来源解析、匹配规则、条件过滤、渲染模板后,异步分发到钉钉 / 企业微信 / 邮件 / Bark / SafeW。
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -14,7 +14,7 @@ rule N──M channel (via rule_channel,可独立开关)
|
||||
|------|------|
|
||||
| **Source** | 来源系统,持有独立 `api_key`,定义 body 解析方式(json / regex / text) |
|
||||
| **Template** | Go `text/template` 模板 |
|
||||
| **Channel** | 发送渠道配置(钉钉 / 企微 / 邮件 / Bark) |
|
||||
| **Channel** | 发送渠道配置(钉钉 / 企微 / 邮件 / Bark / SafeW) |
|
||||
| **Rule** | 绑定 `source + event → template + channels`,可选条件过滤 |
|
||||
| **MessageLog** | 发送记录,便于排查 |
|
||||
|
||||
@@ -351,7 +351,7 @@ Query:`page`、`page_size`(默认同 sources)。**200:** `{ "data": Temp
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 唯一名称(规则里用此名引用) |
|
||||
| `type` | string | 是 | `dingtalk` / `wecom` / `email` / `bark` |
|
||||
| `type` | string | 是 | `dingtalk` / `wecom` / `email` / `bark` / `safew` |
|
||||
| `config` | object | 是 | 见下方各渠道配置 |
|
||||
| `status` | int | 否 | 默认 `1` |
|
||||
|
||||
@@ -398,6 +398,17 @@ SMTP 使用全局 `config.yaml` 的 `smtp` 段;支持 587 STARTTLS / 465 TLS
|
||||
}
|
||||
```
|
||||
|
||||
**SafeW `safew`**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<bot token>",
|
||||
"chat_id": "123456789"
|
||||
}
|
||||
```
|
||||
|
||||
`chat_id` 可为数字或字符串(含 `@username`)。消息以 MarkdownV2 发送:标题加粗,标题和正文均自动转义。
|
||||
|
||||
#### `GET /api/v1/channels` — 列表
|
||||
|
||||
Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page" }`
|
||||
|
||||
@@ -260,6 +260,23 @@ curl -X POST 'http://localhost:8080/api/v1/channels' \
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建 Channel(SafeW)
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/channels' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "safew-ops",
|
||||
"type": "safew",
|
||||
"config": {
|
||||
"token": "<bot token>",
|
||||
"chat_id": "123456789"
|
||||
},
|
||||
"status": 1
|
||||
}'
|
||||
```
|
||||
|
||||
### 列出 Channel
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
# SafeW Channel Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add outbound channel type `safew` that sends rendered notifications via SafeW Bot API `sendMessage`.
|
||||
|
||||
**Architecture:** New `SafeWSender` implementing `adapter.ChannelSender`. Per-channel `token` + `chat_id` in `channel.config`. POST `https://api.safew.bot/bot{token}/sendMessage` with MarkdownV2 text (title and content auto-escaped). Unexported `apiBase` is a test seam only — not part of channel config.
|
||||
|
||||
**Tech Stack:** Go 1.22+, stdlib `net/http`, `encoding/json`, `testing`, `httptest`. No new dependencies.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Channel type string: `safew`
|
||||
- Config JSON keys: `token`, `chat_id` (string or number; normalize to string)
|
||||
- API: `POST https://api.safew.bot/bot{token}/sendMessage`
|
||||
- Body: `{chat_id, text, parse_mode: "MarkdownV2"}`
|
||||
- `text = "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content)`
|
||||
- MarkdownV2 specials: `_ * [ ] ( ) ~ \` > # + - = | { } . !` — prefix each with `\`
|
||||
- HTTP 2xx with `ok: false` is failure; include `description` in the error
|
||||
- Do not add global config, rate limiter, inbound webhook, or e2e changes
|
||||
- Do not commit unless the user explicitly asks
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `internal/adapter/safew.go` | `SafeWSender`, MarkdownV2 escape, chat_id parse, sendMessage HTTP |
|
||||
| `internal/adapter/safew_test.go` | Escape unit tests + httptest send contract tests |
|
||||
| `internal/adapter/adapter.go` | Register `"safew"` in `NewSender` |
|
||||
| `README.md` | Channel type list + safew config example |
|
||||
| `docs/httpie/curls.md` | Create-channel curl |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: MarkdownV2 escape helper
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/adapter/safew.go` (helper only in this task)
|
||||
- Test: `internal/adapter/safew_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing
|
||||
- Produces: `func escapeMarkdownV2(s string) string` in package `adapter`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `internal/adapter/safew_test.go`:
|
||||
|
||||
```go
|
||||
package adapter
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEscapeMarkdownV2(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "underscore and dot", in: "hello_world.", want: `hello\_world\.`},
|
||||
{name: "empty", in: "", want: ""},
|
||||
{name: "no specials", in: "hello", want: "hello"},
|
||||
{name: "backslash first", in: `a\b`, want: `a\\b`},
|
||||
{
|
||||
name: "all specials",
|
||||
in: "_*[]()~`>#+-=|{}.!\\",
|
||||
want: "\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := escapeMarkdownV2(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("escapeMarkdownV2(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/adapter/ -run TestEscapeMarkdownV2 -count=1`
|
||||
|
||||
Expected: FAIL with `undefined: escapeMarkdownV2`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Create `internal/adapter/safew.go`:
|
||||
|
||||
```go
|
||||
package adapter
|
||||
|
||||
import "strings"
|
||||
|
||||
func escapeMarkdownV2(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests and make sure they pass**
|
||||
|
||||
Run: `go test ./internal/adapter/ -run TestEscapeMarkdownV2 -count=1`
|
||||
|
||||
Expected: `PASS`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SafeWSender + factory registration
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/adapter/safew.go` (full sender)
|
||||
- Modify: `internal/adapter/safew_test.go` (httptest cases)
|
||||
- Modify: `internal/adapter/adapter.go` (`case "safew"`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `escapeMarkdownV2(s string) string`
|
||||
- Produces:
|
||||
- `type SafeWSender struct { apiBase string }` — empty `apiBase` means `https://api.safew.bot`
|
||||
- `func (s *SafeWSender) Type() string` returns `"safew"`
|
||||
- `func (s *SafeWSender) Send(title, content string, config json.RawMessage) error`
|
||||
- `NewSender("safew", smtpCfg, limiter)` returns `&SafeWSender{}`
|
||||
|
||||
- [ ] **Step 1: Write the failing send tests**
|
||||
|
||||
In `internal/adapter/safew_test.go`, expand imports to:
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
```
|
||||
|
||||
Keep `TestEscapeMarkdownV2` unchanged. Append:
|
||||
|
||||
```go
|
||||
func TestNewSenderSafew(t *testing.T) {
|
||||
s, err := NewSender("safew", &config.SMTPConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSender(safew): %v", err)
|
||||
}
|
||||
if s.Type() != "safew" {
|
||||
t.Fatalf("Type() = %q, want safew", s.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendSuccess(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(raw, &gotBody); err != nil {
|
||||
t.Errorf("unmarshal request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg, _ := json.Marshal(map[string]any{"token": "tok-1", "chat_id": "123456789"})
|
||||
if err := sender.Send("hello_world.", "price=1.5", cfg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if gotPath != "/bottok-1/sendMessage" {
|
||||
t.Fatalf("path = %q, want /bottok-1/sendMessage", gotPath)
|
||||
}
|
||||
if gotBody["chat_id"] != "123456789" {
|
||||
t.Fatalf("chat_id = %#v, want \"123456789\"", gotBody["chat_id"])
|
||||
}
|
||||
if gotBody["parse_mode"] != "MarkdownV2" {
|
||||
t.Fatalf("parse_mode = %#v, want MarkdownV2", gotBody["parse_mode"])
|
||||
}
|
||||
wantText := "*" + escapeMarkdownV2("hello_world.") + "*\n" + escapeMarkdownV2("price=1.5")
|
||||
if gotBody["text"] != wantText {
|
||||
t.Fatalf("text = %#v, want %#v", gotBody["text"], wantText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendNumericChatID(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg := json.RawMessage(`{"token":"tok","chat_id":123456789}`)
|
||||
if err := sender.Send("t", "c", cfg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if gotBody["chat_id"] != "123456789" {
|
||||
t.Fatalf("chat_id = %#v, want string \"123456789\"", gotBody["chat_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendOKFalse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"description":"chat not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg, _ := json.Marshal(map[string]string{"token": "tok", "chat_id": "1"})
|
||||
err := sender.Send("t", "c", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "chat not found") {
|
||||
t.Fatalf("error %q should contain description", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"description":"Unauthorized"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg, _ := json.Marshal(map[string]string{"token": "bad", "chat_id": "1"})
|
||||
err := sender.Send("t", "c", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unauthorized") {
|
||||
t.Fatalf("error %q should contain description", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendMissingFields(t *testing.T) {
|
||||
hits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cases := []json.RawMessage{
|
||||
json.RawMessage(`{"chat_id":"1"}`),
|
||||
json.RawMessage(`{"token":"tok"}`),
|
||||
json.RawMessage(`{"token":" ","chat_id":"1"}`),
|
||||
json.RawMessage(`{"token":"tok","chat_id":""}`),
|
||||
json.RawMessage(`{"token":"tok","chat_id":null}`),
|
||||
}
|
||||
for _, cfg := range cases {
|
||||
if err := sender.Send("t", "c", cfg); err == nil {
|
||||
t.Fatalf("expected error for config %s", cfg)
|
||||
}
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("unexpected HTTP calls: %d", hits)
|
||||
}
|
||||
|
||||
if err := sender.Send("t", "c", json.RawMessage(`{`)); err == nil {
|
||||
t.Fatal("expected error for invalid json")
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("unexpected HTTP calls after invalid json: %d", hits)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `go test ./internal/adapter/ -count=1`
|
||||
|
||||
Expected: FAIL with `undefined: SafeWSender` and/or `unknown channel type: safew`
|
||||
|
||||
- [ ] **Step 3: Implement sender and register it**
|
||||
|
||||
Replace `internal/adapter/safew.go` with:
|
||||
|
||||
```go
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const safewAPIBase = "https://api.safew.bot"
|
||||
|
||||
type SafeWSender struct {
|
||||
apiBase string
|
||||
}
|
||||
|
||||
type safewConfig struct {
|
||||
Token string `json:"token"`
|
||||
ChatID json.RawMessage `json:"chat_id"`
|
||||
}
|
||||
|
||||
type safewMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
}
|
||||
|
||||
type safewAPIResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *SafeWSender) Type() string { return "safew" }
|
||||
|
||||
func (s *SafeWSender) Send(title, content string, config json.RawMessage) error {
|
||||
var cfg safewConfig
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("parse safew config: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(cfg.Token)
|
||||
if token == "" {
|
||||
return fmt.Errorf("safew: token is required")
|
||||
}
|
||||
chatID, err := parseSafewChatID(cfg.ChatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := safewMessage{
|
||||
ChatID: chatID,
|
||||
Text: "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content),
|
||||
ParseMode: "MarkdownV2",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("safew marshal: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.endpoint(token), "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("safew send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("safew read: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
desc := safewErrorDescription(respBody)
|
||||
if desc != "" {
|
||||
return fmt.Errorf("safew returned status %d: %s", resp.StatusCode, desc)
|
||||
}
|
||||
return fmt.Errorf("safew returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var api safewAPIResponse
|
||||
if err := json.Unmarshal(respBody, &api); err != nil {
|
||||
return fmt.Errorf("safew decode: %w", err)
|
||||
}
|
||||
if !api.OK {
|
||||
desc := api.Description
|
||||
if desc == "" {
|
||||
desc = "ok=false"
|
||||
}
|
||||
return fmt.Errorf("safew: %s", desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SafeWSender) endpoint(token string) string {
|
||||
base := s.apiBase
|
||||
if base == "" {
|
||||
base = safewAPIBase
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/bot" + token + "/sendMessage"
|
||||
}
|
||||
|
||||
func parseSafewChatID(raw json.RawMessage) (string, error) {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "", fmt.Errorf("safew: chat_id is required")
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var id string
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
return "", fmt.Errorf("safew: invalid chat_id: %w", err)
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("safew: chat_id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func safewErrorDescription(body []byte) string {
|
||||
var api safewAPIResponse
|
||||
if err := json.Unmarshal(body, &api); err != nil {
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
return api.Description
|
||||
}
|
||||
|
||||
func escapeMarkdownV2(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
In `internal/adapter/adapter.go`, add the factory case after `"bark"`:
|
||||
|
||||
```go
|
||||
case "bark":
|
||||
return &BarkSender{}, nil
|
||||
case "safew":
|
||||
return &SafeWSender{}, nil
|
||||
case "email":
|
||||
return NewEmailSender(*smtpCfg), nil
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests and make sure they pass**
|
||||
|
||||
Run: `go test ./internal/adapter/ -count=1`
|
||||
|
||||
Expected: `PASS` (existing dingtalk limiter tests plus new safew tests)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/httpie/curls.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: channel type `safew`, config `{token, chat_id}`
|
||||
- Produces: documented create example for operators
|
||||
|
||||
- [ ] **Step 1: Update README channel list**
|
||||
|
||||
In `README.md` line 3, change:
|
||||
|
||||
`异步分发到钉钉 / 企业微信 / 邮件 / Bark。`
|
||||
|
||||
to:
|
||||
|
||||
`异步分发到钉钉 / 企业微信 / 邮件 / Bark / SafeW。`
|
||||
|
||||
Line 17, change:
|
||||
|
||||
`| **Channel** | 发送渠道配置(钉钉 / 企微 / 邮件 / Bark) |`
|
||||
|
||||
to:
|
||||
|
||||
`| **Channel** | 发送渠道配置(钉钉 / 企微 / 邮件 / Bark / SafeW) |`
|
||||
|
||||
Line 354, change:
|
||||
|
||||
`| `type` | string | 是 | `dingtalk` / `wecom` / `email` / `bark` |`
|
||||
|
||||
to:
|
||||
|
||||
`| `type` | string | 是 | `dingtalk` / `wecom` / `email` / `bark` / `safew` |`
|
||||
|
||||
After the Bark config block (after the closing ` ``` ` of the bark url example, before `#### GET /api/v1/channels`), insert:
|
||||
|
||||
```markdown
|
||||
**SafeW `safew`**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<bot token>",
|
||||
"chat_id": "123456789"
|
||||
}
|
||||
```
|
||||
|
||||
`chat_id` 可为数字或字符串(含 `@username`)。消息以 MarkdownV2 发送:标题加粗,标题和正文均自动转义。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add curl example**
|
||||
|
||||
In `docs/httpie/curls.md`, after the Bark create example (after its closing ` ``` `, before `### 列出 Channel`), insert:
|
||||
|
||||
```markdown
|
||||
### 创建 Channel(SafeW)
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/v1/channels' \
|
||||
-H 'Authorization: Bearer admin-sk-change-me' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "safew-ops",
|
||||
"type": "safew",
|
||||
"config": {
|
||||
"token": "<bot token>",
|
||||
"chat_id": "123456789"
|
||||
},
|
||||
"status": 1
|
||||
}'
|
||||
```
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Re-run adapter tests**
|
||||
|
||||
Run: `go test ./internal/adapter/ -count=1`
|
||||
|
||||
Expected: `PASS`
|
||||
@@ -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`)记入失败日志,而不是当成成功
|
||||
@@ -21,6 +21,8 @@ func NewSender(channelType string, smtpCfg *config.SMTPConfig, dingtalkLimiter *
|
||||
return &WeComSender{}, nil
|
||||
case "bark":
|
||||
return &BarkSender{}, nil
|
||||
case "safew":
|
||||
return &SafeWSender{}, nil
|
||||
case "email":
|
||||
return NewEmailSender(*smtpCfg), nil
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const safewAPIBase = "https://api.safew.bot"
|
||||
|
||||
type SafeWSender struct {
|
||||
apiBase string
|
||||
}
|
||||
|
||||
type safewConfig struct {
|
||||
Token string `json:"token"`
|
||||
ChatID json.RawMessage `json:"chat_id"`
|
||||
}
|
||||
|
||||
type safewMessage struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
}
|
||||
|
||||
type safewAPIResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *SafeWSender) Type() string { return "safew" }
|
||||
|
||||
func (s *SafeWSender) Send(title, content string, config json.RawMessage) error {
|
||||
var cfg safewConfig
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("parse safew config: %w", err)
|
||||
}
|
||||
token := strings.TrimSpace(cfg.Token)
|
||||
if token == "" {
|
||||
return fmt.Errorf("safew: token is required")
|
||||
}
|
||||
chatID, err := parseSafewChatID(cfg.ChatID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := safewMessage{
|
||||
ChatID: chatID,
|
||||
Text: "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content),
|
||||
ParseMode: "MarkdownV2",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("safew marshal: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(s.endpoint(token), "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("safew send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("safew read: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
desc := safewErrorDescription(respBody)
|
||||
if desc != "" {
|
||||
return fmt.Errorf("safew returned status %d: %s", resp.StatusCode, desc)
|
||||
}
|
||||
return fmt.Errorf("safew returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var api safewAPIResponse
|
||||
if err := json.Unmarshal(respBody, &api); err != nil {
|
||||
return fmt.Errorf("safew decode: %w", err)
|
||||
}
|
||||
if !api.OK {
|
||||
desc := api.Description
|
||||
if desc == "" {
|
||||
desc = "ok=false"
|
||||
}
|
||||
return fmt.Errorf("safew: %s", desc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SafeWSender) endpoint(token string) string {
|
||||
base := s.apiBase
|
||||
if base == "" {
|
||||
base = safewAPIBase
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/bot" + token + "/sendMessage"
|
||||
}
|
||||
|
||||
func parseSafewChatID(raw json.RawMessage) (string, error) {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "", fmt.Errorf("safew: chat_id is required")
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var id string
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
return "", fmt.Errorf("safew: invalid chat_id: %w", err)
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("safew: chat_id is required")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func safewErrorDescription(body []byte) string {
|
||||
var api safewAPIResponse
|
||||
if err := json.Unmarshal(body, &api); err != nil {
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
return api.Description
|
||||
}
|
||||
|
||||
func escapeMarkdownV2(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\':
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aiaa-notification-service/internal/config"
|
||||
)
|
||||
|
||||
func TestEscapeMarkdownV2(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "underscore and dot", in: "hello_world.", want: `hello\_world\.`},
|
||||
{name: "empty", in: "", want: ""},
|
||||
{name: "no specials", in: "hello", want: "hello"},
|
||||
{name: "backslash first", in: `a\b`, want: `a\\b`},
|
||||
{
|
||||
name: "all specials",
|
||||
in: "_*[]()~`>#+-=|{}.!\\",
|
||||
want: "\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := escapeMarkdownV2(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("escapeMarkdownV2(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSenderSafew(t *testing.T) {
|
||||
s, err := NewSender("safew", &config.SMTPConfig{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSender(safew): %v", err)
|
||||
}
|
||||
if s.Type() != "safew" {
|
||||
t.Fatalf("Type() = %q, want safew", s.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendSuccess(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(raw, &gotBody); err != nil {
|
||||
t.Errorf("unmarshal request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg, _ := json.Marshal(map[string]any{"token": "tok-1", "chat_id": "123456789"})
|
||||
if err := sender.Send("hello_world.", "price=1.5", cfg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if gotPath != "/bottok-1/sendMessage" {
|
||||
t.Fatalf("path = %q, want /bottok-1/sendMessage", gotPath)
|
||||
}
|
||||
if gotBody["chat_id"] != "123456789" {
|
||||
t.Fatalf("chat_id = %#v, want \"123456789\"", gotBody["chat_id"])
|
||||
}
|
||||
if gotBody["parse_mode"] != "MarkdownV2" {
|
||||
t.Fatalf("parse_mode = %#v, want MarkdownV2", gotBody["parse_mode"])
|
||||
}
|
||||
wantText := "*" + escapeMarkdownV2("hello_world.") + "*\n" + escapeMarkdownV2("price=1.5")
|
||||
if gotBody["text"] != wantText {
|
||||
t.Fatalf("text = %#v, want %#v", gotBody["text"], wantText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendNumericChatID(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg := json.RawMessage(`{"token":"tok","chat_id":123456789}`)
|
||||
if err := sender.Send("t", "c", cfg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if gotBody["chat_id"] != "123456789" {
|
||||
t.Fatalf("chat_id = %#v, want string \"123456789\"", gotBody["chat_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendOKFalse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"description":"chat not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg, _ := json.Marshal(map[string]string{"token": "tok", "chat_id": "1"})
|
||||
err := sender.Send("t", "c", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "chat not found") {
|
||||
t.Fatalf("error %q should contain description", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendHTTPError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"description":"Unauthorized"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cfg, _ := json.Marshal(map[string]string{"token": "bad", "chat_id": "1"})
|
||||
err := sender.Send("t", "c", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Unauthorized") {
|
||||
t.Fatalf("error %q should contain description", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWSenderSendMissingFields(t *testing.T) {
|
||||
hits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sender := &SafeWSender{apiBase: srv.URL}
|
||||
cases := []json.RawMessage{
|
||||
json.RawMessage(`{"chat_id":"1"}`),
|
||||
json.RawMessage(`{"token":"tok"}`),
|
||||
json.RawMessage(`{"token":" ","chat_id":"1"}`),
|
||||
json.RawMessage(`{"token":"tok","chat_id":""}`),
|
||||
json.RawMessage(`{"token":"tok","chat_id":null}`),
|
||||
}
|
||||
for _, cfg := range cases {
|
||||
if err := sender.Send("t", "c", cfg); err == nil {
|
||||
t.Fatalf("expected error for config %s", cfg)
|
||||
}
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("unexpected HTTP calls: %d", hits)
|
||||
}
|
||||
|
||||
if err := sender.Send("t", "c", json.RawMessage(`{`)); err == nil {
|
||||
t.Fatal("expected error for invalid json")
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("unexpected HTTP calls after invalid json: %d", hits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user