Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(slack): Support set username and icon from template in slack #340

Merged
merged 2 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
}
})
}
Loading