feat(channels): add Grafana Alertmanager webhook channel

Add input-only channel that receives alerts from Grafana Alertmanager
via webhook and forwards them to the agent for processing.

Features:
- Webhook endpoint at configurable path (default: /webhook/grafana-alertmanager)
- HMAC-SHA256 signature verification when secret is configured
- Requires secret when allow_from is restrictive (allow_from can't restrict webhooks)
- Max body size limit (1 MiB) to prevent memory exhaustion
- Formats alerts into readable markdown with status, labels, annotations, links
- Input-only: Send() returns nil to avoid retry logic

Security:
- Signature verified via X-Grafana-Alerting-Signature header
- Rejects requests without valid signature (403 Forbidden)
- POST-only endpoint (405 for other methods)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy Lo-A-Foe 2026-04-03 13:38:41 +02:00
parent 748ac58dd1
commit 970886881c
No known key found for this signature in database
GPG key ID: C0E4EB79E9E6A23D
7 changed files with 797 additions and 18 deletions

View file

@ -0,0 +1,312 @@
package grafana_alertmanager
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
// maxWebhookBodySize limits request body to prevent memory exhaustion.
maxWebhookBodySize = 1 << 20 // 1 MiB
)
// Alert represents a single alert from Grafana Alertmanager.
type Alert struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"`
SilenceURL string `json:"silenceURL,omitempty"`
DashboardURL string `json:"dashboardURL,omitempty"`
PanelURL string `json:"panelURL,omitempty"`
Values map[string]any `json:"values,omitempty"`
ValueString string `json:"valueString,omitempty"`
}
// WebhookPayload represents the payload sent by Grafana Alertmanager.
type WebhookPayload struct {
Receiver string `json:"receiver"`
Status string `json:"status"`
Alerts []Alert `json:"alerts"`
GroupLabels map[string]string `json:"groupLabels"`
CommonLabels map[string]string `json:"commonLabels"`
CommonAnnotations map[string]string `json:"commonAnnotations"`
ExternalURL string `json:"externalURL"`
Version string `json:"version"`
GroupKey string `json:"groupKey"`
TruncatedAlerts int `json:"truncatedAlerts"`
OrgID int `json:"orgId"`
Title string `json:"title"`
State string `json:"state"`
Message string `json:"message"`
}
// GrafanaAlertmanagerChannel implements a webhook endpoint for Grafana Alertmanager.
type GrafanaAlertmanagerChannel struct {
*channels.BaseChannel
config config.GrafanaAlertmanagerConfig
}
// NewGrafanaAlertmanagerChannel creates a new Grafana Alertmanager channel.
// Returns an error if allow_from is restrictive but no secret is configured,
// since allow_from cannot meaningfully restrict webhook callers (the senderID
// is always "grafana-alertmanager"). Use secret for authentication instead.
func NewGrafanaAlertmanagerChannel(
cfg config.GrafanaAlertmanagerConfig,
messageBus *bus.MessageBus,
) (*GrafanaAlertmanagerChannel, error) {
// Validate: if allow_from is restrictive, secret must be configured
// because allow_from checks senderID which is always "grafana-alertmanager"
if !isOpenAccess(cfg.AllowFrom) && cfg.Secret.String() == "" {
return nil, fmt.Errorf(
"grafana_alertmanager: secret is required when allow_from is restrictive; " +
"allow_from cannot restrict webhook callers (use secret for authentication)",
)
}
base := channels.NewBaseChannel("grafana_alertmanager", cfg, messageBus, cfg.AllowFrom)
return &GrafanaAlertmanagerChannel{
BaseChannel: base,
config: cfg,
}, nil
}
// isOpenAccess returns true if allowFrom permits open access (empty or contains "*").
func isOpenAccess(allowFrom []string) bool {
if len(allowFrom) == 0 {
return true
}
for _, v := range allowFrom {
if v == "*" {
return true
}
}
return false
}
// Start initializes the channel.
func (c *GrafanaAlertmanagerChannel) Start(ctx context.Context) error {
logger.InfoC("grafana_alertmanager", "Starting Grafana Alertmanager channel (Webhook Mode)")
c.SetRunning(true)
return nil
}
// Stop gracefully stops the channel.
func (c *GrafanaAlertmanagerChannel) Stop(ctx context.Context) error {
logger.InfoC("grafana_alertmanager", "Stopping Grafana Alertmanager channel")
c.SetRunning(false)
return nil
}
// Send is a no-op for this input-only channel.
// Returns nil, nil to avoid triggering retry logic in the channel manager.
func (c *GrafanaAlertmanagerChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
// This is an input-only channel, no outbound messages supported.
// Return nil, nil to signal "nothing to do" without triggering retries.
return nil, nil
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (c *GrafanaAlertmanagerChannel) WebhookPath() string {
if c.config.WebhookPath != "" {
return c.config.WebhookPath
}
return "/webhook/grafana-alertmanager"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *GrafanaAlertmanagerChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil {
logger.ErrorCF("grafana_alertmanager", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if int64(len(body)) > maxWebhookBodySize {
logger.WarnC("grafana_alertmanager", "Webhook request body too large, rejected")
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
return
}
// Verify signature if secret is configured
if c.config.Secret.String() != "" {
// Grafana Alerting sends signatures in "X-Grafana-Alerting-Signature" header
signature := r.Header.Get("X-Grafana-Alerting-Signature")
if !c.verifySignature(body, signature) {
logger.WarnC("grafana_alertmanager", "Invalid webhook signature")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("grafana_alertmanager", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
logger.InfoCF("grafana_alertmanager", "Received alert webhook", map[string]any{
"status": payload.Status,
"alert_count": len(payload.Alerts),
"receiver": payload.Receiver,
"title": payload.Title,
})
// Format the alert message
content := c.formatAlertMessage(&payload)
// Determine chat ID - use configured chat_id or generate from receiver
chatID := c.config.ChatID
if chatID == "" {
chatID = "grafana:" + payload.Receiver
}
// Use receiver as sender ID
senderID := "grafana-alertmanager"
// Publish the inbound message
c.HandleMessage(
r.Context(),
bus.Peer{Kind: "webhook", ID: payload.Receiver},
payload.GroupKey, // messageID
senderID,
chatID,
content,
nil, // no media
nil, // no metadata
)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// verifySignature verifies the HMAC-SHA256 signature of the webhook payload.
// The signature is expected to be a hex-encoded string, optionally prefixed with "sha256=".
func (c *GrafanaAlertmanagerChannel) verifySignature(body []byte, signature string) bool {
if signature == "" {
return false
}
// Remove "sha256=" prefix if present
signature = strings.TrimPrefix(signature, "sha256=")
// Decode the provided signature from hex
providedMAC, err := hex.DecodeString(signature)
if err != nil {
return false
}
// Compute expected MAC
mac := hmac.New(sha256.New, []byte(c.config.Secret.String()))
mac.Write(body)
expectedMAC := mac.Sum(nil)
// Constant-time comparison (only works correctly when lengths match)
if len(providedMAC) != len(expectedMAC) {
return false
}
return hmac.Equal(providedMAC, expectedMAC)
}
// formatAlertMessage formats the alert payload into a readable message.
func (c *GrafanaAlertmanagerChannel) formatAlertMessage(payload *WebhookPayload) string {
var sb strings.Builder
// Title and status
status := strings.ToUpper(payload.Status)
if payload.Title != "" {
sb.WriteString(fmt.Sprintf("**[%s] %s**\n\n", status, payload.Title))
} else {
sb.WriteString(fmt.Sprintf("**[%s] Grafana Alert**\n\n", status))
}
// Message if present
if payload.Message != "" {
sb.WriteString(payload.Message)
sb.WriteString("\n\n")
}
// Alert details
for i, alert := range payload.Alerts {
if i > 0 {
sb.WriteString("\n---\n\n")
}
// Alert name from labels
alertName := alert.Labels["alertname"]
if alertName == "" {
alertName = fmt.Sprintf("Alert %d", i+1)
}
sb.WriteString(fmt.Sprintf("**%s** (%s)\n", alertName, alert.Status))
// Summary and description from annotations
if summary := alert.Annotations["summary"]; summary != "" {
sb.WriteString(fmt.Sprintf("Summary: %s\n", summary))
}
if description := alert.Annotations["description"]; description != "" {
sb.WriteString(fmt.Sprintf("Description: %s\n", description))
}
// Value string if present
if alert.ValueString != "" {
sb.WriteString(fmt.Sprintf("Value: %s\n", alert.ValueString))
}
// Key labels (excluding alertname)
var labels []string
for k, v := range alert.Labels {
if k != "alertname" && k != "__alert_rule_uid__" && k != "__alert_rule_namespace_uid__" {
labels = append(labels, fmt.Sprintf("%s=%s", k, v))
}
}
if len(labels) > 0 {
sb.WriteString(fmt.Sprintf("Labels: %s\n", strings.Join(labels, ", ")))
}
// Links
if alert.DashboardURL != "" {
sb.WriteString(fmt.Sprintf("Dashboard: %s\n", alert.DashboardURL))
}
if alert.PanelURL != "" {
sb.WriteString(fmt.Sprintf("Panel: %s\n", alert.PanelURL))
}
if alert.SilenceURL != "" {
sb.WriteString(fmt.Sprintf("Silence: %s\n", alert.SilenceURL))
}
}
// External URL
if payload.ExternalURL != "" {
sb.WriteString(fmt.Sprintf("\nGrafana: %s\n", payload.ExternalURL))
}
return sb.String()
}

View file

@ -0,0 +1,429 @@
package grafana_alertmanager
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewGrafanaAlertmanagerChannel_RequiresSecretWhenAllowFromRestrictive(t *testing.T) {
tests := []struct {
name string
allowFrom []string
secret string
wantErr bool
}{
{
name: "open access (empty allow_from) without secret is allowed",
allowFrom: nil,
secret: "",
wantErr: false,
},
{
name: "open access (wildcard) without secret is allowed",
allowFrom: []string{"*"},
secret: "",
wantErr: false,
},
{
name: "restrictive allow_from without secret fails",
allowFrom: []string{"specific-sender"},
secret: "",
wantErr: true,
},
{
name: "restrictive allow_from with secret is allowed",
allowFrom: []string{"specific-sender"},
secret: "mysecret",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.GrafanaAlertmanagerConfig{
Enabled: true,
AllowFrom: tt.allowFrom,
}
if tt.secret != "" {
cfg.Secret = *config.NewSecureString(tt.secret)
}
msgBus := bus.NewMessageBus()
_, err := NewGrafanaAlertmanagerChannel(cfg, msgBus)
if (err != nil) != tt.wantErr {
t.Errorf("NewGrafanaAlertmanagerChannel() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestWebhookRejectsNonPostMethod(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{}
req := httptest.NewRequest(http.MethodGet, "/webhook/grafana-alertmanager", nil)
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestWebhookRejectsOversizedBody(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", bytes.NewReader(oversized))
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{
config: config.GrafanaAlertmanagerConfig{
Secret: *config.NewSecureString("testsecret"),
},
}
// Create a body exactly at the limit
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", bytes.NewReader(body))
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
// Should fail on signature check, not body size
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d (forbidden due to missing signature), got %d", http.StatusForbidden, rec.Code)
}
}
func TestWebhookRejectsMissingSignatureWhenSecretConfigured(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{
config: config.GrafanaAlertmanagerConfig{
Secret: *config.NewSecureString("testsecret"),
},
}
body := `{"status":"firing","alerts":[]}`
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body))
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}
func TestWebhookRejectsInvalidSignature(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{
config: config.GrafanaAlertmanagerConfig{
Secret: *config.NewSecureString("testsecret"),
},
}
body := `{"status":"firing","alerts":[]}`
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body))
req.Header.Set("X-Grafana-Alerting-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}
func TestWebhookRejectsInvalidJSON(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{
config: config.GrafanaAlertmanagerConfig{}, // no secret, open access
}
body := `{invalid json`
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body))
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestWebhookAcceptsValidPayloadWithoutSecret(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.GrafanaAlertmanagerConfig{
Enabled: true,
ChatID: "test-chat",
}
ch, err := NewGrafanaAlertmanagerChannel(cfg, msgBus)
if err != nil {
t.Fatalf("failed to create channel: %v", err)
}
body := `{
"status": "firing",
"receiver": "test-receiver",
"alerts": [
{
"status": "firing",
"labels": {"alertname": "TestAlert", "severity": "critical"},
"annotations": {"summary": "Test summary", "description": "Test description"}
}
],
"groupKey": "test-group",
"title": "Test Alert Title"
}`
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body))
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
}
if rec.Body.String() != `{"status":"ok"}` {
t.Errorf("expected body %q, got %q", `{"status":"ok"}`, rec.Body.String())
}
}
func TestWebhookAcceptsValidPayloadWithValidSignature(t *testing.T) {
secret := "testsecret123"
msgBus := bus.NewMessageBus()
cfg := config.GrafanaAlertmanagerConfig{
Enabled: true,
Secret: *config.NewSecureString(secret),
ChatID: "test-chat",
}
ch, err := NewGrafanaAlertmanagerChannel(cfg, msgBus)
if err != nil {
t.Fatalf("failed to create channel: %v", err)
}
body := `{"status":"firing","receiver":"test","alerts":[],"groupKey":"g1","title":"Test"}`
// Compute valid signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
signature := hex.EncodeToString(mac.Sum(nil))
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body))
req.Header.Set("X-Grafana-Alerting-Signature", signature)
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
}
}
func TestWebhookAcceptsSignatureWithSha256Prefix(t *testing.T) {
secret := "testsecret123"
msgBus := bus.NewMessageBus()
cfg := config.GrafanaAlertmanagerConfig{
Enabled: true,
Secret: *config.NewSecureString(secret),
ChatID: "test-chat",
}
ch, err := NewGrafanaAlertmanagerChannel(cfg, msgBus)
if err != nil {
t.Fatalf("failed to create channel: %v", err)
}
body := `{"status":"firing","receiver":"test","alerts":[],"groupKey":"g1","title":"Test"}`
// Compute valid signature with sha256= prefix
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana-alertmanager", strings.NewReader(body))
req.Header.Set("X-Grafana-Alerting-Signature", signature)
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code)
}
}
func TestVerifySignature(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{
config: config.GrafanaAlertmanagerConfig{
Secret: *config.NewSecureString("mysecret"),
},
}
body := []byte("test body content")
mac := hmac.New(sha256.New, []byte("mysecret"))
mac.Write(body)
validSig := hex.EncodeToString(mac.Sum(nil))
tests := []struct {
name string
body []byte
signature string
want bool
}{
{
name: "valid signature",
body: body,
signature: validSig,
want: true,
},
{
name: "valid signature with sha256 prefix",
body: body,
signature: "sha256=" + validSig,
want: true,
},
{
name: "empty signature",
body: body,
signature: "",
want: false,
},
{
name: "invalid hex",
body: body,
signature: "notvalidhex!!!",
want: false,
},
{
name: "wrong signature",
body: body,
signature: strings.Repeat("ab", 32), // valid hex but wrong value
want: false,
},
{
name: "wrong length",
body: body,
signature: "abcd",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ch.verifySignature(tt.body, tt.signature)
if got != tt.want {
t.Errorf("verifySignature() = %v, want %v", got, tt.want)
}
})
}
}
func TestFormatAlertMessage(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{}
payload := &WebhookPayload{
Status: "firing",
Receiver: "test-receiver",
Title: "High CPU Usage",
Message: "Server is experiencing high CPU load",
Alerts: []Alert{
{
Status: "firing",
Labels: map[string]string{
"alertname": "HighCPU",
"severity": "critical",
"host": "server1",
},
Annotations: map[string]string{
"summary": "CPU usage above 90%",
"description": "The server CPU has been above 90% for 5 minutes",
},
ValueString: "cpu_usage: 95%",
DashboardURL: "http://grafana/dashboard",
PanelURL: "http://grafana/panel",
SilenceURL: "http://grafana/silence",
},
},
ExternalURL: "http://grafana",
}
result := ch.formatAlertMessage(payload)
// Check key elements are present
checks := []string{
"**[FIRING] High CPU Usage**",
"Server is experiencing high CPU load",
"**HighCPU** (firing)",
"Summary: CPU usage above 90%",
"Description: The server CPU has been above 90% for 5 minutes",
"Value: cpu_usage: 95%",
"severity=critical",
"host=server1",
"Dashboard: http://grafana/dashboard",
"Panel: http://grafana/panel",
"Silence: http://grafana/silence",
"Grafana: http://grafana",
}
for _, check := range checks {
if !strings.Contains(result, check) {
t.Errorf("formatted message missing %q\nGot:\n%s", check, result)
}
}
}
func TestFormatAlertMessage_DefaultTitle(t *testing.T) {
ch := &GrafanaAlertmanagerChannel{}
payload := &WebhookPayload{
Status: "resolved",
Alerts: []Alert{},
}
result := ch.formatAlertMessage(payload)
if !strings.Contains(result, "**[RESOLVED] Grafana Alert**") {
t.Errorf("expected default title, got: %s", result)
}
}
func TestIsOpenAccess(t *testing.T) {
tests := []struct {
name string
allowFrom []string
want bool
}{
{"nil", nil, true},
{"empty", []string{}, true},
{"wildcard only", []string{"*"}, true},
{"wildcard with others", []string{"foo", "*", "bar"}, true},
{"specific values", []string{"foo", "bar"}, false},
{"single specific", []string{"foo"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isOpenAccess(tt.allowFrom); got != tt.want {
t.Errorf("isOpenAccess(%v) = %v, want %v", tt.allowFrom, got, tt.want)
}
})
}
}

