Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(slack): Support set username and icon from template in slack
Browse files Browse the repository at this point in the history
ayatk committed Sep 25, 2024
1 parent 261728a commit a8d054e
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
@@ -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.
49 changes: 42 additions & 7 deletions pkg/services/slack.go
Original file line number Diff line number Diff line change
@@ -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 != "" {
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"

@@ -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()
}
})
}

0 comments on commit a8d054e

Please sign in to comment.