Files
ryan 0d0cd0c510 feat: 新增 SafeW 出站通知渠道
通过 Bot API sendMessage 投递渲染后的通知,MarkdownV2 自动转义标题与正文。

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

14 KiB
Raw Permalink Blame History

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:

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:

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:

import (
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"aiaa-notification-service/internal/config"
)

Keep TestEscapeMarkdownV2 unchanged. Append:

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:

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":

	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:

**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`