View file

@ -0,0 +1,16 @@
package grafana_alertmanager
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory(
"grafana_alertmanager",
func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewGrafanaAlertmanagerChannel(cfg.Channels.GrafanaAlertmanager, b)
},
)
}

View file

@ -443,6 +443,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
}
}
if channels.GrafanaAlertmanager.Enabled {
m.initChannel("grafana_alertmanager", "Grafana Alertmanager")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})

View file

@ -69,6 +69,8 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
webhooks[name] = target.WebhookURL.String()
}
value["webhooks"] = webhooks
case "grafana_alertmanager":
value["secret"] = ch.GrafanaAlertmanager.Secret.String()
}
}
@ -182,4 +184,7 @@ func updateKeys(newcfg, old *config.ChannelsConfig) {
}
}
}
if newcfg.GrafanaAlertmanager.Enabled {
newcfg.GrafanaAlertmanager.Secret = old.GrafanaAlertmanager.Secret
}
}

View file

@ -314,6 +314,7 @@ type ChannelsConfig struct {
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
VK VKConfig `json:"vk" yaml:"vk,omitempty"`
TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"`
GrafanaAlertmanager GrafanaAlertmanagerConfig `json:"grafana_alertmanager" yaml:"grafana_alertmanager,omitempty"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@ -596,6 +597,17 @@ type TeamsWebhookTarget struct {
Title string `json:"title,omitempty" yaml:"-"`
}
// GrafanaAlertmanagerConfig configures the Grafana Alertmanager webhook channel.
// This channel exposes a webhook endpoint that receives alerts from Grafana Alertmanager.
type GrafanaAlertmanagerConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_ENABLED"`
WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_WEBHOOK_PATH"`
Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_ALLOW_FROM"`
ChatID string `json:"chat_id" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_CHAT_ID"`
ForcedSkills []string `json:"forced_skills" yaml:"-" env:"PICOCLAW_CHANNELS_GRAFANA_ALERTMANAGER_FORCED_SKILLS"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5

View file

@ -21,6 +21,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
_ "github.com/sipeed/picoclaw/pkg/channels/grafana_alertmanager"
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"