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,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
### 创建 ChannelSafeW
```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`