You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user