// Package notify 内的 notifier_im.go 实现 IMNotifier:以通用 webhook POST // (Slack incoming-webhook 兼容形态)把通知投递到用户配置的 IM 地址。 // // 投递地址是 per-user 的 im_webhook_url,以密文落库于 notification_prefs // (im_webhook_url_enc);发送时经 Decryptor 解密,明文 URL 不在领域内存层 // 长期驻留。投递前置条件与 EmailNotifier 对称: // - 全局 cfg.Enabled 为 true; // - 用户 im_enabled 为 true 且配置了 webhook URL; // - 消息 severity 达到用户 min_severity。 // // 任一不满足静默返回 nil;HTTP/解密失败返回错误(Dispatcher 仅记日志)。 package notify import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" ) // IMConfig 是 IMNotifier 的全局配置,对应 cfg.Notify.IM。 type IMConfig struct { Enabled bool } // Decryptor 抽象 im_webhook_url 密文的解密。由 crypto.Encryptor 满足 // (Decrypt([]byte) ([]byte, error)),以窄接口注入避免 notify 依赖 crypto 包。 type Decryptor interface { Decrypt(ciphertext []byte) ([]byte, error) } // httpDoer 抽象单次 HTTP 调用,便于测试注入 fake transport。 type httpDoer interface { Do(req *http.Request) (*http.Response, error) } // IMNotifier 通过 webhook POST 投递 IM 消息,实现 Notifier。 type IMNotifier struct { cfg IMConfig prefs PrefsRepository dec Decryptor cl httpDoer log *slog.Logger } // NewIMNotifier 构造 IMNotifier。cl 为 nil 时用默认 http.Client(含 30s 超时 // 由 Dispatcher 的 sendCtx 兜底);log 为 nil 时回落 slog.Default。 func NewIMNotifier(cfg IMConfig, prefs PrefsRepository, dec Decryptor, cl httpDoer, log *slog.Logger) *IMNotifier { if cl == nil { cl = http.DefaultClient } if log == nil { log = slog.Default() } return &IMNotifier{cfg: cfg, prefs: prefs, dec: dec, cl: cl, log: log} } // Name 返回 ChannelIM。 func (n *IMNotifier) Name() Channel { return ChannelIM } // Send 在满足全部前置条件时向用户的 IM webhook POST 一条消息。 func (n *IMNotifier) Send(ctx context.Context, msg Message) error { if !n.cfg.Enabled { return nil } p, err := n.prefs.Get(ctx, msg.UserID) if err != nil { return err } if !p.IMEnabled || len(p.ImWebhookURLEnc) == 0 { return nil } if !meetsMinSeverity(msg.Severity, p.MinSeverity) { return nil } urlBytes, err := n.dec.Decrypt(p.ImWebhookURLEnc) if err != nil { return fmt.Errorf("decrypt im webhook url: %w", err) } url := strings.TrimSpace(string(urlBytes)) if url == "" { return nil } payload, err := json.Marshal(buildIMPayload(msg)) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := n.cl.Do(req) if err != nil { return fmt.Errorf("im webhook post: %w", err) } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("im webhook status %d", resp.StatusCode) } return nil } // buildIMPayload 把 Message 渲染为 Slack incoming-webhook 兼容的 JSON 体。 // 主要字段是 text(含级别、标题、正文、可选链接);附加结构化字段方便富客户端。 func buildIMPayload(msg Message) map[string]any { title := msg.Title if title == "" { title = msg.Topic } var b strings.Builder fmt.Fprintf(&b, "[%s] %s", strings.ToUpper(string(msg.Severity)), title) if msg.Body != "" { fmt.Fprintf(&b, "\n%s", msg.Body) } if msg.Link != "" { fmt.Fprintf(&b, "\n%s", msg.Link) } return map[string]any{ "text": b.String(), "topic": msg.Topic, "severity": string(msg.Severity), "title": title, } }