diff --git a/.gitignore b/.gitignore index d77eddd4..667b3e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.tool-versions .idea notifiers-secret.yaml vendor/ diff --git a/docs/services/googlechat.md b/docs/services/googlechat.md index 821c2302..c64004a5 100644 --- a/docs/services/googlechat.md +++ b/docs/services/googlechat.md @@ -32,7 +32,7 @@ kind: Secret metadata: name: stringData: - space-webhook-url: https://chat.googleapis.com/v1/spaces//messages?key=&token= + space-webhook-url: https://chat.googleapis.com/v1/spaces//messages?key=&token= ``` 6. Create a subscription for your space @@ -47,14 +47,9 @@ metadata: ## Templates -You can send [simple text](https://developers.google.com/chat/reference/message-formats/basic) or [card messages](https://developers.google.com/chat/reference/message-formats/cards) to a Google Chat space. A simple text message template can be defined as follows: +### Card Message -```yaml -template.app-sync-succeeded: | - message: The app {{ .app.metadata.name }} has successfully synced! -``` - -A card message can be defined as follows: +You can send [card messages](https://developers.google.com/chat/reference/message-formats/cards) to a Google Chat space. A card message can be defined as follows: ```yaml template.app-sync-succeeded: | @@ -83,6 +78,53 @@ but this is not recommended as Google has deprecated this field and recommends u The card message can be written in JSON too. +### Simple Text + +You can send [simple text](https://developers.google.com/chat/reference/message-formats/basic) to a Google Chat space. A simple text message template can be defined as follows: + +```yaml +template.app-sync-succeeded: | + googlechat: + text: The app {{ .app.metadata.name }} has successfully synced! +``` + +If neither `googlechat.text` nor `googlechat.cardsV2`/`googlechat.cards` is specified, it default to `message` field: + +```yaml +template.app-sync-succeeded: | + message: The app {{ .app.metadata.name }} has successfully synced! +``` + +If you want to include simple text even if cards are specified or text is resolved to empty (or not provided), you can still default to `message` field: + +```yaml +template.app-sync-succeeded: | + message: The app {{ .app.metadata.name }} has successfully synced! + googlechat: + text: "{{if false}}never{{end}}" + defaultTextToMessage: true +``` + +### Fallback Text + +You can provide a [fallback text](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages#Message.FIELDS.fallback_text) to be used when sending messages. It's a plain-text description of the message's cards, used when the actual cards can't be displayed—for example, mobile notifications. The fallback text can be defined as follows: + +```yaml +template.app-sync-succeeded: | + googlechat: + fallbackText: The app {{ .app.metadata.name }} has successfully synced! +``` + +If you want to include fallback text even if it is resolved to empty (or not provided), you can still default to `message` field: + +```yaml +template.app-sync-succeeded: | + message: The app {{ .app.metadata.name }} has successfully synced! + googlechat: + fallbackText: "{{if false}}never{{end}}" + defaultFallbackTextToMessage: true +``` + ## Chat Threads It is possible send both simple text and card messages in a chat thread by specifying a unique key for the thread. The thread key can be defined as follows: diff --git a/pkg/services/googlechat.go b/pkg/services/googlechat.go index cf6e4a33..cbe65624 100644 --- a/pkg/services/googlechat.go +++ b/pkg/services/googlechat.go @@ -19,18 +19,33 @@ import ( ) type GoogleChatNotification struct { - Cards string `json:"cards"` - CardsV2 string `json:"cardsV2"` - ThreadKey string `json:"threadKey,omitempty"` + Cards string `json:"cards"` + CardsV2 string `json:"cardsV2"` + ThreadKey string `json:"threadKey,omitempty"` + Text string `json:"text,omitempty"` + FallbackText string `json:"fallbackText,omitempty"` + DefaultTextToMessage bool `json:"defaultTextToMessage,omitempty"` + DefaultFallbackTextToMessage bool `json:"defaultFallbackTextToMessage,omitempty"` } type googleChatMessage struct { - Text string `json:"text"` - Cards []chat.Card `json:"cards,omitempty"` - CardsV2 []chat.CardWithId `json:"cardsV2,omitempty"` + Text string `json:"text,omitempty"` + Cards []chat.Card `json:"cards,omitempty"` + CardsV2 []chat.CardWithId `json:"cardsV2,omitempty"` + FallbackText string `json:"fallbackText,omitempty"` } func (n *GoogleChatNotification) GetTemplater(name string, f texttemplate.FuncMap) (Templater, error) { + text, err := texttemplate.New(name).Funcs(f).Parse(n.Text) + if err != nil { + return nil, fmt.Errorf("error in '%s' googlechat.text : %w", name, err) + } + + fallbackText, err := texttemplate.New(name).Funcs(f).Parse(n.FallbackText) + if err != nil { + return nil, fmt.Errorf("error in '%s' googlechat.fallbackText : %w", name, err) + } + cards, err := texttemplate.New(name).Funcs(f).Parse(n.Cards) if err != nil { return nil, fmt.Errorf("error in '%s' googlechat.cards : %w", name, err) @@ -50,6 +65,25 @@ func (n *GoogleChatNotification) GetTemplater(name string, f texttemplate.FuncMa if notification.GoogleChat == nil { notification.GoogleChat = &GoogleChatNotification{} } + notification.GoogleChat.DefaultTextToMessage = n.DefaultTextToMessage + notification.GoogleChat.DefaultFallbackTextToMessage = n.DefaultFallbackTextToMessage + + var textBuff bytes.Buffer + if err := text.Execute(&textBuff, vars); err != nil { + return err + } + if val := textBuff.String(); val != "" { + notification.GoogleChat.Text = val + } + + var fallbackTextBuff bytes.Buffer + if err := fallbackText.Execute(&fallbackTextBuff, vars); err != nil { + return err + } + if val := fallbackTextBuff.String(); val != "" { + notification.GoogleChat.FallbackText = val + } + var cardsBuff bytes.Buffer if err := cards.Execute(&cardsBuff, vars); err != nil { return err @@ -185,10 +219,12 @@ func (s googleChatService) Send(notification Notification, dest Destination) err func googleChatNotificationToMessage(n Notification) (*googleChatMessage, error) { message := &googleChatMessage{} - if n.GoogleChat != nil && (n.GoogleChat.CardsV2 != "" || n.GoogleChat.Cards != "") { + useCards := false + + if n.GoogleChat != nil { if n.GoogleChat.CardsV2 != "" { + useCards = true // Unmarshal Modern Type - var cardData []chat.GoogleAppsCardV1Card err := yaml.Unmarshal([]byte(n.GoogleChat.CardsV2), &cardData) if err != nil { @@ -206,14 +242,34 @@ func googleChatNotificationToMessage(n Notification) (*googleChatMessage, error) } if n.GoogleChat.Cards != "" { + useCards = true // Unmarshal Legacy Type err := yaml.Unmarshal([]byte(n.GoogleChat.Cards), &message.Cards) if err != nil { return nil, err } } - } else { + + // May override message text even if `defaultTextToMessage` is true, give priority to explicit `Text`. + // User may arrange for their template to render empty and fallback to `Message`. + if n.GoogleChat.Text != "" { + message.Text = n.GoogleChat.Text + } else if n.GoogleChat.DefaultTextToMessage { + message.Text = n.Message + } + + // May override message text even if `defaultFallbackTextToMessage` is true, give priority to explicit `FallbackText`. + // User may arrange for their template to render empty and fallback to `Message`. + if n.GoogleChat.FallbackText != "" { + message.FallbackText = n.GoogleChat.FallbackText + } else if n.GoogleChat.DefaultFallbackTextToMessage { + message.FallbackText = n.Message + } + } + + if message.Text == "" && !useCards { message.Text = n.Message } + return message, nil } diff --git a/pkg/services/googlechat_test.go b/pkg/services/googlechat_test.go index 777691d2..cd086365 100644 --- a/pkg/services/googlechat_test.go +++ b/pkg/services/googlechat_test.go @@ -47,6 +47,86 @@ func TestTextMessage_GoogleChat(t *testing.T) { assert.Equal(t, "message value", message.Text) } +func TestTextMessageOverride_GoogleChat(t *testing.T) { + notificationTemplate := Notification{ + Message: "message {{.value}}", + GoogleChat: &GoogleChatNotification{ + Text: "text {{.value}}", + }, + } + + templater, err := notificationTemplate.GetTemplater("test", template.FuncMap{}) + if err != nil { + t.Error(err) + return + } + + notification := Notification{} + + err = templater(¬ification, map[string]any{ + "value": "value", + }) + if err != nil { + t.Error(err) + return + } + + assert.Equal(t, + &GoogleChatNotification{ + Text: "text value", + }, + notification.GoogleChat, + ) + + message, err := googleChatNotificationToMessage(notification) + if err != nil { + t.Error(err) + return + } + + assert.NotNil(t, message) + assert.Equal(t, "text value", message.Text) +} + +func TestTextMessageOverrideEmpty_GoogleChat(t *testing.T) { + notificationTemplate := Notification{ + Message: "message {{.value}}", + GoogleChat: &GoogleChatNotification{ + Text: "{{if false}}never{{end}}", + }, + } + + templater, err := notificationTemplate.GetTemplater("test", template.FuncMap{}) + if err != nil { + t.Error(err) + return + } + + notification := Notification{} + + err = templater(¬ification, map[string]any{ + "value": "value", + }) + if err != nil { + t.Error(err) + return + } + + assert.Equal(t, + &GoogleChatNotification{}, + notification.GoogleChat, + ) + + message, err := googleChatNotificationToMessage(notification) + if err != nil { + t.Error(err) + return + } + + assert.NotNil(t, message) + assert.Equal(t, "message value", message.Text) +} + func TestTextMessageWithThreadKey_GoogleChat(t *testing.T) { notificationTemplate := Notification{ Message: "message {{.value}}", @@ -87,6 +167,7 @@ func TestTextMessageWithThreadKey_GoogleChat(t *testing.T) { func TestCardMessage_GoogleChat(t *testing.T) { notificationTemplate := Notification{ + Message: "message {{.text}}", GoogleChat: &GoogleChatNotification{ Cards: `- sections: - widgets: @@ -128,28 +209,31 @@ func TestCardMessage_GoogleChat(t *testing.T) { } assert.NotNil(t, message) - assert.Equal(t, []chat.Card{ - { - Sections: []*chat.Section{ - { - Widgets: []*chat.WidgetMarkup{ - { - TextParagraph: &chat.TextParagraph{ - Text: "text", - }, - }, { - KeyValue: &chat.KeyValue{ - TopLabel: "topLabel", - }, - }, { - Image: &chat.Image{ - ImageUrl: "imageUrl", - }, - }, { - Buttons: []*chat.Button{ - { - TextButton: &chat.TextButton{ - Text: "button", + assert.Empty(t, message.Text) + assert.Equal(t, + []chat.Card{ + { + Sections: []*chat.Section{ + { + Widgets: []*chat.WidgetMarkup{ + { + TextParagraph: &chat.TextParagraph{ + Text: "text", + }, + }, { + KeyValue: &chat.KeyValue{ + TopLabel: "topLabel", + }, + }, { + Image: &chat.Image{ + ImageUrl: "imageUrl", + }, + }, { + Buttons: []*chat.Button{ + { + TextButton: &chat.TextButton{ + Text: "button", + }, }, }, }, @@ -158,11 +242,13 @@ func TestCardMessage_GoogleChat(t *testing.T) { }, }, }, - }, message.Cards) + message.Cards, + ) } func TestCardV2Message_GoogleChat(t *testing.T) { notificationTemplate := Notification{ + Message: "message {{.text}}", GoogleChat: &GoogleChatNotification{ CardsV2: ` - header: @@ -267,6 +353,349 @@ func TestCardV2Message_GoogleChat(t *testing.T) { cmp.Diff(message.CardsV2, expected, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId"))) } +func TestFullMessage_GoogleChat(t *testing.T) { + notificationTemplate := Notification{ + Message: "Message {{ .action }}", + GoogleChat: &GoogleChatNotification{ + Text: "Text {{ .action }}", + FallbackText: "Fallback Text {{ .action }}", + CardsV2: ` +- header: + title: "Action {{ .action }} as been completed" + subtitle: Argo Notifications + imageUrl: https://argo-rollouts.readthedocs.io/en/stable/assets/logo.png + imageType: CIRCLE + imageAltText: Argo Logo + sections: + - header: Metadata + collapsible: false + uncollapsibleWidgetsCount: 1 + widgets: + - decoratedText: + startIcon: + knownIcon: BOOKMARK + text: "{{ .text }}" + - buttonList: + buttons: + - icon: + knownIcon: BOOKMARK + text: Docs + onClick: + openLink: + url: "{{ .button }}"`, + }, + } + + templater, err := notificationTemplate.GetTemplater("test", template.FuncMap{}) + if err != nil { + t.Error(err) + return + } + + notification := Notification{} + + err = templater(¬ification, map[string]any{ + "action": "test", + "text": "text", + "button": "button", + }) + if err != nil { + t.Error(err) + return + } + + message, err := googleChatNotificationToMessage(notification) + if err != nil { + t.Error(err) + return + } + + expected := &googleChatMessage{ + Text: "Text test", + FallbackText: "Fallback Text test", + CardsV2: []chat.CardWithId{ + { + Card: &chat.GoogleAppsCardV1Card{ + Header: &chat.GoogleAppsCardV1CardHeader{ + Title: "Action test as been completed", + Subtitle: "Argo Notifications", + ImageUrl: "https://argo-rollouts.readthedocs.io/en/stable/assets/logo.png", + ImageType: "CIRCLE", + ImageAltText: "Argo Logo", + }, + Sections: []*chat.GoogleAppsCardV1Section{ + { + Collapsible: false, + Header: "Metadata", + UncollapsibleWidgetsCount: 1, + Widgets: []*chat.GoogleAppsCardV1Widget{ + { + DecoratedText: &chat.GoogleAppsCardV1DecoratedText{ + StartIcon: &chat.GoogleAppsCardV1Icon{ + KnownIcon: "BOOKMARK", + }, + Text: "text", + }, + }, + { + ButtonList: &chat.GoogleAppsCardV1ButtonList{ + Buttons: []*chat.GoogleAppsCardV1Button{ + { + Icon: &chat.GoogleAppsCardV1Icon{ + KnownIcon: "BOOKMARK", + }, + Text: "Docs", + OnClick: &chat.GoogleAppsCardV1OnClick{ + OpenLink: &chat.GoogleAppsCardV1OpenLink{ + Url: "button", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + assert.NotNil(t, message) + assert.True(t, + cmp.Equal(expected, message, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId")), + cmp.Diff(expected, message, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId")), + ) +} + +func TestFullMessageDefaultText_GoogleChat(t *testing.T) { + notificationTemplate := Notification{ + Message: "Message {{ .action }}", + GoogleChat: &GoogleChatNotification{ + DefaultTextToMessage: true, + Text: "{{if false}}never{{end}}", + CardsV2: ` +- header: + title: "Action {{ .action }} as been completed" + subtitle: Argo Notifications + imageUrl: https://argo-rollouts.readthedocs.io/en/stable/assets/logo.png + imageType: CIRCLE + imageAltText: Argo Logo + sections: + - header: Metadata + collapsible: false + uncollapsibleWidgetsCount: 1 + widgets: + - decoratedText: + startIcon: + knownIcon: BOOKMARK + text: "{{ .text }}" + - buttonList: + buttons: + - icon: + knownIcon: BOOKMARK + text: Docs + onClick: + openLink: + url: "{{ .button }}"`, + }, + } + + templater, err := notificationTemplate.GetTemplater("test", template.FuncMap{}) + if err != nil { + t.Error(err) + return + } + + notification := Notification{} + + err = templater(¬ification, map[string]any{ + "action": "test", + "text": "text", + "button": "button", + }) + if err != nil { + t.Error(err) + return + } + + message, err := googleChatNotificationToMessage(notification) + if err != nil { + t.Error(err) + return + } + + expected := &googleChatMessage{ + Text: "Message test", + CardsV2: []chat.CardWithId{ + { + Card: &chat.GoogleAppsCardV1Card{ + Header: &chat.GoogleAppsCardV1CardHeader{ + Title: "Action test as been completed", + Subtitle: "Argo Notifications", + ImageUrl: "https://argo-rollouts.readthedocs.io/en/stable/assets/logo.png", + ImageType: "CIRCLE", + ImageAltText: "Argo Logo", + }, + Sections: []*chat.GoogleAppsCardV1Section{ + { + Collapsible: false, + Header: "Metadata", + UncollapsibleWidgetsCount: 1, + Widgets: []*chat.GoogleAppsCardV1Widget{ + { + DecoratedText: &chat.GoogleAppsCardV1DecoratedText{ + StartIcon: &chat.GoogleAppsCardV1Icon{ + KnownIcon: "BOOKMARK", + }, + Text: "text", + }, + }, + { + ButtonList: &chat.GoogleAppsCardV1ButtonList{ + Buttons: []*chat.GoogleAppsCardV1Button{ + { + Icon: &chat.GoogleAppsCardV1Icon{ + KnownIcon: "BOOKMARK", + }, + Text: "Docs", + OnClick: &chat.GoogleAppsCardV1OnClick{ + OpenLink: &chat.GoogleAppsCardV1OpenLink{ + Url: "button", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + assert.NotNil(t, message) + assert.True(t, + cmp.Equal(expected, message, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId")), + cmp.Diff(expected, message, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId")), + ) +} + +func TestFullMessageDefaultFallback_GoogleChat(t *testing.T) { + notificationTemplate := Notification{ + Message: "Message {{ .action }}", + GoogleChat: &GoogleChatNotification{ + DefaultFallbackTextToMessage: true, + FallbackText: "{{if false}}never{{end}}", + CardsV2: ` +- header: + title: "Action {{ .action }} as been completed" + subtitle: Argo Notifications + imageUrl: https://argo-rollouts.readthedocs.io/en/stable/assets/logo.png + imageType: CIRCLE + imageAltText: Argo Logo + sections: + - header: Metadata + collapsible: false + uncollapsibleWidgetsCount: 1 + widgets: + - decoratedText: + startIcon: + knownIcon: BOOKMARK + text: "{{ .text }}" + - buttonList: + buttons: + - icon: + knownIcon: BOOKMARK + text: Docs + onClick: + openLink: + url: "{{ .button }}"`, + }, + } + + templater, err := notificationTemplate.GetTemplater("test", template.FuncMap{}) + if err != nil { + t.Error(err) + return + } + + notification := Notification{} + + err = templater(¬ification, map[string]any{ + "action": "test", + "text": "text", + "button": "button", + }) + if err != nil { + t.Error(err) + return + } + + message, err := googleChatNotificationToMessage(notification) + if err != nil { + t.Error(err) + return + } + + expected := &googleChatMessage{ + FallbackText: "Message test", + CardsV2: []chat.CardWithId{ + { + Card: &chat.GoogleAppsCardV1Card{ + Header: &chat.GoogleAppsCardV1CardHeader{ + Title: "Action test as been completed", + Subtitle: "Argo Notifications", + ImageUrl: "https://argo-rollouts.readthedocs.io/en/stable/assets/logo.png", + ImageType: "CIRCLE", + ImageAltText: "Argo Logo", + }, + Sections: []*chat.GoogleAppsCardV1Section{ + { + Collapsible: false, + Header: "Metadata", + UncollapsibleWidgetsCount: 1, + Widgets: []*chat.GoogleAppsCardV1Widget{ + { + DecoratedText: &chat.GoogleAppsCardV1DecoratedText{ + StartIcon: &chat.GoogleAppsCardV1Icon{ + KnownIcon: "BOOKMARK", + }, + Text: "text", + }, + }, + { + ButtonList: &chat.GoogleAppsCardV1ButtonList{ + Buttons: []*chat.GoogleAppsCardV1Button{ + { + Icon: &chat.GoogleAppsCardV1Icon{ + KnownIcon: "BOOKMARK", + }, + Text: "Docs", + OnClick: &chat.GoogleAppsCardV1OnClick{ + OpenLink: &chat.GoogleAppsCardV1OpenLink{ + Url: "button", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + assert.NotNil(t, message) + assert.True(t, + cmp.Equal(expected, message, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId")), + cmp.Diff(expected, message, cmpopts.IgnoreFields(chat.CardWithId{}, "CardId")), + ) +} + func TestCreateClient_NoError(t *testing.T) { opts := GoogleChatOptions{WebhookUrls: map[string]string{"test": "testUrl"}} service := NewGoogleChatService(opts).(*googleChatService)