Skip to content

Commit d3b6c8e

Browse files
feat(notify): branded Slack Block Kit alerts with CloudDrove footer (#31)
* feat(notify): branded Slack Block Kit alerts with CloudDrove footer Slack notifications were plain text and looked unpolished. Render them as Block Kit attachments instead: a colored bar (green for new syncs, red for failures), an emoji header with the count, code-formatted image refs grouped per destination in detailed mode, and a constant CloudDrove-branded context footer (logo + clouddrove.com link). - internal/notify/slack.go: add Message/Section types and a Block Kit renderer (header, auto-split section blocks under Slack's char limit, divider, branded footer). Keep SendText for plain callers. - internal/sync/sync.go: build notify.Message values instead of plain strings; preserve compact/detailed formats and truncation caps. - tests: rewrite the sync formatter tests for the new builders and add notify tests for block structure, footer branding, payload POST, and long-content section splitting. * feat(notify): use official CloudDrove favicon for Slack footer logo Switch the footer logo from the GitHub org avatar to the official clouddrove.com favicon (apple-touch-icon.png — PNG, since Slack image blocks do not render .ico). * feat(notify): add SyncerD logo thumbnail to Slack alerts Show the SyncerD product logo (assets/syncerd-logo.png via raw GitHub URL) as an image accessory on the first section block, alongside the CloudDrove favicon in the footer. * feat(notify): use SyncerD logo in footer; drop CloudDrove favicon Footer now reads [SyncerD logo] SyncerD — powered by CloudDrove. Removes the CloudDrove favicon image (text link to clouddrove.com kept) and the trailing emoji. * feat(notify): drop large section thumbnail; keep small footer logo only The section accessory rendered the SyncerD logo as a large thumbnail. Remove it so the only image is the small (~16px) footer context icon. --------- Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
1 parent ea8b0b6 commit d3b6c8e

4 files changed

Lines changed: 395 additions & 91 deletions

File tree

internal/notify/slack.go

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ import (
99
"time"
1010
)
1111

12+
// CloudDrove branding used in every notification footer.
13+
const (
14+
brandName = "CloudDrove"
15+
brandURL = "https://clouddrove.com"
16+
// SyncerD product logo, used as the alert thumbnail and footer icon.
17+
syncerdLogoURL = "https://raw.githubusercontent.com/clouddrove/SyncerD/master/assets/syncerd-logo.png"
18+
19+
// Attachment color bars.
20+
ColorSuccess = "#2EB67D" // green
21+
ColorFailure = "#E01E5A" // red
22+
23+
// Slack limits we stay under.
24+
maxHeaderChars = 150
25+
maxSectionChars = 2900
26+
)
27+
1228
type SlackClient struct {
1329
WebhookURL string
1430
Channel string
@@ -17,14 +33,145 @@ type SlackClient struct {
1733
HTTP *http.Client
1834
}
1935

36+
// Section is one logical group in a notification (e.g. a destination and its
37+
// affected image refs). Heading is optional.
38+
type Section struct {
39+
Heading string
40+
Lines []string
41+
}
42+
43+
// Message is a provider-agnostic notification that Send renders into Slack
44+
// Block Kit with consistent CloudDrove branding.
45+
type Message struct {
46+
Emoji string // e.g. ":white_check_mark:"
47+
Title string // e.g. "3 image(s)/tag(s) synced"
48+
Color string // ColorSuccess / ColorFailure
49+
Fallback string // plain-text summary for notifications/screen readers
50+
Sections []Section
51+
}
52+
53+
// --- Block Kit payload types ---
54+
2055
type slackPayload struct {
21-
Text string `json:"text"`
22-
Channel string `json:"channel,omitempty"`
23-
Username string `json:"username,omitempty"`
24-
IconEmoji string `json:"icon_emoji,omitempty"`
56+
Channel string `json:"channel,omitempty"`
57+
Username string `json:"username,omitempty"`
58+
IconEmoji string `json:"icon_emoji,omitempty"`
59+
Text string `json:"text,omitempty"` // fallback
60+
Attachments []attachment `json:"attachments,omitempty"`
61+
}
62+
63+
type attachment struct {
64+
Color string `json:"color,omitempty"`
65+
Blocks []block `json:"blocks,omitempty"`
66+
}
67+
68+
// block is a generic Block Kit block. Fields are pointers/omitempty so a single
69+
// type can represent header, section, divider, and context blocks.
70+
type block struct {
71+
Type string `json:"type"`
72+
Text *textObject `json:"text,omitempty"`
73+
Elements []any `json:"elements,omitempty"`
2574
}
2675

76+
type textObject struct {
77+
Type string `json:"type"` // "plain_text" | "mrkdwn"
78+
Text string `json:"text"`
79+
Emoji bool `json:"emoji,omitempty"`
80+
}
81+
82+
type imageElement struct {
83+
Type string `json:"type"` // "image"
84+
ImageURL string `json:"image_url"`
85+
AltText string `json:"alt_text"`
86+
}
87+
88+
// SendText sends a plain-text message (kept for simple callers/tests).
2789
func (c *SlackClient) SendText(ctx context.Context, text string) error {
90+
return c.send(ctx, slackPayload{
91+
Channel: c.Channel,
92+
Username: c.Username,
93+
IconEmoji: c.IconEmoji,
94+
Text: text,
95+
})
96+
}
97+
98+
// Send renders a Message as a branded Block Kit notification.
99+
func (c *SlackClient) Send(ctx context.Context, m Message) error {
100+
if c == nil || c.WebhookURL == "" {
101+
return nil
102+
}
103+
return c.send(ctx, slackPayload{
104+
Channel: c.Channel,
105+
Username: c.Username,
106+
IconEmoji: c.IconEmoji,
107+
Text: m.Fallback,
108+
Attachments: []attachment{{Color: m.Color, Blocks: c.renderBlocks(m)}},
109+
})
110+
}
111+
112+
func (c *SlackClient) renderBlocks(m Message) []block {
113+
blocks := make([]block, 0, len(m.Sections)+3)
114+
115+
header := m.Title
116+
if m.Emoji != "" {
117+
header = m.Emoji + " " + m.Title
118+
}
119+
blocks = append(blocks, block{
120+
Type: "header",
121+
Text: &textObject{Type: "plain_text", Text: truncate(header, maxHeaderChars), Emoji: true},
122+
})
123+
124+
for _, sec := range m.Sections {
125+
var buf bytes.Buffer
126+
if sec.Heading != "" {
127+
buf.WriteString("*" + sec.Heading + "*\n")
128+
}
129+
for _, line := range sec.Lines {
130+
// Flush into a new section block before exceeding Slack's limit.
131+
if buf.Len()+len(line)+1 > maxSectionChars {
132+
blocks = append(blocks, sectionBlock(buf.String()))
133+
buf.Reset()
134+
}
135+
buf.WriteString(line)
136+
buf.WriteByte('\n')
137+
}
138+
if buf.Len() > 0 {
139+
blocks = append(blocks, sectionBlock(buf.String()))
140+
}
141+
}
142+
143+
blocks = append(blocks, block{Type: "divider"})
144+
blocks = append(blocks, footerBlock())
145+
return blocks
146+
}
147+
148+
func sectionBlock(text string) block {
149+
return block{Type: "section", Text: &textObject{Type: "mrkdwn", Text: text}}
150+
}
151+
152+
// footerBlock is the constant CloudDrove-branded context footer.
153+
func footerBlock() block {
154+
return block{
155+
Type: "context",
156+
Elements: []any{
157+
imageElement{Type: "image", ImageURL: syncerdLogoURL, AltText: "SyncerD"},
158+
&textObject{Type: "mrkdwn", Text: fmt.Sprintf(
159+
"*SyncerD* — powered by <%s|%s>", brandURL, brandName)},
160+
},
161+
}
162+
}
163+
164+
func truncate(s string, max int) string {
165+
if len(s) <= max {
166+
return s
167+
}
168+
if max <= 1 {
169+
return s[:max]
170+
}
171+
return s[:max-1] + "…"
172+
}
173+
174+
func (c *SlackClient) send(ctx context.Context, payload slackPayload) error {
28175
if c == nil || c.WebhookURL == "" {
29176
return nil
30177
}
@@ -33,12 +180,6 @@ func (c *SlackClient) SendText(ctx context.Context, text string) error {
33180
httpClient = &http.Client{Timeout: 10 * time.Second}
34181
}
35182

36-
payload := slackPayload{
37-
Text: text,
38-
Channel: c.Channel,
39-
Username: c.Username,
40-
IconEmoji: c.IconEmoji,
41-
}
42183
b, err := json.Marshal(payload)
43184
if err != nil {
44185
return fmt.Errorf("marshal slack payload: %w", err)

internal/notify/slack_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package notify
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func sampleMessage() Message {
13+
return Message{
14+
Emoji: ":white_check_mark:",
15+
Title: "2 image(s)/tag(s) synced",
16+
Color: ColorSuccess,
17+
Fallback: "SyncerD: 2 synced",
18+
Sections: []Section{{Lines: []string{"• `a`", "• `b`"}}},
19+
}
20+
}
21+
22+
func TestRenderBlocksStructure(t *testing.T) {
23+
c := &SlackClient{}
24+
blocks := c.renderBlocks(sampleMessage())
25+
26+
if len(blocks) < 3 {
27+
t.Fatalf("expected header + section + footer, got %d blocks", len(blocks))
28+
}
29+
if blocks[0].Type != "header" {
30+
t.Errorf("first block should be header, got %q", blocks[0].Type)
31+
}
32+
if blocks[0].Text == nil || !strings.Contains(blocks[0].Text.Text, "synced") {
33+
t.Errorf("header text missing title: %+v", blocks[0].Text)
34+
}
35+
36+
footer := blocks[len(blocks)-1]
37+
if footer.Type != "context" {
38+
t.Fatalf("last block should be context footer, got %q", footer.Type)
39+
}
40+
var hasBrand, hasLogo bool
41+
for _, el := range footer.Elements {
42+
switch v := el.(type) {
43+
case *textObject:
44+
if strings.Contains(v.Text, brandName) && strings.Contains(v.Text, brandURL) {
45+
hasBrand = true
46+
}
47+
case imageElement:
48+
if v.ImageURL == syncerdLogoURL {
49+
hasLogo = true
50+
}
51+
}
52+
}
53+
if !hasBrand {
54+
t.Errorf("footer missing CloudDrove branding text")
55+
}
56+
if !hasLogo {
57+
t.Errorf("footer missing CloudDrove logo image")
58+
}
59+
}
60+
61+
func TestSendPostsBlockKitPayload(t *testing.T) {
62+
var got map[string]any
63+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+
_ = json.NewDecoder(r.Body).Decode(&got)
65+
w.WriteHeader(http.StatusOK)
66+
}))
67+
defer srv.Close()
68+
69+
c := &SlackClient{WebhookURL: srv.URL}
70+
if err := c.Send(context.Background(), sampleMessage()); err != nil {
71+
t.Fatalf("Send returned error: %v", err)
72+
}
73+
74+
atts, ok := got["attachments"].([]any)
75+
if !ok || len(atts) != 1 {
76+
t.Fatalf("expected one attachment, got %v", got["attachments"])
77+
}
78+
att := atts[0].(map[string]any)
79+
if att["color"] != ColorSuccess {
80+
t.Errorf("expected success color, got %v", att["color"])
81+
}
82+
if got["text"] != "SyncerD: 2 synced" {
83+
t.Errorf("expected fallback text, got %v", got["text"])
84+
}
85+
}
86+
87+
func TestSendNoopWithoutWebhook(t *testing.T) {
88+
c := &SlackClient{}
89+
if err := c.Send(context.Background(), sampleMessage()); err != nil {
90+
t.Errorf("expected nil error with empty webhook, got %v", err)
91+
}
92+
}
93+
94+
func TestLongSectionSplitsIntoMultipleBlocks(t *testing.T) {
95+
var lines []string
96+
for i := 0; i < 400; i++ {
97+
lines = append(lines, "• `registry.example.com/library/some-image:tag-1234567890`")
98+
}
99+
c := &SlackClient{}
100+
blocks := c.renderBlocks(Message{Title: "t", Sections: []Section{{Lines: lines}}})
101+
102+
sectionCount := 0
103+
for _, b := range blocks {
104+
if b.Type == "section" {
105+
sectionCount++
106+
if b.Text != nil && len(b.Text.Text) > maxSectionChars {
107+
t.Errorf("section exceeds Slack limit: %d chars", len(b.Text.Text))
108+
}
109+
}
110+
}
111+
if sectionCount < 2 {
112+
t.Errorf("expected long content to split into multiple section blocks, got %d", sectionCount)
113+
}
114+
}

0 commit comments

Comments
 (0)