From fbfb5c33b38d85a3f3f4644eb64ff0ec81eb3815 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 20:55:17 +0000 Subject: [PATCH 1/3] feat(slackapi): add OpenConversation and the mrkdwn request field The Slack adapter sends mrkdwn:false with plain-text posts, and it opens a DM with conversations.open for its ephemeral fallback. The typed methods could not express either request, so the adapter built those bodies itself. The new tests pin the JSON bodies that the adapter sends today. --- adapters/slack/slackapi/README.md | 1 + adapters/slack/slackapi/chat.go | 6 +++ adapters/slack/slackapi/chat_test.go | 43 +++++++++++++++++++ adapters/slack/slackapi/conversations.go | 20 +++++++++ adapters/slack/slackapi/conversations_test.go | 14 ++++++ adapters/slack/slackapi/doc.go | 10 ++--- adapters/slack/slackapi/form_test.go | 3 ++ 7 files changed, 92 insertions(+), 5 deletions(-) diff --git a/adapters/slack/slackapi/README.md b/adapters/slack/slackapi/README.md index 33d7a9b..1ea41d9 100644 --- a/adapters/slack/slackapi/README.md +++ b/adapters/slack/slackapi/README.md @@ -39,6 +39,7 @@ The Slack adapter (package `slack`) uses this package for its Web API calls, rat | `Client.UpdateMessage` | `chat.update` | [chat.go:Client.UpdateMessage] | | `Client.DeleteMessage` | `chat.delete` | [chat.go:Client.DeleteMessage] | | `Client.ConversationInfo` | `conversations.info` | [conversations.go:Client.ConversationInfo] | +| `Client.OpenConversation` | `conversations.open` | [conversations.go:Client.OpenConversation] | | `Client.ConversationHistory` | `conversations.history` | [conversations.go:Client.ConversationHistory] | | `Client.ConversationReplies` | `conversations.replies` | [conversations.go:Client.ConversationReplies] | | `Client.FileInfo` | `files.info` | [files.go:Client.FileInfo] | diff --git a/adapters/slack/slackapi/chat.go b/adapters/slack/slackapi/chat.go index 20934e7..b2a6292 100644 --- a/adapters/slack/slackapi/chat.go +++ b/adapters/slack/slackapi/chat.go @@ -17,6 +17,9 @@ type PostMessageRequest struct { // MarkdownText is the message text in standard Markdown. It cannot be // combined with Text or Blocks and holds at most 12,000 characters. MarkdownText string `json:"markdown_text,omitempty"` + // Mrkdwn set to false turns off Slack mrkdwn parsing of Text. Nil uses + // the Slack default, which parses it. + Mrkdwn *bool `json:"mrkdwn,omitempty"` // UnfurlLinks turns the unfurl of text links on or off. Nil uses the // Slack default. UnfurlLinks *bool `json:"unfurl_links,omitempty"` @@ -64,6 +67,9 @@ type PostEphemeralRequest struct { // MarkdownText is the message text in standard Markdown. It cannot be // combined with Text or Blocks and holds at most 12,000 characters. MarkdownText string `json:"markdown_text,omitempty"` + // Mrkdwn set to false turns off Slack mrkdwn parsing of Text, as for + // PostMessageRequest. Nil uses the Slack default. + Mrkdwn *bool `json:"mrkdwn,omitempty"` } // PostEphemeralResponse is the response of chat.postEphemeral. diff --git a/adapters/slack/slackapi/chat_test.go b/adapters/slack/slackapi/chat_test.go index 379509b..a83b8a6 100644 --- a/adapters/slack/slackapi/chat_test.go +++ b/adapters/slack/slackapi/chat_test.go @@ -70,6 +70,26 @@ func TestPostMessage(t *testing.T) { "unfurl_media": true }`) }) + + t.Run("Mrkdwn", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.Respond("chat.postMessage", json.RawMessage(`{"ok":true,"channel":"C1","ts":"1700000000.000100"}`)) + + _, err := methodsNewClient(srv).PostMessage(t.Context(), slackapi.PostMessageRequest{ + Channel: "C1", + Text: "*not bold*", + Mrkdwn: new(false), + }) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + methodsCheckJSONBody(t, srv, "chat.postMessage", `{ + "channel": "C1", + "text": "*not bold*", + "mrkdwn": false + }`) + }) } func TestUpdateMessage(t *testing.T) { @@ -165,3 +185,26 @@ func TestPostEphemeral(t *testing.T) { }`) methodsCheckEqual(t, "response", resp, &slackapi.PostEphemeralResponse{MessageTS: "1700000000.000200"}) } + +func TestPostEphemeralMrkdwn(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("chat.postEphemeral", json.RawMessage(`{"ok":true,"message_ts":"1700000000.000200"}`)) + + _, err := methodsNewClient(srv).PostEphemeral(t.Context(), slackapi.PostEphemeralRequest{ + Channel: "C1", + User: "U1", + Text: "*not bold*", + Mrkdwn: new(false), + }) + if err != nil { + t.Fatalf("PostEphemeral: %v", err) + } + methodsCheckJSONBody(t, srv, "chat.postEphemeral", `{ + "channel": "C1", + "user": "U1", + "text": "*not bold*", + "mrkdwn": false + }`) +} diff --git a/adapters/slack/slackapi/conversations.go b/adapters/slack/slackapi/conversations.go index 55a2c73..495ceba 100644 --- a/adapters/slack/slackapi/conversations.go +++ b/adapters/slack/slackapi/conversations.go @@ -64,6 +64,26 @@ func (c *Client) ConversationInfo(ctx context.Context, req ConversationInfoReque return &resp.Channel, nil } +// OpenConversationRequest is the request of conversations.open. +type OpenConversationRequest struct { + // Users is a comma-separated list of 1 to 8 user IDs, without the user of + // the token. One ID opens a DM, and more open a group DM. + Users string `json:"users"` +} + +// OpenConversation calls conversations.open and returns the DM or group DM +// with the users. Slack creates the conversation when it does not exist yet. +// The returned Conversation has only its ID set. +func (c *Client) OpenConversation(ctx context.Context, req OpenConversationRequest) (*Conversation, error) { + var resp struct { + Channel Conversation `json:"channel"` + } + if err := c.Call(ctx, "conversations.open", req, &resp); err != nil { + return nil, err + } + return &resp.Channel, nil +} + // ConversationHistoryRequest is the request of conversations.history. type ConversationHistoryRequest struct { // Channel is the conversation ID. diff --git a/adapters/slack/slackapi/conversations_test.go b/adapters/slack/slackapi/conversations_test.go index d78c1f1..99180c8 100644 --- a/adapters/slack/slackapi/conversations_test.go +++ b/adapters/slack/slackapi/conversations_test.go @@ -57,6 +57,20 @@ func TestConversationInfo(t *testing.T) { }) } +func TestOpenConversation(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("conversations.open", json.RawMessage(`{"ok":true,"channel":{"id":"D1"}}`)) + + conv, err := methodsNewClient(srv).OpenConversation(t.Context(), slackapi.OpenConversationRequest{Users: "U1"}) + if err != nil { + t.Fatalf("OpenConversation: %v", err) + } + methodsCheckJSONBody(t, srv, "conversations.open", `{"users":"U1"}`) + methodsCheckEqual(t, "conversation", conv, &slackapi.Conversation{ID: "D1"}) +} + const ( methodsPageFirst = `{"type": "message", "user": "U1", "text": "first", "ts": "1700000000.000001", "reactions": [{"name": "wave", "count": 1}]}` methodsPageSecond = `{"type": "message", "bot_id": "B1", "text": "second", "ts": "1700000000.000002", "thread_ts": "1700000000.000001"}` diff --git a/adapters/slack/slackapi/doc.go b/adapters/slack/slackapi/doc.go index 8141a95..f9adeb6 100644 --- a/adapters/slack/slackapi/doc.go +++ b/adapters/slack/slackapi/doc.go @@ -16,11 +16,11 @@ // defaults. The typed methods cover the calls that a bot needs: AuthTest, // PostMessage, PostEphemeral, UpdateMessage, DeleteMessage, // SetAssistantThreadStatus, SetAgentSessionStatus, AddReaction, -// RemoveReaction, UserInfo, ConversationInfo, ConversationHistory, -// ConversationReplies, and FileInfo. Call is the escape hatch for other -// methods. PostResponseURL posts JSON without a token to the response_url of a -// slash command or an interaction. WithToken returns a copy of a Client that -// sends another token. +// RemoveReaction, UserInfo, ConversationInfo, OpenConversation, +// ConversationHistory, ConversationReplies, and FileInfo. Call is the escape +// hatch for other methods. PostResponseURL posts JSON without a token to the +// response_url of a slash command or an interaction. WithToken returns a copy +// of a Client that sends another token. // // The setup of a bot needs three more methods. OAuthV2Access exchanges the code // of an install for a bot token, with the client ID and the client secret of diff --git a/adapters/slack/slackapi/form_test.go b/adapters/slack/slackapi/form_test.go index 98ac2c0..b66cca9 100644 --- a/adapters/slack/slackapi/form_test.go +++ b/adapters/slack/slackapi/form_test.go @@ -108,6 +108,9 @@ func TestMethodsReturnAPIError(t *testing.T) { {"conversations.info", func(ctx context.Context, c *slackapi.Client) (any, error) { return c.ConversationInfo(ctx, slackapi.ConversationInfoRequest{}) }}, + {"conversations.open", func(ctx context.Context, c *slackapi.Client) (any, error) { + return c.OpenConversation(ctx, slackapi.OpenConversationRequest{}) + }}, {"conversations.history", func(ctx context.Context, c *slackapi.Client) (any, error) { return c.ConversationHistory(ctx, slackapi.ConversationHistoryRequest{}) }}, From b96c048c036dad2d9a3f2a533011a0636237dac5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 20:57:07 +0000 Subject: [PATCH 2/3] refactor(adapters/slack): move Web API calls onto typed slackapi methods Init, PostMessage, PostEphemeralMessage, and the DM fallback now call AuthTest, PostMessage, PostEphemeral, and OpenConversation. The adapter's own request and response structs and its call and callWithToken wrappers are gone, so each of these methods has one request encoding. PostNative still sends its opaque payload through Client.Call. The typed request takes []Block, and the adapter docs promise that the payload is not decoded or checked. The request bodies are the same, except that the keys of a threaded chat.postEphemeral body come in a different order. SentMessage.Raw now holds the slackapi response type instead of an unexported struct. --- adapters/slack/README.md | 5 +- adapters/slack/interactive.go | 19 +++++-- adapters/slack/slack.go | 99 ++++++----------------------------- 3 files changed, 33 insertions(+), 90 deletions(-) diff --git a/adapters/slack/README.md b/adapters/slack/README.md index 7300c6a..0ad774a 100644 --- a/adapters/slack/README.md +++ b/adapters/slack/README.md @@ -94,8 +94,9 @@ The code has compile-time assertions for `chat.Listener`, `chat.HistoryReader`, ## Side Effects -- In single-install mode, `Init` calls `auth.test` unless all three IDs are configured, and writes the team, bot user, and bot IDs on the `Adapter`. [slack.go:Init] -- Posts call `chat.postMessage`, `chat.postEphemeral`, `conversations.open` (DM fallback), and `views.open` through `slackapi.Client.Call`. [slack.go:PostMessage,PostEphemeralMessage,postEphemeralFallback], [interactive.go:PostNative,openModalWithToken] +- In single-install mode, `Init` calls `auth.test` through `slackapi.Client.AuthTest` unless all three IDs are configured, and writes the team, bot user, and bot IDs on the `Adapter`. [slack.go:Init] +- Posts call `chat.postMessage`, `chat.postEphemeral`, and `conversations.open` (DM fallback) through the typed `slackapi.Client` methods `PostMessage`, `PostEphemeral`, and `OpenConversation`. [slack.go:PostMessage,PostEphemeralMessage,postEphemeralFallback] +- `PostNative` sends its payload to `chat.postMessage` as `blocks` without decoding it, and `OpenModal` calls `views.open`. Both go through `slackapi.Client.Call`. [interactive.go:PostNative,nativePostPayload,openModalWithToken] - `ReadHistory` calls `conversations.replies` for threads and `conversations.history` for direct messages. [history.go:ReadHistory] - `RespondURL` POSTs to the `response_url` through `slackapi.Client.PostResponseURL`, with no bot token. [interactive.go:RespondURL] - `Listen` opens a Socket Mode websocket through `slackapi.Client.Listen` with the app token. [slack.go:Listen] diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index 2587589..4907ef5 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -305,7 +305,7 @@ func (a *Adapter) PostNative(ctx context.Context, thread chat.ThreadRef, content if err != nil { return nil, err } - payload := postMessagePayload{ + payload := nativePostPayload{ Channel: thread.Channel, Blocks: content.Payload, } @@ -313,11 +313,11 @@ func (a *Adapter) PostNative(ctx context.Context, thread chat.ThreadRef, content payload.ThreadTS = thread.Root } - var resp postMessageResponse - if err := a.callWithToken(ctx, token, "chat.postMessage", payload, &resp); err != nil { + var resp slackapi.PostMessageResponse + if err := a.api.WithToken(token).Call(ctx, "chat.postMessage", payload, &resp); err != nil { return nil, err } - return &chat.SentMessage{ID: resp.TS, ThreadID: thread.ID, Raw: resp}, nil + return &chat.SentMessage{ID: resp.TS, ThreadID: thread.ID, Raw: &resp}, nil } // OpenModal opens a modal via views.open using a trigger_id reachable from a @@ -359,7 +359,7 @@ func (a *Adapter) OpenModalForTenant(ctx context.Context, tenant string, trigger func (a *Adapter) openModalWithToken(ctx context.Context, token string, triggerID string, view any) error { var resp openViewResponse - return a.callWithToken(ctx, token, "views.open", openViewPayload{TriggerID: triggerID, View: view}, &resp) + return a.api.WithToken(token).Call(ctx, "views.open", openViewPayload{TriggerID: triggerID, View: view}, &resp) } // OpenModalFromRaw opens a modal using the trigger_id preserved on a Command or @@ -446,6 +446,15 @@ func triggerIDFromRaw(raw any) string { } } +// nativePostPayload is the chat.postMessage request of PostNative. It sends the +// opaque native payload as blocks without decoding it, which the []Block of +// slackapi.PostMessageRequest cannot do. +type nativePostPayload struct { + Channel string `json:"channel"` + ThreadTS string `json:"thread_ts,omitempty"` + Blocks any `json:"blocks,omitempty"` +} + type openViewPayload struct { TriggerID string `json:"trigger_id"` View any `json:"view"` diff --git a/adapters/slack/slack.go b/adapters/slack/slack.go index 6d40d5d..76770b3 100644 --- a/adapters/slack/slack.go +++ b/adapters/slack/slack.go @@ -158,8 +158,8 @@ func (a *Adapter) Init(ctx context.Context) error { return nil } - var resp authTestResponse - if err := a.call(ctx, "auth.test", map[string]any{}, &resp); err != nil { + resp, err := a.api.AuthTest(ctx) + if err != nil { return err } if err := requireMatchingAuthField("team_id", a.teamID, resp.TeamID); err != nil { @@ -324,7 +324,7 @@ func (a *Adapter) PostMessage(ctx context.Context, thread chat.ThreadRef, msg ch if err != nil { return nil, err } - payload := postMessagePayload{ + payload := slackapi.PostMessageRequest{ Channel: thread.Channel, Text: messageFields.Text, MarkdownText: messageFields.MarkdownText, @@ -334,8 +334,8 @@ func (a *Adapter) PostMessage(ctx context.Context, thread chat.ThreadRef, msg ch payload.ThreadTS = thread.Root } - var resp postMessageResponse - if err := a.callWithToken(ctx, token, "chat.postMessage", payload, &resp); err != nil { + resp, err := a.api.WithToken(token).PostMessage(ctx, payload) + if err != nil { return nil, err } return &chat.SentMessage{ID: resp.TS, ThreadID: thread.ID, Raw: resp}, nil @@ -351,7 +351,7 @@ func (a *Adapter) PostEphemeralMessage(ctx context.Context, thread chat.ThreadRe return nil, err } if !a.disableNativeEphemeral { - payload := postEphemeralPayload{ + payload := slackapi.PostEphemeralRequest{ Channel: thread.Channel, User: actor.ID, Text: messageFields.Text, @@ -362,8 +362,7 @@ func (a *Adapter) PostEphemeralMessage(ctx context.Context, thread chat.ThreadRe payload.ThreadTS = thread.Root } - var resp postEphemeralResponse - err := a.callWithToken(ctx, token, "chat.postEphemeral", payload, &resp) + resp, err := a.api.WithToken(token).PostEphemeral(ctx, payload) if err == nil { return &chat.SentMessage{ID: resp.MessageTS, ThreadID: thread.ID, Raw: resp}, nil } @@ -593,25 +592,26 @@ func (a *Adapter) postEphemeralFallback(ctx context.Context, token string, tenan if err != nil { return nil, err } - var openResp openConversationResponse - if err := a.callWithToken(ctx, token, "conversations.open", openConversationPayload{Users: actor.ID}, &openResp); err != nil { + api := a.api.WithToken(token) + conv, err := api.OpenConversation(ctx, slackapi.OpenConversationRequest{Users: actor.ID}) + if err != nil { return nil, err } - if openResp.Channel.ID == "" { + if conv.ID == "" { return nil, errors.New("slack: conversations.open did not return channel id") } - threadID, err := encodeThreadID(threadPayload{Team: tenant, Channel: openResp.Channel.ID, Direct: true}) + threadID, err := encodeThreadID(threadPayload{Team: tenant, Channel: conv.ID, Direct: true}) if err != nil { return nil, err } - var postResp postMessageResponse - if err := a.callWithToken(ctx, token, "chat.postMessage", postMessagePayload{ - Channel: openResp.Channel.ID, + postResp, err := api.PostMessage(ctx, slackapi.PostMessageRequest{ + Channel: conv.ID, Text: messageFields.Text, MarkdownText: messageFields.MarkdownText, Mrkdwn: messageFields.Mrkdwn, - }, &postResp); err != nil { + }) + if err != nil { if apiErr, rejected := apiRejection(err); rejected { return nil, fmt.Errorf("slack: fallback chat.postMessage failed: %s", apiErr.Code) } @@ -638,22 +638,6 @@ func slackMessageFields(msg chat.PostableMessage) (slackMessage, error) { } } -// call posts to the Slack Web API with the single-install bot token. Multi-tenant -// callers resolve a per-workspace token and use callWithToken instead. -func (a *Adapter) call(ctx context.Context, method string, payload any, dest any) error { - return a.callWithToken(ctx, a.botToken, method, payload, dest) -} - -// callWithToken posts to the Slack Web API authorizing with the given per-workspace -// bot token. It is the single outbound seam both modes share. Bounded rate-limit -// retry lives in slackapi (ADR 0005): a transient Slack 429, ratelimited, or -// rate_limited response is retried within the per-adapter RetryPolicy and the -// caller's context deadline, then surfaced as a typed *RateLimited on exhaustion. -// An ok:false response returns a *slackapi.APIError. -func (a *Adapter) callWithToken(ctx context.Context, token string, method string, payload any, dest any) error { - return a.api.WithToken(token).Call(ctx, method, payload, dest) -} - // apiRejection reports whether err is an ok:false Web API response, as opposed to // a transport, HTTP status, or rate-limit failure. func apiRejection(err error) (*slackapi.APIError, bool) { @@ -732,57 +716,6 @@ func decodeThreadID(id chat.ThreadID) (threadPayload, error) { return payload, nil } -type authTestResponse struct { - OK bool `json:"ok"` - Error string `json:"error"` - TeamID string `json:"team_id"` - UserID string `json:"user_id"` - BotID string `json:"bot_id"` -} - -type postMessagePayload struct { - Channel string `json:"channel"` - ThreadTS string `json:"thread_ts,omitempty"` - Text string `json:"text,omitempty"` - MarkdownText string `json:"markdown_text,omitempty"` - Mrkdwn *bool `json:"mrkdwn,omitempty"` - Blocks any `json:"blocks,omitempty"` -} - -type postMessageResponse struct { - OK bool `json:"ok"` - Error string `json:"error"` - Channel string `json:"channel"` - TS string `json:"ts"` -} - -type postEphemeralPayload struct { - Channel string `json:"channel"` - ThreadTS string `json:"thread_ts,omitempty"` - User string `json:"user"` - Text string `json:"text,omitempty"` - MarkdownText string `json:"markdown_text,omitempty"` - Mrkdwn *bool `json:"mrkdwn,omitempty"` -} - -type postEphemeralResponse struct { - OK bool `json:"ok"` - Error string `json:"error"` - MessageTS string `json:"message_ts"` -} - -type openConversationPayload struct { - Users string `json:"users"` -} - -type openConversationResponse struct { - OK bool `json:"ok"` - Error string `json:"error"` - Channel struct { - ID string `json:"id"` - } `json:"channel"` -} - func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { From 486770601e4eaa34a4757a4f859f3d567c13aac9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 20:58:53 +0000 Subject: [PATCH 3/3] refactor(adapters/slack): drop the nativePostPayload comment --- adapters/slack/interactive.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/adapters/slack/interactive.go b/adapters/slack/interactive.go index 4907ef5..6d23c18 100644 --- a/adapters/slack/interactive.go +++ b/adapters/slack/interactive.go @@ -446,9 +446,6 @@ func triggerIDFromRaw(raw any) string { } } -// nativePostPayload is the chat.postMessage request of PostNative. It sends the -// opaque native payload as blocks without decoding it, which the []Block of -// slackapi.PostMessageRequest cannot do. type nativePostPayload struct { Channel string `json:"channel"` ThreadTS string `json:"thread_ts,omitempty"`