Skip to content

Commit

Permalink
feat(slack): Support set username and icon from template in slack (#340)
Browse files Browse the repository at this point in the history
Signed-off-by: ayatk <[email protected]>
Co-authored-by: ayatk <[email protected]>
  • Loading branch information
ayatk and ayatk authored Oct 7, 2024
1 parent 22ccfe0 commit 2fef5c9
Show file tree
Hide file tree
Showing 3 changed files with 327 additions and 7 deletions.
29 changes: 29 additions & 0 deletions docs/services/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 42 additions & 7 deletions pkg/services/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
256 changes: 256 additions & 0 deletions pkg/services/slack_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package services

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"text/template"

Expand All @@ -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}}",
Expand All @@ -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)
Expand All @@ -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()
}
})
}

0 comments on commit 2fef5c9

Please sign in to comment.