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 |
|-----------------------------|-------------|