From 6f9db6c6d354e039ca59280201b3c6fe78f6716f Mon Sep 17 00:00:00 2001 From: Kelsey Mills <42537394+kelseymills@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:21:21 +0000 Subject: [PATCH] RESP-4311 Mostly update forked slack go library (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add example of updating modal * socketmode.Client.run: Fix #1125 by allowing producer of channel to close when it is finished, and consumer to see the close * socketmode.Client.run: Fix small race potential on 'firstErr' as the outer goroutine did not wait on all inner goroutines that had a chance to set it, also make sure to check error for context.Canceled appropriately * socketmode.Client.Events: Change all events produced to Events channel via a method that has an escape hatch; unable to change public Events field without breaking api, though * socketmode.Client.run: Cover edge case where all goroutines exit nil, when getting buffered (first) error * socketmode: Slight refactor of Send/Ack on Client to allow passing contexts for channel ops, though they are very similar now * socketmode: Shore up error handling via errors.Is/As/(Un)Wrapping, other small changes * delete dupulicated code at L181 * Change comments at L183 to suggestion Co-authored-by: Naoki Kanatani * Change http response StatusUnauthorized at L188 to StatusInternalServerError Co-authored-by: Naoki Kanatani * socketmode example: use embedded api client in socketmode client * socketmode remove duplicate debug logging * socketmode bytes no longer used * socketmodehandler: implemented RunEventLoopContext method. now socketmodehandler will react on context cancellation * socketmode: awaiting of message receiver goroutine in run method to avoid panics * bugfix: silly mistake in runLoop method, renamed closed to ok (recv chan) * Export the Binder type from slacktest * Add ReplyUsers to Msg Struct This is something that Slack returns by default on API response. * add support for user_profile_changed callback event * add test * add 2FA type to slack user Signed-off-by: Ivan Milchev * Remove omitempty * Test with Go 1.18~1.20 * Update README for slacktest * Add actions/stale action * Increase operations-per-run * Bump up golangci-lint and its action to latest * Add git repository's checkout option * Add setup-go step * gofmt * Add 'FIXME's for deadman timer reset race * Fix race on deadman timer Elapsed+Reset by more efficient use of ticker and ping time channel. Remove deadman file as it is no longer needed * Add UnfurlLinks and UnfurlMedia to WebhookMessage * to support 'classic unfurl' configuration options in webhook messages. * https://api.slack.com/reference/messaging/link-unfurling#classic_unfurl * chore: unnecessary use of fmt.Sprintf * Redact tokens in debug log * Tiny unrelated lint fix * Add support for refresh tokens * Conversation inviteShared support * Create structs for manifest endpoint * Add requests to validate manifests * Add create and delete methods, implement common interface * Add tokens * Finish manifest methods * Fix linting * Rename value * More tests, return manifest itself when exporting * Undo pointer and take reference manually * More tests * Add options for tokens * Use correct method * use config token * Pass config token as param * Small bugfix * add `omitempty` to ConfirmationBlockObject.Deny a * change case "/humboldttest": to case "/slash" to handle http request correctly * remove post message because it's not related to sample of updating modal * add time.sleep to wait to see updated modal * Add examples * add time pkg * Re-phrase example docs * Add is_enterprise_install flag to command, interactions, and oauth2 response * Add blocks in slackevents.MessageEvent * Add support for Rich Text Input * Add Video Block (#1210) Add support for Video Blocks Ref. https://api.slack.com/reference/block-kit/blocks#video cf. #1205 --------- Co-authored-by: David Parsley Co-authored-by: Ben Bourdin * Add rich_text_input block conv * Create openid.connect.token function to fetch access token * Fix incorrectly commented functions * Improved comment for GetOpenIDConnectToken * Add RichTextList support * Add support for Indent field Co-authored-by: SimonLiuRoblox <84740141+SimonLiuRoblox@users.noreply.github.com> * Add block element type FileInput * Add Files field related to FileInput element in the BlockAction * add connected_team_ids, shared_team_ids, internal_team_ids, is_global_shared field to conversation * fix: expose is_enterprise_install field * Add "edited" field to the app mention event * Fix linter * Fix: always add context to errors during block serde Signed-off-by: kayos@tcp.direct * Add WithURL support for ButtonBlockElement * add rich_text_quote and rich_text_preformatted * add deleted timestamp for deleted msg events * Adding structure comments & definitions * Parse string or boolean for SlashCommand.IsEnterpriseInstall * validate text length * Add missing message option for link_names * fix some typos * ignore vendor * bots.info * chat.scheduledMessages.list * files.list * usergroups.create * usergroups.list * reactions.list * search.all search.files search.messages * team.accessLogs * team.billableInfo * team.profile.get * pr-prep * Fix UploadFileV2 when using text content (#1291) * Trigger GitHub Actions * chore: slice replace loop (#1211) * chore: slice replace loop * Trigger GitHub Actions --------- Co-authored-by: Lorenzo Aiello * Add isReadOnly property to Conversation struct (#1232) Co-authored-by: Miguel Campos * chore: make channel optional on `UploadFileV2` (#1293) * Support no channel * added test * fix channel id param name * chore: Adding Deprecation Notice for files.upload (#1300) * ci: Adding Go Test Coverage for 1.21 and 1.22 (#1298) * docs: Updating Inline Docs and API References (#1299) * docs: Add an example demonstrating correct usage of GetUsersPaginated (#1201) * Add an example demonstrating correct usage of GetUsersPaginated * Requeue GitHub Actions --------- Co-authored-by: Lorenzo Aiello * docs: Adding an example for AddRemoteFile and DeleteFile (#1303) * docs: Adding an example for ConversationHistory (#1302) * slackevents: support metadata in MessageEvent (#1307) Support for receiving metadata when reading conversation history was first added in PR #1083. This commit extends metadata support to message events when connected to the Slack WebSocket API. Signed-off-by: Robert Fratto * chore: replace ioutil with io or os package (#1310) * add file access field to file struct for slackevents (#1312) * Add slack_file to image block (#1311) Co-authored-by: Rhys M * feat: Events api reconcilation (#1306) * events added with test * added events with tests * added all events and changes done * feat: Add support for parsing AppRateLimited events (#1308) The code changes in this commit add support for parsing AppRateLimited events in the `ParseEvent` function. This allows the application to handle rate-limited events from the Slack API. * feat: Add Properties.Canvas to Channel (#1228) * Add Properties.Canvas to Channel * Trigger GitHub Actions --------- Co-authored-by: Lorenzo Aiello * fix: create multipart form when multipart request (#1117) * fix: create multipart form when multipart request * call createFormFields in go func() del coment * Trigger GitHub Actions --------- Co-authored-by: Lorenzo Aiello * feat: Add go version 1.23 to test matrix (test.yml) (#1315) * ci: Bump GitHub Actions to Latest Versions * fix: fix deprecated comment for UploadFile and UploadFileContext (#1316) * Support publishing a messge to a specific thread (#1309) Support publishing a messge to a specific thread https://api.slack.com/interactivity/handling#publishing_in_thread From slack interactivity documentation: Publishing responses in thread If you want to publish a message to a specific thread, you'll need to include an attribute response_type and set its value to in_channel. Then, to specify the thread, include a thread_ts. Also, be sure to set replace_original to false or you'll overwrite the message you're wanting to respond to! * fix: Add required `format` field to rich text date blocks (#1317) As per [block kit docs](https://api.slack.com/reference/block-kit/blocks#date-element-type), the date element requires a format string to be included. Currently, submitting a block kit with this element results in a slack API error. Also added the two optional fields `url` and `fallback` for posterity. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. * fix: Updated RichTextInputBlockElement InitialValue data type (#1320) ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [x] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. Fix for [issue #1276](https://github.com/slack-go/slack/issues/1276) Updated the datatype of RichTextInputBlockElement InitialValue from string to *RichTextBlock * feat: Add support for unicode parameter in emoji type of rich text blocks (#1319) This PR adds support for the `unicode` parameter to the `RichTextSectionEmojiElement` struct for rich text blocks. While this parameter is not officially documented in Slack's API, it is present in the JSON payload of actual Slack messages and represents the Unicode code point of the emoji. https://api.slack.com/reference/block-kit/blocks#emoji-element-type For example, a rich text block with an emoji can include the unicode field like this: ```json "blocks": [ { "type": "rich_text", "block_id": "xxxxx", "elements": [ { "type": "rich_text_section", "elements": [ { "type": "emoji", "name": "+1", "unicode": "1f44d" } ] } ] } ] ``` The unicode parameter behaves similarly to the skin-tone parameter, which is also undocumented but has already been included in the structure. This PR aligns the handling of unicode in the same way to ensure emojis are fully supported in Slack message payloads. Please review, and feel free to provide feedback if any adjustments are needed. Thank you! ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. * Add endpoints for `calls.*` apis and `Type: call` in blockkit (#1190) Implement the API methods for the Calls API in Slack https://api.slack.com/apis/calls Implemented methods - `calls.add` - Indicate a new call has been started - `calls.end` - Indicate to slack that the call has ended - `calls.info` - Get information about an ongoing slack call object - `calls.update` - update call information - `calls.participants.add` - `calls.participants.remove` Additionally, I've added the minimal version of `Block{Type: "call", CallID: string}` which slack recommends/requires be posted back to the channel https://api.slack.com/apis/calls#post_to_channel. All implemented functionality is publicly documented. There appear to be additional attributes on the `type: call` block, however those appear to be internal values for slack's rendering, so I have left them out. See this gist for specific responses https://gist.github.com/winston-stripe/0cac608bd63b42d73a352be53577f7fd ##### Pull Request Guidelines These are recommendations for pull requests. They are strictly guidelines to help manage expectations. ##### PR preparation Run `make pr-prep` from the root of the repository to run formatting, linting and tests. ##### Should this be an issue instead - [ ] is it a convenience method? (no new functionality, streamlines some use case) - [ ] exposes a previously private type, const, method, etc. - [ ] is it application specific (caching, retry logic, rate limiting, etc) - [ ] is it performance related. ##### API changes Since API changes have to be maintained they undergo a more detailed review and are more likely to require changes. - no tests, if you're adding to the API include at least a single test of the happy case. - If you can accomplish your goal without changing the API, then do so. - dependency changes. updates are okay. adding/removing need justification. ###### Examples of API changes that do not meet guidelines: - in library cache for users. caches are use case specific. - Convenience methods for Sending Messages, update, post, ephemeral, etc. consider opening an issue instead. --------- Co-authored-by: Winston Durand * feat: Add Convenience Methods to Block Elements (#1279) Adds some convenience methods to block elements to easily add functionality --------- Co-authored-by: Lorenzo Aiello * feat: Add functions.completeError and functions.completeSuccess (#1328) Completion of https://github.com/slack-go/slack/pull/1301 - Adds the new complete functions for the Function Execution Event - Adds the context version of those methods --- > this PR to handle event [function_executed](https://api.slack.com/events/function_executed) and response the function with [functions.completeSuccess](https://api.slack.com/methods/functions.completeSuccess) and [functions.completeError](https://api.slack.com/methods/functions.completeError) --------- Co-authored-by: Yoga Setiawan * feat: Add support for external_limited option of inviteShared (#1330) Expose the ability to override the [external_limited option](https://api.slack.com/methods/conversations.inviteShared#arg_external_limited) for inviteShared. Adding the param to all the InviteSharedEmailsToConversation, etc. methods would be a breaking change to those callers, so I opted instead to expose the underlying helper (renamed to InviteSharedToConversation). I feel like the convenience methods (InviteSharedEmailsToConversation/InviteSharedUserIDsToConversation) are not actually that much more convenient than just using the helper, and I think we can eventually remove them in favor of having people call InviteSharedToConversation directly. But that's a future thing. Although it's slightly inconvenient for the caller to use *bool for ExternalLimited, the two alternatives I considered are, I think worse: - Include ExternalLimited as a bool in the InviteSharedParams. I dislike this way because it gives the SDK user of InviteSharedToConversation a different default behavior from inviteShared, since the default value in the API is true. - Add a bool like NonExternalLimited to InviteSharedParams. This way the defaulting is consistent with the API if it's not specified; however, the InviteSharedParams no longer mirror the API args, which I think is confusing. * feat: Add support for Canvas API methods (#1334) This PR introduces new functionalities for managing canvases and creating channel-specific canvases. - CreateCanvas - DeleteCanvas - EditCanvas - SetCanvasAccess - DeleteCanvasAccess - LookupCanvasSections - CreateChannelCanvas Closes #1333 * Add back a few things I removed from our version when merging * Remove duplicate TeamID * Fix tests * Fix linting * Implement interface for generic section * Fix methods to generate new rich text types * Implement set elements * Revert rich text changes * Delete unnecessary file --------- Signed-off-by: Ivan Milchev Signed-off-by: kayos@tcp.direct Signed-off-by: Robert Fratto Co-authored-by: kouwakai Co-authored-by: Ian Burton Co-authored-by: KouWakai <35868597+KouWakai@users.noreply.github.com> Co-authored-by: Naoki Kanatani Co-authored-by: kittydoor Co-authored-by: lololozhkin Co-authored-by: lololozhkin Co-authored-by: Kevin Paulisse Co-authored-by: Aleksa Arsic <85497919+aleksa11010@users.noreply.github.com> Co-authored-by: Jeffrey Dorrycott Co-authored-by: Ivan Milchev Co-authored-by: Lauri Heiskanen Co-authored-by: guoguangwu Co-authored-by: Daniel Abraham Co-authored-by: Barak Amar Co-authored-by: stijndcl Co-authored-by: Kamil Gwóźdź Co-authored-by: David Parsley Co-authored-by: Hussachai Puripunpinyo Co-authored-by: invzhi Co-authored-by: Peiman Jafari Co-authored-by: walkure Co-authored-by: David Parsley Co-authored-by: Ben Bourdin Co-authored-by: Daniel Michaels Co-authored-by: Daniel Pieper Co-authored-by: Daniel Pieper <40295712+daniel-pieper-personio@users.noreply.github.com> Co-authored-by: SimonLiuRoblox <84740141+SimonLiuRoblox@users.noreply.github.com> Co-authored-by: Aotokitsuruya Co-authored-by: Peter Nguyen Co-authored-by: Alvin See Co-authored-by: IbirbyZh <040796@gmail.com> Co-authored-by: kayos@tcp.direct Co-authored-by: Josh Branham Co-authored-by: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Co-authored-by: Jeremy Birtola Co-authored-by: Andres Peñaloza Co-authored-by: Robby Dyer Co-authored-by: yutasb Co-authored-by: candiduslynx Co-authored-by: Alex Shcherbakov Co-authored-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com> Co-authored-by: Lorenzo Aiello Co-authored-by: Lorenzo Aiello Co-authored-by: Miguel Campos Co-authored-by: Miguel Campos Co-authored-by: Alexander Forbes-Reed Co-authored-by: Adam Rothman Co-authored-by: Robert Fratto Co-authored-by: Naka Masato Co-authored-by: Peter Nguyen <33224337+zFlabmonsta@users.noreply.github.com> Co-authored-by: rhysm Co-authored-by: Rhys M Co-authored-by: Arya Khochare <91268931+Aryakoste@users.noreply.github.com> Co-authored-by: K.Utsunomiya <32708603+nemuvski@users.noreply.github.com> Co-authored-by: ku KUMAGAI Co-authored-by: mineo Co-authored-by: ICHINOSE Shogo Co-authored-by: Nikolai Co-authored-by: Luke Josh <92695731+luke-josh@users.noreply.github.com> Co-authored-by: Manjish Co-authored-by: YutoKashiwagi <58618766+YutoKashiwagi@users.noreply.github.com> Co-authored-by: Winston Durand <68715117+winston-stripe@users.noreply.github.com> Co-authored-by: Winston Durand Co-authored-by: Obed S. <113571073+obed-sj@users.noreply.github.com> Co-authored-by: Gideon Williams Co-authored-by: Yoga Setiawan Co-authored-by: Bennett Amodio Co-authored-by: Jaro Spisak <61154065+jarospisak-unity@users.noreply.github.com> --- .github/workflows/test.yml | 21 +- .gitignore | 1 + apps.go | 10 +- auth.go | 11 +- block.go | 2 + block_call.go | 59 +- block_call_test.go | 13 + block_conv.go | 9 +- block_element.go | 249 ++- block_element_test.go | 46 +- block_image.go | 20 +- block_input.go | 12 + block_object.go | 12 +- block_object_test.go | 19 + block_video.go | 65 + block_video_test.go | 23 + bookmarks.go | 197 +- bookmarks_test.go | 28 +- bots.go | 25 +- bots_test.go | 2 +- calls.go | 216 ++ calls_test.go | 189 ++ canvas.go | 264 +++ canvas_test.go | 216 ++ channels.go | 9 +- chat.go | 115 +- chat_test.go | 83 +- conversation.go | 243 ++- conversation_test.go | 185 +- dnd.go | 27 +- emoji.go | 6 +- examples/blocks/README.md | 2 +- examples/buttons/buttons.go | 2 +- .../conversation_history.go | 23 + examples/dialog/dialog.go | 4 +- examples/eventsapi/events.go | 4 +- examples/files_remote/files_remote.go | 30 + examples/function/function.go | 60 + examples/function/manifest.json | 56 + examples/manifests/README.md | 38 + examples/manifests/manifest.go | 45 + examples/modal/modal.go | 60 +- examples/pagination/pagination.go | 59 + examples/pins/pins.go | 5 +- examples/slash/slash.go | 3 +- examples/socketmode/socketmode.go | 5 +- examples/team/team.go | 7 +- examples/tokens/README.md | 10 + examples/tokens/tokens.go | 33 + examples/workflow_step/handler.go | 6 +- examples/workflow_step/middleware.go | 6 +- files.go | 94 +- files_test.go | 13 +- function_execute.go | 93 + function_execute_test.go | 80 + interactions.go | 47 +- interactions_test.go | 4 +- manifests.go | 297 +++ manifests_test.go | 149 ++ messages.go | 9 +- misc.go | 32 +- oauth.go | 59 +- pins.go | 14 +- reactions.go | 22 +- reminders.go | 36 +- reminders_test.go | 4 +- remotefiles.go | 41 +- search.go | 4 + slack.go | 24 +- slackevents/inner_events.go | 786 ++++++- slackevents/inner_events_test.go | 1848 ++++++++++++++++- slackevents/parsers.go | 30 +- slackevents/parsers_test.go | 27 + slacktest/README.md | 6 +- slacktest/data.go | 12 +- slacktest/handlers.go | 15 +- slacktest/handlers_test.go | 2 +- slacktest/server.go | 5 +- slacktest/server_test.go | 2 +- slash.go | 64 +- slash_test.go | 68 + socketmode/deadman.go | 31 - socketmode/socket_mode_managed_conn.go | 300 +-- socketmode/socketmode.go | 12 + socketmode/socketmode_handler.go | 26 +- stars.go | 19 +- team.go | 76 +- tokens.go | 52 + tokens_test.go | 45 + usergroups.go | 57 +- users.go | 120 +- users_test.go | 5 +- views.go | 10 +- webhooks.go | 5 +- workflow_step.go | 5 +- 95 files changed, 6619 insertions(+), 866 deletions(-) create mode 100644 block_call_test.go create mode 100644 block_video.go create mode 100644 block_video_test.go create mode 100644 calls.go create mode 100644 calls_test.go create mode 100644 canvas.go create mode 100644 canvas_test.go create mode 100644 examples/conversation_history/conversation_history.go create mode 100644 examples/files_remote/files_remote.go create mode 100644 examples/function/function.go create mode 100644 examples/function/manifest.json create mode 100644 examples/manifests/README.md create mode 100644 examples/manifests/manifest.go create mode 100644 examples/pagination/pagination.go create mode 100644 examples/tokens/README.md create mode 100644 examples/tokens/tokens.go create mode 100644 function_execute.go create mode 100644 function_execute_test.go create mode 100644 manifests.go create mode 100644 manifests_test.go delete mode 100644 socketmode/deadman.go create mode 100644 tokens.go create mode 100644 tokens_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e88bcb33..1885b147f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,13 +12,16 @@ jobs: strategy: matrix: go: - - '1.17' - '1.18' - '1.19' + - '1.20' + - '1.21' + - '1.22' + - '1.23' name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: run test @@ -29,13 +32,17 @@ jobs: runs-on: ubuntu-22.04 name: lint steps: - - uses: actions/checkout@v3 + - uses: actions/setup-go@v5 + with: + go-version: '1.20' + cache: false + - uses: actions/checkout@v4 with: # NOTE: Because we are a fork, # we must fetch all history for all branches # and tags. - fetch-depth: '0' + fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@537aa1903e5d359d0b27dbc19ddd22c5087f3fbc # v3.2.0 + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 with: - version: v1.49.0 + version: v1.52.2 diff --git a/.gitignore b/.gitignore index ac6f3eeb3..027f4de5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.test *~ .idea/ +/vendor/ \ No newline at end of file diff --git a/apps.go b/apps.go index 10d429752..7322b15b9 100644 --- a/apps.go +++ b/apps.go @@ -19,11 +19,15 @@ type EventAuthorization struct { IsEnterpriseInstall bool `json:"is_enterprise_install"` } +// ListEventAuthorizations lists authed users and teams for the given event_context. +// You must provide an app-level token to the client using OptionAppLevelToken. +// For more details, see ListEventAuthorizationsContext documentation. func (api *Client) ListEventAuthorizations(eventContext string) ([]EventAuthorization, error) { return api.ListEventAuthorizationsContext(context.Background(), eventContext) } -// ListEventAuthorizationsContext lists authed users and teams for the given event_context. You must provide an app-level token to the client using OptionAppLevelToken. More info: https://api.slack.com/methods/apps.event.authorizations.list +// ListEventAuthorizationsContext lists authed users and teams for the given event_context with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.event.authorizations.list func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventContext string) ([]EventAuthorization, error) { resp := &listEventAuthorizationsResponse{} @@ -43,10 +47,14 @@ func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventCont return resp.Authorizations, nil } +// UninstallApp uninstalls your app from a workspace. +// For more details, see UninstallAppContext documentation. func (api *Client) UninstallApp(clientID, clientSecret string) error { return api.UninstallAppContext(context.Background(), clientID, clientSecret) } +// UninstallAppContext uninstalls your app from a workspace with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.uninstall func (api *Client) UninstallAppContext(ctx context.Context, clientID, clientSecret string) error { values := url.Values{ "client_id": {clientID}, diff --git a/auth.go b/auth.go index 332be8069..972f59ea6 100644 --- a/auth.go +++ b/auth.go @@ -23,12 +23,14 @@ func (api *Client) authRequest(ctx context.Context, path string, values url.Valu return response, response.Err() } -// SendAuthRevoke will send a revocation for our token +// SendAuthRevoke will send a revocation for our token. +// For more details, see SendAuthRevokeContext documentation. func (api *Client) SendAuthRevoke(token string) (*AuthRevokeResponse, error) { return api.SendAuthRevokeContext(context.Background(), token) } -// SendAuthRevokeContext will send a revocation request for our token to api.revoke with context +// SendAuthRevokeContext will send a revocation request for our token to api.revoke with a custom context. +// Slack API docs: https://api.slack.com/methods/auth.revoke func (api *Client) SendAuthRevokeContext(ctx context.Context, token string) (*AuthRevokeResponse, error) { if token == "" { token = api.token @@ -52,12 +54,13 @@ type ListTeamsParameters struct { } // ListTeams returns all workspaces a token can access. -// More info: https://api.slack.com/methods/admin.teams.list +// For more details, see ListTeamsContext documentation. func (api *Client) ListTeams(params ListTeamsParameters) ([]Team, string, error) { return api.ListTeamsContext(context.Background(), params) } -// ListTeams returns all workspaces a token can access with a custom context. +// ListTeamsContext returns all workspaces a token can access with a custom context. +// Slack API docs: https://api.slack.com/methods/auth.teams.list func (api *Client) ListTeamsContext(ctx context.Context, params ListTeamsParameters) ([]Team, string, error) { values := url.Values{ "token": {api.token}, diff --git a/block.go b/block.go index 3a59f0a7d..562733d80 100644 --- a/block.go +++ b/block.go @@ -19,6 +19,7 @@ const ( MBTHeader MessageBlockType = "header" MBTRichText MessageBlockType = "rich_text" MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" ) // Block defines an interface all block types should implement @@ -41,6 +42,7 @@ type BlockAction struct { Text TextBlockObject `json:"text"` Value string `json:"value"` RichTextValue RichTextBlock `json:"rich_text_value"` + Files []File `json:"files"` ActionTs string `json:"action_ts"` SelectedOption OptionBlockObject `json:"selected_option"` SelectedOptions []OptionBlockObject `json:"selected_options"` diff --git a/block_call.go b/block_call.go index 8fff17775..cf1414a61 100644 --- a/block_call.go +++ b/block_call.go @@ -1,53 +1,24 @@ package slack -// CallBlock defines information +// CallBlock defines data that is used to display a call in slack. +// +// More Information: https://api.slack.com/apis/calls#post_to_channel type CallBlock struct { - Type MessageBlockType `json:"type"` - CallID string `json:"call_id"` - BlockID string `json:"block_id"` - APIDecorationAvailable bool `json:"api_decoration_available"` - Call Call `json:"call"` -} - -type AppIconUrls struct { - Image32 string `json:"image_32"` - Image36 string `json:"image_36"` - Image48 string `json:"image_48"` - Image64 string `json:"image_64"` - Image72 string `json:"image_72"` - Image96 string `json:"image_96"` - Image128 string `json:"image_128"` - Image192 string `json:"image_192"` - Image512 string `json:"image_512"` - Image1024 string `json:"image_1024"` - ImageOriginal string `json:"image_original"` -} -type CallInfo struct { - ID string `json:"id"` - AppID string `json:"app_id"` - AppIconUrls AppIconUrls `json:"app_icon_urls"` - DateStart int `json:"date_start"` - ActiveParticipants []interface{} `json:"active_participants"` - AllParticipants []interface{} `json:"all_participants"` - DisplayID string `json:"display_id"` - JoinURL string `json:"join_url"` - DesktopAppJoinURL string `json:"desktop_app_join_url"` - Name string `json:"name"` - CreatedBy string `json:"created_by"` - DateEnd int `json:"date_end"` - Channels []string `json:"channels"` - IsDmCall bool `json:"is_dm_call"` - WasRejected bool `json:"was_rejected"` - WasMissed bool `json:"was_missed"` - WasAccepted bool `json:"was_accepted"` - HasEnded bool `json:"has_ended"` -} -type Call struct { - CallInfo CallInfo `json:"v1"` - MediaBackendType string `json:"media_backend_type"` + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + CallID string `json:"call_id"` + Call Call `json:"call"` } // BlockType returns the type of the block func (s CallBlock) BlockType() MessageBlockType { return s.Type } + +// NewCallBlock returns a new instance of a file block +func NewCallBlock(callID string) *CallBlock { + return &CallBlock{ + Type: MBTCall, + CallID: callID, + } +} diff --git a/block_call_test.go b/block_call_test.go new file mode 100644 index 000000000..c118542a5 --- /dev/null +++ b/block_call_test.go @@ -0,0 +1,13 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCallBlock(t *testing.T) { + callBlock := NewCallBlock("ACallID") + assert.Equal(t, string(callBlock.Type), "call") + assert.Equal(t, callBlock.CallID, "ACallID") +} diff --git a/block_conv.go b/block_conv.go index 9efaae6a2..6d3323a0e 100644 --- a/block_conv.go +++ b/block_conv.go @@ -2,7 +2,6 @@ package slack import ( "encoding/json" - "errors" "fmt" ) @@ -72,6 +71,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &SectionBlock{} case "call": block = &CallBlock{} + case "video": + block = &VideoBlock{} default: block = &UnknownBlock{} } @@ -136,8 +137,10 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &RadioButtonsBlockElement{} case "number_input": e = &NumberInputBlockElement{} + case "file_input": + e = &FileInputBlockElement{} default: - return errors.New("unsupported block element type") + return fmt.Errorf("unsupported block element type %v", s.TypeVal) } if err := json.Unmarshal(a.Element, e); err != nil { @@ -443,7 +446,7 @@ func (e *ContextElements) UnmarshalJSON(data []byte) error { e.Elements = append(e.Elements, elem.(*ImageBlockElement)) default: - return errors.New("unsupported context element type") + return fmt.Errorf("unsupported context element type %v", contextElementType) } } diff --git a/block_element.go b/block_element.go index 8a474e062..9e6a5a208 100644 --- a/block_element.go +++ b/block_element.go @@ -12,10 +12,11 @@ const ( METDatetimepicker MessageElementType = "datetimepicker" METPlainTextInput MessageElementType = "plain_text_input" METRadioButtons MessageElementType = "radio_buttons" - METEmailInput MessageElementType = "email_text_input" - METNumberInput MessageElementType = "number_input" - METURLInput MessageElementType = "url_text_input" METRichTextInput MessageElementType = "rich_text_input" + METEmailTextInput MessageElementType = "email_text_input" + METURLTextInput MessageElementType = "url_text_input" + METNumber MessageElementType = "number_input" + METFileInput MessageElementType = "file_input" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -181,6 +182,12 @@ func (s *ButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *Butt return s } +// WithURL adds a URL for the button to link to and returns the modified ButtonBlockElement +func (s *ButtonBlockElement) WithURL(url string) *ButtonBlockElement { + s.URL = url + return s +} + // NewButtonBlockElement returns an instance of a new button element to be used within a block func NewButtonBlockElement(actionID, value string, text *TextBlockObject) *ButtonBlockElement { return &ButtonBlockElement{ @@ -251,6 +258,36 @@ func NewOptionsSelectBlockElement(optType string, placeholder *TextBlockObject, } } +// WithInitialOption sets the initial option for the select element +func (s *SelectBlockElement) WithInitialOption(option *OptionBlockObject) *SelectBlockElement { + s.InitialOption = option + return s +} + +// WithInitialUser sets the initial user for the select element +func (s *SelectBlockElement) WithInitialUser(user string) *SelectBlockElement { + s.InitialUser = user + return s +} + +// WithInitialConversation sets the initial conversation for the select element +func (s *SelectBlockElement) WithInitialConversation(conversation string) *SelectBlockElement { + s.InitialConversation = conversation + return s +} + +// WithInitialChannel sets the initial channel for the select element +func (s *SelectBlockElement) WithInitialChannel(channel string) *SelectBlockElement { + s.InitialChannel = channel + return s +} + +// WithConfirm adds a confirmation dialogue to the select element +func (s *SelectBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *SelectBlockElement { + s.Confirm = confirm + return s +} + // NewOptionsGroupSelectBlockElement returns a new instance of SelectBlockElement for use with // the Options object only. func NewOptionsGroupSelectBlockElement( @@ -302,6 +339,48 @@ func NewOptionsMultiSelectBlockElement(optType string, placeholder *TextBlockObj } } +// WithInitialOptions sets the initial options for the multi-select element +func (s *MultiSelectBlockElement) WithInitialOptions(options ...*OptionBlockObject) *MultiSelectBlockElement { + s.InitialOptions = options + return s +} + +// WithInitialUsers sets the initial users for the multi-select element +func (s *MultiSelectBlockElement) WithInitialUsers(users ...string) *MultiSelectBlockElement { + s.InitialUsers = users + return s +} + +// WithInitialConversations sets the initial conversations for the multi-select element +func (s *MultiSelectBlockElement) WithInitialConversations(conversations ...string) *MultiSelectBlockElement { + s.InitialConversations = conversations + return s +} + +// WithInitialChannels sets the initial channels for the multi-select element +func (s *MultiSelectBlockElement) WithInitialChannels(channels ...string) *MultiSelectBlockElement { + s.InitialChannels = channels + return s +} + +// WithConfirm adds a confirmation dialogue to the multi-select element +func (s *MultiSelectBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *MultiSelectBlockElement { + s.Confirm = confirm + return s +} + +// WithMaxSelectedItems sets the maximum number of items that can be selected +func (s *MultiSelectBlockElement) WithMaxSelectedItems(maxSelectedItems int) *MultiSelectBlockElement { + s.MaxSelectedItems = &maxSelectedItems + return s +} + +// WithMinQueryLength sets the minimum query length for the multi-select element +func (s *MultiSelectBlockElement) WithMinQueryLength(minQueryLength int) *MultiSelectBlockElement { + s.MinQueryLength = &minQueryLength + return s +} + // NewOptionsGroupMultiSelectBlockElement returns a new instance of MultiSelectBlockElement for use with // the Options object only. func NewOptionsGroupMultiSelectBlockElement( @@ -345,6 +424,12 @@ func NewOverflowBlockElement(actionID string, options ...*OptionBlockObject) *Ov } } +// WithConfirm adds a confirmation dialogue to the overflow element +func (s *OverflowBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *OverflowBlockElement { + s.Confirm = confirm + return s +} + // DatePickerBlockElement defines an element which lets users easily select a // date from a calendar style UI. Date picker elements can be used inside of // section and actions blocks. @@ -444,43 +529,12 @@ func (s EmailInputBlockElement) ElementType() MessageElementType { // element that only accepts email addresses. func NewEmailInputBlockElement(placeholder *TextBlockObject, actionID string) *EmailInputBlockElement { return &EmailInputBlockElement{ - Type: METEmailInput, + Type: METEmailTextInput, ActionID: actionID, Placeholder: placeholder, } } -// NumberInputBlockElement defines an element which lets users select a number -// from a nice UI. This element can only be used inside modals. -// -// More Information: https://api.slack.com/reference/block-kit/block-elements#number -type NumberInputBlockElement struct { - Type MessageElementType `json:"type"` - ActionID string `json:"action_id,omitempty"` - IsDecimalAllowed bool `json:"is_decimal_allowed"` - InitialValue string `json:"initial_value,omitempty"` - MinValue string `json:"min_value,omitempty"` - MaxValue string `json:"max_value,omitempty"` - DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` - FocusOnLoad bool `json:"focus_on_load,omitempty"` - Placeholder *TextBlockObject `json:"placeholder,omitempty"` -} - -// ElementType returns the type of the Element -func (s NumberInputBlockElement) ElementType() MessageElementType { - return s.Type -} - -// NewNumberInputBlockElement returns an instance of a number input. -func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, isDecimalAllowed bool) *NumberInputBlockElement { - return &NumberInputBlockElement{ - Type: METNumberInput, - ActionID: actionID, - Placeholder: placeholder, - IsDecimalAllowed: isDecimalAllowed, - } -} - // PlainTextInputBlockElement creates a field where a user can enter freeform // data. // Plain-text input elements are currently only available in modals. @@ -516,6 +570,36 @@ func NewPlainTextInputBlockElement(placeholder *TextBlockObject, actionID string } } +// WithInitialValue sets the initial value for the plain-text input element +func (s *PlainTextInputBlockElement) WithInitialValue(initialValue string) *PlainTextInputBlockElement { + s.InitialValue = initialValue + return s +} + +// WithMinLength sets the minimum length for the plain-text input element +func (s *PlainTextInputBlockElement) WithMinLength(minLength int) *PlainTextInputBlockElement { + s.MinLength = minLength + return s +} + +// WithMaxLength sets the maximum length for the plain-text input element +func (s *PlainTextInputBlockElement) WithMaxLength(maxLength int) *PlainTextInputBlockElement { + s.MaxLength = maxLength + return s +} + +// WithMultiline sets the multiline property for the plain-text input element +func (s *PlainTextInputBlockElement) WithMultiline(multiline bool) *PlainTextInputBlockElement { + s.Multiline = multiline + return s +} + +// WithDispatchActionConfig sets the dispatch action config for the plain-text input element +func (s *PlainTextInputBlockElement) WithDispatchActionConfig(config *DispatchActionConfig) *PlainTextInputBlockElement { + s.DispatchActionConfig = config + return s +} + // RichTextInputBlockElement creates a field where allows users to enter formatted text // in a WYSIWYG composer, offering the same messaging writing experience as in Slack // More Information: https://api.slack.com/reference/block-kit/block-elements#rich_text_input @@ -564,7 +648,7 @@ func (s URLInputBlockElement) ElementType() MessageElementType { // element func NewURLInputBlockElement(placeholder *TextBlockObject, actionID string) *URLInputBlockElement { return &URLInputBlockElement{ - Type: METURLInput, + Type: METURLTextInput, ActionID: actionID, Placeholder: placeholder, } @@ -621,3 +705,96 @@ func NewRadioButtonsBlockElement(actionID string, options ...*OptionBlockObject) Options: options, } } + +// NumberInputBlockElement creates a field where a user can enter number +// data. +// Number input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#number +type NumberInputBlockElement struct { + Type MessageElementType `json:"type"` + IsDecimalAllowed bool `json:"is_decimal_allowed"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + MinValue string `json:"min_value,omitempty"` + MaxValue string `json:"max_value,omitempty"` + DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` +} + +// ElementType returns the type of the Element +func (s NumberInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewNumberInputBlockElement returns an instance of a number input element +func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, isDecimalAllowed bool) *NumberInputBlockElement { + return &NumberInputBlockElement{ + Type: METNumber, + ActionID: actionID, + Placeholder: placeholder, + IsDecimalAllowed: isDecimalAllowed, + } +} + +// WithInitialValue sets the initial value for the number input element +func (s *NumberInputBlockElement) WithInitialValue(initialValue string) *NumberInputBlockElement { + s.InitialValue = initialValue + return s +} + +// WithMinValue sets the minimum value for the number input element +func (s *NumberInputBlockElement) WithMinValue(minValue string) *NumberInputBlockElement { + s.MinValue = minValue + return s +} + +// WithMaxValue sets the maximum value for the number input element +func (s *NumberInputBlockElement) WithMaxValue(maxValue string) *NumberInputBlockElement { + s.MaxValue = maxValue + return s +} + +// WithDispatchActionConfig sets the dispatch action config for the number input element +func (s *NumberInputBlockElement) WithDispatchActionConfig(config *DispatchActionConfig) *NumberInputBlockElement { + s.DispatchActionConfig = config + return s +} + +// FileInputBlockElement creates a field where a user can upload a file. +// +// File input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#file_input +type FileInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + FileTypes []string `json:"filetypes,omitempty"` + MaxFiles int `json:"max_files,omitempty"` +} + +// ElementType returns the type of the Element +func (s FileInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewFileInputBlockElement returns an instance of a file input element +func NewFileInputBlockElement(actionID string) *FileInputBlockElement { + return &FileInputBlockElement{ + Type: METFileInput, + ActionID: actionID, + } +} + +// WithFileTypes sets the file types that can be uploaded +func (s *FileInputBlockElement) WithFileTypes(fileTypes ...string) *FileInputBlockElement { + s.FileTypes = fileTypes + return s +} + +// WithMaxFiles sets the maximum number of files that can be uploaded +func (s *FileInputBlockElement) WithMaxFiles(maxFiles int) *FileInputBlockElement { + s.MaxFiles = maxFiles + return s +} diff --git a/block_element_test.go b/block_element_test.go index c38f17466..83edcc798 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -43,6 +43,16 @@ func TestWithStyleForButtonElement(t *testing.T) { } +func TestWithURLForButtonElement(t *testing.T) { + + btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) + btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) + + btnElement.WithURL("https://foo.bar") + assert.Equal(t, btnElement.URL, "https://foo.bar") + +} + func TestNewOptionsSelectBlockElement(t *testing.T) { testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) @@ -165,16 +175,6 @@ func TestNewRichTextInputBlockElement(t *testing.T) { assert.Equal(t, richTextInputElement.ActionID, "test") } -func TestNewNumberInputBlockElement(t *testing.T) { - - numberInputElement := NewNumberInputBlockElement(nil, "test", true) - - assert.Equal(t, string(numberInputElement.Type), "number_input") - assert.Equal(t, numberInputElement.ActionID, "test") - assert.Equal(t, numberInputElement.IsDecimalAllowed, true) - -} - func TestNewURLInputBlockElement(t *testing.T) { urlInputElement := NewURLInputBlockElement(nil, "test") @@ -227,3 +227,29 @@ func TestNewRadioButtonsBlockElement(t *testing.T) { assert.Equal(t, len(radioButtonsElement.Options), 3) } + +func TestNewNumberInputBlockElement(t *testing.T) { + + numberInputElement := NewNumberInputBlockElement(nil, "test", true) + + assert.Equal(t, string(numberInputElement.Type), "number_input") + assert.Equal(t, numberInputElement.ActionID, "test") + assert.Equal(t, numberInputElement.IsDecimalAllowed, true) + +} + +func TestNewFileInputBlockElement(t *testing.T) { + + fileInputElement := NewFileInputBlockElement("test") + + assert.Equal(t, string(fileInputElement.Type), "file_input") + assert.Equal(t, fileInputElement.ActionID, "test") + + fileInputElement.WithFileTypes("jpg", "png") + assert.Equal(t, len(fileInputElement.FileTypes), 2) + assert.Contains(t, fileInputElement.FileTypes, "jpg") + assert.Contains(t, fileInputElement.FileTypes, "png") + + fileInputElement.WithMaxFiles(10) + assert.Equal(t, fileInputElement.MaxFiles, 10) +} diff --git a/block_image.go b/block_image.go index 90cbd14e4..b3d2cb8cf 100644 --- a/block_image.go +++ b/block_image.go @@ -4,11 +4,21 @@ package slack // // More Information: https://api.slack.com/reference/messaging/blocks#image type ImageBlock struct { - Type MessageBlockType `json:"type"` - ImageURL string `json:"image_url"` - AltText string `json:"alt_text"` - BlockID string `json:"block_id,omitempty"` - Title *TextBlockObject `json:"title,omitempty"` + Type MessageBlockType `json:"type"` + ImageURL string `json:"image_url,omitempty"` + AltText string `json:"alt_text"` + BlockID string `json:"block_id,omitempty"` + Title *TextBlockObject `json:"title,omitempty"` + SlackFile *SlackFileObject `json:"slack_file,omitempty"` +} + +// SlackFileObject Defines an object containing Slack file information to be used in an +// image block or image element. +// +// More Information: https://api.slack.com/reference/block-kit/composition-objects#slack_file +type SlackFileObject struct { + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` } // BlockType returns the type of the block diff --git a/block_input.go b/block_input.go index 78ffcdb81..7c1272a64 100644 --- a/block_input.go +++ b/block_input.go @@ -28,3 +28,15 @@ func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockEl Hint: hint, } } + +// WithOptional sets the optional flag on the input block +func (s *InputBlock) WithOptional(optional bool) *InputBlock { + s.Optional = optional + return s +} + +// WithDispatchAction sets the dispatch action flag on the input block +func (s *InputBlock) WithDispatchAction(dispatchAction bool) *InputBlock { + s.DispatchAction = dispatchAction + return s +} diff --git a/block_object.go b/block_object.go index f70405eba..a3e78d4ea 100644 --- a/block_object.go +++ b/block_object.go @@ -147,6 +147,16 @@ func (s TextBlockObject) Validate() error { return errors.New("emoji cannot be true in mrkdown") } + // https://api.slack.com/reference/block-kit/composition-objects#text__fields + if len(s.Text) == 0 { + return errors.New("text must have a minimum length of 1") + } + + // https://api.slack.com/reference/block-kit/composition-objects#text__fields + if len(s.Text) > 3000 { + return errors.New("text cannot be longer than 3000 characters") + } + return nil } @@ -177,7 +187,7 @@ type ConfirmationBlockObject struct { Title *TextBlockObject `json:"title"` Text *TextBlockObject `json:"text"` Confirm *TextBlockObject `json:"confirm"` - Deny *TextBlockObject `json:"deny"` + Deny *TextBlockObject `json:"deny,omitempty"` Style Style `json:"style,omitempty"` } diff --git a/block_object_test.go b/block_object_test.go index 9889fae41..1f4874a02 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -2,6 +2,7 @@ package slack import ( "errors" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -126,6 +127,24 @@ func TestValidateTextBlockObject(t *testing.T) { }, expected: errors.New("emoji cannot be true in mrkdown"), }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: "", + Emoji: false, + Verbatim: false, + }, + expected: errors.New("text must have a minimum length of 1"), + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: strings.Repeat("a", 3001), + Emoji: false, + Verbatim: false, + }, + expected: errors.New("text cannot be longer than 3000 characters"), + }, } for _, test := range tests { diff --git a/block_video.go b/block_video.go new file mode 100644 index 000000000..322c614f9 --- /dev/null +++ b/block_video.go @@ -0,0 +1,65 @@ +package slack + +// VideoBlock defines data required to display a video as a block element +// +// More Information: https://api.slack.com/reference/block-kit/blocks#video +type VideoBlock struct { + Type MessageBlockType `json:"type"` + VideoURL string `json:"video_url"` + ThumbnailURL string `json:"thumbnail_url"` + AltText string `json:"alt_text"` + Title *TextBlockObject `json:"title"` + BlockID string `json:"block_id,omitempty"` + TitleURL string `json:"title_url,omitempty"` + AuthorName string `json:"author_name,omitempty"` + ProviderName string `json:"provider_name,omitempty"` + ProviderIconURL string `json:"provider_icon_url,omitempty"` + Description *TextBlockObject `json:"description,omitempty"` +} + +// BlockType returns the type of the block +func (s VideoBlock) BlockType() MessageBlockType { + return s.Type +} + +// NewVideoBlock returns an instance of a new Video Block type +func NewVideoBlock(videoURL, thumbnailURL, altText, blockID string, title *TextBlockObject) *VideoBlock { + return &VideoBlock{ + Type: MBTVideo, + VideoURL: videoURL, + ThumbnailURL: thumbnailURL, + AltText: altText, + BlockID: blockID, + Title: title, + } +} + +// WithAuthorName sets the author name for the VideoBlock +func (s *VideoBlock) WithAuthorName(authorName string) *VideoBlock { + s.AuthorName = authorName + return s +} + +// WithTitleURL sets the title URL for the VideoBlock +func (s *VideoBlock) WithTitleURL(titleURL string) *VideoBlock { + s.TitleURL = titleURL + return s +} + +// WithDescription sets the description for the VideoBlock +func (s *VideoBlock) WithDescription(description *TextBlockObject) *VideoBlock { + s.Description = description + return s +} + +// WithProviderIconURL sets the provider icon URL for the VideoBlock +func (s *VideoBlock) WithProviderIconURL(providerIconURL string) *VideoBlock { + s.ProviderIconURL = providerIconURL + return s +} + +// WithProviderName sets the provider name for the VideoBlock +func (s *VideoBlock) WithProviderName(providerName string) *VideoBlock { + s.ProviderName = providerName + return s +} diff --git a/block_video_test.go b/block_video_test.go new file mode 100644 index 000000000..d4b791fab --- /dev/null +++ b/block_video_test.go @@ -0,0 +1,23 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewVideoBlock(t *testing.T) { + + videoTitle := NewTextBlockObject("plain_text", "VideoTitle", false, false) + videoBlock := NewVideoBlock( + "https://example.com/example.mp4", + "https://example.com/thumbnail.png", + "alternative text", "blockID", videoTitle) + + assert.Equal(t, string(videoBlock.Type), "video") + assert.Equal(t, videoBlock.Title.Type, "plain_text") + assert.Equal(t, videoBlock.BlockID, "blockID") + assert.Contains(t, videoBlock.Title.Text, "VideoTitle") + assert.Contains(t, videoBlock.VideoURL, "example.mp4") + +} diff --git a/bookmarks.go b/bookmarks.go index bc1bbfff5..e8c541bb4 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -24,102 +24,133 @@ type Bookmark struct { AppID *string `json:"app_id"` } -// ListBookmarks returns all the bookmarks in the given channel -func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) { - return api.ListBookmarksContext(context.Background(), channelID) +type AddBookmarkParameters struct { + Title string // A required title for the bookmark + Type string // A required type for the bookmark + Link string // URL required for type:link + Emoji string // An optional emoji + EntityID string + ParentID string + ChannelID string `json:"channel_id"` } -// ListBookmarksContext returns all the bookmarks in the given channel -func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) { - values := url.Values{ - "token": {api.token}, - "channel_id": {channelID}, - } +type EditBookmarkParameters struct { + Title *string // Change the title. Set to "" to clear + Emoji *string // Change the emoji. Set to "" to clear + Link string // Change the link + ChannelID string `json:"channel_id"` + BookmarkID string `json:"bookmark_id"` + Type string `json:"type,omitempty"` +} - response := &listBookmarksResponseFull{} - err := api.postMethod(ctx, "bookmarks.list", values, response) - if err != nil { - return nil, err - } - if !response.Ok { - return nil, errors.New(response.Error) - } - return response.Bookmarks, nil +type addBookmarkResponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse } -type AddBookmarkParameters struct { - Title string `json:"title"` - Type string `json:"type"` - Link string `json:"link,omitempty"` - Emoji string `json:"emoji,omitempty"` - EntityID string `json:"entity_id,omitempty"` - ParentID string `json:"parent_id,omitempty"` - ChannelID string `json:"channel_id"` +type editBookmarkResponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse +} + +type listBookmarksResponse struct { + Bookmarks []Bookmark `json:"bookmarks"` + SlackResponse } -// AddBookmark creates a new bookmark. ChannelID, Title, and Type are required -// (`Type=link` is the sensible default!). The other params are all optional. -func (api *Client) AddBookmark(params AddBookmarkParameters) (*Bookmark, error) { - return api.AddBookmarkContext(context.Background(), params) +// AddBookmark adds a bookmark in a channel. +// For more details, see AddBookmarkContext documentation. +func (api *Client) AddBookmark(channelID string, params AddBookmarkParameters) (Bookmark, error) { + return api.AddBookmarkContext(context.Background(), channelID, params) } -// AddBookmarkContext creates a new bookmark. ChannelID, Title, and Type are required -// (`Type: "link"` is the sensible default!). The other params are all optional. -func (api *Client) AddBookmarkContext(ctx context.Context, params AddBookmarkParameters) (*Bookmark, error) { - response := &singleBookmarkResponse{} +// AddBookmarkContext adds a bookmark in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.add +func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, params AddBookmarkParameters) (Bookmark, error) { values := url.Values{ + "channel_id": {channelID}, "token": {api.token}, - "channel_id": {params.ChannelID}, "title": {params.Title}, "type": {params.Type}, } - + if params.Link != "" { + values.Set("link", params.Link) + } if params.Emoji != "" { - values["emoji"] = []string{params.Emoji} + values.Set("emoji", params.Emoji) } - if params.EntityID != "" { - values["entity_id"] = []string{params.EntityID} + values.Set("entity_id", params.EntityID) } - - if params.Link != "" { - values["link"] = []string{params.Link} + if params.ParentID != "" { + values.Set("parent_id", params.ParentID) } - if params.ParentID != "" { - values["parent_id"] = []string{params.ParentID} + response := &addBookmarkResponse{} + if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil { + return Bookmark{}, err } - err := api.postMethod(ctx, "bookmarks.add", values, response) - if err != nil { - return nil, err + return response.Bookmark, response.Err() +} + +// RemoveBookmark removes a bookmark from a channel. +// For more details, see RemoveBookmarkContext documentation. +func (api *Client) RemoveBookmark(channelID, bookmarkID string) error { + return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID) +} + +// RemoveBookmarkContext removes a bookmark from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.remove +func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error { + values := url.Values{ + "channel_id": {channelID}, + "token": {api.token}, + "bookmark_id": {bookmarkID}, } - if err := response.Err(); err != nil { - return nil, err + + response := &SlackResponse{} + if err := api.postMethod(ctx, "bookmarks.remove", values, response); err != nil { + return err } - return &response.Bookmark, nil + return response.Err() } -type EditBookmarkParameters struct { - Title string `json:"title,omitempty"` - Emoji string `json:"emoji,omitempty"` - Link string `json:"link,omitempty"` - ChannelID string `json:"channel_id"` - BookmarkID string `json:"bookmark_id"` - Type string `json:"type,omitempty"` +// ListBookmarks returns all bookmarks for a channel. +// For more details, see ListBookmarksContext documentation. +func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) { + return api.ListBookmarksContext(context.Background(), channelID) } -// EditBookmark updates an existing bookmark. ChannelID and BookmarkID are -// required, other params are optional. -func (api *Client) EditBookmark(params EditBookmarkParameters) (*Bookmark, error) { - return api.EditBookmarkContext(context.Background(), params) +// ListBookmarksContext returns all bookmarks for a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.edit +func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &listBookmarksResponseFull{} + err := api.postMethod(ctx, "bookmarks.list", values, response) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response.Bookmarks, nil +} + +// EditBookmark edits a bookmark in a channel. +// For more details, see EditBookmarkContext documentation. +func (api *Client) EditBookmark(channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { + return api.EditBookmarkContext(context.Background(), channelID, bookmarkID, params) } -// EditBookmarkContext updates an existing bookmark. ChannelID and BookmarkID -// are required, other params are optional. -func (api *Client) EditBookmarkContext(ctx context.Context, params EditBookmarkParameters) (*Bookmark, error) { - response := &singleBookmarkResponse{} +// EditBookmarkContext edits a bookmark in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.edit +func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { values := url.Values{ "token": {api.token}, "channel_id": {params.ChannelID}, @@ -130,49 +161,29 @@ func (api *Client) EditBookmarkContext(ctx context.Context, params EditBookmarkP values["type"] = []string{params.Type} } - if params.Emoji != "" { - values["emoji"] = []string{params.Emoji} + if params.Emoji != nil { + values["emoji"] = []string{*params.Emoji} } if params.Link != "" { values["link"] = []string{params.Link} } - if params.Title != "" { - values["title"] = []string{params.Title} + if params.Title != nil { + values["title"] = []string{*params.Title} } + response := &editBookmarkResponse{} err := api.postMethod(ctx, "bookmarks.edit", values, &response) if err != nil { - return nil, err + return Bookmark{}, err } if err := response.Err(); err != nil { - return nil, err - } - - return &response.Bookmark, nil -} - -// RemoveBookmark deletes a bookmark from the given channel -func (api *Client) RemoveBookmark(channelID, bookmarkID string) error { - return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID) -} - -// RemoveBookmarkContext deletes a bookmark from the given channel -func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error { - response := &SlackResponse{} - values := url.Values{ - "token": {api.token}, - "channel_id": {channelID}, - "bookmark_id": {bookmarkID}, + return Bookmark{}, err } - err := api.postMethod(ctx, "bookmarks.remove", values, response) - if err != nil { - return err - } - return response.Err() + return response.Bookmark, nil } type listBookmarksResponseFull struct { diff --git a/bookmarks_test.go b/bookmarks_test.go index 60221f37e..6da45c182 100644 --- a/bookmarks_test.go +++ b/bookmarks_test.go @@ -87,10 +87,9 @@ func TestAddBookmark(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bookmark, err := api.AddBookmark(AddBookmarkParameters{ - ChannelID: "C12345", - Title: "Homepage", - Type: "link", + bookmark, err := api.AddBookmark("C12345", AddBookmarkParameters{ + Title: "Homepage", + Type: "link", }) if err != nil { @@ -98,11 +97,6 @@ func TestAddBookmark(t *testing.T) { return } - if bookmark == nil { - t.Fatal("bookmark returned was nil") - return - } - if bookmark.ID != "Bk12345" { t.Errorf("bookmark ID should be Bk12345, got %s", bookmark.ID) } @@ -112,12 +106,13 @@ func TestEditBookmark(t *testing.T) { http.HandleFunc("/bookmarks.edit", getBookmark) once.Do(startServer) + emoji := ":siren:" + title := "hello2" + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bookmark, err := api.EditBookmark(EditBookmarkParameters{ - ChannelID: "C12345", - BookmarkID: "Bk12345", - Emoji: ":siren:", - Title: "hello2", + bookmark, err := api.EditBookmark("C12345", "Bk12345", EditBookmarkParameters{ + Emoji: &emoji, + Title: &title, }) if err != nil { @@ -125,11 +120,6 @@ func TestEditBookmark(t *testing.T) { return } - if bookmark == nil { - t.Fatal("bookmark returned was nil") - return - } - if bookmark.ID != "Bk12345" { t.Errorf("bookmark ID should be Bk12345, got %s", bookmark.ID) } diff --git a/bots.go b/bots.go index da21ba0c9..1ab946962 100644 --- a/bots.go +++ b/bots.go @@ -35,19 +35,30 @@ func (api *Client) botRequest(ctx context.Context, path string, values url.Value return response, nil } -// GetBotInfo will retrieve the complete bot information -func (api *Client) GetBotInfo(bot string) (*Bot, error) { - return api.GetBotInfoContext(context.Background(), bot) +type GetBotInfoParameters struct { + Bot string + TeamID string } -// GetBotInfoContext will retrieve the complete bot information using a custom context -func (api *Client) GetBotInfoContext(ctx context.Context, bot string) (*Bot, error) { +// GetBotInfo will retrieve the complete bot information. +// For more details, see GetBotInfoContext documentation. +func (api *Client) GetBotInfo(parameters GetBotInfoParameters) (*Bot, error) { + return api.GetBotInfoContext(context.Background(), parameters) +} + +// GetBotInfoContext will retrieve the complete bot information using a custom context. +// Slack API docs: https://api.slack.com/methods/bots.info +func (api *Client) GetBotInfoContext(ctx context.Context, parameters GetBotInfoParameters) (*Bot, error) { values := url.Values{ "token": {api.token}, } - if bot != "" { - values.Add("bot", bot) + if parameters.Bot != "" { + values.Add("bot", parameters.Bot) + } + + if parameters.TeamID != "" { + values.Add("team_id", parameters.TeamID) } response, err := api.botRequest(ctx, "bots.info", values) diff --git a/bots_test.go b/bots_test.go index ce7f66805..14a509e5f 100644 --- a/bots_test.go +++ b/bots_test.go @@ -29,7 +29,7 @@ func TestGetBotInfo(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bot, err := api.GetBotInfo("B02875YLA") + bot, err := api.GetBotInfo(GetBotInfoParameters{Bot: "B02875YLA"}) if err != nil { t.Errorf("Unexpected error: %s", err) return diff --git a/calls.go b/calls.go new file mode 100644 index 000000000..2d6e91f16 --- /dev/null +++ b/calls.go @@ -0,0 +1,216 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "time" +) + +type Call struct { + ID string `json:"id"` + Title string `json:"title"` + DateStart JSONTime `json:"date_start"` + DateEnd JSONTime `json:"date_end"` + ExternalUniqueID string `json:"external_unique_id"` + JoinURL string `json:"join_url"` + DesktopAppJoinURL string `json:"desktop_app_join_url"` + ExternalDisplayID string `json:"external_display_id"` + Participants []CallParticipant `json:"users"` + Channels []string `json:"channels"` +} + +// CallParticipant is a thin user representation which has a SlackID, ExternalID, or both. +// +// See: https://api.slack.com/apis/calls#users +type CallParticipant struct { + SlackID string `json:"slack_id,omitempty"` + ExternalID string `json:"external_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// Valid checks if the CallUser has a is valid with a SlackID or ExternalID or both. +func (u CallParticipant) Valid() bool { + return u.SlackID != "" || u.ExternalID != "" +} + +type AddCallParameters struct { + JoinURL string // Required + ExternalUniqueID string // Required + CreatedBy string // Required if using a bot token + Title string + DesktopAppJoinURL string + ExternalDisplayID string + DateStart JSONTime + Participants []CallParticipant +} + +type UpdateCallParameters struct { + Title string + DesktopAppJoinURL string + JoinURL string +} + +type EndCallParameters struct { + // Duration is the duration of the call in seconds. Omitted if 0. + Duration time.Duration +} + +type callResponse struct { + Call Call `json:"call"` + SlackResponse +} + +// AddCall adds a new Call to the Slack API. +func (api *Client) AddCall(params AddCallParameters) (Call, error) { + return api.AddCallContext(context.Background(), params) +} + +// AddCallContext adds a new Call to the Slack API. +func (api *Client) AddCallContext(ctx context.Context, params AddCallParameters) (Call, error) { + values := url.Values{ + "token": {api.token}, + "join_url": {params.JoinURL}, + "external_unique_id": {params.ExternalUniqueID}, + } + if params.CreatedBy != "" { + values.Set("created_by", params.CreatedBy) + } + if params.DateStart != 0 { + values.Set("date_start", strconv.FormatInt(int64(params.DateStart), 10)) + } + if params.DesktopAppJoinURL != "" { + values.Set("desktop_app_join_url", params.DesktopAppJoinURL) + } + if params.ExternalDisplayID != "" { + values.Set("external_display_id", params.ExternalDisplayID) + } + if params.Title != "" { + values.Set("title", params.Title) + } + if len(params.Participants) > 0 { + data, err := json.Marshal(params.Participants) + if err != nil { + return Call{}, err + } + values.Set("users", string(data)) + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.add", values, response); err != nil { + return Call{}, err + } + + return response.Call, response.Err() +} + +// GetCallInfo returns information about a Call. +func (api *Client) GetCall(callID string) (Call, error) { + return api.GetCallContext(context.Background(), callID) +} + +// GetCallInfoContext returns information about a Call. +func (api *Client) GetCallContext(ctx context.Context, callID string) (Call, error) { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.info", values, response); err != nil { + return Call{}, err + } + return response.Call, response.Err() +} + +func (api *Client) UpdateCall(callID string, params UpdateCallParameters) (Call, error) { + return api.UpdateCallContext(context.Background(), callID, params) +} + +// UpdateCallContext updates a Call with the given parameters. +func (api *Client) UpdateCallContext(ctx context.Context, callID string, params UpdateCallParameters) (Call, error) { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + if params.DesktopAppJoinURL != "" { + values.Set("desktop_app_join_url", params.DesktopAppJoinURL) + } + if params.JoinURL != "" { + values.Set("join_url", params.JoinURL) + } + if params.Title != "" { + values.Set("title", params.Title) + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.update", values, response); err != nil { + return Call{}, err + } + return response.Call, response.Err() +} + +// EndCall ends a Call. +func (api *Client) EndCall(callID string, params EndCallParameters) error { + return api.EndCallContext(context.Background(), callID, params) +} + +// EndCallContext ends a Call. +func (api *Client) EndCallContext(ctx context.Context, callID string, params EndCallParameters) error { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + if params.Duration != 0 { + values.Set("duration", strconv.FormatInt(int64(params.Duration.Seconds()), 10)) + } + + response := &SlackResponse{} + if err := api.postMethod(ctx, "calls.end", values, response); err != nil { + return err + } + return response.Err() +} + +// CallAddParticipants adds users to a Call. +func (api *Client) CallAddParticipants(callID string, participants []CallParticipant) error { + return api.CallAddParticipantsContext(context.Background(), callID, participants) +} + +// CallAddParticipantsContext adds users to a Call. +func (api *Client) CallAddParticipantsContext(ctx context.Context, callID string, participants []CallParticipant) error { + return api.setCallParticipants(ctx, "calls.participants.add", callID, participants) +} + +// CallRemoveParticipants removes users from a Call. +func (api *Client) CallRemoveParticipants(callID string, participants []CallParticipant) error { + return api.CallRemoveParticipantsContext(context.Background(), callID, participants) +} + +// CallRemoveParticipantsContext removes users from a Call. +func (api *Client) CallRemoveParticipantsContext(ctx context.Context, callID string, participants []CallParticipant) error { + return api.setCallParticipants(ctx, "calls.participants.remove", callID, participants) +} + +func (api *Client) setCallParticipants(ctx context.Context, method, callID string, participants []CallParticipant) error { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + data, err := json.Marshal(participants) + if err != nil { + return err + } + values.Set("users", string(data)) + + response := &SlackResponse{} + if err := api.postMethod(ctx, method, values, response); err != nil { + return err + } + return response.Err() +} diff --git a/calls_test.go b/calls_test.go new file mode 100644 index 000000000..0c225fb86 --- /dev/null +++ b/calls_test.go @@ -0,0 +1,189 @@ +package slack + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestCall(callID string) Call { + return Call{ + ID: callID, + Title: "test call", + JoinURL: "https://example.com/example", + ExternalUniqueID: "123", + } +} + +func testClient(api string, f http.HandlerFunc) *Client { + http.HandleFunc(api, f) + once.Do(startServer) + return New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) +} + +var callTestId = 999 + +func addCallHandler(t *testing.T) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + call := Call{ + ID: fmt.Sprintf("R%d", callTestId), + Title: r.FormValue("title"), + JoinURL: r.FormValue("join_url"), + ExternalUniqueID: r.FormValue("external_unique_id"), + ExternalDisplayID: r.FormValue("external_display_id"), + DesktopAppJoinURL: r.FormValue("desktop_app_join_url"), + } + callTestId += 1 + json.Unmarshal([]byte(r.FormValue("users")), &call.Participants) + if start := r.FormValue("date_start"); start != "" { + dateStart, err := strconv.ParseInt(start, 10, 64) + require.NoError(t, err) + call.DateStart = JSONTime(dateStart) + } + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + } +} + +func TestAddCall(t *testing.T) { + api := testClient("/calls.add", addCallHandler(t)) + params := AddCallParameters{ + Title: "test call", + JoinURL: "https://example.com/example", + ExternalUniqueID: "123", + } + call, err := api.AddCall(params) + require.NoError(t, err) + assert.Equal(t, params.Title, call.Title) + assert.Equal(t, params.JoinURL, call.JoinURL) + assert.Equal(t, params.ExternalUniqueID, call.ExternalUniqueID) +} + +func getCallHandler(calls []Call) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + callID := r.FormValue("id") + + rw.Header().Set("Content-Type", "application/json") + for _, call := range calls { + if call.ID == callID { + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + return + } + } + // Fail if the call doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestGetCall(t *testing.T) { + calls := []Call{ + getTestCall("R1234567890"), + getTestCall("R1234567891"), + } + http.HandleFunc("/calls.info", getCallHandler(calls)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + for _, call := range calls { + resp, err := api.GetCall(call.ID) + require.NoError(t, err) + assert.Equal(t, call, resp) + } + // Test a call that doesn't exist + _, err := api.GetCall("R1234567892") + require.Error(t, err) +} + +func updateCallHandler(calls []Call) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + callID := r.FormValue("id") + + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + + for _, call := range calls { + if call.ID == callID { + if title := r.FormValue("title"); title != "" { + call.Title = title + } + if joinURL := r.FormValue("join_url"); joinURL != "" { + call.JoinURL = joinURL + } + if desktopAppJoinURL := r.FormValue("desktop_app_join_url"); desktopAppJoinURL != "" { + call.DesktopAppJoinURL = desktopAppJoinURL + } + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + return + } + } + // Fail if the call doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestUpdateCall(t *testing.T) { + calls := []Call{ + getTestCall("R1234567890"), + getTestCall("R1234567891"), + getTestCall("R1234567892"), + getTestCall("R1234567893"), + getTestCall("R1234567894"), + } + http.HandleFunc("/calls.update", updateCallHandler(calls)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + changes := []struct { + callID string + params UpdateCallParameters + }{ + { + callID: "R1234567890", + params: UpdateCallParameters{Title: "test"}, + }, + { + callID: "R1234567891", + params: UpdateCallParameters{JoinURL: "https://example.com/join"}, + }, + { + callID: "R1234567892", + params: UpdateCallParameters{DesktopAppJoinURL: "https://example.com/join"}, + }, + { // Change multiple fields at once + callID: "R1234567893", + params: UpdateCallParameters{ + Title: "test", + JoinURL: "https://example.com/join", + }, + }, + } + + for _, change := range changes { + call, err := api.UpdateCall(change.callID, change.params) + require.NoError(t, err) + if change.params.Title != "" && call.Title != change.params.Title { + t.Fatalf("Expected title to be %s, got %s", change.params.Title, call.Title) + } + if change.params.JoinURL != "" && call.JoinURL != change.params.JoinURL { + t.Fatalf("Expected join_url to be %s, got %s", change.params.JoinURL, call.JoinURL) + } + if change.params.DesktopAppJoinURL != "" && call.DesktopAppJoinURL != change.params.DesktopAppJoinURL { + t.Fatalf("Expected desktop_app_join_url to be %s, got %s", change.params.DesktopAppJoinURL, call.DesktopAppJoinURL) + } + } +} diff --git a/canvas.go b/canvas.go new file mode 100644 index 000000000..5225afa35 --- /dev/null +++ b/canvas.go @@ -0,0 +1,264 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +type CanvasDetails struct { + CanvasID string `json:"canvas_id"` +} + +type DocumentContent struct { + Type string `json:"type"` + Markdown string `json:"markdown,omitempty"` +} + +type CanvasChange struct { + Operation string `json:"operation"` + SectionID string `json:"section_id,omitempty"` + DocumentContent DocumentContent `json:"document_content"` +} + +type EditCanvasParams struct { + CanvasID string `json:"canvas_id"` + Changes []CanvasChange `json:"changes"` +} + +type SetCanvasAccessParams struct { + CanvasID string `json:"canvas_id"` + AccessLevel string `json:"access_level"` + ChannelIDs []string `json:"channel_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type DeleteCanvasAccessParams struct { + CanvasID string `json:"canvas_id"` + ChannelIDs []string `json:"channel_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type LookupCanvasSectionsCriteria struct { + SectionTypes []string `json:"section_types,omitempty"` + ContainsText string `json:"contains_text,omitempty"` +} + +type LookupCanvasSectionsParams struct { + CanvasID string `json:"canvas_id"` + Criteria LookupCanvasSectionsCriteria `json:"criteria"` +} + +type CanvasSection struct { + ID string `json:"id"` +} + +type LookupCanvasSectionsResponse struct { + SlackResponse + Sections []CanvasSection `json:"sections"` +} + +// CreateCanvas creates a new canvas. +// For more details, see CreateCanvasContext documentation. +func (api *Client) CreateCanvas(title string, documentContent DocumentContent) (string, error) { + return api.CreateCanvasContext(context.Background(), title, documentContent) +} + +// CreateCanvasContext creates a new canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.create +func (api *Client) CreateCanvasContext(ctx context.Context, title string, documentContent DocumentContent) (string, error) { + values := url.Values{ + "token": {api.token}, + } + if title != "" { + values.Add("title", title) + } + if documentContent.Type != "" { + documentContentJSON, err := json.Marshal(documentContent) + if err != nil { + return "", err + } + values.Add("document_content", string(documentContentJSON)) + } + + response := struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{} + + err := api.postMethod(ctx, "canvases.create", values, &response) + if err != nil { + return "", err + } + + return response.CanvasID, response.Err() +} + +// DeleteCanvas deletes an existing canvas. +// For more details, see DeleteCanvasContext documentation. +func (api *Client) DeleteCanvas(canvasID string) error { + return api.DeleteCanvasContext(context.Background(), canvasID) +} + +// DeleteCanvasContext deletes an existing canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.delete +func (api *Client) DeleteCanvasContext(ctx context.Context, canvasID string) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {canvasID}, + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.delete", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// EditCanvas edits an existing canvas. +// For more details, see EditCanvasContext documentation. +func (api *Client) EditCanvas(params EditCanvasParams) error { + return api.EditCanvasContext(context.Background(), params) +} + +// EditCanvasContext edits an existing canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.edit +func (api *Client) EditCanvasContext(ctx context.Context, params EditCanvasParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + + changesJSON, err := json.Marshal(params.Changes) + if err != nil { + return err + } + values.Add("changes", string(changesJSON)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "canvases.edit", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// SetCanvasAccess sets the access level to a canvas for specified entities. +// For more details, see SetCanvasAccessContext documentation. +func (api *Client) SetCanvasAccess(params SetCanvasAccessParams) error { + return api.SetCanvasAccessContext(context.Background(), params) +} + +// SetCanvasAccessContext sets the access level to a canvas for specified entities with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.access.set +func (api *Client) SetCanvasAccessContext(ctx context.Context, params SetCanvasAccessParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + "access_level": {params.AccessLevel}, + } + if len(params.ChannelIDs) > 0 { + channelIDsJSON, err := json.Marshal(params.ChannelIDs) + if err != nil { + return err + } + values.Add("channel_ids", string(channelIDsJSON)) + } + if len(params.UserIDs) > 0 { + userIDsJSON, err := json.Marshal(params.UserIDs) + if err != nil { + return err + } + values.Add("user_ids", string(userIDsJSON)) + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.access.set", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// DeleteCanvasAccess removes access to a canvas for specified entities. +// For more details, see DeleteCanvasAccessContext documentation. +func (api *Client) DeleteCanvasAccess(params DeleteCanvasAccessParams) error { + return api.DeleteCanvasAccessContext(context.Background(), params) +} + +// DeleteCanvasAccessContext removes access to a canvas for specified entities with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.access.delete +func (api *Client) DeleteCanvasAccessContext(ctx context.Context, params DeleteCanvasAccessParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + if len(params.ChannelIDs) > 0 { + channelIDsJSON, err := json.Marshal(params.ChannelIDs) + if err != nil { + return err + } + values.Add("channel_ids", string(channelIDsJSON)) + } + if len(params.UserIDs) > 0 { + userIDsJSON, err := json.Marshal(params.UserIDs) + if err != nil { + return err + } + values.Add("user_ids", string(userIDsJSON)) + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.access.delete", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// LookupCanvasSections finds sections matching the provided criteria. +// For more details, see LookupCanvasSectionsContext documentation. +func (api *Client) LookupCanvasSections(params LookupCanvasSectionsParams) ([]CanvasSection, error) { + return api.LookupCanvasSectionsContext(context.Background(), params) +} + +// LookupCanvasSectionsContext finds sections matching the provided criteria with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.sections.lookup +func (api *Client) LookupCanvasSectionsContext(ctx context.Context, params LookupCanvasSectionsParams) ([]CanvasSection, error) { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + + criteriaJSON, err := json.Marshal(params.Criteria) + if err != nil { + return nil, err + } + values.Add("criteria", string(criteriaJSON)) + + response := LookupCanvasSectionsResponse{} + + err = api.postMethod(ctx, "canvases.sections.lookup", values, &response) + if err != nil { + return nil, err + } + + return response.Sections, response.Err() +} diff --git a/canvas_test.go b/canvas_test.go new file mode 100644 index 000000000..c0e301039 --- /dev/null +++ b/canvas_test.go @@ -0,0 +1,216 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func createCanvasHandler(rw http.ResponseWriter, r *http.Request) { + title := r.FormValue("title") + documentContent := r.FormValue("document_content") + + rw.Header().Set("Content-Type", "application/json") + + if title != "" && documentContent != "" { + resp, _ := json.Marshal(&struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{ + SlackResponse: SlackResponse{Ok: true}, + CanvasID: "F1234ABCD", + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestCreateCanvas(t *testing.T) { + http.HandleFunc("/canvases.create", createCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "Test Content", + } + + canvasID, err := api.CreateCanvas("Test Canvas", documentContent) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if canvasID != "F1234ABCD" { + t.Fatalf("Expected canvas ID to be F1234ABCD, got %s", canvasID) + } +} + +func deleteCanvasHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestDeleteCanvas(t *testing.T) { + http.HandleFunc("/canvases.delete", deleteCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.DeleteCanvas("F1234ABCD") + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func editCanvasHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestEditCanvas(t *testing.T) { + http.HandleFunc("/canvases.edit", editCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := EditCanvasParams{ + CanvasID: "F1234ABCD", + Changes: []CanvasChange{ + { + Operation: "update", + SectionID: "S1234", + DocumentContent: DocumentContent{ + Type: "markdown", + Markdown: "Updated Content", + }, + }, + }, + } + + err := api.EditCanvas(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func setCanvasAccessHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestSetCanvasAccess(t *testing.T) { + http.HandleFunc("/canvases.access.set", setCanvasAccessHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := SetCanvasAccessParams{ + CanvasID: "F1234ABCD", + AccessLevel: "read", + ChannelIDs: []string{"C1234ABCD"}, + UserIDs: []string{"U1234ABCD"}, + } + + err := api.SetCanvasAccess(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func deleteCanvasAccessHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestDeleteCanvasAccess(t *testing.T) { + http.HandleFunc("/canvases.access.delete", deleteCanvasAccessHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := DeleteCanvasAccessParams{ + CanvasID: "F1234ABCD", + ChannelIDs: []string{"C1234ABCD"}, + UserIDs: []string{"U1234ABCD"}, + } + + err := api.DeleteCanvasAccess(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func lookupCanvasSectionsHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + sections := []CanvasSection{ + {ID: "S1234"}, + {ID: "S5678"}, + } + + resp, _ := json.Marshal(&LookupCanvasSectionsResponse{ + SlackResponse: SlackResponse{Ok: true}, + Sections: sections, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestLookupCanvasSections(t *testing.T) { + http.HandleFunc("/canvases.sections.lookup", lookupCanvasSectionsHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := LookupCanvasSectionsParams{ + CanvasID: "F1234ABCD", + Criteria: LookupCanvasSectionsCriteria{ + SectionTypes: []string{"h1", "h2"}, + ContainsText: "Test", + }, + } + + sections, err := api.LookupCanvasSections(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + expectedSections := []CanvasSection{ + {ID: "S1234"}, + {ID: "S5678"}, + } + + if !reflect.DeepEqual(expectedSections, sections) { + t.Fatalf("Expected sections %v, got %v", expectedSections, sections) + } +} diff --git a/channels.go b/channels.go index 2fca8b92e..88d567bff 100644 --- a/channels.go +++ b/channels.go @@ -19,10 +19,11 @@ type channelResponseFull struct { // Channel contains information about the channel type Channel struct { GroupConversation - IsChannel bool `json:"is_channel"` - IsGeneral bool `json:"is_general"` - IsMember bool `json:"is_member"` - Locale string `json:"locale"` + IsChannel bool `json:"is_channel"` + IsGeneral bool `json:"is_general"` + IsMember bool `json:"is_member"` + Locale string `json:"locale"` + Properties *Properties `json:"properties"` } func (api *Client) channelRequest(ctx context.Context, path string, values url.Values) (*channelResponseFull, error) { diff --git a/chat.go b/chat.go index 4448e9f40..96843d68d 100644 --- a/chat.go +++ b/chat.go @@ -4,9 +4,10 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net/http" "net/url" + "regexp" "strconv" "github.com/slack-go/slack/slackutilsx" @@ -29,14 +30,14 @@ const ( type chatResponseFull struct { Channel string `json:"channel"` - Timestamp string `json:"ts"` //Regular message timestamp - MessageTimeStamp string `json:"message_ts"` //Ephemeral message timestamp - ScheduledMessageID string `json:"scheduled_message_id,omitempty"` //Scheduled message id + Timestamp string `json:"ts"` // Regular message timestamp + MessageTimeStamp string `json:"message_ts"` // Ephemeral message timestamp + ScheduledMessageID string `json:"scheduled_message_id,omitempty"` // Scheduled message id Text string `json:"text"` SlackResponse } -// getMessageTimestamp will inspect the `chatResponseFull` to ruturn a timestamp value +// getMessageTimestamp will inspect the `chatResponseFull` to return a timestamp value // in `chat.postMessage` its under `ts` // in `chat.postEphemeral` its under `message_ts` func (c chatResponseFull) getMessageTimestamp() string { @@ -87,12 +88,14 @@ func NewPostMessageParameters() PostMessageParameters { } } -// DeleteMessage deletes a message in a channel +// DeleteMessage deletes a message in a channel. +// For more details, see DeleteMessageContext documentation. func (api *Client) DeleteMessage(channel, messageTimestamp string) (string, string, error) { return api.DeleteMessageContext(context.Background(), channel, messageTimestamp) } -// DeleteMessageContext deletes a message in a channel with a custom context +// DeleteMessageContext deletes a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.delete func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTimestamp string) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -105,13 +108,13 @@ func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTim // ScheduleMessage sends a message to a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see ScheduleMessageContext documentation. func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOption) (string, string, error) { return api.ScheduleMessageContext(context.Background(), channelID, postAt, options...) } -// ScheduleMessageContext sends a message to a channel with a custom context -// -// For more details, see ScheduleMessage documentation. +// ScheduleMessageContext sends a message to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.scheduleMessage func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -125,12 +128,13 @@ func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt // PostMessage sends a message to a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see PostMessageContext documentation. func (api *Client) PostMessage(channelID string, options ...MsgOption) (string, string, error) { return api.PostMessageContext(context.Background(), channelID, options...) } -// PostMessageContext sends a message to a channel with a custom context -// For more details, see PostMessage documentation. +// PostMessageContext sends a message to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) PostMessageContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -144,12 +148,13 @@ func (api *Client) PostMessageContext(ctx context.Context, channelID string, opt // PostEphemeral sends an ephemeral message to a user in a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see PostEphemeralContext documentation. func (api *Client) PostEphemeral(channelID, userID string, options ...MsgOption) (string, error) { return api.PostEphemeralContext(context.Background(), channelID, userID, options...) } -// PostEphemeralContext sends an ephemeal message to a user in a channel with a custom context -// For more details, see PostEphemeral documentation +// PostEphemeralContext sends an ephemeral message to a user in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postEphemeral func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID string, options ...MsgOption) (timestamp string, err error) { _, timestamp, _, err = api.SendMessageContext( ctx, @@ -160,12 +165,14 @@ func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID s return timestamp, err } -// UpdateMessage updates a message in a channel +// UpdateMessage updates a message in a channel. +// For more details, see UpdateMessageContext documentation. func (api *Client) UpdateMessage(channelID, timestamp string, options ...MsgOption) (string, string, string, error) { return api.UpdateMessageContext(context.Background(), channelID, timestamp, options...) } -// UpdateMessageContext updates a message in a channel +// UpdateMessageContext updates a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.update func (api *Client) UpdateMessageContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext( ctx, @@ -175,38 +182,38 @@ func (api *Client) UpdateMessageContext(ctx context.Context, channelID, timestam ) } -// UnfurlMessage unfurls a message in a channel +// UnfurlMessage unfurls a message in a channel. +// For more details, see UnfurlMessageContext documentation. func (api *Client) UnfurlMessage(channelID, timestamp string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { return api.UnfurlMessageContext(context.Background(), channelID, timestamp, unfurls, options...) } -// UnfurlMessageContext unfurls a message in a channel with a custom context +// UnfurlMessageContext unfurls a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.unfurl func (api *Client) UnfurlMessageContext(ctx context.Context, channelID, timestamp string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(ctx, channelID, MsgOptionUnfurl(timestamp, unfurls), MsgOptionCompose(options...)) } -// UnfurlMessageWithAuthURL sends an unfurl request containing an -// authentication URL. -// For more details see: -// https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls +// UnfurlMessageWithAuthURL sends an unfurl request containing an authentication URL. +// For more details, see UnfurlMessageWithAuthURLContext documentation. func (api *Client) UnfurlMessageWithAuthURL(channelID, timestamp string, userAuthURL string, options ...MsgOption) (string, string, string, error) { return api.UnfurlMessageWithAuthURLContext(context.Background(), channelID, timestamp, userAuthURL, options...) } -// UnfurlMessageWithAuthURLContext sends an unfurl request containing an -// authentication URL. -// For more details see: -// https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls +// UnfurlMessageWithAuthURLContext sends an unfurl request containing an authentication URL with a custom context. +// For more details see: https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls func (api *Client) UnfurlMessageWithAuthURLContext(ctx context.Context, channelID, timestamp string, userAuthURL string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(ctx, channelID, MsgOptionUnfurlAuthURL(timestamp, userAuthURL), MsgOptionCompose(options...)) } // SendMessage more flexible method for configuring messages. +// For more details, see SendMessageContext documentation. func (api *Client) SendMessage(channel string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(context.Background(), channel, options...) } // SendMessageContext more flexible method for configuring messages with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestamp string, _text string, err error) { var ( req *http.Request @@ -219,12 +226,12 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt } if api.Debug() { - reqBody, err := ioutil.ReadAll(req.Body) + reqBody, err := io.ReadAll(req.Body) if err != nil { return "", "", "", err } - req.Body = ioutil.NopCloser(bytes.NewBuffer(reqBody)) - api.Debugf("Sending request: %s", string(reqBody)) + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + api.Debugf("Sending request: %s", redactToken(reqBody)) } if err = doPost(ctx, api.httpclient, req, parser(&response), api); err != nil { @@ -234,6 +241,20 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() } +func redactToken(b []byte) []byte { + // See https://api.slack.com/authentication/token-types + // and https://api.slack.com/authentication/rotation + re, err := regexp.Compile(`(token=x[a-z.]+)-[0-9A-Za-z-]+`) + if err != nil { + // The regular expression above should never result in errors, + // but just in case, do no harm. + return b + } + // Keep "token=" and the first element of the token, which identifies its type + // (this could be useful for debugging, e.g. when using a wrong token). + return re.ReplaceAll(b, []byte("$1-REDACTED")) +} + // UnsafeApplyMsgOptions utility function for debugging/testing chat requests. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this function // will be supported by the library. @@ -356,6 +377,7 @@ func (t responseURLSender) BuildRequestContext(ctx context.Context) (*http.Reque req, err := jsonReq(ctx, t.endpoint, Msg{ Text: t.values.Get("text"), Timestamp: t.values.Get("ts"), + ThreadTimestamp: t.values.Get("thread_ts"), Attachments: t.attachments, Blocks: t.blocks, Metadata: t.metadata, @@ -681,6 +703,14 @@ func MsgOptionMetadata(metadata SlackMetadata) MsgOption { } } +// MsgOptionLinkNames finds and links user groups. Does not support linking individual users +func MsgOptionLinkNames(linkName bool) MsgOption { + return func(config *sendConfig) error { + config.values.Set("link_names", strconv.FormatBool(linkName)) + return nil + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that @@ -748,22 +778,21 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { } } -// PermalinkParameters are the parameters required to get a permalink to a -// message. Slack documentation can be found here: -// https://api.slack.com/methods/chat.getPermalink +// PermalinkParameters are the parameters required to get a permalink to a message. type PermalinkParameters struct { Channel string Ts string } -// GetPermalink returns the permalink for a message. It takes -// PermalinkParameters and returns a string containing the permalink. It -// returns an error if unable to retrieve the permalink. +// GetPermalink returns the permalink for a message. It takes PermalinkParameters and returns a string containing the +// permalink. It returns an error if unable to retrieve the permalink. +// For more details, see GetPermalinkContext documentation. func (api *Client) GetPermalink(params *PermalinkParameters) (string, error) { return api.GetPermalinkContext(context.Background(), params) } // GetPermalinkContext returns the permalink for a message using a custom context. +// Slack API docs: https://api.slack.com/methods/chat.getPermalink func (api *Client) GetPermalinkContext(ctx context.Context, params *PermalinkParameters) (string, error) { values := url.Values{ "channel": {params.Channel}, @@ -784,18 +813,21 @@ func (api *Client) GetPermalinkContext(ctx context.Context, params *PermalinkPar type GetScheduledMessagesParameters struct { Channel string + TeamID string Cursor string Latest string Limit int Oldest string } -// GetScheduledMessages returns the list of scheduled messages based on params +// GetScheduledMessages returns the list of scheduled messages based on params. +// For more details, see GetScheduledMessagesContext documentation. func (api *Client) GetScheduledMessages(params *GetScheduledMessagesParameters) (channels []ScheduledMessage, nextCursor string, err error) { return api.GetScheduledMessagesContext(context.Background(), params) } -// GetScheduledMessagesContext returns the list of scheduled messages in a Slack team with a custom context +// GetScheduledMessagesContext returns the list of scheduled messages based on params with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.getScheduledMessages.list func (api *Client) GetScheduledMessagesContext(ctx context.Context, params *GetScheduledMessagesParameters) (channels []ScheduledMessage, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -803,6 +835,9 @@ func (api *Client) GetScheduledMessagesContext(ctx context.Context, params *GetS if params.Channel != "" { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Cursor != "" { values.Add("cursor", params.Cursor) } @@ -835,12 +870,14 @@ type DeleteScheduledMessageParameters struct { AsUser bool } -// DeleteScheduledMessage returns the list of scheduled messages based on params +// DeleteScheduledMessage deletes a pending scheduled message. +// For more details, see DeleteScheduledMessageContext documentation. func (api *Client) DeleteScheduledMessage(params *DeleteScheduledMessageParameters) (bool, error) { return api.DeleteScheduledMessageContext(context.Background(), params) } -// DeleteScheduledMessageContext returns the list of scheduled messages in a Slack team with a custom context +// DeleteScheduledMessageContext deletes a pending scheduled message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.deleteScheduledMessage func (api *Client) DeleteScheduledMessageContext(ctx context.Context, params *DeleteScheduledMessageParameters) (bool, error) { values := url.Values{ "token": {api.token}, diff --git a/chat_test.go b/chat_test.go index 7a549fa90..917ff965e 100644 --- a/chat_test.go +++ b/chat_test.go @@ -1,11 +1,14 @@ package slack import ( + "bytes" "encoding/json" - "io/ioutil" + "io" + "log" "net/http" "net/url" "reflect" + "regexp" "testing" ) @@ -39,7 +42,6 @@ func TestGetPermalink(t *testing.T) { timeStamp := "p135854651500008" http.HandleFunc("/chat.getPermalink", func(rw http.ResponseWriter, r *http.Request) { - if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want { t.Errorf("request uses unexpected content type: got %s, want %s", got, want) } @@ -183,6 +185,28 @@ func TestPostMessage(t *testing.T) { "user_auth_message": []string{"Please!"}, }, }, + "LinkNames true": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionLinkNames(true), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "link_names": []string{"true"}, + }, + }, + "LinkNames false": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionLinkNames(false), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "link_names": []string{"false"}, + }, + }, } once.Do(startServer) @@ -192,7 +216,7 @@ func TestPostMessage(t *testing.T) { t.Run(name, func(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -218,7 +242,7 @@ func TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -246,7 +270,7 @@ func TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied(t *testing.T) { func TestPostMessageWhenMsgOptionReplaceOriginalApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -273,7 +297,7 @@ func TestPostMessageWhenMsgOptionReplaceOriginalApplied(t *testing.T) { func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -296,3 +320,50 @@ func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { _, _, _ = api.PostMessage("CXXX", MsgOptionDeleteOriginal(responseURL)) } + +func TestSendMessageContextRedactsTokenInDebugLog(t *testing.T) { + tests := []struct { + name string + token string + want string + }{ + { + name: "regular token", + token: "xtest-token-1234-abcd", + want: "xtest-REDACTED", + }, + { + name: "refresh token", + token: "xoxe.xtest-token-1234-abcd", + want: "xoxe.xtest-REDACTED", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + once.Do(startServer) + buf := bytes.NewBufferString("") + + opts := []Option{ + OptionAPIURL("http://" + serverAddr + "/"), + OptionLog(log.New(buf, "", log.Lshortfile)), + OptionDebug(true), + } + api := New(tt.token, opts...) + // Why send the token in the message text too? To test that we're not + // redacting substrings in the request which look like a token but aren't. + api.SendMessage("CXXX", MsgOptionText(token, false)) + s := buf.String() + + re := regexp.MustCompile(`token=[\w.-]*`) + want := "token=" + tt.want + if got := re.FindString(s); got != want { + t.Errorf("Logged token in SendMessageContext(): got %q, want %q", got, want) + } + re = regexp.MustCompile(`text=[\w.-]*`) + want = "text=" + token + if got := re.FindString(s); got != want { + t.Errorf("Logged text in SendMessageContext(): got %q, want %q", got, want) + } + }) + } +} diff --git a/conversation.go b/conversation.go index ace6a5737..73b2ed474 100644 --- a/conversation.go +++ b/conversation.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "errors" "net/url" "strconv" @@ -22,8 +23,10 @@ type Conversation struct { IsIM bool `json:"is_im"` IsExtShared bool `json:"is_ext_shared"` IsOrgShared bool `json:"is_org_shared"` + IsGlobalShared bool `json:"is_global_shared"` IsPendingExtShared bool `json:"is_pending_ext_shared"` IsPrivate bool `json:"is_private"` + IsReadOnly bool `json:"is_read_only"` IsMpIM bool `json:"is_mpim"` Unlinked int `json:"unlinked"` NameNormalized string `json:"name_normalized"` @@ -32,6 +35,9 @@ type Conversation struct { Priority float64 `json:"priority"` User string `json:"user"` ConversationHostID string `json:"conversation_host_id,omitempty"` + ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` + SharedTeamIDs []string `json:"shared_team_ids,omitempty"` + InternalTeamIDs []string `json:"internal_team_ids,omitempty"` // TODO support pending_shared } @@ -61,6 +67,17 @@ type Purpose struct { LastSet JSONTime `json:"last_set"` } +// Properties contains the Canvas associated to the channel. +type Properties struct { + Canvas Canvas `json:"canvas"` +} + +type Canvas struct { + FileId string `json:"file_id"` + IsEmpty bool `json:"is_empty"` + QuipThreadId string `json:"quip_thread_id"` +} + type GetUsersInConversationParameters struct { ChannelID string Cursor string @@ -80,12 +97,14 @@ type responseMetaData struct { NextCursor string `json:"next_cursor"` } -// GetUsersInConversation returns the list of users in a conversation +// GetUsersInConversation returns the list of users in a conversation. +// For more details, see GetUsersInConversationContext documentation. func (api *Client) GetUsersInConversation(params *GetUsersInConversationParameters) ([]string, string, error) { return api.GetUsersInConversationContext(context.Background(), params) } -// GetUsersInConversationContext returns the list of users in a conversation with a custom context +// GetUsersInConversationContext returns the list of users in a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.members func (api *Client) GetUsersInConversationContext(ctx context.Context, params *GetUsersInConversationParameters) ([]string, string, error) { values := url.Values{ "token": {api.token}, @@ -115,12 +134,14 @@ func (api *Client) GetUsersInConversationContext(ctx context.Context, params *Ge return response.Members, response.ResponseMetaData.NextCursor, nil } -// GetConversationsForUser returns the list conversations for a given user +// GetConversationsForUser returns the list conversations for a given user. +// For more details, see GetConversationsForUserContext documentation. func (api *Client) GetConversationsForUser(params *GetConversationsForUserParameters) (channels []Channel, nextCursor string, err error) { return api.GetConversationsForUserContext(context.Background(), params) } // GetConversationsForUserContext returns the list conversations for a given user with a custom context +// Slack API docs: https://api.slack.com/methods/users.conversations func (api *Client) GetConversationsForUserContext(ctx context.Context, params *GetConversationsForUserParameters) (channels []Channel, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -157,12 +178,14 @@ func (api *Client) GetConversationsForUserContext(ctx context.Context, params *G return response.Channels, response.ResponseMetaData.NextCursor, response.Err() } -// ArchiveConversation archives a conversation +// ArchiveConversation archives a conversation. +// For more details, see ArchiveConversationContext documentation. func (api *Client) ArchiveConversation(channelID string) error { return api.ArchiveConversationContext(context.Background(), channelID) } -// ArchiveConversationContext archives a conversation with a custom context +// ArchiveConversationContext archives a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.archive func (api *Client) ArchiveConversationContext(ctx context.Context, channelID string) error { values := url.Values{ "token": {api.token}, @@ -178,12 +201,14 @@ func (api *Client) ArchiveConversationContext(ctx context.Context, channelID str return response.Err() } -// UnArchiveConversation reverses conversation archival +// UnArchiveConversation reverses conversation archival. +// For more details, see UnArchiveConversationContext documentation. func (api *Client) UnArchiveConversation(channelID string) error { return api.UnArchiveConversationContext(context.Background(), channelID) } -// UnArchiveConversationContext reverses conversation archival with a custom context +// UnArchiveConversationContext reverses conversation archival with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.unarchive func (api *Client) UnArchiveConversationContext(ctx context.Context, channelID string) error { values := url.Values{ "token": {api.token}, @@ -198,12 +223,14 @@ func (api *Client) UnArchiveConversationContext(ctx context.Context, channelID s return response.Err() } -// SetTopicOfConversation sets the topic for a conversation +// SetTopicOfConversation sets the topic for a conversation. +// For more details, see SetTopicOfConversationContext documentation. func (api *Client) SetTopicOfConversation(channelID, topic string) (*Channel, error) { return api.SetTopicOfConversationContext(context.Background(), channelID, topic) } -// SetTopicOfConversationContext sets the topic for a conversation with a custom context +// SetTopicOfConversationContext sets the topic for a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.setTopic func (api *Client) SetTopicOfConversationContext(ctx context.Context, channelID, topic string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -222,12 +249,14 @@ func (api *Client) SetTopicOfConversationContext(ctx context.Context, channelID, return response.Channel, response.Err() } -// SetPurposeOfConversation sets the purpose for a conversation +// SetPurposeOfConversation sets the purpose for a conversation. +// For more details, see SetPurposeOfConversationContext documentation. func (api *Client) SetPurposeOfConversation(channelID, purpose string) (*Channel, error) { return api.SetPurposeOfConversationContext(context.Background(), channelID, purpose) } -// SetPurposeOfConversationContext sets the purpose for a conversation with a custom context +// SetPurposeOfConversationContext sets the purpose for a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.setPurpose func (api *Client) SetPurposeOfConversationContext(ctx context.Context, channelID, purpose string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -247,12 +276,14 @@ func (api *Client) SetPurposeOfConversationContext(ctx context.Context, channelI return response.Channel, response.Err() } -// RenameConversation renames a conversation +// RenameConversation renames a conversation. +// For more details, see RenameConversationContext documentation. func (api *Client) RenameConversation(channelID, channelName string) (*Channel, error) { return api.RenameConversationContext(context.Background(), channelID, channelName) } -// RenameConversationContext renames a conversation with a custom context +// RenameConversationContext renames a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.rename func (api *Client) RenameConversationContext(ctx context.Context, channelID, channelName string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -272,12 +303,14 @@ func (api *Client) RenameConversationContext(ctx context.Context, channelID, cha return response.Channel, response.Err() } -// InviteUsersToConversation invites users to a channel +// InviteUsersToConversation invites users to a channel. +// For more details, see InviteUsersToConversation documentation. func (api *Client) InviteUsersToConversation(channelID string, users ...string) (*Channel, error) { return api.InviteUsersToConversationContext(context.Background(), channelID, users...) } -// InviteUsersToConversationContext invites users to a channel with a custom context +// InviteUsersToConversationContext invites users to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.invite func (api *Client) InviteUsersToConversationContext(ctx context.Context, channelID string, users ...string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -297,12 +330,95 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } -// KickUserFromConversation removes a user from a conversation +// InviteSharedEmailsToConversation invites users to a shared channels by email. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) +} + +// InviteSharedEmailsToConversationContext invites users to a shared channels by email using context. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedEmailsToConversationContext(ctx context.Context, channelID string, emails ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) +} + +// InviteSharedUserIDsToConversation invites users to a shared channels by user id. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedUserIDsToConversation(channelID string, userIDs ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) +} + +// InviteSharedUserIDsToConversationContext invites users to a shared channels by user id with context. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, channelID string, userIDs ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) +} + +// InviteSharedToConversationParams defines the parameters for the InviteSharedToConversation and InviteSharedToConversationContext functions. +type InviteSharedToConversationParams struct { + ChannelID string + Emails []string + UserIDs []string + ExternalLimited *bool +} + +// InviteSharedToConversation invites emails or userIDs to a channel. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedToConversation(params InviteSharedToConversationParams) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), params) +} + +// InviteSharedToConversationContext invites emails or userIDs to a channel with a custom context. +// This is a helper function for InviteSharedEmailsToConversation and InviteSharedUserIDsToConversation. +// It accepts either emails or userIDs, but not both. +// Slack API docs: https://api.slack.com/methods/conversations.inviteShared +func (api *Client) InviteSharedToConversationContext(ctx context.Context, params InviteSharedToConversationParams) (string, bool, error) { + values := url.Values{ + "token": {api.token}, + "channel": {params.ChannelID}, + } + if len(params.Emails) > 0 { + values.Add("emails", strings.Join(params.Emails, ",")) + } else if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) + } + if params.ExternalLimited != nil { + values.Add("external_limited", strconv.FormatBool(*params.ExternalLimited)) + } + response := struct { + SlackResponse + InviteID string `json:"invite_id"` + IsLegacySharedChannel bool `json:"is_legacy_shared_channel"` + }{} + + err := api.postMethod(ctx, "conversations.inviteShared", values, &response) + if err != nil { + return "", false, err + } + + return response.InviteID, response.IsLegacySharedChannel, response.Err() +} + +// KickUserFromConversation removes a user from a conversation. +// For more details, see KickUserFromConversationContext documentation. func (api *Client) KickUserFromConversation(channelID string, user string) error { return api.KickUserFromConversationContext(context.Background(), channelID, user) } -// KickUserFromConversationContext removes a user from a conversation with a custom context +// KickUserFromConversationContext removes a user from a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.kick func (api *Client) KickUserFromConversationContext(ctx context.Context, channelID string, user string) error { values := url.Values{ "token": {api.token}, @@ -319,12 +435,14 @@ func (api *Client) KickUserFromConversationContext(ctx context.Context, channelI return response.Err() } -// CloseConversation closes a direct message or multi-person direct message +// CloseConversation closes a direct message or multi-person direct message. +// For more details, see CloseConversationContext documentation. func (api *Client) CloseConversation(channelID string) (noOp bool, alreadyClosed bool, err error) { return api.CloseConversationContext(context.Background(), channelID) } -// CloseConversationContext closes a direct message or multi-person direct message with a custom context +// CloseConversationContext closes a direct message or multi-person direct message with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.close func (api *Client) CloseConversationContext(ctx context.Context, channelID string) (noOp bool, alreadyClosed bool, err error) { values := url.Values{ "token": {api.token}, @@ -350,12 +468,14 @@ type CreateConversationParams struct { TeamID string } -// CreateConversation initiates a public or private channel-based conversation +// CreateConversation initiates a public or private channel-based conversation. +// For more details, see CreateConversationContext documentation. func (api *Client) CreateConversation(params CreateConversationParams) (*Channel, error) { return api.CreateConversationContext(context.Background(), params) } -// CreateConversationContext initiates a public or private channel-based conversation with a custom context +// CreateConversationContext initiates a public or private channel-based conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.create func (api *Client) CreateConversationContext(ctx context.Context, params CreateConversationParams) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -380,12 +500,14 @@ type GetConversationInfoInput struct { IncludeNumMembers bool } -// GetConversationInfo retrieves information about a conversation +// GetConversationInfo retrieves information about a conversation. +// For more details, see GetConversationInfoContext documentation. func (api *Client) GetConversationInfo(input *GetConversationInfoInput) (*Channel, error) { return api.GetConversationInfoContext(context.Background(), input) } -// GetConversationInfoContext retrieves information about a conversation with a custom context +// GetConversationInfoContext retrieves information about a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.info func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetConversationInfoInput) (*Channel, error) { if input == nil { return nil, errors.New("GetConversationInfoInput must not be nil") @@ -408,12 +530,14 @@ func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetCon return &response.Channel, response.Err() } -// LeaveConversation leaves a conversation +// LeaveConversation leaves a conversation. +// For more details, see LeaveConversationContext documentation. func (api *Client) LeaveConversation(channelID string) (bool, error) { return api.LeaveConversationContext(context.Background(), channelID) } -// LeaveConversationContext leaves a conversation with a custom context +// LeaveConversationContext leaves a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.leave func (api *Client) LeaveConversationContext(ctx context.Context, channelID string) (bool, error) { values := url.Values{ "token": {api.token}, @@ -439,12 +563,14 @@ type GetConversationRepliesParameters struct { IncludeAllMetadata bool } -// GetConversationReplies retrieves a thread of messages posted to a conversation +// GetConversationReplies retrieves a thread of messages posted to a conversation. +// For more details, see GetConversationRepliesContext documentation. func (api *Client) GetConversationReplies(params *GetConversationRepliesParameters) (msgs []Message, hasMore bool, nextCursor string, err error) { return api.GetConversationRepliesContext(context.Background(), params) } -// GetConversationRepliesContext retrieves a thread of messages posted to a conversation with a custom context +// GetConversationRepliesContext retrieves a thread of messages posted to a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.replies func (api *Client) GetConversationRepliesContext(ctx context.Context, params *GetConversationRepliesParameters) (msgs []Message, hasMore bool, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -498,12 +624,14 @@ type GetConversationsParameters struct { TeamID string } -// GetConversations returns the list of channels in a Slack team +// GetConversations returns the list of channels in a Slack team. +// For more details, see GetConversationsContext documentation. func (api *Client) GetConversations(params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { return api.GetConversationsContext(context.Background(), params) } -// GetConversationsContext returns the list of channels in a Slack team with a custom context +// GetConversationsContext returns the list of channels in a Slack team with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.list func (api *Client) GetConversationsContext(ctx context.Context, params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -544,12 +672,14 @@ type OpenConversationParameters struct { Users []string } -// OpenConversation opens or resumes a direct message or multi-person direct message +// OpenConversation opens or resumes a direct message or multi-person direct message. +// For more details, see OpenConversationContext documentation. func (api *Client) OpenConversation(params *OpenConversationParameters) (*Channel, bool, bool, error) { return api.OpenConversationContext(context.Background(), params) } -// OpenConversationContext opens or resumes a direct message or multi-person direct message with a custom context +// OpenConversationContext opens or resumes a direct message or multi-person direct message with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.open func (api *Client) OpenConversationContext(ctx context.Context, params *OpenConversationParameters) (*Channel, bool, bool, error) { values := url.Values{ "token": {api.token}, @@ -576,12 +706,14 @@ func (api *Client) OpenConversationContext(ctx context.Context, params *OpenConv return response.Channel, response.NoOp, response.AlreadyOpen, response.Err() } -// JoinConversation joins an existing conversation +// JoinConversation joins an existing conversation. +// For more details, see JoinConversationContext documentation. func (api *Client) JoinConversation(channelID string) (*Channel, string, []string, error) { return api.JoinConversationContext(context.Background(), channelID) } -// JoinConversationContext joins an existing conversation with a custom context +// JoinConversationContext joins an existing conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.join func (api *Client) JoinConversationContext(ctx context.Context, channelID string) (*Channel, string, []string, error) { values := url.Values{"token": {api.token}, "channel": {channelID}} response := struct { @@ -628,12 +760,14 @@ type GetConversationHistoryResponse struct { Messages []Message `json:"messages"` } -// GetConversationHistory joins an existing conversation +// GetConversationHistory joins an existing conversation. +// For more details, see GetConversationHistoryContext documentation. func (api *Client) GetConversationHistory(params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { return api.GetConversationHistoryContext(context.Background(), params) } -// GetConversationHistoryContext joins an existing conversation with a custom context +// GetConversationHistoryContext joins an existing conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.history func (api *Client) GetConversationHistoryContext(ctx context.Context, params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { values := url.Values{"token": {api.token}, "channel": {params.ChannelID}} if params.Cursor != "" { @@ -669,12 +803,14 @@ func (api *Client) GetConversationHistoryContext(ctx context.Context, params *Ge return &response, response.Err() } -// MarkConversation sets the read mark of a conversation to a specific point +// MarkConversation sets the read mark of a conversation to a specific point. +// For more details, see MarkConversationContext documentation. func (api *Client) MarkConversation(channel, ts string) (err error) { return api.MarkConversationContext(context.Background(), channel, ts) } -// MarkConversationContext sets the read mark of a conversation to a specific point with a custom context +// MarkConversationContext sets the read mark of a conversation to a specific point with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.mark func (api *Client) MarkConversationContext(ctx context.Context, channel, ts string) error { values := url.Values{ "token": {api.token}, @@ -690,3 +826,36 @@ func (api *Client) MarkConversationContext(ctx context.Context, channel, ts stri } return response.Err() } + +// CreateChannelCanvas creates a new canvas in a channel. +// For more details, see CreateChannelCanvasContext documentation. +func (api *Client) CreateChannelCanvas(channel string, documentContent DocumentContent) (string, error) { + return api.CreateChannelCanvasContext(context.Background(), channel, documentContent) +} + +// CreateChannelCanvasContext creates a new canvas in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.canvases.create +func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, documentContent DocumentContent) (string, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channel}, + } + if documentContent.Type != "" { + documentContentJSON, err := json.Marshal(documentContent) + if err != nil { + return "", err + } + values.Add("document_content", string(documentContentJSON)) + } + + response := struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{} + err := api.postMethod(ctx, "conversations.canvases.create", values, &response) + if err != nil { + return "", err + } + + return response.CanvasID, response.Err() +} diff --git a/conversation_test.go b/conversation_test.go index 066e98fc4..6c39a101c 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -56,8 +56,6 @@ func assertSimpleChannel(t *testing.T, channel *Channel) { assert.NotNil(t, channel) assert.Equal(t, "C024BE91L", channel.ID) assert.Equal(t, "fun", channel.Name) - assert.Len(t, channel.PreviousNames, 1) - assert.Equal(t, channel.PreviousNames[0], "not-fun") assert.Equal(t, true, channel.IsChannel) assert.Equal(t, JSONTime(1360782804), channel.Created) assert.Equal(t, "U024BE7LH", channel.Creator) @@ -155,6 +153,85 @@ func TestCreateSimpleGroup(t *testing.T) { assertSimpleGroup(t, group) } +// Channel with Canvas +var channelWithCanvas = `{ + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "created": 1360782804, + "creator": "U024BE7LH", + "is_archived": false, + "is_general": false, + "members": [ + "U024BE7LH" + ], + "topic": { + "value": "Fun times", + "creator": "U024BE7LV", + "last_set": 1369677212 + }, + "purpose": { + "value": "This channel is for fun", + "creator": "U024BE7LH", + "last_set": 1360782804 + }, + "is_member": true, + "last_read": "1401383885.000061", + "unread_count": 0, + "unread_count_display": 0, + "properties": { + "canvas": { + "file_id": "F05RQ01LJU0", + "is_empty": true, + "quip_thread_id": "XFB9AAlvIyJ" + } + } +}` + +func unmarshalChannelWithCanvas(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestChannelWithCanvas(t *testing.T) { + channel, err := unmarshalChannelWithCanvas(channelWithCanvas) + assert.Nil(t, err) + assertChannelWithCanvas(t, channel) +} + +func assertChannelWithCanvas(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, "F05RQ01LJU0", channel.Properties.Canvas.FileId) + assert.Equal(t, true, channel.Properties.Canvas.IsEmpty) + assert.Equal(t, "XFB9AAlvIyJ", channel.Properties.Canvas.QuipThreadId) +} + +func TestCreateChannelWithCanvas(t *testing.T) { + channel := &Channel{} + channel.ID = "C024BE91L" + channel.Name = "fun" + channel.IsChannel = true + channel.Created = JSONTime(1360782804) + channel.Creator = "U024BE7LH" + channel.IsArchived = false + channel.IsGeneral = false + channel.IsMember = true + channel.LastRead = "1401383885.000061" + channel.UnreadCount = 0 + channel.UnreadCountDisplay = 0 + channel.Properties = &Properties{ + Canvas: Canvas{ + FileId: "F05RQ01LJU0", + IsEmpty: true, + QuipThreadId: "XFB9AAlvIyJ", + }, + } + assertChannelWithCanvas(t, channel) +} + // IM var simpleIM = `{ "id": "D024BFF1M", @@ -293,6 +370,20 @@ func okChannelJsonHandler(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } +func okInviteSharedJsonHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { + SlackResponse + InviteID string `json:"invite_id"` + IsLegacySharedChannel bool `json:"is_legacy_shared_channel"` + }{ + SlackResponse: SlackResponse{Ok: true}, + InviteID: "I01234567", + IsLegacySharedChannel: false, + }) + rw.Write(response) +} + func TestSetTopicOfConversation(t *testing.T) { http.HandleFunc("/conversations.setTopic", okChannelJsonHandler) once.Do(startServer) @@ -354,6 +445,65 @@ func TestInviteUsersToConversation(t *testing.T) { } } +func TestInviteSharedToConversation(t *testing.T) { + http.HandleFunc("/conversations.inviteShared", okInviteSharedJsonHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + t.Run("user_ids", func(t *testing.T) { + userIDs := []string{"UXXXXXXX1", "UXXXXXXX2"} + inviteID, isLegacySharedChannel, err := api.InviteSharedUserIDsToConversation("CXXXXXXXX", userIDs...) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) + + t.Run("emails", func(t *testing.T) { + emails := []string{"nopcoder@slack.com", "nopcoder@example.com"} + inviteID, isLegacySharedChannel, err := api.InviteSharedEmailsToConversation("CXXXXXXXX", emails...) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) + + t.Run("external_limited", func(t *testing.T) { + userIDs := []string{"UXXXXXXX1", "UXXXXXXX2"} + externalLimited := true + inviteID, isLegacySharedChannel, err := api.InviteSharedToConversation(InviteSharedToConversationParams{ + ChannelID: "CXXXXXXXX", + UserIDs: userIDs, + ExternalLimited: &externalLimited, + }) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) +} + func TestKickUserFromConversation(t *testing.T) { http.HandleFunc("/conversations.kick", okJSONHandler) once.Do(startServer) @@ -597,3 +747,34 @@ func TestMarkConversation(t *testing.T) { return } } + +func createChannelCanvasHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{ + SlackResponse: SlackResponse{Ok: true}, + CanvasID: "F05RQ01LJU0", + }) + rw.Write(response) +} + +func TestCreateChannelCanvas(t *testing.T) { + http.HandleFunc("/conversations.canvases.create", createChannelCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "> channel canvas!", + } + + canvasID, err := api.CreateChannelCanvas("C1234567890", documentContent) + if err != nil { + t.Errorf("Failed to create channel canvas: %v", err) + return + } + + assert.Equal(t, "F05RQ01LJU0", canvasID) +} diff --git a/dnd.go b/dnd.go index a3aa680cd..81eaf5024 100644 --- a/dnd.go +++ b/dnd.go @@ -45,12 +45,14 @@ func (api *Client) dndRequest(ctx context.Context, path string, values url.Value return response, response.Err() } -// EndDND ends the user's scheduled Do Not Disturb session +// EndDND ends the user's scheduled Do Not Disturb session. +// For more information see the EndDNDContext documentation. func (api *Client) EndDND() error { return api.EndDNDContext(context.Background()) } -// EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context +// EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.endDnd func (api *Client) EndDNDContext(ctx context.Context) error { values := url.Values{ "token": {api.token}, @@ -65,12 +67,14 @@ func (api *Client) EndDNDContext(ctx context.Context) error { return response.Err() } -// EndSnooze ends the current user's snooze mode +// EndSnooze ends the current user's snooze mode. +// For more information see the EndSnoozeContext documentation. func (api *Client) EndSnooze() (*DNDStatus, error) { return api.EndSnoozeContext(context.Background()) } -// EndSnoozeContext ends the current user's snooze mode with a custom context +// EndSnoozeContext ends the current user's snooze mode with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.endSnooze func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -84,11 +88,13 @@ func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { } // GetDNDInfo provides information about a user's current Do Not Disturb settings. +// For more information see the GetDNDInfoContext documentation. func (api *Client) GetDNDInfo(user *string) (*DNDStatus, error) { return api.GetDNDInfoContext(context.Background(), user) } // GetDNDInfoContext provides information about a user's current Do Not Disturb settings with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.info func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -105,11 +111,13 @@ func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDSta } // GetDNDTeamInfo provides information about a user's current Do Not Disturb settings. +// For more information see the GetDNDTeamInfoContext documentation. func (api *Client) GetDNDTeamInfo(users []string) (map[string]DNDStatus, error) { return api.GetDNDTeamInfoContext(context.Background(), users) } // GetDNDTeamInfoContext provides information about a user's current Do Not Disturb settings with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.teamInfo func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (map[string]DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -128,15 +136,16 @@ func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (m return response.Users, nil } -// SetSnooze adjusts the snooze duration for a user's Do Not Disturb -// settings. If a snooze session is not already active for the user, invoking -// this method will begin one for the specified duration. +// SetSnooze adjusts the snooze duration for a user's Do Not Disturb settings. +// For more information see the SetSnoozeContext documentation. func (api *Client) SetSnooze(minutes int) (*DNDStatus, error) { return api.SetSnoozeContext(context.Background(), minutes) } -// SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings with a custom context. -// For more information see the SetSnooze docs +// SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings. +// If a snooze session is not already active for the user, invoking this method will +// begin one for the specified duration. +// Slack API docs: https://api.slack.com/methods/dnd.setSnooze func (api *Client) SetSnoozeContext(ctx context.Context, minutes int) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, diff --git a/emoji.go b/emoji.go index b2b0c6c90..139df0fd2 100644 --- a/emoji.go +++ b/emoji.go @@ -10,12 +10,14 @@ type emojiResponseFull struct { SlackResponse } -// GetEmoji retrieves all the emojis +// GetEmoji retrieves all the emojis. +// For more details see GetEmojiContext documentation. func (api *Client) GetEmoji() (map[string]string, error) { return api.GetEmojiContext(context.Background()) } -// GetEmojiContext retrieves all the emojis with a custom context +// GetEmojiContext retrieves all the emojis with a custom context. +// Slack API docs: https://api.slack.com/methods/emoji.list func (api *Client) GetEmojiContext(ctx context.Context) (map[string]string, error) { values := url.Values{ "token": {api.token}, diff --git a/examples/blocks/README.md b/examples/blocks/README.md index 588f2485a..2e5d81fef 100644 --- a/examples/blocks/README.md +++ b/examples/blocks/README.md @@ -53,7 +53,7 @@ To preview this block on the builder website, you should copy just the contents The first example demonstrates usage of Sections, Fields and Action buttons. You can view the [Approval Example](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3CfakeLink.toEmployeeProfile.com%7CFred%20Enriquez%20-%20New%20device%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22fields%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Type%3A*%5CnComputer%20(laptop)%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*When%3A*%5CnSubmitted%20Aut%2010%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Last%20Update%3A*%5CnMar%2010%2C%202015%20(3%20years%2C%205%20months)%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Reason%3A*%5CnAll%20vowel%20keys%20aren%27t%20working.%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Specs%3A*%5Cn%5C%22Cheetah%20Pro%2015%5C%22%20-%20Fast%2C%20really%20fast%5C%22%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleOne`. #### Example 2 - Approval - With Images -The secoond example adds additional complexity by introducing images as accessories to main blocks of text. You can view this [Approval Example with Images](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3Cgoogle.com%7CFred%20Enriquez%20-%20Time%20Off%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Type%3A*%5CnPaid%20time%20off%5Cn*When%3A*%5CnAug%2010-Aug%2013%5Cn*Hours%3A*%2016.0%20(2%20days)%5Cn*Remaining%20balance%3A*%2032.0%20hours%20(4%20days)%5Cn*Comments%3A*%20%5C%22Family%20in%20town%2C%20going%20camping!%5C%22%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FapprovalsNewDevice.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22computer%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleTwo`. +The second example adds additional complexity by introducing images as accessories to main blocks of text. You can view this [Approval Example with Images](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3Cgoogle.com%7CFred%20Enriquez%20-%20Time%20Off%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Type%3A*%5CnPaid%20time%20off%5Cn*When%3A*%5CnAug%2010-Aug%2013%5Cn*Hours%3A*%2016.0%20(2%20days)%5Cn*Remaining%20balance%3A*%2032.0%20hours%20(4%20days)%5Cn*Comments%3A*%20%5C%22Family%20in%20town%2C%20going%20camping!%5C%22%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FapprovalsNewDevice.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22computer%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleTwo`. #### Example 3 - Notifications This example shows how to add actions to your block that will trigger an interactive message to your application. You can view the rendered example for [Notifications](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%22text%22%3A%20%22Looks%20like%20you%20have%20a%20scheduling%20conflict%20with%20this%20event%3A%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toUserProfiles.com%7CIris%20%2F%20Zelda%201-1%3E*%5CnTuesday%2C%20January%2021%204%3A00-4%3A30pm%5CnBuilding%202%20-%20Havarti%20Cheese%20(3)%5Cn2%20guests%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2Fnotifications.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22calendar%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22context%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FnotificationsWarningIcon.png%22%2C%0A%09%09%09%09%22alt_text%22%3A%20%22notifications%20warning%20icon%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Conflicts%20with%20Team%20Huddle%3A%204%3A15-4%3A30pm*%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Propose%20a%20new%20time%3A*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Today%20-%204%3A30-5pm*%5CnEveryone%20is%20available%3A%20%40iris%2C%20%40zelda%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Tomorrow%20-%204-4%3A30pm*%5CnEveryone%20is%20available%3A%20%40iris%2C%20%40zelda%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Tomorrow%20-%206-6%3A30pm*%5CnSome%20people%20aren%27t%20available%3A%20%40iris%2C%20~%40zelda~%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3Cfakelink.ToMoreTimes.com%7CShow%20more%20times%3E*%22%0A%09%09%7D%0A%09%7D%0A%5D) on the block builder website. Refer to the function `exampleThree` for details on how this block can be generated. diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index 82772faa1..b5ef09c49 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -50,7 +50,7 @@ func main() { if err != nil { fmt.Printf("Could not send message: %v", err) } - fmt.Printf("Message with buttons sucessfully sent to channel %s at %s", channelID, timestamp) + fmt.Printf("Message with buttons successfully sent to channel %s at %s", channelID, timestamp) http.HandleFunc("/actions", actionHandler) http.ListenAndServe(":3000", nil) } diff --git a/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go new file mode 100644 index 000000000..569c12d68 --- /dev/null +++ b/examples/conversation_history/conversation_history.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN") + params := slack.GetConversationHistoryParameters{ + ChannelID: "C0123456789", + } + messages, err := api.GetConversationHistoryContext(context.Background(), ¶ms) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, message := range messages.Messages { + fmt.Printf("Message: %s\n", message.Attachments[0].Color) + } +} diff --git a/examples/dialog/dialog.go b/examples/dialog/dialog.go index 4b4d6ab18..dc0d431c3 100644 --- a/examples/dialog/dialog.go +++ b/examples/dialog/dialog.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -26,7 +26,7 @@ func handler(w http.ResponseWriter, r *http.Request) { // Read request body defer r.Body.Close() - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Printf("[ERROR] Fail to read request body: %v", err) diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go index 25049ad07..b3bb70ab4 100644 --- a/examples/eventsapi/events.go +++ b/examples/eventsapi/events.go @@ -3,7 +3,7 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "os" @@ -18,7 +18,7 @@ func main() { signingSecret := os.Getenv("SLACK_SIGNING_SECRET") http.HandleFunc("/events-endpoint", func(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return diff --git a/examples/files_remote/files_remote.go b/examples/files_remote/files_remote.go new file mode 100644 index 000000000..60bcfa967 --- /dev/null +++ b/examples/files_remote/files_remote.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN") + params := slack.RemoteFileParameters{ + Title: "My File", + ExternalID: "my-file-123", + ExternalURL: "https://raw.githubusercontent.com/slack-go/slack/master/README.md", + } + file, err := api.AddRemoteFileContext(context.Background(), params) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("Name: %s, URL: %s\n", file.Name, file.URLPrivate) + + err = api.DeleteFileContext(context.Background(), file.ID) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("File %s deleted successfully.\n", file.Name) +} diff --git a/examples/function/function.go b/examples/function/function.go new file mode 100644 index 000000000..d654890fe --- /dev/null +++ b/examples/function/function.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + "os" +) + +func main() { + api := slack.New( + os.Getenv("SLACK_BOT_TOKEN"), + slack.OptionDebug(true), + slack.OptionAppLevelToken(os.Getenv("SLACK_APP_TOKEN")), + ) + client := socketmode.New(api, socketmode.OptionDebug(true)) + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + continue + } + + fmt.Printf("Event received: %+v\n", eventsAPIEvent) + client.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.FunctionExecutedEvent: + callbackID := ev.Function.CallbackID + if callbackID == "sample_function" { + userId := ev.Inputs["user_id"] + payload := map[string]string{ + "user_id": userId, + } + + err := api.FunctionCompleteSuccess(ev.FunctionExecutionID, slack.FunctionCompleteSuccessRequestOptionOutput(payload)) + if err != nil { + fmt.Printf("failed posting message: %v \n", err) + } + } + } + default: + client.Debugf("unsupported Events API event received\n") + } + + default: + fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) + } + } + }() + client.Run() +} diff --git a/examples/function/manifest.json b/examples/function/manifest.json new file mode 100644 index 000000000..5f673f96d --- /dev/null +++ b/examples/function/manifest.json @@ -0,0 +1,56 @@ +{ + "display_information": { + "name": "Function Example" + }, + "features": { + "app_home": { + "home_tab_enabled": false, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": true + }, + "bot_user": { + "display_name": "Function Example", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": true, + "socket_mode_enabled": true, + "token_rotation_enabled": false + }, + "functions": { + "sample_function": { + "title": "Sample function", + "description": "Runs sample function", + "input_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Message recipient", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "user_id" + } + }, + "output_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "User that completed the function", + "is_required": true, + "name": "user_id" + } + } + } + } +} diff --git a/examples/manifests/README.md b/examples/manifests/README.md new file mode 100644 index 000000000..ba6db2332 --- /dev/null +++ b/examples/manifests/README.md @@ -0,0 +1,38 @@ +# Manifest examples + +This example shows how to interact with the +new [manifest endpoints](https://api.slack.com/reference/manifests#manifest_apis). These endpoints require a special set +of tokens called `configuration tokens`. Refer to +the [relevant documentation](https://api.slack.com/authentication/config-tokens) for how to create these tokens. + +For examples on how to use configuration tokens, see the [tokens example](../tokens). + +## Usage info + +The manifest endpoints allow you to configure your application programmatically instead of manually creating +a `manifest.yaml` file and uploading it on your Slack application's dashboard. + +A manifest should follow a specific structure and has a handful of required fields. These are describe in +the [manifest documentation](https://api.slack.com/reference/manifests#fields), but Slack additionally returns very +informative error messages for malformed templates to help you pin down what the issue is. The library itself does not +attempt to perform any form of validation on your manifest. + +**Note that each configuration token may only be used once before being invalidated. Again refer to the tokens example +for more information.** + +## Available methods + +- ``Slack.CreateManifest()`` +- ``Slack.DeleteManifest()`` +- ``Slack.ExportManifest()`` +- ``Slack.UpdateManifest()`` + +## Example details + +The example code here only shows how to _update_ an application using a manifest. The other available methods are either +identical in usage or trivial to use, so no full example is provided for them. + +The example doesn't rotate the configuration tokens after updating the manifest. **You should almost always do this**. +Your access token is invalidated after sending a request, and rotating your tokens will allow you to make another +request in the future. This example does not do this explicitly as it would just repeat the tokens example. For sake of +simplicity, it only focuses on the manifest part. diff --git a/examples/manifests/manifest.go b/examples/manifests/manifest.go new file mode 100644 index 000000000..bfcfa9cab --- /dev/null +++ b/examples/manifests/manifest.go @@ -0,0 +1,45 @@ +package manifests + +import ( + "fmt" + "github.com/slack-go/slack" +) + +// createManifest programmatically creates a Slack app manifest +func createManifest() *slack.Manifest { + return &slack.Manifest{ + Display: slack.Display{ + Name: "Your Application", + }, + // ... other configuration here + } +} + +func main() { + api := slack.New( + "YOUR_TOKEN_HERE", + // You may choose to provide your access token when creating your Slack client + // or when invoking the method calls + slack.OptionConfigToken("YOUR_CONFIG_ACCESS_TOKEN_HERE"), + ) + + // Create a new Manifest object + manifest := createManifest() + + // Update your application using the new manifest + // You may pass your token as a parameter here as well, if you didn't do it above + response, err := api.UpdateManifest(manifest, "", "YOUR_APP_ID_HERE") + if err != nil { + fmt.Printf("error updating Slack application: %v\n", err) + return + } + + if !response.Ok { + fmt.Printf("unable to update Slack application: %v\n", response.Errors) + } + + fmt.Println("successfully updated Slack application") + + // The access token is now invalid, so it should be rotated for future use + // Refer to the examples about tokens for more details +} diff --git a/examples/modal/modal.go b/examples/modal/modal.go index e628e853f..13115a826 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -15,10 +15,11 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "github.com/slack-go/slack" + "time" ) func generateModalRequest() slack.ModalViewRequest { @@ -60,6 +61,30 @@ func generateModalRequest() slack.ModalViewRequest { return modalRequest } +func updateModal() slack.ModalViewRequest { + // Create a ModalViewRequest with a header and two inputs + titleText := slack.NewTextBlockObject("plain_text", "My App", false, false) + closeText := slack.NewTextBlockObject("plain_text", "Close", false, false) + submitText := slack.NewTextBlockObject("plain_text", "Submit", false, false) + + headerText := slack.NewTextBlockObject("mrkdwn", "Modal updated!", false, false) + headerSection := slack.NewSectionBlock(headerText, nil, nil) + + blocks := slack.Blocks{ + BlockSet: []slack.Block{ + headerSection, + }, + } + + var modalRequest slack.ModalViewRequest + modalRequest.Type = slack.ViewType("modal") + modalRequest.Title = titleText + modalRequest.Close = closeText + modalRequest.Submit = submitText + modalRequest.Blocks = blocks + return modalRequest +} + // This was taken from the slash example // https://github.com/slack-go/slack/blob/master/examples/slash/slash.go func verifySigningSecret(r *http.Request) error { @@ -70,13 +95,13 @@ func verifySigningSecret(r *http.Request) error { return err } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { fmt.Println(err.Error()) return err } // Need to use r.Body again when unmarshalling SlashCommand and InteractionCallback - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + r.Body = io.NopCloser(bytes.NewBuffer(body)) verifier.Write(body) if err = verifier.Ensure(); err != nil { @@ -104,7 +129,7 @@ func handleSlash(w http.ResponseWriter, r *http.Request) { } switch s.Command { - case "/humboldttest": + case "/slash": api := slack.New("YOUR_TOKEN_HERE") modalRequest := generateModalRequest() _, err = api.OpenView(s.TriggerID, modalRequest) @@ -134,21 +159,30 @@ func handleModal(w http.ResponseWriter, r *http.Request) { return } - // Note there might be a better way to get this info, but I figured this structure out from looking at the json response - firstName := i.View.State.Values["First Name"]["firstName"].Value - lastName := i.View.State.Values["Last Name"]["lastName"].Value - - msg := fmt.Sprintf("Hello %s %s, nice to meet you!", firstName, lastName) - api := slack.New("YOUR_TOKEN_HERE") - _, _, err = api.PostMessage(i.User.ID, - slack.MsgOptionText(msg, false), - slack.MsgOptionAttachments()) if err != nil { fmt.Printf(err.Error()) w.WriteHeader(http.StatusUnauthorized) return } + + // update modal sample + switch i.Type { + //update when interaction type is view_submission + case slack.InteractionTypeViewSubmission: + //you can use any modal you want to show to users just like creating modal. + updateModal := updateModal() + // You must set one of external_id or view_id and you can use hash for avoiding race condition. + // More details: https://api.slack.com/surfaces/modals/using#updating_apis + _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) + // Wait for a few seconds to see result this code is necesarry due to slack server modal is going to be closed after the update + time.Sleep(time.Second * 2) + if err != nil { + fmt.Printf("Error updating view: %s", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } } func main() { diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go new file mode 100644 index 000000000..265910137 --- /dev/null +++ b/examples/pagination/pagination.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/slack-go/slack" +) + +func getAllUserUIDs(ctx context.Context, client *slack.Client, pageSize int) ([]string, error) { + var uids []string + var err error + + pages := 0 + pager := client.GetUsersPaginated(slack.GetUsersOptionLimit(pageSize)) + for { + // Note reassignment of pager to the value returned by Next() + pager, err = pager.Next(ctx) + if failedErr := pager.Failure(err); failedErr != nil { + var rateLimited *slack.RateLimitedError + if errors.As(failedErr, &rateLimited) && rateLimited.Retryable() { + fmt.Println("Rate limited by Slack API; sleeping", rateLimited.RetryAfter) + select { + case <-ctx.Done(): + return uids, ctx.Err() + case <-time.After(rateLimited.RetryAfter): + continue + } + } + return uids, fmt.Errorf("paginating users: %w", failedErr) + } + if pager.Done(err) { + break + } + + for _, user := range pager.Users { + uids = append(uids, user.ID) + } + + pages++ + } + + fmt.Printf("Pagination complete after %d pages\n", pages) + + return uids, nil +} + +func main() { + client := slack.New("YOUR_TOKEN_HERE") + + uids, err := getAllUserUIDs(context.Background(), client, 1000) + if err != nil { + panic(err) + } + + fmt.Printf("Collected %d UIDs\n", len(uids)) +} diff --git a/examples/pins/pins.go b/examples/pins/pins.go index 08d2ab120..03e7793c3 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -7,9 +7,8 @@ import ( "github.com/slack-go/slack" ) -/* -WARNING: This example is destructive in the sense that it create a channel called testpinning -*/ +// WARNING: This example is destructive in the sense that it create a channel called testpinning + func main() { var ( apiToken string diff --git a/examples/slash/slash.go b/examples/slash/slash.go index c70c865ff..e35ab10a8 100644 --- a/examples/slash/slash.go +++ b/examples/slash/slash.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "io" - "io/ioutil" "net/http" "github.com/slack-go/slack" @@ -27,7 +26,7 @@ func main() { return } - r.Body = ioutil.NopCloser(io.TeeReader(r.Body, &verifier)) + r.Body = io.NopCloser(io.TeeReader(r.Body, &verifier)) s, err := slack.SlashCommandParse(r) if err != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index 6f1e65b48..1490a932b 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -72,7 +72,7 @@ func main() { innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: - _, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + _, _, err := client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) } @@ -137,7 +137,8 @@ func main() { ), ), ), - }} + }, + } client.Ack(*evt.Request, payload) default: diff --git a/examples/team/team.go b/examples/team/team.go index 8d2fcdbc6..cba71aa74 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -9,17 +9,16 @@ import ( func main() { api := slack.New("YOUR_TOKEN_HERE") //Example for single user - billingActive, err := api.GetBillableInfo("U023BECGF") + billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: "U023BECGF"}) if err != nil { fmt.Printf("%s\n", err) return } fmt.Printf("ID: U023BECGF, BillingActive: %v\n\n\n", billingActive["U023BECGF"]) - //Example for team - billingActiveForTeam, _ := api.GetBillableInfoForTeam() + //Example for team. Note: passing empty TeamID just uses the current user team. + billingActiveForTeam, _ := api.GetBillableInfo(slack.GetBillableInfoParams{}) for id, value := range billingActiveForTeam { fmt.Printf("ID: %v, BillingActive: %v\n", id, value) } - } diff --git a/examples/tokens/README.md b/examples/tokens/README.md new file mode 100644 index 000000000..7e8e163e7 --- /dev/null +++ b/examples/tokens/README.md @@ -0,0 +1,10 @@ +# Tokens examples + +The refresh token endpoint can be used to update +your [configuration tokenset](https://api.slack.com/authentication/config-tokens). These tokens may only be used **once +** before being invalidated, and are only valid for up to **12 hours**. + +Once a token has been used, or before it expires, you can use the `RotateTokens()` method to obtain a fresh set to use +for the next request. Depending on your use-case you may want to store these somewhere for a future run, so they are +only returned by the method call. If you wish to update the tokens inside the active Slack client, this can be done +using `UpdateConfigTokens()`. diff --git a/examples/tokens/tokens.go b/examples/tokens/tokens.go new file mode 100644 index 000000000..63d46d25f --- /dev/null +++ b/examples/tokens/tokens.go @@ -0,0 +1,33 @@ +package tokens + +import ( + "fmt" + "github.com/slack-go/slack" +) + +func main() { + api := slack.New( + "YOUR_TOKEN_HERE", + // You may choose to provide your config tokens when creating your Slack client + // or when invoking the method calls + slack.OptionConfigToken("YOUR_CONFIG_ACCESS_TOKEN_HERE"), + slack.OptionConfigRefreshToken("YOUR_REFRESH_TOKEN_HERE"), + ) + + // Obtain a fresh set of tokens + // You may pass your tokens as a parameter here as well, if you didn't do it above + freshTokens, err := api.RotateTokens("", "") + if err != nil { + fmt.Printf("error rotating tokens: %v\n", err) + return + } + + fmt.Printf("new access token: %s\n", freshTokens.Token) + fmt.Printf("new refresh token: %s\n", freshTokens.RefreshToken) + fmt.Printf("new tokenset expires at: %d\n", freshTokens.ExpiresAt) + + // Optionally: update the tokens inside the running Slack client + // This isn't necessary if you restart the application after storing the tokens elsewhere, + // or pass them as parameters to RotateTokens() explicitly + api.UpdateConfigTokens(freshTokens) +} diff --git a/examples/workflow_step/handler.go b/examples/workflow_step/handler.go index b25a5be42..d0eee101f 100644 --- a/examples/workflow_step/handler.go +++ b/examples/workflow_step/handler.go @@ -3,7 +3,7 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -25,7 +25,7 @@ func handleMyWorkflowStep(w http.ResponseWriter, r *http.Request) { } // see: https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return @@ -88,7 +88,7 @@ func handleInteraction(w http.ResponseWriter, r *http.Request) { return } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return diff --git a/examples/workflow_step/middleware.go b/examples/workflow_step/middleware.go index 0684e50d6..6fe826910 100644 --- a/examples/workflow_step/middleware.go +++ b/examples/workflow_step/middleware.go @@ -2,20 +2,20 @@ package main import ( "bytes" - "io/ioutil" + "io" "net/http" "github.com/slack-go/slack" ) func (v *SecretsVerifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } r.Body.Close() - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + r.Body = io.NopCloser(bytes.NewBuffer(body)) sv, err := slack.NewSecretsVerifier(r.Header, appCtx.config.signingSecret) if err != nil { diff --git a/files.go b/files.go index 356284420..b26317145 100644 --- a/files.go +++ b/files.go @@ -11,7 +11,7 @@ import ( ) const ( - // Add here the defaults in the siten + // Add here the defaults in the site DEFAULT_FILES_USER = "" DEFAULT_FILES_CHANNEL = "" DEFAULT_FILES_TS_FROM = 0 @@ -129,6 +129,7 @@ type FileUploadParameters struct { type GetFilesParameters struct { User string Channel string + TeamID string TimestampFrom JSONTime TimestampTo JSONTime Types string @@ -142,6 +143,7 @@ type ListFilesParameters struct { Limit int User string Channel string + TeamID string Types string Cursor string } @@ -232,12 +234,14 @@ func (api *Client) fileRequest(ctx context.Context, path string, values url.Valu return response, response.Err() } -// GetFileInfo retrieves a file and related comments +// GetFileInfo retrieves a file and related comments. +// For more details, see GetFileInfoContext documentation. func (api *Client) GetFileInfo(fileID string, count, page int) (*File, []Comment, *Paging, error) { return api.GetFileInfoContext(context.Background(), fileID, count, page) } -// GetFileInfoContext retrieves a file and related comments with a custom context +// GetFileInfoContext retrieves a file and related comments with a custom context. +// Slack API docs: https://api.slack.com/methods/files.info func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, page int) (*File, []Comment, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -253,24 +257,25 @@ func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, return &response.File, response.Comments, &response.Paging, nil } -// GetFile retreives a given file from its private download URL +// GetFile retrieves a given file from its private download URL. func (api *Client) GetFile(downloadURL string, writer io.Writer) error { return api.GetFileContext(context.Background(), downloadURL, writer) } -// GetFileContext retreives a given file from its private download URL with a custom context -// +// GetFileContext retrieves a given file from its private download URL with a custom context. // For more details, see GetFile documentation. func (api *Client) GetFileContext(ctx context.Context, downloadURL string, writer io.Writer) error { return downloadFile(ctx, api.httpclient, api.token, downloadURL, writer, api) } -// GetFiles retrieves all files according to the parameters given +// GetFiles retrieves all files according to the parameters given. +// For more details, see GetFilesContext documentation. func (api *Client) GetFiles(params GetFilesParameters) ([]File, *Paging, error) { return api.GetFilesContext(context.Background(), params) } -// GetFilesContext retrieves all files according to the parameters given with a custom context +// GetFilesContext retrieves all files according to the parameters given with a custom context. +// Slack API docs: https://api.slack.com/methods/files.list func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameters) ([]File, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -281,6 +286,9 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter if params.Channel != DEFAULT_FILES_CHANNEL { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.TimestampFrom != DEFAULT_FILES_TS_FROM { values.Add("ts_from", strconv.FormatInt(int64(params.TimestampFrom), 10)) } @@ -308,13 +316,13 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter } // ListFiles retrieves all files according to the parameters given. Uses cursor based pagination. +// For more details, see ListFilesContext documentation. func (api *Client) ListFiles(params ListFilesParameters) ([]File, *ListFilesParameters, error) { return api.ListFilesContext(context.Background(), params) } // ListFilesContext retrieves all files according to the parameters given with a custom context. -// -// For more details, see ListFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.list func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParameters) ([]File, *ListFilesParameters, error) { values := url.Values{ "token": {api.token}, @@ -326,6 +334,9 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet if params.Channel != DEFAULT_FILES_CHANNEL { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Limit != DEFAULT_FILES_COUNT { values.Add("limit", strconv.Itoa(params.Limit)) } @@ -343,12 +354,18 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet return response.Files, ¶ms, nil } -// UploadFile uploads a file +// UploadFile uploads a file. +// +// Deprecated: Use [Client.UploadFileV2] instead. This will stop functioning on March 11, 2025. +// For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { return api.UploadFileContext(context.Background(), params) } -// UploadFileContext uploads a file and setting a custom context +// UploadFileContext uploads a file and setting a custom context. +// +// Deprecated: Use [Client.UploadFileV2Context] instead. This will stop functioning on March 11, 2025. +// For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More // investigation needed, but for now this will do. @@ -396,12 +413,14 @@ func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParam return &response.File, response.Err() } -// DeleteFileComment deletes a file's comment +// DeleteFileComment deletes a file's comment. +// For more details, see DeleteFileCommentContext documentation. func (api *Client) DeleteFileComment(commentID, fileID string) error { return api.DeleteFileCommentContext(context.Background(), fileID, commentID) } -// DeleteFileCommentContext deletes a file's comment with a custom context +// DeleteFileCommentContext deletes a file's comment with a custom context. +// Slack API docs: https://api.slack.com/methods/files.comments.delete func (api *Client) DeleteFileCommentContext(ctx context.Context, fileID, commentID string) (err error) { if fileID == "" || commentID == "" { return ErrParametersMissing @@ -416,12 +435,14 @@ func (api *Client) DeleteFileCommentContext(ctx context.Context, fileID, comment return err } -// DeleteFile deletes a file +// DeleteFile deletes a file. +// For more details, see DeleteFileContext documentation. func (api *Client) DeleteFile(fileID string) error { return api.DeleteFileContext(context.Background(), fileID) } -// DeleteFileContext deletes a file with a custom context +// DeleteFileContext deletes a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.delete func (api *Client) DeleteFileContext(ctx context.Context, fileID string) (err error) { values := url.Values{ "token": {api.token}, @@ -432,12 +453,14 @@ func (api *Client) DeleteFileContext(ctx context.Context, fileID string) (err er return err } -// RevokeFilePublicURL disables public/external sharing for a file +// RevokeFilePublicURL disables public/external sharing for a file. +// For more details, see RevokeFilePublicURLContext documentation. func (api *Client) RevokeFilePublicURL(fileID string) (*File, error) { return api.RevokeFilePublicURLContext(context.Background(), fileID) } -// RevokeFilePublicURLContext disables public/external sharing for a file with a custom context +// RevokeFilePublicURLContext disables public/external sharing for a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.revokePublicURL func (api *Client) RevokeFilePublicURLContext(ctx context.Context, fileID string) (*File, error) { values := url.Values{ "token": {api.token}, @@ -451,12 +474,14 @@ func (api *Client) RevokeFilePublicURLContext(ctx context.Context, fileID string return &response.File, nil } -// ShareFilePublicURL enabled public/external sharing for a file +// ShareFilePublicURL enabled public/external sharing for a file. +// For more details, see ShareFilePublicURLContext documentation. func (api *Client) ShareFilePublicURL(fileID string) (*File, []Comment, *Paging, error) { return api.ShareFilePublicURLContext(context.Background(), fileID) } -// ShareFilePublicURLContext enabled public/external sharing for a file with a custom context +// ShareFilePublicURLContext enabled public/external sharing for a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.sharedPublicURL func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) (*File, []Comment, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -470,7 +495,7 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) return &response.File, response.Comments, &response.Paging, nil } -// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file +// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file. func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) { values := url.Values{ "token": {api.token}, @@ -496,9 +521,8 @@ func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURL func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters) (err error) { values := url.Values{} if params.Content != "" { - values.Add("content", params.Content) - values.Add("token", api.token) - err = postForm(ctx, api.httpclient, params.UploadURL, values, nil, api) + contentReader := strings.NewReader(params.Content) + err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, contentReader, nil, api) } else if params.File != "" { err = postLocalWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.File, "file", api.token, values, nil, api) } else if params.Reader != nil { @@ -515,11 +539,13 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return nil, err } values := url.Values{ - "token": {api.token}, - "files": {string(requestBytes)}, - "channel_id": {params.channel}, + "token": {api.token}, + "files": {string(requestBytes)}, } + if params.channel != "" { + values.Add("channel_id", params.channel) + } if params.initialComment != "" { values.Add("initial_comment", params.initialComment) } @@ -537,18 +563,18 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return response, nil } -// UploadFileV2 uploads file to a given slack channel using 3 steps - -// 1. Get an upload URL using files.getUploadURLExternal API -// 2. Send the file as a post to the URL provided by slack -// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// UploadFileV2 uploads file to a given slack channel using 3 steps. +// For more details, see UploadFileV2Context documentation. func (api *Client) UploadFileV2(params UploadFileV2Parameters) (*FileSummary, error) { return api.UploadFileV2Context(context.Background(), params) } -// UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - +// UploadFileV2Context uploads file to a given slack channel using 3 steps - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// +// Slack Docs: https://api.slack.com/messaging/files#uploading_files func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2Parameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") @@ -556,9 +582,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P if params.FileSize == 0 { return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") } - if params.Channel == "" { - return nil, fmt.Errorf("file.upload.v2: channel cannot be empty") - } + u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{ altText: params.AltTxt, fileName: params.Filename, diff --git a/files_test.go b/files_test.go index f304a4e95..1df46aa6e 100644 --- a/files_test.go +++ b/files_test.go @@ -3,7 +3,7 @@ package slack import ( "bytes" "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -44,7 +44,7 @@ func (h *fileCommentHandler) handler(w http.ResponseWriter, r *http.Request) { type mockHTTPClient struct{} func (m *mockHTTPClient) Do(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewBufferString(`OK`))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(`OK`))}, nil } func TestSlack_GetFile(t *testing.T) { @@ -272,4 +272,13 @@ func TestUploadFileV2(t *testing.T) { if _, err := api.UploadFileV2(params); err != nil { t.Errorf("Unexpected error: %s", err) } + + reader = bytes.NewBufferString("test no channel") + params = UploadFileV2Parameters{ + Filename: "test.txt", + Reader: reader, + FileSize: 15} + if _, err := api.UploadFileV2(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } } diff --git a/function_execute.go b/function_execute.go new file mode 100644 index 000000000..4ec8f9f4c --- /dev/null +++ b/function_execute.go @@ -0,0 +1,93 @@ +package slack + +import ( + "context" + "encoding/json" +) + +type ( + FunctionCompleteSuccessRequest struct { + FunctionExecutionID string `json:"function_execution_id"` + Outputs map[string]string `json:"outputs"` + } + + FunctionCompleteErrorRequest struct { + FunctionExecutionID string `json:"function_execution_id"` + Error string `json:"error"` + } +) + +type FunctionCompleteSuccessRequestOption func(opt *FunctionCompleteSuccessRequest) error + +func FunctionCompleteSuccessRequestOptionOutput(outputs map[string]string) FunctionCompleteSuccessRequestOption { + return func(opt *FunctionCompleteSuccessRequest) error { + if len(outputs) > 0 { + opt.Outputs = outputs + } + return nil + } +} + +// FunctionCompleteSuccess indicates function is completed +func (api *Client) FunctionCompleteSuccess(functionExecutionId string, options ...FunctionCompleteSuccessRequestOption) error { + return api.FunctionCompleteSuccessContext(context.Background(), functionExecutionId, options...) +} + +// FunctionCompleteSuccess indicates function is completed +func (api *Client) FunctionCompleteSuccessContext(ctx context.Context, functionExecutionId string, options ...FunctionCompleteSuccessRequestOption) error { + // More information: https://api.slack.com/methods/functions.completeSuccess + r := &FunctionCompleteSuccessRequest{ + FunctionExecutionID: functionExecutionId, + } + for _, option := range options { + option(r) + } + + endpoint := api.endpoint + "functions.completeSuccess" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} + +// FunctionCompleteError indicates function is completed with error +func (api *Client) FunctionCompleteError(functionExecutionID string, errorMessage string) error { + return api.FunctionCompleteErrorContext(context.Background(), functionExecutionID, errorMessage) +} + +// FunctionCompleteErrorContext indicates function is completed with error +func (api *Client) FunctionCompleteErrorContext(ctx context.Context, functionExecutionID string, errorMessage string) error { + // More information: https://api.slack.com/methods/functions.completeError + r := FunctionCompleteErrorRequest{ + FunctionExecutionID: functionExecutionID, + } + r.Error = errorMessage + + endpoint := api.endpoint + "functions.completeError" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} diff --git a/function_execute_test.go b/function_execute_test.go new file mode 100644 index 000000000..356e22328 --- /dev/null +++ b/function_execute_test.go @@ -0,0 +1,80 @@ +package slack + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" +) + +func postHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + t.Error(err) + return + } + + var req FunctionCompleteSuccessRequest + err = json.Unmarshal(body, &req) + if err != nil { + t.Error(err) + return + } + + switch req.FunctionExecutionID { + case "function-success": + postSuccess(rw, r) + case "function-failure": + postFailure(rw, r) + } + } +} + +func postSuccess(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": true + }`) + rw.Write(response) +} + +func postFailure(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": false, + "error": "function_execution_not_found" + }`) + rw.Write(response) + rw.WriteHeader(500) +} + +func TestFunctionComplete(t *testing.T) { + http.HandleFunc("/functions.completeSuccess", postHandler(t)) + + once.Do(startServer) + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.FunctionCompleteSuccess("function-success") + if err != nil { + t.Error(err) + } + + err = api.FunctionCompleteSuccess("function-failure") + if err == nil { + t.Fail() + } + + err = api.FunctionCompleteSuccessContext(context.Background(), "function-success") + if err != nil { + t.Error(err) + } + + err = api.FunctionCompleteSuccessContext(context.Background(), "function-failure") + if err == nil { + t.Fail() + } +} diff --git a/interactions.go b/interactions.go index e362caa86..8c6414707 100644 --- a/interactions.go +++ b/interactions.go @@ -33,29 +33,30 @@ const ( // InteractionCallback is sent from slack when a user interactions with a button or dialog. type InteractionCallback struct { - Type InteractionType `json:"type"` - Token string `json:"token"` - CallbackID string `json:"callback_id"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - ActionTs string `json:"action_ts"` - Team Team `json:"team"` - Channel Channel `json:"channel"` - User User `json:"user"` - OriginalMessage Message `json:"original_message"` - Message Message `json:"message"` - Name string `json:"name"` - Value string `json:"value"` - MessageTs string `json:"message_ts"` - AttachmentID string `json:"attachment_id"` - ActionCallback ActionCallbacks `json:"actions"` - View View `json:"view"` - ActionID string `json:"action_id"` - APIAppID string `json:"api_app_id"` - BlockID string `json:"block_id"` - Container Container `json:"container"` - Enterprise Enterprise `json:"enterprise"` - WorkflowStep InteractionWorkflowStep `json:"workflow_step"` + Type InteractionType `json:"type"` + Token string `json:"token"` + CallbackID string `json:"callback_id"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + ActionTs string `json:"action_ts"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + OriginalMessage Message `json:"original_message"` + Message Message `json:"message"` + Name string `json:"name"` + Value string `json:"value"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + ActionCallback ActionCallbacks `json:"actions"` + View View `json:"view"` + ActionID string `json:"action_id"` + APIAppID string `json:"api_app_id"` + BlockID string `json:"block_id"` + Container Container `json:"container"` + Enterprise Enterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + WorkflowStep InteractionWorkflowStep `json:"workflow_step"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback diff --git a/interactions_test.go b/interactions_test.go index 400811b8a..830552814 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -293,13 +293,13 @@ func TestViewSubmissionCallback(t *testing.T) { State: &ViewState{ Values: map[string]map[string]BlockAction{ "multi-line": { - "ml-value": BlockAction{ + "ml-value": { Type: "plain_text_input", Value: "No onions", }, }, "target_channel": { - "target_select": BlockAction{ + "target_select": { Type: "conversations_select", Value: "C1AB2C3DE", }, diff --git a/manifests.go b/manifests.go new file mode 100644 index 000000000..0a972a25d --- /dev/null +++ b/manifests.go @@ -0,0 +1,297 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// Manifest is an application manifest schema +type Manifest struct { + Metadata ManifestMetadata `json:"_metadata,omitempty" yaml:"_metadata,omitempty"` + Display Display `json:"display_information" yaml:"display_information"` + Settings Settings `json:"settings,omitempty" yaml:"settings,omitempty"` + Features Features `json:"features,omitempty" yaml:"features,omitempty"` + OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` +} + +// CreateManifest creates an app from an app manifest. +// For more details, see CreateManifestContext documentation. +func (api *Client) CreateManifest(manifest *Manifest, token string) (*ManifestResponse, error) { + return api.CreateManifestContext(context.Background(), manifest, token) +} + +// CreateManifestContext creates an app from an app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.create +func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest, token string) (*ManifestResponse, error) { + if token == "" { + token = api.configToken + } + + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "manifest": {string(jsonBytes)}, + } + + response := &ManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.create", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// DeleteManifest permanently deletes an app created through app manifests. +// For more details, see DeleteManifestContext documentation. +func (api *Client) DeleteManifest(token string, appId string) (*SlackResponse, error) { + return api.DeleteManifestContext(context.Background(), token, appId) +} + +// DeleteManifestContext permanently deletes an app created through app manifests with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.delete +func (api *Client) DeleteManifestContext(ctx context.Context, token string, appId string) (*SlackResponse, error) { + if token == "" { + token = api.configToken + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "apps.manifest.delete", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// ExportManifest exports an app manifest from an existing app. +// For more details, see ExportManifestContext documentation. +func (api *Client) ExportManifest(token string, appId string) (*Manifest, error) { + return api.ExportManifestContext(context.Background(), token, appId) +} + +// ExportManifestContext exports an app manifest from an existing app with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.export +func (api *Client) ExportManifestContext(ctx context.Context, token string, appId string) (*Manifest, error) { + if token == "" { + token = api.configToken + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + } + + response := &ExportManifestResponse{} + err := api.postMethod(ctx, "apps.manifest.export", values, response) + if err != nil { + return nil, err + } + + return &response.Manifest, response.Err() +} + +// UpdateManifest updates an app from an app manifest. +// For more details, see UpdateManifestContext documentation. +func (api *Client) UpdateManifest(manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { + return api.UpdateManifestContext(context.Background(), manifest, token, appId) +} + +// UpdateManifestContext updates an app from an app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.update +func (api *Client) UpdateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { + if token == "" { + token = api.configToken + } + + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + "manifest": {string(jsonBytes)}, + } + + response := &UpdateManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.update", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// ValidateManifest sends a request to apps.manifest.validate to validate your app manifest. +// For more details, see ValidateManifestContext documentation. +func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ManifestResponse, error) { + return api.ValidateManifestContext(context.Background(), manifest, token, appId) +} + +// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.validate +func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ManifestResponse, error) { + if token == "" { + token = api.configToken + } + + // Marshal manifest into string + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "manifest": {string(jsonBytes)}, + } + + if appId != "" { + values.Add("app_id", appId) + } + + response := &ManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.validate", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// ManifestMetadata is a group of settings that describe the manifest +type ManifestMetadata struct { + MajorVersion int `json:"major_version,omitempty" yaml:"major_version,omitempty"` + MinorVersion int `json:"minor_version,omitempty" yaml:"minor_version,omitempty"` +} + +// Display is a group of settings that describe parts of an app's appearance within Slack +type Display struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + LongDescription string `json:"long_description,omitempty" yaml:"long_description,omitempty"` + BackgroundColor string `json:"background_color,omitempty" yaml:"background_color,omitempty"` +} + +// Settings is a group of settings corresponding to the Settings section of the app config pages. +type Settings struct { + AllowedIPAddressRanges []string `json:"allowed_ip_address_ranges,omitempty" yaml:"allowed_ip_address_ranges,omitempty"` + EventSubscriptions EventSubscriptions `json:"event_subscriptions,omitempty" yaml:"event_subscriptions,omitempty"` + Interactivity Interactivity `json:"interactivity,omitempty" yaml:"interactivity,omitempty"` + OrgDeployEnabled bool `json:"org_deploy_enabled,omitempty" yaml:"org_deploy_enabled,omitempty"` + SocketModeEnabled bool `json:"socket_mode_enabled,omitempty" yaml:"socket_mode_enabled,omitempty"` +} + +// EventSubscriptions is a group of settings that describe the Events API configuration +type EventSubscriptions struct { + RequestUrl string `json:"request_url,omitempty" yaml:"request_url,omitempty"` + BotEvents []string `json:"bot_events,omitempty" yaml:"bot_events,omitempty"` + UserEvents []string `json:"user_events,omitempty" yaml:"user_events,omitempty"` +} + +// Interactivity is a group of settings that describe the interactivity configuration +type Interactivity struct { + IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` + RequestUrl string `json:"request_url,omitempty" yaml:"request_url,omitempty"` + MessageMenuOptionsUrl string `json:"message_menu_options_url,omitempty" yaml:"message_menu_options_url,omitempty"` +} + +// Features is a group of settings corresponding to the Features section of the app config pages +type Features struct { + AppHome AppHome `json:"app_home,omitempty" yaml:"app_home,omitempty"` + BotUser BotUser `json:"bot_user,omitempty" yaml:"bot_user,omitempty"` + Shortcuts []Shortcut `json:"shortcuts,omitempty" yaml:"shortcuts,omitempty"` + SlashCommands []ManifestSlashCommand `json:"slash_commands,omitempty" yaml:"slash_commands,omitempty"` + WorkflowSteps []WorkflowStep `json:"workflow_steps,omitempty" yaml:"workflow_steps,omitempty"` +} + +// AppHome is a group of settings that describe the App Home configuration +type AppHome struct { + HomeTabEnabled bool `json:"home_tab_enabled,omitempty" yaml:"home_tab_enabled,omitempty"` + MessagesTabEnabled bool `json:"messages_tab_enabled,omitempty" yaml:"messages_tab_enabled,omitempty"` + MessagesTabReadOnlyEnabled bool `json:"messages_tab_read_only_enabled,omitempty" yaml:"messages_tab_read_only_enabled,omitempty"` +} + +// BotUser is a group of settings that describe bot user configuration +type BotUser struct { + DisplayName string `json:"display_name" yaml:"display_name"` + AlwaysOnline bool `json:"always_online,omitempty" yaml:"always_online,omitempty"` +} + +// Shortcut is a group of settings that describes shortcut configuration +type Shortcut struct { + Name string `json:"name" yaml:"name"` + CallbackID string `json:"callback_id" yaml:"callback_id"` + Description string `json:"description" yaml:"description"` + Type ShortcutType `json:"type" yaml:"type"` +} + +// ShortcutType is a new string type for the available types of shortcuts +type ShortcutType string + +const ( + MessageShortcut ShortcutType = "message" + GlobalShortcut ShortcutType = "global" +) + +// ManifestSlashCommand is a group of settings that describes slash command configuration +type ManifestSlashCommand struct { + Command string `json:"command" yaml:"command"` + Description string `json:"description" yaml:"description"` + ShouldEscape bool `json:"should_escape,omitempty" yaml:"should_escape,omitempty"` + Url string `json:"url,omitempty" yaml:"url,omitempty"` + UsageHint string `json:"usage_hint,omitempty" yaml:"usage_hint,omitempty"` +} + +// WorkflowStep is a group of settings that describes workflow steps configuration +type WorkflowStep struct { + Name string `json:"name" yaml:"name"` + CallbackID string `json:"callback_id" yaml:"callback_id"` +} + +// OAuthConfig is a group of settings that describe OAuth configuration for the app +type OAuthConfig struct { + RedirectUrls []string `json:"redirect_urls,omitempty" yaml:"redirect_urls,omitempty"` + Scopes OAuthScopes `json:"scopes,omitempty" yaml:"scopes,omitempty"` +} + +// OAuthScopes is a group of settings that describe permission scopes configuration +type OAuthScopes struct { + Bot []string `json:"bot,omitempty" yaml:"bot,omitempty"` + User []string `json:"user,omitempty" yaml:"user,omitempty"` +} + +// ManifestResponse is the response returned by the API for apps.manifest.x endpoints +type ManifestResponse struct { + Errors []ManifestValidationError `json:"errors,omitempty"` + SlackResponse +} + +// ManifestValidationError is an error message returned for invalid manifests +type ManifestValidationError struct { + Message string `json:"message"` + Pointer string `json:"pointer"` +} + +type ExportManifestResponse struct { + Manifest Manifest `json:"manifest,omitempty"` + SlackResponse +} + +type UpdateManifestResponse struct { + AppId string `json:"app_id,omitempty"` + PermissionsUpdated bool `json:"permissions_updated,omitempty"` + ManifestResponse +} diff --git a/manifests_test.go b/manifests_test.go new file mode 100644 index 000000000..9133e68c8 --- /dev/null +++ b/manifests_test.go @@ -0,0 +1,149 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestCreateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.create", handleCreateManifest) + once.Do(startServer) + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.CreateManifest(&manif, "token") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(resp, getTestManifestResponse()) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleCreateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(getTestManifestResponse()) + rw.Write(response) +} + +func TestDeleteManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.delete", handleDeleteManifest) + expectedResponse := SlackResponse{Ok: true} + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.DeleteManifest("token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleDeleteManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(SlackResponse{Ok: true}) + rw.Write(response) +} + +func TestExportManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.export", handleExportManifest) + expectedResponse := getTestManifest() + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.ExportManifest("token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleExportManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(ExportManifestResponse{Manifest: getTestManifest()}) + rw.Write(response) +} + +func TestUpdateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.update", handleUpdateManifest) + expectedResponse := UpdateManifestResponse{AppId: "app id"} + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.UpdateManifest(&manif, "token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleUpdateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(UpdateManifestResponse{AppId: "app id"}) + rw.Write(response) +} + +func TestValidateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.validate", handleValidateManifest) + expectedResponse := ManifestResponse{SlackResponse: SlackResponse{Ok: true}} + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.ValidateManifest(&manif, "token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleValidateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(ManifestResponse{SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func getTestManifest() Manifest { + return Manifest{ + Display: Display{ + Name: "test", + Description: "this is a test", + }, + } +} + +func getTestManifestResponse() *ManifestResponse { + return &ManifestResponse{ + SlackResponse: SlackResponse{ + Ok: true, + }, + } +} diff --git a/messages.go b/messages.go index 333404563..13a1ede98 100644 --- a/messages.go +++ b/messages.go @@ -100,10 +100,11 @@ type Msg struct { Members []string `json:"members,omitempty"` // channels.replies, groups.replies, im.replies, mpim.replies - ReplyCount int `json:"reply_count,omitempty"` - Replies []Reply `json:"replies,omitempty"` - ParentUserId string `json:"parent_user_id,omitempty"` - LatestReply string `json:"latest_reply,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + ReplyUsers []string `json:"reply_users,omitempty"` + Replies []Reply `json:"replies,omitempty"` + ParentUserId string `json:"parent_user_id,omitempty"` + LatestReply string `json:"latest_reply,omitempty"` // file_share, file_comment, file_mention Files []File `json:"files,omitempty"` diff --git a/misc.go b/misc.go index 3fe189943..a8c64bf83 100644 --- a/misc.go +++ b/misc.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "mime" "mime/multipart" "net/http" @@ -85,13 +84,12 @@ func (e RateLimitedError) Retryable() bool { return true } -func fileUploadReq(ctx context.Context, path string, values url.Values, r io.Reader) (*http.Request, error) { +func fileUploadReq(ctx context.Context, path string, r io.Reader) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, r) if err != nil { return nil, err } - req.URL.RawQuery = values.Encode() return req, nil } @@ -149,7 +147,7 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http. } func parseResponseBody(body io.ReadCloser, intf interface{}, d Debug) error { - response, err := ioutil.ReadAll(body) + response, err := io.ReadAll(body) if err != nil { return err } @@ -178,9 +176,16 @@ func postLocalWithMultipartResponse(ctx context.Context, client httpClient, meth func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf interface{}, d Debug) error { pipeReader, pipeWriter := io.Pipe() wr := multipart.NewWriter(pipeWriter) + errc := make(chan error) go func() { defer pipeWriter.Close() + defer wr.Close() + err := createFormFields(wr, values) + if err != nil { + errc <- err + return + } ioWriter, err := wr.CreateFormFile(fieldname, name) if err != nil { errc <- err @@ -196,7 +201,8 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam return } }() - req, err := fileUploadReq(ctx, path, values, pipeReader) + + req, err := fileUploadReq(ctx, path, pipeReader) if err != nil { return err } @@ -222,6 +228,20 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam } } +func createFormFields(mw *multipart.Writer, values url.Values) error { + for key, value := range values { + writer, err := mw.CreateFormField(key) + if err != nil { + return err + } + _, err = writer.Write([]byte(value[0])) + if err != nil { + return err + } + } + return nil +} + func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error { resp, err := client.Do(req) if err != nil { @@ -338,7 +358,7 @@ func newJSONParser(dst interface{}) responseParser { func newTextParser(dst interface{}) responseParser { return func(resp *http.Response) error { - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { return err } diff --git a/oauth.go b/oauth.go index e7969b020..0c77eca40 100644 --- a/oauth.go +++ b/oauth.go @@ -70,12 +70,23 @@ type OAuthV2ResponseAuthedUser struct { TokenType string `json:"token_type"` } -// GetOAuthToken retrieves an AccessToken +// OpenIDConnectResponse ... +type OpenIDConnectResponse struct { + Ok bool `json:"ok"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IdToken string `json:"id_token"` + SlackResponse +} + +// GetOAuthToken retrieves an AccessToken. +// For more details, see GetOAuthTokenContext documentation. func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthTokenContext retrieves an AccessToken with a custom context +// GetOAuthTokenContext retrieves an AccessToken with a custom context. +// For more details, see GetOAuthResponseContext documentation. func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI) if err != nil { @@ -85,11 +96,13 @@ func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clie } // GetBotOAuthToken retrieves top-level and bot AccessToken - https://api.slack.com/legacy/oauth#bot_user_access_tokens +// For more details, see GetBotOAuthTokenContext documentation. func GetBotOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) { return GetBotOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context +// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context. +// For more details, see GetOAuthResponseContext documentation. func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) { response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI) if err != nil { @@ -98,12 +111,14 @@ func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, c return response.AccessToken, response.Scope, response.Bot, nil } -// GetOAuthResponse retrieves OAuth response +// GetOAuthResponse retrieves OAuth response. +// For more details, see GetOAuthResponseContext documentation. func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) { return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthResponseContext retrieves OAuth response with custom context +// GetOAuthResponseContext retrieves OAuth response with custom context. +// Slack API docs: https://api.slack.com/methods/oauth.access func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) { values := url.Values{ "client_id": {clientID}, @@ -118,12 +133,14 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c return response, response.Err() } -// GetOAuthV2Response gets a V2 OAuth access token response - https://api.slack.com/methods/oauth.v2.access +// GetOAuthV2Response gets a V2 OAuth access token response. +// For more details, see GetOAuthV2ResponseContext documentation. func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) { return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response +// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response. +// Slack API docs: https://api.slack.com/methods/oauth.v2.access func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, @@ -138,12 +155,14 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, return response, response.Err() } -// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2Token with a context, gets a V2 OAuth access token response. +// For more details, see RefreshOAuthV2TokenContext documentation. func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken) } -// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response. +// Slack API docs: https://api.slack.com/methods/oauth.v2.access func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, @@ -157,3 +176,25 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID } return response, response.Err() } + +// GetOpenIDConnectToken exchanges a temporary OAuth verifier code for an access token for Sign in with Slack. +// For more details, see GetOpenIDConnectTokenContext documentation. +func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { + return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) +} + +// GetOpenIDConnectTokenContext with a context, gets an access token for Sign in with Slack. +// Slack API docs: https://api.slack.com/methods/openid.connect.token +func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { + values := url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + response := &OpenIDConnectResponse{} + if err = postForm(ctx, client, APIURL+"openid.connect.token", values, response, discard{}); err != nil { + return nil, err + } + return response, response.Err() +} diff --git a/pins.go b/pins.go index ef97c8dfb..5e6cf0c7f 100644 --- a/pins.go +++ b/pins.go @@ -12,12 +12,14 @@ type listPinsResponseFull struct { SlackResponse } -// AddPin pins an item in a channel +// AddPin pins an item in a channel. +// For more details, see AddPinContext documentation. func (api *Client) AddPin(channel string, item ItemRef) error { return api.AddPinContext(context.Background(), channel, item) } -// AddPinContext pins an item in a channel with a custom context +// AddPinContext pins an item in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.add func (api *Client) AddPinContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -41,12 +43,14 @@ func (api *Client) AddPinContext(ctx context.Context, channel string, item ItemR return response.Err() } -// RemovePin un-pins an item from a channel +// RemovePin un-pins an item from a channel. +// For more details, see RemovePinContext documentation. func (api *Client) RemovePin(channel string, item ItemRef) error { return api.RemovePinContext(context.Background(), channel, item) } -// RemovePinContext un-pins an item from a channel with a custom context +// RemovePinContext un-pins an item from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.remove func (api *Client) RemovePinContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -71,11 +75,13 @@ func (api *Client) RemovePinContext(ctx context.Context, channel string, item It } // ListPins returns information about the items a user reacted to. +// For more details, see ListPinsContext documentation. func (api *Client) ListPins(channel string) ([]Item, *Paging, error) { return api.ListPinsContext(context.Background(), channel) } // ListPinsContext returns information about the items a user reacted to with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.list func (api *Client) ListPinsContext(ctx context.Context, channel string) ([]Item, *Paging, error) { values := url.Values{ "channel": {channel}, diff --git a/reactions.go b/reactions.go index 2a9bd42e7..240f5ba92 100644 --- a/reactions.go +++ b/reactions.go @@ -67,10 +67,11 @@ const ( // ListReactionsParameters is the inputs to find all reactions by a user. type ListReactionsParameters struct { - User string - Count int - Page int - Full bool + User string + TeamID string + Count int + Page int + Full bool } // NewListReactionsParameters initializes the inputs to find all reactions @@ -128,11 +129,13 @@ func (res listReactionsResponseFull) extractReactedItems() []ReactedItem { } // AddReaction adds a reaction emoji to a message, file or file comment. +// For more details, see AddReactionContext documentation. func (api *Client) AddReaction(name string, item ItemRef) error { return api.AddReactionContext(context.Background(), name, item) } // AddReactionContext adds a reaction emoji to a message, file or file comment with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.add func (api *Client) AddReactionContext(ctx context.Context, name string, item ItemRef) error { values := url.Values{ "token": {api.token}, @@ -162,11 +165,13 @@ func (api *Client) AddReactionContext(ctx context.Context, name string, item Ite } // RemoveReaction removes a reaction emoji from a message, file or file comment. +// For more details, see RemoveReactionContext documentation. func (api *Client) RemoveReaction(name string, item ItemRef) error { return api.RemoveReactionContext(context.Background(), name, item) } // RemoveReactionContext removes a reaction emoji from a message, file or file comment with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.remove func (api *Client) RemoveReactionContext(ctx context.Context, name string, item ItemRef) error { values := url.Values{ "token": {api.token}, @@ -196,11 +201,13 @@ func (api *Client) RemoveReactionContext(ctx context.Context, name string, item } // GetReactions returns details about the reactions on an item. +// For more details, see GetReactionsContext documentation. func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { return api.GetReactionsContext(context.Background(), item, params) } -// GetReactionsContext returns details about the reactions on an item with a custom context +// GetReactionsContext returns details about the reactions on an item with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.get func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { values := url.Values{ "token": {api.token}, @@ -234,11 +241,13 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params } // ListReactions returns information about the items a user reacted to. +// For more details, see ListReactionsContext documentation. func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, *Paging, error) { return api.ListReactionsContext(context.Background(), params) } // ListReactionsContext returns information about the items a user reacted to with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.list func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -246,6 +255,9 @@ func (api *Client) ListReactionsContext(ctx context.Context, params ListReaction if params.User != DEFAULT_REACTIONS_USER { values.Add("user", params.User) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Count != DEFAULT_REACTIONS_COUNT { values.Add("count", strconv.Itoa(params.Count)) } diff --git a/reminders.go b/reminders.go index 53d67c03c..e025bc9b6 100644 --- a/reminders.go +++ b/reminders.go @@ -41,23 +41,18 @@ func (api *Client) doReminders(ctx context.Context, path string, values url.Valu // create an array of pointers to reminders var reminders = make([]*Reminder, 0, len(response.Reminders)) - for _, reminder := range response.Reminders { - reminders = append(reminders, reminder) - } - + reminders = append(reminders, response.Reminders...) return reminders, response.Err() } // ListReminders lists all the reminders created by or for the authenticated user -// -// See https://api.slack.com/methods/reminders.list +// For more details, see ListRemindersContext documentation. func (api *Client) ListReminders() ([]*Reminder, error) { return api.ListRemindersContext(context.Background()) } -// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context -// -// For more details, see ListReminders documentation. +// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context. +// Slack API docs: https://api.slack.com/methods/reminders.list func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -66,17 +61,14 @@ func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error } // AddChannelReminder adds a reminder for a channel. -// -// See https://api.slack.com/methods/reminders.add (NOTE: the ability to set -// reminders on a channel is currently undocumented but has been tested to -// work) +// For more details, see AddChannelReminderContext documentation. func (api *Client) AddChannelReminder(channelID, text, time string) (*Reminder, error) { return api.AddChannelReminderContext(context.Background(), channelID, text, time) } // AddChannelReminderContext adds a reminder for a channel with a custom context -// -// For more details, see AddChannelReminder documentation. +// NOTE: the ability to set reminders on a channel is currently undocumented but has been tested to work. +// Slack API docs: https://api.slack.com/methods/reminders.add func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, text, time string) (*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -88,17 +80,13 @@ func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, tex } // AddUserReminder adds a reminder for a user. -// -// See https://api.slack.com/methods/reminders.add (NOTE: the ability to set -// reminders on a channel is currently undocumented but has been tested to -// work) +// For more details, see AddUserReminderContext documentation. func (api *Client) AddUserReminder(userID, text, time string) (*Reminder, error) { return api.AddUserReminderContext(context.Background(), userID, text, time) } // AddUserReminderContext adds a reminder for a user with a custom context -// -// For more details, see AddUserReminder documentation. +// Slack API docs: https://api.slack.com/methods/reminders.add func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, time string) (*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -110,15 +98,13 @@ func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, tim } // DeleteReminder deletes an existing reminder. -// -// See https://api.slack.com/methods/reminders.delete +// For more details, see DeleteReminderContext documentation. func (api *Client) DeleteReminder(id string) error { return api.DeleteReminderContext(context.Background(), id) } // DeleteReminderContext deletes an existing reminder with a custom context -// -// For more details, see DeleteReminder documentation. +// Slack API docs: https://api.slack.com/methods/reminders.delete func (api *Client) DeleteReminderContext(ctx context.Context, id string) error { values := url.Values{ "token": {api.token}, diff --git a/reminders_test.go b/reminders_test.go index 25291b543..09dd6a096 100644 --- a/reminders_test.go +++ b/reminders_test.go @@ -2,7 +2,7 @@ package slack import ( "bytes" - "io/ioutil" + "io" "net/http" "reflect" "testing" @@ -185,7 +185,7 @@ func (m *mockRemindersListHTTPClient) Do(*http.Request) (*http.Response, error) ] }` - return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewBufferString(responseString))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(responseString))}, nil } func TestSlack_ListReminders(t *testing.T) { diff --git a/remotefiles.go b/remotefiles.go index 8a908a8f3..42639a178 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -97,14 +97,13 @@ func (api *Client) remoteFileRequest(ctx context.Context, path string, values ur } // AddRemoteFile adds a remote file. Unlike regular files, remote files must be explicitly shared. -// For more details: -// https://api.slack.com/methods/files.remote.add +// For more details see the AddRemoteFileContext documentation. func (api *Client) AddRemoteFile(params RemoteFileParameters) (*RemoteFile, error) { return api.AddRemoteFileContext(context.Background(), params) } // AddRemoteFileContext adds a remote file and setting a custom context -// For more details see the AddRemoteFile documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.add func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFileParameters) (remotefile *RemoteFile, err error) { if params.ExternalID == "" || params.ExternalURL == "" || params.Title == "" { return nil, ErrParametersMissing @@ -138,14 +137,13 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa } // ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination. -// For more details: -// https://api.slack.com/methods/files.remote.list +// For more details see the ListRemoteFilesContext documentation. func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, error) { return api.ListRemoteFilesContext(context.Background(), params) } // ListRemoteFilesContext retrieves all remote files according to the parameters given with a custom context. Uses cursor based pagination. -// For more details see the ListRemoteFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.list func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, error) { values := url.Values{ "token": {api.token}, @@ -177,14 +175,13 @@ func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemote } // GetRemoteFileInfo retrieves the complete remote file information. -// For more details: -// https://api.slack.com/methods/files.remote.info +// For more details see the GetRemoteFileInfoContext documentation. func (api *Client) GetRemoteFileInfo(externalID, fileID string) (remotefile *RemoteFile, err error) { return api.GetRemoteFileInfoContext(context.Background(), externalID, fileID) } // GetRemoteFileInfoContext retrieves the complete remote file information given with a custom context. -// For more details see the GetRemoteFileInfo documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.info func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fileID string) (remotefile *RemoteFile, err error) { if fileID == "" && externalID == "" { return nil, fmt.Errorf("either externalID or fileID is required") @@ -208,15 +205,14 @@ func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fil return &response.RemoteFile, err } -// ShareRemoteFile shares a remote file to channels -// For more details: -// https://api.slack.com/methods/files.remote.share +// ShareRemoteFile shares a remote file to channels. +// For more details see the ShareRemoteFileContext documentation. func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) (file *RemoteFile, err error) { return api.ShareRemoteFileContext(context.Background(), channels, externalID, fileID) } // ShareRemoteFileContext shares a remote file to channels with a custom context. -// For more details see the ShareRemoteFile documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.share func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { if channels == nil || len(channels) == 0 { return nil, ErrParametersMissing @@ -241,20 +237,17 @@ func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string return &response.RemoteFile, err } -// UpdateRemoteFile updates a remote file -// For more details: -// https://api.slack.com/methods/files.remote.update +// UpdateRemoteFile updates a remote file. +// For more details see the UpdateRemoteFileContext documentation. func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { return api.UpdateRemoteFileContext(context.Background(), fileID, params) } -// UpdateRemoteFileContext updates a remote file with a custom context -// For more details see the UpdateRemoteFile documentation. +// UpdateRemoteFileContext updates a remote file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.remote.update func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { response := &remoteFileResponseFull{} - values := url.Values{ - "token": {api.token}, - } + values := url.Values{} if fileID != "" { values.Add("file", fileID) } @@ -276,6 +269,7 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p if params.PreviewImageReader != nil { err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { + values.Add("token", api.token) response, err = api.remoteFileRequest(ctx, "files.remote.update", values) } @@ -287,14 +281,13 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p } // RemoveRemoteFile removes a remote file. -// For more details: -// https://api.slack.com/methods/files.remote.remove +// For more information see the RemoveRemoteFileContext documentation. func (api *Client) RemoveRemoteFile(externalID, fileID string) (err error) { return api.RemoveRemoteFileContext(context.Background(), externalID, fileID) } // RemoveRemoteFileContext removes a remote file with a custom context -// For more information see the RemoveRemoteFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.remove func (api *Client) RemoveRemoteFileContext(ctx context.Context, externalID, fileID string) (err error) { if fileID == "" && externalID == "" { return fmt.Errorf("either externalID or fileID is required") diff --git a/search.go b/search.go index de6b40acb..d27497aae 100644 --- a/search.go +++ b/search.go @@ -15,6 +15,7 @@ const ( ) type SearchParameters struct { + TeamID string Sort string SortDirection string Highlight bool @@ -93,6 +94,9 @@ func (api *Client) _search(ctx context.Context, path, query string, params Searc "token": {api.token}, "query": {query}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Sort != DEFAULT_SEARCH_SORT { values.Add("sort", params.Sort) } diff --git a/slack.go b/slack.go index ea3aab6d6..756106fe4 100644 --- a/slack.go +++ b/slack.go @@ -57,12 +57,14 @@ type authTestResponseFull struct { type ParamOption func(*url.Values) type Client struct { - token string - appLevelToken string - endpoint string - debug bool - log ilogger - httpclient httpClient + token string + appLevelToken string + configToken string + configRefreshToken string + endpoint string + debug bool + log ilogger + httpclient httpClient } // Option defines an option for a Client @@ -99,6 +101,16 @@ func OptionAppLevelToken(token string) func(*Client) { return func(c *Client) { c.appLevelToken = token } } +// OptionConfigToken sets a configuration token for the client. +func OptionConfigToken(token string) func(*Client) { + return func(c *Client) { c.configToken = token } +} + +// OptionConfigRefreshToken sets a configuration refresh token for the client. +func OptionConfigRefreshToken(token string) func(*Client) { + return func(c *Client) { c.configRefreshToken = token } +} + // New builds a slack client from the provided token and options. func New(token string, options ...Option) *Client { s := &Client{ diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 6fcb0b59b..7032d1f93 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -31,6 +31,9 @@ type AppMentionEvent struct { // BotID is filled out when a bot triggers the app_mention event BotID string `json:"bot_id,omitempty"` + + // When the app is mentioned in the edited message + Edited *Edited `json:"edited,omitempty"` } // AppHomeOpenedEvent Your Slack app home was opened. @@ -275,8 +278,11 @@ type MessageEvent struct { Upload bool `json:"upload"` Files []File `json:"files"` + Blocks slack.Blocks `json:"blocks,omitempty"` Attachments []slack.Attachment `json:"attachments,omitempty"` + Metadata slack.SlackMetadata `json:"metadata,omitempty"` + // Root is the message that was broadcast to the channel when the SubType is // thread_broadcast. If this is not a thread_broadcast message event, this // value is nil. @@ -346,12 +352,6 @@ type TeamJoinEvent struct { EventTimestamp string `json:"event_ts"` } -// UserChangeEvent happens when a user's profile changes -type UserChangeEvent struct { - Type string `json:"type"` - User *slack.User `json:"user"` -} - // UserProfileChangeEvent happens when a user's profile changes type UserProfileChangeEvent struct { Type string `json:"type"` @@ -476,6 +476,7 @@ type File struct { DisplayAsBot bool `json:"display_as_bot"` Username string `json:"username"` URLPrivate string `json:"url_private"` + FileAccess string `json:"file_access"` URLPrivateDownload string `json:"url_private_download"` Thumb64 string `json:"thumb_64"` Thumb80 string `json:"thumb_80"` @@ -556,6 +557,568 @@ type TeamAccessRevokedEvent struct { TeamIDs []string `json:"team_ids"` } +// UserProfileChangedEvent is sent if access to teams was revoked for your org-wide app. +type UserProfileChangedEvent struct { + User *slack.User `json:"user"` + CacheTs int `json:"cache_ts"` + Type string `json:"type"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteApprovedEvent is sent if your invitation has been approved +type SharedChannelInviteApprovedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *slack.Conversation `json:"channel"` + ApprovingTeamID string `json:"approving_team_id"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + ApprovingUser *SlackEventUser `json:"approving_user"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteAcceptedEvent is sent if external org accepts a Slack Connect channel invite +type SharedChannelInviteAcceptedEvent struct { + Type string `json:"type"` + ApprovalRequired bool `json:"approval_required"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + AcceptingUser *SlackEventUser `json:"accepting_user"` + EventTs string `json:"event_ts"` + RequiresSponsorship bool `json:"requires_sponsorship,omitempty"` +} + +// SharedChannelInviteDeclinedEvent is sent if external or internal org declines the Slack Connect invite +type SharedChannelInviteDeclinedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + DecliningTeamID string `json:"declining_team_id"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + DecliningUser *SlackEventUser `json:"declining_user"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteReceivedEvent is sent if a bot or app is invited to a Slack Connect channel +type SharedChannelInviteReceivedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + EventTs string `json:"event_ts"` +} + +// SlackEventTeam is a struct for teams in ShareChannel events +type SlackEventTeam struct { + ID string `json:"id"` + Name string `json:"name"` + Icon *SlackEventIcon `json:"icon,omitempty"` + AvatarBaseURL string `json:"avatar_base_url,omitempty"` + IsVerified bool `json:"is_verified"` + Domain string `json:"domain"` + DateCreated int `json:"date_created"` + RequiresSponsorship bool `json:"requires_sponsorship,omitempty"` + // TeamID string `json:"team_id,omitempty"` +} + +// SlackEventIcon is a struct for icons in ShareChannel events +type SlackEventIcon struct { + ImageDefault bool `json:"image_default,omitempty"` + Image34 string `json:"image_34,omitempty"` + Image44 string `json:"image_44,omitempty"` + Image68 string `json:"image_68,omitempty"` + Image88 string `json:"image_88,omitempty"` + Image102 string `json:"image_102,omitempty"` + Image132 string `json:"image_132,omitempty"` + Image230 string `json:"image_230,omitempty"` +} + +// SlackEventUser is a struct for users in ShareChannel events +type SlackEventUser struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Updated int `json:"updated,omitempty"` + Profile *slack.UserProfile `json:"profile,omitempty"` + WhoCanShareContactCard string `json:"who_can_share_contact_card,omitempty"` +} + +// SharedChannel is a struct for shared channels in ShareChannel events +type SharedChannel struct { + ID string `json:"id"` + IsPrivate bool `json:"is_private"` + IsIm bool `json:"is_im"` + Name string `json:"name,omitempty"` +} + +// SharedInvite is a struct for shared invites in ShareChannel events +type SharedInvite struct { + ID string `json:"id"` + DateCreated int `json:"date_created"` + DateInvalid int `json:"date_invalid"` + InvitingTeam *SlackEventTeam `json:"inviting_team,omitempty"` + InvitingUser *SlackEventUser `json:"inviting_user,omitempty"` + RecipientEmail string `json:"recipient_email,omitempty"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + IsSponsored bool `json:"is_sponsored,omitempty"` + IsExternalLimited bool `json:"is_external_limited,omitempty"` +} + +type ChannelHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type CommandsChangedEvent struct { + Type string `json:"type"` + EventTs string `json:"event_ts"` +} + +type DndUpdatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + DndStatus struct { + DndEnabled bool `json:"dnd_enabled"` + NextDndStartTs int64 `json:"next_dnd_start_ts"` + NextDndEndTs int64 `json:"next_dnd_end_ts"` + SnoozeEnabled bool `json:"snooze_enabled"` + SnoozeEndtime int64 `json:"snooze_endtime"` + } `json:"dnd_status"` +} + +type DndUpdatedUserEvent struct { + Type string `json:"type"` + User string `json:"user"` + DndStatus struct { + DndEnabled bool `json:"dnd_enabled"` + NextDndStartTs int64 `json:"next_dnd_start_ts"` + NextDndEndTs int64 `json:"next_dnd_end_ts"` + } `json:"dnd_status"` +} + +type EmailDomainChangedEvent struct { + Type string `json:"type"` + EmailDomain string `json:"email_domain"` + EventTs string `json:"event_ts"` +} + +type GroupCloseEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type GroupHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type GroupOpenEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type ImCloseEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type ImCreatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel struct { + ID string `json:"id"` + } `json:"channel"` +} + +type ImHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type ImOpenEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type SubTeam struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + IsUsergroup bool `json:"is_usergroup"` + Name string `json:"name"` + Description string `json:"description"` + Handle string `json:"handle"` + IsExternal bool `json:"is_external"` + DateCreate int64 `json:"date_create"` + DateUpdate int64 `json:"date_update"` + DateDelete int64 `json:"date_delete"` + AutoType string `json:"auto_type"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + DeletedBy string `json:"deleted_by"` + Prefs struct { + Channels []string `json:"channels"` + Groups []string `json:"groups"` + } `json:"prefs"` + Users []string `json:"users"` + UserCount int `json:"user_count"` +} + +type SubteamCreatedEvent struct { + Type string `json:"type"` + Subteam SubTeam `json:"subteam"` +} + +type SubteamMembersChangedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` + TeamID string `json:"team_id"` + DatePreviousUpdate int `json:"date_previous_update"` + DateUpdate int64 `json:"date_update"` + AddedUsers []string `json:"added_users"` + AddedUsersCount string `json:"added_users_count"` + RemovedUsers []string `json:"removed_users"` + RemovedUsersCount string `json:"removed_users_count"` +} + +type SubteamSelfAddedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` +} + +type SubteamSelfRemovedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` +} + +type SubteamUpdatedEvent struct { + Type string `json:"type"` + Subteam SubTeam `json:"subteam"` +} + +type TeamDomainChangeEvent struct { + Type string `json:"type"` + URL string `json:"url"` + Domain string `json:"domain"` + TeamID string `json:"team_id"` +} + +type TeamRenameEvent struct { + Type string `json:"type"` + Name string `json:"name"` + TeamID string `json:"team_id"` +} + +type UserChangeEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type AppDeletedEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type AppInstalledEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type AppRequestedEvent struct { + Type string `json:"type"` + AppRequest struct { + ID string `json:"id"` + App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + HelpURL string `json:"help_url"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + AppHomepageURL string `json:"app_homepage_url"` + AppDirectoryURL string `json:"app_directory_url"` + IsAppDirectoryApproved bool `json:"is_app_directory_approved"` + IsInternal bool `json:"is_internal"` + AdditionalInfo string `json:"additional_info"` + } `json:"app"` + PreviousResolution struct { + Status string `json:"status"` + Scopes []struct { + Name string `json:"name"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + TokenType string `json:"token_type"` + } `json:"scopes"` + } `json:"previous_resolution"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + } `json:"team"` + Enterprise interface{} `json:"enterprise"` + Scopes []struct { + Name string `json:"name"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + TokenType string `json:"token_type"` + } `json:"scopes"` + Message string `json:"message"` + } `json:"app_request"` +} + +type AppUninstalledTeamEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type CallRejectedEvent struct { + Token string `json:"token"` + TeamID string `json:"team_id"` + APIAppID string `json:"api_app_id"` + Event struct { + Type string `json:"type"` + CallID string `json:"call_id"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + ExternalUniqueID string `json:"external_unique_id"` + } `json:"event"` + Type string `json:"type"` + EventID string `json:"event_id"` + AuthedUsers []string `json:"authed_users"` +} + +type ChannelSharedEvent struct { + Type string `json:"type"` + ConnectedTeamID string `json:"connected_team_id"` + Channel string `json:"channel"` + EventTs string `json:"event_ts"` +} + +type FileCreatedEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File struct { + ID string `json:"id"` + } `json:"file"` +} + +type FilePublicEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File struct { + ID string `json:"id"` + } `json:"file"` +} + +type FunctionExecutedEvent struct { + Type string `json:"type"` + Function struct { + ID string `json:"id"` + CallbackID string `json:"callback_id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + InputParameters []struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Title string `json:"title"` + IsRequired bool `json:"is_required"` + } `json:"input_parameters"` + OutputParameters []struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Title string `json:"title"` + IsRequired bool `json:"is_required"` + } `json:"output_parameters"` + AppID string `json:"app_id"` + DateCreated int64 `json:"date_created"` + DateUpdated int64 `json:"date_updated"` + DateDeleted int64 `json:"date_deleted"` + } `json:"function"` + Inputs map[string]string `json:"inputs"` + FunctionExecutionID string `json:"function_execution_id"` + WorkflowExecutionID string `json:"workflow_execution_id"` + EventTs string `json:"event_ts"` + BotAccessToken string `json:"bot_access_token"` +} + +type InviteRequestedEvent struct { + Type string `json:"type"` + InviteRequest struct { + ID string `json:"id"` + Email string `json:"email"` + DateCreated int64 `json:"date_created"` + RequesterIDs []string `json:"requester_ids"` + ChannelIDs []string `json:"channel_ids"` + InviteType string `json:"invite_type"` + RealName string `json:"real_name"` + DateExpire int64 `json:"date_expire"` + RequestReason string `json:"request_reason"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + } `json:"team"` + } `json:"invite_request"` +} + +type StarAddedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item struct { + } `json:"item"` + EventTS string `json:"event_ts"` +} + +type StarRemovedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item struct { + } `json:"item"` + EventTS string `json:"event_ts"` +} + +type UserHuddleChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type User struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Color string `json:"color"` + RealName string `json:"real_name"` + TZ string `json:"tz"` + TZLabel string `json:"tz_label"` + TZOffset int `json:"tz_offset"` + Profile Profile `json:"profile"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + IsBot bool `json:"is_bot"` + IsAppUser bool `json:"is_app_user"` + Updated int64 `json:"updated"` + IsEmailConfirmed bool `json:"is_email_confirmed"` + WhoCanShareContactCard string `json:"who_can_share_contact_card"` + Locale string `json:"locale"` +} + +type Profile struct { + Title string `json:"title"` + Phone string `json:"phone"` + Skype string `json:"skype"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + DisplayName string `json:"display_name"` + DisplayNameNormalized string `json:"display_name_normalized"` + Fields map[string]interface{} `json:"fields"` + StatusText string `json:"status_text"` + StatusEmoji string `json:"status_emoji"` + StatusEmojiDisplayInfo []interface{} `json:"status_emoji_display_info"` + StatusExpiration int `json:"status_expiration"` + AvatarHash string `json:"avatar_hash"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + Image512 string `json:"image_512"` + StatusTextCanonical string `json:"status_text_canonical"` + Team string `json:"team"` +} + +type UserStatusChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type Actor struct { + ID string `json:"id"` + Name string `json:"name"` + IsBot bool `json:"is_bot"` + TeamID string `json:"team_id"` + Timezone string `json:"timezone"` + RealName string `json:"real_name"` + DisplayName string `json:"display_name"` +} + +type TargetUser struct { + Email string `json:"email"` + InviteID string `json:"invite_id"` +} + +type TeamIcon struct { + Image34 string `json:"image_34"` + ImageDefault bool `json:"image_default"` +} + +type Team struct { + ID string `json:"id"` + Icon TeamIcon `json:"icon"` + Name string `json:"name"` + Domain string `json:"domain"` + IsVerified bool `json:"is_verified"` + DateCreated int64 `json:"date_created"` + AvatarBaseURL string `json:"avatar_base_url"` + RequiresSponsorship bool `json:"requires_sponsorship"` +} + +type SharedChannelInviteRequestedEvent struct { + Actor Actor `json:"actor"` + ChannelID string `json:"channel_id"` + EventType string `json:"event_type"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` + TargetUsers []TargetUser `json:"target_users"` + TeamsInChannel []Team `json:"teams_in_channel"` + IsExternalLimited bool `json:"is_external_limited"` + ChannelDateCreated int64 `json:"channel_date_created"` + ChannelMessageLatestCounted int64 `json:"channel_message_latest_counted_timestamp"` +} + type EventsAPIType string const ( @@ -605,9 +1168,9 @@ const ( LinkShared = EventsAPIType("link_shared") // Message A message was posted to a channel, private channel (group), im, or mim Message = EventsAPIType("message") - // Member Joined Channel + // MemberJoinedChannel is sent if a member joined a channel. MemberJoinedChannel = EventsAPIType("member_joined_channel") - // Member Left Channel + // MemberLeftChannel is sent if a member left a channel. MemberLeftChannel = EventsAPIType("member_left_channel") // PinAdded An item was pinned to a channel PinAdded = EventsAPIType("pin_added") @@ -619,70 +1182,185 @@ const ( ReactionRemoved = EventsAPIType("reaction_removed") // TeamJoin A new user joined the workspace TeamJoin = EventsAPIType("team_join") + // Slack connect app or bot invite received + SharedChannelInviteReceived = EventsAPIType("shared_channel_invite_received") + // Slack connect channel invite approved + SharedChannelInviteApproved = EventsAPIType("shared_channel_invite_approved") + // Slack connect channel invite declined + SharedChannelInviteDeclined = EventsAPIType("shared_channel_invite_declined") + // Slack connect channel invite accepted by an end user + SharedChannelInviteAccepted = EventsAPIType("shared_channel_invite_accepted") // TokensRevoked APP's API tokes are revoked TokensRevoked = EventsAPIType("tokens_revoked") // EmojiChanged A custom emoji has been added or changed EmojiChanged = EventsAPIType("emoji_changed") - // A user has changed - UserChange = EventsAPIType("user_change") // Specifically the user's profile has changed UserProfileChange = EventsAPIType("user_profile_changed") // WorkflowStepExecute Happens, if a workflow step of your app is invoked WorkflowStepExecute = EventsAPIType("workflow_step_execute") // MessageMetadataPosted A message with metadata was posted MessageMetadataPosted = EventsAPIType("message_metadata_posted") - // MessageMetadataPosted A message with metadata was updated + // MessageMetadataUpdated A message with metadata was updated MessageMetadataUpdated = EventsAPIType("message_metadata_updated") - // MessageMetadataPosted A message with metadata was deleted + // MessageMetadataDeleted A message with metadata was deleted MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") // TeamAccessGranted is sent if access to teams was granted for your org-wide app. TeamAccessGranted = EventsAPIType("team_access_granted") - // TeamAccessrevoked is sent if access to teams was revoked for your org-wide app. - TeamAccessrevoked = EventsAPIType("team_access_revoked") + // TeamAccessRevoked is sent if access to teams was revoked for your org-wide app. + TeamAccessRevoked = EventsAPIType("team_access_revoked") + // UserProfileChanged is sent if a user's profile information has changed. + UserProfileChanged = EventsAPIType("user_profile_changed") + // ChannelHistoryChanged The history of a channel changed + ChannelHistoryChanged = EventsAPIType("channel_history_changed") + // CommandsChanged A command was changed + CommandsChanged = EventsAPIType("commands_changed") + // DndUpdated Do Not Disturb settings were updated + DndUpdated = EventsAPIType("dnd_updated") + // DndUpdatedUser Do Not Disturb settings for a user were updated + DndUpdatedUser = EventsAPIType("dnd_updated_user") + // EmailDomainChanged The email domain changed + EmailDomainChanged = EventsAPIType("email_domain_changed") + // GroupClose A group was closed + GroupClose = EventsAPIType("group_close") + // GroupHistoryChanged The history of a group changed + GroupHistoryChanged = EventsAPIType("group_history_changed") + // GroupOpen A group was opened + GroupOpen = EventsAPIType("group_open") + // ImClose An instant message channel was closed + ImClose = EventsAPIType("im_close") + // ImCreated An instant message channel was created + ImCreated = EventsAPIType("im_created") + // ImHistoryChanged The history of an instant message channel changed + ImHistoryChanged = EventsAPIType("im_history_changed") + // ImOpen An instant message channel was opened + ImOpen = EventsAPIType("im_open") + // SubteamCreated A subteam was created + SubteamCreated = EventsAPIType("subteam_created") + // SubteamMembersChanged The members of a subteam changed + SubteamMembersChanged = EventsAPIType("subteam_members_changed") + // SubteamSelfAdded The current user was added to a subteam + SubteamSelfAdded = EventsAPIType("subteam_self_added") + // SubteamSelfRemoved The current user was removed from a subteam + SubteamSelfRemoved = EventsAPIType("subteam_self_removed") + // SubteamUpdated A subteam was updated + SubteamUpdated = EventsAPIType("subteam_updated") + // TeamDomainChange The team's domain changed + TeamDomainChange = EventsAPIType("team_domain_change") + // TeamRename The team was renamed + TeamRename = EventsAPIType("team_rename") + // UserChange A user object has changed + UserChange = EventsAPIType("user_change") + // AppDeleted is an event when an app is deleted from a workspace + AppDeleted = EventsAPIType("app_deleted") + // AppInstalled is an event when an app is installed to a workspace + AppInstalled = EventsAPIType("app_installed") + // AppRequested is an event when a user requests to install an app to a workspace + AppRequested = EventsAPIType("app_requested") + // AppUninstalledTeam is an event when an app is uninstalled from a team + AppUninstalledTeam = EventsAPIType("app_uninstalled_team") + // CallRejected is an event when a Slack call is rejected + CallRejected = EventsAPIType("call_rejected") + // ChannelShared is an event when a channel is shared with another workspace + ChannelShared = EventsAPIType("channel_shared") + // FileCreated is an event when a file is created in a workspace + FileCreated = EventsAPIType("file_created") + // FilePublic is an event when a file is made public in a workspace + FilePublic = EventsAPIType("file_public") + // FunctionExecuted is an event when a Slack function is executed + FunctionExecuted = EventsAPIType("function_executed") + // InviteRequested is an event when a user requests an invite to a workspace + InviteRequested = EventsAPIType("invite_requested") + // SharedChannelInviteRequested is an event when an invitation to share a channel is requested + SharedChannelInviteRequested = EventsAPIType("shared_channel_invite_requested") + // StarAdded is an event when a star is added to a message or file + StarAdded = EventsAPIType("star_added") + // StarRemoved is an event when a star is removed from a message or file + StarRemoved = EventsAPIType("star_removed") + // UserHuddleChanged is an event when a user's huddle status changes + UserHuddleChanged = EventsAPIType("user_huddle_changed") + // UserStatusChanged is an event when a user's status changes + UserStatusChanged = EventsAPIType("user_status_changed") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - FileChange: FileChangeEvent{}, - FileDeleted: FileDeletedEvent{}, - FileShared: FileSharedEvent{}, - FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - UserProfileChange: UserProfileChangeEvent{}, - UserChange: UserChangeEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessrevoked: TeamAccessRevokedEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, + SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, + SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, + ChannelHistoryChanged: ChannelHistoryChangedEvent{}, + DndUpdated: DndUpdatedEvent{}, + DndUpdatedUser: DndUpdatedUserEvent{}, + EmailDomainChanged: EmailDomainChangedEvent{}, + GroupClose: GroupCloseEvent{}, + GroupHistoryChanged: GroupHistoryChangedEvent{}, + GroupOpen: GroupOpenEvent{}, + ImClose: ImCloseEvent{}, + ImCreated: ImCreatedEvent{}, + ImHistoryChanged: ImHistoryChangedEvent{}, + ImOpen: ImOpenEvent{}, + SubteamCreated: SubteamCreatedEvent{}, + SubteamMembersChanged: SubteamMembersChangedEvent{}, + SubteamSelfAdded: SubteamSelfAddedEvent{}, + SubteamSelfRemoved: SubteamSelfRemovedEvent{}, + SubteamUpdated: SubteamUpdatedEvent{}, + TeamDomainChange: TeamDomainChangeEvent{}, + TeamRename: TeamRenameEvent{}, + UserChange: UserChangeEvent{}, + AppDeleted: AppDeletedEvent{}, + AppInstalled: AppInstalledEvent{}, + AppRequested: AppRequestedEvent{}, + AppUninstalledTeam: AppUninstalledTeamEvent{}, + CallRejected: CallRejectedEvent{}, + ChannelShared: ChannelSharedEvent{}, + FileCreated: FileCreatedEvent{}, + FilePublic: FilePublicEvent{}, + FunctionExecuted: FunctionExecutedEvent{}, + InviteRequested: InviteRequestedEvent{}, + SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, + StarAdded: StarAddedEvent{}, + StarRemoved: StarRemovedEvent{}, + UserHuddleChanged: UserHuddleChangedEvent{}, + UserStatusChanged: UserStatusChangedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index ae3ee179c..4307e8ad6 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -2,7 +2,10 @@ package slackevents import ( "encoding/json" + "fmt" "testing" + + "github.com/stretchr/testify/assert" ) func TestAppMention(t *testing.T) { @@ -297,6 +300,12 @@ func TestMessageEvent(t *testing.T) { "ts": "1355517524.000000" } }, + "metadata": { + "event_type": "example", + "event_payload": { + "key": "value" + } + }, "previous_message": { "text": "Live long and prospect." } @@ -376,19 +385,31 @@ func TestThreadBroadcastEvent(t *testing.T) { func TestMemberJoinedChannelEvent(t *testing.T) { rawE := []byte(` - { - "type": "member_joined_channel", - "user": "W06GH7XHN", - "channel": "C0698JE0H", - "channel_type": "C", - "team": "T024BE7LD", - "inviter": "U123456789" + { + "type": "member_joined_channel", + "user": "W06GH7XHN", + "channel": "C0698JE0H", + "channel_type": "C", + "team": "T024BE7LD", + "inviter": "U123456789" } `) - err := json.Unmarshal(rawE, &MemberJoinedChannelEvent{}) + evt := MemberJoinedChannelEvent{} + err := json.Unmarshal(rawE, &evt) if err != nil { t.Error(err) } + + expected := MemberJoinedChannelEvent{ + Type: "member_joined_channel", + User: "W06GH7XHN", + Channel: "C0698JE0H", + ChannelType: "C", + Team: "T024BE7LD", + Inviter: "U123456789", + } + + assert.Equal(t, expected, evt) } func TestMemberLeftChannelEvent(t *testing.T) { @@ -821,3 +842,1814 @@ func TestMessageMetadataDeleted(t *testing.T) { t.Fail() } } + +func TestUserProfileChanged(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "user": { + "id": "whatever", + "team_id": "whatever", + "name": "whatever", + "deleted": true, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "whatever", + "real_name_normalized": "whatever", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "whatever", + "api_app_id": "whatever", + "always_active": true, + "bot_id": "whatever", + "first_name": "whatever", + "last_name": "", + "image_24": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_32": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_48": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_72": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_192": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_512": "https://secure.gravatar.com/avatar/whatever.jpg", + "status_text_canonical": "", + "team": "whatever" + }, + "is_bot": true, + "is_app_user": false, + "updated": 1678984254 + }, + "cache_ts": 1678984254, + "type": "user_profile_changed", + "event_ts": "1678984255.006500" + }, + "type": "event_callback", + "event_id": "whatever", + "event_time": 1678984255, + "authorizations": [ + { + "enterprise_id": null, + "team_id": "whatever", + "user_id": "whatever", + "is_bot": false, + "is_enterprise_install": false + } + ], + "is_ext_shared_channel": false + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, &evt) + if err != nil { + t.Error(err) + } + + if evt.Type != "event_callback" { + t.Fail() + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Error(err) + } + + if parsedEvent.InnerEvent.Type != "user_profile_changed" { + t.Fail() + } + + actual, ok := parsedEvent.InnerEvent.Data.(*UserProfileChangedEvent) + if !ok { + t.Fail() + } + + if actual.User.Name != "whatever" { + t.Fail() + } +} + +func TestSharedChannelInvite(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_received", + "invite": { + "id": "I028YDERZSQ", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "event_ts": "1626876010.000100" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteReceivedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I028YDERZSQ" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_received" { + t.Fail() + } + +} + +// Test that the shared_channel_invite_accepted event can be unmarshalled +func TestSharedChannelAccepted(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_accepted", + "approval_required": false, + "invite": { + "id": "I028YDERZSQ", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com", + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "accepting_user": { + "id": "U87654321", + "team_id": "T87654321", + "name": "golden", + "updated": 1624406113, + "profile": { + "real_name": "Golden Doodle", + "display_name": "Golden", + "real_name_normalized": "Golden Doodle", + "display_name_normalized": "Golden", + "team": "T87654321", + "avatar_hash": "g717728b118x", + "email": "golden@doodle.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626877800.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteAcceptedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I028YDERZSQ" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.Channel.Name != "test-slack-connect" { + t.Fail() + fmt.Println(actual.Channel.Name + ", does not match the test name.") + } + + if actual.AcceptingUser.ID != "U87654321" { + t.Fail() + } + + if actual.AcceptingUser.Profile.RealName != "Golden Doodle" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_accepted" { + t.Fail() + } + +} + +// Test that the shared_channel_invite_declined event can be unmarshalled +func TestSharedChannelApproved(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_approved", + "invite": { + "id": "I01354X80CA", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com", + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "approving_team_id": "T87654321", + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "approving_user": { + "id": "U012A3CDE", + "team_id": "T87654321", + "name": "spengler", + "updated": 1624406532, + "profile": { + "real_name": "Egon Spengler", + "display_name": "Egon", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "Egon", + "team": "T87654321", + "avatar_hash": "g216425b1681", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626881400.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteApprovedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I01354X80CA" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.ApprovingTeamID != "T87654321" { + t.Fail() + } + + if actual.ApprovingUser.Name != "spengler" { + t.Fail() + } + + if actual.ApprovingUser.Profile.RealName != "Egon Spengler" { + t.Fail() + } + + if actual.TeamsInChannel[0].ID != "T12345678" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_approved" { + t.Fail() + } + +} + +func TestSharedChannelDeclined(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_declined", + "invite": { + "id": "I01354X80CA", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "declining_team_id": "T87654321", + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "declining_user": { + "id": "U012A3CDE", + "team_id": "T87654321", + "name": "spengler", + "updated": 1624406532, + "profile": { + "real_name": "Egon Spengler", + "display_name": "Egon", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "Egon", + "team": "T87654321", + "avatar_hash": "g216425b1681", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626881400.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteDeclinedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I01354X80CA" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientEmail != "golden@doodle.com" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.DecliningTeamID != "T87654321" { + t.Fail() + } + + if actual.DecliningUser.Name != "spengler" { + t.Fail() + } + + if actual.DecliningUser.Profile.RealName != "Egon Spengler" { + t.Fail() + } + + if actual.TeamsInChannel[0].ID != "T12345678" { + t.Fail() + } + + if actual.EventTs != "1626881400.000000" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_declined" { + t.Fail() + } + +} + +func TestChannelHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "channel_history_changed", + "latest": "1358877455.000010", + "ts": "1358877455.000008", + "event_ts": "1358877455.000011" + } + `) + + var e ChannelHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "channel_history_changed" { + t.Errorf("type should be channel_history_changed, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1358877455.000008" { + t.Errorf("ts should be 1358877455.000008, was %s", e.Ts) + } + if e.EventTs != "1358877455.000011" { + t.Errorf("event_ts should be 1358877455.000011, was %s", e.EventTs) + } +} + +func TestDndUpdatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "dnd_updated", + "user": "U1234567890", + "dnd_status": { + "dnd_enabled": true, + "next_dnd_start_ts": 1624473600, + "next_dnd_end_ts": 1624516800, + "snooze_enabled": false + } + } + `) + + var e DndUpdatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "dnd_updated" { + t.Errorf("type should be dnd_updated, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if !e.DndStatus.DndEnabled { + t.Errorf("dnd_enabled should be true, was %v", e.DndStatus.DndEnabled) + } + if e.DndStatus.NextDndStartTs != 1624473600 { + t.Errorf("next_dnd_start_ts should be 1624473600, was %d", e.DndStatus.NextDndStartTs) + } + if e.DndStatus.NextDndEndTs != 1624516800 { + t.Errorf("next_dnd_end_ts should be 1624516800, was %d", e.DndStatus.NextDndEndTs) + } + if e.DndStatus.SnoozeEnabled { + t.Errorf("snooze_enabled should be false, was %v", e.DndStatus.SnoozeEnabled) + } +} + +func TestDndUpdatedUserEvent(t *testing.T) { + rawE := []byte(` + { + "type": "dnd_updated_user", + "user": "U1234", + "dnd_status": { + "dnd_enabled": true, + "next_dnd_start_ts": 1450387800, + "next_dnd_end_ts": 1450423800 + } + } + `) + + var e DndUpdatedUserEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "dnd_updated_user" { + t.Errorf("type should be dnd_updated_user, was %s", e.Type) + } + if e.User != "U1234" { + t.Errorf("user should be U1234, was %s", e.User) + } + if !e.DndStatus.DndEnabled { + t.Errorf("dnd_enabled should be true, was %v", e.DndStatus.DndEnabled) + } + if e.DndStatus.NextDndStartTs != 1450387800 { + t.Errorf("next_dnd_start_ts should be 1450387800, was %d", e.DndStatus.NextDndStartTs) + } + if e.DndStatus.NextDndEndTs != 1450423800 { + t.Errorf("next_dnd_end_ts should be 1450423800, was %d", e.DndStatus.NextDndEndTs) + } +} + +func TestEmailDomainChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "email_domain_changed", + "email_domain": "example.com", + "event_ts": "1234567890.123456" + } + `) + + var e EmailDomainChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "email_domain_changed" { + t.Errorf("type should be email_domain_changed, was %s", e.Type) + } + if e.EmailDomain != "example.com" { + t.Errorf("email_domain should be example.com, was %s", e.EmailDomain) + } + if e.EventTs != "1234567890.123456" { + t.Errorf("event_ts should be 1234567890.123456, was %s", e.EventTs) + } +} + +func TestGroupHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_history_changed", + "latest": "1358877455.000010", + "ts": "1361482916.000003", + "event_ts": "1361482916.000004" + } + `) + + var e GroupHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_history_changed" { + t.Errorf("type should be group_history_changed, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1361482916.000003" { + t.Errorf("ts should be 1361482916.000003, was %s", e.Ts) + } +} + +func TestGroupOpenEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_open", + "user": "U024BE7LH", + "channel": "G024BE91L" + } + `) + + var e GroupOpenEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_open" { + t.Errorf("type should be group_open, was %s", e.Type) + } + if e.User != "U024BE7LH" { + t.Errorf("user should be U024BE7LH, was %s", e.User) + } + if e.Channel != "G024BE91L" { + t.Errorf("channel should be G024BE91L, was %s", e.Channel) + } +} + +func TestGroupCloseEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_close", + "user": "U1234567890", + "channel": "G1234567890" + } + `) + + var e GroupCloseEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_close" { + t.Errorf("type should be group_close, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "G1234567890" { + t.Errorf("channel should be G1234567890, was %s", e.Channel) + } +} + +func TestImCloseEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_close", + "user": "U1234567890", + "channel": "D1234567890" + } + `) + + var e ImCloseEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_close" { + t.Errorf("type should be im_close, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "D1234567890" { + t.Errorf("channel should be D1234567890, was %s", e.Channel) + } +} + +func TestImCreatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_created", + "user": "U1234567890", + "channel": { + "id": "C12345678" + } + } + `) + + var e ImCreatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_created" { + t.Errorf("type should be im_created, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel.ID != "C12345678" { + t.Errorf("channel.id should be C12345678, was %s", e.Channel.ID) + } +} + +func TestImHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_history_changed", + "latest": "1358877455.000010", + "ts": "1361482916.000003", + "event_ts": "1361482916.000004" + } + `) + + var e ImHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_history_changed" { + t.Errorf("type should be im_created, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1361482916.000003" { + t.Errorf("ts should be 1361482916.000003, was %s", e.Ts) + } + if e.EventTs != "1361482916.000004" { + t.Errorf("event_ts should be 1361482916.000004, was %s", e.EventTs) + } +} + +func TestImOpenEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_open", + "user": "U1234567890", + "channel": "D1234567890" + } + `) + + var e ImOpenEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_open" { + t.Errorf("type should be im_open, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "D1234567890" { + t.Errorf("channel should be D1234567890, was %s", e.Channel) + } +} + +func TestSubteamCreatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_created", + "subteam": { + "id": "S1234567890", + "team_id": "T1234567890", + "is_usergroup": true, + "name": "subteam", + "description": "A test subteam", + "handle": "subteam_handle", + "is_external": false, + "date_create": 1624473600, + "date_update": 1624473600, + "date_delete": 0, + "auto_type": "auto", + "created_by": "U1234567890", + "updated_by": "U1234567890", + "deleted_by": "", + "prefs": { + "channels": ["C1234567890"], + "groups": ["G1234567890"] + }, + "users": ["U1234567890"], + "user_count": 1 + } + } + `) + + var e SubteamCreatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_created" { + t.Errorf("type should be subteam_created, was %s", e.Type) + } + if e.Subteam.ID != "S1234567890" { + t.Errorf("subteam.id should be S1234567890, was %s", e.Subteam.ID) + } + if e.Subteam.TeamID != "T1234567890" { + t.Errorf("subteam.team_id should be T1234567890, was %s", e.Subteam.TeamID) + } + if !e.Subteam.IsUsergroup { + t.Errorf("subteam.is_usergroup should be true, was %v", e.Subteam.IsUsergroup) + } + if e.Subteam.Name != "subteam" { + t.Errorf("subteam.name should be subteam, was %s", e.Subteam.Name) + } + if e.Subteam.Description != "A test subteam" { + t.Errorf("subteam.description should be 'A test subteam', was %s", e.Subteam.Description) + } + if e.Subteam.Handle != "subteam_handle" { + t.Errorf("subteam.handle should be subteam_handle, was %s", e.Subteam.Handle) + } + if e.Subteam.IsExternal { + t.Errorf("subteam.is_external should be false, was %v", e.Subteam.IsExternal) + } + if e.Subteam.DateCreate != 1624473600 { + t.Errorf("subteam.date_create should be 1624473600, was %d", e.Subteam.DateCreate) + } + if e.Subteam.DateUpdate != 1624473600 { + t.Errorf("subteam.date_update should be 1624473600, was %d", e.Subteam.DateUpdate) + } + if e.Subteam.DateDelete != 0 { + t.Errorf("subteam.date_delete should be 0, was %d", e.Subteam.DateDelete) + } + if e.Subteam.AutoType != "auto" { + t.Errorf("subteam.auto_type should be auto, was %s", e.Subteam.AutoType) + } + if e.Subteam.CreatedBy != "U1234567890" { + t.Errorf("subteam.created_by should be U1234567890, was %s", e.Subteam.CreatedBy) + } + if e.Subteam.UpdatedBy != "U1234567890" { + t.Errorf("subteam.updated_by should be U1234567890, was %s", e.Subteam.UpdatedBy) + } + if e.Subteam.DeletedBy != "" { + t.Errorf("subteam.deleted_by should be empty, was %s", e.Subteam.DeletedBy) + } + if len(e.Subteam.Prefs.Channels) != 1 || e.Subteam.Prefs.Channels[0] != "C1234567890" { + t.Errorf("subteam.prefs.channels should contain C1234567890, was %v", e.Subteam.Prefs.Channels) + } + if len(e.Subteam.Prefs.Groups) != 1 || e.Subteam.Prefs.Groups[0] != "G1234567890" { + t.Errorf("subteam.prefs.groups should contain G1234567890, was %v", e.Subteam.Prefs.Groups) + } + if len(e.Subteam.Users) != 1 || e.Subteam.Users[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.Subteam.Users) + } + if e.Subteam.UserCount != 1 { + t.Errorf("subteam.user_count should be 1, was %d", e.Subteam.UserCount) + } +} + +func TestSubteamMembersChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_members_changed", + "subteam_id": "S1234567890", + "team_id": "T1234567890", + "date_previous_update": 1446670362, + "date_update": 1624473600, + "added_users": ["U1234567890"], + "added_users_count": "3", + "removed_users": ["U0987654321"], + "removed_users_count": "1" + } + `) + + var e SubteamMembersChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_members_changed" { + t.Errorf("type should be subteam_members_changed, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } + if e.TeamID != "T1234567890" { + t.Errorf("team_id should be T1234567890, was %s", e.TeamID) + } + if e.DateUpdate != 1624473600 { + t.Errorf("date_update should be 1624473600, was %d", e.DateUpdate) + } + if len(e.AddedUsers) != 1 || e.AddedUsers[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.AddedUsers) + } + if len(e.RemovedUsers) != 1 || e.RemovedUsers[0] != "U0987654321" { + t.Errorf("subteam.users should contain U0987654321, was %v", e.RemovedUsers) + } +} + +func TestSubteamSelfAddedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_self_added", + "subteam_id": "S1234567890" + } + `) + + var e SubteamSelfAddedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_self_added" { + t.Errorf("type should be subteam_self_added, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } +} + +func TestSubteamSelfRemovedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_self_removed", + "subteam_id": "S1234567890" + } + `) + + var e SubteamSelfRemovedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_self_removed" { + t.Errorf("type should be subteam_self_removed, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } +} + +func TestSubteamUpdatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_updated", + "subteam": { + "id": "S1234567890", + "team_id": "T1234567890", + "is_usergroup": true, + "name": "updated_subteam", + "description": "An updated test subteam", + "handle": "updated_subteam_handle", + "is_external": false, + "date_create": 1624473600, + "date_update": 1624473600, + "date_delete": 0, + "auto_type": "auto", + "created_by": "U1234567890", + "updated_by": "U1234567890", + "deleted_by": "", + "prefs": { + "channels": ["C1234567890"], + "groups": ["G1234567890"] + }, + "users": ["U1234567890"], + "user_count": 1 + } + } + `) + + var e SubteamUpdatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_updated" { + t.Errorf("type should be subteam_updated, was %s", e.Type) + } + if e.Subteam.ID != "S1234567890" { + t.Errorf("subteam.id should be S1234567890, was %s", e.Subteam.ID) + } + if e.Subteam.TeamID != "T1234567890" { + t.Errorf("subteam.team_id should be T1234567890, was %s", e.Subteam.TeamID) + } + if !e.Subteam.IsUsergroup { + t.Errorf("subteam.is_usergroup should be true, was %v", e.Subteam.IsUsergroup) + } + if e.Subteam.Name != "updated_subteam" { + t.Errorf("subteam.name should be updated_subteam, was %s", e.Subteam.Name) + } + if e.Subteam.Description != "An updated test subteam" { + t.Errorf("subteam.description should be 'An updated test subteam', was %s", e.Subteam.Description) + } + if e.Subteam.Handle != "updated_subteam_handle" { + t.Errorf("subteam.handle should be updated_subteam_handle, was %s", e.Subteam.Handle) + } + if e.Subteam.IsExternal { + t.Errorf("subteam.is_external should be false, was %v", e.Subteam.IsExternal) + } + if e.Subteam.DateCreate != 1624473600 { + t.Errorf("subteam.date_create should be 1624473600, was %d", e.Subteam.DateCreate) + } + if e.Subteam.DateUpdate != 1624473600 { + t.Errorf("subteam.date_update should be 1624473600, was %d", e.Subteam.DateUpdate) + } + if e.Subteam.DateDelete != 0 { + t.Errorf("subteam.date_delete should be 0, was %d", e.Subteam.DateDelete) + } + if e.Subteam.AutoType != "auto" { + t.Errorf("subteam.auto_type should be auto, was %s", e.Subteam.AutoType) + } + if e.Subteam.CreatedBy != "U1234567890" { + t.Errorf("subteam.created_by should be U1234567890, was %s", e.Subteam.CreatedBy) + } + if e.Subteam.UpdatedBy != "U1234567890" { + t.Errorf("subteam.updated_by should be U1234567890, was %s", e.Subteam.UpdatedBy) + } + if e.Subteam.DeletedBy != "" { + t.Errorf("subteam.deleted_by should be empty, was %s", e.Subteam.DeletedBy) + } + if len(e.Subteam.Prefs.Channels) != 1 || e.Subteam.Prefs.Channels[0] != "C1234567890" { + t.Errorf("subteam.prefs.channels should contain C1234567890, was %v", e.Subteam.Prefs.Channels) + } + if len(e.Subteam.Prefs.Groups) != 1 || e.Subteam.Prefs.Groups[0] != "G1234567890" { + t.Errorf("subteam.prefs.groups should contain G1234567890, was %v", e.Subteam.Prefs.Groups) + } + if len(e.Subteam.Users) != 1 || e.Subteam.Users[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.Subteam.Users) + } + if e.Subteam.UserCount != 1 { + t.Errorf("subteam.user_count should be 1, was %d", e.Subteam.UserCount) + } +} + +func TestTeamDomainChangeEvent(t *testing.T) { + rawE := []byte(` + { + "type": "team_domain_change", + "url": "https://newdomain.slack.com", + "domain": "newdomain", + "team_id": "T1234" + } + `) + + var e TeamDomainChangeEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "team_domain_change" { + t.Errorf("type should be team_domain_change, was %s", e.Type) + } + if e.URL != "https://newdomain.slack.com" { + t.Errorf("url should be https://newdomain.slack.com, was %s", e.URL) + } + if e.Domain != "newdomain" { + t.Errorf("domain should be newdomain, was %s", e.Domain) + } + if e.TeamID != "T1234" { + t.Errorf("team_id should be 'T1234', was %s", e.TeamID) + } +} + +func TestTeamRenameEvent(t *testing.T) { + rawE := []byte(` + { + "type": "team_rename", + "name": "new_team_name", + "team_id": "T1234" + } + `) + + var e TeamRenameEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "team_rename" { + t.Errorf("type should be team_rename, was %s", e.Type) + } + if e.Name != "new_team_name" { + t.Errorf("name should be new_team_name, was %s", e.Name) + } + if e.TeamID != "T1234" { + t.Errorf("team_id should be 'T1234', was %s", e.TeamID) + } +} + +func TestUserChangeEvent(t *testing.T) { + jsonStr := `{ + "user": { + "id": "U1234567", + "team_id": "T1234567", + "name": "some-user", + "deleted": false, + "color": "4bbe2e", + "real_name": "Some User", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "Some User", + "real_name_normalized": "Some User", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "riding a train", + "status_emoji": ":mountain_railway:", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "g12345678910", + "first_name": "Some", + "last_name": "User", + "image_24": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=24&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-24.png", + "image_32": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=32&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-32.png", + "image_48": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=48&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-48.png", + "image_72": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=72&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-72.png", + "image_192": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=192&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-192.png", + "image_512": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=512&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-512.png", + "status_text_canonical": "", + "team": "T1234567" + }, + "is_admin": false, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1648596421, + "is_email_confirmed": true, + "who_can_share_contact_card": "EVERYONE", + "locale": "en-US" + }, + "cache_ts": 1648596421, + "type": "user_change", + "event_ts": "1648596712.000001" + }` + + var event UserChangeEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal UserChangeEvent: %v", err) + } + + if event.Type != "user_change" { + t.Errorf("Expected type to be 'user_change', got %s", event.Type) + } + + if event.User.ID != "U1234567" { + t.Errorf("Expected user ID to be 'U1234567', got %s", event.User.ID) + } + + if event.User.Profile.StatusText != "riding a train" { + t.Errorf("Expected status text to be 'riding a train', got %s", event.User.Profile.StatusText) + } + + if event.User.Profile.StatusEmoji != ":mountain_railway:" { + t.Errorf("Expected status emoji to be ':mountain_railway:', got %s", event.User.Profile.StatusEmoji) + } + + if event.CacheTS != 1648596421 { + t.Errorf("Expected cache_ts to be 1648596421, got %d", event.CacheTS) + } + + if event.EventTS != "1648596712.000001" { + t.Errorf("Expected event_ts to be '1648596712.000001', got %s", event.EventTS) + } +} + +func TestAppDeletedEvent(t *testing.T) { + jsonStr := `{ + "type": "app_deleted", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppDeletedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppDeletedEvent: %v", err) + } + + if event.Type != "app_deleted" { + t.Errorf("Expected type to be 'app_deleted', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestAppInstalledEvent(t *testing.T) { + jsonStr := `{ + "type": "app_installed", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "user_id": "U013B64J7SZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppInstalledEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppInstalledEvent: %v", err) + } + + if event.Type != "app_installed" { + t.Errorf("Expected type to be 'app_installed', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestAppRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "app_requested", + "app_request": { + "id": "1234", + "app": { + "id": "A5678", + "name": "Brent's app", + "description": "They're good apps, Bront.", + "help_url": "brontsapp.com", + "privacy_policy_url": "brontsapp.com", + "app_homepage_url": "brontsapp.com", + "app_directory_url": "https://slack.slack.com/apps/A102ARD7Y", + "is_app_directory_approved": true, + "is_internal": false, + "additional_info": "none" + }, + "previous_resolution": { + "status": "approved", + "scopes": [{ + "name": "app_requested", + "description": "allows this app to listen for app install requests", + "is_sensitive": false, + "token_type": "user" + }] + }, + "user": { + "id": "U1234", + "name": "Bront", + "email": "bront@brent.com" + }, + "team": { + "id": "T1234", + "name": "Brant App Team", + "domain": "brantappteam" + }, + "enterprise": null, + "scopes": [{ + "name": "app_requested", + "description": "allows this app to listen for app install requests", + "is_sensitive": false, + "token_type": "user" + }], + "message": "none" + } + }` + + var event AppRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppRequestedEvent: %v", err) + } + + if event.Type != "app_requested" { + t.Errorf("Expected type to be 'app_requested', got %s", event.Type) + } + + if event.AppRequest.ID != "1234" { + t.Errorf("app_request.id should be '1234', was %s", event.AppRequest.ID) + } + + if event.AppRequest.App.ID != "A5678" { + t.Fail() + } + + if event.AppRequest.User.ID != "U1234" { + t.Errorf("app_request.user.id should be 'U1234', was %s", event.AppRequest.User.ID) + } + + if event.AppRequest.Team.ID != "T1234" { + t.Fail() + } +} + +func TestAppUninstalledTeamEvent(t *testing.T) { + jsonStr := `{ + "type": "app_uninstalled_team", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "user_id": "U013B64J7SZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppUninstalledTeamEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppUninstalledTeamEvent: %v", err) + } + + if event.Type != "app_uninstalled_team" { + t.Errorf("Expected type to be 'app_uninstalled_team', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestCallRejectedEvent(t *testing.T) { + jsonStr := `{ + "token": "12345FVmRUzNDOAu12345h", + "team_id": "T123ABC456", + "api_app_id": "BBBU04BB4", + "event": { + "type": "call_rejected", + "call_id": "R123ABC456", + "user_id": "U123ABC456", + "channel_id": "D123ABC456", + "external_unique_id": "123-456-7890" + }, + "type": "event_callback", + "event_id": "Ev123ABC456", + "event_time": 1563448153, + "authed_users": ["U123ABC456"] + }` + + var event CallRejectedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal CallRejectedEvent: %v", err) + } + + if event.Event.Type != "call_rejected" { + t.Errorf("Expected event type to be 'call_rejected', got %s", event.Event.Type) + } + if event.TeamID != "T123ABC456" { + t.Errorf("Expected team_id to be 'T123ABC456', got %s", event.TeamID) + } + if event.Event.CallID != "R123ABC456" { + t.Fail() + } + +} + +func TestChannelSharedEvent(t *testing.T) { + jsonStr := `{ + "type": "channel_shared", + "connected_team_id": "E163Q94DX", + "channel": "C123ABC456", + "event_ts": "1561064063.001100" + }` + + var event ChannelSharedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal ChannelSharedEvent: %v", err) + } + + if event.Type != "channel_shared" { + t.Errorf("Expected type to be 'channel_shared', got %s", event.Type) + } + + if event.ConnectedTeamID != "E163Q94DX" { + t.Errorf("Expected connected_team_id to be 'E163Q94DX', got %s", event.ConnectedTeamID) + } + + if event.Channel != "C123ABC456" { + t.Fail() + } +} + +func TestFileCreatedEvent(t *testing.T) { + jsonStr := `{ + "type": "file_created", + "file_id": "F2147483862", + "file": { + "id": "F2147483862" + } + }` + + var event FileCreatedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FileCreatedEvent: %v", err) + } + + if event.Type != "file_created" { + t.Errorf("Expected type to be 'file_created', got %s", event.Type) + } + if event.FileID != "F2147483862" { + t.Errorf("Expected file_id to be 'F2147483862', got %s", event.FileID) + } +} + +func TestFilePublicEvent(t *testing.T) { + jsonStr := `{ + "type": "file_public", + "file_id": "F2147483862", + "file": { + "id": "F2147483862" + } + }` + + var event FilePublicEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FilePublicEvent: %v", err) + } + + if event.Type != "file_public" { + t.Errorf("Expected type to be 'file_public', got %s", event.Type) + } + + if event.FileID != "F2147483862" { + t.Errorf("Expected file_id to be 'F2147483862', got %s", event.FileID) + } +} + +func TestFunctionExecutedEvent(t *testing.T) { + jsonStr := `{ + "type": "function_executed", + "function": { + "id": "Fn123456789O", + "callback_id": "sample_function", + "title": "Sample function", + "description": "Runs sample function", + "type": "app", + "input_parameters": [ + { + "type": "slack#/types/user_id", + "name": "user_id", + "description": "Message recipient", + "title": "User", + "is_required": true + } + ], + "output_parameters": [ + { + "type": "slack#/types/user_id", + "name": "user_id", + "description": "User that completed the function", + "title": "Greeting", + "is_required": true + } + ], + "app_id": "AP123456789", + "date_created": 1694727597, + "date_updated": 1698947481, + "date_deleted": 0 + }, + "inputs": { "user_id": "USER12345678" }, + "function_execution_id": "Fx1234567O9L", + "workflow_execution_id": "WxABC123DEF0", + "event_ts": "1698958075.998738", + "bot_access_token": "abcd-1325532282098-1322446258629-6123648410839-527a1cab3979cad288c9e20330d212cf" + }` + + var event FunctionExecutedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FunctionExecutedEvent: %v", err) + } + + if event.Type != "function_executed" { + t.Errorf("Expected type to be 'function_executed', got %s", event.Type) + } + + if event.Function.ID != "Fn123456789O" { + t.Errorf("Expected function.id to be 'Fn123456789O', got %s", event.Function.ID) + } + + if event.FunctionExecutionID != "Fx1234567O9L" { + t.Fail() + } +} + +func TestInviteRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "invite_requested", + "invite_request": { + "id": "12345", + "email": "bront@puppies.com", + "date_created": 123455, + "requester_ids": ["U123ABC456"], + "channel_ids": ["C123ABC456"], + "invite_type": "full_member", + "real_name": "Brent", + "date_expire": 123456, + "request_reason": "They're good dogs, Brant", + "team": { + "id": "T12345", + "name": "Puppy ratings workspace incorporated", + "domain": "puppiesrus" + } + } + }` + + var event InviteRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal InviteRequestedEvent: %v", err) + } + + if event.Type != "invite_requested" { + t.Errorf("Expected type to be 'invite_requested', got %s", event.Type) + } + + if event.InviteRequest.ID != "12345" { + t.Errorf("invite_request.id should be '12345', was %s", event.InviteRequest.ID) + } + + if event.InviteRequest.Email != "bront@puppies.com" { + t.Fail() + } +} + +func TestSharedChannelInviteRequested_UnmarshalJSON(t *testing.T) { + jsonData := ` + { + "actor": { + "id": "U012345ABCD", + "name": "primary-owner", + "is_bot": false, + "team_id": "E0123456ABC", + "timezone": "", + "real_name": "primary-owner", + "display_name": "" + }, + "channel_id": "C0123ABCDEF", + "event_type": "slack#/events/shared_channel_invite_requested", + "channel_name": "our-channel", + "channel_type": "public", + "target_users": [ + { + "email": "user@some-corp.com", + "invite_id": "I0123456ABC" + } + ], + "teams_in_channel": [ + { + "id": "E0123456ABC", + "icon": { + "image_34": "https://slack.com/some-corp/v123/img/abc_0123.png", + "image_default": true + }, + "name": "some_enterprise", + "domain": "someenterprise", + "is_verified": false, + "date_created": 1637947110, + "avatar_base_url": "https://slack.com/some-corp/", + "requires_sponsorship": false + }, + { + "id": "T012345ABCD", + "icon": { + "image_34": "https://slack.com/another-corp/v456/img/def_4567.png", + "image_default": true + }, + "name": "another_enterprise", + "domain": "anotherenterprise", + "is_verified": false, + "date_created": 1645550933, + "avatar_base_url": "https://slack.com/another-corp/", + "requires_sponsorship": false + } + ], + "is_external_limited": true, + "channel_date_created": 1718725442, + "channel_message_latest_counted_timestamp": 1718745614025449 + }` + + var event SharedChannelInviteRequestedEvent + err := json.Unmarshal([]byte(jsonData), &event) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if event.Actor.ID != "U012345ABCD" { + t.Errorf("Expected Actor.ID to be 'U012345ABCD', got '%s'", event.Actor.ID) + } + if event.ChannelID != "C0123ABCDEF" { + t.Errorf("Expected ChannelID to be 'C0123ABCDEF', got '%s'", event.ChannelID) + } + if len(event.TargetUsers) != 1 || event.TargetUsers[0].Email != "user@some-corp.com" { + t.Errorf("Expected one TargetUser with Email 'user@some-corp.com', got '%v'", event.TargetUsers) + } + if len(event.TeamsInChannel) != 2 || event.TeamsInChannel[1].Name != "another_enterprise" { + t.Errorf("Expected second team to have name 'another_enterprise', got '%v'", event.TeamsInChannel) + } +} diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 96ba5681b..9e8c22b7f 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -117,7 +117,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.EnterpriseID, nil, EventsAPIInnerEvent{}, - }, fmt.Errorf("Inner Event does not exist! %s", iE.Type) + }, fmt.Errorf("inner Event does not exist! %s", iE.Type) } t := reflect.TypeOf(v) recvEvent := reflect.New(t).Interface() @@ -192,7 +192,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error } if !cfg.TokenVerified { - return EventsAPIEvent{}, errors.New("Invalid verification token") + return EventsAPIEvent{}, errors.New("invalid verification token") } if e.Type == CallbackEvent { @@ -212,6 +212,32 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error } return innerEvent, nil } + + if e.Type == AppRateLimited { + appRateLimitedEvent := &EventsAPIAppRateLimited{} + err = json.Unmarshal(rawEvent, appRateLimitedEvent) + if err != nil { + return EventsAPIEvent{ + "", + "", + "unmarshalling_error", + "", + "", + &slack.UnmarshallingErrorEvent{ErrorObj: err}, + EventsAPIInnerEvent{}, + }, err + } + return EventsAPIEvent{ + e.Token, + e.TeamID, + e.Type, + e.APIAppID, + e.EnterpriseID, + appRateLimitedEvent, + EventsAPIInnerEvent{}, + }, nil + } + urlVerificationEvent := &EventsAPIURLVerificationEvent{} err = json.Unmarshal(rawEvent, urlVerificationEvent) if err != nil { diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index 821d50f70..2e2b63401 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -73,6 +73,33 @@ func TestParseURLVerificationEvent(t *testing.T) { } } +func TestParseAppRateLimitedEvent(t *testing.T) { + event := ` + { + "token": "fake-token", + "team_id": "T123ABC456", + "minute_rate_limited": 1518467820, + "api_app_id": "A123ABC456", + "type": "app_rate_limited" + } + ` + msg, e := ParseEvent(json.RawMessage(event), OptionVerifyToken(&TokenComparator{"fake-token"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + switch ev := msg.Data.(type) { + case *EventsAPIAppRateLimited: + { + } + default: + { + fmt.Println(ev) + t.Fail() + } + } +} + func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) { eventsAPIRawCallbackEvent := ` { diff --git a/slacktest/README.md b/slacktest/README.md index 3897c062a..9a09dad62 100644 --- a/slacktest/README.md +++ b/slacktest/README.md @@ -1,7 +1,9 @@ # slacktest -This package was copied from https://github.com/lusis/slack-test for historical reasons. -This package's license is the following. +This package was originally copied from https://github.com/lusis/slack-test for historical reasons. +It is currently in use with some modifications. + +The license of this package is as follows. --- diff --git a/slacktest/data.go b/slacktest/data.go index 70f0f5af5..ab659340b 100644 --- a/slacktest/data.go +++ b/slacktest/data.go @@ -3,7 +3,7 @@ package slacktest import ( "fmt" - slack "github.com/slack-go/slack" + "github.com/slack-go/slack" ) const defaultBotName = "TestSlackBot" @@ -35,11 +35,11 @@ var okWebResponse = slack.SlackResponse{ Ok: true, } -var defaultOkJSON = fmt.Sprintf(` +var defaultOkJSON = ` { "ok": true } - `) + ` var defaultChannelsListJSON = fmt.Sprintf(` { @@ -250,3 +250,9 @@ var renameConversationJSON = fmt.Sprintf(templateConversationJSON, "newName", var inviteConversationJSON = fmt.Sprintf(templateConversationJSON, defaultConversationName, nowAsJSONTime(), defaultBotID, defaultConversationName, "", "", 0, "", "", 0, 1, "") + +const inviteSharedResponseJSON = `{ + "ok": true, + "invite_id": "I02UKAJ6RJA", + "is_legacy_shared_channel": false +}` diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 6ac37c289..22c1680f6 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -46,7 +46,7 @@ type GroupConversationResponse struct { } func (sts *Server) conversationsInfoHandler(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) log.Printf(msg) @@ -108,6 +108,11 @@ func inviteConversationHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(inviteConversationJSON)) } +// handle conversations.inviteShared +func inviteSharedConversationHandler(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(inviteSharedResponseJSON)) +} + // handle groups.list func listGroupsHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(defaultGroupsListJSON)) @@ -121,7 +126,7 @@ func reactionAddHandler(w http.ResponseWriter, _ *http.Request) { // handle chat.postMessage func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) log.Printf(msg) @@ -213,7 +218,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { // RTMConnectHandler generates a valid connection func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { - _, err := ioutil.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) log.Printf(msg) @@ -243,7 +248,7 @@ func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { } func rtmStartHandler(w http.ResponseWriter, r *http.Request) { - _, err := ioutil.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) log.Printf(msg) diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index dc244e73c..30f1e50ee 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -119,7 +119,7 @@ func TestBotInfoHandler(t *testing.T) { go s.Start() client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL())) - bot, err := client.GetBotInfo(s.BotID) + bot, err := client.GetBotInfo(slack.GetBotInfoParameters{Bot: s.BotID}) assert.NoError(t, err) assert.Equal(t, s.BotID, bot.ID) assert.Equal(t, s.BotName, bot.Name) diff --git a/slacktest/server.go b/slacktest/server.go index 1b18923a7..6d9849451 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -26,10 +26,10 @@ type Customize interface { Handle(pattern string, handler http.HandlerFunc) } -type binder func(Customize) +type Binder func(Customize) // NewTestServer returns a slacktest.Server ready to be started -func NewTestServer(custom ...binder) *Server { +func NewTestServer(custom ...Binder) *Server { serverChans := newMessageChannels() channels := &serverChannels{} @@ -55,6 +55,7 @@ func NewTestServer(custom ...binder) *Server { s.Handle("/conversations.setPurpose", setConversationPurposeHandler) s.Handle("/conversations.rename", renameConversationHandler) s.Handle("/conversations.invite", inviteConversationHandler) + s.Handle("/conversations.inviteShared", inviteSharedConversationHandler) s.Handle("/users.info", usersInfoHandler) s.Handle("/users.lookupByEmail", usersInfoHandler) s.Handle("/bots.info", botsInfoHandler) diff --git a/slacktest/server_test.go b/slacktest/server_test.go index ac4244471..45f620858 100644 --- a/slacktest/server_test.go +++ b/slacktest/server_test.go @@ -48,7 +48,7 @@ func TestBotDirectMessageBotHandler(t *testing.T) { s := NewTestServer() go s.Start() s.SendDirectMessageToBot("some text") - expectedMsg := fmt.Sprintf("some text") + expectedMsg := "some text" time.Sleep(2 * time.Second) assert.True(t, s.SawOutgoingMessage(expectedMsg)) s.Stop() diff --git a/slash.go b/slash.go index b2c509476..fd46abfc4 100644 --- a/slash.go +++ b/slash.go @@ -1,25 +1,29 @@ package slack import ( + "encoding/json" + "fmt" "net/http" + "strconv" ) // SlashCommand contains information about a request of the slash command type SlashCommand struct { - Token string `json:"token"` - TeamID string `json:"team_id"` - TeamDomain string `json:"team_domain"` - EnterpriseID string `json:"enterprise_id,omitempty"` - EnterpriseName string `json:"enterprise_name,omitempty"` - ChannelID string `json:"channel_id"` - ChannelName string `json:"channel_name"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - Command string `json:"command"` - Text string `json:"text"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - APIAppID string `json:"api_app_id"` + Token string `json:"token"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EnterpriseID string `json:"enterprise_id,omitempty"` + EnterpriseName string `json:"enterprise_name,omitempty"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Command string `json:"command"` + Text string `json:"text"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + APIAppID string `json:"api_app_id"` } // SlashCommandParse will parse the request of the slash command @@ -32,6 +36,7 @@ func SlashCommandParse(r *http.Request) (s SlashCommand, err error) { s.TeamDomain = r.PostForm.Get("team_domain") s.EnterpriseID = r.PostForm.Get("enterprise_id") s.EnterpriseName = r.PostForm.Get("enterprise_name") + s.IsEnterpriseInstall = r.PostForm.Get("is_enterprise_install") == "true" s.ChannelID = r.PostForm.Get("channel_id") s.ChannelName = r.PostForm.Get("channel_name") s.UserID = r.PostForm.Get("user_id") @@ -53,3 +58,34 @@ func (s SlashCommand) ValidateToken(verificationTokens ...string) bool { } return false } + +// UnmarshalJSON handles is_enterprise_install being either a boolean or a +// string when parsing JSON from various payloads +func (s *SlashCommand) UnmarshalJSON(data []byte) error { + type SlashCommandCopy SlashCommand + scopy := &struct { + *SlashCommandCopy + IsEnterpriseInstall interface{} `json:"is_enterprise_install"` + }{ + SlashCommandCopy: (*SlashCommandCopy)(s), + } + + if err := json.Unmarshal(data, scopy); err != nil { + return err + } + + switch rawValue := scopy.IsEnterpriseInstall.(type) { + case string: + b, err := strconv.ParseBool(rawValue) + if err != nil { + return fmt.Errorf("parsing boolean for is_enterprise_install: %w", err) + } + s.IsEnterpriseInstall = b + case bool: + s.IsEnterpriseInstall = rawValue + default: + return fmt.Errorf("wrong data type for is_enterprise_install: %T", scopy.IsEnterpriseInstall) + } + + return nil +} diff --git a/slash_test.go b/slash_test.go index e5c79e93f..e068fc40c 100644 --- a/slash_test.go +++ b/slash_test.go @@ -1,6 +1,7 @@ package slack import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -99,3 +100,70 @@ func TestSlash_ServeHTTP(t *testing.T) { resp.Body.Close() } } + +func TestSlash_UnmarshalJSON(t *testing.T) { + tests := []struct { + body string + wantIsEnterpriseInstall bool + wantToken string + wantUnmarshalError string + }{ + { + body: `{"token":"blahblah","is_enterprise_install":"false"}`, + wantIsEnterpriseInstall: false, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":false}`, + wantIsEnterpriseInstall: false, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":"true"}`, + wantIsEnterpriseInstall: true, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":true}`, + wantIsEnterpriseInstall: true, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":42}`, + wantUnmarshalError: "wrong data type for is_enterprise_install: float64", + }, + { + body: `{"token":"blahblah","is_enterprise_install":"unconvertable to bool"}`, + wantUnmarshalError: "parsing boolean for is_enterprise_install: strconv.ParseBool: parsing \"unconvertable to bool\": invalid syntax", + }, + } + + for i, test := range tests { + var result SlashCommand + + err := json.Unmarshal([]byte(test.body), &result) + if err != nil { + if err.Error() != test.wantUnmarshalError { + t.Errorf("%d: Got error %v, want error %q", i, err, test.wantUnmarshalError) + } + continue + } + + if test.wantUnmarshalError != "" { + t.Errorf("%d: Got no error, want error %q", i, test.wantUnmarshalError) + continue + } + + if result.IsEnterpriseInstall != test.wantIsEnterpriseInstall { + t.Errorf("%d: Got IsEnterpriseInstall %v, want IsEnterpriseInstall %v", i, result.IsEnterpriseInstall, test.wantIsEnterpriseInstall) + } + + if result.Token != test.wantToken { + t.Errorf("%d: Got Token %v, want Token %v", i, result.Token, test.wantToken) + } + } +} diff --git a/socketmode/deadman.go b/socketmode/deadman.go deleted file mode 100644 index 7aeea760e..000000000 --- a/socketmode/deadman.go +++ /dev/null @@ -1,31 +0,0 @@ -package socketmode - -import "time" - -type deadmanTimer struct { - timeout time.Duration - timer *time.Timer -} - -func newDeadmanTimer(timeout time.Duration) *deadmanTimer { - return &deadmanTimer{ - timeout: timeout, - timer: time.NewTimer(timeout), - } -} - -func (smc *deadmanTimer) Elapsed() <-chan time.Time { - return smc.timer.C -} - -func (smc *deadmanTimer) Reset() { - // Note that this is the correct way to Reset a non-expired timer - if !smc.timer.Stop() { - select { - case <-smc.timer.C: - default: - } - } - - smc.timer.Reset(smc.timeout) -} diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b259fd624..b94456f49 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -54,13 +54,14 @@ func (smc *Client) RunContext(ctx context.Context) error { } func (smc *Client) run(ctx context.Context, connectionCount int) error { - messages := make(chan json.RawMessage) - defer close(messages) - - deadmanTimer := newDeadmanTimer(smc.maxPingInterval) + messages := make(chan json.RawMessage, 1) + pingChan := make(chan time.Time, 1) pingHandler := func(_ string) error { - deadmanTimer.Reset() + select { + case pingChan <- time.Now(): + default: + } return nil } @@ -82,20 +83,24 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - smc.Events <- newEvent(EventTypeConnected, &ConnectedEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnected, &ConnectedEvent{ ConnectionCount: connectionCount, Info: info, - }) + })) smc.Debugf("WebSocket connection succeeded on try %d", connectionCount) // We're now connected so we can set up listeners - var ( - wg sync.WaitGroup - firstErr error - firstErrOnce sync.Once - ) + wg := new(sync.WaitGroup) + // sendErr relies on the buffer of 1 here + errc := make(chan error, 1) + sendErr := func(err error) { + select { + case errc <- err: + default: + } + } wg.Add(1) go func() { @@ -104,9 +109,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The response sender sends Socket Mode responses over the WebSocket conn if err := smc.runResponseSender(ctx, conn); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() @@ -117,55 +120,79 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The handler reads Socket Mode requests, and enqueues responses for sending by the response sender if err := smc.runRequestHandler(ctx, messages); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() - // We don't wait on runMessageReceiver because it doesn't block on a select with the context, - // so we'd have to wait for the ReadJSON to time out, which can take a while. go func() { defer cancel() + // We close messages here as it is the producer for the channel. + defer close(messages) // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by // the request handler if err := smc.runMessageReceiver(ctx, conn, messages); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() wg.Add(1) - go func() { + go func(pingInterval time.Duration) { defer wg.Done() - - select { - case <-ctx.Done(): + defer func() { // Detect when the connection is dead and try close connection. - if err = conn.Close(); err != nil { + if err := conn.Close(); err != nil { smc.Debugf("Failed to close connection: %v", err) } - case <-deadmanTimer.Elapsed(): - firstErrOnce.Do(func() { - firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") - }) + }() + + done := ctx.Done() + var lastPing time.Time + + // More efficient than constantly resetting a timer w/ Stop+Reset + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + + for { + select { + case <-done: + return + + case lastPing = <-pingChan: + // This case gets the time of the last ping. + // If this case never fires then the pingHandler was never called + // in which case lastPing is the zero time.Time value, and will 'fail' + // the next tick, causing us to exit. - cancel() + case now := <-ticker.C: + // Our last ping is older than our interval + if now.Sub(lastPing) > pingInterval { + sendErr(errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval")) + + cancel() + return + } + } } - }() + }(smc.maxPingInterval) wg.Wait() - if firstErr == context.Canceled { - return firstErr + select { + case err = <-errc: + // Get buffered error + default: + // Or nothing if they all exited nil + } + + if errors.Is(err, context.Canceled) { + return err } // wg.Wait() finishes only after any of the above go routines finishes and cancels the // context, allowing the other threads to shut down gracefully. - // Also, we can expect firstErr to be not nil, as goroutines can finish only on error. - smc.Debugf("Reconnecting due to %v", firstErr) + // Also, we can expect our (first)err to be not nil, as goroutines can finish only on error. + smc.Debugf("Reconnecting due to %v", err) return nil } @@ -193,10 +220,10 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP ) // send connecting event - smc.Events <- newEvent(EventTypeConnecting, &slack.ConnectingEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnecting, &slack.ConnectingEvent{ Attempt: boff.Attempts() + 1, ConnectionCount: connectionCount, - }) + })) // attempt to start the connection info, conn, err := smc.openAndDial(ctx, additionalPingHandler) @@ -212,26 +239,32 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP default: } - switch actual := err.(type) { - case slack.StatusCodeError: - if actual.Code == http.StatusNotFound { - smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) - smc.Events <- newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{}) - return nil, nil, err - } - case *slack.RateLimitedError: - backoff = actual.RetryAfter - default: + var ( + actual slack.StatusCodeError + rlError *slack.RateLimitedError + ) + + if errors.As(err, &actual) && actual.Code == http.StatusNotFound { + smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) + smc.sendEvent(ctx, newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{})) + + return nil, nil, err + } else if errors.As(err, &rlError) { + backoff = rlError.RetryAfter } + // If we check for errors.Is(err, context.Canceled) here and + // return early then we don't send the Event below that some users + // may already rely on; ie a behavior change. + backoff = timex.Max(backoff, boff.Duration()) // any other errors are treated as recoverable and we try again after // sending the event along the Events channel - smc.Events <- newEvent(EventTypeConnectionError, &slack.ConnectionErrorEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnectionError, &slack.ConnectionErrorEvent{ Attempt: boff.Attempts(), Backoff: backoff, ErrorObj: err, - }) + })) // get time we should wait before attempting to connect again smc.Debugf("reconnection %d failed: %s reconnecting in %v\n", boff.Attempts(), err, backoff) @@ -239,9 +272,11 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP // wait for one of the following to occur, // backoff duration has elapsed, disconnectCh is signalled, or // the smc finishes disconnecting. + timer := time.NewTimer(backoff) select { - case <-time.After(backoff): // retry after the backoff. + case <-timer.C: // retry after the backoff. case <-ctx.Done(): + timer.Stop() return nil, nil, ctx.Err() } } @@ -276,12 +311,13 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s smc.Debugf("Failed to dial to the websocket: %s", err) return nil, nil, err } + if additionalPingHandler == nil { + additionalPingHandler = func(_ string) error { return nil } + } conn.SetPingHandler(func(appData string) error { - if additionalPingHandler != nil { - if err := additionalPingHandler(appData); err != nil { - return err - } + if err := additionalPingHandler(appData); err != nil { + return err } smc.handlePing(conn, appData) @@ -312,10 +348,10 @@ func (smc *Client) runResponseSender(ctx context.Context, conn *websocket.Conn) smc.Debugf("Sending Socket Mode response with envelope ID %q: %v", res.EnvelopeID, res) if err := unsafeWriteSocketModeResponse(conn, res); err != nil { - smc.Events <- newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{ + smc.sendEvent(ctx, newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{ Cause: err, Response: res, - }) + })) } smc.Debugf("Finished sending Socket Mode response with envelope ID %q", res.EnvelopeID) @@ -332,16 +368,22 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra select { case <-ctx.Done(): return ctx.Err() - case message := <-websocket: + case message, ok := <-websocket: + if !ok { + // The producer closed the channel because it encountered an error (or panic), + // we need only return. + return nil + } + smc.Debugf("Received WebSocket message: %s", message) // listen for incoming messages that need to be parsed evt, err := smc.parseEvent(message) if err != nil { - smc.Events <- newEvent(EventTypeErrorBadMessage, &ErrorBadMessage{ + smc.sendEvent(ctx, newEvent(EventTypeErrorBadMessage, &ErrorBadMessage{ Cause: err, Message: message, - }) + })) } else if evt != nil { if evt.Type == EventTypeDisconnect { // We treat the `disconnect` request from Slack as an error internally, @@ -349,7 +391,7 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra return errorRequestedDisconnect{} } - smc.Events <- *evt + smc.sendEvent(ctx, *evt) } } } @@ -385,11 +427,7 @@ func unsafeWriteSocketModeResponse(conn *websocket.Conn, res *Response) error { // Remove write deadline regardless of WriteJSON succeeds or not defer conn.SetWriteDeadline(time.Time{}) - if err := conn.WriteJSON(res); err != nil { - return err - } - - return nil + return conn.WriteJSON(res) } func newEvent(tpe EventType, data interface{}, req ...*Request) Event { @@ -407,29 +445,54 @@ func newEvent(tpe EventType, data interface{}, req ...*Request) Event { // This tells Slack that the we have received the request denoted by the envelope ID, // by sending back the envelope ID over the WebSocket connection. func (smc *Client) Ack(req Request, payload ...interface{}) { - res := Response{ - EnvelopeID: req.EnvelopeID, - } - + var pld interface{} if len(payload) > 0 { - res.Payload = payload[0] + pld = payload[0] } - smc.Send(res) + smc.AckCtx(context.TODO(), req.EnvelopeID, pld) +} + +// AckCtx acknowledges the Socket Mode request envelope ID with the payload. +// +// This tells Slack that the we have received the request denoted by the request (envelope) ID, +// by sending back the ID over the WebSocket connection. +func (smc *Client) AckCtx(ctx context.Context, reqID string, payload interface{}) error { + return smc.SendCtx(ctx, Response{ + EnvelopeID: reqID, + Payload: payload, + }) } // Send sends the Socket Mode response over a WebSocket connection. // This is usually used for acknowledging requests, but if you need more control over Client.Ack(). // It's normally recommended to use Client.Ack() instead of this. func (smc *Client) Send(res Response) { - js, err := json.Marshal(res) - if err != nil { - panic(err) + smc.SendCtx(context.TODO(), res) +} + +// SendCtx sends the Socket Mode response over a WebSocket connection. +// This is usually used for acknowledging requests, but if you need more control +// it's normally recommended to use Client.AckCtx() instead of this. +func (smc *Client) SendCtx(ctx context.Context, res Response) error { + if smc.debug { + js, err := json.Marshal(res) + + // Log the error so users of `Send` don't see it entirely disappear as that method + // does not return an error and used to panic on failure (with or without debug) + smc.Debugf("Scheduling Socket Mode response (error: %v) for envelope ID %s: %s", err, res.EnvelopeID, js) + if err != nil { + return err + } } - smc.Debugf("Scheduling Socket Mode response for envelope ID %s: %s", res.EnvelopeID, js) + select { + case <-ctx.Done(): + return ctx.Err() + case smc.socketModeResponses <- &res: + } - smc.socketModeResponses <- &res + return nil } // receiveMessagesInto attempts to receive an event from the WebSocket connection for Socket Mode. @@ -441,53 +504,56 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn event := json.RawMessage{} err := conn.ReadJSON(&event) + if err != nil { + // check if the connection was closed. + // This version of the gorilla/websocket package also does a type assertion + // on the error, rather than unwrapping it, so we'll do the unwrapping then pass + // the unwrapped error + var wsErr *websocket.CloseError + if errors.As(err, &wsErr) && websocket.IsUnexpectedCloseError(wsErr) { + return err + } - // check if the connection was closed. - if websocket.IsUnexpectedCloseError(err) { - return err - } + if errors.Is(err, io.ErrUnexpectedEOF) { + // EOF's don't seem to signify a failed connection so instead we ignore + // them here and detect a failed connection upon attempting to send a + // 'PING' message - switch { - case err == io.ErrUnexpectedEOF: - // EOF's don't seem to signify a failed connection so instead we ignore - // them here and detect a failed connection upon attempting to send a - // 'PING' message + // Unlike RTM, we don't ping from the our end as there seem to have no client ping. + // We just continue to the next loop so that we `smc.disconnected` should be received if + // this EOF error was actually due to disconnection. - // Unlike RTM, we don't ping from the our end as there seem to have no client ping. - // We just continue to the next loop so that we `smc.disconnected` should be received if - // this EOF error was actually due to disconnection. + return nil + } - return nil - case err != nil: // All other errors from ReadJSON come from NextReader, and should // kill the read loop and force a reconnect. - smc.Events <- newEvent(EventTypeIncomingError, &slack.IncomingEventError{ + // TODO: Unless it's a JSON unmarshal-type error in which case maybe reconnecting isn't needed... + smc.sendEvent(ctx, newEvent(EventTypeIncomingError, &slack.IncomingEventError{ ErrorObj: err, - }) + })) return err - case len(event) == 0: - smc.Debugln("Received empty event") - default: - if smc.debug { - buf := &bytes.Buffer{} - d := json.NewEncoder(buf) - d.SetIndent("", " ") - if err := d.Encode(event); err != nil { - smc.Debugln("Failed encoding decoded json:", err) - } - reencoded := buf.String() + } - smc.Debugln("Incoming WebSocket message:", reencoded) + if smc.debug { + buf := &bytes.Buffer{} + d := json.NewEncoder(buf) + d.SetIndent("", " ") + if err := d.Encode(event); err != nil { + smc.Debugln("Failed encoding decoded json:", err) } + reencoded := buf.String() - select { - case sink <- event: - case <-ctx.Done(): - smc.Debugln("cancelled while attempting to send raw event") + smc.Debugln("Incoming WebSocket message:", reencoded) + } - return ctx.Err() - } + select { + case sink <- event: + case <-ctx.Done(): + smc.Debugln("cancelled while attempting to send raw event") + + return ctx.Err() } return nil @@ -500,7 +566,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { req := &Request{} err := json.Unmarshal(wsMsg, req) if err != nil { - return nil, fmt.Errorf("unmarshalling WebSocket message: %v", err) + return nil, fmt.Errorf("unmarshalling WebSocket message: %w", err) } var evt Event @@ -516,7 +582,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { eventsAPIEvent, err := slackevents.ParseEvent(payloadEvent, slackevents.OptionNoVerifyToken()) if err != nil { - return nil, fmt.Errorf("parsing Events API event: %v", err) + return nil, fmt.Errorf("parsing Events API event: %w", err) } evt = newEvent(EventTypeEventsAPI, eventsAPIEvent, req) @@ -529,7 +595,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { var cmd slack.SlashCommand if err := json.Unmarshal(req.Payload, &cmd); err != nil { - return nil, fmt.Errorf("parsing slash command: %v", err) + return nil, fmt.Errorf("parsing slash command: %w", err) } evt = newEvent(EventTypeSlashCommand, cmd, req) @@ -543,7 +609,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { var callback slack.InteractionCallback if err := json.Unmarshal(req.Payload, &callback); err != nil { - return nil, fmt.Errorf("parsing interaction callback: %v", err) + return nil, fmt.Errorf("parsing interaction callback: %w", err) } evt = newEvent(EventTypeInteractive, callback, req) diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go index 1871e6763..6ca8f487c 100644 --- a/socketmode/socketmode.go +++ b/socketmode/socketmode.go @@ -119,3 +119,15 @@ func New(api *slack.Client, options ...Option) *Client { return result } + +// sendEvent safely sends an event into the Clients Events channel +// and blocks until buffer space is had, or the context is canceled. +// This prevents deadlocking in the event that Events buffer is full, +// other goroutines are waiting, and/or timing allows receivers to exit +// before all senders are finished. +func (smc *Client) sendEvent(ctx context.Context, event Event) { + select { + case smc.Events <- event: + case <-ctx.Done(): + } +} diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 636feefa5..5b56f2954 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -1,6 +1,8 @@ package socketmode import ( + "context" + "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" ) @@ -105,15 +107,31 @@ func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { // RunSlackEventLoop receives the event via the socket func (r *SocketmodeHandler) RunEventLoop() error { - go r.runEventLoop() + go r.runEventLoop(context.Background()) return r.Client.Run() } +func (r *SocketmodeHandler) RunEventLoopContext(ctx context.Context) error { + go r.runEventLoop(ctx) + + return r.Client.RunContext(ctx) +} + // Call the dispatcher for each incomming event -func (r *SocketmodeHandler) runEventLoop() { - for evt := range r.Client.Events { - r.dispatcher(evt) +func (r *SocketmodeHandler) runEventLoop(ctx context.Context) { + for { + select { + case evt, ok := <-r.Client.Events: + if !ok { + return + } + + r.dispatcher(evt) + + case <-ctx.Done(): + return + } } } diff --git a/stars.go b/stars.go index 6e0ebbe32..51926854e 100644 --- a/stars.go +++ b/stars.go @@ -36,12 +36,14 @@ func NewStarsParameters() StarsParameters { } } -// AddStar stars an item in a channel +// AddStar stars an item in a channel. +// For more information see the AddStarContext documentation. func (api *Client) AddStar(channel string, item ItemRef) error { return api.AddStarContext(context.Background(), channel, item) } -// AddStarContext stars an item in a channel with a custom context +// AddStarContext stars an item in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.add func (api *Client) AddStarContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -65,12 +67,14 @@ func (api *Client) AddStarContext(ctx context.Context, channel string, item Item return response.Err() } -// RemoveStar removes a starred item from a channel +// RemoveStar removes a starred item from a channel. +// For more information see the RemoveStarContext documentation. func (api *Client) RemoveStar(channel string, item ItemRef) error { return api.RemoveStarContext(context.Background(), channel, item) } -// RemoveStarContext removes a starred item from a channel with a custom context +// RemoveStarContext removes a starred item from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.remove func (api *Client) RemoveStarContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -94,12 +98,14 @@ func (api *Client) RemoveStarContext(ctx context.Context, channel string, item I return response.Err() } -// ListStars returns information about the stars a user added +// ListStars returns information about the stars a user added. +// For more information see the ListStarsContext documentation. func (api *Client) ListStars(params StarsParameters) ([]Item, *Paging, error) { return api.ListStarsContext(context.Background(), params) } -// ListStarsContext returns information about the stars a user added with a custom context +// ListStarsContext returns information about the stars a user added with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.list func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) ([]Item, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -147,7 +153,6 @@ func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, e } // GetStarredContext returns a list of StarredItem items with a custom context -// // For more details see GetStarred func (api *Client) GetStarredContext(ctx context.Context, params StarsParameters) ([]StarredItem, *Paging, error) { items, paging, err := api.ListStarsContext(ctx, params) diff --git a/team.go b/team.go index d21a1b642..4e890c2be 100644 --- a/team.go +++ b/team.go @@ -74,8 +74,9 @@ type BillingActive struct { // AccessLogParameters contains all the parameters necessary (including the optional ones) for a GetAccessLogs() request type AccessLogParameters struct { - Count int - Page int + TeamID string + Count int + Page int } // NewAccessLogParameters provides an instance of AccessLogParameters with all the sane default values set @@ -124,12 +125,14 @@ func (api *Client) teamProfileRequest(ctx context.Context, client httpClient, pa return response, response.Err() } -// GetTeamInfo gets the Team Information of the user +// GetTeamInfo gets the Team Information of the user. +// For more information see the GetTeamInfoContext documentation. func (api *Client) GetTeamInfo() (*TeamInfo, error) { return api.GetTeamInfoContext(context.Background()) } -// GetOtherTeamInfoContext gets Team information for any team with a custom context +// GetOtherTeamInfoContext gets Team information for any team with a custom context. +// Slack API docs: https://api.slack.com/methods/team.info func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*TeamInfo, error) { if team == "" { return api.GetTeamInfoContext(ctx) @@ -145,12 +148,14 @@ func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*T return &response.Team, nil } -// GetOtherTeamInfo gets Team information for any team +// GetOtherTeamInfo gets Team information for any team. +// For more information see the GetOtherTeamInfoContext documentation. func (api *Client) GetOtherTeamInfo(team string) (*TeamInfo, error) { return api.GetOtherTeamInfoContext(context.Background(), team) } -// GetTeamInfoContext gets the Team Information of the user with a custom context +// GetTeamInfoContext gets the Team Information of the user with a custom context. +// Slack API docs: https://api.slack.com/methods/team.info func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { values := url.Values{ "token": {api.token}, @@ -163,35 +168,44 @@ func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { return &response.Team, nil } -// GetTeamProfile gets the Team Profile settings of the user -func (api *Client) GetTeamProfile() (*TeamProfile, error) { - return api.GetTeamProfileContext(context.Background()) +// GetTeamProfile gets the Team Profile settings of the user. +// For more information see the GetTeamProfileContext documentation. +func (api *Client) GetTeamProfile(teamID ...string) (*TeamProfile, error) { + return api.GetTeamProfileContext(context.Background(), teamID...) } -// GetTeamProfileContext gets the Team Profile settings of the user with a custom context -func (api *Client) GetTeamProfileContext(ctx context.Context) (*TeamProfile, error) { +// GetTeamProfileContext gets the Team Profile settings of the user with a custom context. +// Slack API docs: https://api.slack.com/methods/team.profile.get +func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) (*TeamProfile, error) { values := url.Values{ "token": {api.token}, } + if len(teamID) > 0 { + values["team_id"] = teamID + } response, err := api.teamProfileRequest(ctx, api.httpclient, "team.profile.get", values) if err != nil { return nil, err } return &response.Profile, nil - } -// GetAccessLogs retrieves a page of logins according to the parameters given +// GetAccessLogs retrieves a page of logins according to the parameters given. +// For more information see the GetAccessLogsContext documentation. func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, *Paging, error) { return api.GetAccessLogsContext(context.Background(), params) } -// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context +// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context. +// Slack API docs: https://api.slack.com/methods/team.accessLogs func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogParameters) ([]Login, *Paging, error) { values := url.Values{ "token": {api.token}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Count != DEFAULT_LOGINS_COUNT { values.Add("count", strconv.Itoa(params.Count)) } @@ -206,30 +220,30 @@ func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogPar return response.Logins, &response.Paging, nil } -// GetBillableInfo ... -func (api *Client) GetBillableInfo(user string) (map[string]BillingActive, error) { - return api.GetBillableInfoContext(context.Background(), user) +type GetBillableInfoParams struct { + User string + TeamID string } -// GetBillableInfoContext ... -func (api *Client) GetBillableInfoContext(ctx context.Context, user string) (map[string]BillingActive, error) { +// GetBillableInfo gets the billable users information of the team. +// For more information see the GetBillableInfoContext documentation. +func (api *Client) GetBillableInfo(params GetBillableInfoParams) (map[string]BillingActive, error) { + return api.GetBillableInfoContext(context.Background(), params) +} + +// GetBillableInfoContext gets the billable users information of the team with a custom context. +// Slack API docs: https://api.slack.com/methods/team.billableInfo +func (api *Client) GetBillableInfoContext(ctx context.Context, params GetBillableInfoParams) (map[string]BillingActive, error) { values := url.Values{ "token": {api.token}, - "user": {user}, } - return api.billableInfoRequest(ctx, "team.billableInfo", values) -} - -// GetBillableInfoForTeam returns the billing_active status of all users on the team. -func (api *Client) GetBillableInfoForTeam() (map[string]BillingActive, error) { - return api.GetBillableInfoForTeamContext(context.Background()) -} + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } -// GetBillableInfoForTeamContext returns the billing_active status of all users on the team with a custom context -func (api *Client) GetBillableInfoForTeamContext(ctx context.Context) (map[string]BillingActive, error) { - values := url.Values{ - "token": {api.token}, + if params.User != "" { + values.Add("user", params.User) } return api.billableInfoRequest(ctx, "team.billableInfo", values) diff --git a/tokens.go b/tokens.go new file mode 100644 index 000000000..49bbde9b1 --- /dev/null +++ b/tokens.go @@ -0,0 +1,52 @@ +package slack + +import ( + "context" + "net/url" +) + +// RotateTokens exchanges a refresh token for a new app configuration token. +// For more information see the RotateTokensContext documentation. +func (api *Client) RotateTokens(configToken string, refreshToken string) (*TokenResponse, error) { + return api.RotateTokensContext(context.Background(), configToken, refreshToken) +} + +// RotateTokensContext exchanges a refresh token for a new app configuration token with a custom context. +// Slack API docs: https://api.slack.com/methods/tooling.tokens.rotate +func (api *Client) RotateTokensContext(ctx context.Context, configToken string, refreshToken string) (*TokenResponse, error) { + if configToken == "" { + configToken = api.configToken + } + + if refreshToken == "" { + refreshToken = api.configRefreshToken + } + + values := url.Values{ + "refresh_token": {refreshToken}, + } + + response := &TokenResponse{} + err := api.getMethod(ctx, "tooling.tokens.rotate", configToken, values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// UpdateConfigTokens replaces the configuration tokens in the client with those returned by the API +func (api *Client) UpdateConfigTokens(response *TokenResponse) { + api.configToken = response.Token + api.configRefreshToken = response.RefreshToken +} + +type TokenResponse struct { + Token string `json:"token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + TeamId string `json:"team_id,omitempty"` + UserId string `json:"user_id,omitempty"` + IssuedAt uint64 `json:"iat,omitempty"` + ExpiresAt uint64 `json:"exp,omitempty"` + SlackResponse +} diff --git a/tokens_test.go b/tokens_test.go new file mode 100644 index 000000000..621174598 --- /dev/null +++ b/tokens_test.go @@ -0,0 +1,45 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestRotateTokens(t *testing.T) { + http.HandleFunc("/tooling.tokens.rotate", handleRotateToken) + expected := getTestTokenResponse() + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + tok, err := api.RotateTokens("expired-config", "old-refresh") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expected, *tok) { + t.Fatal(ErrIncorrectResponse) + } +} + +func getTestTokenResponse() TokenResponse { + return TokenResponse{ + Token: "token", + RefreshToken: "refresh", + UserId: "uid", + TeamId: "tid", + IssuedAt: 1, + ExpiresAt: 1, + SlackResponse: SlackResponse{Ok: true}, + } +} + +func handleRotateToken(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(getTestTokenResponse()) + rw.Write(response) +} diff --git a/usergroups.go b/usergroups.go index 050aa6f28..41e381459 100644 --- a/usergroups.go +++ b/usergroups.go @@ -50,18 +50,24 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url return response, response.Err() } -// CreateUserGroup creates a new user group +// CreateUserGroup creates a new user group. +// For more information see the CreateUserGroupContext documentation. func (api *Client) CreateUserGroup(userGroup UserGroup) (UserGroup, error) { return api.CreateUserGroupContext(context.Background(), userGroup) } -// CreateUserGroupContext creates a new user group with a custom context +// CreateUserGroupContext creates a new user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.create func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) { values := url.Values{ "token": {api.token}, "name": {userGroup.Name}, } + if userGroup.TeamID != "" { + values["team_id"] = []string{userGroup.TeamID} + } + if userGroup.Handle != "" { values["handle"] = []string{userGroup.Handle} } @@ -81,12 +87,14 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro return response.UserGroup, nil } -// DisableUserGroup disables an existing user group +// DisableUserGroup disables an existing user group. +// For more information see the DisableUserGroupContext documentation. func (api *Client) DisableUserGroup(userGroup string) (UserGroup, error) { return api.DisableUserGroupContext(context.Background(), userGroup) } -// DisableUserGroupContext disables an existing user group with a custom context +// DisableUserGroupContext disables an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.disable func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -100,12 +108,14 @@ func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string return response.UserGroup, nil } -// EnableUserGroup enables an existing user group +// EnableUserGroup enables an existing user group. +// For more information see the EnableUserGroupContext documentation. func (api *Client) EnableUserGroup(userGroup string) (UserGroup, error) { return api.EnableUserGroupContext(context.Background(), userGroup) } -// EnableUserGroupContext enables an existing user group with a custom context +// EnableUserGroupContext enables an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.enable func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -122,6 +132,12 @@ func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) // GetUserGroupsOption options for the GetUserGroups method call. type GetUserGroupsOption func(*GetUserGroupsParams) +func GetUserGroupsOptionWithTeamID(teamID string) GetUserGroupsOption { + return func(params *GetUserGroupsParams) { + params.TeamID = teamID + } +} + // GetUserGroupsOptionIncludeCount include the number of users in each User Group (default: false) func GetUserGroupsOptionIncludeCount(b bool) GetUserGroupsOption { return func(params *GetUserGroupsParams) { @@ -152,18 +168,20 @@ func GetUserGroupsOptionTeamID(teamID string) GetUserGroupsOption { // GetUserGroupsParams contains arguments for GetUserGroups method call type GetUserGroupsParams struct { + TeamID string IncludeCount bool IncludeDisabled bool IncludeUsers bool - TeamID string } -// GetUserGroups returns a list of user groups for the team +// GetUserGroups returns a list of user groups for the team. +// For more information see the GetUserGroupsContext documentation. func (api *Client) GetUserGroups(options ...GetUserGroupsOption) ([]UserGroup, error) { return api.GetUserGroupsContext(context.Background(), options...) } -// GetUserGroupsContext returns a list of user groups for the team with a custom context +// GetUserGroupsContext returns a list of user groups for the team with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.list func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserGroupsOption) ([]UserGroup, error) { params := GetUserGroupsParams{} @@ -174,6 +192,9 @@ func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserG values := url.Values{ "token": {api.token}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.IncludeCount { values.Add("include_count", "true") } @@ -233,12 +254,14 @@ type UpdateUserGroupsParams struct { Channels *[]string } -// UpdateUserGroup will update an existing user group +// UpdateUserGroup will update an existing user group. +// For more information see the UpdateUserGroupContext documentation. func (api *Client) UpdateUserGroup(userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { return api.UpdateUserGroupContext(context.Background(), userGroupID, options...) } -// UpdateUserGroupContext will update an existing user group with a custom context +// UpdateUserGroupContext will update an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.update func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { params := UpdateUserGroupsParams{} @@ -274,12 +297,14 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin return response.UserGroup, nil } -// GetUserGroupMembers will retrieve the current list of users in a group +// GetUserGroupMembers will retrieve the current list of users in a group. +// For more information see the GetUserGroupMembersContext documentation. func (api *Client) GetUserGroupMembers(userGroup string) ([]string, error) { return api.GetUserGroupMembersContext(context.Background(), userGroup) } -// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context +// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.users.list func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string) ([]string, error) { values := url.Values{ "token": {api.token}, @@ -293,12 +318,14 @@ func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup str return response.Users, nil } -// UpdateUserGroupMembers will update the members of an existing user group +// UpdateUserGroupMembers will update the members of an existing user group. +// For more information see the UpdateUserGroupMembersContext documentation. func (api *Client) UpdateUserGroupMembers(userGroup string, members string) (UserGroup, error) { return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members) } -// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context +// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.update func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string) (UserGroup, error) { values := url.Values{ "token": {api.token}, diff --git a/users.go b/users.go index 55f42118f..b51b1721f 100644 --- a/users.go +++ b/users.go @@ -17,31 +17,32 @@ const ( // UserProfile contains all the information details of a given user type UserProfile struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` RealName string `json:"real_name"` RealNameNormalized string `json:"real_name_normalized"` DisplayName string `json:"display_name"` DisplayNameNormalized string `json:"display_name_normalized"` - Email string `json:"email"` - Skype string `json:"skype"` - Phone string `json:"phone"` + AvatarHash string `json:"avatar_hash"` + Email string `json:"email,omitempty"` + Skype string `json:"skyp,omitempty"` + Phone string `json:"phone,omitempty"` Image24 string `json:"image_24"` Image32 string `json:"image_32"` Image48 string `json:"image_48"` Image72 string `json:"image_72"` Image192 string `json:"image_192"` Image512 string `json:"image_512"` - ImageOriginal string `json:"image_original"` - Title string `json:"title"` + ImageOriginal string `json:"image_original,omitempty"` + Title string `json:"title,omitempty"` BotID string `json:"bot_id,omitempty"` ApiAppID string `json:"api_app_id,omitempty"` StatusText string `json:"status_text,omitempty"` StatusEmoji string `json:"status_emoji,omitempty"` StatusEmojiDisplayInfo []UserProfileStatusEmojiDisplayInfo `json:"status_emoji_display_info,omitempty"` - StatusExpiration int `json:"status_expiration"` + StatusExpiration int `json:"status_expiration,omitempty"` Team string `json:"team"` - Fields UserProfileCustomFields `json:"fields"` + Fields UserProfileCustomFields `json:"fields,omitempty"` } type UserProfileStatusEmojiDisplayInfo struct { @@ -130,6 +131,7 @@ type User struct { IsAppUser bool `json:"is_app_user"` IsInvitedUser bool `json:"is_invited_user"` Has2FA bool `json:"has_2fa"` + TwoFactorType *string `json:"two_factor_type"` HasFiles bool `json:"has_files"` Presence string `json:"presence"` Locale string `json:"locale"` @@ -225,11 +227,13 @@ func (api *Client) userRequest(ctx context.Context, path string, values url.Valu } // GetUserPresence will retrieve the current presence status of given user. +// For more information see the GetUserPresenceContext documentation. func (api *Client) GetUserPresence(user string) (*UserPresence, error) { return api.GetUserPresenceContext(context.Background(), user) } // GetUserPresenceContext will retrieve the current presence status of given user with a custom context. +// Slack API docs: https://api.slack.com/methods/users.getPresence func (api *Client) GetUserPresenceContext(ctx context.Context, user string) (*UserPresence, error) { values := url.Values{ "token": {api.token}, @@ -243,12 +247,14 @@ func (api *Client) GetUserPresenceContext(ctx context.Context, user string) (*Us return &response.UserPresence, nil } -// GetUserInfo will retrieve the complete user information +// GetUserInfo will retrieve the complete user information. +// For more information see the GetUserInfoContext documentation. func (api *Client) GetUserInfo(user string) (*User, error) { return api.GetUserInfoContext(context.Background(), user) } -// GetUserInfoContext will retrieve the complete user information with a custom context +// GetUserInfoContext will retrieve the complete user information with a custom context. +// Slack API docs: https://api.slack.com/methods/users.info func (api *Client) GetUserInfoContext(ctx context.Context, user string) (*User, error) { values := url.Values{ "token": {api.token}, @@ -263,12 +269,14 @@ func (api *Client) GetUserInfoContext(ctx context.Context, user string) (*User, return &response.User, nil } -// GetUsersInfo will retrieve the complete multi-users information +// GetUsersInfo will retrieve the complete multi-users information. +// For more information see the GetUsersInfoContext documentation. func (api *Client) GetUsersInfo(users ...string) (*[]User, error) { return api.GetUsersInfoContext(context.Background(), users...) } -// GetUsersInfoContext will retrieve the complete multi-users information with a custom context +// GetUsersInfoContext will retrieve the complete multi-users information with a custom context. +// Slack API docs: https://api.slack.com/methods/users.info func (api *Client) GetUsersInfoContext(ctx context.Context, users ...string) (*[]User, error) { values := url.Values{ "token": {api.token}, @@ -405,12 +413,14 @@ func (api *Client) GetUsersContext(ctx context.Context, options ...GetUsersOptio return results, p.Failure(err) } -// GetUserByEmail will retrieve the complete user information by email +// GetUserByEmail will retrieve the complete user information by email. +// For more information see the GetUserByEmailContext documentation. func (api *Client) GetUserByEmail(email string) (*User, error) { return api.GetUserByEmailContext(context.Background(), email) } -// GetUserByEmailContext will retrieve the complete user information by email with a custom context +// GetUserByEmailContext will retrieve the complete user information by email with a custom context. +// Slack API docs: https://api.slack.com/methods/users.lookupByEmail func (api *Client) GetUserByEmailContext(ctx context.Context, email string) (*User, error) { values := url.Values{ "token": {api.token}, @@ -423,12 +433,14 @@ func (api *Client) GetUserByEmailContext(ctx context.Context, email string) (*Us return &response.User, nil } -// SetUserAsActive marks the currently authenticated user as active +// SetUserAsActive marks the currently authenticated user as active. +// For more information see the SetUserAsActiveContext documentation. func (api *Client) SetUserAsActive() error { return api.SetUserAsActiveContext(context.Background()) } -// SetUserAsActiveContext marks the currently authenticated user as active with a custom context +// SetUserAsActiveContext marks the currently authenticated user as active with a custom context. +// Slack API docs: https://api.slack.com/methods/users.setActive func (api *Client) SetUserAsActiveContext(ctx context.Context) (err error) { values := url.Values{ "token": {api.token}, @@ -438,12 +450,14 @@ func (api *Client) SetUserAsActiveContext(ctx context.Context) (err error) { return err } -// SetUserPresence changes the currently authenticated user presence +// SetUserPresence changes the currently authenticated user presence. +// For more information see the SetUserPresenceContext documentation. func (api *Client) SetUserPresence(presence string) error { return api.SetUserPresenceContext(context.Background(), presence) } -// SetUserPresenceContext changes the currently authenticated user presence with a custom context +// SetUserPresenceContext changes the currently authenticated user presence with a custom context. +// Slack API docs: https://api.slack.com/methods/users.setPresence func (api *Client) SetUserPresenceContext(ctx context.Context, presence string) error { values := url.Values{ "token": {api.token}, @@ -454,12 +468,14 @@ func (api *Client) SetUserPresenceContext(ctx context.Context, presence string) return err } -// GetUserIdentity will retrieve user info available per identity scopes +// GetUserIdentity will retrieve user info available per identity scopes. +// For more information see the GetUserIdentityContext documentation. func (api *Client) GetUserIdentity() (*UserIdentityResponse, error) { return api.GetUserIdentityContext(context.Background()) } -// GetUserIdentityContext will retrieve user info available per identity scopes with a custom context +// GetUserIdentityContext will retrieve user info available per identity scopes with a custom context. +// Slack API docs: https://api.slack.com/methods/users.identity func (api *Client) GetUserIdentityContext(ctx context.Context) (response *UserIdentityResponse, err error) { values := url.Values{ "token": {api.token}, @@ -478,12 +494,14 @@ func (api *Client) GetUserIdentityContext(ctx context.Context) (response *UserId return response, nil } -// SetUserPhoto changes the currently authenticated user's profile image +// SetUserPhoto changes the currently authenticated user's profile image. +// For more information see the SetUserPhotoContext documentation. func (api *Client) SetUserPhoto(image string, params UserSetPhotoParams) error { return api.SetUserPhotoContext(context.Background(), image, params) } -// SetUserPhotoContext changes the currently authenticated user's profile image using a custom context +// SetUserPhotoContext changes the currently authenticated user's profile image using a custom context. +// Slack API docs: https://api.slack.com/methods/users.setPhoto func (api *Client) SetUserPhotoContext(ctx context.Context, image string, params UserSetPhotoParams) (err error) { response := &SlackResponse{} values := url.Values{} @@ -505,12 +523,14 @@ func (api *Client) SetUserPhotoContext(ctx context.Context, image string, params return response.Err() } -// DeleteUserPhoto deletes the current authenticated user's profile image +// DeleteUserPhoto deletes the current authenticated user's profile image. +// For more information see the DeleteUserPhotoContext documentation. func (api *Client) DeleteUserPhoto() error { return api.DeleteUserPhotoContext(context.Background()) } -// DeleteUserPhotoContext deletes the current authenticated user's profile image with a custom context +// DeleteUserPhotoContext deletes the current authenticated user's profile image with a custom context. +// Slack API docs: https://api.slack.com/methods/users.deletePhoto func (api *Client) DeleteUserPhotoContext(ctx context.Context) (err error) { response := &SlackResponse{} values := url.Values{ @@ -526,13 +546,13 @@ func (api *Client) DeleteUserPhotoContext(ctx context.Context) (err error) { } // SetUserRealName changes the currently authenticated user's realName -// -// For more information see SetUserRealNameContextWithUser +// For more information see the SetUserRealNameContextWithUser documentation. func (api *Client) SetUserRealName(realName string) error { return api.SetUserRealNameContextWithUser(context.Background(), "", realName) } -// SetUserRealNameContextWithUser will set a real name for the provided user with a custom context +// SetUserRealNameContextWithUser will set a real name for the provided user with a custom context. +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, realName string) error { profile, err := json.Marshal( &struct { @@ -564,20 +584,22 @@ func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, rea return response.Err() } -// SetUserCustomFields sets Custom Profile fields on the provided users account. Due to the non-repeating elements -// within the request, a map fields is required. The key in the map signifies the field that will be updated. -// -// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console from -// SCIM or User Entered to API. -// -// See GetTeamProfile for information to retrieve possible fields for your account. +// SetUserCustomFields sets Custom Profile fields on the provided users account. +// For more information see the SetUserCustomFieldsContext documentation. func (api *Client) SetUserCustomFields(userID string, customFields map[string]UserProfileCustomField) error { return api.SetUserCustomFieldsContext(context.Background(), userID, customFields) } -// SetUserCustomFieldsContext will set a users custom profile field with context. +// SetUserCustomFieldsContext sets Custom Profile fields on the provided users account. +// Due to the non-repeating elements within the request, a map fields is required. +// The key in the map signifies the field that will be updated. +// +// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console +// from SCIM or User Entered to API. // -// For more information see SetUserCustomFields +// See GetTeamProfile for information to retrieve possible fields for your account. +// +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string, customFields map[string]UserProfileCustomField) error { // Convert data to data type with custom marshall / unmarshall @@ -613,32 +635,30 @@ func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string } -// SetUserCustomStatus will set a custom status and emoji for the currently -// authenticated user. If statusEmoji is "" and statusText is not, the Slack API -// will automatically set it to ":speech_balloon:". Otherwise, if both are "" -// the Slack API will unset the custom status/emoji. If statusExpiration is set to 0 -// the status will not expire. +// SetUserCustomStatus will set a custom status and emoji for the currently authenticated user. +// For more information see the SetUserCustomStatusContext documentation. func (api *Client) SetUserCustomStatus(statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(context.Background(), "", statusText, statusEmoji, statusExpiration) } -// SetUserCustomStatusContext will set a custom status and emoji for the currently authenticated user with a custom context -// -// For more information see SetUserCustomStatus +// SetUserCustomStatusContext will set a custom status and emoji for the currently authenticated user with a custom context. +// For more information see the SetUserCustomStatusContextWithUser documentation. func (api *Client) SetUserCustomStatusContext(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(ctx, "", statusText, statusEmoji, statusExpiration) } // SetUserCustomStatusWithUser will set a custom status and emoji for the provided user. -// -// For more information see SetUserCustomStatus +// For more information see the SetUserCustomStatusContextWithUser documentation. func (api *Client) SetUserCustomStatusWithUser(user, statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(context.Background(), user, statusText, statusEmoji, statusExpiration) } -// SetUserCustomStatusContextWithUser will set a custom status and emoji for the provided user with a custom context +// SetUserCustomStatusContextWithUser will set a custom status and emoji for the currently authenticated user. +// If statusEmoji is "" and statusText is not, the Slack API will automatically set it to ":speech_balloon:". +// Otherwise, if both are "" the Slack API will unset the custom status/emoji. If statusExpiration is set to 0 +// the status will not expire. // -// For more information see SetUserCustomStatus +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserCustomStatusContextWithUser(ctx context.Context, user, statusText, statusEmoji string, statusExpiration int64) error { // XXX(theckman): this anonymous struct is for making requests to the Slack // API for setting and unsetting a User's Custom Status/Emoji. To change @@ -703,6 +723,7 @@ type GetUserProfileParameters struct { } // GetUserProfile retrieves a user's profile information. +// For more information see the GetUserProfileContext documentation. func (api *Client) GetUserProfile(params *GetUserProfileParameters) (*UserProfile, error) { return api.GetUserProfileContext(context.Background(), params) } @@ -713,6 +734,7 @@ type getUserProfileResponse struct { } // GetUserProfileContext retrieves a user's profile information with a context. +// Slack API docs: https://api.slack.com/methods/users.profile.get func (api *Client) GetUserProfileContext(ctx context.Context, params *GetUserProfileParameters) (*UserProfile, error) { values := url.Values{"token": {api.token}} diff --git a/users_test.go b/users_test.go index 5ba915995..eedfd2d60 100644 --- a/users_test.go +++ b/users_test.go @@ -8,7 +8,6 @@ import ( "image/draw" "image/png" "io" - "io/ioutil" "net/http" "os" "reflect" @@ -556,7 +555,7 @@ func setUserPhotoHandler(wantBytes []byte, wantParams UserSetPhotoParams) http.H httpTestErrReply(w, true, fmt.Sprintf("failed to open uploaded file: %+v", err)) return } - gotBytes, err := ioutil.ReadAll(file) + gotBytes, err := io.ReadAll(file) if err != nil { httpTestErrReply(w, true, fmt.Sprintf("failed to read uploaded file: %+v", err)) return @@ -577,7 +576,7 @@ func createUserPhoto(t *testing.T) (*os.File, []byte, func()) { photo := image.NewRGBA(image.Rect(0, 0, 64, 64)) draw.Draw(photo, photo.Bounds(), image.Black, image.ZP, draw.Src) - f, err := ioutil.TempFile(os.TempDir(), "profile.png") + f, err := os.CreateTemp(os.TempDir(), "profile.png") if err != nil { t.Fatalf("failed to create test photo: %+v\n", err) } diff --git a/views.go b/views.go index e6a961788..0822d2cb2 100644 --- a/views.go +++ b/views.go @@ -155,6 +155,7 @@ type ViewResponse struct { } // OpenView opens a view for a user. +// For more information see the OpenViewContext documentation. func (api *Client) OpenView(triggerID string, view ModalViewRequest) (*ViewResponse, error) { return api.OpenViewContext(context.Background(), triggerID, view) } @@ -177,6 +178,7 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool { } // OpenViewContext opens a view for a user with a custom context. +// Slack API docs: https://api.slack.com/methods/views.open func (api *Client) OpenViewContext( ctx context.Context, triggerID string, @@ -208,11 +210,13 @@ func (api *Client) OpenViewContext( } // PublishView publishes a static view for a user. +// For more information see the PublishViewContext documentation. func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash string) (*ViewResponse, error) { return api.PublishViewContext(context.Background(), userID, view, hash) } // PublishViewContext publishes a static view for a user with a custom context. +// Slack API docs: https://api.slack.com/methods/views.publish func (api *Client) PublishViewContext( ctx context.Context, userID string, @@ -241,11 +245,13 @@ func (api *Client) PublishViewContext( } // PushView pushes a view onto the stack of a root view. +// For more information see the PushViewContext documentation. func (api *Client) PushView(triggerID string, view ModalViewRequest) (*ViewResponse, error) { return api.PushViewContext(context.Background(), triggerID, view) } -// PublishViewContext pushes a view onto the stack of a root view with a custom context. +// PushViewContext pushes a view onto the stack of a root view with a custom context. +// Slack API docs: https://api.slack.com/methods/views.push func (api *Client) PushViewContext( ctx context.Context, triggerID string, @@ -272,11 +278,13 @@ func (api *Client) PushViewContext( } // UpdateView updates an existing view. +// For more information see the UpdateViewContext documentation. func (api *Client) UpdateView(view ModalViewRequest, externalID, hash, viewID string) (*ViewResponse, error) { return api.UpdateViewContext(context.Background(), view, externalID, hash, viewID) } // UpdateViewContext updates an existing view with a custom context. +// Slack API docs: https://api.slack.com/methods/views.update func (api *Client) UpdateViewContext( ctx context.Context, view ModalViewRequest, diff --git a/webhooks.go b/webhooks.go index e3233536a..5a854f38b 100644 --- a/webhooks.go +++ b/webhooks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" ) @@ -24,6 +23,8 @@ type WebhookMessage struct { ReplaceOriginal bool `json:"replace_original"` DeleteOriginal bool `json:"delete_original"` ReplyBroadcast bool `json:"reply_broadcast,omitempty"` + UnfurlLinks bool `json:"unfurl_links,omitempty"` + UnfurlMedia bool `json:"unfurl_media,omitempty"` } func PostWebhook(url string, msg *WebhookMessage) error { @@ -55,7 +56,7 @@ func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *h return fmt.Errorf("failed to post webhook: %w", err) } defer func() { - io.Copy(ioutil.Discard, resp.Body) + io.Copy(io.Discard, resp.Body) resp.Body.Close() }() diff --git a/workflow_step.go b/workflow_step.go index bcc892c5a..747a5dc00 100644 --- a/workflow_step.go +++ b/workflow_step.go @@ -44,12 +44,15 @@ func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externa } } +// SaveWorkflowStepConfiguration opens a configuration modal for a workflow step. +// For more information see the SaveWorkflowStepConfigurationContext documentation. func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs) } +// SaveWorkflowStepConfigurationContext saves the configuration of a workflow step with a custom context. +// Slack API docs: https://api.slack.com/methods/workflows.updateStep func (api *Client) SaveWorkflowStepConfigurationContext(ctx context.Context, workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - // More information: https://api.slack.com/methods/workflows.updateStep wscr := WorkflowStepCompleteResponse{ WorkflowStepEditID: workflowStepEditID, Inputs: inputs,