Skip to content

feat: add WithHTTPClient/WithEventContext ClientOptions; fix: WithContext receiver mutation - #29

Open
fatmcgav wants to merge 3 commits into
slack-io:mainfrom
fatmcgav:feat/with-http-client-option
Open

feat: add WithHTTPClient/WithEventContext ClientOptions; fix: WithContext receiver mutation#29
fatmcgav wants to merge 3 commits into
slack-io:mainfrom
fatmcgav:feat/with-http-client-option

Conversation

@fatmcgav

@fatmcgav fatmcgav commented Aug 18, 2026

Copy link
Copy Markdown

WithHTTPClient ClientOption

slacker.NewClient(botToken, appToken, ...ClientOption) builds its internal *slack.Client via slack.New(botToken, slackOpts...), but newSlackOptions in slacker.go only ever passes slack.OptionDebug, slack.OptionAppLevelToken, and optionally slack.OptionAPIURL. There is no ClientOption that lets a caller supply a custom *http.Client (or http.RoundTripper), even though the wrapped github.com/slack-go/slack package already supports this via slack.OptionHTTPClient.

Every field on Slacker (and on *slack.Client) is unexported with no setters, so today the only way to control the HTTP transport slacker uses is at construction time, through a new ClientOption. This blocks consumers who want to instrument outbound Slack Web API calls — e.g. wiring up OpenTelemetry's otelhttp transport for tracing/metrics, request logging, custom retry/timeout behaviour, or proxying — since they cannot get a handle on the transport at all.

Adds a WithHTTPClient ClientOption that plumbs a caller-supplied *http.Client through to slack.New via slack.OptionHTTPClient:

client := slacker.NewClient(botToken, appToken,
    slacker.WithHTTPClient(&http.Client{
        Transport: otelhttp.NewTransport(http.DefaultTransport),
    }),
)
  • options.go: new HTTPClient *http.Client field on clientOptions, and the WithHTTPClient option.
  • slacker.go: newSlackOptions forwards slack.OptionHTTPClient(...) only when HTTPClient is set.

Purely additive and backward-compatible: when the option isn't passed, HTTPClient stays nil and behaviour is unchanged — slack-go/slack falls back to its own bare &http.Client{}, exactly as it does today.

Fix: WithContext mutated the receiver instead of returning a copy

While adding tests for the option above, I found CommandContext.WithContext, InteractionContext.WithContext, and JobContext.WithContext (context.go) are documented with the standard http.Request.WithContext contract — "returns a shallow copy of r with its context changed to ctx" — but don't honour it:

r2 := new(CommandContext)
*r2 = *r
r.ctx = ctx   // mutates the receiver, not the copy
return r      // returns the receiver, not the copy

