go / slack
I post messages to Slack from background jobs. Three methods of
the Web API cover it: chat.postMessage, chat.update, and
chat.delete. A small client wraps them.
Auth is a bot token sent as Authorization: Bearer ... on every
request.
Blocks as maps
Slack's Block Kit is a large,
evolving JSON schema. Rather than mirror every element as a typed
Go struct, a block is a map[string]any aliased for readability:
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, which is the right trade for a format that changes on Slack's schedule, not mine.
Methods
Each method builds a body map and delegates to a shared do.
Post returns the message timestamp (ts), the handle needed to
later update or delete it. The text field is the
device-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 per method
The interesting decision is that retry policy differs by method,
not by client. Post and Update run once (singleAttempt = []time.Duration{0}): a retried post risks a duplicate message,
and neither is idempotent.
Delete retries on Slack's default transient schedule, because
it runs as a best-effort cleanup job and chat.delete is
idempotent. Retrying an already-applied delete just 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 a plain argument to do, so the policy
lives at the call site where the idempotency reasoning does.
The 200 {"ok": false} gotcha
Slack returns HTTP 200 for application-level failures
(channel_not_found, missing_scope, rate limits) with an
{"ok": false, "error": "..."} body. A client that only checks
the status code treats these as success. So 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
}
Only the fields callers act on are parsed:
type apiResponse struct {
OK bool `json:"ok"`
TS string `json:"ts"`
Error string `json:"error"`
}
The error string is Slack's error code, which is the actionable
part. On a parse failure the raw body is returned so an operator
can see what Slack actually sent. A per-request timeout arrives
wrapped in a *url.Error; unwrapping it to a stable
"slack: timeout" saves callers from introspecting
transport-layer errors.
Narrowed interfaces
Consumers depend on the smallest surface they use. 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)
}