You've already forked agentic-coding-workflow
132 lines
3.8 KiB
Go
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,
|
|
}
|
|
}
|