Files
2026-06-22 08:55:57 +08:00

132 lines
3.8 KiB
Go

// 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,
}
}