managarten/services/mana-notify-go/internal/channel/webhook.go
Till JS 585cdc1753 feat(mana-notify): rewrite notification service from NestJS to Go
Replaces the NestJS mana-notify service with a Go implementation.
Features: 4 notification channels (email/SMTP, Expo push, Matrix,
webhook), goroutine worker pool with retry/backoff (replaces BullMQ),
Go template engine (replaces Handlebars), PostgreSQL with auto-migrations
(5 tables), user preferences with quiet hours, idempotency via
externalId, batch sending, scheduled delivery, JWT + service key auth.

22 API endpoints, 1:1 compatible. Binary: 21 MB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:28:19 +01:00

91 lines
2.1 KiB
Go

package channel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
)
type WebhookService struct {
client *http.Client
}
func NewWebhookService() *WebhookService {
return &WebhookService{
client: &http.Client{Timeout: 10 * time.Second},
}
}
type WebhookMessage struct {
URL string
Method string // POST or PUT
Headers map[string]string
Body map[string]any
Timeout int // ms
}
type WebhookResult struct {
Success bool
StatusCode int
Error string
DurationMs int
}
func (s *WebhookService) Send(ctx context.Context, msg *WebhookMessage) WebhookResult {
start := time.Now()
method := msg.Method
if method == "" {
method = http.MethodPost
}
timeout := 10 * time.Second
if msg.Timeout > 0 {
timeout = time.Duration(msg.Timeout) * time.Millisecond
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
body, err := json.Marshal(msg.Body)
if err != nil {
return WebhookResult{Success: false, Error: "marshal error", DurationMs: int(time.Since(start).Milliseconds())}
}
req, err := http.NewRequestWithContext(ctx, method, msg.URL, bytes.NewReader(body))
if err != nil {
return WebhookResult{Success: false, Error: err.Error(), DurationMs: int(time.Since(start).Milliseconds())}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "ManaNotify/1.0")
for k, v := range msg.Headers {
req.Header.Set(k, v)
}
resp, err := s.client.Do(req)
durationMs := int(time.Since(start).Milliseconds())
if err != nil {
slog.Error("webhook send failed", "url", msg.URL, "error", err)
return WebhookResult{Success: false, Error: err.Error(), DurationMs: durationMs}
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return WebhookResult{
Success: false,
StatusCode: resp.StatusCode,
Error: fmt.Sprintf("webhook returned %d", resp.StatusCode),
DurationMs: durationMs,
}
}
return WebhookResult{Success: true, StatusCode: resp.StatusCode, DurationMs: durationMs}
}