diff --git a/README.md b/README.md index e5ffddb..60e1977 100644 --- a/README.md +++ b/README.md @@ -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": "", + "chat_id": "123456789" +} +``` + +`chat_id` 可为数字或字符串(含 `@username`)。消息以 MarkdownV2 发送:标题加粗,标题和正文均自动转义。 + #### `GET /api/v1/channels` — 列表 Query:`page`、`page_size`。**200:** `{ "data": Channel[], "total", "page" }` diff --git a/docs/httpie/curls.md b/docs/httpie/curls.md index bc07285..2fc0518 100644 --- a/docs/httpie/curls.md +++ b/docs/httpie/curls.md @@ -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": "", + "chat_id": "123456789" + }, + "status": 1 + }' +``` + ### 列出 Channel ```bash diff --git a/docs/superpowers/plans/2026-08-14-safew-channel.md b/docs/superpowers/plans/2026-08-14-safew-channel.md new file mode 100644 index 0000000..54f1224 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-safew-channel.md @@ -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": "", + "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` diff --git a/docs/superpowers/specs/2026-08-14-safew-channel-design.md b/docs/superpowers/specs/2026-08-14-safew-channel-design.md new file mode 100644 index 0000000..071bc46 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-safew-channel-design.md @@ -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": "", + "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": "", + "text": "**\n", + "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`)记入失败日志,而不是当成成功 diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 997d89d..a9008e2 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -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: diff --git a/internal/adapter/safew.go b/internal/adapter/safew.go new file mode 100644 index 0000000..c0d3416 --- /dev/null +++ b/internal/adapter/safew.go @@ -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() +} diff --git a/internal/adapter/safew_test.go b/internal/adapter/safew_test.go new file mode 100644 index 0000000..e5ec9da --- /dev/null +++ b/internal/adapter/safew_test.go @@ -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) + } +}