diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 94124bb..a1fc14f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "Bash(git commit *)", "Bash(go vet *)", "Bash(/Users/ryan/.claude/plugins/cache/superpowers-marketplace/superpowers/6.0.3/skills/subagent-driven-development/scripts/task-brief *)", - "Bash(go get *)" + "Bash(go get *)", + "Bash(go test *)" ] } } diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go new file mode 100644 index 0000000..a6cf66a --- /dev/null +++ b/internal/adapter/adapter.go @@ -0,0 +1,29 @@ +package adapter + +import ( + "encoding/json" + "fmt" + + "aiaa-notification-service/internal/config" +) + +// ChannelSender sends a rendered message to a specific channel. +type ChannelSender interface { + Type() string + Send(title, content string, config json.RawMessage) error +} + +func NewSender(channelType string, smtpCfg *config.SMTPConfig) (ChannelSender, error) { + switch channelType { + case "dingtalk": + return &DingTalkSender{}, nil + case "wecom": + return &WeComSender{}, nil + case "bark": + return &BarkSender{}, nil + case "email": + return nil, fmt.Errorf("email sender not yet implemented (see Task 10)") + default: + return nil, fmt.Errorf("unknown channel type: %s", channelType) + } +} diff --git a/internal/adapter/bark.go b/internal/adapter/bark.go new file mode 100644 index 0000000..5c62ddf --- /dev/null +++ b/internal/adapter/bark.go @@ -0,0 +1,41 @@ +package adapter + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type barkConfig struct { + URL string `json:"url"` +} + +type barkPayload struct { + Title string `json:"title"` + Body string `json:"body"` +} + +type BarkSender struct{} + +func (s *BarkSender) Type() string { return "bark" } + +func (s *BarkSender) Send(title, content string, config json.RawMessage) error { + var cfg barkConfig + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parse bark config: %w", err) + } + + payload := barkPayload{Title: title, Body: content} + body, _ := json.Marshal(payload) + resp, err := http.Post(cfg.URL, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("bark send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("bark returned status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/adapter/dingtalk.go b/internal/adapter/dingtalk.go new file mode 100644 index 0000000..dd94c38 --- /dev/null +++ b/internal/adapter/dingtalk.go @@ -0,0 +1,73 @@ +package adapter + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type dingtalkConfig struct { + WebhookURL string `json:"webhook_url"` + Secret string `json:"secret,omitempty"` +} + +type dingtalkMessage struct { + MsgType string `json:"msgtype"` + Markdown *dingtalkMD `json:"markdown"` +} + +type dingtalkMD struct { + Title string `json:"title"` + Text string `json:"text"` +} + +type DingTalkSender struct{} + +func (s *DingTalkSender) Type() string { return "dingtalk" } + +func (s *DingTalkSender) Send(title, content string, config json.RawMessage) error { + var cfg dingtalkConfig + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parse dingtalk config: %w", err) + } + + reqURL := cfg.WebhookURL + if cfg.Secret != "" { + timestamp := time.Now().UnixMilli() + sign := dingtalkSign(timestamp, cfg.Secret) + reqURL = fmt.Sprintf("%s×tamp=%d&sign=%s", cfg.WebhookURL, timestamp, sign) + } + + msg := dingtalkMessage{ + MsgType: "markdown", + Markdown: &dingtalkMD{ + Title: title, + Text: content, + }, + } + + body, _ := json.Marshal(msg) + resp, err := http.Post(reqURL, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("dingtalk send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("dingtalk returned status %d", resp.StatusCode) + } + return nil +} + +func dingtalkSign(timestamp int64, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + fmt.Fprintf(mac, "%d\n%s", timestamp, secret) + signData := mac.Sum(nil) + return url.QueryEscape(base64.StdEncoding.EncodeToString(signData)) +} diff --git a/internal/adapter/wecom.go b/internal/adapter/wecom.go new file mode 100644 index 0000000..130e73e --- /dev/null +++ b/internal/adapter/wecom.go @@ -0,0 +1,51 @@ +package adapter + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type wecomConfig struct { + WebhookURL string `json:"webhook_url"` +} + +type wecomMessage struct { + MsgType string `json:"msgtype"` + Markdown *wecomMD `json:"markdown"` +} + +type wecomMD struct { + Content string `json:"content"` +} + +type WeComSender struct{} + +func (s *WeComSender) Type() string { return "wecom" } + +func (s *WeComSender) Send(title, content string, config json.RawMessage) error { + var cfg wecomConfig + if err := json.Unmarshal(config, &cfg); err != nil { + return fmt.Errorf("parse wecom config: %w", err) + } + + msg := wecomMessage{ + MsgType: "markdown", + Markdown: &wecomMD{ + Content: fmt.Sprintf("## %s\n%s", title, content), + }, + } + + body, _ := json.Marshal(msg) + resp, err := http.Post(cfg.WebhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("wecom send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("wecom returned status %d", resp.StatusCode) + } + return nil +}