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..6d23c18 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,12 @@ func triggerIDFromRaw(raw any) string { } } +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 2edde91..163185e 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 != "" { 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{}) }},