diff --git a/README.md b/README.md index 806eb0e..ca3a957 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ An incident management tool that supports alerting across multiple channels with ## Features -- 🚨 **Multi-channel Alerts**: Send incident notifications to Slack, Microsoft Teams, Telegram, Viber, Email, and Lark (more channels coming!) +- 🚨 **Multi-channel Alerts**: Send incident notifications to Slack, Microsoft Teams, Telegram, Viber, Email, Lark, and Google Chat (more channels coming!) - 📝 **Custom Templates**: Define your own alert messages using Go templates - 🔧 **Easy Configuration**: YAML-based configuration with environment variables support - 📡 **REST API**: Simple HTTP interface to receive alerts @@ -712,6 +712,13 @@ alert: dev: ${LARK_OTHER_WEBHOOK_URL_DEV} prod: ${LARK_OTHER_WEBHOOK_URL_PROD} + googlechat: + enable: false # Default value, will be overridden by GOOGLECHAT_ENABLE env var + webhook_url: ${GOOGLECHAT_WEBHOOK_URL} # Google Chat Webhook URL + template_path: "config/googlechat_message.tmpl" + message_properties: + button_text: "Acknowledge Alert" # Custom text for the acknowledgment button + queue: enable: true debug_body: true @@ -876,6 +883,14 @@ Viber supports two types of API integrations: | `LARK_OTHER_WEBHOOK_URL_DEV` | (Optional) Webhook URL for the development environment. **Can be selected per request using the `lark_other_webhook_url=dev` query parameter.** | | `LARK_OTHER_WEBHOOK_URL_PROD` | (Optional) Webhook URL for the production environment. **Can be selected per request using the `lark_other_webhook_url=prod` query parameter.** | +### Google Chat Configuration +| Variable | Description | +|---------------------------|-------------| +| `GOOGLECHAT_ENABLE` | Set to `true` to enable Google Chat notifications. | +| `GOOGLECHAT_WEBHOOK_URL` | The incoming webhook URL obtained from your Google Chat space. | + +For Google Chat, you also need to configure `message_properties` in `config.yaml` for the acknowledgment button text, as shown in the 'Complete Configuration' section. + ### Queue Services Configuration | Variable | Description | |-----------------------------|-------------| diff --git a/config/config.yaml b/config/config.yaml index 0731b47..8ae4f7e 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -64,6 +64,13 @@ alert: dev: ${LARK_OTHER_WEBHOOK_URL_DEV} prod: ${LARK_OTHER_WEBHOOK_URL_PROD} + googlechat: + enable: false # Default value, will be overridden by GOOGLECHAT_ENABLE env var + webhook_url: ${GOOGLECHAT_WEBHOOK_URL} # Google Chat webhook URL (required) + template_path: "config/googlechat_message.tmpl" + message_properties: + button_text: "Acknowledge Alert" # Custom text for the acknowledgment button + queue: enable: true debug_body: true diff --git a/config/googlechat_message.tmpl b/config/googlechat_message.tmpl new file mode 100644 index 0000000..907060c --- /dev/null +++ b/config/googlechat_message.tmpl @@ -0,0 +1,348 @@ +{{/* + Universal Google Chat Alert Template + Supports: Alertmanager, Grafana, Sentry, Fluent Bit, CloudWatch +*/}} + +{{/* Helper Variables */}} +{{- $defaultRunbook := or (env "DEFAULT_RUNBOOK_URL") "" -}} +{{- $severityIcons := dict "CRITICAL" "🔴" "ERROR" "🟠" "WARNING" "🟡" "INFO" "ℹ️" "RESOLVED" "✅" -}} +{{- $statusIcons := dict "FIRING" "🔥" "RESOLVED" "✅" "UNKNOWN" "ℹ️" -}} + +{{/* Detect Source System - Same logic as before */}} +{{- $source := "Unknown" -}} +{{- if and .receiver -}} + {{- if or .commonAnnotations.dashboardURL (and .alerts (index .alerts 0).dashboardURL) -}} + {{- $source = "Grafana" -}} + {{- else -}} + {{- $source = "Prometheus" -}} + {{- end -}} +{{- else if .AlarmName -}} + {{- $source = "CloudWatch" -}} +{{- else if or .log .kubernetes.pod_name -}} + {{- $source = "Fluent Bit" -}} +{{- else if or .event.event_id .data.issue.id -}} + {{- $source = "Sentry" -}} +{{- end -}} + +{{/* Process Alerts - Same logic as before */}} +{{- $alerts := list -}} +{{- if or (eq $source "Prometheus") (eq $source "Grafana") -}} + {{- $alerts = .alerts -}} +{{- else -}} + {{- $alerts = list . -}} {{/* Treat single payload as one alert */}} +{{- end -}} + +{{- /* Start of Google Chat JSON output */ -}} +{"cardsV2": [ +{{- range $index, $alert := $alerts -}} + {{/* Initialize unified alert data structure */}} + {{- $unified := dict + "SourceSystem" $source + "Severity" "INFO" + "Status" "UNKNOWN" + "Title" "Unknown Alert" + "Resource" "N/A" + "Description" "No description." + "Timestamp" (now | format "2006-01-02 15:04:05") + "DiagnosticLink" "" + "RunbookLink" $defaultRunbook + -}} + + {{/* Map severity based on alert type - Same logic as before */}} + {{- $rawSeverity := "" -}} + {{- if eq $source "Prometheus" -}} + {{- $rawSeverity = or $alert.labels.severity "info" -}} + {{- else if eq $source "Grafana" -}} + {{- $rawSeverity = or $alert.labels.severity "info" -}} + {{- else if eq $source "CloudWatch" -}} + {{- $rawSeverity = or $alert.NewStateValue "info" -}} + {{- else if eq $source "Fluent Bit" -}} + {{- $rawSeverity = or $alert.level "info" -}} + {{- else if eq $source "Sentry" -}} + {{- $rawSeverity = or $alert.data.issue.level $alert.event.level "info" -}} + {{- end -}} + + {{- $severity := lower $rawSeverity -}} + {{- $mappedSeverity := "INFO" -}} + {{- if or (eq $severity "critical") (eq $severity "fatal") (eq $severity "alarm") (eq $severity "p1") (eq $severity "1") -}} + {{- $mappedSeverity = "CRITICAL" -}} + {{- else if or (eq $severity "error") (eq $severity "high") (eq $severity "p2") (eq $severity "2") -}} + {{- $mappedSeverity = "ERROR" -}} + {{- else if or (eq $severity "warning") (eq $severity "warn") (eq $severity "p3") (eq $severity "3") -}} + {{- $mappedSeverity = "WARNING" -}} + {{- else if or (eq $severity "ok") (eq $severity "resolved") -}} + {{- $mappedSeverity = "RESOLVED" -}} + {{- end -}} + + {{/* Map status based on alert type - Same logic as before */}} + {{- $rawStatus := "" -}} + {{- if eq $source "Prometheus" -}} + {{- $rawStatus = or $alert.status "unknown" -}} + {{- else if eq $source "Grafana" -}} + {{- $rawStatus = or $alert.status "unknown" -}} + {{- else if eq $source "CloudWatch" -}} + {{- $rawStatus = or $alert.NewStateValue "unknown" -}} + {{- else if eq $source "Fluent Bit" -}} + {{- $rawStatus = or $alert.level "unknown" -}} + {{- else if eq $source "Sentry" -}} + {{- $rawStatus = or $alert.action "unknown" -}} + {{- end -}} + + {{- $status := lower $rawStatus -}} + {{- $mappedStatus := "UNKNOWN" -}} + {{- if or (eq $status "firing") (eq $status "alarm") (eq $status "active") (eq $status "unresolved") (eq $status "created") (eq $status "triggered") -}} + {{- $mappedStatus = "FIRING" -}} + {{- else if or (eq $status "resolved") (eq $status "ok") (eq $status "completed") -}} + {{- $mappedStatus = "RESOLVED" -}} + {{- end -}} + + {{/* Source-Specific Data Extraction - Same logic as before */}} + {{- if eq $source "Prometheus" -}} + {{- $unified = dict + "SourceSystem" $source + "Severity" $mappedSeverity + "Status" $mappedStatus + "Title" (or $alert.labels.alertname "Prometheus Alert") + "Resource" (or $alert.labels.instance $alert.labels.pod $alert.labels.job "N/A") + "Description" (or $alert.annotations.description $alert.annotations.message $alert.annotations.summary "No description.") + "Timestamp" (or $alert.startsAt (now | format "2006-01-02 15:04:05")) + "DiagnosticLink" (or $alert.generatorURL "") + "RunbookLink" (or $alert.annotations.runbook_url $defaultRunbook) + -}} + {{- else if eq $source "Grafana" -}} + {{- $unified = dict + "SourceSystem" $source + "Severity" $mappedSeverity + "Status" $mappedStatus + "Title" (or $alert.labels.alertname $alert.annotations.summary $alert.annotations.title "Grafana Alert") + "Resource" (or $alert.labels.instance $alert.labels.pod $alert.labels.job $alert.labels.host "N/A") + "Description" (or $alert.annotations.description $alert.annotations.message "No description.") + "Timestamp" (or $alert.startsAt (now | format "2006-01-02 15:04:05")) + "DiagnosticLink" (or $alert.panelURL $alert.dashboardURL $alert.generatorURL "") + "RunbookLink" (or $alert.annotations.runbook_url $defaultRunbook) + -}} + {{- else if eq $source "CloudWatch" -}} + {{- $formattedDimensions := "" -}} + {{- $metricNamespace := or $alert.Trigger.Namespace "AWS" -}} + {{- $metricName := or $alert.Trigger.MetricName "Unknown" -}} + {{- if $alert.Trigger.Dimensions -}} + {{- $dimensionsList := list -}} + {{- range $dimension := $alert.Trigger.Dimensions -}} + {{- if and $dimension.name $dimension.value -}} + {{- $dimensionsList = append $dimensionsList (printf "%s: %s" $dimension.name $dimension.value) -}} + {{- end -}} + {{- end -}} + {{- if $dimensionsList -}} + {{- $formattedDimensions = join (stringSlice $dimensionsList) ", " -}} + {{- end -}} + {{- end -}} + {{- $resource := "N/A" -}} + {{- if $formattedDimensions -}} + {{- $resource = printf "%s/%s (%s)" $metricNamespace $metricName $formattedDimensions -}} + {{- else -}} + {{- $resource = printf "%s/%s" $metricNamespace $metricName -}} + {{- end -}} + {{- $regionCode := "" -}} + {{- if contains "us-east-1" $alert.AlarmArn -}} + {{- $regionCode = "us-east-1" -}} + {{- else if contains "us-east-2" $alert.AlarmArn -}} + {{- $regionCode = "us-east-2" -}} + {{- else if contains "us-west-1" $alert.AlarmArn -}} + {{- $regionCode = "us-west-1" -}} + {{- else if contains "us-west-2" $alert.AlarmArn -}} + {{- $regionCode = "us-west-2" -}} + {{- else if contains "eu-central-1" $alert.AlarmArn -}} + {{- $regionCode = "eu-central-1" -}} + {{- else if contains "eu-west-1" $alert.AlarmArn -}} + {{- $regionCode = "eu-west-1" -}} + {{- else if contains "ap-northeast-1" $alert.AlarmArn -}} + {{- $regionCode = "ap-northeast-1" -}} + {{- else if contains "ap-southeast-1" $alert.AlarmArn -}} + {{- $regionCode = "ap-southeast-1" -}} + {{- else if contains "ap-southeast-2" $alert.AlarmArn -}} + {{- $regionCode = "ap-southeast-2" -}} + {{- else -}} + {{- $regionCode = "us-east-1" -}} + {{- end -}} + {{- $diagnosticLink := printf "https://%s.console.aws.amazon.com/cloudwatch/home?region=%s#alarmsV2:alarm/%s" $regionCode $regionCode $alert.AlarmName -}} + {{- $unified = dict + "SourceSystem" $source + "Severity" $mappedSeverity + "Status" $mappedStatus + "Title" (or $alert.AlarmName "CloudWatch Alert") + "Resource" $resource + "Description" (or $alert.NewStateReason "No description.") + "Timestamp" (or $alert.StateChangeTime (now | format "2006-01-02 15:04:05")) + "DiagnosticLink" $diagnosticLink + "RunbookLink" $defaultRunbook + "AWSAccount" (or $alert.AWSAccountId "") + "AWSRegion" $regionCode + -}} + {{- else if eq $source "Fluent Bit" -}} + {{- $detectedSeverity := "INFO" -}} + {{- if and $alert.log (regexMatch "(?i)ERROR" $alert.log) -}} + {{- $detectedSeverity = "ERROR" -}} + {{- else if and $alert.log (regexMatch "(?i)CRITICAL" $alert.log) -}} + {{- $detectedSeverity = "WARNING" -}} + {{- else if and $alert.log (regexMatch "(?i)WARNING" $alert.log) -}} + {{- $detectedSeverity = "WARNING" -}} + {{- end -}} + {{- $podResource := "unknown" -}} + {{- if $alert.kubernetes -}} + {{- $podName := or $alert.kubernetes.pod_name "unknown-pod" -}} + {{- $namespace := or $alert.kubernetes.namespace_name "unknown-namespace" -}} + {{- $containerName := or $alert.kubernetes.container_name "unknown-container" -}} + {{- $podResource = printf "pod/%s (container: %s) in namespace %s" $podName $containerName $namespace -}} + {{- end -}} + {{- $timestamp := "" -}} + {{- if $alert.time -}} + {{- $timestamp = $alert.time -}} + {{- else if $alert.date -}} + {{- $timestamp = $alert.date | toString -}} + {{- else -}} + {{- $timestamp = now | format "2006-01-02 15:04:05" -}} + {{- end -}} + {{- $appName := "unknown" -}} + {{- if and $alert.kubernetes $alert.kubernetes.labels $alert.kubernetes.labels.app -}} + {{- $appName = $alert.kubernetes.labels.app -}} + {{- end -}} + {{- $errorMessage := $alert.log -}} + {{- $shortError := $alert.log -}} + {{- if contains "\n" $shortError -}} + {{- $lines := split "\n" $shortError -}} + {{- $shortError = index $lines 0 -}} + {{- end -}} + {{- $unified = dict + "SourceSystem" $source + "Severity" $detectedSeverity + "Status" "FIRING" + "Title" (printf "Error in %s" $appName) + "Resource" $podResource + "Description" $errorMessage + "Timestamp" $timestamp + "DiagnosticLink" "" + "RunbookLink" $defaultRunbook + "K8s" (dict + "Namespace" (or $alert.kubernetes.namespace_name "") + "PodName" (or $alert.kubernetes.pod_name "") + "ContainerName" (or $alert.kubernetes.container_name "") + "Node" (or $alert.kubernetes.host "") + "Labels" (or $alert.kubernetes.labels dict) + ) + -}} + {{- else if eq $source "Sentry" -}} + {{- $unified = dict + "SourceSystem" $source + "Severity" $mappedSeverity + "Status" $mappedStatus + "Title" (or $alert.data.issue.title $alert.message $alert.event.title "Sentry Alert") + "Resource" (printf "%s/%s" (or $alert.project_slug "unknown") (or $alert.data.issue.culprit $alert.culprit "N/A")) + "Description" (or $alert.data.issue.metadata.value $alert.event.logentry.formatted "No description.") + "Timestamp" (or $alert.data.issue.firstSeen $alert.event.timestamp (now | format "2006-01-02 15:04:05")) + "DiagnosticLink" (or $alert.data.issue.web_url $alert.url "") + "RunbookLink" $defaultRunbook + -}} + {{- end -}} + + {{- $severityIcon := or (index $severityIcons $unified.Severity) "ℹ️" -}} + {{- $statusIcon := or (index $statusIcons $unified.Status) "ℹ️" -}} + + { + "card": { + "header": { + "title": "{{ $statusIcon }} {{ $unified.Status }}: {{ $unified.Title | js }}", + "subtitle": "{{ $severityIcon }} Severity: {{ $unified.Severity }} ({{ $unified.SourceSystem }})" + }, + "sections": [ + { + "widgets": [ + { + "textParagraph": { + "text": "Resource: {{ $unified.Resource | js }}" + } + }, + { + "textParagraph": { + "text": "Description: {{ $unified.Description | js }}" + } + }, + { + "textParagraph": { + "text": "Time: {{ $unified.Timestamp | js }}" + } + } + {{- if $unified.AWSAccount -}}, + { + "textParagraph": { + "text": "AWS Account: {{ $unified.AWSAccount | js }}" + } + } + {{- end -}} + {{- if $unified.AWSRegion -}}, + { + "textParagraph": { + "text": "AWS Region: {{ $unified.AWSRegion | js }}" + } + } + {{- end -}} + {{- if and (eq $unified.SourceSystem "Fluent Bit") $unified.K8s -}}, + { + "textParagraph": { + "text": "Kubernetes Metadata:" + } + } + {{- if $unified.K8s.Namespace -}}, + { "textParagraph": { "text": " • Namespace: {{ $unified.K8s.Namespace | js }}" } } + {{- end -}} + {{- if $unified.K8s.PodName -}}, + { "textParagraph": { "text": " • Pod: {{ $unified.K8s.PodName | js }}" } } + {{- end -}} + {{- if $unified.K8s.ContainerName -}}, + { "textParagraph": { "text": " • Container: {{ $unified.K8s.ContainerName | js }}" } } + {{- end -}} + {{- if $unified.K8s.Node -}}, + { "textParagraph": { "text": " • Node: {{ $unified.K8s.Node | js }}" } } + {{- end -}} + {{- if $unified.K8s.Labels -}}, + { "textParagraph": { "text": " • Labels:" } } + {{- range $key, $value := $unified.K8s.Labels -}} + ,{ "textParagraph": { "text": " - {{ $key | js }}: {{ $value | js }}" } } + {{- end -}} + {{- end -}} + {{- end -}} + {{- $buttons := list -}} + {{- if $unified.RunbookLink -}} + {{- $buttons = append $buttons (dict "text" "Runbook" "onClick" (dict "openLink" (dict "url" $unified.RunbookLink))) -}} + {{- end -}} + {{- if $unified.DiagnosticLink -}} + {{- $buttons = append $buttons (dict "text" "Diagnostics" "onClick" (dict "openLink" (dict "url" $unified.DiagnosticLink))) -}} + {{- end -}} + {{- if $alert.AckURL -}} + {{- $buttons = append $buttons (dict "text" "Acknowledge" "onClick" (dict "openLink" (dict "url" $alert.AckURL))) -}} + {{- end -}} + {{- if $buttons -}}, + { + "buttonList": { + "buttons": [ + {{- range $btnIndex, $button := $buttons -}} + { + "text": "{{ $button.text | js }}", + "onClick": { + "openLink": { + "url": "{{ $button.onClick.openLink.url | js }}" + } + } + }{{- if ne (add $btnIndex 1) (len $buttons) -}},{{- end -}} + {{- end -}} + ] + } + } + {{- end -}} + ] + } + ] + } + } + {{- if ne (add $index 1) (len $alerts) -}},{{- end -}} +{{- end -}} +]} \ No newline at end of file diff --git a/go.mod b/go.mod index bb09479..912560b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/slack-go/slack v0.15.0 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 ) require ( @@ -23,7 +24,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect github.com/aws/smithy-go v1.22.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect ) require ( diff --git a/pkg/common/factory_alert.go b/pkg/common/factory_alert.go index 8762dc4..f4a13c7 100644 --- a/pkg/common/factory_alert.go +++ b/pkg/common/factory_alert.go @@ -67,6 +67,14 @@ func (f *AlertProviderFactory) CreateProviders() ([]core.AlertProvider, error) { providers = append(providers, larkProvider) } + if f.cfg.Alert.GoogleChat.Enable { + googleChatProvider, err := f.createGoogleChatProvider() + if err != nil { + return nil, fmt.Errorf("failed to create GoogleChat provider: %w", err) + } + providers = append(providers, googleChatProvider) + } + return providers, nil } @@ -98,6 +106,25 @@ func (f *AlertProviderFactory) createTelegramProvider() (core.AlertProvider, err }, f.cfg.Proxy), nil } +func (f *AlertProviderFactory) createGoogleChatProvider() (core.AlertProvider, error) { + gc := f.cfg.Alert.GoogleChat // Assuming GoogleChat is added to f.cfg.Alert in a later step + if gc.WebhookURL == "" || gc.TemplatePath == "" { + return nil, fmt.Errorf("missing required GoogleChat configuration: WebhookURL and TemplatePath are required") + } + + // Construct the GoogleChatConfig for NewGoogleChatProvider + // This assumes config.GoogleChatConfig and config.GoogleChatMessageProperties structs exist + // and that gc (f.cfg.Alert.GoogleChat) has matching fields. + googleChatCfg := config.GoogleChatConfig{ + WebhookURL: gc.WebhookURL, + TemplatePath: gc.TemplatePath, + MessageProperties: config.GoogleChatMessageProperties{ + ButtonText: gc.MessageProperties.ButtonText, + }, + } + return NewGoogleChatProvider(googleChatCfg), nil +} + func (f *AlertProviderFactory) createViberProvider() (core.AlertProvider, error) { vc := f.cfg.Alert.Viber diff --git a/pkg/common/googlechat.go b/pkg/common/googlechat.go new file mode 100644 index 0000000..6e4ce5b --- /dev/null +++ b/pkg/common/googlechat.go @@ -0,0 +1,136 @@ +package common + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + "strings" + "text/template" + "time" + + "github.com/VersusControl/versus-incident/pkg/config" + m "github.com/VersusControl/versus-incident/pkg/models" + "github.com/VersusControl/versus-incident/pkg/utils" +) + +// GoogleChatMessageProperties holds configuration for Google Chat message buttons +type GoogleChatMessageProperties struct { + ButtonText string +} + +// GoogleChatProvider holds the configuration for the Google Chat alert provider +type GoogleChatProvider struct { + webhookURL string + templatePath string + msgProps GoogleChatMessageProperties + httpClient *http.Client +} + +// NewGoogleChatProvider initializes a new GoogleChatProvider +func NewGoogleChatProvider(cfg config.GoogleChatConfig) *GoogleChatProvider { + return &GoogleChatProvider{ + webhookURL: cfg.WebhookURL, + templatePath: cfg.TemplatePath, + msgProps: GoogleChatMessageProperties{ + ButtonText: cfg.MessageProperties.ButtonText, + }, + httpClient: &http.Client{Timeout: 10 * time.Second}, // Using standard http.Client as utils.NewHTTPClient is hypothetical + } +} + +// SendAlert sends an alert to Google Chat +func (s *GoogleChatProvider) SendAlert(i *m.Incident) error { + var status interface{} + if i.Content != nil { + status = (*i.Content)["status"] + } + utils.Log.Infof("GoogleChatProvider: Received alert ID %s, Status: %s", i.ID, status) + + incidentData := make(map[string]interface{}) + if i.Content != nil { + for k, v := range *i.Content { + incidentData[k] = v + } + } + + var ackURL string + // Determine if resolved. Safely access status. + var statusStr string + if statusVal, ok := incidentData["status"]; ok { + if str, ok := statusVal.(string); ok { + statusStr = str + } + } + isResolved := strings.ToLower(statusStr) == "resolved" + if !isResolved { + // Process AckURL, similar to SlackProvider.processAckURL + if ackURLVal, ok := incidentData["AckURL"]; ok { + ackURL = fmt.Sprintf("%v", ackURLVal) + delete(incidentData, "AckURL") // Remove from incidentData as it's handled separately + } + utils.Log.Debugf("GoogleChatProvider: AckURL for incident %s: %s", i.ID, ackURL) + } + payload, err := renderCardPayload(s.templatePath, incidentData) + if err != nil { + utils.Log.Errorf("GoogleChatProvider: Error rendering card payload for incident %s: %v", i.ID, err) + return fmt.Errorf("failed to render Google Chat card payload for incident %s: %w", i.ID, err) + } + utils.Log.Debugf("GoogleChatProvider: Payload for incident %s: %s", i.ID, string(payload)) + + utils.Log.Infof("GoogleChatProvider: Sending alert %s to Google Chat webhook", i.ID) + req, err := http.NewRequest("POST", s.webhookURL, bytes.NewBuffer(payload)) + if err != nil { + utils.Log.Errorf("GoogleChatProvider: Error creating HTTP request for incident %s: %v", i.ID, err) + return fmt.Errorf("failed to create Google Chat request for incident %s: %w", i.ID, err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + utils.Log.Errorf("GoogleChatProvider: Error sending alert %s to Google Chat: %v", i.ID, err) + return fmt.Errorf("failed to send Google Chat message for incident %s: %w", i.ID, err) + } + defer resp.Body.Close() + + responseBodyBytes, readErr := ioutil.ReadAll(resp.Body) + if readErr != nil { + utils.Log.Errorf("GoogleChatProvider: Failed to read response body for incident %s: %v", i.ID, readErr) + // Continue to check status code, as the request might have succeeded + } + + if resp.StatusCode != http.StatusOK { + utils.Log.Errorf("GoogleChatProvider: Google Chat responded with status %s for incident %s. Body: %s", resp.Status, i.ID, string(responseBodyBytes)) + return fmt.Errorf("failed to send Google Chat message for incident %s: status code %d, response: %s", i.ID, resp.StatusCode, string(responseBodyBytes)) + } + + utils.Log.Infof("GoogleChatProvider: Successfully sent alert %s to Google Chat", i.ID) + return nil +} + +// renderCardPayload generates the JSON payload for a Google Chat Card +func renderCardPayload(templatePath string, incidentData map[string]interface{}) ([]byte, error) { + if templatePath == "" { + utils.Log.Errorf("GoogleChatProvider: Template path is not configured.") + return nil, fmt.Errorf("google chat template path is not configured") + } + + tmplName := filepath.Base(templatePath) + tmpl, err := template.New(tmplName).Funcs(utils.GetTemplateFuncMaps()).ParseFiles(templatePath) + if err != nil { + utils.Log.Errorf("GoogleChatProvider: Error parsing template %s: %v", templatePath, err) + return nil, fmt.Errorf("failed to parse Google Chat template %s: %w", templatePath, err) + } + + var buf bytes.Buffer + + if err := tmpl.Execute(&buf, incidentData); err != nil { + utils.Log.Errorf("GoogleChatProvider: Error executing template %s: %v", templatePath, err) + return nil, fmt.Errorf("failed to execute Google Chat template %s: %w", templatePath, err) + } + + // The template is expected to produce a valid JSON. + // We don't marshal here because the template itself should generate the JSON string. + return buf.Bytes(), nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 3772012..54ff24c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -38,6 +38,7 @@ type AlertConfig struct { Email EmailConfig MSTeams MSTeamsConfig Lark LarkConfig + GoogleChat GoogleChatConfig `yaml:"googlechat" mapstructure:"googlechat"` } type SlackConfig struct { @@ -101,6 +102,17 @@ type LarkConfig struct { UseProxy bool `mapstructure:"use_proxy"` } +type GoogleChatMessageProperties struct { + ButtonText string `yaml:"button_text" mapstructure:"button_text"` +} + +type GoogleChatConfig struct { + Enable bool `yaml:"enable" mapstructure:"enable"` + WebhookURL string `yaml:"webhook_url" mapstructure:"webhook_url"` + TemplatePath string `yaml:"template_path" mapstructure:"template_path"` + MessageProperties GoogleChatMessageProperties `yaml:"message_properties" mapstructure:"message_properties"` +} + type QueueConfig struct { Enable bool `mapstructure:"enable"` DebugBody bool `mapstructure:"debug_body"` @@ -212,6 +224,7 @@ func LoadConfig(path string) error { setEnableFromEnv("MSTEAMS_ENABLE", &cfg.Alert.MSTeams.Enable) setEnableFromEnv("LARK_ENABLE", &cfg.Alert.Lark.Enable) setEnableFromEnv("LARK_USE_PROXY", &cfg.Alert.Lark.UseProxy) + setEnableFromEnv("GOOGLECHAT_ENABLE", &cfg.Alert.GoogleChat.Enable) setEnableFromEnv("SNS_ENABLE", &cfg.Queue.SNS.Enable) setEnableFromEnv("ONCALL_ENABLE", &cfg.OnCall.Enable) diff --git a/pkg/utils/func_maps.go b/pkg/utils/func_maps.go index 264c255..96e4d51 100644 --- a/pkg/utils/func_maps.go +++ b/pkg/utils/func_maps.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "reflect" + "encoding/json" "regexp" "strings" "sync" @@ -384,6 +385,49 @@ func GetTemplateFuncMaps() template.FuncMap { match, _ := regexp.MatchString(pattern, s) return match }, + + // JSON helper functions + "escapeJsonString": func(s string) string { + b, err := json.Marshal(s) + if err != nil { + // This should ideally not happen for a simple string. + // Log or handle more gracefully in a real scenario. + // For now, returning a placeholder or the original string might be options. + // Returning an error string is also possible if the template can handle it. + return "error escaping string" + } + // json.Marshal wraps the string in quotes, trim them. + if len(b) < 2 { // Should not happen if Marshal succeeded + return "" + } + return string(b[1 : len(b)-1]) + }, + "buildJsonArray": func(elements ...string) string { + var validElements []string + for _, el := range elements { + // Trim whitespace to ensure that strings with only spaces are also considered empty + if strings.TrimSpace(el) != "" { + validElements = append(validElements, el) + } + } + if len(validElements) == 0 { + return "[]" + } + return "[" + strings.Join(validElements, ",") + "]" + }, + "buildJsonObjectMembers": func(members ...string) string { + var validMembers []string + for _, m := range members { + // Trim whitespace + if strings.TrimSpace(m) != "" { + validMembers = append(validMembers, m) + } + } + if len(validMembers) == 0 { + return "{}" + } + return "{" + strings.Join(validMembers, ",") + "}" + }, } return funcMaps diff --git a/pkg/utils/logger.go b/pkg/utils/logger.go new file mode 100644 index 0000000..4593367 --- /dev/null +++ b/pkg/utils/logger.go @@ -0,0 +1,84 @@ +package utils + +import ( + "log" + "os" +) + +// Logger defines a simple interface for logging. +type Logger interface { + Info(args ...any) + Infof(format string, args ...any) + Error(args ...any) + Errorf(format string, args ...any) + Debug(args ...any) + Debugf(format string, args ...any) +} + +// simpleLogger is a basic implementation of the Logger interface. +type simpleLogger struct { + infoLogger *log.Logger + errorLogger *log.Logger + debugLogger *log.Logger +} + +// NewSimpleLogger creates a new SimpleLogger. +// It will log info and error messages to stdout and stderr respectively. +// Debug messages are logged to stdout if LOG_LEVEL=DEBUG. +func NewSimpleLogger() Logger { + return &simpleLogger{ + infoLogger: log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile), + errorLogger: log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile), + debugLogger: log.New(os.Stdout, "DEBUG: ", log.Ldate|log.Ltime|log.Lshortfile), + } +} + +func (l *simpleLogger) Info(args ...any) { + l.infoLogger.Println(args...) +} + +func (l *simpleLogger) Infof(format string, args ...any) { + l.infoLogger.Printf(format, args...) +} + +func (l *simpleLogger) Error(args ...any) { + l.errorLogger.Println(args...) +} + +func (l *simpleLogger) Errorf(format string, args ...any) { + l.errorLogger.Printf(format, args...) +} + +func (l *simpleLogger) Debug(args ...any) { + if os.Getenv("LOG_LEVEL") == "DEBUG" { + l.debugLogger.Println(args...) + } +} + +func (l *simpleLogger) Debugf(format string, args ...any) { + if os.Getenv("LOG_LEVEL") == "DEBUG" { + l.debugLogger.Printf(format, args...) + } +} + +// Global logger instance +var Log Logger + +// InitLogger initializes the global logger. This function can be called from main or other setup routines. +// It allows for explicit initialization, which can be better for testing or more complex setups +// than relying solely on a package-level init(). +// For this project, the global Log is also initialized by default in an init() func. +func InitLogger(appName, logLevel, logOutput, logFile string) { // Parameters added to match existing InitLogger call in googlechat_test.go + // For now, we'll just use the simple logger, ignoring the parameters, + // as the request specifies a simple logger. + // A more advanced implementation would use these parameters. + Log = NewSimpleLogger() +} + +func init() { + // Initialize the global logger by default. + // This can be overridden by an explicit call to InitLogger if needed. + if Log == nil { + Log = NewSimpleLogger() + } +} diff --git a/src/userguide/configuration.md b/src/userguide/configuration.md index 295acb0..b07fe9b 100644 --- a/src/userguide/configuration.md +++ b/src/userguide/configuration.md @@ -80,6 +80,13 @@ alert: dev: ${LARK_OTHER_WEBHOOK_URL_DEV} prod: ${LARK_OTHER_WEBHOOK_URL_PROD} + googlechat: + enable: false # Default value, will be overridden by GOOGLECHAT_ENABLE env var + webhook_url: ${GOOGLECHAT_WEBHOOK_URL} # Google Chat Webhook URL + template_path: "config/googlechat_message.tmpl" + message_properties: + button_text: "Acknowledge Alert" # Custom text for the acknowledgment button + queue: enable: true debug_body: true @@ -253,6 +260,28 @@ This automatic detection provides backward compatibility while supporting newer | `LARK_OTHER_WEBHOOK_URL_DEV` | (Optional) Webhook URL for the development team. **Can be selected per request using the `lark_other_webhook_url=dev` query parameter.** | | `LARK_OTHER_WEBHOOK_URL_PROD` | (Optional) Webhook URL for the production team. **Can be selected per request using the `lark_other_webhook_url=prod` query parameter.** | +### Google Chat Configuration + +To enable Google Chat notifications, configure the following settings in your `config.yaml` and corresponding environment variables: + +```yaml +alert: + googlechat: + enable: false # Set to true or use GOOGLECHAT_ENABLE=true + webhook_url: ${GOOGLECHAT_WEBHOOK_URL} # Google Chat Webhook URL + template_path: "config/googlechat_message.tmpl" # Path to the message template + message_properties: + button_text: "Acknowledge Alert" # Custom text for the acknowledgment button +``` + +| Variable | Description | +|--------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `GOOGLECHAT_ENABLE` | Set to `true` to enable Google Chat notifications. Overrides the `enable` field in `config.yaml`. | +| `GOOGLECHAT_WEBHOOK_URL` | The incoming webhook URL for your Google Chat space. To get this URL, go to your Google Chat space, click the space name, then "Apps & integrations", then "Add webhooks". Give it a name and copy the provided URL. | + +**Message Properties:** +- `button_text`: Defines the text for the acknowledgment button displayed on unresolved alerts that have an `AckURL`. + ### Queue Services Configuration | Variable | Description | |-----------------------------|-------------|