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.
The client sends a bot token as Authorization: Bearer ... on
every request.
Blocks as maps
Slack's Block Kit is a large,
evolving JSON schema. A block is a map[string]any with an alias
for readability, rather than a typed Go struct per element:
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. That is the right trade for a format that Slack changes on its own schedule.
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
Retry policy differs by method, not by client. Neither Post nor
Update is idempotent, and a retried post risks a duplicate
message. So both run once (singleAttempt = []time.Duration{0}).
Delete retries on Slack's default transient schedule, because
it runs as a best-effort cleanup job and 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 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
}
The client parses only the fields callers act on:
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, do returns the raw body, so an
operator can see what Slack sent. A per-request timeout arrives
wrapped in a *url.Error. do unwraps it to a stable
"slack: timeout", which saves callers from reading
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 that
interface, and tests inject a fake:
type Poster interface {
Post(ctx context.Context, channel, text string, blocks []Block) (string, error)
}