Files
aiaa-notification-server/docs/superpowers/plans/2026-08-14-safew-channel.md
T
ryan 0d0cd0c510 feat: 新增 SafeW 出站通知渠道
通过 Bot API sendMessage 投递渲染后的通知,MarkdownV2 自动转义标题与正文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 00:58:44 +08:00

539 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`