Two bugs, and the second survives fixing the first:

  1. It mutates r and returns rr2 is built and discarded, silently changing state any other holder of the original pointer would see.
  2. Even copied correctly, response is a shallow-copied pointer to the same *ResponseReplier/*ResponseWriter, wrapping a *Writer whose ctx is captured once at construction (newWriter(ctx, ...)). So WithContext changed what Context() reported but not what context Reply/ReplyError/ReplyBlocks/Post* actually issued their Slack API calls against — they kept using the original, stale context. This defeated any caller attaching a per-request context (e.g. one carrying an OpenTelemetry span) after construction.

Fixed all three WithContext methods to build a real independent copy, rebuilding the Writer/Replier/response against the new ctx via the same unexported constructors newCommandContext/newInteractionContext/newJobContext already use.

Thread ctx through message-event enrichment; add WithEventContext

handleMessageEvent is the single entry point for both free-text messages and slash commands, but before any command is matched it called newMessageEvent (message_event.go) — which enriches the raw event via getChannel/getUserProfile — with no ctx at all, even though handleMessageEvent(ctx, event) already had one in scope. Those two helpers, plus ignoreBotMessage's bot-identity lookup, called the non-Context Slack API variants (GetConversationInfo, GetUserInfo, GetBotInfo). Combined with WithHTTPClient above, a caller instrumenting outbound calls (e.g. via otelhttp) would see these three calls surface as disconnected trace roots — there was no way to attach a context/span before slacker started its own pre-dispatch work.

  • Threaded ctx through newMessageEventgetChannel/getUserProfile, and into ignoreBotMessage, switching each to its Context variant (GetConversationInfoContext, GetUserInfoContext, GetBotInfoContext).

  • Added WithEventContext, a ClientOption taking an EventContextFunc that lets a caller derive a per-event context (e.g. one carrying a span) before this enrichment runs:

    client := slacker.NewClient(botToken, appToken,
        slacker.WithEventContext(func(ctx context.Context, event any) (context.Context, func()) {
            ctx, span := tracer.Start(ctx, "slack.event")
            return ctx, func() { span.End() }
        }),
    )

    The returned cleanup func is deferred until dispatch for that event — enrichment, command/interaction routing, and the matched handler — has fully returned, and is called via both handleMessageEvent and handleInteractionEvent, nil-guarded so behaviour is unchanged when the option isn't set.

All touched functions (newMessageEvent, getChannel, getUserProfile, ignoreBotMessage) are unexported and single-call-site, so this is additive with no consumer-facing signature break.

Tests

This repo had no test files at all before this PR, so all of the following are new:

  • options_test.go: covers WithHTTPClient's default (nil), that it sets the field, and that newSlackOptions only appends slack.OptionHTTPClient(...) when set.
  • context_test.go: for all three context types —
    • WithContext returns a different pointer and does not mutate the receiver's context (regression test for bug 1).
    • After WithContext, Response().Reply/Post issues its Slack API call with the new context, not the original (regression test for bug 2) — verified via a fake http.RoundTripper wired in through WithHTTPClient, letting the test observe the exact context.Context slack-go attaches to the outgoing *http.Request.
  • event_context_test.go: for both handleMessageEvent and handleInteractionEvent
    • With no WithEventContext set, enrichment/handler Slack API calls carry the caller's original ctx (regression test proving the drop is fixed).
    • With WithEventContext set, those calls carry the derived context, and the cleanup func runs exactly once, after dispatch has fully returned.

Confirmed the context_test.go assertions fail against the pre-fix code and pass with the fix applied.

I didn't touch the README or add a changelog entry — this repo doesn't currently document ClientOptions individually in the README, and there's no CHANGELOG.md to update, so there was no existing convention to extend. Happy to add either if maintainers would like.

🤖 Generated with Claude Code

fatmcgav and others added 2 commits August 18, 2026 22:51
`slacker.NewClient` built its internal `*slack.Client` via `slack.New`, but
`newSlackOptions` only ever forwarded `slack.OptionDebug`,
`slack.OptionAppLevelToken`, and optionally `slack.OptionAPIURL`. Every field
on `Slacker` and `*slack.Client` is unexported with no setters, so there was
no way to control the HTTP transport slacker uses, even though
`slack-go/slack` already supports it via `slack.OptionHTTPClient`.

This blocked consumers who want to instrument outbound Slack Web API calls —
e.g. OpenTelemetry's `otelhttp` transport for tracing/metrics, request
logging, custom retry/timeout behaviour, or proxying — since they had no
handle on the transport at all.

Add a `WithHTTPClient` `ClientOption` that plumbs a caller-supplied
`*http.Client` through to `slack.New` via `slack.OptionHTTPClient`, forwarded
conditionally from `newSlackOptions` in `slacker.go`. Purely additive: when
the option isn't passed, `HTTPClient` stays `nil` and behaviour is unchanged
from today (`slack-go/slack` falls back to its own bare `&http.Client{}`).

Adds `options_test.go` (no test files previously existed in this repo)
covering the new default, `WithHTTPClient` setting the field, and
`newSlackOptions` only appending `slack.OptionHTTPClient` when set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`CommandContext.WithContext`, `InteractionContext.WithContext`, and
`JobContext.WithContext` were documented with the standard
`http.Request.WithContext` contract — "returns a shallow copy of r with its
context changed to ctx" — but didn't honour it:

```go
r2 := new(CommandContext)
*r2 = *r
r.ctx = ctx   // mutates the receiver, not the copy
return r      // returns the receiver, not the copy
```

Two bugs, and the second survives fixing the first:

1. It mutated `r` and returned `r` — `r2` was built and discarded, silently
   changing state any other holder of the original pointer would see.
2. Even copying correctly, `response` was a shallow-copied pointer to the
   *same* `*ResponseReplier`/`*ResponseWriter`, which wraps a `*Writer` whose
   `ctx` is captured once at construction (`newWriter(ctx, ...)`). So
   `WithContext` changed what `Context()` reported but not what context
   `Reply`/`ReplyError`/`ReplyBlocks`/`Post*` actually issued their Slack API
   calls against — they kept using the original, stale context. This defeated
   any caller attaching a per-request context (e.g. one carrying an
   OpenTelemetry span) after construction.

Fix all three `WithContext` methods in `context.go` to build a real
independent copy, rebuilding the `Writer`/`Replier`/`response` against the new
`ctx` via the same unexported constructors `newCommandContext`/
`newInteractionContext`/`newJobContext` already use.

Adds `context_test.go` covering, for all three context types:
- `WithContext` returns a different pointer and does not mutate the
  receiver's context (regression test for bug slack-io#1).
- After `WithContext`, `Response().Reply`/`Post` issues its Slack API call
  with the *new* context, not the original (regression test for bug slack-io#2) —
  verified via a fake `http.RoundTripper` wired in through the
  `WithHTTPClient` option (added earlier on this branch), which lets the test
  observe the exact `context.Context` slack-go attaches to the outgoing
  `*http.Request`.

Confirmed both new assertions fail against the pre-fix code and pass with the
fix applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fatmcgav fatmcgav changed the title feat: add WithHTTPClient ClientOption feat: add WithHTTPClient ClientOption; fix: WithContext receiver mutation Aug 18, 2026
…ntext`

`handleMessageEvent` discarded the caller's `ctx` before enriching a raw
event via `newMessageEvent` (`message_event.go`), so `getChannel`,
`getUserProfile`, and `ignoreBotMessage` called `GetConversationInfo`,
`GetUserInfo`, and `GetBotInfo` — the non-`Context` variants — with no way
to attach a caller's context/span. Callers instrumenting outbound calls via
`WithHTTPClient` (e.g. an `otelhttp`-wrapped client) saw these three calls
surface as disconnected trace roots.

- Thread `ctx` through `newMessageEvent` → `getChannel`/`getUserProfile`,
  and into `ignoreBotMessage`, switching each to its `Context` variant
  (`GetConversationInfoContext`, `GetUserInfoContext`, `GetBotInfoContext`).
- Add `WithEventContext`, a `ClientOption` taking an `EventContextFunc` that
  lets a caller derive a per-event context (e.g. one carrying a span)
  before this enrichment runs, with a cleanup func deferred until dispatch
  for that event has fully returned. Wired into both `handleMessageEvent`
  and `handleInteractionEvent`, nil-guarded so behaviour is unchanged when
  the option isn't set.

All touched functions are unexported and single-call-site, so this is
additive with no consumer-facing signature break.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fatmcgav fatmcgav changed the title feat: add WithHTTPClient ClientOption; fix: WithContext receiver mutation feat: add WithHTTPClient/WithEventContext ClientOptions; fix: WithContext receiver mutation Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant