From 9c622b6561311c2b15e97400ed0d25e7f69e0453 Mon Sep 17 00:00:00 2001 From: ayatk Date: Wed, 25 Sep 2024 17:31:46 +0900 Subject: [PATCH] feat(slack): Support set username and icon from template in slack Signed-off-by: ayatk --- docs/services/slack.md | 29 +++++ pkg/services/slack.go | 49 ++++++- pkg/services/slack_test.go | 256 +++++++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 7 deletions(-) diff --git a/docs/services/slack.md b/docs/services/slack.md index deb979cb..e665d7a4 100644 --- a/docs/services/slack.md +++ b/docs/services/slack.md @@ -117,6 +117,35 @@ template.app-sync-status: | }] ``` +If you want to specify an icon and username for each message, you can specify values for `username` and `icon` in the `slack` field. +For icon you can specify emoji and image URL, just like in the service definition. +If you set `username` and `icon` in template, the values set in template will be used even if values are specified in the service definition. + +```yaml +template.app-sync-status: | + message: | + Application {{.app.metadata.name}} sync is {{.app.status.sync.status}}. + Application details: {{.context.argocdUrl}}/applications/{{.app.metadata.name}}. + slack: + username: "testbot" + icon: https://example.com/image.png + attachments: | + [{ + "title": "{{.app.metadata.name}}", + "title_link": "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}", + "color": "#18be52", + "fields": [{ + "title": "Sync Status", + "value": "{{.app.status.sync.status}}", + "short": true + }, { + "title": "Repository", + "value": "{{.app.spec.source.repoURL}}", + "short": true + }] + }] +``` + The messages can be aggregated to the slack threads by grouping key which can be specified in a `groupingKey` string field under `slack` field. `groupingKey` is used across each template and works independently on each slack channel. When multiple applications will be updated at the same time or frequently, the messages in slack channel can be easily read by aggregating with git commit hash, application name, etc. diff --git a/pkg/services/slack.go b/pkg/services/slack.go index 5e9678bc..c2952477 100644 --- a/pkg/services/slack.go +++ b/pkg/services/slack.go @@ -22,6 +22,8 @@ import ( var slackState = slackutil.NewState(rate.NewLimiter(rate.Inf, 1)) type SlackNotification struct { + Username string `json:"username,omitempty"` + Icon string `json:"icon,omitempty"` Attachments string `json:"attachments,omitempty"` Blocks string `json:"blocks,omitempty"` GroupingKey string `json:"groupingKey"` @@ -30,6 +32,16 @@ type SlackNotification struct { } func (n *SlackNotification) GetTemplater(name string, f texttemplate.FuncMap) (Templater, error) { + slackUsername, err := texttemplate.New(name).Funcs(f).Parse(n.Username) + if err != nil { + return nil, err + } + + slackIcon, err := texttemplate.New(name).Funcs(f).Parse(n.Icon) + if err != nil { + return nil, err + } + slackAttachments, err := texttemplate.New(name).Funcs(f).Parse(n.Attachments) if err != nil { return nil, err @@ -47,6 +59,18 @@ func (n *SlackNotification) GetTemplater(name string, f texttemplate.FuncMap) (T if notification.Slack == nil { notification.Slack = &SlackNotification{} } + var slackUsernameData bytes.Buffer + if err := slackUsername.Execute(&slackUsernameData, vars); err != nil { + return err + } + notification.Slack.Username = slackUsernameData.String() + + var slackIconData bytes.Buffer + if err := slackIcon.Execute(&slackIconData, vars); err != nil { + return err + } + notification.Slack.Icon = slackIconData.String() + var slackAttachmentsData bytes.Buffer if err := slackAttachments.Execute(&slackAttachmentsData, vars); err != nil { return err @@ -96,18 +120,29 @@ func buildMessageOptions(notification Notification, dest Destination, opts Slack msgOptions := []slack.MsgOption{slack.MsgOptionText(notification.Message, false)} slackNotification := &SlackNotification{} - if opts.Username != "" { + if notification.Slack != nil && notification.Slack.Username != "" { + msgOptions = append(msgOptions, slack.MsgOptionUsername(notification.Slack.Username)) + } else if opts.Username != "" { msgOptions = append(msgOptions, slack.MsgOptionUsername(opts.Username)) } - if opts.Icon != "" { - if validIconEmoji.MatchString(opts.Icon) { - msgOptions = append(msgOptions, slack.MsgOptionIconEmoji(opts.Icon)) - } else if isValidIconURL(opts.Icon) { - msgOptions = append(msgOptions, slack.MsgOptionIconURL(opts.Icon)) + + if opts.Icon != "" || (notification.Slack != nil && notification.Slack.Icon != "") { + var icon string + if notification.Slack != nil && notification.Slack.Icon != "" { + icon = notification.Slack.Icon } else { - log.Warnf("Icon reference '%v' is not a valid emoji or url", opts.Icon) + icon = opts.Icon + } + + if validIconEmoji.MatchString(icon) { + msgOptions = append(msgOptions, slack.MsgOptionIconEmoji(icon)) + } else if isValidIconURL(icon) { + msgOptions = append(msgOptions, slack.MsgOptionIconURL(icon)) + } else { + log.Warnf("Icon reference '%v' is not a valid emoji or url", icon) } } + if notification.Slack != nil { attachments := make([]slack.Attachment, 0) if notification.Slack.Attachments != "" { diff --git a/pkg/services/slack_test.go b/pkg/services/slack_test.go index 0ef12a41..95fe135e 100644 --- a/pkg/services/slack_test.go +++ b/pkg/services/slack_test.go @@ -1,6 +1,11 @@ package services import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" "testing" "text/template" @@ -26,6 +31,8 @@ func TestValidIconURL(t *testing.T) { func TestGetTemplater_Slack(t *testing.T) { n := Notification{ Slack: &SlackNotification{ + Username: "{{.bar}}-{{.foo}}", + Icon: ":{{.foo}}:", Attachments: "{{.foo}}", Blocks: "{{.bar}}", GroupingKey: "{{.foo}}-{{.bar}}", @@ -48,6 +55,8 @@ func TestGetTemplater_Slack(t *testing.T) { return } + assert.Equal(t, "world-hello", notification.Slack.Username) + assert.Equal(t, ":hello:", notification.Slack.Icon) assert.Equal(t, "hello", notification.Slack.Attachments) assert.Equal(t, "world", notification.Slack.Blocks) assert.Equal(t, "hello-world", notification.Slack.GroupingKey) @@ -63,3 +72,250 @@ func TestBuildMessageOptionsWithNonExistTemplate(t *testing.T) { assert.Empty(t, sn.GroupingKey) assert.Equal(t, slackutil.Post, sn.DeliveryPolicy) } + +type chatResponseFull struct { + Channel string `json:"channel"` + Timestamp string `json:"ts"` // Regular message timestamp + MessageTimeStamp string `json:"message_ts"` // Ephemeral message timestamp + Text string `json:"text"` +} + +func TestSlack_SendNotification(t *testing.T) { + dummyResponse, err := json.Marshal(chatResponseFull{ + Channel: "test", + Timestamp: "1503435956.000247", + MessageTimeStamp: "1503435956.000247", + Text: "text", + }) + assert.NoError(t, err) + + t.Run("only message", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + assert.NoError(t, err) + v := url.Values{} + v.Add("channel", "test-channel") + v.Add("text", "Annotation description") + v.Add("token", "something-token") + assert.Equal(t, string(data), v.Encode()) + + writer.WriteHeader(http.StatusOK) + _, err = writer.Write(dummyResponse) + assert.NoError(t, err) + })) + defer server.Close() + + service := NewSlackService(SlackOptions{ + ApiURL: server.URL + "/", + Token: "something-token", + InsecureSkipVerify: true, + }) + + err := service.Send( + Notification{Message: "Annotation description"}, + Destination{Recipient: "test-channel", Service: "slack"}, + ) + if !assert.NoError(t, err) { + t.FailNow() + } + }) + + t.Run("attachments", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + assert.NoError(t, err) + v := url.Values{} + v.Add("attachments", `[{"pretext":"pre-hello","text":"text-world","blocks":null}]`) + v.Add("channel", "test") + v.Add("text", "Attachments description") + v.Add("token", "something-token") + assert.Equal(t, string(data), v.Encode()) + + writer.WriteHeader(http.StatusOK) + _, err = writer.Write(dummyResponse) + assert.NoError(t, err) + })) + defer server.Close() + + service := NewSlackService(SlackOptions{ + ApiURL: server.URL + "/", + Token: "something-token", + InsecureSkipVerify: true, + }) + + err := service.Send( + Notification{ + Message: "Attachments description", + Slack: &SlackNotification{ + Attachments: `[{"pretext": "pre-hello", "text": "text-world"}]`, + }, + }, + Destination{Recipient: "test", Service: "slack"}, + ) + if !assert.NoError(t, err) { + t.FailNow() + } + }) + + t.Run("blocks", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + assert.NoError(t, err) + v := url.Values{} + v.Add("attachments", "[]") + v.Add("blocks", `[{"type":"section","text":{"type":"plain_text","text":"Hello world"}}]`) + v.Add("channel", "test") + v.Add("text", "Attachments description") + v.Add("token", "something-token") + assert.Equal(t, string(data), v.Encode()) + + writer.WriteHeader(http.StatusOK) + _, err = writer.Write(dummyResponse) + assert.NoError(t, err) + })) + defer server.Close() + + service := NewSlackService(SlackOptions{ + ApiURL: server.URL + "/", + Token: "something-token", + InsecureSkipVerify: true, + }) + + err := service.Send( + Notification{ + Message: "Attachments description", + Slack: &SlackNotification{ + Blocks: `[{"type": "section", "text": {"type": "plain_text", "text": "Hello world"}}]`, + }, + }, + Destination{Recipient: "test", Service: "slack"}, + ) + if !assert.NoError(t, err) { + t.FailNow() + } + }) +} + +func TestSlack_SetUsernameAndIcon(t *testing.T) { + dummyResponse, err := json.Marshal(chatResponseFull{ + Channel: "test", + Timestamp: "1503435956.000247", + MessageTimeStamp: "1503435956.000247", + Text: "text", + }) + assert.NoError(t, err) + + t.Run("no set", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + assert.NoError(t, err) + v := url.Values{} + v.Add("channel", "test") + v.Add("text", "test") + v.Add("token", "something-token") + assert.Equal(t, string(data), v.Encode()) + + writer.WriteHeader(http.StatusOK) + _, err = writer.Write(dummyResponse) + assert.NoError(t, err) + })) + defer server.Close() + + service := NewSlackService(SlackOptions{ + ApiURL: server.URL + "/", + Token: "something-token", + InsecureSkipVerify: true, + }) + + err := service.Send( + Notification{ + Message: "test", + }, + Destination{Recipient: "test", Service: "slack"}, + ) + if !assert.NoError(t, err) { + t.FailNow() + } + }) + + t.Run("set service config", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + assert.NoError(t, err) + v := url.Values{} + v.Add("channel", "test") + v.Add("icon_emoji", ":smile:") + v.Add("text", "test") + v.Add("token", "something-token") + v.Add("username", "foo") + + assert.Equal(t, string(data), v.Encode()) + + writer.WriteHeader(http.StatusOK) + _, err = writer.Write(dummyResponse) + assert.NoError(t, err) + })) + defer server.Close() + + service := NewSlackService(SlackOptions{ + ApiURL: server.URL + "/", + Token: "something-token", + Username: "foo", + Icon: ":smile:", + InsecureSkipVerify: true, + }) + + err := service.Send( + Notification{ + Message: "test", + }, + Destination{Recipient: "test", Service: "slack"}, + ) + if !assert.NoError(t, err) { + t.FailNow() + } + }) + + t.Run("set service config and template", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + data, err := io.ReadAll(request.Body) + assert.NoError(t, err) + v := url.Values{} + v.Add("attachments", "[]") + v.Add("channel", "test") + v.Add("icon_emoji", ":wink:") + v.Add("text", "test") + v.Add("token", "something-token") + v.Add("username", "template set") + + assert.Equal(t, string(data), v.Encode()) + + writer.WriteHeader(http.StatusOK) + _, err = writer.Write(dummyResponse) + assert.NoError(t, err) + })) + defer server.Close() + + service := NewSlackService(SlackOptions{ + ApiURL: server.URL + "/", + Token: "something-token", + Username: "foo", + Icon: ":smile:", + InsecureSkipVerify: true, + }) + + err := service.Send( + Notification{ + Message: "test", + Slack: &SlackNotification{ + Username: "template set", + Icon: ":wink:", + }, + }, + Destination{Recipient: "test", Service: "slack"}, + ) + if !assert.NoError(t, err) { + t.FailNow() + } + }) +}