From bcf06e14165cacebaee05c37451dbc437369d0b7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 19 Oct 2026 05:00:59 +0000 Subject: [PATCH 1/2] feat(slackapi): add the Web API pieces that the Coder Agents Slack bot needs Add OAuthV2Access, PostEphemeral, FileInfo with shares, SetAgentSessionStatus, and CreateApp and DeleteApp for apps.manifest. Add redirect_urls and agent_view to Manifest, and decoders for agent_session_stopped and channel_shared events. --- adapters/slack/slackapi/apps.go | 69 +++++++++++++++++++ adapters/slack/slackapi/apps_test.go | 81 ++++++++++++++++++++++ adapters/slack/slackapi/chat.go | 39 +++++++++++ adapters/slack/slackapi/chat_test.go | 24 +++++++ adapters/slack/slackapi/client.go | 3 +- adapters/slack/slackapi/doc.go | 34 +++++---- adapters/slack/slackapi/envelope.go | 63 ++++++++++++++++- adapters/slack/slackapi/envelope_test.go | 87 ++++++++++++++++++++++++ adapters/slack/slackapi/files.go | 49 +++++++++++++ adapters/slack/slackapi/files_test.go | 59 ++++++++++++++++ adapters/slack/slackapi/form_test.go | 18 +++++ adapters/slack/slackapi/manifest.go | 13 ++++ adapters/slack/slackapi/manifest_test.go | 19 ++++-- adapters/slack/slackapi/oauth.go | 75 ++++++++++++++++++++ adapters/slack/slackapi/oauth_test.go | 82 ++++++++++++++++++++++ adapters/slack/slackapi/status.go | 29 ++++++++ adapters/slack/slackapi/status_test.go | 47 +++++++++++++ docs/reference.md | 21 ++++-- 18 files changed, 787 insertions(+), 25 deletions(-) create mode 100644 adapters/slack/slackapi/apps.go create mode 100644 adapters/slack/slackapi/apps_test.go create mode 100644 adapters/slack/slackapi/oauth.go create mode 100644 adapters/slack/slackapi/oauth_test.go diff --git a/adapters/slack/slackapi/apps.go b/adapters/slack/slackapi/apps.go new file mode 100644 index 0000000..4107a20 --- /dev/null +++ b/adapters/slack/slackapi/apps.go @@ -0,0 +1,69 @@ +package slackapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// CreateAppRequest is the request of apps.manifest.create. +type CreateAppRequest struct { + // Manifest is the manifest of the new app. + Manifest Manifest +} + +// CreateAppResponse is the response of apps.manifest.create. +type CreateAppResponse struct { + // AppID is the ID of the new app. + AppID string `json:"app_id"` + // Credentials are the credentials of the new app. + Credentials AppCredentials `json:"credentials"` +} + +// AppCredentials are the credentials of an app that apps.manifest.create +// returns. +type AppCredentials struct { + // ClientID is the OAuth client ID. + ClientID string `json:"client_id"` + // ClientSecret is the OAuth client secret. + ClientSecret string `json:"client_secret"` + // SigningSecret is the secret that VerifyRequest checks the requests from + // Slack with. + SigningSecret string `json:"signing_secret"` +} + +// DeleteAppRequest is the request of apps.manifest.delete. +type DeleteAppRequest struct { + // AppID is the ID of the app. + AppID string +} + +// CreateApp calls apps.manifest.create to create an app from req.Manifest. +// Call it on a Client with an app configuration token (xoxe.xoxp-): +// api.WithToken(configToken).CreateApp(ctx, req). The token belongs to one +// user and one workspace, and expires after 12 hours. CreateApp sends the +// manifest as a JSON string in a form-encoded body. For an invalid manifest, +// Slack returns the error code "invalid_manifest", and APIError.Raw holds the +// "errors" list of the response. +func (c *Client) CreateApp(ctx context.Context, req CreateAppRequest) (*CreateAppResponse, error) { + const method = "apps.manifest.create" + manifest, err := json.Marshal(req.Manifest) + if err != nil { + return nil, fmt.Errorf("slack: encode %s request: %w", method, err) + } + var resp CreateAppResponse + if err := c.Call(ctx, method, url.Values{"manifest": {string(manifest)}}, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// DeleteApp calls apps.manifest.delete to delete an app that was created from +// a manifest. Call it on a Client with an app configuration token, as for +// CreateApp. It sends a form-encoded body. +func (c *Client) DeleteApp(ctx context.Context, req DeleteAppRequest) error { + values := url.Values{} + setFormString(values, "app_id", req.AppID) + return c.Call(ctx, "apps.manifest.delete", values, nil) +} diff --git a/adapters/slack/slackapi/apps_test.go b/adapters/slack/slackapi/apps_test.go new file mode 100644 index 0000000..cf5b23c --- /dev/null +++ b/adapters/slack/slackapi/apps_test.go @@ -0,0 +1,81 @@ +package slackapi_test + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/coder/chat/adapters/slack/slackapi" + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" +) + +const appsConfigToken = "xoxe.xoxp-config" + +// appsOnlyCall returns the only request to method on srv and checks that it +// is form encoded with the app configuration token. +func appsOnlyCall(t *testing.T, srv *slackapitest.Server, method string) slackapitest.Call { + t.Helper() + calls := srv.Calls(method) + if len(calls) != 1 { + t.Fatalf("%s calls = %d, want 1", method, len(calls)) + } + call := calls[0] + if got, want := call.Header.Get("Authorization"), "Bearer "+appsConfigToken; got != want { + t.Errorf("%s Authorization = %q, want %q", method, got, want) + } + if got := call.Header.Get("Content-Type"); got != methodsFormType { + t.Errorf("%s Content-Type = %q, want %q", method, got, methodsFormType) + } + return call +} + +func TestCreateApp(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("apps.manifest.create", json.RawMessage(`{ + "ok": true, + "app_id": "A0APP", + "credentials": { + "client_id": "123.456", + "client_secret": "client-secret", + "verification_token": "verification-token", + "signing_secret": "signing-secret" + }, + "oauth_authorize_url": "https://slack.com/oauth/v2/authorize?client_id=123.456&scope=chat:write" + }`)) + + resp, err := methodsNewClient(srv).WithToken(appsConfigToken).CreateApp(t.Context(), slackapi.CreateAppRequest{ + Manifest: formatsManifest(), + }) + if err != nil { + t.Fatalf("CreateApp: %v", err) + } + call := appsOnlyCall(t, srv, "apps.manifest.create") + if len(call.Form) != 1 || len(call.Form["manifest"]) != 1 { + t.Fatalf("form = %v, want only manifest", call.Form) + } + formatsAssertSameJSON(t, []byte(call.Form.Get("manifest")), []byte(formatsManifestJSON)) + methodsCheckEqual(t, "response", resp, &slackapi.CreateAppResponse{ + AppID: "A0APP", + Credentials: slackapi.AppCredentials{ + ClientID: "123.456", + ClientSecret: "client-secret", + SigningSecret: "signing-secret", + }, + }) +} + +func TestDeleteApp(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("apps.manifest.delete", json.RawMessage(`{"ok":true}`)) + + err := methodsNewClient(srv).WithToken(appsConfigToken).DeleteApp(t.Context(), slackapi.DeleteAppRequest{AppID: "A0APP"}) + if err != nil { + t.Fatalf("DeleteApp: %v", err) + } + call := appsOnlyCall(t, srv, "apps.manifest.delete") + methodsCheckEqual(t, "form", call.Form, url.Values{"app_id": {"A0APP"}}) +} diff --git a/adapters/slack/slackapi/chat.go b/adapters/slack/slackapi/chat.go index ae005c1..20934e7 100644 --- a/adapters/slack/slackapi/chat.go +++ b/adapters/slack/slackapi/chat.go @@ -46,6 +46,45 @@ func (c *Client) PostMessage(ctx context.Context, req PostMessageRequest) (*Post return &resp, nil } +// PostEphemeralRequest is the request of chat.postEphemeral. +type PostEphemeralRequest struct { + // Channel is the ID of the channel, DM, or group DM. + Channel string `json:"channel"` + // User is the ID of the only user who sees the message. The user must be + // a member of Channel. + User string `json:"user"` + // ThreadTS posts the message in a thread. It must be the ts of the parent + // message, not the ts of a reply. + ThreadTS string `json:"thread_ts,omitempty"` + // Text is the message text in Slack mrkdwn. With Blocks, Slack uses it as + // the fallback text for notifications. + Text string `json:"text,omitempty"` + // Blocks are the Block Kit blocks of the message. + Blocks []Block `json:"blocks,omitempty"` + // 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"` +} + +// PostEphemeralResponse is the response of chat.postEphemeral. +type PostEphemeralResponse struct { + // MessageTS is the ts of the message. Slack does not store an ephemeral + // message, so the ts cannot update or delete it. + MessageTS string `json:"message_ts"` +} + +// PostEphemeral calls chat.postEphemeral to post a message that only one user +// sees. Slack returns the error code "user_not_in_channel" when the user is +// not a member of the channel. Set Text, Blocks, or MarkdownText, as for +// PostMessage. +func (c *Client) PostEphemeral(ctx context.Context, req PostEphemeralRequest) (*PostEphemeralResponse, error) { + var resp PostEphemeralResponse + if err := c.Call(ctx, "chat.postEphemeral", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + // UpdateMessageRequest is the request of chat.update. type UpdateMessageRequest struct { // Channel is the ID of the channel of the message. diff --git a/adapters/slack/slackapi/chat_test.go b/adapters/slack/slackapi/chat_test.go index 9aa4018..379509b 100644 --- a/adapters/slack/slackapi/chat_test.go +++ b/adapters/slack/slackapi/chat_test.go @@ -141,3 +141,27 @@ func TestDeleteMessage(t *testing.T) { methodsCheckJSONBody(t, srv, "chat.delete", `{"channel":"C1","ts":"1700000000.000100"}`) }) } + +func TestPostEphemeral(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("chat.postEphemeral", json.RawMessage(`{"ok":true,"message_ts":"1700000000.000200"}`)) + + resp, err := methodsNewClient(srv).PostEphemeral(t.Context(), slackapi.PostEphemeralRequest{ + Channel: "C1", + User: "U1", + ThreadTS: "1700000000.000001", + Text: "I only answer members of this workspace.", + }) + if err != nil { + t.Fatalf("PostEphemeral: %v", err) + } + methodsCheckJSONBody(t, srv, "chat.postEphemeral", `{ + "channel": "C1", + "user": "U1", + "thread_ts": "1700000000.000001", + "text": "I only answer members of this workspace." + }`) + methodsCheckEqual(t, "response", resp, &slackapi.PostEphemeralResponse{MessageTS: "1700000000.000200"}) +} diff --git a/adapters/slack/slackapi/client.go b/adapters/slack/slackapi/client.go index 0f898ba..e2975a0 100644 --- a/adapters/slack/slackapi/client.go +++ b/adapters/slack/slackapi/client.go @@ -27,7 +27,8 @@ var defaultFileOrigins = []string{"https://files.slack.com", "https://slack.com" // Options configures a Client. type Options struct { // Token is the bot or user token. Call, the typed methods, and DownloadFile - // send it as a bearer token. UploadToURL and PostResponseURL never send it. + // send it as a bearer token. UploadToURL, PostResponseURL, and + // OAuthV2Access never send it. Token string // BaseURL is the Web API base URL. Empty uses DefaultBaseURL. New removes a // trailing slash. diff --git a/adapters/slack/slackapi/doc.go b/adapters/slack/slackapi/doc.go index c82dbee..8141a95 100644 --- a/adapters/slack/slackapi/doc.go +++ b/adapters/slack/slackapi/doc.go @@ -14,19 +14,27 @@ // // New returns a Client for Options; the fields of Options document the // defaults. The typed methods cover the calls that a bot needs: AuthTest, -// PostMessage, UpdateMessage, DeleteMessage, SetAssistantThreadStatus, -// AddReaction, RemoveReaction, UserInfo, ConversationInfo, ConversationHistory, -// and ConversationReplies. 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. +// 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. +// +// 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 +// the app and no bearer token. CreateApp and DeleteApp create and delete an +// app from a Manifest. Call them on a Client with an app configuration token: +// api.WithToken(configToken).CreateApp(ctx, req). // // Web API calls are POSTs with the bearer token and a JSON body. Slack // documents users.info, conversations.info, conversations.history, -// conversations.replies, and files.getUploadURLExternal as GET or form-only -// methods, so the client sends them with a form-encoded body. Call sends a -// url.Values payload form encoded and any other payload as JSON, so it reaches -// form-only methods too. +// conversations.replies, files.info, and files.getUploadURLExternal as GET or +// form-only methods, so the client sends them with a form-encoded body. +// OAuthV2Access, CreateApp, and DeleteApp send a form-encoded body too. Call +// sends a url.Values payload form encoded and any other payload as JSON, so it +// reaches form-only methods too. // // An ok:false response or a non-2xx status returns *APIError. Every method // except DownloadFile retries Slack throttling (HTTP 429, or the ratelimited or @@ -56,8 +64,10 @@ // url_verification request, and gives each other request to its Handler as a // Delivery. // -// ParseEnvelope decodes an Events API payload, and Envelope.MessageEvent -// decodes its app_mention or message event. +// ParseEnvelope decodes an Events API payload. Envelope.MessageEvent decodes +// its app_mention or message event, Envelope.AgentSessionStoppedEvent its +// agent_session_stopped event, and Envelope.ChannelSharedEvent its +// channel_shared event. // // # Socket Mode // diff --git a/adapters/slack/slackapi/envelope.go b/adapters/slack/slackapi/envelope.go index 90166d2..62cd7c5 100644 --- a/adapters/slack/slackapi/envelope.go +++ b/adapters/slack/slackapi/envelope.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" ) // Envelope types of a Slack Events API request. @@ -16,7 +17,7 @@ const ( EnvelopeEventCallback = "event_callback" ) -// Event types that MessageEvent decodes. +// Event types that the event methods of Envelope decode. const ( // EventTypeAppMention is the type of an event for a message that // mentions the app. @@ -24,6 +25,12 @@ const ( // EventTypeMessage is the type of an event for a message in a // conversation that the app is a member of. EventTypeMessage = "message" + // EventTypeAgentSessionStopped is the type of an event for a click on the + // Stop button of an agent session. + EventTypeAgentSessionStopped = "agent_session_stopped" + // EventTypeChannelShared is the type of an event for a channel that is + // shared with another workspace or organization. + EventTypeChannelShared = "channel_shared" ) // Message subtypes of Message.Subtype. @@ -108,6 +115,56 @@ type MessageEvent struct { // another type. It returns an error if the event is missing or is not valid // JSON for its type. func (e *Envelope) MessageEvent() (*MessageEvent, bool, error) { + return decodeEvent[MessageEvent](e, EventTypeAppMention, EventTypeMessage) +} + +// AgentSessionStoppedEvent is an agent_session_stopped event. Slack sends it +// when a user clicks Stop on an agent session in the processing status. Slack +// does not change the status of the session. +type AgentSessionStoppedEvent struct { + // Channel is the ID of the channel of the session. + Channel string `json:"channel,omitempty"` + // ThreadTS is the ts of the parent message of the thread of the session. + ThreadTS string `json:"thread_ts,omitempty"` + // User is the ID of the user who clicked Stop. + User string `json:"user,omitempty"` + // EventTS is the timestamp of the event. + EventTS string `json:"event_ts,omitempty"` +} + +// AgentSessionStoppedEvent decodes the inner event of an EnvelopeEventCallback +// envelope when its type is EventTypeAgentSessionStopped. It returns false and +// errors as MessageEvent does. +func (e *Envelope) AgentSessionStoppedEvent() (*AgentSessionStoppedEvent, bool, error) { + return decodeEvent[AgentSessionStoppedEvent](e, EventTypeAgentSessionStopped) +} + +// ChannelSharedEvent is a channel_shared event. Slack sends it when a channel +// becomes shared, and when another workspace or organization joins a shared +// channel. Slack also sends it for a share between the workspaces of one +// Enterprise Grid organization. +type ChannelSharedEvent struct { + // Channel is the ID of the channel. + Channel string `json:"channel,omitempty"` + // ConnectedTeamID is the ID of the workspace (T) or the organization (E) + // that the channel is now shared with. + ConnectedTeamID string `json:"connected_team_id,omitempty"` + // EventTS is the timestamp of the event. + EventTS string `json:"event_ts,omitempty"` +} + +// ChannelSharedEvent decodes the inner event of an EnvelopeEventCallback +// envelope when its type is EventTypeChannelShared. It returns false and +// errors as MessageEvent does. +func (e *Envelope) ChannelSharedEvent() (*ChannelSharedEvent, bool, error) { + return decodeEvent[ChannelSharedEvent](e, EventTypeChannelShared) +} + +// decodeEvent decodes the inner event of an EnvelopeEventCallback envelope +// into T when the event has one of types. It returns false for an envelope of +// another type and for an event of another type, and an error if the event is +// missing or is not valid JSON for T. +func decodeEvent[T any](e *Envelope, types ...string) (*T, bool, error) { if e.Type != EnvelopeEventCallback { return nil, false, nil } @@ -120,12 +177,12 @@ func (e *Envelope) MessageEvent() (*MessageEvent, bool, error) { if err := json.Unmarshal(e.Event, &head); err != nil { return nil, false, fmt.Errorf("slack: invalid event: %w", err) } - if head.Type != EventTypeAppMention && head.Type != EventTypeMessage { + if !slices.Contains(types, head.Type) { // Other event types can reuse field names with other shapes, for // example an object in "channel", so they are not decoded further. return nil, false, nil } - var ev MessageEvent + var ev T if err := json.Unmarshal(e.Event, &ev); err != nil { return nil, false, fmt.Errorf("slack: invalid %s event: %w", head.Type, err) } diff --git a/adapters/slack/slackapi/envelope_test.go b/adapters/slack/slackapi/envelope_test.go index ec415f7..4fdf5c1 100644 --- a/adapters/slack/slackapi/envelope_test.go +++ b/adapters/slack/slackapi/envelope_test.go @@ -460,3 +460,90 @@ func TestEnvelopeMessageEventErrors(t *testing.T) { }) } } + +func TestEnvelopeAgentSessionStoppedEvent(t *testing.T) { + t.Parallel() + + env := inboundParse(t, inboundEventBody(`{ + "type": "agent_session_stopped", + "channel": "C0CHANNEL1", + "thread_ts": "1700000100.000200", + "user": "U061F7AUR", + "event_ts": "1700000900.001000", + "streaming_message_ts": ["1700000800.000900"] + }`)) + got, ok, err := env.AgentSessionStoppedEvent() + if err != nil || !ok { + t.Fatalf("AgentSessionStoppedEvent() = %+v, %t, %v, want an event", got, ok, err) + } + want := &slackapi.AgentSessionStoppedEvent{ + Channel: "C0CHANNEL1", + ThreadTS: "1700000100.000200", + User: "U061F7AUR", + EventTS: "1700000900.001000", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("event mismatch\ngot: %+v\nwant: %+v", got, want) + } + if ev, ok, err := env.MessageEvent(); ev != nil || ok || err != nil { + t.Errorf("MessageEvent() = %+v, %t, %v, want no event", ev, ok, err) + } +} + +func TestEnvelopeChannelSharedEvent(t *testing.T) { + t.Parallel() + + env := inboundParse(t, inboundEventBody(`{ + "type": "channel_shared", + "connected_team_id": "E163Q94DX", + "channel": "C0CHANNEL1", + "event_ts": "1700000900.001000" + }`)) + got, ok, err := env.ChannelSharedEvent() + if err != nil || !ok { + t.Fatalf("ChannelSharedEvent() = %+v, %t, %v, want an event", got, ok, err) + } + want := &slackapi.ChannelSharedEvent{ + Channel: "C0CHANNEL1", + ConnectedTeamID: "E163Q94DX", + EventTS: "1700000900.001000", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("event mismatch\ngot: %+v\nwant: %+v", got, want) + } + if ev, ok, err := env.AgentSessionStoppedEvent(); ev != nil || ok || err != nil { + t.Errorf("AgentSessionStoppedEvent() = %+v, %t, %v, want no event", ev, ok, err) + } +} + +func TestEnvelopeEventDecodersSkipAndReject(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + eventType string + decode func(*slackapi.Envelope) (any, bool, error) + }{ + {"AgentSessionStoppedEvent", slackapi.EventTypeAgentSessionStopped, func(e *slackapi.Envelope) (any, bool, error) { return e.AgentSessionStoppedEvent() }}, + {"ChannelSharedEvent", slackapi.EventTypeChannelShared, func(e *slackapi.Envelope) (any, bool, error) { return e.ChannelSharedEvent() }}, + } + skipped := []string{ + inboundEventBody(`{"type":"message","channel":"C0CHANNEL1","user":"U061F7AUR","text":"hi","ts":"1700000200.000300"}`), + `{"type":"app_rate_limited","team_id":"T0001","minute_rate_limited":1700001060,"api_app_id":"A0APP1234"}`, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + for _, body := range skipped { + env := inboundParse(t, body) + if got, ok, err := tt.decode(env); !reflect.ValueOf(got).IsNil() || ok || err != nil { + t.Errorf("%s() of %s = %+v, %t, %v, want no event", tt.name, env.Type, got, ok, err) + } + } + env := inboundParse(t, inboundEventBody(`{"type":"`+tt.eventType+`","channel":{"id":"C0CHANNEL1"}}`)) + if got, ok, err := tt.decode(env); err == nil || ok || !reflect.ValueOf(got).IsNil() { + t.Errorf("%s() of an object channel = %+v, %t, %v, want an error", tt.name, got, ok, err) + } + }) + } +} diff --git a/adapters/slack/slackapi/files.go b/adapters/slack/slackapi/files.go index c7a729e..f6aefd4 100644 --- a/adapters/slack/slackapi/files.go +++ b/adapters/slack/slackapi/files.go @@ -97,6 +97,55 @@ type UploadFileRequest struct { InitialComment string } +// FileInfoRequest is the request of files.info. +type FileInfoRequest struct { + // File is the file ID. + File string +} + +// FileInfoResponse is the file that files.info returns, with its shares. +type FileInfoResponse struct { + File + // Shares are the messages that share the file, by channel. + Shares FileShares `json:"shares,omitzero"` + // HasMoreShares is true when Shares does not hold every share of the + // file. + HasMoreShares bool `json:"has_more_shares,omitzero"` +} + +// FileShares are the shares of a File, by channel ID. +type FileShares struct { + // Public are the shares in public channels. + Public map[string][]FileShare `json:"public,omitempty"` + // Private are the shares in private channels, DMs, and group DMs. + Private map[string][]FileShare `json:"private,omitempty"` +} + +// FileShare is one message that shares a File. +type FileShare struct { + // TS is the ts of the message that shares the file. + TS string `json:"ts,omitempty"` + // ThreadTS is the ts of the thread root of the message that shares the + // file, as in Message.ThreadTS. It is empty when the message is not in a + // thread. + ThreadTS string `json:"thread_ts,omitempty"` +} + +// FileInfo calls files.info and returns the file with its shares. It sends a +// form-encoded body. Slack returns the error code "file_not_found" for an +// unknown file. +func (c *Client) FileInfo(ctx context.Context, req FileInfoRequest) (*FileInfoResponse, error) { + values := url.Values{} + setFormString(values, "file", req.File) + var resp struct { + File FileInfoResponse `json:"file"` + } + if err := c.Call(ctx, "files.info", values, &resp); err != nil { + return nil, err + } + return &resp.File, nil +} + // GetUploadURLExternal calls files.getUploadURLExternal to get an upload URL // and an ID for a new file. It sends a form-encoded body. func (c *Client) GetUploadURLExternal(ctx context.Context, req GetUploadURLExternalRequest) (*GetUploadURLExternalResponse, error) { diff --git a/adapters/slack/slackapi/files_test.go b/adapters/slack/slackapi/files_test.go index 6f6b63a..769d45a 100644 --- a/adapters/slack/slackapi/files_test.go +++ b/adapters/slack/slackapi/files_test.go @@ -766,3 +766,62 @@ func TestDownloadFile(t *testing.T) { } }) } + +func TestFileInfo(t *testing.T) { + t.Parallel() + + srv := slackapitest.NewServer(t) + srv.Respond("files.info", json.RawMessage(`{ + "ok": true, + "file": { + "id": "F1", + "name": "report.txt", + "mimetype": "text/plain", + "size": 3, + "url_private_download": "https://files.slack.com/files-pri/T1-F1/download/report.txt", + "shares": { + "public": { + "C1": [ + {"reply_users": ["U1"], "reply_count": 1, "ts": "1700000000.000200", "thread_ts": "1700000000.000001", "channel_name": "general", "team_id": "T1"}, + {"reply_users": [], "reply_count": 0, "ts": "1700000000.000300"} + ] + }, + "private": { + "D1": [{"reply_users": [], "reply_count": 0, "ts": "1700000000.000400"}] + } + }, + "channels": ["C1"], + "ims": ["D1"], + "has_more_shares": true + }, + "comments": [], + "response_metadata": {"next_cursor": ""} + }`)) + + got, err := methodsNewClient(srv).FileInfo(t.Context(), slackapi.FileInfoRequest{File: "F1"}) + if err != nil { + t.Fatalf("FileInfo: %v", err) + } + methodsCheckForm(t, srv, "files.info", url.Values{"file": {"F1"}}) + methodsCheckEqual(t, "file", got, &slackapi.FileInfoResponse{ + File: slackapi.File{ + ID: "F1", + Name: "report.txt", + MIMEType: "text/plain", + Size: 3, + URLPrivateDownload: "https://files.slack.com/files-pri/T1-F1/download/report.txt", + }, + Shares: slackapi.FileShares{ + Public: map[string][]slackapi.FileShare{ + "C1": { + {TS: "1700000000.000200", ThreadTS: "1700000000.000001"}, + {TS: "1700000000.000300"}, + }, + }, + Private: map[string][]slackapi.FileShare{ + "D1": {{TS: "1700000000.000400"}}, + }, + }, + HasMoreShares: true, + }) +} diff --git a/adapters/slack/slackapi/form_test.go b/adapters/slack/slackapi/form_test.go index c6d285c..98ac2c0 100644 --- a/adapters/slack/slackapi/form_test.go +++ b/adapters/slack/slackapi/form_test.go @@ -129,6 +129,24 @@ func TestMethodsReturnAPIError(t *testing.T) { {"files.completeUploadExternal", func(ctx context.Context, c *slackapi.Client) (any, error) { return c.CompleteUploadExternal(ctx, slackapi.CompleteUploadExternalRequest{}) }}, + {"oauth.v2.access", func(ctx context.Context, c *slackapi.Client) (any, error) { + return c.OAuthV2Access(ctx, slackapi.OAuthV2AccessRequest{}) + }}, + {"chat.postEphemeral", func(ctx context.Context, c *slackapi.Client) (any, error) { + return c.PostEphemeral(ctx, slackapi.PostEphemeralRequest{}) + }}, + {"files.info", func(ctx context.Context, c *slackapi.Client) (any, error) { + return c.FileInfo(ctx, slackapi.FileInfoRequest{}) + }}, + {"agents.sessions.setStatus", func(ctx context.Context, c *slackapi.Client) (any, error) { + return nil, c.SetAgentSessionStatus(ctx, slackapi.SetAgentSessionStatusRequest{}) + }}, + {"apps.manifest.create", func(ctx context.Context, c *slackapi.Client) (any, error) { + return c.CreateApp(ctx, slackapi.CreateAppRequest{}) + }}, + {"apps.manifest.delete", func(ctx context.Context, c *slackapi.Client) (any, error) { + return nil, c.DeleteApp(ctx, slackapi.DeleteAppRequest{}) + }}, } for _, tt := range tests { t.Run(tt.method, func(t *testing.T) { diff --git a/adapters/slack/slackapi/manifest.go b/adapters/slack/slackapi/manifest.go index 8a92328..1e40208 100644 --- a/adapters/slack/slackapi/manifest.go +++ b/adapters/slack/slackapi/manifest.go @@ -45,6 +45,9 @@ type ManifestFeatures struct { AppHome *ManifestAppHome `json:"app_home,omitempty"` // BotUser is the optional bot_user section. BotUser *ManifestBotUser `json:"bot_user,omitempty"` + // AgentView is the optional agent_view section. It turns on the agent + // feature of the app. + AgentView *ManifestAgentView `json:"agent_view,omitempty"` } // ManifestAppHome is the features.app_home section of a Manifest. @@ -67,8 +70,18 @@ type ManifestBotUser struct { AlwaysOnline bool `json:"always_online"` } +// ManifestAgentView is the features.agent_view section of a Manifest. +type ManifestAgentView struct { + // AgentDescription is the required description of the agent. Slack + // accepts at most 300 characters. + AgentDescription string `json:"agent_description"` +} + // ManifestOAuthConfig is the oauth_config section of a Manifest. type ManifestOAuthConfig struct { + // RedirectURLs are the optional OAuth redirect URLs of the app. Slack + // accepts at most 1000. + RedirectURLs []string `json:"redirect_urls,omitempty"` // Scopes is the optional scopes section. Scopes *ManifestScopes `json:"scopes,omitempty"` } diff --git a/adapters/slack/slackapi/manifest_test.go b/adapters/slack/slackapi/manifest_test.go index 6d5a68b..c77c4ff 100644 --- a/adapters/slack/slackapi/manifest_test.go +++ b/adapters/slack/slackapi/manifest_test.go @@ -18,9 +18,11 @@ const formatsManifestJSON = `{ }, "features": { "app_home": {"home_tab_enabled": false, "messages_tab_enabled": false, "messages_tab_read_only_enabled": false}, - "bot_user": {"display_name": "coder", "always_online": true} + "bot_user": {"display_name": "coder", "always_online": true}, + "agent_view": {"agent_description": "Mention me in a channel to start a Coder Agents chat."} }, "oauth_config": { + "redirect_urls": ["https://coder.example.com/api/v2/slack/callback"], "scopes": {"bot": ["app_mentions:read", "chat:write", "users:read"]} }, "settings": { @@ -50,9 +52,13 @@ func formatsManifest() slackapi.Manifest { Features: &slackapi.ManifestFeatures{ AppHome: &slackapi.ManifestAppHome{}, BotUser: &slackapi.ManifestBotUser{DisplayName: "coder", AlwaysOnline: true}, + AgentView: &slackapi.ManifestAgentView{ + AgentDescription: "Mention me in a channel to start a Coder Agents chat.", + }, }, OAuthConfig: &slackapi.ManifestOAuthConfig{ - Scopes: &slackapi.ManifestScopes{Bot: []string{"app_mentions:read", "chat:write", "users:read"}}, + RedirectURLs: []string{"https://coder.example.com/api/v2/slack/callback"}, + Scopes: &slackapi.ManifestScopes{Bot: []string{"app_mentions:read", "chat:write", "users:read"}}, }, Settings: &slackapi.ManifestSettings{ EventSubscriptions: &slackapi.ManifestEventSubscriptions{ @@ -102,15 +108,18 @@ func TestManifestMinimal(t *testing.T) { manifest: slackapi.Manifest{ Metadata: &slackapi.ManifestMetadata{}, DisplayInformation: slackapi.ManifestDisplayInformation{Name: "bot"}, - Features: &slackapi.ManifestFeatures{BotUser: &slackapi.ManifestBotUser{DisplayName: "bot"}}, - OAuthConfig: &slackapi.ManifestOAuthConfig{Scopes: &slackapi.ManifestScopes{}}, + Features: &slackapi.ManifestFeatures{ + BotUser: &slackapi.ManifestBotUser{DisplayName: "bot"}, + AgentView: &slackapi.ManifestAgentView{}, + }, + OAuthConfig: &slackapi.ManifestOAuthConfig{Scopes: &slackapi.ManifestScopes{}}, Settings: &slackapi.ManifestSettings{ EventSubscriptions: &slackapi.ManifestEventSubscriptions{}, Interactivity: &slackapi.ManifestInteractivity{}, }, }, want: `{"_metadata":{},"display_information":{"name":"bot"},` + - `"features":{"bot_user":{"display_name":"bot","always_online":false}},` + + `"features":{"bot_user":{"display_name":"bot","always_online":false},"agent_view":{"agent_description":""}},` + `"oauth_config":{"scopes":{}},` + `"settings":{"event_subscriptions":{},"interactivity":{"is_enabled":false},` + `"org_deploy_enabled":false,"socket_mode_enabled":false,"token_rotation_enabled":false}}`, diff --git a/adapters/slack/slackapi/oauth.go b/adapters/slack/slackapi/oauth.go new file mode 100644 index 0000000..f47e762 --- /dev/null +++ b/adapters/slack/slackapi/oauth.go @@ -0,0 +1,75 @@ +package slackapi + +import ( + "context" + "net/url" +) + +// OAuthV2AccessRequest is the request of oauth.v2.access. +type OAuthV2AccessRequest struct { + // ClientID is the OAuth client ID of the app. + ClientID string + // ClientSecret is the OAuth client secret of the app. + ClientSecret string + // Code is the code that Slack sent to the redirect URL of the app. + Code string + // RedirectURI is the redirect_uri of the authorize request. Slack requires + // it when the authorize request had one. Empty sends none. + RedirectURI string +} + +// OAuthV2AccessResponse is the response of oauth.v2.access for a bot install. +type OAuthV2AccessResponse struct { + // AccessToken is the bot token (xoxb-). + AccessToken string `json:"access_token"` + // TokenType is "bot" for a bot token. + TokenType string `json:"token_type"` + // Scope is the comma-separated list of the scopes of the bot token. + Scope string `json:"scope"` + // BotUserID is the user ID of the bot. + BotUserID string `json:"bot_user_id"` + // AppID is the ID of the app. + AppID string `json:"app_id"` + // Team is the workspace that installed the app. Slack sends no team for an + // install on an Enterprise Grid organization. + Team OAuthTeam `json:"team"` + // Enterprise is the Enterprise Grid organization of the install. It is + // empty outside Enterprise Grid. + Enterprise OAuthTeam `json:"enterprise"` + // IsEnterpriseInstall is true for an install on a whole Enterprise Grid + // organization. + IsEnterpriseInstall bool `json:"is_enterprise_install"` + // RefreshToken refreshes AccessToken when the app has token rotation on. + // It is empty otherwise. + RefreshToken string `json:"refresh_token"` + // ExpiresIn is the lifetime of AccessToken in seconds when the app has + // token rotation on. It is zero otherwise. + ExpiresIn int `json:"expires_in"` +} + +// OAuthTeam is the ID and the name of a workspace or an Enterprise Grid +// organization in an oauth.v2.access response. +type OAuthTeam struct { + // ID is the team ID (T) or the organization ID (E). + ID string `json:"id"` + // Name is the name of the workspace or the organization. + Name string `json:"name"` +} + +// OAuthV2Access calls oauth.v2.access to exchange the code of an install for a +// bot token. It sends the client ID and the client secret in a form-encoded +// body, and never sends the token of c, so any Client can call it. Slack +// returns the error code "invalid_code" for a code that is unknown, expired, or +// already used, and "bad_client_secret" for a wrong client secret. +func (c *Client) OAuthV2Access(ctx context.Context, req OAuthV2AccessRequest) (*OAuthV2AccessResponse, error) { + values := url.Values{} + setFormString(values, "client_id", req.ClientID) + setFormString(values, "client_secret", req.ClientSecret) + setFormString(values, "code", req.Code) + setFormString(values, "redirect_uri", req.RedirectURI) + var resp OAuthV2AccessResponse + if err := c.WithToken("").Call(ctx, "oauth.v2.access", values, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/adapters/slack/slackapi/oauth_test.go b/adapters/slack/slackapi/oauth_test.go new file mode 100644 index 0000000..138910f --- /dev/null +++ b/adapters/slack/slackapi/oauth_test.go @@ -0,0 +1,82 @@ +package slackapi_test + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/coder/chat/adapters/slack/slackapi" + "github.com/coder/chat/adapters/slack/slackapi/slackapitest" +) + +func TestOAuthV2Access(t *testing.T) { + t.Parallel() + + t.Run("Request", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.Respond("oauth.v2.access", json.RawMessage(`{ + "ok": true, + "access_token": "xoxb-installed", + "token_type": "bot", + "scope": "app_mentions:read,chat:write", + "bot_user_id": "U0BOT", + "app_id": "A0APP", + "team": {"name": "Acme", "id": "T0TEAM"}, + "enterprise": null, + "authed_user": {"id": "U0ADMIN"}, + "is_enterprise_install": false + }`)) + + resp, err := methodsNewClient(srv).OAuthV2Access(t.Context(), slackapi.OAuthV2AccessRequest{ + ClientID: "123.456", + ClientSecret: "client-secret", + Code: "install-code", + RedirectURI: "https://coder.example.com/callback", + }) + if err != nil { + t.Fatalf("OAuthV2Access: %v", err) + } + calls := srv.Calls("oauth.v2.access") + if len(calls) != 1 { + t.Fatalf("oauth.v2.access calls = %d, want 1", len(calls)) + } + if got := calls[0].Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want none", got) + } + methodsCheckEqual(t, "form", calls[0].Form, url.Values{ + "client_id": {"123.456"}, + "client_secret": {"client-secret"}, + "code": {"install-code"}, + "redirect_uri": {"https://coder.example.com/callback"}, + }) + methodsCheckEqual(t, "response", resp, &slackapi.OAuthV2AccessResponse{ + AccessToken: "xoxb-installed", + TokenType: "bot", + Scope: "app_mentions:read,chat:write", + BotUserID: "U0BOT", + AppID: "A0APP", + Team: slackapi.OAuthTeam{ID: "T0TEAM", Name: "Acme"}, + }) + }) + + t.Run("NoRedirectURI", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.Respond("oauth.v2.access", json.RawMessage(`{"ok":true}`)) + + _, err := methodsNewClient(srv).OAuthV2Access(t.Context(), slackapi.OAuthV2AccessRequest{ + ClientID: "123.456", + ClientSecret: "client-secret", + Code: "install-code", + }) + if err != nil { + t.Fatalf("OAuthV2Access: %v", err) + } + methodsCheckEqual(t, "form", srv.Calls("oauth.v2.access")[0].Form, url.Values{ + "client_id": {"123.456"}, + "client_secret": {"client-secret"}, + "code": {"install-code"}, + }) + }) +} diff --git a/adapters/slack/slackapi/status.go b/adapters/slack/slackapi/status.go index 96a4d25..e3da7db 100644 --- a/adapters/slack/slackapi/status.go +++ b/adapters/slack/slackapi/status.go @@ -23,3 +23,32 @@ type SetAssistantThreadStatusRequest struct { func (c *Client) SetAssistantThreadStatus(ctx context.Context, req SetAssistantThreadStatusRequest) error { return c.Call(ctx, "assistant.threads.setStatus", req, nil) } + +// Agent session statuses for SetAgentSessionStatusRequest.Status. +const ( + // AgentSessionActive marks an open session where the agent does not work. + AgentSessionActive = "active" + // AgentSessionProcessing shows that the agent works, with a Stop button. + // A click on Stop sends an agent_session_stopped event. + AgentSessionProcessing = "processing" +) + +// SetAgentSessionStatusRequest is the request of agents.sessions.setStatus. +type SetAgentSessionStatusRequest struct { + // ChannelID is the ID of the channel of the session. + ChannelID string `json:"channel_id"` + // ThreadTS is the ts of the parent message of the thread of the session. + // Leave it empty only for a session channel, which has no thread. + ThreadTS string `json:"thread_ts,omitempty"` + // Status is the new status, for example AgentSessionProcessing. Slack also + // accepts "suspended" and "closed". + Status string `json:"status"` +} + +// SetAgentSessionStatus calls agents.sessions.setStatus. Slack creates the +// session when the thread has none. The app must be a member of the channel. +// In a workspace without the agent feature, Slack returns the error code +// "feature_disabled". +func (c *Client) SetAgentSessionStatus(ctx context.Context, req SetAgentSessionStatusRequest) error { + return c.Call(ctx, "agents.sessions.setStatus", req, nil) +} diff --git a/adapters/slack/slackapi/status_test.go b/adapters/slack/slackapi/status_test.go index b342a83..b5956fe 100644 --- a/adapters/slack/slackapi/status_test.go +++ b/adapters/slack/slackapi/status_test.go @@ -2,6 +2,7 @@ package slackapi_test import ( "encoding/json" + "errors" "testing" "github.com/coder/chat/adapters/slack/slackapi" @@ -52,3 +53,49 @@ func TestSetAssistantThreadStatus(t *testing.T) { }`) }) } + +func TestSetAgentSessionStatus(t *testing.T) { + t.Parallel() + + t.Run("Request", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.Respond("agents.sessions.setStatus", json.RawMessage(`{"ok":true,"status":"processing","agent_status":"processing"}`)) + + err := methodsNewClient(srv).SetAgentSessionStatus(t.Context(), slackapi.SetAgentSessionStatusRequest{ + ChannelID: "C1", + ThreadTS: "1700000000.000001", + Status: slackapi.AgentSessionProcessing, + }) + if err != nil { + t.Fatalf("SetAgentSessionStatus: %v", err) + } + methodsCheckJSONBody(t, srv, "agents.sessions.setStatus", `{ + "channel_id": "C1", + "thread_ts": "1700000000.000001", + "status": "processing" + }`) + }) + + t.Run("FeatureDisabled", func(t *testing.T) { + t.Parallel() + srv := slackapitest.NewServer(t) + srv.Respond("agents.sessions.setStatus", json.RawMessage(`{"ok":false,"error":"feature_disabled"}`)) + + err := methodsNewClient(srv).SetAgentSessionStatus(t.Context(), slackapi.SetAgentSessionStatusRequest{ + ChannelID: "C1", + ThreadTS: "1700000000.000001", + Status: slackapi.AgentSessionActive, + }) + apiErr, ok := errors.AsType[*slackapi.APIError](err) + if !ok { + t.Fatalf("SetAgentSessionStatus error = %v, want *slackapi.APIError", err) + } + if apiErr.Code != "feature_disabled" { + t.Errorf("APIError.Code = %q, want %q", apiErr.Code, "feature_disabled") + } + if calls := srv.Calls("agents.sessions.setStatus"); len(calls) != 1 { + t.Errorf("agents.sessions.setStatus calls = %d, want 1", len(calls)) + } + }) +} diff --git a/docs/reference.md b/docs/reference.md index cd6fb83..8154ac7 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -716,12 +716,14 @@ The client methods and how each one sends its request: | Method | Slack call | Request | | --- | --- | --- | | `AuthTest` | `auth.test` | JSON | -| `PostMessage`, `UpdateMessage`, `DeleteMessage` | `chat.postMessage`, `chat.update`, `chat.delete` | JSON | +| `PostMessage`, `PostEphemeral`, `UpdateMessage`, `DeleteMessage` | `chat.postMessage`, `chat.postEphemeral`, `chat.update`, `chat.delete` | JSON | | `SetAssistantThreadStatus` | `assistant.threads.setStatus` | JSON | +| `SetAgentSessionStatus` | `agents.sessions.setStatus` | JSON | | `AddReaction`, `RemoveReaction` | `reactions.add`, `reactions.remove` | JSON | | `UserInfo` | `users.info` | form | | `ConversationInfo` | `conversations.info` | form | | `ConversationHistory`, `ConversationReplies` | `conversations.history`, `conversations.replies` | form; one `MessagePage` per call, send `NextCursor` as `Cursor` for the next page | +| `FileInfo` | `files.info` | form; returns the file with its `Shares` and `HasMoreShares` | | `GetUploadURLExternal` | `files.getUploadURLExternal` | form | | `UploadToURL` | the `upload_url` from `GetUploadURLExternal` | raw bytes, no token | | `CompleteUploadExternal` | `files.completeUploadExternal` | JSON | @@ -729,11 +731,17 @@ The client methods and how each one sends its request: | `DownloadFile` | a file URL such as `url_private_download` | GET, no retry | | `Call` | any Web API method (the escape hatch) | form for a `url.Values` payload, else JSON | | `PostResponseURL` | the `response_url` of a command or an interaction | JSON, no token | +| `OAuthV2Access` | `oauth.v2.access` | form with the client ID and the client secret, no token | +| `CreateApp`, `DeleteApp` | `apps.manifest.create`, `apps.manifest.delete` | form, with an app configuration token; `CreateApp` sends the `Manifest` as a JSON string | Requests are POSTs with the bearer token unless the table says otherwise. Slack documents `users.info`, `conversations.info`, `conversations.history`, -`conversations.replies`, and `files.getUploadURLExternal` as GET or form-only -methods, so the client sends them form encoded. Every method except +`conversations.replies`, `files.info`, and `files.getUploadURLExternal` as GET +or form-only methods, so the client sends them form encoded. Call `CreateApp` +and `DeleteApp` on a client with an app configuration token (`xoxe.xoxp-`), +for example `client.WithToken(configToken).CreateApp(ctx, req)`. +`SetAgentSessionStatus` fails with the `APIError` code `feature_disabled` in a +workspace without the Slack agent feature. Every method except `DownloadFile` retries Slack throttling (HTTP 429, or the error code `ratelimited` or `rate_limited`) within the `RetryPolicy`. It honors `Retry-After` up to `MaxDelay` and never sleeps past the context deadline @@ -800,6 +808,10 @@ Inbound deliveries: with any subtype, and returns false for other envelopes and events. For `message_changed` and `message_deleted`, `NewMessage` and `PreviousMessage` hold the changed message. +- `(*Envelope).AgentSessionStoppedEvent()` decodes an `agent_session_stopped` + event (a click on Stop in an agent session), and + `(*Envelope).ChannelSharedEvent()` decodes a `channel_shared` event. Each + returns false for other envelopes and events, as `MessageEvent` does. Socket Mode: @@ -855,7 +867,8 @@ Formats: - `UserMentions(text)` returns the user IDs of `<@U123>` and `<@U123|label>` mentions. `ReplaceUserMentions(text, name)` replaces each mention with `@` and the name that `name` returns. -- `Manifest` and its section types encode a Slack app manifest for a bot app. +- `Manifest` and its section types encode a Slack app manifest for a bot app, + with its OAuth redirect URLs and the agent feature (`features.agent_view`). ### Testing With slackapitest From d7c3e7f5bbbb18cfe1e8193654b75329fd99dc87 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 20 Oct 2026 06:29:52 +0000 Subject: [PATCH 2/2] test(slackapi): live tests for the bot Web API methods TestLiveApp creates an app from a manifest and deletes it, after it checks the invalid_manifest error list. TestLiveOAuth exchanges a code from a real install. TestLive also checks FileInfo shares and PostEphemeral. Document that agents.sessions.setStatus returns not_authorized when the manifest has no features.agent_view. --- adapters/slack/slackapi/live_test.go | 153 +++++++++++++++++++++++++++ adapters/slack/slackapi/status.go | 5 +- docs/reference.md | 3 +- 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/adapters/slack/slackapi/live_test.go b/adapters/slack/slackapi/live_test.go index 2da2c3b..fb9b8b0 100644 --- a/adapters/slack/slackapi/live_test.go +++ b/adapters/slack/slackapi/live_test.go @@ -24,6 +24,28 @@ // scopes chat:write and channels:history. Run it with: // // SLACKAPI_LIVE_TOKEN=xoxb-... SLACKAPI_LIVE_APP_TOKEN=xapp-... SLACKAPI_LIVE_CHANNEL=C... go test -run TestLiveSocketMode -v -count=1 -timeout 3m ./adapters/slack/slackapi/ +// +// TestLiveApp creates an app from a manifest and deletes it. It needs only: +// +// SLACKAPI_LIVE_CONFIG_TOKEN app configuration token (xoxe.xoxp-...) +// +// Get the token on https://api.slack.com/apps. It expires after 12 hours. Run it with: +// +// SLACKAPI_LIVE_CONFIG_TOKEN=xoxe.xoxp-... go test -run TestLiveApp -v -count=1 ./adapters/slack/slackapi/ +// +// TestLiveOAuth exchanges an OAuth code for a bot token. Slack gives a code +// only after a user installs the app in a browser, so the test needs: +// +// SLACKAPI_LIVE_OAUTH_CLIENT_ID client ID of an app with a redirect URL +// SLACKAPI_LIVE_OAUTH_CLIENT_SECRET client secret of the app +// SLACKAPI_LIVE_OAUTH_REDIRECT_URI the redirect URL, if the authorize URL has one +// SLACKAPI_LIVE_OAUTH_CODE the code parameter of the redirect +// +// Open https://slack.com/oauth/v2/authorize?client_id=...&scope=chat:write&redirect_uri=... +// in a browser and click Allow. The browser goes to the redirect URL with the +// code. The code works once and expires after 10 minutes. Then run: +// +// SLACKAPI_LIVE_OAUTH_CLIENT_ID=... SLACKAPI_LIVE_OAUTH_CLIENT_SECRET=... SLACKAPI_LIVE_OAUTH_REDIRECT_URI=https://... SLACKAPI_LIVE_OAUTH_CODE=... go test -run TestLiveOAuth -v -count=1 ./adapters/slack/slackapi/ package slackapi_test @@ -246,6 +268,130 @@ func TestLive(t *testing.T) { t.Fatalf("step 10 DownloadFile: got %q, want %q", downloaded.Bytes(), content) } t.Logf("step 10 DownloadFile: %d bytes match the upload, %d redirects %v", n, len(redirects), redirects) + + info, err := client.FileInfo(ctx, slackapi.FileInfoRequest{File: uploaded.ID}) + if err != nil { + t.Fatalf("step 11 FileInfo: %s", liveErr(err)) + } + shares := info.Shares.Public[channel] + if found && !slices.Contains(shares, slackapi.FileShare{TS: shareTS, ThreadTS: parent.TS}) { + t.Fatalf("step 11 FileInfo: shares in %s are %+v, want the message %s in the thread %s", channel, shares, shareTS, parent.TS) + } + t.Logf("step 11 FileInfo: file %s, shares in %s %+v, has more shares %t", info.ID, channel, shares, info.HasMoreShares) + + ephemeral, err := client.PostEphemeral(ctx, slackapi.PostEphemeralRequest{ + Channel: channel, + User: auth.UserID, + ThreadTS: parent.TS, + MarkdownText: "Ephemeral reply sent with **markdown_text**. Only the bot user sees it.", + }) + if err != nil { + t.Fatalf("step 12 PostEphemeral: %s", liveErr(err)) + } + t.Logf("step 12 PostEphemeral to the bot user: message ts %s", ephemeral.MessageTS) +} + +func TestLiveApp(t *testing.T) { + t.Parallel() + + configToken := os.Getenv("SLACKAPI_LIVE_CONFIG_TOKEN") + if configToken == "" { + t.Skip("set SLACKAPI_LIVE_CONFIG_TOKEN to run the live app smoke test") + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + client := slackapi.New(slackapi.Options{Token: configToken}) + + scopes := &slackapi.ManifestScopes{Bot: []string{"chat:write", "channels:read"}} + manifest := slackapi.Manifest{ + DisplayInformation: slackapi.ManifestDisplayInformation{Name: fmt.Sprintf("slackapi live %d", time.Now().Unix())}, + Features: &slackapi.ManifestFeatures{ + BotUser: &slackapi.ManifestBotUser{DisplayName: "slackapi-live"}, + AgentView: &slackapi.ManifestAgentView{AgentDescription: "Smoke test of slackapi."}, + }, + OAuthConfig: &slackapi.ManifestOAuthConfig{ + RedirectURLs: []string{"https://localhost/slack/oauth/callback"}, + Scopes: scopes, + }, + Settings: &slackapi.ManifestSettings{ + EventSubscriptions: &slackapi.ManifestEventSubscriptions{BotEvents: []string{"agent_session_stopped", "app_mention", "channel_shared"}}, + SocketModeEnabled: true, + }, + } + + created, err := client.CreateApp(ctx, slackapi.CreateAppRequest{Manifest: manifest}) + if err == nil { + liveDeleteAppOnCleanup(t, client, created.AppID) + t.Fatalf("step 1 CreateApp without app_mentions:read: created the app %s, want invalid_manifest", created.AppID) + } + type manifestError struct { + Code string `json:"code"` + Pointer string `json:"pointer"` + } + var invalid struct { + Errors []manifestError `json:"errors"` + } + if apiErr, ok := errors.AsType[*slackapi.APIError](err); ok && apiErr.Code == "invalid_manifest" { + _ = json.Unmarshal(apiErr.Raw, &invalid) + } + if !slices.ContainsFunc(invalid.Errors, func(e manifestError) bool { return e.Code == "app_mention_event_missing_scope" }) { + t.Fatalf("step 1 CreateApp without app_mentions:read: %s, errors %+v, want invalid_manifest with app_mention_event_missing_scope", liveErr(err), invalid.Errors) + } + t.Logf("step 1 CreateApp without app_mentions:read: invalid_manifest, errors %+v", invalid.Errors) + + scopes.Bot = append(scopes.Bot, "app_mentions:read") + created, err = client.CreateApp(ctx, slackapi.CreateAppRequest{Manifest: manifest}) + if err != nil { + t.Fatalf("step 2 CreateApp: %s", liveErr(err)) + } + liveDeleteAppOnCleanup(t, client, created.AppID) + creds := created.Credentials + if created.AppID == "" || creds.ClientID == "" || creds.ClientSecret == "" || creds.SigningSecret == "" { + t.Fatalf("step 2 CreateApp: app ID %q, client ID %q, client secret set %t, signing secret set %t, want all set", created.AppID, creds.ClientID, creds.ClientSecret != "", creds.SigningSecret != "") + } + t.Logf("step 2 CreateApp: app %s, client ID %s, client secret and signing secret set", created.AppID, creds.ClientID) +} + +func TestLiveOAuth(t *testing.T) { + t.Parallel() + + req := slackapi.OAuthV2AccessRequest{ + ClientID: os.Getenv("SLACKAPI_LIVE_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("SLACKAPI_LIVE_OAUTH_CLIENT_SECRET"), + Code: os.Getenv("SLACKAPI_LIVE_OAUTH_CODE"), + RedirectURI: os.Getenv("SLACKAPI_LIVE_OAUTH_REDIRECT_URI"), + } + if req.ClientID == "" || req.ClientSecret == "" || req.Code == "" { + t.Skip("set SLACKAPI_LIVE_OAUTH_CLIENT_ID, SLACKAPI_LIVE_OAUTH_CLIENT_SECRET, and SLACKAPI_LIVE_OAUTH_CODE to run the live OAuth smoke test") + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + client := slackapi.New(slackapi.Options{}) + + resp, err := client.OAuthV2Access(ctx, req) + if err != nil { + t.Fatalf("step 1 OAuthV2Access: %s", liveErr(err)) + } + if resp.AccessToken == "" || resp.TokenType != "bot" || resp.BotUserID == "" || resp.AppID == "" || resp.Team.ID == "" && resp.Enterprise.ID == "" { + t.Fatalf("step 1 OAuthV2Access: access token set %t, token type %q, bot user %q, app %q, team %q, enterprise %q, want all set", resp.AccessToken != "", resp.TokenType, resp.BotUserID, resp.AppID, resp.Team.ID, resp.Enterprise.ID) + } + t.Logf("step 1 OAuthV2Access: token type %q, scope %q, bot user %s, app %s, team %s %q, enterprise %q, enterprise install %t, refresh token set %t, expires in %d s", + resp.TokenType, resp.Scope, resp.BotUserID, resp.AppID, resp.Team.ID, resp.Team.Name, resp.Enterprise.ID, resp.IsEnterpriseInstall, resp.RefreshToken != "", resp.ExpiresIn) + + auth, err := client.WithToken(resp.AccessToken).AuthTest(ctx) + if err != nil { + t.Fatalf("step 2 AuthTest with the new token: %s", liveErr(err)) + } + if auth.UserID != resp.BotUserID { + t.Fatalf("step 2 AuthTest with the new token: user %s, want the bot user %s", auth.UserID, resp.BotUserID) + } + t.Logf("step 2 AuthTest with the new token: user %s, team %s", auth.UserID, auth.TeamID) + + _, err = client.OAuthV2Access(ctx, req) + if apiErr, ok := errors.AsType[*slackapi.APIError](err); !ok || apiErr.Code != "invalid_code" { + t.Fatalf("step 3 OAuthV2Access with the used code: got %v, want invalid_code", err) + } + t.Logf("step 3 OAuthV2Access with the used code: invalid_code") } func TestLiveSocketMode(t *testing.T) { @@ -542,6 +688,13 @@ func liveDeleteFileOnCleanup(t *testing.T, client *slackapi.Client, fileID strin }) } +func liveDeleteAppOnCleanup(t *testing.T, client *slackapi.Client, appID string) { + t.Helper() + liveCleanup(t, "DeleteApp "+appID, func(ctx context.Context) error { + return client.DeleteApp(ctx, slackapi.DeleteAppRequest{AppID: appID}) + }) +} + // liveCleanup registers fn as a cleanup. Cleanups run in reverse order, so // replies are deleted before the parent. fn gets a fresh context because the // test context is done when cleanups run. diff --git a/adapters/slack/slackapi/status.go b/adapters/slack/slackapi/status.go index e3da7db..41beee2 100644 --- a/adapters/slack/slackapi/status.go +++ b/adapters/slack/slackapi/status.go @@ -47,8 +47,9 @@ type SetAgentSessionStatusRequest struct { // SetAgentSessionStatus calls agents.sessions.setStatus. Slack creates the // session when the thread has none. The app must be a member of the channel. -// In a workspace without the agent feature, Slack returns the error code -// "feature_disabled". +// The app manifest must have ManifestFeatures.AgentView. Without it, Slack +// returns the error code "not_authorized". In a workspace without the agent +// feature, Slack returns the error code "feature_disabled". func (c *Client) SetAgentSessionStatus(ctx context.Context, req SetAgentSessionStatusRequest) error { return c.Call(ctx, "agents.sessions.setStatus", req, nil) } diff --git a/docs/reference.md b/docs/reference.md index 8154ac7..90f2f7a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -740,7 +740,8 @@ Slack documents `users.info`, `conversations.info`, `conversations.history`, or form-only methods, so the client sends them form encoded. Call `CreateApp` and `DeleteApp` on a client with an app configuration token (`xoxe.xoxp-`), for example `client.WithToken(configToken).CreateApp(ctx, req)`. -`SetAgentSessionStatus` fails with the `APIError` code `feature_disabled` in a +`SetAgentSessionStatus` fails with the `APIError` code `not_authorized` when +the app manifest has no `features.agent_view`, and with `feature_disabled` in a workspace without the Slack agent feature. Every method except `DownloadFile` retries Slack throttling (HTTP 429, or the error code `ratelimited` or `rate_limited`) within the `RetryPolicy`. It honors