go / slack
I post messages to Slack from background jobs. A small
client wraps three Web API methods:
chat.postMessage, chat.update, and chat.delete. It sends a bot
token as Authorization: Bearer ... on every request.
Blocks as maps
Slack's Block Kit is a large JSON
schema that changes often. A block is a map[string]any:
type Block = map[string]any
slack.Block{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": "hello"},
}
Callers stay close to the JSON in Slack's Block Kit Builder. The cost is no compile-time schema check.
Methods
Each method builds a body map and calls a shared do. Post returns
the message timestamp (ts), which Update and Delete take. The
text field is the notification fallback; blocks render the body:
func (c *Client) Post(ctx context.Context, channel, text string, blocks []Block) (string, error) {
body := map[string]any{
"channel": channel,
"text": text,
"link_names": true,
"blocks": blocks,
"unfurl_media": false,
}
resp, err := c.do(ctx, c.postURL, body, singleAttempt)
if err != nil {
return "", err
}
return resp.TS, nil
}
Retry policy
Neither Post nor Update is idempotent, and a retried post can
duplicate a message. Both run once (singleAttempt = []time.Duration{0}).
Delete retries on the default schedule because chat.delete is
idempotent. A retry of an applied delete returns message_not_found:
func (c *Client) Delete(ctx context.Context, channel, ts string) error {
body := map[string]any{"channel": channel, "ts": ts}
_, err := c.do(ctx, c.deleteURL, body, httputil.DefaultRetryDelays)
return err
}
The retry schedule is an argument to do, so the policy lives at the
call site.
200 {"ok": false}
Slack returns HTTP 200 for application failures such as
channel_not_found with an {"ok": false, "error": "..."} body.
do checks both the status and the ok field:
func (c *Client) do(ctx context.Context, url string, body map[string]any, retryDelays []time.Duration) (apiResponse, error) {
buf, err := json.Marshal(body)
if err != nil {
return apiResponse{}, fmt.Errorf("marshal request: %w", err)
}
res, err := httputil.Do(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
return req, nil
}, httputil.Config{
Client: c.httpClient,
RetryDelays: retryDelays,
})
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return apiResponse{}, fmt.Errorf("slack: timeout")
}
return apiResponse{}, fmt.Errorf("slack: %w", err)
}
if res.StatusCode/100 != 2 {
return apiResponse{}, fmt.Errorf("slack: HTTP %d", res.StatusCode)
}
var parsed apiResponse
if err := json.Unmarshal(res.Body, &parsed); err != nil {
return apiResponse{}, fmt.Errorf("slack: parse response: %s", string(res.Body))
}
if !parsed.OK {
if parsed.Error != "" {
return apiResponse{}, fmt.Errorf("slack: %s", parsed.Error)
}
return apiResponse{}, fmt.Errorf("slack: not ok")
}
return parsed, nil
}
The client parses only the fields callers act on:
type apiResponse struct {
OK bool `json:"ok"`
TS string `json:"ts"`
Error string `json:"error"`
}
On a parse failure, do returns the raw body. A timeout arrives
wrapped in a *url.Error, and do maps it to "slack: timeout".
Interfaces
A worker that only posts takes a one-method interface. *Client
satisfies it, and tests inject a fake:
type Poster interface {
Post(ctx context.Context, channel, text string, blocks []Block) (string, error)
}