diff --git a/config/config.go b/config/config.go index a66ea7ec17..bbea05c88b 100644 --- a/config/config.go +++ b/config/config.go @@ -464,6 +464,17 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { } sc.APIURL = (*amcommoncfg.SecretURL)(sc.AppURL) } + // update_message and post_updates_to_thread require the bot-token API. + // The endpoint can only be verified for URLs known at load time; + // api_url_file is read at notification time and is accepted as-is. + if len(sc.APIURLFile) == 0 && (sc.APIURL == nil || sc.APIURL.String() != "https://slack.com/api/chat.postMessage") { + if sc.UpdateMessage { + return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage") + } + if sc.PostUpdatesToThread { + return errors.New("post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage") + } + } } for _, poc := range rcv.PushoverConfigs { if poc == nil { diff --git a/config/config_test.go b/config/config_test.go index 76264a1778..628335f269 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1322,6 +1322,41 @@ func TestSlackUpdateMessageWebhookURL(t *testing.T) { } } +func TestSlackPostUpdatesToThreadWebhookURL(t *testing.T) { + _, err := LoadFile("testdata/conf.slack-post-updates-to-thread-and-webhook.yml") + if err == nil { + t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-webhook", err) + } + if err.Error() != "post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage" { + t.Errorf("Expected: %s\nGot: %s", "post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error()) + } +} + +func TestSlackUpdateMessageWithAppToken(t *testing.T) { + // The app token flow resolves api_url to the Slack bot API during global + // config resolution, so update_message must be accepted with it. + _, err := LoadFile("testdata/conf.slack-update-message-and-app-token.yml") + if err != nil { + t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-update-message-and-app-token.yml", err) + } +} + +func TestSlackPostUpdatesToThreadWithAppToken(t *testing.T) { + _, err := LoadFile("testdata/conf.slack-post-updates-to-thread-and-app-token.yml") + if err != nil { + t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-post-updates-to-thread-and-app-token.yml", err) + } +} + +func TestSlackUpdateMessageWithAPIURLFile(t *testing.T) { + // api_url_file is read at notification time, so its content cannot be + // verified at load time and the configuration must be accepted. + _, err := LoadFile("testdata/conf.slack-update-message-and-api-url-file.yml") + if err != nil { + t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-update-message-and-api-url-file.yml", err) + } +} + func TestSlackGlobalAppToken(t *testing.T) { conf, err := LoadFile("testdata/conf.slack-default-app-token.yml") if err != nil { diff --git a/config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml b/config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml new file mode 100644 index 0000000000..236551c77b --- /dev/null +++ b/config/testdata/conf.slack-post-updates-to-thread-and-app-token.yml @@ -0,0 +1,12 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + # bot token flow without explicit api_url + - channel: '#alerts1' + text: 'test' + send_resolved: true + app_token: 'xoxb-some-token' + post_updates_to_thread: true diff --git a/config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml b/config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml new file mode 100644 index 0000000000..b39e538ed9 --- /dev/null +++ b/config/testdata/conf.slack-post-updates-to-thread-and-webhook.yml @@ -0,0 +1,13 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + # use global + - channel: '#alerts1' + text: 'test' + send_resolved: true + # trying to use webhook urls with post_updates_to_thread + api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' + post_updates_to_thread: true diff --git a/config/testdata/conf.slack-update-message-and-api-url-file.yml b/config/testdata/conf.slack-update-message-and-api-url-file.yml new file mode 100644 index 0000000000..293317514f --- /dev/null +++ b/config/testdata/conf.slack-update-message-and-api-url-file.yml @@ -0,0 +1,13 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + # api_url_file is read at notification time; accepted at load time + - channel: '#alerts1' + text: 'test' + send_resolved: true + api_url_file: '/etc/slack/api_url' + update_message: true + post_updates_to_thread: true diff --git a/config/testdata/conf.slack-update-message-and-app-token.yml b/config/testdata/conf.slack-update-message-and-app-token.yml new file mode 100644 index 0000000000..d8d69c2fa6 --- /dev/null +++ b/config/testdata/conf.slack-update-message-and-app-token.yml @@ -0,0 +1,12 @@ +route: + receiver: 'slack-notifications' + group_by: [alertname] +receivers: + - name: 'slack-notifications' + slack_configs: + # bot token flow without explicit api_url + - channel: '#alerts1' + text: 'test' + send_resolved: true + app_token: 'xoxb-some-token' + update_message: true diff --git a/docs/configuration.md b/docs/configuration.md index 86b137a7e2..9e8b15a923 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1761,6 +1761,16 @@ fields: # Enables updating existing Slack messages instead of creating new ones on alert state change. # Webhook URLs do not support updates. [ update_message: | default = false ] + +# Posts subsequent notifications for an alert group as replies in the thread of the +# initial message instead of new channel messages. When combined with update_message, +# the initial message is updated in place and a reply is also posted to its thread, +# except for notifications triggered only by repeat_interval: the updated message +# already carries the current state, so it is not copied into the thread again. +# Requires a Slack app with a bot token (chat:write scope) and api_url set to +# https://slack.com/api/chat.postMessage. Incoming webhooks cannot be used, they do +# not return the message identifiers a thread needs. +[ post_updates_to_thread: | default = false ] ``` #### `` (Slack) diff --git a/notify/slack/config.go b/notify/slack/config.go index c2e67af53e..d0d47fde0e 100644 --- a/notify/slack/config.go +++ b/notify/slack/config.go @@ -176,6 +176,13 @@ type SlackConfig struct { // Requires bot token with chat:write scope. Webhook URLs do not support updates. UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"` + + // PostUpdatesToThread enables posting subsequent notifications for an alert group + // as replies in the thread of the initial message. When combined with UpdateMessage, + // the initial message is updated in place and a reply is also posted to its thread. + // Requires bot token with chat:write scope. Webhook URLs do not support threads. + + PostUpdatesToThread bool `yaml:"post_updates_to_thread" json:"post_updates_to_thread,omitempty"` // Timeout is the maximum time allowed to invoke the slack. Setting this to 0 // does not impose a timeout. Timeout time.Duration `yaml:"timeout" json:"timeout"` @@ -191,6 +198,10 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error { return c.Validate() } +// Validate checks that the Slack configuration endpoints and credentials are +// mutually consistent. The endpoint requirements of update_message and +// post_updates_to_thread are checked during global config resolution, once +// api_url has been resolved from the global section or an app token. func (c *SlackConfig) Validate() error { if c.APIURL != nil && len(c.APIURLFile) > 0 { return errors.New("at most one of api_url & api_url_file must be configured") @@ -202,9 +213,5 @@ func (c *SlackConfig) Validate() error { return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured") } - if c.UpdateMessage && c.APIURL.String() != "https://slack.com/api/chat.postMessage" { - return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage") - } - return nil } diff --git a/notify/slack/slack.go b/notify/slack/slack.go index 2a3b86f8d9..6151690daf 100644 --- a/notify/slack/slack.go +++ b/notify/slack/slack.go @@ -36,6 +36,11 @@ import ( // https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters. const maxTitleLenRunes = 1024 +// chatPostMessageURL is the only api_url that supports message updates and +// threads, both of which need the message identifiers returned by the bot-token +// Web API. Incoming webhooks return no identifiers. +const chatPostMessageURL = "https://slack.com/api/chat.postMessage" + // New returns a new Slack notification handler. func New(c *SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "slack", httpOpts...) @@ -153,6 +158,10 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify u = strings.TrimSpace(string(content)) } + if err := requireBotAPIURL(n.conf, u); err != nil { + return notify.Unrecoverable(err, notify.DefaultReason) + } + if n.conf.Timeout > 0 { postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured slack timeout reached (%s)", n.conf.Timeout)) defer cancel() @@ -169,27 +178,92 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) notify.Notify Attachments: []attachment{*att}, } - // If a notification for this alert group has already been sent and `update_message` config is set - // edit API endpoint and payload to update notification instead of sending a new one. + // If a notification for this alert group has already been sent, `update_message` + // edits the initial message instead of sending a new one and `post_updates_to_thread` + // posts the notification as a reply in the initial message's thread. var store *nflog.Store + var threadTs, channelId string - if n.conf.UpdateMessage { + if n.conf.UpdateMessage || n.conf.PostUpdatesToThread { var ok bool store, ok = notify.NflogStore(ctx) if !ok { - logger.Warn("cannot create NflogStore, updatable messages will be disabled.") + logger.Warn("cannot create NflogStore, updatable and threaded messages will be disabled.") } else { - threadTs, _ := store.GetStr("threadTs") - channelId, _ := store.GetStr("channelId") - logger.Debug("attempt recovering threadTs and channelId to update an existing message", "threadTs", threadTs, "channelId", channelId) - if threadTs != "" && channelId != "" { - u = "https://slack.com/api/chat.update" - req.Timestamp = threadTs - req.Channel = channelId - logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId) - } + threadTs, _ = store.GetStr("threadTs") + channelId, _ = store.GetStr("channelId") + logger.Debug("attempt recovering threadTs and channelId of the initial message", "threadTs", threadTs, "channelId", channelId) + } + } + + postURL := u + initialMessageSent := threadTs != "" && channelId != "" + if initialMessageSent { + switch { + case n.conf.UpdateMessage: + u = "https://slack.com/api/chat.update" + req.Timestamp = threadTs + req.Channel = channelId + logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId) + case n.conf.PostUpdatesToThread: + req.ThreadTimestamp = threadTs + req.Channel = channelId + logger.Debug("posting to thread of previously sent message", "threadTs", threadTs, "channelId", channelId) } } + + // The thread reply must not overwrite the initial message's timestamp in the + // nflog store, so no store is passed when the request targets a thread. + responseStore := store + if initialMessageSent { + responseStore = nil + } + if verdict := n.postRequest(ctx, u, req, responseStore); verdict.Err() != nil { + return verdict + } + + // When update_message and post_updates_to_thread are combined, the initial + // message was just updated in place; additionally post a reply to its thread, + // unless nothing changed in the alert group: a notification triggered only by + // repeat_interval would add a copy of the message that was just updated. + if initialMessageSent && n.conf.UpdateMessage && n.conf.PostUpdatesToThread && !repeatIntervalOnly(ctx) { + threadReq := *req + threadReq.Timestamp = "" + threadReq.ThreadTimestamp = threadTs + logger.Debug("posting update to thread of previously sent message", "threadTs", threadTs, "channelId", channelId) + return n.postRequest(ctx, postURL, &threadReq, nil) + } + + return notify.Success() +} + +// requireBotAPIURL rejects a resolved api_url that cannot support message +// updates or threads. Config loading already performs this check for api_url +// and app_token; api_url_file can only be checked here, because its content is +// read at notification time. +func requireBotAPIURL(conf *SlackConfig, u string) error { + if !conf.UpdateMessage && !conf.PostUpdatesToThread { + return nil + } + if u == chatPostMessageURL { + return nil + } + if conf.UpdateMessage { + return fmt.Errorf("update_message can only be used with bot tokens. api_url must be set to %s", chatPostMessageURL) + } + return fmt.Errorf("post_updates_to_thread can only be used with bot tokens. api_url must be set to %s", chatPostMessageURL) +} + +// repeatIntervalOnly reports whether the notification was triggered solely by +// repeat_interval elapsing, meaning the state of the alert group is unchanged. +func repeatIntervalOnly(ctx context.Context) bool { + reason, ok := notify.NotificationReason(ctx) + return ok && reason == notify.ReasonRepeatIntervalElapsed +} + +// postRequest encodes and sends a single request to the Slack API, classifies +// errors as retriable or not, and hands the response to slackResponseHandler. +func (n *Notifier) postRequest(ctx context.Context, u string, req *request, store *nflog.Store) notify.NotifyVerdict { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(req); err != nil { return notify.Unrecoverable(err, notify.DefaultReason) diff --git a/notify/slack/slack_test.go b/notify/slack/slack_test.go index 1ffee08c09..7305fbc8a8 100644 --- a/notify/slack/slack_test.go +++ b/notify/slack/slack_test.go @@ -33,6 +33,7 @@ import ( amcommoncfg "github.com/prometheus/alertmanager/config/common" + "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/template" @@ -396,3 +397,218 @@ func TestNotifier_Notify_RetryAfterDelay(t *testing.T) { require.Error(t, verdict.Err()) require.Equal(t, 1*time.Second, verdict.Delay()) } + +func TestSlackPostUpdatesToThread(t *testing.T) { + type capturedRequest struct { + url string + body map[string]any + } + + newTestNotifier := func(t *testing.T, conf *SlackConfig, captured *[]capturedRequest, respTs string) *Notifier { + t.Helper() + u, _ := url.Parse("https://slack.com/api/chat.postMessage") + conf.APIURL = &amcommoncfg.SecretURL{URL: u} + conf.Channel = "#test-channel" + conf.HTTPConfig = &commoncfg.HTTPClientConfig{} + + tmpl, err := template.FromGlobs([]string{}) + require.NoError(t, err) + tmpl.ExternalURL = u + + notifier, err := New(conf, tmpl, slog.New(slog.DiscardHandler)) + require.NoError(t, err) + + notifier.postJSONFunc = func(ctx context.Context, client *http.Client, reqURL string, body io.Reader) (*http.Response, error) { + var decoded map[string]any + require.NoError(t, json.NewDecoder(body).Decode(&decoded)) + *captured = append(*captured, capturedRequest{url: reqURL, body: decoded}) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"ok": true, "channel": "C123", "ts": "` + respTs + `"}`)), + } + return resp, nil + } + return notifier + } + + newCtx := func(store *nflog.Store) context.Context { + ctx := notify.WithGroupKey(context.Background(), "test-group-key") + return notify.WithNflogStore(ctx, store) + } + + t.Run("first notification posts to channel and stores thread ts", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "111.222") + store := nflog.NewStore(nil) + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[0].url) + require.NotContains(t, captured[0].body, "ts") + require.NotContains(t, captured[0].body, "thread_ts") + + threadTs, _ := store.GetStr("threadTs") + channelId, _ := store.GetStr("channelId") + require.Equal(t, "111.222", threadTs) + require.Equal(t, "C123", channelId) + }) + + t.Run("subsequent notification updates message and posts thread reply", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 2) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[0].body["ts"]) + require.Equal(t, "C123", captured[0].body["channel"]) + require.NotContains(t, captured[0].body, "thread_ts") + + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[1].url) + require.Equal(t, "111.222", captured[1].body["thread_ts"]) + require.Equal(t, "C123", captured[1].body["channel"]) + require.NotContains(t, captured[1].body, "ts") + + // The stored root message ts must not be overwritten by the responses. + threadTs, _ := store.GetStr("threadTs") + require.Equal(t, "111.222", threadTs) + }) + + t.Run("repeat_interval notification only updates the message", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + ctx := notify.WithNotificationReason(newCtx(store), notify.ReasonRepeatIntervalElapsed) + require.NoError(t, notifier.Notify(ctx).Err()) + + // The updated channel message already carries the current state, so no + // copy of it is added to the thread. + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[0].body["ts"]) + }) + + t.Run("repeat_interval notification still posts to thread without update_message", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + ctx := notify.WithNotificationReason(newCtx(store), notify.ReasonRepeatIntervalElapsed) + require.NoError(t, notifier.Notify(ctx).Err()) + + // Without update_message nothing else carries the notification. + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[0].url) + require.Equal(t, "111.222", captured[0].body["thread_ts"]) + }) + + t.Run("state change notification posts to thread after repeat_interval", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true, PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + ctx := notify.WithNotificationReason(newCtx(store), notify.ReasonNewAlertsInGroup) + require.NoError(t, notifier.Notify(ctx).Err()) + + require.Len(t, captured, 2) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[1].body["thread_ts"]) + }) + + t.Run("subsequent notification posts only to thread without update_message", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{PostUpdatesToThread: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.postMessage", captured[0].url) + require.Equal(t, "111.222", captured[0].body["thread_ts"]) + require.Equal(t, "C123", captured[0].body["channel"]) + require.NotContains(t, captured[0].body, "ts") + + threadTs, _ := store.GetStr("threadTs") + require.Equal(t, "111.222", threadTs) + }) + + t.Run("update_message alone does not post thread reply", func(t *testing.T) { + var captured []capturedRequest + notifier := newTestNotifier(t, &SlackConfig{UpdateMessage: true}, &captured, "999.999") + store := nflog.NewStore(nil) + store.SetStr("threadTs", "111.222") + store.SetStr("channelId", "C123") + + require.NoError(t, notifier.Notify(newCtx(store)).Err()) + + require.Len(t, captured, 1) + require.Equal(t, "https://slack.com/api/chat.update", captured[0].url) + require.Equal(t, "111.222", captured[0].body["ts"]) + require.NotContains(t, captured[0].body, "thread_ts") + }) +} + +func TestSlackRejectsWebhookAPIURLFileAtNotifyTime(t *testing.T) { + f, err := os.CreateTemp("", "slack_test") + require.NoError(t, err, "creating temp file failed") + t.Cleanup(func() { os.Remove(f.Name()) }) + _, err = f.WriteString("https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX\n") + require.NoError(t, err, "writing to temp file failed") + + for _, tc := range []struct { + name string + conf SlackConfig + err string + }{ + { + name: "update_message", + conf: SlackConfig{UpdateMessage: true}, + err: "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", + }, + { + name: "post_updates_to_thread", + conf: SlackConfig{PostUpdatesToThread: true}, + err: "post_updates_to_thread can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", + }, + } { + t.Run(tc.name, func(t *testing.T) { + conf := tc.conf + conf.APIURLFile = f.Name() + conf.Channel = "#test-channel" + conf.HTTPConfig = &commoncfg.HTTPClientConfig{} + + tmpl, err := template.FromGlobs([]string{}) + require.NoError(t, err) + tmpl.ExternalURL, err = url.Parse("http://am") + require.NoError(t, err) + + notifier, err := New(&conf, tmpl, slog.New(slog.DiscardHandler)) + require.NoError(t, err) + + notifier.postJSONFunc = func(ctx context.Context, client *http.Client, reqURL string, body io.Reader) (*http.Response, error) { + t.Fatal("no request must be sent to a webhook URL") + return nil, nil + } + + ctx := notify.WithNflogStore(notify.WithGroupKey(context.Background(), "test-group-key"), nflog.NewStore(nil)) + verdict := notifier.Notify(ctx) + require.False(t, verdict.ShouldRetry()) + require.EqualError(t, verdict.Err(), tc.err) + }) + } +} diff --git a/notify/slack/types.go b/notify/slack/types.go index 4427b055fe..19971d3b2f 100644 --- a/notify/slack/types.go +++ b/notify/slack/types.go @@ -36,14 +36,15 @@ type Notifier struct { // request is the request for sending a Slack notification. type request struct { - Channel string `json:"channel,omitempty"` - Timestamp string `json:"ts,omitempty"` - Username string `json:"username,omitempty"` - IconEmoji string `json:"icon_emoji,omitempty"` - IconURL string `json:"icon_url,omitempty"` - LinkNames bool `json:"link_names,omitempty"` - Text string `json:"text,omitempty"` - Attachments []attachment `json:"attachments"` + Channel string `json:"channel,omitempty"` + Timestamp string `json:"ts,omitempty"` + ThreadTimestamp string `json:"thread_ts,omitempty"` + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` + LinkNames bool `json:"link_names,omitempty"` + Text string `json:"text,omitempty"` + Attachments []attachment `json:"attachments"` } // attachment is used to display a richly formatted message block.