Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions adapters/slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 11 additions & 5 deletions adapters/slack/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,19 +305,19 @@ 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,
}
if !thread.Direct {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
99 changes: 16 additions & 83 deletions adapters/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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 != "" {
Expand Down
1 change: 1 addition & 0 deletions adapters/slack/slackapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] |
Expand Down
6 changes: 6 additions & 0 deletions adapters/slack/slackapi/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions adapters/slack/slackapi/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}`)
}
20 changes: 20 additions & 0 deletions adapters/slack/slackapi/conversations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions adapters/slack/slackapi/conversations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`
Expand Down
10 changes: 5 additions & 5 deletions adapters/slack/slackapi/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions adapters/slack/slackapi/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
}},
Expand Down
Loading