# 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": "", "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": "", "chat_id": "123456789" }, "status": 1 }' ``` ``` - [ ] **Step 3: Re-run adapter tests** Run: `go test ./internal/adapter/ -count=1` Expected: `PASS`