diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..d7ce766be --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: nlopes +buy_me_a_coffee: nlopes diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2ff0cdc3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..e0812bd1a --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,25 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '0 0 * * 1' # every Monday 0:00 UTC + + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + any-of-labels: 'feedback given' + days-before-stale: 45 + days-before-pr-close: 10 + stale-issue-message: 'This issue is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' + stale-pr-message: 'This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days.' + close-issue-message: 'This issue was closed because it has been stalled for 10 days with no activity.' + close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.' + operations-per-run: 120 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e9c737ad..fa21dcb18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,33 +6,54 @@ on: - master pull_request: +permissions: + contents: read + jobs: - ci: - runs-on: ubuntu-latest - name: lint - steps: - - uses: actions/checkout@v2 - - name: golangci-lint - uses: golangci/golangci-lint-action@v2 - with: - version: v1.32 test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: go: - - '1.13' - - '1.14' - - '1.15' - - '1.16' - - '1.17' + - "1.26" + - "1.27" name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 with: go-version: ${{ matrix.go }} - name: run test run: go test -v -race ./... - env: - GO111MODULE: on + + lint: + runs-on: ubuntu-24.04 + strategy: + matrix: + go: + - "1.26" + - "1.27" + name: lint go-${{ matrix.go }} + steps: + - uses: actions/setup-go@v7 + with: + go-version: ${{ matrix.go }} + cache: false + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.13.1 + - name: staticcheck + uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # 1.4.1 + with: + version: "2026.2" + - name: Check 'go mod tidy' makes no changes + run: | + go mod tidy + if ! git diff --exit-code go.mod go.sum; then + echo "❌ go.mod or go.sum files are not tidy. Please run 'go mod tidy' and commit the changes." + exit 1 + fi diff --git a/.gitignore b/.gitignore index ac6f3eeb3..9b3903cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.test *~ .idea/ +/vendor/ +.env* diff --git a/.golangci.yml b/.golangci.yml index c16f5389a..b26581e2e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,14 +1,40 @@ +version: "2" run: timeout: 6m issues-exit-code: 1 linters: - disable-all: true + default: none enable: - - goimports + - gocritic - govet - - interfacer - misspell - - structcheck + - modernize - unconvert -issues: - new: true + - unused + settings: + modernize: + # omitempty on nested struct fields has no effect, but switching to + # omitzero would change JSON output for a public API. Keep the tags as-is. + disable: + - omitzero + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - goimports + exclusions: + generated: lax + warn-unused: true + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 32da687bb..58b42c26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,103 +1,655 @@ -### v0.7.0 - October 2, 2020 -full differences can be viewed using `git log --oneline --decorate --color v0.6.6..v0.7.0` -Thank you for many contributions! - -#### Breaking Changes -- Add ScheduledMessage type ([#753]) -- Add description field to option block object ([#783]) -- Fix wrong conditional branch ([#782]) - - The behavior of the user's application may change.(The current behavior is incorrect) - -#### Highlights -- example: fix to start up a server ([#773]) -- example: Add explanation how the message could be sent in a proper way ([#787]) -- example: fix typo in error log ([#779]) -- refactor: Make GetConversationsParameters.ExcludeArchived optional ([#791]) -- refactor: Unify variables to "config" ([#800]) -- refactor: Rename wrong file name ([#810]) -- feature: Add SetUserRealName for change user's realName([#755]) -- feature: Add response metadata to slack response ([#772]) -- feature: Add response metadata to slack response ([#778]) -- feature: Add select block element conversations filter field ([#790]) -- feature: Add Root field to MessageEvent to support thread_broadcast subtype ([#793]) -- feature: Add bot_profile to messages ([#794]) -- doc: Add logo to README ([#813]) -- doc: Update current project status and Add changelog for v0.7.0 ([#814]) - -[#753]: https://github.com/slack-go/slack/pull/753 -[#755]: https://github.com/slack-go/slack/pull/755 -[#772]: https://github.com/slack-go/slack/pull/772 -[#773]: https://github.com/slack-go/slack/pull/773 -[#778]: https://github.com/slack-go/slack/pull/778 -[#779]: https://github.com/slack-go/slack/pull/779 -[#782]: https://github.com/slack-go/slack/pull/782 -[#783]: https://github.com/slack-go/slack/pull/783 -[#787]: https://github.com/slack-go/slack/pull/787 -[#790]: https://github.com/slack-go/slack/pull/790 -[#791]: https://github.com/slack-go/slack/pull/791 -[#793]: https://github.com/slack-go/slack/pull/793 -[#794]: https://github.com/slack-go/slack/pull/794 -[#800]: https://github.com/slack-go/slack/pull/800 -[#810]: https://github.com/slack-go/slack/pull/810 -[#813]: https://github.com/slack-go/slack/pull/813 -[#814]: https://github.com/slack-go/slack/pull/814 - -### v0.6.0 - August 31, 2019 -full differences can be viewed using `git log --oneline --decorate --color v0.5.0..v0.6.0` -thanks to everyone who has contributed since January! - - -#### Breaking Changes: -- Info struct has had fields removed related to deprecated functionality by slack. -- minor adjustments to some structs. -- some internal default values have changed, usually to be more inline with slack defaults or to correct inability to set a particular value. (Message Parse for example.) - -##### Highlights: -- new slacktest package easy mocking for slack client. use, enjoy, please submit PRs for improvements and default behaviours! shamelessly taken from the [slack-test repo](https://github.com/lusis/slack-test) thank you lusis for letting us use it and bring it into the slack repo. -- blocks, blocks, blocks. -- RTM ManagedConnection has undergone a significant cleanup. -in particular handles backoffs gracefully, removed many deadlocks, -and Disconnect is now much more responsive. - -### v0.5.0 - January 20, 2019 -full differences can be viewed using `git log --oneline --decorate --color v0.4.0..v0.5.0` -- Breaking changes: various old struct fields have been removed or updated to match slack's api. -- deadlock fix in RTM disconnect. - -### v0.4.0 - October 06, 2018 -full differences can be viewed using `git log --oneline --decorate --color v0.3.0..v0.4.0` -- Breaking Change: renamed ApplyMessageOption, to mark it as unsafe, -this means it may break without warning in the future. -- Breaking: Msg structure files field changed to an array. -- General: implementation for new security headers. -- RTM: deadlock fix between connect/disconnect. -- Events: various new fields added. -- Web: various fixes, new fields exposed, new methods added. -- Interactions: minor additions expect breaking changes in next release for dialogs/button clicks. -- Utils: new methods added. - -### v0.3.0 - July 30, 2018 -full differences can be viewed using `git log --oneline --decorate --color v0.2.0..v0.3.0` -- slack events initial support added. (still considered experimental and undergoing changes, stability not promised) -- vendored depedencies using dep, ensure using up to date tooling before filing issues. -- RTM has improved its ability to identify dead connections and reconnect automatically (worth calling out in case it has unintended side effects). -- bug fixes (various timestamp handling, error handling, RTM locking, etc). - -### v0.2.0 - Feb 10, 2018 - -Release adds a bunch of functionality and improvements, mainly to give people a recent version to vendor against. - -Please check [0.2.0](https://github.com/nlopes/slack/releases/tag/v0.2.0) - -### v0.1.0 - May 28, 2017 - -This is released before adding context support. -As the used context package is the one from Go 1.7 this will be the last -compatible with Go < 1.7. - -Please check [0.1.0](https://github.com/nlopes/slack/releases/tag/v0.1.0) - -### v0.0.1 - Jul 26, 2015 - -If you just updated from master and it broke your implementation, please -check [0.0.1](https://github.com/nlopes/slack/releases/tag/v0.0.1) +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- The minimum supported Go version is now 1.26. The library supports the two most recent Go + releases, so the test matrix covers Go 1.26 and Go 1.27. + +## [0.29.0] - 2026-08-15 + +### Fixed + +- `slackevents`: `AppMentionEvent` and `MessageEvent` now expose the Data Access API action + token through a new `ActionToken` field, which reads `action_token` from the event object + itself. Slack sends the token there on `app_mention` and `message` events, but both types + only modelled it nested inside `assistant_thread`, so the token was silently dropped and + bot-token calls to `assistant.search.context` failed with `invalid_action_token`. The + existing `AssistantThread` field is unchanged (#1577, #1580). + +## [0.28.0] - 2026-08-15 + +### Added + +- Block Kit: Add support for [`container`](https://docs.slack.dev/reference/block-kit/blocks/container-block/) block through `ContainerBlock`, with a `NewContainerBlock` constructor, fluent `With*` builders (`WithTitle`, `WithRichTextTitle`, `WithSubtitle`, `WithIcon`, `WithWidth`, `WithCollapsible`, `WithHeaderDivider`, `WithBlockID`), an `AddChildBlock` helper and a `Validate` method (#1574). +- `PostMessageWithResponse` and `PostMessageWithResponseContext` return the full `Message` object from the `chat.postMessage` response alongside the channel and timestamp, giving access to response-only fields such as `Message.ThreadTimestamp` (#1572). +- `CompleteUploadExternalParameters` and `UploadFileParameters` now take `Channels`, which shares a single uploaded file with up to 100 conversations in one `files.completeUploadExternal` request (#1579). + +## [0.27.0] - 2026-06-27 + +### Added + +- Block Kit: Add support for + [`data_visualization`](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/) block through `DataVisualizationBlock`. +- Interaction payloads: `Team` now preserves `enterprise_id` and `enterprise_name` when + Slack includes Enterprise org details in interaction payload `team` objects. + +## [0.26.0] - 2026-06-14 + +### Added + +- `slackevents`: `EventsAPIEvent` now unmarshals the `is_ext_shared_channel` field, and + `ParseEvent` propagates it for callback events (#1564 and #1565). +- Block Kit: `HeaderBlock` now supports the optional `level` field (1-4, mapping to H1-H4 + heading levels), with a `HeaderBlockOptionLevel` builder (#1563). +- Block Kit: `CardBlock` now supports the `slack_icon` field via the new `SlackIconObject` + composition object (with a `NewSlackIconObject` constructor and `WithSlackIcon` builder) + and the `subtext` field (with a `WithSubtext` builder). `slack_icon` is mutually + exclusive with `icon` (#1562). + +## [0.25.0] - 2026-06-05 + +### Changed + +> [!WARNING] +> **Breaking change.** + +- `TableBlock.Rows` is now `[][]TableCell` (was `[][]*RichTextBlock`), so `table` + blocks no longer drop `raw_text`, `raw_number`, and `null` cells (#1558). + +## [0.24.0] - 2026-05-24 + +### Added + +- Block Kit: `DataTableBlock` for the [`data_table`](https://docs.slack.dev/reference/block-kit/blocks/data-table-block/) + block, with `NewDataTableBlock`, `AddRow`, raw-text/raw-number/rich-text cell + constructors, and `WithPageSize` / `WithRowHeaderColumnIndex` builders. + +### Changed + +- `NewTaskCardBlock` and `NewPlanBlock` nil-guard their variadic options, + matching the other block constructors (#1236). + +## [0.23.1] - 2026-05-10 + +### Fixed + +- `NewSecretsVerifier` now rejects empty signing secrets to avoid accepting forged request + signatures when applications are misconfigured. + +## [0.23.0] - 2026-04-22 + +### Added + +- **Block Kit: `CardBlock` and `CarouselBlock`** — Support for two of the new + agent-UI blocks announced in the + [April 16 Slack changelog](https://docs.slack.dev/changelog/2026/04/16/block-kit-new-blocks). + `CardBlock` is constructed via `NewCardBlock` with a functional-options + pattern and fluent `With*` builders (`WithTitle`, `WithSubtitle`, `WithBody`, + `WithIcon`, `WithHeroImage`, `WithActions`). `CarouselBlock` is constructed + via `NewCarouselBlock` with a variadic `*CardBlock` list plus `WithBlockID` + and `AddCard` helpers. Both blocks wire into `Blocks.UnmarshalJSON` for + round-trip fidelity, and reuse existing `ImageBlockElement` / + `ButtonBlockElement` / `BlockElements` types rather than introducing new + composition objects. +- **Block Kit: `AlertBlock`** — Support for the third of the new agent-UI + blocks from the + [April 16 Slack changelog](https://docs.slack.dev/changelog/2026/04/16/block-kit-new-blocks). + `AlertBlock` is constructed via `NewAlertBlock` with a `*TextBlockObject` + body and a functional-options pattern. Severity is set via + `AlertBlockOptionLevel` (`AlertLevelDefault`, `AlertLevelInfo`, + `AlertLevelWarning`, `AlertLevelError`, `AlertLevelSuccess`) and the block + ID via `AlertBlockOptionBlockID`. Wires into `Blocks.UnmarshalJSON` for + round-trip fidelity. Must be delivered via the streaming chunks API — + `chat.postMessage` rejects it as an unsupported block type. +- **Streaming-message chunks API** — `chat.startStream` / `chat.appendStream` / + `chat.stopStream` now accept a `chunks` parameter. Added `MsgOptionChunks` + along with a `StreamChunk` interface and four chunk types: + `MarkdownTextChunk`, `TaskUpdateChunk`, `PlanUpdateChunk`, and `BlocksChunk` + (each with a `New*Chunk` constructor). This is the supported transport for + streaming Block Kit content and the new agent-UI blocks in particular + (which `chat.postMessage` rejects as `Unsupported block type`). +- **`MsgOptionTaskDisplayMode`** — New option for `chat.startStream` controlling + whether task chunks render as a sequential timeline or a grouped plan. + Accepts `TaskDisplayModeTimeline` or `TaskDisplayModePlan`. +- Added `Username`, `IconURL`, and `IconEmoji` fields to + `AssistantThreadsSetStatusParameters`, forwarded by + `SetAssistantThreadsStatusContext`, matching the new optional parameters on + [`assistant.threads.setStatus`](https://docs.slack.dev/reference/methods/assistant.threads.setStatus) + for customising the status-update presentation. +- Exposed `SocketmodeHandler.DispatchEvent` (previously the unexported + `dispatcher`), enabling integration tests to exercise registered handlers + without a live WebSocket connection. The unexported `dispatcher` is kept as + a thin wrapper for backwards compatibility. Closes #1549. + +## [0.22.0] - 2026-04-12 + +### Added + +- Added missing parameters to `assistant.search.context` (`Sort`, `SortDir`, `Before`, + `After`, `Highlight`, `IncludeContextMessages`, `IncludeDeletedUsers`, + `IncludeMessageBlocks`, `IncludeArchivedChannels`, `DisableSemanticSearch`, `Modifiers`, + `TermClauses`) and new response types (`AssistantSearchContextFile`, + `AssistantSearchContextChannel`, `AssistantSearchContextMessageContext`) to match the + full Real-Time Search API surface. +- Added `Underline`, `Highlight`, `ClientHighlight`, and `Unlink` fields to + `RichTextSectionTextStyle`. Added `Style` field to `RichTextSectionUserGroupElement`. +- Added `BotOptional` and `UserOptional` fields to `OAuthScopes` for app manifests. +- Added PKCE support for OAuth: `OAuthOptionCodeVerifier` option for + `GetOAuthV2Response`, `GenerateCodeVerifier()` and `GenerateCodeChallenge()` + helper functions (RFC 7636). `client_secret` is now conditionally omitted when + empty in both `GetOAuthV2ResponseContext` and `RefreshOAuthV2TokenContext`. + +### Fixed + +- `ChannelTypes` and `ContentTypes` now send comma-separated values instead of repeated + form keys, matching the convention used by every other method in the library. +- In `socketmode` malformed JSON messages no longer force an unnecessary reconnect. + Instead the error is emitted and the connection continues as normal. + +## [0.21.1] - 2026-04-08 + +### Added + +- **`slackevents.ChannelType*` constants and `MessageEvent` helpers** — Added + `ChannelTypeChannel`, `ChannelTypeGroup`, `ChannelTypeIM`, `ChannelTypeMPIM` constants + and `IsChannel()`, `IsGroup()`, `IsIM()`, `IsMpIM()` methods on `MessageEvent` so + callers no longer need to compare raw strings. + +### Fixed + +- **Duplicate attachment/block serialization in `MsgOptionAttachments` / `MsgOptionBlocks`** — + Attachments and blocks were serialized twice: once into typed struct fields (for the JSON + response-URL path) and again into `url.Values` (for the form POST path). Serialization for + the form path now happens inside `formSender.BuildRequestContext`, so each sender owns its + own marshalling. This fixes the long-standing FIXME and eliminates redundant `json.Marshal` + calls in the option functions. ([#1547]) + + > [!NOTE] + > `UnsafeApplyMsgOptions` returns `config.values` directly. After this change, + > `attachments` and `blocks` keys are no longer present in those values since + > marshalling is deferred to send time. This function is documented as unsupported. + +## [0.21.0] - 2026-04-05 + +### Deprecated + +- **`slackevents.ParseActionEvent`** — Cannot parse `block_actions` payloads (returns + unmarshalling error). Use `slack.InteractionCallback` with `json.Unmarshal` instead, + or `slack.InteractionCallbackParse` for HTTP requests. `InteractionCallback` handles + all interaction types. ([#596]) +- **`slackevents.MessageAction`**, **`MessageActionEntity`**, **`MessageActionResponse`** — + Associated types that only support legacy `interactive_message` payloads. + +### Removed + +- **`IM` struct** — Removed the `IM` struct (and unused internal types `imChannel`, + `imResponseFull`). The `IsUserDeleted` field has been moved to `Conversation`, where it + is populated for IM-type conversations. Code using `IM` should switch to `Conversation`. + + > [!NOTE] + > In practice no user should be affected — `IM` was never returned by any public API + > method in this library, so there was no way to obtain one outside of manual construction. + +- **`Info.GetBotByID`, `GetUserByID`, `GetChannelByID`, `GetGroupByID`, `GetIMByID`** — + These methods were deprecated and returned `nil` unconditionally. They have been removed. + + > [!WARNING] + > **Breaking change.** If you are calling any of these methods, remove those calls — they + > were already no-ops. + +### Added + +- **`admin.teams.settings.*` API support** — `AdminTeamsSettingsInfo`, + `AdminTeamsSettingsSetDefaultChannels`, `AdminTeamsSettingsSetDescription`, + `AdminTeamsSettingsSetDiscoverability`, `AdminTeamsSettingsSetIcon`, and + `AdminTeamsSettingsSetName`. Includes `TeamDiscoverability` enum with `Open`, + `InviteOnly`, `Closed`, and `Unlisted` variants. ([#960]) +- **`OAuthOptionAPIURL` for package-level OAuth functions** — All package-level OAuth + functions (`GetOAuthV2Response`, `GetOpenIDConnectToken`, `RefreshOAuthV2Token`, etc.) + now accept variadic `OAuthOption` arguments. Use `OAuthOptionAPIURL(url)` to override + the default Slack API URL, enabling integration tests against a local HTTP server. + Existing callers are unaffected. ([#744]) +- **`GetOpenIDConnectUserInfo` / `GetOpenIDConnectUserInfoContext`** — Returns identity + information about the user associated with the token via `openid.connect.userInfo`. + Complements the existing `GetOpenIDConnectToken` method. ([#967]) +- **HTTP response headers** — Slack API response headers (e.g. `X-OAuth-Scopes`, + `X-Accepted-OAuth-Scopes`, `X-Ratelimit-*`) are now accessible. `AuthTestResponse` + exposes a `Header` field directly. For all other methods, use + `OptionOnResponseHeaders(func(method string, headers http.Header))` to register a + callback that fires after every API call. ([#1076]) +- **`DNDOptionTeamID`** — `GetDNDInfo` and `GetDNDTeamInfo` now accept optional + `ParamOption` arguments. Use `DNDOptionTeamID("T...")` to pass `team_id`, which is + required after workspace migration (Slack returns `missing_argument` without it). + ([#1157]) +- **`UpdateUserGroupMembersList` / `UpdateUserGroupMembersListContext`** — Convenience + wrappers around `UpdateUserGroupMembers` that accept `[]string` instead of a + comma-separated string, enabling clean chaining with `GetUserGroupMembers`. ([#1172]) +- **`SetUserProfile` / `SetUserProfileContext`** — Set multiple user profile fields in a + single API call by passing a `*UserProfile` struct to `users.profile.set`. Complements + the existing single-field methods (`SetUserRealName`, `SetUserCustomStatus`, etc.). + ([#1158]) +- **API warning callbacks** — Slack API responses may include a `warnings` field with + deprecation notices or usage hints. Use `OptionWarnings(func(warnings []string))` to + register a callback that receives these warnings. ([#1540]) +- **RTM support for `user_status_changed`, `user_huddle_changed`, `user_profile_changed` + events** — these events are now mapped in `EventMapping` with dedicated structs + (`UserStatusChangedEvent`, `UserHuddleChangedEvent`, `UserProfileChangedEvent`). + Previously they triggered `UnmarshallingErrorEvent`. ([#1541]) +- **RTM support for `sh_room_join`, `sh_room_leave`, `sh_room_update`, `channel_updated` + events** — Slack Call/Huddle room events and channel property updates are now mapped with + dedicated structs (`SHRoomJoinEvent`, `SHRoomLeaveEvent`, `SHRoomUpdateEvent`, + `ChannelUpdatedEvent`). ([#858]) +- **`CacheTS` and `EventTS` fields on `UserChangeEvent`** — these fields were sent by Slack + but silently dropped during unmarshalling. +- **`workflows.featured` API support** — add, list, remove, and set featured workflows on + channels via `WorkflowsFeaturedAdd`, `WorkflowsFeaturedList`, `WorkflowsFeaturedRemove`, + and `WorkflowsFeaturedSet` +- **`IsConnectorBot` and `IsWorkflowBot` in `User`** — boolean flags for connector and + workflow bot users +- **`GuestInvitedBy` in `UserProfile`** — user ID of whoever invited a guest user +- **`Blocks` field on `MessageEvent`** — block data from webhook payloads is now directly + accessible via `event.Blocks` instead of only through `event.Message.Blocks`. ([#1257]) +- **`Username` field on `User`** — Slack's interaction payloads (block_actions, shortcuts) + include a `username` field in the user object that was previously dropped during + unmarshalling. ([#1218]) +- **`Blocks`, `Attachments`, `Files`, `Upload` fields on `AppMentionEvent`** — these fields + are sent by Slack in `app_mention` event payloads but were silently dropped. ([#961]) +- **`HandleShortcut`, `HandleViewSubmission`, `HandleViewClosed` in socketmode handler** — + Level 3 handlers that dispatch `shortcut`/`message_action`, `view_submission`, and + `view_closed` interactions by `CallbackID`, matching the pattern of + `HandleInteractionBlockAction` and `HandleSlashCommand`. ([#1161]) +- **`BlockFromJSON` / `MustBlockFromJSON`** — Create blocks from raw JSON strings, enabling + direct use of output from Slack's [Block Kit Builder](https://app.slack.com/block-kit-builder) + or quick adoption of new block types before the library adds typed support. The original + JSON is preserved through marshalling. ([#1497]) + +### Documentation + +- **`ViewSubmissionResponse` constructors** — `NewClearViewSubmissionResponse`, + `NewUpdateViewSubmissionResponse`, `NewPushViewSubmissionResponse`, and + `NewErrorsViewSubmissionResponse` now have doc comments explaining the HTTP response + pattern (write JSON and return promptly) and the Socket Mode pattern (pass as Ack + payload). `NewErrorsViewSubmissionResponse` additionally documents that map keys must + be `BlockID`s of `InputBlock` elements. ([#726], [#1013]) +- **Socket Mode examples** — `examples/socketmode/` and `examples/socketmode_handler/` now + have doc comments explaining the two-token requirement: app-level token (`xapp-`) for the + WebSocket connection and bot token (`xoxb-`) for API calls. ([#941]) + +### Fixed + +- **`UnknownBlock` round-trip data loss** — Unrecognized block types (e.g. new Slack block + types not yet supported by this library) now preserve their full JSON through + unmarshal/marshal cycles. Previously only `type` and `block_id` were retained, silently + discarding all other fields. + +### Changed + +- Adjusted some `admin` errors that started with uppercase to be lowercase per go + conventions. + + > [!WARNING] + > **Breaking change.** If you are matching the error content in your code, this is a + > BREAKING CHANGE. +- **`WebhookMessage.UnfurlLinks` and `UnfurlMedia` are now `*bool`** — Previously these + were `bool` with `omitempty`, which meant `false` was silently stripped from the JSON + payload. Users could not explicitly disable link or media unfurling via webhooks. The + fields are now `*bool` so that `nil` (omit), `false`, and `true` all serialize correctly. + ([#1231]) + + > [!WARNING] + > **Breaking change.** Code that sets these fields directly must be updated: + > + > ```go + > // Before + > msg := slack.WebhookMessage{UnfurlLinks: true} + > + > // After — use a helper or a variable + > t := true + > msg := slack.WebhookMessage{UnfurlLinks: &t} + > ``` + > + > Leaving the fields unset (`nil`) preserves the previous default behavior — Slack's + > server-side defaults apply (`unfurl_links=false`, `unfurl_media=true`). + +- **`User.Has2FA` is now `*bool`** — When using a bot token, Slack's `users.list` API omits + `has_2fa` entirely. With a plain `bool`, this was indistinguishable from explicitly `false`. + Now `nil` means absent/unknown, `false` means explicitly disabled, `true` means enabled. + ([#1121]) + + > [!WARNING] + > **Breaking change.** Code that reads `Has2FA` must handle the pointer: + > + > ```go + > // Before + > if user.Has2FA { ... } + > + > // After + > if user.Has2FA != nil && *user.Has2FA { ... } + > ``` + +- **`ListReactions` now uses cursor-based pagination** — `ListReactionsParameters` replaces + `Count`/`Page` with `Cursor`/`Limit`, and `ListReactions`/`ListReactionsContext` now return + `([]ReactedItem, string, error)` where the string is the next cursor, instead of + `([]ReactedItem, *Paging, error)`. ([#825]) + + > [!WARNING] + > **Breaking change.** Both the parameters and return signature have changed: + > + > ```go + > // Before + > params := slack.NewListReactionsParameters() + > params.Count = 100 + > params.Page = 2 + > items, paging, err := api.ListReactions(params) + > + > // After + > params := slack.NewListReactionsParameters() + > params.Limit = 100 + > items, nextCursor, err := api.ListReactions(params) + > // Use nextCursor for the next page: params.Cursor = nextCursor + > ``` + +- **`ListStars`/`GetStarred` now use cursor-based pagination** — `StarsParameters` replaces + `Count`/`Page` with `Cursor`/`Limit` (and adds `TeamID`), and `ListStars`/`ListStarsContext`/ + `GetStarred`/`GetStarredContext` now return `string` (next cursor) instead of `*Paging`. + Slack's `stars.list` API no longer returns `paging` data — only `response_metadata.next_cursor`. + + > [!WARNING] + > **Breaking change.** Both the parameters and return signature have changed: + > + > ```go + > // Before + > params := slack.NewStarsParameters() + > params.Count = 100 + > params.Page = 2 + > items, paging, err := api.ListStars(params) + > + > // After + > params := slack.NewStarsParameters() + > params.Limit = 100 + > items, nextCursor, err := api.ListStars(params) + > // Use nextCursor for the next page: params.Cursor = nextCursor + > ``` + +- **`GetAccessLogs` now uses cursor-based pagination** — `AccessLogParameters` replaces + `Count`/`Page` with `Cursor`/`Limit` (and adds `Before`), and `GetAccessLogs`/ + `GetAccessLogsContext` now return `string` (next cursor) instead of `*Paging`. + Slack's `team.accessLogs` API warns `use_cursor_pagination_instead` when using the old + parameters. + + > [!WARNING] + > **Breaking change.** Both the parameters and return signature have changed: + > + > ```go + > // Before + > params := slack.NewAccessLogParameters() + > params.Count = 100 + > params.Page = 2 + > logins, paging, err := api.GetAccessLogs(params) + > + > // After + > params := slack.NewAccessLogParameters() + > params.Limit = 100 + > logins, nextCursor, err := api.GetAccessLogs(params) + > // Use nextCursor for the next page: params.Cursor = nextCursor + > ``` + +### Fixed + +- **Socket Mode: large Ack payloads no longer silently fail** — Two issues caused `Ack()` + payloads to be silently dropped by Slack. First, gorilla/websocket's default 4KB write + buffer fragmented messages into WebSocket continuation frames that Slack does not + reassemble. The library now uses a 32KB write buffer. Second, Slack silently drops + Socket Mode responses at or above 20KB — `Ack()`, `Send()`, and `SendCtx()` now return + an error when the serialized response reaches this limit. ([#1196]) + + > [!WARNING] + > **Breaking change.** `Ack()` and `Send()` now return `error`. Existing call sites that + > don't capture the return value continue to compile without changes. + +- **`MsgOptionBlocks()` with no arguments now sends `blocks=[]`** — Previously, calling + `MsgOptionBlocks()` with no arguments or a nil spread was a silent no-op, making it + impossible to clear blocks from a message via `chat.update`. The Slack API requires an + explicit `blocks=[]` to reliably remove blocks. ([#1214]) + + > [!WARNING] + > **Breaking change.** `MsgOptionBlocks()` with no arguments now sends `blocks=[]` instead + > of being a no-op. If you were relying on this to be a no-op, remove the option entirely: + > + > ```go + > // Before — this was a no-op, now it sends blocks=[] + > api.PostMessage(ch, slack.MsgOptionBlocks(), slack.MsgOptionText("text", false)) + > + > // After — omit MsgOptionBlocks entirely to not set blocks + > api.PostMessage(ch, slack.MsgOptionText("text", false)) + > ``` + +- **`WorkflowButtonBlockElement` missing from `UnmarshalJSON`** — `workflow_button` blocks + now unmarshal correctly through `BlockElements`, `InputBlock`, and `Accessory` paths. + Also adds missing `multi_*_select` and `file_input` cases to `BlockElements.UnmarshalJSON`, + and fixes `toBlockElement` for `RichTextInputElement` and `WorkflowButtonElement`. ([#1539]) +- **`NewBlockHeader` nil pointer dereference** — passing a nil text object no longer panics. ([#1236]) +- **`ValidateUniqueBlockID` rejects empty block IDs** — multiple input blocks with no + explicit `block_id` set (empty string) were incorrectly flagged as duplicates, causing + `OpenView` to fail. ([#1184]) + +## [0.20.0] - 2026-03-21 + +> [!WARNING] +> `trigger_id` and `workflow_id` are NOT in any documentation or in any of the official +libraries, so exercise caution if you use these. + +### Added + +- **`workflow_id` and `trigger_id` in `Message`** — It seems that some types of messages, + e.g: `bot_message`, can carry `trigger_id` and `workflow_id`. +- **`RichTextQuote.Border` field** — optional border toggle (matches the docs now) +- **`RichTextPreformatted.Language` field** — enables syntax highlighting for preformatted + blocks + +### Fixed + +- **Remove embedding of `RichTextSection`** — `RichTextQuote` and `RichTextPreformatted` + are now flattened as they should have always been. This is a breaking change for anyone + using these structs directly. + +## [0.19.0] - 2026-03-04 + +### Added + +- **Optional HTTP retry for Web API** — Retries are off by default. Enable with `OptionRetry(n)` for 429-only retries or `OptionRetryConfig(cfg)` for full control including 5xx and connection errors with exponential backoff. ([#1532]) +- **`task_card` and `plan` agent blocks** — New block types for task cards and plan agent blocks. ([#1536]) + +### Changed + +- CI: bumped `actions/stale` from 10.1.1 to 10.2.0. ([#1534]) +- Use `golangci-lint` in Makefile. ([#1533]) + +## [0.18.0] - 2026-02-21 + +### Added + +- **`focus_on_load` support for remaining block elements** — Static/external/users/conversations/channels select, multi-select variants, datepicker, timepicker, plain_text_input, checkboxes, radio_buttons, and number_input. ([#1519]) +- **`PlainText` and `PreviewPlainText` fields on `File`** — Email file objects now include the plain text body fields instead of silently discarding them. ([#1522]) +- **Missing fields on `User`, `UserProfile`, and `EnterpriseUser`** — `who_can_share_contact_card`, `always_active`, `pronouns`, `image_1024`, `is_custom_image`, `status_text_canonical`, `huddle_state`, `huddle_state_expiration_ts`, `start_date`, and `is_primary_owner`. ([#1526]) +- **Work Objects support** — Chat unfurl with Work Object metadata, entity details (flexpane), `entity_details_requested` event, and associated types (`WorkObjectMetadata`, `WorkObjectEntity`, `WorkObjectExternalRef`). ([#1529]) +- **`admin.roles.*` API methods** — `admin.roles.listAssignments`, `admin.roles.addAssignments`, and `admin.roles.removeAssignments`. ([#1520]) + +### Fixed + +- **`UserProfile.Skype` JSON tag** — Corrected typo from `"skyp"` to `"skype"`. ([#1524]) +- **`assistant.threads.setSuggestedPrompts` title parameter** — Title is now sent when non-empty. ([#1528]) + +### Changed + +- CI test matrix updated: dropped Go 1.24, added Go 1.26; bumped golangci-lint to v2.10.1. ([#1530]) + +## [0.18.0-rc2] - 2026-01-28 + +### Added + +- **Audit Logs example** - New example demonstrating how to use the Audit Logs API. ([#1144]) +- **Admin Conversations API support** - Comprehensive support for `admin.conversations.*` + methods including core operations (archive, unarchive, create, delete, rename, invite, + search, lookup, getTeams, convertToPrivate, convertToPublic, disconnectShared, setTeams), + bulk operations (bulkArchive, bulkDelete, bulkMove), preferences, retention management, + restrict access controls, and EKM channel info. ([#1329]) + +### Changed + +- **BREAKING**: Removed deprecated `UploadFile`, `UploadFileContext`, and + `FileUploadParameters`. The `files.upload` API was discontinued by Slack on November + 12, 2025. ([#1481]) +- **BREAKING**: Renamed `UploadFileV2` → `UploadFile`, `UploadFileV2Context` → + `UploadFileContext`, and `UploadFileV2Parameters` → `UploadFileParameters`. The "V2" + suffix is no longer needed now that the old API is removed. ([#1481]) + +### Fixed + +- **File upload error wrapping** - `UploadFile` now wraps errors with the step name + (`GetUploadURLExternal`, `UploadToURL`, or `CompleteUploadExternal`) so callers can + identify which of the three upload steps failed. ([#1491]) +- **Audit Logs API endpoint** - Fixed `GetAuditLogs` to use the correct endpoint + (`api.slack.com`) instead of the regular API endpoint (`slack.com/api`). The Audit + Logs API requires a different base URL. Added `OptionAuditAPIURL` for testing. ([#1144]) +- **Socket mode websocket dial debugging** - Added debug logging when a custom dialer is + used including HTTP response status on dial failures. This helps diagnose proxy/TLS + issues like "bad handshake" errors. ([#1360]) +- **`MsgOptionPostMessageParameters` now passes `MetaData`** - Previously, metadata was + silently dropped when using `PostMessageParameters`. ([#1343]) + +## [0.18.0-rc1] - 2026-01-26 + +### Added + +- **Huddle support** - New `HuddleRoom`, `HuddleParticipantEvent`, and `HuddleRecording` + types for handling Slack huddle events (`huddle_thread` subtype messages). +- **Call block data parsing** - `CallBlock` now includes full call data when retrieved + from Slack messages, with new `CallBlockData`, `CallBlockDataV1`, and `CallBlockIconURLs` + types. ([#897]) +- **Chat Streaming API support** - New streaming API for real-time chat interactions + with example usage. ([#1506]) +- **Data Access API support** - Full support for Slack's Data Access API with + example implementation. ([#1439]) +- **Cursor-based pagination for `GetUsers`** - More efficient user retrieval + with cursor pagination. ([#1465]) +- **`GetAllConversations` with pagination** - Retrieve all conversations with + automatic pagination handling, including rate limit and server error handling. ([#1463]) +- **Table blocks support** - Parse and create table blocks with proper + unmarshaling. ([#1490], [#1511]) +- **Context actions block support** - New `context_actions` block type. ([#1495]) +- **Workflow button block element** - Support for `workflow_button` in block + elements. ([#1499]) +- **`loading_messages` parameter for `SetAssistantThreadsStatus`** - Optional + parameter to customize loading state messages. ([#1489]) +- **Attachment image fields** - Added `ImageBytes`, `ImageHeight`, and `ImageWidth` + fields to attachments. ([#1516]) +- **`RecordChannel` to conversation properties** - New property for conversation + metadata. ([#1513]) +- **Title argument for `CreateChannelCanvas`** - Canvas creation now supports + custom titles. ([#1483]) +- **`PostEphemeral` handler for slacktest** - Audit outgoing ephemeral messages + in test environments. ([#1517]) +- **`PreviewImageName` for remote files** - Customize preview image filename + instead of using the default `preview.jpg`. + +### Fixed + +- **`PublishView` no longer sends empty hash** - Prevents unnecessary payload + when hash is empty. ([#1515]) +- **`ImageBlockElement` validation** - Now properly validates that either + `imageURL` or `SlackFile` is provided. ([#1488]) +- **Rich text section channel return** - Correctly returns channel for section + channel rich text elements. ([#1472]) +- **`KickUserFromConversation` error handling** - Errors are now properly parsed + as a map structure. ([#1471]) + +### Changed + +- **BREAKING**: `GetReactions` now returns `ReactedItem` instead of `[]ItemReaction`. + This aligns the response with the actual Slack API, which includes the item itself + (message, file, or file_comment) alongside reactions. To migrate, use `resp.Reactions` + to access the slice of reactions. ([#1480]) +- **BREAKING**: `Settings` struct fields `Interactivity` and `EventSubscriptions` + are now pointers, allowing them to be omitted when empty. ([#1461]) +- Minimum Go version bumped to 1.24. ([#1504]) + +## [0.17.3] - 2025-07-04 + +Previous release. See [GitHub releases](https://github.com/slack-go/slack/releases/tag/v0.17.3) +for details. + +[#897]: https://github.com/slack-go/slack/issues/897 +[#1236]: https://github.com/slack-go/slack/issues/1236 +[#1257]: https://github.com/slack-go/slack/issues/1257 +[#1144]: https://github.com/slack-go/slack/issues/1144 +[#1329]: https://github.com/slack-go/slack/issues/1329 +[#1343]: https://github.com/slack-go/slack/issues/1343 +[#1360]: https://github.com/slack-go/slack/issues/1360 +[#1439]: https://github.com/slack-go/slack/pull/1439 +[#1461]: https://github.com/slack-go/slack/pull/1461 +[#1463]: https://github.com/slack-go/slack/pull/1463 +[#1465]: https://github.com/slack-go/slack/pull/1465 +[#1471]: https://github.com/slack-go/slack/pull/1471 +[#1472]: https://github.com/slack-go/slack/pull/1472 +[#1480]: https://github.com/slack-go/slack/pull/1480 +[#1483]: https://github.com/slack-go/slack/pull/1483 +[#1488]: https://github.com/slack-go/slack/pull/1488 +[#1489]: https://github.com/slack-go/slack/pull/1489 +[#1490]: https://github.com/slack-go/slack/pull/1490 +[#1491]: https://github.com/slack-go/slack/issues/1491 +[#1495]: https://github.com/slack-go/slack/pull/1495 +[#1497]: https://github.com/slack-go/slack/pull/1497 +[#1499]: https://github.com/slack-go/slack/pull/1499 +[#1504]: https://github.com/slack-go/slack/pull/1504 +[#1506]: https://github.com/slack-go/slack/pull/1506 +[#1511]: https://github.com/slack-go/slack/pull/1511 +[#1513]: https://github.com/slack-go/slack/pull/1513 +[#1515]: https://github.com/slack-go/slack/pull/1515 +[#1516]: https://github.com/slack-go/slack/pull/1516 +[#1517]: https://github.com/slack-go/slack/pull/1517 +[#1519]: https://github.com/slack-go/slack/pull/1519 +[#1520]: https://github.com/slack-go/slack/pull/1520 +[#1522]: https://github.com/slack-go/slack/pull/1522 +[#1524]: https://github.com/slack-go/slack/pull/1524 +[#1526]: https://github.com/slack-go/slack/pull/1526 +[#1528]: https://github.com/slack-go/slack/pull/1528 +[#1529]: https://github.com/slack-go/slack/pull/1529 +[#1530]: https://github.com/slack-go/slack/pull/1530 +[#1532]: https://github.com/slack-go/slack/pull/1532 +[#1533]: https://github.com/slack-go/slack/pull/1533 +[#1534]: https://github.com/slack-go/slack/pull/1534 +[#1536]: https://github.com/slack-go/slack/pull/1536 +[#596]: https://github.com/slack-go/slack/issues/596 +[#1541]: https://github.com/slack-go/slack/issues/1541 +[#1172]: https://github.com/slack-go/slack/issues/1172 +[#1076]: https://github.com/slack-go/slack/issues/1076 +[#1157]: https://github.com/slack-go/slack/issues/1157 +[#1196]: https://github.com/slack-go/slack/issues/1196 +[#1547]: https://github.com/slack-go/slack/pull/1547 + +[Unreleased]: https://github.com/slack-go/slack/compare/v0.29.0...HEAD +[0.29.0]: https://github.com/slack-go/slack/compare/v0.28.0...v0.29.0 +[0.28.0]: https://github.com/slack-go/slack/compare/v0.27.0...v0.28.0 +[0.27.0]: https://github.com/slack-go/slack/compare/v0.26.0...v0.27.0 +[0.26.0]: https://github.com/slack-go/slack/compare/v0.25.0...v0.26.0 +[0.25.0]: https://github.com/slack-go/slack/compare/v0.24.0...v0.25.0 +[0.24.0]: https://github.com/slack-go/slack/compare/v0.23.1...v0.24.0 +[0.23.1]: https://github.com/slack-go/slack/compare/v0.23.0...v0.23.1 +[0.23.0]: https://github.com/slack-go/slack/compare/v0.22.0...v0.23.0 +[0.22.0]: https://github.com/slack-go/slack/compare/v0.21.1...0.22.0 +[0.21.1]: https://github.com/slack-go/slack/compare/v0.21.0...v0.21.1 +[0.21.0]: https://github.com/slack-go/slack/compare/v0.20.0...v0.21.0 +[0.20.0]: https://github.com/slack-go/slack/compare/v0.19.0...v0.20.0 +[0.19.0]: https://github.com/slack-go/slack/compare/v0.18.0...v0.19.0 +[0.18.0]: https://github.com/slack-go/slack/compare/v0.18.0-rc2...v0.18.0 +[0.18.0-rc2]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc2 +[0.18.0-rc1]: https://github.com/slack-go/slack/releases/tag/v0.18.0-rc1 +[0.17.3]: https://github.com/slack-go/slack/releases/tag/v0.17.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..8b92d3531 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing Guide + +Welcome! We are glad that you want to contribute to our project! 💖 + +There are a just a few small guidelines you ask everyone to follow to make things a bit smoother and more consistent. + +## Opening Pull Requests + +1. It's generally best to start by opening a new issue describing the bug or feature you're intending to fix. Even if you think it's relatively minor, it's helpful to know what people are working on. Mention in the initial issue that you are planning to work on that bug or feature so that it can be assigned to you. + +2. Follow the normal process of [forking](https://help.github.com/articles/fork-a-repo) the project, and set up a new branch to work in. It's important that each group of changes be done in separate branches in order to ensure that a pull request only includes the commits related to that bug or feature. + +3. Any significant changes should almost always be accompanied by tests. The project already has some test coverage, so look at some of the existing tests if you're unsure how to go about it. + +4. Run `make pr-prep` to format your code and check that it passes all tests and linters. + +5. Do your best to have [well-formed commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) for each change. This provides consistency throughout the project, and ensures that commit messages are able to be formatted properly by various git tools. _Pull Request Titles_ should generally follow the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) format to ease the release note process when cutting releases. + +6. Finally, push the commits to your fork and submit a [pull request](https://help.github.com/articles/creating-a-pull-request). NOTE: Please do not use force-push on PRs in this repo, as it makes it more difficult for reviewers to see what has changed since the last code review. We always perform "squash and merge" actions on PRs in this repo, so it doesn't matter how many commits your PR has, as they will end up being a single commit after merging. This is done to make a much cleaner `git log` history and helps to find regressions in the code using existing tools such as `git bisect`. + +## Code Comments + +Every exported method needs to have code comments that follow [Go Doc Comments](https://go.dev/doc/comment). A typical method's comments will look like this: + +```go +// PostMessage sends a message to a channel. +// +// Slack API docs: https://api.dev.slack.com/methods/chat.postMessage +func (api *Client) PostMessage(ctx context.Context, input PostMesssageInput) (PostMesssageOutput, error) { +... +} +``` + +The first line is the name of the method followed by a short description. This could also be a longer description if needed, but there is no need to repeat any details that are documented in Slack's documentation because users are expected to follow the documentation links to learn more. + +After the description comes a link to the Slack API documentation. + +## Other notes on code organization + +Currently, everything is defined in the main `slack` package, with API methods group separate files by the [Slack API Method Groupings](https://api.dev.slack.com/methods). diff --git a/Makefile b/Makefile index 727964016..a8104014d 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ help: @echo "" @echo " make deps : Fetch all dependencies" @echo " make fmt : Run go fmt to fix any formatting issues" - @echo " make lint : Use go vet to check for linting issues" + @echo " make lint : Run golangci-lint for linting issues" @echo " make test : Run all short tests" @echo " make test-race : Run all tests with race condition checking" @echo " make test-integration : Run all tests without limiting to short" @@ -22,7 +22,7 @@ fmt: @go fmt . lint: - @go vet . + @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 run ./... test: @go test -v -count=1 -timeout 300s -short ./... diff --git a/README.md b/README.md index 39b04ce83..a10f2a269 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@ -Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) +Slack API in Go [![Go Reference](https://pkg.go.dev/badge/github.com/slack-go/slack.svg)](https://pkg.go.dev/github.com/slack-go/slack) [![CI](https://github.com/slack-go/slack/actions/workflows/test.yml/badge.svg)](https://github.com/slack-go/slack/actions/workflows/test.yml) =============== -This is the original Slack library for Go created by Norberto Lopes, transferred to a GitHub organization. - -You can also chat with us on the #slack-go, #slack-go-ja Slack channel on the Gophers Slack. +You can chat with us on the [#slack-go](https://gophers.slack.com/archives/C02JQ98JHNC), [#slack-go-ja](https://gophers.slack.com/archives/C02HNL8EN3H) Slack channel on the [Gophers Slack](https://gophers.slack.com). ![logo](logo.png "icon") @@ -15,13 +13,20 @@ a fully managed way. There is currently no major version released. Therefore, minor version releases may include backward incompatible changes. -See [CHANGELOG.md](https://github.com/slack-go/slack/blob/master/CHANGELOG.md) or [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. +See [Releases](https://github.com/slack-go/slack/releases) for more information about the changes. + +## Go Versions supported + +We support the same versions of Go as the officially supported Go versions (see [Go +Release Policy](https://go.dev/doc/devel/release#policy)). ## Installing ### *go get* - $ go get -u github.com/slack-go/slack +```bash +go get -u github.com/slack-go/slack +``` ## Example @@ -29,24 +34,24 @@ See [CHANGELOG.md](https://github.com/slack-go/slack/blob/master/CHANGELOG.md) o ```golang import ( - "fmt" + "fmt" - "github.com/slack-go/slack" + "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") - // If you set debugging, it will log all requests to the console - // Useful when encountering issues - // slack.New("YOUR_TOKEN_HERE", slack.OptionDebug(true)) - groups, err := api.GetUserGroups(false) - if err != nil { - fmt.Printf("%s\n", err) - return - } - for _, group := range groups { - fmt.Printf("ID: %s, Name: %s\n", group.ID, group.Name) - } + api := slack.New("YOUR_TOKEN_HERE") + // If you set debugging, it will log all requests to the console + // Useful when encountering issues + // slack.New("YOUR_TOKEN_HERE", slack.OptionDebug(true)) + groups, err := api.GetUserGroups(slack.GetUserGroupsOptionIncludeUsers(false)) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, group := range groups { + fmt.Printf("ID: %s, Name: %s\n", group.ID, group.Name) + } } ``` @@ -63,13 +68,21 @@ func main() { api := slack.New("YOUR_TOKEN_HERE") user, err := api.GetUserInfo("U023BECGF") if err != nil { - fmt.Printf("%s\n", err) - return + fmt.Printf("%s\n", err) + return } fmt.Printf("ID: %s, Fullname: %s, Email: %s\n", user.ID, user.Profile.RealName, user.Profile.Email) } ``` +### HTTP retries + +Retries are off by default. Use **OptionRetry(n)** for 429-only retries, or **OptionRetryConfig(cfg)** for full control (connection, 429, opt-in 5xx). With a custom client, pass retry options after `OptionHTTPClient`. See package `slack` doc for handler details. + +```golang +api := slack.New("YOUR_TOKEN_HERE", slack.OptionRetry(3)) +``` + ## Minimal Socket Mode usage: See https://github.com/slack-go/slack/blob/master/examples/socketmode/socketmode.go @@ -86,7 +99,13 @@ See https://github.com/slack-go/slack/blob/master/examples/websocket/websocket.g See https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go +## Socketmode Event Handler (Experimental) + +When using socket mode, dealing with an event can be pretty lengthy as it requires you to route the event to the right place. + +Instead, you can use `SocketmodeHandler` much like you use an HTTP handler to register which event you would like to listen to and what callback function will process that event when it occurs. +See [./examples/socketmode_handler/socketmode_handler.go](./examples/socketmode_handler/socketmode_handler.go) ## Contributing You are more than welcome to contribute to this project. Fork and diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index 8607960b8..000000000 --- a/TODO.txt +++ /dev/null @@ -1,3 +0,0 @@ -- Add more tests!!! -- Add support to have markdown hints - - See section Message Formatting at https://api.slack.com/docs/formatting diff --git a/admin.go b/admin.go index d51426b56..1b0d2178a 100644 --- a/admin.go +++ b/admin.go @@ -59,7 +59,7 @@ func (api *Client) InviteGuestContext(ctx context.Context, teamName, channel, fi err := api.adminRequest(ctx, "invite", teamName, values) if err != nil { - return fmt.Errorf("Failed to invite single-channel guest: %s", err) + return fmt.Errorf("failed to invite single-channel guest: %s", err) } return nil @@ -86,7 +86,7 @@ func (api *Client) InviteRestrictedContext(ctx context.Context, teamName, channe err := api.adminRequest(ctx, "invite", teamName, values) if err != nil { - return fmt.Errorf("Failed to restricted account: %s", err) + return fmt.Errorf("failed to restricted account: %s", err) } return nil @@ -110,7 +110,7 @@ func (api *Client) InviteToTeamContext(ctx context.Context, teamName, firstName, err := api.adminRequest(ctx, "invite", teamName, values) if err != nil { - return fmt.Errorf("Failed to invite to team: %s", err) + return fmt.Errorf("failed to invite to team: %s", err) } return nil @@ -132,7 +132,7 @@ func (api *Client) SetRegularContext(ctx context.Context, teamName, user string) err := api.adminRequest(ctx, "setRegular", teamName, values) if err != nil { - return fmt.Errorf("Failed to change the user (%s) to a regular user: %s", user, err) + return fmt.Errorf("failed to change the user (%s) to a regular user: %s", user, err) } return nil @@ -154,7 +154,7 @@ func (api *Client) SendSSOBindingEmailContext(ctx context.Context, teamName, use err := api.adminRequest(ctx, "sendSSOBind", teamName, values) if err != nil { - return fmt.Errorf("Failed to send SSO binding email for user (%s): %s", user, err) + return fmt.Errorf("failed to send SSO binding email for user (%s): %s", user, err) } return nil @@ -177,7 +177,7 @@ func (api *Client) SetUltraRestrictedContext(ctx context.Context, teamName, uid, err := api.adminRequest(ctx, "setUltraRestricted", teamName, values) if err != nil { - return fmt.Errorf("Failed to ultra-restrict account: %s", err) + return fmt.Errorf("failed to ultra-restrict account: %s", err) } return nil diff --git a/admin_conversations.go b/admin_conversations.go new file mode 100644 index 000000000..eba5d98ea --- /dev/null +++ b/admin_conversations.go @@ -0,0 +1,807 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "strings" +) + +// AdminConversationsInviteParams contains arguments for AdminConversationsInvite method call. +type AdminConversationsInviteParams struct { + ChannelID string + UserIDs []string +} + +// AdminConversationsInvite invites users to a channel. +// For more information see the admin.conversations.invite docs: +// https://api.slack.com/methods/admin.conversations.invite +func (api *Client) AdminConversationsInvite(ctx context.Context, params AdminConversationsInviteParams) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + "user_ids": {strings.Join(params.UserIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.invite", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsArchive archives a public or private channel. +// For more information see the admin.conversations.archive docs: +// https://api.slack.com/methods/admin.conversations.archive +func (api *Client) AdminConversationsArchive(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.archive", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsUnarchive unarchives a public or private channel. +// For more information see the admin.conversations.unarchive docs: +// https://api.slack.com/methods/admin.conversations.unarchive +func (api *Client) AdminConversationsUnarchive(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.unarchive", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsRename renames a public or private channel. +// For more information see the admin.conversations.rename docs: +// https://api.slack.com/methods/admin.conversations.rename +func (api *Client) AdminConversationsRename(ctx context.Context, channelID, name string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "name": {name}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.rename", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsDelete deletes a public or private channel. +// For more information see the admin.conversations.delete docs: +// https://api.slack.com/methods/admin.conversations.delete +func (api *Client) AdminConversationsDelete(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.delete", values, response) + if err != nil { + return err + } + + return response.Err() +} + +type adminConversationsDisconnectSharedParams struct { + leavingTeamIDs []string +} + +// AdminConversationsDisconnectSharedOption is an option for AdminConversationsDisconnectShared. +type AdminConversationsDisconnectSharedOption func(*adminConversationsDisconnectSharedParams) + +// AdminConversationsDisconnectSharedOptionLeavingTeamIDs sets the team IDs of the workspaces to disconnect. +func AdminConversationsDisconnectSharedOptionLeavingTeamIDs(teamIDs []string) AdminConversationsDisconnectSharedOption { + return func(params *adminConversationsDisconnectSharedParams) { + params.leavingTeamIDs = teamIDs + } +} + +// AdminConversationsDisconnectShared disconnects a connected channel from one or more workspaces. +// For more information see the admin.conversations.disconnectShared docs: +// https://api.slack.com/methods/admin.conversations.disconnectShared +func (api *Client) AdminConversationsDisconnectShared(ctx context.Context, channelID string, options ...AdminConversationsDisconnectSharedOption) error { + params := adminConversationsDisconnectSharedParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + if len(params.leavingTeamIDs) > 0 { + values.Add("leaving_team_ids", strings.Join(params.leavingTeamIDs, ",")) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.disconnectShared", values, response) + if err != nil { + return err + } + + return response.Err() +} + +type adminConversationsCreateParams struct { + description string + orgWide bool + teamID string +} + +// AdminConversationsCreateOption is an option for AdminConversationsCreate. +type AdminConversationsCreateOption func(*adminConversationsCreateParams) + +// AdminConversationsCreateOptionDescription sets the description of the channel. +func AdminConversationsCreateOptionDescription(description string) AdminConversationsCreateOption { + return func(params *adminConversationsCreateParams) { + params.description = description + } +} + +// AdminConversationsCreateOptionOrgWide sets whether the channel should be org-wide. +func AdminConversationsCreateOptionOrgWide(orgWide bool) AdminConversationsCreateOption { + return func(params *adminConversationsCreateParams) { + params.orgWide = orgWide + } +} + +// AdminConversationsCreateOptionTeamID sets the team ID where the channel should be created. +func AdminConversationsCreateOptionTeamID(teamID string) AdminConversationsCreateOption { + return func(params *adminConversationsCreateParams) { + params.teamID = teamID + } +} + +// AdminConversationsCreateResponse represents the response from admin.conversations.create. +type AdminConversationsCreateResponse struct { + SlackResponse + ChannelID string `json:"channel_id"` +} + +// AdminConversationsCreate creates a public or private channel-based conversation. +// For more information see the admin.conversations.create docs: +// https://api.slack.com/methods/admin.conversations.create +func (api *Client) AdminConversationsCreate(ctx context.Context, name string, isPrivate bool, options ...AdminConversationsCreateOption) (string, error) { + params := adminConversationsCreateParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "is_private": {strconv.FormatBool(isPrivate)}, + "name": {name}, + } + + if params.description != "" { + values.Add("description", params.description) + } + + if params.orgWide { + values.Add("org_wide", "true") + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &AdminConversationsCreateResponse{} + err := api.postMethod(ctx, "admin.conversations.create", values, response) + if err != nil { + return "", err + } + + return response.ChannelID, response.Err() +} + +// AdminConversationsGetTeamsParams contains arguments for AdminConversationsGetTeams method call. +type AdminConversationsGetTeamsParams struct { + ChannelID string + Cursor string + Limit int +} + +// AdminConversationsGetTeamsResponse represents the response from admin.conversations.getTeams. +type AdminConversationsGetTeamsResponse struct { + SlackResponse + TeamIDs []string `json:"team_ids"` +} + +// AdminConversationsGetTeams gets all the workspaces a given public or private channel is connected to within this Enterprise org. +// For more information see the admin.conversations.getTeams docs: +// https://api.slack.com/methods/admin.conversations.getTeams +func (api *Client) AdminConversationsGetTeams(ctx context.Context, params AdminConversationsGetTeamsParams) ([]string, string, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + } + + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + + if params.Limit > 0 { + values.Add("limit", strconv.Itoa(params.Limit)) + } + + response := &AdminConversationsGetTeamsResponse{} + err := api.postMethod(ctx, "admin.conversations.getTeams", values, response) + if err != nil { + return nil, "", err + } + + return response.TeamIDs, response.ResponseMetadata.Cursor, response.Err() +} + +type adminConversationsSearchParams struct { + cursor string + limit int + query string + searchChannelType []string + sort string + sortDir string + teamIDs []string + connectedTeamIDs []string + totalCountOnly bool +} + +// AdminConversationsSearchOption is an option for AdminConversationsSearch. +type AdminConversationsSearchOption func(*adminConversationsSearchParams) + +// AdminConversationsSearchOptionCursor sets the cursor for pagination. +func AdminConversationsSearchOptionCursor(cursor string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.cursor = cursor + } +} + +// AdminConversationsSearchOptionLimit sets the maximum number of results to return. +func AdminConversationsSearchOptionLimit(limit int) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.limit = limit + } +} + +// AdminConversationsSearchOptionQuery sets the search query. +func AdminConversationsSearchOptionQuery(query string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.query = query + } +} + +// AdminConversationsSearchOptionSearchChannelTypes sets the channel types to search. +// Valid values: "private", "public", "private_exclude", "multi_workspace", "org_wide", "external_shared_exclude", "external_shared" +func AdminConversationsSearchOptionSearchChannelTypes(types []string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.searchChannelType = types + } +} + +// AdminConversationsSearchOptionSort sets the sort field. +// Valid values: "name", "member_count", "created" +func AdminConversationsSearchOptionSort(sort string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.sort = sort + } +} + +// AdminConversationsSearchOptionSortDir sets the sort direction. +// Valid values: "asc", "desc" +func AdminConversationsSearchOptionSortDir(sortDir string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.sortDir = sortDir + } +} + +// AdminConversationsSearchOptionTeamIDs filters results to channels in the specified teams. +func AdminConversationsSearchOptionTeamIDs(teamIDs []string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.teamIDs = teamIDs + } +} + +// AdminConversationsSearchOptionConnectedTeamIDs filters results to channels connected to the specified teams. +func AdminConversationsSearchOptionConnectedTeamIDs(teamIDs []string) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.connectedTeamIDs = teamIDs + } +} + +// AdminConversationsSearchOptionTotalCountOnly when true, only returns the total count of matching channels. +func AdminConversationsSearchOptionTotalCountOnly(totalCountOnly bool) AdminConversationsSearchOption { + return func(params *adminConversationsSearchParams) { + params.totalCountOnly = totalCountOnly + } +} + +// ChannelEmailAddress represents an email address associated with a channel. +type ChannelEmailAddress struct { + Address string `json:"address"` + CreatorID string `json:"creator_id"` + TeamID string `json:"team_id"` +} + +// AdminConversationOwnershipDetail represents ownership details for lists/canvas. +type AdminConversationOwnershipDetail struct { + Count int `json:"count,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +// AdminConversationLists represents lists/canvas information in admin conversations. +type AdminConversationLists struct { + OwnershipDetails []AdminConversationOwnershipDetail `json:"ownership_details,omitempty"` + TotalCount int `json:"total_count,omitempty"` +} + +// AdminConversation represents a conversation in admin API responses. +type AdminConversation struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Purpose string `json:"purpose,omitempty"` + MemberCount int `json:"member_count,omitempty"` + Created int64 `json:"created,omitempty"` + CreatorID string `json:"creator_id,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + IsGeneral bool `json:"is_general,omitempty"` + LastActivityTimestamp int64 `json:"last_activity_ts,omitempty"` + IsFrozen bool `json:"is_frozen,omitempty"` + IsOrgDefault bool `json:"is_org_default,omitempty"` + IsOrgMandatory bool `json:"is_org_mandatory,omitempty"` + IsOrgShared bool `json:"is_org_shared,omitempty"` + IsExtShared bool `json:"is_ext_shared,omitempty"` + IsGlobalShared bool `json:"is_global_shared,omitempty"` + IsPendingExtShared bool `json:"is_pending_ext_shared,omitempty"` + IsDisconnectInProgress bool `json:"is_disconnect_in_progress,omitempty"` + ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` + ConnectedLimitedTeamIDs []string `json:"connected_limited_team_ids,omitempty"` + PendingConnectedTeamIDs []string `json:"pending_connected_team_ids,omitempty"` + InternalTeamIDs []string `json:"internal_team_ids,omitempty"` + InternalTeamIDsCount int `json:"internal_team_ids_count,omitempty"` + InternalTeamIDsSampleTeam string `json:"internal_team_ids_sample_team,omitempty"` + ContextTeamID string `json:"context_team_id,omitempty"` + ConversationHostID string `json:"conversation_host_id,omitempty"` + ChannelEmailAddresses []ChannelEmailAddress `json:"channel_email_addresses,omitempty"` + ChannelManagerCount int `json:"channel_manager_count,omitempty"` + ExternalUserCount int `json:"external_user_count,omitempty"` + Canvas *AdminConversationLists `json:"canvas,omitempty"` + Lists *AdminConversationLists `json:"lists,omitempty"` + Properties *Properties `json:"properties,omitempty"` +} + +// AdminConversationsSearchResponse represents the response from admin.conversations.search. +type AdminConversationsSearchResponse struct { + SlackResponse + Conversations []AdminConversation `json:"conversations"` + TotalCount int `json:"total_count"` + NextCursor string `json:"next_cursor"` +} + +// AdminConversationsSearch searches for public or private channels in an Enterprise organization. +// For more information see the admin.conversations.search docs: +// https://api.slack.com/methods/admin.conversations.search +func (api *Client) AdminConversationsSearch(ctx context.Context, options ...AdminConversationsSearchOption) (*AdminConversationsSearchResponse, error) { + params := adminConversationsSearchParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + if params.query != "" { + values.Add("query", params.query) + } + + if len(params.searchChannelType) > 0 { + values.Add("search_channel_types", strings.Join(params.searchChannelType, ",")) + } + + if params.sort != "" { + values.Add("sort", params.sort) + } + + if params.sortDir != "" { + values.Add("sort_dir", params.sortDir) + } + + if len(params.teamIDs) > 0 { + values.Add("team_ids", strings.Join(params.teamIDs, ",")) + } + + if len(params.connectedTeamIDs) > 0 { + values.Add("connected_team_ids", strings.Join(params.connectedTeamIDs, ",")) + } + + if params.totalCountOnly { + values.Add("total_count_only", "true") + } + + response := &AdminConversationsSearchResponse{} + err := api.postMethod(ctx, "admin.conversations.search", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +type adminConversationsLookupParams struct { + cursor string + limit int + maxMemberCount int +} + +// AdminConversationsLookupOption is an option for AdminConversationsLookup. +type AdminConversationsLookupOption func(*adminConversationsLookupParams) + +// AdminConversationsLookupOptionCursor sets the cursor for pagination. +func AdminConversationsLookupOptionCursor(cursor string) AdminConversationsLookupOption { + return func(params *adminConversationsLookupParams) { + params.cursor = cursor + } +} + +// AdminConversationsLookupOptionLimit sets the maximum number of results to return. +func AdminConversationsLookupOptionLimit(limit int) AdminConversationsLookupOption { + return func(params *adminConversationsLookupParams) { + params.limit = limit + } +} + +// AdminConversationsLookupOptionMaxMemberCount filters to channels with at most this many members. +func AdminConversationsLookupOptionMaxMemberCount(maxMemberCount int) AdminConversationsLookupOption { + return func(params *adminConversationsLookupParams) { + params.maxMemberCount = maxMemberCount + } +} + +// AdminConversationsLookupResponse represents the response from admin.conversations.lookup. +type AdminConversationsLookupResponse struct { + SlackResponse + Channels []string `json:"channels"` +} + +// AdminConversationsLookup returns channels on the given team matching the specified filters. +// For more information see the admin.conversations.lookup docs: +// https://api.slack.com/methods/admin.conversations.lookup +func (api *Client) AdminConversationsLookup(ctx context.Context, teamIDs []string, lastMessageActivityBefore int64, options ...AdminConversationsLookupOption) ([]string, string, error) { + params := adminConversationsLookupParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "last_message_activity_before": {strconv.FormatInt(lastMessageActivityBefore, 10)}, + "team_ids": {strings.Join(teamIDs, ",")}, + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + if params.maxMemberCount > 0 { + values.Add("max_member_count", strconv.Itoa(params.maxMemberCount)) + } + + response := &AdminConversationsLookupResponse{} + err := api.postMethod(ctx, "admin.conversations.lookup", values, response) + if err != nil { + return nil, "", err + } + + return response.Channels, response.ResponseMetadata.Cursor, response.Err() +} + +// AdminConversationsBulkArchive archives public or private channels in bulk. +// For more information see the admin.conversations.bulkArchive docs: +// https://api.slack.com/methods/admin.conversations.bulkArchive +func (api *Client) AdminConversationsBulkArchive(ctx context.Context, channelIDs []string) error { + values := url.Values{ + "token": {api.token}, + "channel_ids": {strings.Join(channelIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.bulkArchive", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsBulkDelete deletes public or private channels in bulk. +// For more information see the admin.conversations.bulkDelete docs: +// https://api.slack.com/methods/admin.conversations.bulkDelete +func (api *Client) AdminConversationsBulkDelete(ctx context.Context, channelIDs []string) error { + values := url.Values{ + "token": {api.token}, + "channel_ids": {strings.Join(channelIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.bulkDelete", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsBulkMoveParams contains arguments for AdminConversationsBulkMove method call. +type AdminConversationsBulkMoveParams struct { + ChannelIDs []string + TargetTeamID string +} + +// AdminConversationsBulkMove moves public or private channels in bulk. +// For more information see the admin.conversations.bulkMove docs: +// https://api.slack.com/methods/admin.conversations.bulkMove +func (api *Client) AdminConversationsBulkMove(ctx context.Context, params AdminConversationsBulkMoveParams) error { + values := url.Values{ + "token": {api.token}, + "channel_ids": {strings.Join(params.ChannelIDs, ",")}, + "target_team_id": {params.TargetTeamID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.bulkMove", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationPrefs represents conversation preferences. +type AdminConversationPrefs struct { + WhoCanPost *AdminConversationPref `json:"who_can_post,omitempty"` + CanThread *AdminConversationPref `json:"can_thread,omitempty"` + CanHuddle *AdminConversationPref `json:"can_huddle,omitempty"` + EnableAtHere *AdminConversationPrefEnabled `json:"enable_at_here,omitempty"` + EnableAtChannel *AdminConversationPrefEnabled `json:"enable_at_channel,omitempty"` +} + +// AdminConversationPrefEnabled represents an enabled/disabled preference. +type AdminConversationPrefEnabled struct { + Enabled bool `json:"enabled"` +} + +// AdminConversationPref represents a single conversation preference. +type AdminConversationPref struct { + Type []string `json:"type,omitempty"` + User []string `json:"user,omitempty"` +} + +// AdminConversationsGetConversationPrefsResponse represents the response from admin.conversations.getConversationPrefs. +type AdminConversationsGetConversationPrefsResponse struct { + SlackResponse + Prefs AdminConversationPrefs `json:"prefs"` +} + +// AdminConversationsGetConversationPrefs gets conversation preferences for a public or private channel. +// For more information see the admin.conversations.getConversationPrefs docs: +// https://api.slack.com/methods/admin.conversations.getConversationPrefs +func (api *Client) AdminConversationsGetConversationPrefs(ctx context.Context, channelID string) (*AdminConversationPrefs, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &AdminConversationsGetConversationPrefsResponse{} + err := api.postMethod(ctx, "admin.conversations.getConversationPrefs", values, response) + if err != nil { + return nil, err + } + + return &response.Prefs, response.Err() +} + +// AdminConversationsSetConversationPrefsParams contains arguments for AdminConversationsSetConversationPrefs method call. +type AdminConversationsSetConversationPrefsParams struct { + ChannelID string + Prefs AdminConversationPrefs +} + +// AdminConversationsSetConversationPrefs sets conversation preferences for a public or private channel. +// For more information see the admin.conversations.setConversationPrefs docs: +// https://api.slack.com/methods/admin.conversations.setConversationPrefs +func (api *Client) AdminConversationsSetConversationPrefs(ctx context.Context, params AdminConversationsSetConversationPrefsParams) error { + prefsJSON, err := json.Marshal(params.Prefs) + if err != nil { + return err + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + "prefs": {string(prefsJSON)}, + } + + response := &SlackResponse{} + err = api.postMethod(ctx, "admin.conversations.setConversationPrefs", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsGetCustomRetentionResponse represents the response from admin.conversations.getCustomRetention. +type AdminConversationsGetCustomRetentionResponse struct { + SlackResponse + DurationDays int `json:"duration_days"` + IsPolicyEnabled bool `json:"is_policy_enabled"` +} + +// AdminConversationsGetCustomRetention gets a conversation's custom retention policy. +// For more information see the admin.conversations.getCustomRetention docs: +// https://api.slack.com/methods/admin.conversations.getCustomRetention +func (api *Client) AdminConversationsGetCustomRetention(ctx context.Context, channelID string) (*AdminConversationsGetCustomRetentionResponse, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &AdminConversationsGetCustomRetentionResponse{} + err := api.postMethod(ctx, "admin.conversations.getCustomRetention", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// AdminConversationsSetCustomRetention sets a conversation's custom retention policy. +// For more information see the admin.conversations.setCustomRetention docs: +// https://api.slack.com/methods/admin.conversations.setCustomRetention +func (api *Client) AdminConversationsSetCustomRetention(ctx context.Context, channelID string, durationDays int) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "duration_days": {strconv.Itoa(durationDays)}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.setCustomRetention", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsRemoveCustomRetention removes a conversation's custom retention policy. +// For more information see the admin.conversations.removeCustomRetention docs: +// https://api.slack.com/methods/admin.conversations.removeCustomRetention +func (api *Client) AdminConversationsRemoveCustomRetention(ctx context.Context, channelID string) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.removeCustomRetention", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsSetTeamsParams contains arguments for AdminConversationsSetTeams +// method calls. +type AdminConversationsSetTeamsParams struct { + ChannelID string + OrgChannel *bool + TargetTeamIDs []string + TeamID *string +} + +// Set the workspaces in an Enterprise Grid organisation that connect to a public or +// private channel. +// See: https://api.slack.com/methods/admin.conversations.setTeams +func (api *Client) AdminConversationsSetTeams(ctx context.Context, params AdminConversationsSetTeamsParams) error { + values := url.Values{ + "token": {api.token}, + "channel_id": {params.ChannelID}, + } + + if params.OrgChannel != nil { + values.Add("org_channel", strconv.FormatBool(*params.OrgChannel)) + } + + if len(params.TargetTeamIDs) > 0 { + values.Add("target_team_ids", strings.Join(params.TargetTeamIDs, ",")) // ["T123", "T456"] - > "T123,T456" + } + + if params.TeamID != nil { + values.Add("team_id", *params.TeamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.setTeams", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// ConversationsConvertToPrivate converts a public channel to a private channel. To do +// this, you must have the admin.conversations:write scope. There are other requirements: +// you should read the Slack documentation for more details. +// See: https://api.slack.com/methods/admin.conversations.convertToPrivate +func (api *Client) AdminConversationsConvertToPrivate(ctx context.Context, channelID string) error { + values := url.Values{ + "token": []string{api.token}, + "channel_id": []string{channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.convertToPrivate", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// ConversationsConvertToPublic converts a private channel to a public channel. To do +// this, you must have the admin.conversations:write scope. There are other requirements: +// you should read the Slack documentation for more details. +// See: https://api.slack.com/methods/admin.conversations.convertToPublic +func (api *Client) AdminConversationsConvertToPublic(ctx context.Context, channelID string) error { + values := url.Values{ + "token": []string{api.token}, + "channel_id": []string{channelID}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.convertToPublic", values, response) + if err != nil { + return err + } + + return response.Err() +} diff --git a/admin_conversations_ekm.go b/admin_conversations_ekm.go new file mode 100644 index 000000000..f4b4e14ef --- /dev/null +++ b/admin_conversations_ekm.go @@ -0,0 +1,101 @@ +package slack + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +type adminConversationsEKMListOriginalConnectedChannelInfoParams struct { + channelIDs []string + teamIDs []string + cursor string + limit int +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOption is an option for +// AdminConversationsEKMListOriginalConnectedChannelInfo. +type AdminConversationsEKMListOriginalConnectedChannelInfoOption func(*adminConversationsEKMListOriginalConnectedChannelInfoParams) + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionChannelIDs filters results to specific channels. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionChannelIDs(channelIDs []string) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.channelIDs = channelIDs + } +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs filters results to specific teams. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs(teamIDs []string) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.teamIDs = teamIDs + } +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionCursor sets the cursor for pagination. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionCursor(cursor string) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.cursor = cursor + } +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit sets the maximum number of results to return. +func AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit(limit int) AdminConversationsEKMListOriginalConnectedChannelInfoOption { + return func(params *adminConversationsEKMListOriginalConnectedChannelInfoParams) { + params.limit = limit + } +} + +// AdminConversationsEKMOriginalConnectedChannelInfo represents channel info for EKM response. +type AdminConversationsEKMOriginalConnectedChannelInfo struct { + ID string `json:"id"` + OriginalConnectedHostID string `json:"original_connected_host_id"` + OriginalConnectedChannelID string `json:"original_connected_channel_id"` + InternalTeamIDs []string `json:"internal_team_ids_count"` +} + +// AdminConversationsEKMListOriginalConnectedChannelInfoResponse represents the response from +// admin.conversations.ekm.listOriginalConnectedChannelInfo. +type AdminConversationsEKMListOriginalConnectedChannelInfoResponse struct { + SlackResponse + Channels []AdminConversationsEKMOriginalConnectedChannelInfo `json:"channels"` +} + +// AdminConversationsEKMListOriginalConnectedChannelInfo lists the original connected channel +// information for Slack Connect channels. +// For more information see the admin.conversations.ekm.listOriginalConnectedChannelInfo docs: +// https://api.slack.com/methods/admin.conversations.ekm.listOriginalConnectedChannelInfo +func (api *Client) AdminConversationsEKMListOriginalConnectedChannelInfo(ctx context.Context, options ...AdminConversationsEKMListOriginalConnectedChannelInfoOption) (*AdminConversationsEKMListOriginalConnectedChannelInfoResponse, error) { + params := adminConversationsEKMListOriginalConnectedChannelInfoParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + } + + if len(params.channelIDs) > 0 { + values.Add("channel_ids", strings.Join(params.channelIDs, ",")) + } + + if len(params.teamIDs) > 0 { + values.Add("team_ids", strings.Join(params.teamIDs, ",")) + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + response := &AdminConversationsEKMListOriginalConnectedChannelInfoResponse{} + err := api.postMethod(ctx, "admin.conversations.ekm.listOriginalConnectedChannelInfo", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} diff --git a/admin_conversations_ekm_test.go b/admin_conversations_ekm_test.go new file mode 100644 index 000000000..bd5fb3a1b --- /dev/null +++ b/admin_conversations_ekm_test.go @@ -0,0 +1,58 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminConversationsEKMListOriginalConnectedChannelInfo(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.ekm.listOriginalConnectedChannelInfo", mockEKMListHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + response, err := api.AdminConversationsEKMListOriginalConnectedChannelInfo(context.Background(), + AdminConversationsEKMListOriginalConnectedChannelInfoOptionChannelIDs([]string{"C123", "C456"}), + AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs([]string{"T789"}), + AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit(100), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(response.Channels) != 1 { + t.Errorf("unexpected channel count: %d", len(response.Channels)) + return + } + + if response.Channels[0].ID != "C1234567890" { + t.Errorf("unexpected channel ID: %s", response.Channels[0].ID) + return + } +} + +func mockEKMListHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsEKMListOriginalConnectedChannelInfoResponse{ + SlackResponse: SlackResponse{Ok: true}, + Channels: []AdminConversationsEKMOriginalConnectedChannelInfo{ + { + ID: "C1234567890", + OriginalConnectedHostID: "T123", + OriginalConnectedChannelID: "C9876543210", + InternalTeamIDs: []string{"T001", "T002"}, + }, + }, + }) + + _, _ = rw.Write(response) + } +} diff --git a/admin_conversations_restrictAccess.go b/admin_conversations_restrictAccess.go new file mode 100644 index 000000000..344579972 --- /dev/null +++ b/admin_conversations_restrictAccess.go @@ -0,0 +1,150 @@ +package slack + +import ( + "context" + "net/url" +) + +// AdminConversationsRestrictAccessAddGroup + +type adminConversationsRestrictAccessAddGroupParams struct { + teamID string +} + +// AdminConversationsRestrictAccessAddGroupOption is an option for AdminConversationsRestrictAccessAddGroup. +type AdminConversationsRestrictAccessAddGroupOption func(*adminConversationsRestrictAccessAddGroupParams) + +// AdminConversationsRestrictAccessAddGroupOptionTeamID sets the workspace where the channel exists. +// Required if using an org token. +func AdminConversationsRestrictAccessAddGroupOptionTeamID(teamID string) AdminConversationsRestrictAccessAddGroupOption { + return func(params *adminConversationsRestrictAccessAddGroupParams) { + params.teamID = teamID + } +} + +// AdminConversationsRestrictAccessAddGroup adds an allowlist of IDP groups +// for accessing a channel. +// For more information see the admin.conversations.restrictAccess.addGroup docs: +// https://api.slack.com/methods/admin.conversations.restrictAccess.addGroup +func (api *Client) AdminConversationsRestrictAccessAddGroup(ctx context.Context, channelID, groupID string, options ...AdminConversationsRestrictAccessAddGroupOption) error { + params := adminConversationsRestrictAccessAddGroupParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "group_id": {groupID}, + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.restrictAccess.addGroup", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// AdminConversationsRestrictAccessListGroups + +type adminConversationsRestrictAccessListGroupsParams struct { + teamID string +} + +// AdminConversationsRestrictAccessListGroupsOption is an option for AdminConversationsRestrictAccessListGroups. +type AdminConversationsRestrictAccessListGroupsOption func(*adminConversationsRestrictAccessListGroupsParams) + +// AdminConversationsRestrictAccessListGroupsOptionTeamID sets the workspace where the channel exists. +// Required if using an org token. +func AdminConversationsRestrictAccessListGroupsOptionTeamID(teamID string) AdminConversationsRestrictAccessListGroupsOption { + return func(params *adminConversationsRestrictAccessListGroupsParams) { + params.teamID = teamID + } +} + +// AdminConversationsRestrictAccessListGroupsResponse represents the response from +// admin.conversations.restrictAccess.listGroups. +type AdminConversationsRestrictAccessListGroupsResponse struct { + SlackResponse + GroupIDs []string `json:"group_ids"` +} + +// AdminConversationsRestrictAccessListGroups lists the allowlist of IDP groups +// for a private channel. +// For more information see the admin.conversations.restrictAccess.listGroups docs: +// https://api.slack.com/methods/admin.conversations.restrictAccess.listGroups +func (api *Client) AdminConversationsRestrictAccessListGroups(ctx context.Context, channelID string, options ...AdminConversationsRestrictAccessListGroupsOption) ([]string, error) { + params := adminConversationsRestrictAccessListGroupsParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &AdminConversationsRestrictAccessListGroupsResponse{} + err := api.postMethod(ctx, "admin.conversations.restrictAccess.listGroups", values, response) + if err != nil { + return nil, err + } + + return response.GroupIDs, response.Err() +} + +// AdminConversationsRestrictAccessRemoveGroup + +type adminConversationsRestrictAccessRemoveGroupParams struct { + teamID string +} + +// AdminConversationsRestrictAccessRemoveGroupOption is an option for AdminConversationsRestrictAccessRemoveGroup. +type AdminConversationsRestrictAccessRemoveGroupOption func(*adminConversationsRestrictAccessRemoveGroupParams) + +// AdminConversationsRestrictAccessRemoveGroupOptionTeamID sets the workspace where the channel exists. +// Required if using an org token. +func AdminConversationsRestrictAccessRemoveGroupOptionTeamID(teamID string) AdminConversationsRestrictAccessRemoveGroupOption { + return func(params *adminConversationsRestrictAccessRemoveGroupParams) { + params.teamID = teamID + } +} + +// AdminConversationsRestrictAccessRemoveGroup removes an IDP group from the +// allowlist of a private channel. +// For more information see the admin.conversations.restrictAccess.removeGroup docs: +// https://api.slack.com/methods/admin.conversations.restrictAccess.removeGroup +func (api *Client) AdminConversationsRestrictAccessRemoveGroup(ctx context.Context, channelID, groupID string, options ...AdminConversationsRestrictAccessRemoveGroupOption) error { + params := adminConversationsRestrictAccessRemoveGroupParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + "group_id": {groupID}, + } + + if params.teamID != "" { + values.Add("team_id", params.teamID) + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.conversations.restrictAccess.removeGroup", values, response) + if err != nil { + return err + } + + return response.Err() +} diff --git a/admin_conversations_restrictAccess_test.go b/admin_conversations_restrictAccess_test.go new file mode 100644 index 000000000..dbcc4e02c --- /dev/null +++ b/admin_conversations_restrictAccess_test.go @@ -0,0 +1,119 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminConversationsRestrictAccessAddGroup(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.restrictAccess.addGroup", mockRestrictAccessHandler(t, "group_id")) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRestrictAccessAddGroup(context.Background(), + "C1234567890", + "G123", + AdminConversationsRestrictAccessAddGroupOptionTeamID("T789"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsRestrictAccessListGroups(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.restrictAccess.listGroups", mockRestrictAccessListGroupsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + groupIDs, err := api.AdminConversationsRestrictAccessListGroups(context.Background(), + "C1234567890", + AdminConversationsRestrictAccessListGroupsOptionTeamID("T789"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(groupIDs) != 2 { + t.Errorf("unexpected group count: %d", len(groupIDs)) + return + } +} + +func TestAdminConversationsRestrictAccessRemoveGroup(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.restrictAccess.removeGroup", mockRestrictAccessHandler(t, "group_id")) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRestrictAccessRemoveGroup(context.Background(), + "C1234567890", + "G123", + AdminConversationsRestrictAccessRemoveGroupOptionTeamID("T789"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func mockRestrictAccessHandler(t *testing.T, requiredField string) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form[requiredField]) == 0 { + t.Errorf("missing %s in request", requiredField) + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockRestrictAccessListGroupsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsRestrictAccessListGroupsResponse{ + SlackResponse: SlackResponse{Ok: true}, + GroupIDs: []string{"G123", "G456"}, + }) + + _, _ = rw.Write(response) + } +} diff --git a/admin_conversations_test.go b/admin_conversations_test.go new file mode 100644 index 000000000..1727aebba --- /dev/null +++ b/admin_conversations_test.go @@ -0,0 +1,581 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminConversationsInvite(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.invite", mockAdminInviteHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsInvite(context.Background(), AdminConversationsInviteParams{ + ChannelID: "C1234567890", + UserIDs: []string{"U123", "U456"}, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsSetTeams(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.setTeams", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + orgChannel := true + teamID := "T789" + + err := api.AdminConversationsSetTeams(context.Background(), AdminConversationsSetTeamsParams{ + ChannelID: "C1234567890", + OrgChannel: &orgChannel, + TargetTeamIDs: []string{"T123", "T456"}, + TeamID: &teamID, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsConvertToPrivate(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.convertToPrivate", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsConvertToPrivate(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsConvertToPublic(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.convertToPublic", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsConvertToPublic(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsArchive(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.archive", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsArchive(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsUnarchive(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.unarchive", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsUnarchive(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsRename(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.rename", mockAdminRenameHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRename(context.Background(), "C1234567890", "new-channel-name") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsDelete(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.delete", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsDelete(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsCreate(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.create", mockAdminCreateHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + channelID, err := api.AdminConversationsCreate(context.Background(), "test-channel", true, + AdminConversationsCreateOptionDescription("A test channel"), + AdminConversationsCreateOptionTeamID("T123"), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if channelID != "C1234567890" { + t.Errorf("unexpected channel_id: %s", channelID) + return + } +} + +func TestAdminConversationsGetTeams(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.getTeams", mockAdminGetTeamsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + teamIDs, cursor, err := api.AdminConversationsGetTeams(context.Background(), AdminConversationsGetTeamsParams{ + ChannelID: "C1234567890", + Limit: 100, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(teamIDs) != 2 { + t.Errorf("unexpected team count: %d", len(teamIDs)) + return + } + + if cursor != "next_cursor_value" { + t.Errorf("unexpected cursor: %s", cursor) + return + } +} + +func TestAdminConversationsSearch(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.search", mockAdminSearchHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + response, err := api.AdminConversationsSearch(context.Background(), + AdminConversationsSearchOptionQuery("test"), + AdminConversationsSearchOptionLimit(100), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(response.Conversations) != 1 { + t.Errorf("unexpected conversation count: %d", len(response.Conversations)) + return + } + + if response.Conversations[0].ID != "C1234567890" { + t.Errorf("unexpected conversation ID: %s", response.Conversations[0].ID) + return + } +} + +func TestAdminConversationsBulkArchive(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.bulkArchive", mockAdminChannelIDsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsBulkArchive(context.Background(), []string{"C123", "C456"}) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsBulkDelete(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.bulkDelete", mockAdminChannelIDsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsBulkDelete(context.Background(), []string{"C123", "C456"}) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsBulkMove(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.bulkMove", mockAdminBulkMoveHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsBulkMove(context.Background(), AdminConversationsBulkMoveParams{ + ChannelIDs: []string{"C123", "C456"}, + TargetTeamID: "T789", + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsGetConversationPrefs(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.getConversationPrefs", mockAdminGetConversationPrefsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + prefs, err := api.AdminConversationsGetConversationPrefs(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if prefs.WhoCanPost == nil || len(prefs.WhoCanPost.Type) != 1 || prefs.WhoCanPost.Type[0] != "admin" { + t.Errorf("unexpected prefs: %+v", prefs) + return + } +} + +func TestAdminConversationsSetCustomRetention(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.setCustomRetention", mockAdminRetentionHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsSetCustomRetention(context.Background(), "C1234567890", 90) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsRemoveCustomRetention(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.removeCustomRetention", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsRemoveCustomRetention(context.Background(), "C1234567890") + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +func TestAdminConversationsDisconnectShared(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.conversations.disconnectShared", mockAdminChannelIDHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminConversationsDisconnectShared(context.Background(), "C1234567890", + AdminConversationsDisconnectSharedOptionLeavingTeamIDs([]string{"T123", "T456"}), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } +} + +// mockAdminChannelIDHandler returns a handler which expects a channel_id to be present +// in the request, and will fail the test if it isn't present. +func mockAdminChannelIDHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminInviteHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form["user_ids"]) == 0 { + t.Error("missing user_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminRenameHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form["name"]) == 0 { + t.Error("missing name in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminCreateHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["name"]) == 0 { + t.Error("missing name in request") + return + } + + if len(r.Form["is_private"]) == 0 { + t.Error("missing is_private in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsCreateResponse{ + SlackResponse: SlackResponse{Ok: true}, + ChannelID: "C1234567890", + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminGetTeamsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsGetTeamsResponse{ + SlackResponse: SlackResponse{ + Ok: true, + ResponseMetadata: ResponseMetadata{Cursor: "next_cursor_value"}, + }, + TeamIDs: []string{"T123", "T456"}, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminSearchHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsSearchResponse{ + SlackResponse: SlackResponse{Ok: true}, + Conversations: []AdminConversation{ + { + ID: "C1234567890", + Name: "test-channel", + IsPrivate: false, + MemberCount: 10, + }, + }, + TotalCount: 1, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminChannelIDsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_ids"]) == 0 { + t.Error("missing channel_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminBulkMoveHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_ids"]) == 0 { + t.Error("missing channel_ids in request") + return + } + + if len(r.Form["target_team_id"]) == 0 { + t.Error("missing target_team_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminGetConversationPrefsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminConversationsGetConversationPrefsResponse{ + SlackResponse: SlackResponse{Ok: true}, + Prefs: AdminConversationPrefs{ + WhoCanPost: &AdminConversationPref{Type: []string{"admin"}}, + }, + }) + + _, _ = rw.Write(response) + } +} + +func mockAdminRetentionHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["channel_id"]) == 0 { + t.Error("missing channel_id in request") + return + } + + if len(r.Form["duration_days"]) == 0 { + t.Error("missing duration_days in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(SlackResponse{ + Ok: true, + }) + + _, _ = rw.Write(response) + } +} diff --git a/admin_roles.go b/admin_roles.go new file mode 100644 index 000000000..676acf0bd --- /dev/null +++ b/admin_roles.go @@ -0,0 +1,204 @@ +package slack + +import ( + "context" + "net/url" + "strconv" + "strings" +) + +// AdminRolesAddAssignmentsParams contains arguments for AdminRolesAddAssignments method call. +type AdminRolesAddAssignmentsParams struct { + RoleID string + EntityIDs []string + UserIDs []string +} + +// AdminRolesRejectedUser represents a user that could not be assigned a role. +type AdminRolesRejectedUser struct { + ID string `json:"id"` + Error string `json:"error"` +} + +// AdminRolesRejectedEntity represents an entity that could not be assigned a role. +type AdminRolesRejectedEntity struct { + ID string `json:"id"` + Error string `json:"error"` +} + +// AdminRolesAddAssignmentsResponse represents the response from admin.roles.addAssignments. +type AdminRolesAddAssignmentsResponse struct { + SlackResponse + RejectedUsers []AdminRolesRejectedUser `json:"rejected_users"` + RejectedEntities []AdminRolesRejectedEntity `json:"rejected_entities"` +} + +// AdminRolesAddAssignments adds members to a specified role. +// For more information see the admin.roles.addAssignments docs: +// https://api.slack.com/methods/admin.roles.addAssignments +func (api *Client) AdminRolesAddAssignments(ctx context.Context, params AdminRolesAddAssignmentsParams) (*AdminRolesAddAssignmentsResponse, error) { + values := url.Values{ + "token": {api.token}, + "role_id": {params.RoleID}, + } + + if len(params.EntityIDs) > 0 { + values.Add("entity_ids", strings.Join(params.EntityIDs, ",")) + } + + if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) + } + + response := &AdminRolesAddAssignmentsResponse{} + err := api.postMethod(ctx, "admin.roles.addAssignments", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +type adminRolesListAssignmentsParams struct { + roleIDs []string + entityIDs []string + limit int + cursor string + sortDirection string +} + +// AdminRolesListAssignmentsOption is an option for AdminRolesListAssignments. +type AdminRolesListAssignmentsOption func(*adminRolesListAssignmentsParams) + +// AdminRolesListAssignmentsOptionRoleIDs filters results to the specified role IDs. +func AdminRolesListAssignmentsOptionRoleIDs(roleIDs []string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.roleIDs = roleIDs + } +} + +// AdminRolesListAssignmentsOptionEntityIDs filters results to the specified entity IDs. +func AdminRolesListAssignmentsOptionEntityIDs(entityIDs []string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.entityIDs = entityIDs + } +} + +// AdminRolesListAssignmentsOptionLimit sets the maximum number of results to return. +func AdminRolesListAssignmentsOptionLimit(limit int) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.limit = limit + } +} + +// AdminRolesListAssignmentsOptionCursor sets the cursor for pagination. +func AdminRolesListAssignmentsOptionCursor(cursor string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.cursor = cursor + } +} + +// AdminRolesListAssignmentsOptionSortDir sets the sort direction. +// Valid values: "asc", "desc". +func AdminRolesListAssignmentsOptionSortDir(sortDir string) AdminRolesListAssignmentsOption { + return func(params *adminRolesListAssignmentsParams) { + params.sortDirection = sortDir + } +} + +// RoleAssignment represents a single role assignment. +type RoleAssignment struct { + RoleID string `json:"role_id"` + EntityID string `json:"entity_id,omitempty"` + UserID string `json:"user_id,omitempty"` + DateCreate int64 `json:"date_create,omitempty"` +} + +// AdminRolesListAssignmentsResponse represents the response from admin.roles.listAssignments. +type AdminRolesListAssignmentsResponse struct { + SlackResponse + RoleAssignments []RoleAssignment `json:"role_assignments"` + ResponseMetadata ResponseMetadata `json:"response_metadata"` +} + +// AdminRolesListAssignments lists assignments for roles. +// For more information see the admin.roles.listAssignments docs: +// https://api.slack.com/methods/admin.roles.listAssignments +func (api *Client) AdminRolesListAssignments(ctx context.Context, options ...AdminRolesListAssignmentsOption) (*AdminRolesListAssignmentsResponse, error) { + params := adminRolesListAssignmentsParams{} + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + } + + if len(params.roleIDs) > 0 { + values.Add("role_ids", strings.Join(params.roleIDs, ",")) + } + + if len(params.entityIDs) > 0 { + values.Add("entity_ids", strings.Join(params.entityIDs, ",")) + } + + if params.limit > 0 { + values.Add("limit", strconv.Itoa(params.limit)) + } + + if params.cursor != "" { + values.Add("cursor", params.cursor) + } + + if params.sortDirection != "" { + values.Add("sort_dir", params.sortDirection) + } + + response := &AdminRolesListAssignmentsResponse{} + err := api.postMethod(ctx, "admin.roles.listAssignments", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// AdminRolesRemoveAssignmentsParams contains arguments for AdminRolesRemoveAssignments method call. +type AdminRolesRemoveAssignmentsParams struct { + RoleID string + EntityIDs []string + UserIDs []string +} + +// AdminRolesRemoveAssignmentsResponse represents the response from admin.roles.removeAssignments. +type AdminRolesRemoveAssignmentsResponse struct { + SlackResponse + RejectedUsers []AdminRolesRejectedUser `json:"rejected_users"` + RejectedEntities []AdminRolesRejectedEntity `json:"rejected_entities"` +} + +// AdminRolesRemoveAssignments removes members from a specified role. +// For more information see the admin.roles.removeAssignments docs: +// https://api.slack.com/methods/admin.roles.removeAssignments +func (api *Client) AdminRolesRemoveAssignments(ctx context.Context, params AdminRolesRemoveAssignmentsParams) (*AdminRolesRemoveAssignmentsResponse, error) { + values := url.Values{ + "token": {api.token}, + "role_id": {params.RoleID}, + } + + if len(params.EntityIDs) > 0 { + values.Add("entity_ids", strings.Join(params.EntityIDs, ",")) + } + + if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) + } + + response := &AdminRolesRemoveAssignmentsResponse{} + err := api.postMethod(ctx, "admin.roles.removeAssignments", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} diff --git a/admin_roles_test.go b/admin_roles_test.go new file mode 100644 index 000000000..82787623e --- /dev/null +++ b/admin_roles_test.go @@ -0,0 +1,164 @@ +package slack + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminRolesAddAssignments(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.roles.addAssignments", mockAdminRolesAddAssignmentsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.AdminRolesAddAssignments(context.Background(), AdminRolesAddAssignmentsParams{ + RoleID: "Rl0L", + UserIDs: []string{"U123", "U456"}, + EntityIDs: []string{"E123"}, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if !resp.Ok { + t.Errorf("expected Ok to be true") + } +} + +func mockAdminRolesAddAssignmentsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["role_id"]) == 0 { + t.Error("missing role_id in request") + return + } + + if len(r.Form["user_ids"]) == 0 && len(r.Form["entity_ids"]) == 0 { + t.Error("missing user_ids or entity_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminRolesAddAssignmentsResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + + _, _ = rw.Write(response) + } +} + +func TestAdminRolesListAssignments(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.roles.listAssignments", mockAdminRolesListAssignmentsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.AdminRolesListAssignments(context.Background(), + AdminRolesListAssignmentsOptionRoleIDs([]string{"Rl0L"}), + AdminRolesListAssignmentsOptionLimit(10), + ) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if !resp.Ok { + t.Errorf("expected Ok to be true") + } + + if len(resp.RoleAssignments) != 1 { + t.Errorf("expected 1 role assignment, got %d", len(resp.RoleAssignments)) + } + + if resp.RoleAssignments[0].RoleID != "Rl0L" { + t.Errorf("expected role ID Rl0L, got %s", resp.RoleAssignments[0].RoleID) + } +} + +func mockAdminRolesListAssignmentsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminRolesListAssignmentsResponse{ + SlackResponse: SlackResponse{Ok: true}, + RoleAssignments: []RoleAssignment{ + { + RoleID: "Rl0L", + UserID: "U123", + }, + }, + }) + + _, _ = rw.Write(response) + } +} + +func TestAdminRolesRemoveAssignments(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/admin.roles.removeAssignments", mockAdminRolesRemoveAssignmentsHandler(t)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.AdminRolesRemoveAssignments(context.Background(), AdminRolesRemoveAssignmentsParams{ + RoleID: "Rl0L", + UserIDs: []string{"U123", "U456"}, + EntityIDs: []string{"E123"}, + }) + if err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if !resp.Ok { + t.Errorf("expected Ok to be true") + } +} + +func mockAdminRolesRemoveAssignmentsHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + if err := r.ParseForm(); err != nil { + t.Errorf("unexpected error: %s", err) + return + } + + if len(r.Form["role_id"]) == 0 { + t.Error("missing role_id in request") + return + } + + if len(r.Form["user_ids"]) == 0 && len(r.Form["entity_ids"]) == 0 { + t.Error("missing user_ids or entity_ids in request") + return + } + + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(AdminRolesRemoveAssignmentsResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + + _, _ = rw.Write(response) + } +} diff --git a/admin_teams.go b/admin_teams.go new file mode 100644 index 000000000..17ae3df33 --- /dev/null +++ b/admin_teams.go @@ -0,0 +1,153 @@ +package slack + +import ( + "context" + "net/url" + "strings" +) + +// AdminTeamSettings contains workspace settings returned by admin.teams.settings.info. +type AdminTeamSettings struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Domain string `json:"domain"` + EmailDomain string `json:"email_domain"` + AvatarBaseURL string `json:"avatar_base_url"` + IsVerified bool `json:"is_verified"` + Icon TeamSettingsIcon `json:"icon"` + EnterpriseID string `json:"enterprise_id"` + EnterpriseName string `json:"enterprise_name"` + EnterpriseDomain string `json:"enterprise_domain"` + DefaultChannels []string `json:"default_channels"` +} + +// TeamSettingsIcon contains team icon URLs and a default flag. +type TeamSettingsIcon struct { + ImageDefault bool `json:"image_default"` + Image34 string `json:"image_34"` + Image44 string `json:"image_44"` + Image68 string `json:"image_68"` + Image88 string `json:"image_88"` + Image102 string `json:"image_102"` + Image132 string `json:"image_132"` + Image230 string `json:"image_230"` +} + +// TeamDiscoverability represents the discoverability setting for a workspace. +type TeamDiscoverability string + +const ( + TeamDiscoverabilityOpen TeamDiscoverability = "open" + TeamDiscoverabilityInviteOnly TeamDiscoverability = "invite_only" + TeamDiscoverabilityClosed TeamDiscoverability = "closed" + TeamDiscoverabilityUnlisted TeamDiscoverability = "unlisted" +) + +type adminTeamSettingsInfoResponse struct { + Team AdminTeamSettings `json:"team"` + SlackResponse +} + +// AdminTeamsSettingsInfo returns workspace settings. +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.info +func (api *Client) AdminTeamsSettingsInfo(ctx context.Context, teamID string) (*AdminTeamSettings, error) { + values := url.Values{ + "token": {api.token}, + "team_id": {teamID}, + } + + response := &adminTeamSettingsInfoResponse{} + err := api.postMethod(ctx, "admin.teams.settings.info", values, response) + if err != nil { + return nil, err + } + + return &response.Team, response.Err() +} + +// AdminTeamsSettingsSetDefaultChannels sets the default channels for a workspace. +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setDefaultChannels +func (api *Client) AdminTeamsSettingsSetDefaultChannels(ctx context.Context, teamID string, channelIDs ...string) error { + values := url.Values{ + "token": {api.token}, + "team_id": {teamID}, + "channel_ids": {strings.Join(channelIDs, ",")}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.teams.settings.setDefaultChannels", values, response) + if err != nil { + return err + } + return response.Err() +} + +// AdminTeamsSettingsSetDescription sets the description for a workspace. +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setDescription +func (api *Client) AdminTeamsSettingsSetDescription(ctx context.Context, teamID, description string) error { + values := url.Values{ + "token": {api.token}, + "team_id": {teamID}, + "description": {description}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.teams.settings.setDescription", values, response) + if err != nil { + return err + } + return response.Err() +} + +// AdminTeamsSettingsSetDiscoverability sets the discoverability for a workspace. +// The discoverability parameter must be one of: open, invite_only, closed, or unlisted. +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setDiscoverability +func (api *Client) AdminTeamsSettingsSetDiscoverability(ctx context.Context, teamID string, discoverability TeamDiscoverability) error { + values := url.Values{ + "token": {api.token}, + "team_id": {teamID}, + "discoverability": {string(discoverability)}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.teams.settings.setDiscoverability", values, response) + if err != nil { + return err + } + return response.Err() +} + +// AdminTeamsSettingsSetIcon sets the icon for a workspace. +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setIcon +func (api *Client) AdminTeamsSettingsSetIcon(ctx context.Context, teamID, imageURL string) error { + values := url.Values{ + "token": {api.token}, + "team_id": {teamID}, + "image_url": {imageURL}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.teams.settings.setIcon", values, response) + if err != nil { + return err + } + return response.Err() +} + +// AdminTeamsSettingsSetName sets the name for a workspace. +// Slack API docs: https://docs.slack.dev/reference/methods/admin.teams.settings.setName +func (api *Client) AdminTeamsSettingsSetName(ctx context.Context, teamID, name string) error { + values := url.Values{ + "token": {api.token}, + "team_id": {teamID}, + "name": {name}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "admin.teams.settings.setName", values, response) + if err != nil { + return err + } + return response.Err() +} diff --git a/admin_teams_test.go b/admin_teams_test.go new file mode 100644 index 000000000..4a545d842 --- /dev/null +++ b/admin_teams_test.go @@ -0,0 +1,119 @@ +package slack + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getAdminTeamsSettingsInfo(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + rw.Write([]byte(`{ + "ok": true, + "team": { + "id": "T12345", + "name": "Test Workspace", + "url": "https://test-workspace.slack.com/", + "domain": "test-workspace", + "email_domain": "example.com", + "avatar_base_url": "https://ca.slack-edge.com/", + "is_verified": false, + "icon": { + "image_default": true, + "image_34": "https://example.com/icon_34.png", + "image_44": "https://example.com/icon_44.png", + "image_68": "https://example.com/icon_68.png", + "image_88": "https://example.com/icon_88.png", + "image_102": "https://example.com/icon_102.png", + "image_132": "https://example.com/icon_132.png", + "image_230": "https://example.com/icon_230.png" + }, + "enterprise_id": "E12345", + "enterprise_name": "Test Enterprise", + "enterprise_domain": "test-enterprise", + "default_channels": ["C12345", "C67890"] + } + }`)) +} + +func TestAdminTeamsSettingsInfo(t *testing.T) { + http.HandleFunc("/admin.teams.settings.info", getAdminTeamsSettingsInfo) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + settings, err := api.AdminTeamsSettingsInfo(context.Background(), "T12345") + require.NoError(t, err) + + assert.Equal(t, "T12345", settings.ID) + assert.Equal(t, "Test Workspace", settings.Name) + assert.Equal(t, "https://test-workspace.slack.com/", settings.URL) + assert.Equal(t, "test-workspace", settings.Domain) + assert.Equal(t, "example.com", settings.EmailDomain) + assert.Equal(t, "https://ca.slack-edge.com/", settings.AvatarBaseURL) + assert.False(t, settings.IsVerified) + assert.Equal(t, "E12345", settings.EnterpriseID) + assert.Equal(t, "Test Enterprise", settings.EnterpriseName) + assert.Equal(t, "test-enterprise", settings.EnterpriseDomain) + assert.Equal(t, []string{"C12345", "C67890"}, settings.DefaultChannels) + assert.True(t, settings.Icon.ImageDefault) + assert.Equal(t, "https://example.com/icon_34.png", settings.Icon.Image34) +} + +func okHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + rw.Write([]byte(`{"ok": true}`)) +} + +func TestAdminTeamsSettingsSetDefaultChannels(t *testing.T) { + http.HandleFunc("/admin.teams.settings.setDefaultChannels", okHandler) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminTeamsSettingsSetDefaultChannels(context.Background(), "T12345", "C111", "C222") + require.NoError(t, err) +} + +func TestAdminTeamsSettingsSetDescription(t *testing.T) { + http.HandleFunc("/admin.teams.settings.setDescription", okHandler) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminTeamsSettingsSetDescription(context.Background(), "T12345", "A test workspace") + require.NoError(t, err) +} + +func TestAdminTeamsSettingsSetDiscoverability(t *testing.T) { + http.HandleFunc("/admin.teams.settings.setDiscoverability", okHandler) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminTeamsSettingsSetDiscoverability(context.Background(), "T12345", TeamDiscoverabilityInviteOnly) + require.NoError(t, err) +} + +func TestAdminTeamsSettingsSetIcon(t *testing.T) { + http.HandleFunc("/admin.teams.settings.setIcon", okHandler) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminTeamsSettingsSetIcon(context.Background(), "T12345", "https://example.com/icon.png") + require.NoError(t, err) +} + +func TestAdminTeamsSettingsSetName(t *testing.T) { + http.HandleFunc("/admin.teams.settings.setName", okHandler) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.AdminTeamsSettingsSetName(context.Background(), "T12345", "New Name") + require.NoError(t, err) +} diff --git a/apps.go b/apps.go index 10d429752..c75569fb6 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{} @@ -31,7 +35,7 @@ func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventCont "event_context": eventContext, }) - err := postJSON(ctx, api.httpclient, api.endpoint+"apps.event.authorizations.list", api.appLevelToken, request, &resp, api) + err := api.postJSONMethod(ctx, "apps.event.authorizations.list", api.appLevelToken, request, &resp) if err != nil { return nil, err @@ -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/apps_test.go b/apps_test.go index 6d5f6216c..1a2e23521 100644 --- a/apps_test.go +++ b/apps_test.go @@ -14,11 +14,12 @@ func TestListEventAuthorizations(t *testing.T) { authorizations, err := api.ListEventAuthorizations("1-message-T012345678-DR12345678") - if err != nil { + switch { + case err != nil: t.Errorf("Failed, but should have succeeded") - } else if len(authorizations) != 1 { + case len(authorizations) != 1: t.Errorf("Didn't get 1 authorization") - } else if authorizations[0].UserID != "U123456789" { + case authorizations[0].UserID != "U123456789": t.Errorf("User ID is wrong") } } diff --git a/assistant.go b/assistant.go new file mode 100644 index 000000000..fb82cb64a --- /dev/null +++ b/assistant.go @@ -0,0 +1,377 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "strings" +) + +// AssistantThreadSetStatusParameters are the parameters for AssistantThreadSetStatus +type AssistantThreadsSetStatusParameters struct { + ChannelID string `json:"channel_id"` + Status string `json:"status"` + ThreadTS string `json:"thread_ts"` + LoadingMessages []string `json:"loading_messages,omitempty"` + Username string `json:"username,omitempty"` + IconURL string `json:"icon_url,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` +} + +// AssistantThreadSetTitleParameters are the parameters for AssistantThreadSetTitle +type AssistantThreadsSetTitleParameters struct { + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Title string `json:"title"` +} + +// AssistantThreadSetSuggestedPromptsParameters are the parameters for AssistantThreadSetSuggestedPrompts +type AssistantThreadsSetSuggestedPromptsParameters struct { + Title string `json:"title"` + ChannelID string `json:"channel_id"` + ThreadTS string `json:"thread_ts"` + Prompts []AssistantThreadsPrompt `json:"prompts"` +} + +// AssistantThreadPrompt is a suggested prompt for a thread +type AssistantThreadsPrompt struct { + Title string `json:"title"` + Message string `json:"message"` +} + +// AssistantSearchContextParameters are the parameters for AssistantSearchContext +type AssistantSearchContextParameters struct { + Query string `json:"query"` + ActionToken string `json:"action_token,omitempty"` + ChannelTypes []string `json:"channel_types,omitempty"` + ContentTypes []string `json:"content_types,omitempty"` + ContextChannelID string `json:"context_channel_id,omitempty"` + Cursor string `json:"cursor,omitempty"` + IncludeBots bool `json:"include_bots,omitempty"` + Limit int `json:"limit,omitempty"` + IncludeDeletedUsers bool `json:"include_deleted_users,omitempty"` + Before int64 `json:"before,omitempty"` + After int64 `json:"after,omitempty"` + IncludeContextMessages bool `json:"include_context_messages,omitempty"` + Sort string `json:"sort,omitempty"` + SortDir string `json:"sort_dir,omitempty"` + IncludeMessageBlocks bool `json:"include_message_blocks,omitempty"` + Highlight bool `json:"highlight,omitempty"` + TermClauses []string `json:"term_clauses,omitempty"` + Modifiers string `json:"modifiers,omitempty"` + IncludeArchivedChannels bool `json:"include_archived_channels,omitempty"` + DisableSemanticSearch bool `json:"disable_semantic_search,omitempty"` +} + +// AssistantSearchContextMessage represents a search result message +type AssistantSearchContextMessage struct { + AuthorUserID string `json:"author_user_id"` + AuthorName string `json:"author_name,omitempty"` + TeamID string `json:"team_id"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name,omitempty"` + MessageTS string `json:"message_ts"` + Content string `json:"content"` + IsAuthorBot bool `json:"is_author_bot"` + Permalink string `json:"permalink"` + Blocks Blocks `json:"blocks,omitempty"` + ContextMessages *AssistantSearchContextMessageContext `json:"context_messages,omitempty"` +} + +// AssistantSearchContextMessageContext contains context messages surrounding a search result +type AssistantSearchContextMessageContext struct { + Before []AssistantSearchContextMessage `json:"before"` + After []AssistantSearchContextMessage `json:"after"` +} + +// AssistantSearchContextFile represents a search result file +type AssistantSearchContextFile struct { + UploaderUserID string `json:"uploader_user_id"` + AuthorUserID string `json:"author_user_id"` + AuthorName string `json:"author_name"` + TeamID string `json:"team_id"` + FileID string `json:"file_id"` + DateCreated int64 `json:"date_created"` + DateUpdated int64 `json:"date_updated"` + Title string `json:"title"` + FileType string `json:"file_type"` + Permalink string `json:"permalink"` + Content string `json:"content"` +} + +// AssistantSearchContextChannel represents a search result channel +type AssistantSearchContextChannel struct { + TeamID string `json:"team_id"` + CreatorUserID string `json:"creator_user_id"` + CreatorName string `json:"creator_name"` + DateCreated int64 `json:"date_created"` + DateUpdated int64 `json:"date_updated"` + Name string `json:"name"` + Topic string `json:"topic"` + Purpose string `json:"purpose"` + Permalink string `json:"permalink"` +} + +// AssistantSearchContextResults contains the search results +type AssistantSearchContextResults struct { + Messages []AssistantSearchContextMessage `json:"messages,omitempty"` + Files []AssistantSearchContextFile `json:"files,omitempty"` + Channels []AssistantSearchContextChannel `json:"channels,omitempty"` +} + +// AssistantSearchContextResponse is the response from assistant.search.context +type AssistantSearchContextResponse struct { + SlackResponse + Results AssistantSearchContextResults `json:"results"` + ResponseMetadata struct { + NextCursor string `json:"next_cursor"` + } `json:"response_metadata"` +} + +// AssistantThreadSetSuggestedPrompts sets the suggested prompts for a thread +func (p *AssistantThreadsSetSuggestedPromptsParameters) AddPrompt(title, message string) { + p.Prompts = append(p.Prompts, AssistantThreadsPrompt{ + Title: title, + Message: message, + }) +} + +// SetAssistantThreadsSugesstedPrompts sets the suggested prompts for a thread +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPrompts(params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + return api.SetAssistantThreadsSuggestedPromptsContext(context.Background(), params) +} + +// SetAssistantThreadSuggestedPromptsContext sets the suggested prompts for a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setSuggestedPrompts +func (api *Client) SetAssistantThreadsSuggestedPromptsContext(ctx context.Context, params AssistantThreadsSetSuggestedPromptsParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + values.Add("channel_id", params.ChannelID) + + if params.Title != "" { + values.Add("title", params.Title) + } + + // Send Prompts as JSON + prompts, err := json.Marshal(params.Prompts) + if err != nil { + return err + } + + values.Add("prompts", string(prompts)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setSuggestedPrompts", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadsStatus sets the status of a thread. +// This method accepts either the chat:write or assistant:write scope. +// Note: the assistant:write scope is being deprecated in favor of chat:write. +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatus(params AssistantThreadsSetStatusParameters) (err error) { + return api.SetAssistantThreadsStatusContext(context.Background(), params) +} + +// SetAssistantThreadsStatusContext sets the status of a thread with a custom context. +// This method accepts either the chat:write or assistant:write scope. +// Note: the assistant:write scope is being deprecated in favor of chat:write. +// @see https://api.slack.com/methods/assistant.threads.setStatus +func (api *Client) SetAssistantThreadsStatusContext(ctx context.Context, params AssistantThreadsSetStatusParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + values.Add("channel_id", params.ChannelID) + + // Always send the status parameter, if empty, it will clear any existing status + values.Add("status", params.Status) + + if len(params.LoadingMessages) > 0 { + values.Add("loading_messages", strings.Join(params.LoadingMessages, ",")) + } + + if params.Username != "" { + values.Add("username", params.Username) + } + + if params.IconURL != "" { + values.Add("icon_url", params.IconURL) + } + + if params.IconEmoji != "" { + values.Add("icon_emoji", params.IconEmoji) + } + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setStatus", values, &response) + if err != nil { + return + } + + return response.Err() +} + +// SetAssistantThreadsTitle sets the title of a thread +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitle(params AssistantThreadsSetTitleParameters) (err error) { + return api.SetAssistantThreadsTitleContext(context.Background(), params) +} + +// SetAssistantThreadsTitleContext sets the title of a thread with a custom context +// @see https://api.slack.com/methods/assistant.threads.setTitle +func (api *Client) SetAssistantThreadsTitleContext(ctx context.Context, params AssistantThreadsSetTitleParameters) (err error) { + + values := url.Values{ + "token": {api.token}, + } + + if params.ChannelID != "" { + values.Add("channel_id", params.ChannelID) + } + + if params.ThreadTS != "" { + values.Add("thread_ts", params.ThreadTS) + } + + if params.Title != "" { + values.Add("title", params.Title) + } + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "assistant.threads.setTitle", values, &response) + if err != nil { + return + } + + return response.Err() + +} + +// SearchAssistantContext searches messages across the Slack organization +// @see https://api.slack.com/methods/assistant.search.context +func (api *Client) SearchAssistantContext(params AssistantSearchContextParameters) (*AssistantSearchContextResponse, error) { + return api.SearchAssistantContextContext(context.Background(), params) +} + +// SearchAssistantContextContext searches messages across the Slack organization with a custom context +// @see https://api.slack.com/methods/assistant.search.context +func (api *Client) SearchAssistantContextContext(ctx context.Context, params AssistantSearchContextParameters) (*AssistantSearchContextResponse, error) { + values := url.Values{ + "token": {api.token}, + } + + values.Add("query", params.Query) + + if params.ActionToken != "" { + values.Add("action_token", params.ActionToken) + } + + if len(params.ChannelTypes) > 0 { + values.Add("channel_types", strings.Join(params.ChannelTypes, ",")) + } + + if len(params.ContentTypes) > 0 { + values.Add("content_types", strings.Join(params.ContentTypes, ",")) + } + + if params.ContextChannelID != "" { + values.Add("context_channel_id", params.ContextChannelID) + } + + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + + if params.IncludeBots { + values.Add("include_bots", "true") + } + + if params.Limit > 0 { + values.Add("limit", strconv.Itoa(params.Limit)) + } + + if params.IncludeDeletedUsers { + values.Add("include_deleted_users", "true") + } + + if params.Before > 0 { + values.Add("before", strconv.FormatInt(params.Before, 10)) + } + + if params.After > 0 { + values.Add("after", strconv.FormatInt(params.After, 10)) + } + + if params.IncludeContextMessages { + values.Add("include_context_messages", "true") + } + + if params.Sort != "" { + values.Add("sort", params.Sort) + } + + if params.SortDir != "" { + values.Add("sort_dir", params.SortDir) + } + + if params.IncludeMessageBlocks { + values.Add("include_message_blocks", "true") + } + + if params.Highlight { + values.Add("highlight", "true") + } + + if len(params.TermClauses) > 0 { + values.Add("term_clauses", strings.Join(params.TermClauses, ",")) + } + + if params.Modifiers != "" { + values.Add("modifiers", params.Modifiers) + } + + if params.IncludeArchivedChannels { + values.Add("include_archived_channels", "true") + } + + if params.DisableSemanticSearch { + values.Add("disable_semantic_search", "true") + } + + response := &AssistantSearchContextResponse{} + + err := api.postMethod(ctx, "assistant.search.context", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} diff --git a/assistant_test.go b/assistant_test.go new file mode 100644 index 000000000..39a9aabad --- /dev/null +++ b/assistant_test.go @@ -0,0 +1,334 @@ +package slack + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestAssistantThreadsSuggestedPrompts(t *testing.T) { + + http.HandleFunc("/assistant.threads.setSuggestedPrompts", okJSONHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetSuggestedPromptsParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + } + + params.AddPrompt("title1", "message1") + params.AddPrompt("title2", "message2") + + err := api.SetAssistantThreadsSuggestedPrompts(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func TestSetAssistantThreadsStatus(t *testing.T) { + + http.HandleFunc("/assistant.threads.setStatus", okJSONHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetStatusParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Status: "updated status", + LoadingMessages: []string{"updating status..."}, + Username: "custom name", + IconURL: "https://example.com/icon.png", + IconEmoji: ":thinking_face:", + } + + err := api.SetAssistantThreadsStatus(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func assistantThreadsTitleHandler(rw http.ResponseWriter, r *http.Request) { + + channelID := r.FormValue("channel_id") + threadTS := r.FormValue("thread_ts") + title := r.FormValue("title") + + rw.Header().Set("Content-Type", "application/json") + + if channelID != "" && threadTS != "" && title != "" { + + resp, _ := json.Marshal(&addBookmarkResponse{ + SlackResponse: SlackResponse{Ok: true}, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } + +} + +func TestSetAssistantThreadsTitle(t *testing.T) { + + http.HandleFunc("/assistant.threads.setTitle", assistantThreadsTitleHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantThreadsSetTitleParameters{ + ChannelID: "CXXXXXXXX", + ThreadTS: "1234567890.123456", + Title: "updated title", + } + + err := api.SetAssistantThreadsTitle(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + +} + +func assistantSearchContextHandler(rw http.ResponseWriter, r *http.Request) { + query := r.FormValue("query") + rw.Header().Set("Content-Type", "application/json") + + if query != "" { + resp, _ := json.Marshal(&AssistantSearchContextResponse{ + SlackResponse: SlackResponse{Ok: true}, + Results: AssistantSearchContextResults{ + Messages: []AssistantSearchContextMessage{ + { + AuthorUserID: "U1234567890", + TeamID: "T1234567890", + ChannelID: "C1234567890", + MessageTS: "1234567890.123456", + Content: "This is a test message", + IsAuthorBot: false, + Permalink: "https://example.slack.com/archives/C1234567890/p1234567890123456", + }, + }, + }, + ResponseMetadata: struct { + NextCursor string `json:"next_cursor"` + }{ + NextCursor: "next_cursor_value", + }, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "invalid_arguments" }`)) + } +} + +func TestSearchAssistantContext(t *testing.T) { + http.HandleFunc("/assistant.search.context", assistantSearchContextHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := AssistantSearchContextParameters{ + Query: "test query", + ActionToken: "test_action_token", + ChannelTypes: []string{"public_channel", "private_channel"}, + ContentTypes: []string{"messages"}, + ContextChannelID: "C1234567890", + Cursor: "cursor_value", + IncludeBots: true, + Limit: 10, + } + + response, err := api.SearchAssistantContext(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !response.Ok { + t.Fatalf("Expected Ok to be true") + } + + if len(response.Results.Messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(response.Results.Messages)) + } + + message := response.Results.Messages[0] + if message.AuthorUserID != "U1234567890" { + t.Fatalf("Expected AuthorUserID to be U1234567890, got %s", message.AuthorUserID) + } + + if message.TeamID != "T1234567890" { + t.Fatalf("Expected TeamID to be T1234567890, got %s", message.TeamID) + } + + if message.ChannelID != "C1234567890" { + t.Fatalf("Expected ChannelID to be C1234567890, got %s", message.ChannelID) + } + + if message.MessageTS != "1234567890.123456" { + t.Fatalf("Expected MessageTS to be '1234567890.123456', got %s", message.MessageTS) + } + + if message.Content != "This is a test message" { + t.Fatalf("Expected Content to be 'This is a test message', got %s", message.Content) + } + + if message.IsAuthorBot != false { + t.Fatalf("Expected IsAuthorBot to be false, got %v", message.IsAuthorBot) + } + + if response.ResponseMetadata.NextCursor != "next_cursor_value" { + t.Fatalf("Expected NextCursor to be 'next_cursor_value', got %s", response.ResponseMetadata.NextCursor) + } +} + +func TestSearchAssistantContextExpandedResponse(t *testing.T) { + raw := `{ + "ok": true, + "results": { + "messages": [ + { + "author_user_id": "U111", + "author_name": "Test User", + "team_id": "T111", + "channel_id": "C111", + "channel_name": "general", + "message_ts": "1234567890.123456", + "content": "Hello world", + "is_author_bot": false, + "permalink": "https://example.slack.com/archives/C111/p1234567890123456", + "context_messages": { + "before": [ + { + "author_user_id": "U222", + "team_id": "T111", + "channel_id": "C111", + "message_ts": "1234567889.000000", + "content": "Before message", + "is_author_bot": false, + "permalink": "https://example.slack.com/archives/C111/p1234567889000000" + } + ], + "after": [ + { + "author_user_id": "U333", + "team_id": "T111", + "channel_id": "C111", + "message_ts": "1234567891.000000", + "content": "After message", + "is_author_bot": true, + "permalink": "https://example.slack.com/archives/C111/p1234567891000000" + } + ] + } + } + ], + "files": [ + { + "uploader_user_id": "U111", + "author_user_id": "U111", + "author_name": "Test User", + "team_id": "T111", + "file_id": "F111", + "date_created": 1700000000, + "date_updated": 1700001000, + "title": "test.pdf", + "file_type": "pdf", + "permalink": "https://example.slack.com/files/U111/F111/test.pdf", + "content": "File content excerpt" + } + ], + "channels": [ + { + "team_id": "T111", + "creator_user_id": "U111", + "creator_name": "Test User", + "date_created": 1600000000, + "date_updated": 1700000000, + "name": "general", + "topic": "General discussion", + "purpose": "Company-wide announcements", + "permalink": "https://example.slack.com/archives/C111" + } + ] + }, + "response_metadata": { + "next_cursor": "cursor123" + } + }` + + var response AssistantSearchContextResponse + if err := json.Unmarshal([]byte(raw), &response); err != nil { + t.Fatalf("Unmarshal error: %s", err) + } + + if !response.Ok { + t.Fatalf("Expected Ok to be true") + } + + // Verify messages + if len(response.Results.Messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(response.Results.Messages)) + } + + msg := response.Results.Messages[0] + if msg.AuthorName != "Test User" { + t.Errorf("Expected AuthorName 'Test User', got %q", msg.AuthorName) + } + if msg.ChannelName != "general" { + t.Errorf("Expected ChannelName 'general', got %q", msg.ChannelName) + } + + // Verify context messages + if msg.ContextMessages == nil { + t.Fatal("Expected ContextMessages to be non-nil") + } + if len(msg.ContextMessages.Before) != 1 { + t.Fatalf("Expected 1 before context message, got %d", len(msg.ContextMessages.Before)) + } + if msg.ContextMessages.Before[0].Content != "Before message" { + t.Errorf("Expected before content 'Before message', got %q", msg.ContextMessages.Before[0].Content) + } + if len(msg.ContextMessages.After) != 1 { + t.Fatalf("Expected 1 after context message, got %d", len(msg.ContextMessages.After)) + } + if !msg.ContextMessages.After[0].IsAuthorBot { + t.Errorf("Expected after message IsAuthorBot true") + } + + // Verify files + if len(response.Results.Files) != 1 { + t.Fatalf("Expected 1 file, got %d", len(response.Results.Files)) + } + file := response.Results.Files[0] + if file.FileID != "F111" { + t.Errorf("Expected FileID 'F111', got %q", file.FileID) + } + if file.Title != "test.pdf" { + t.Errorf("Expected Title 'test.pdf', got %q", file.Title) + } + if file.FileType != "pdf" { + t.Errorf("Expected FileType 'pdf', got %q", file.FileType) + } + if file.DateCreated != 1700000000 { + t.Errorf("Expected DateCreated 1700000000, got %d", file.DateCreated) + } + + // Verify channels + if len(response.Results.Channels) != 1 { + t.Fatalf("Expected 1 channel, got %d", len(response.Results.Channels)) + } + ch := response.Results.Channels[0] + if ch.Name != "general" { + t.Errorf("Expected Name 'general', got %q", ch.Name) + } + if ch.Topic != "General discussion" { + t.Errorf("Expected Topic 'General discussion', got %q", ch.Topic) + } + if ch.Purpose != "Company-wide announcements" { + t.Errorf("Expected Purpose 'Company-wide announcements', got %q", ch.Purpose) + } + + // Verify cursor + if response.ResponseMetadata.NextCursor != "cursor123" { + t.Errorf("Expected NextCursor 'cursor123', got %q", response.ResponseMetadata.NextCursor) + } +} diff --git a/attachments.go b/attachments.go index f4eb9b932..9720b99e3 100644 --- a/attachments.go +++ b/attachments.go @@ -47,7 +47,8 @@ type AttachmentActionOptionGroup struct { } // AttachmentActionCallback is sent from Slack when a user clicks a button in an interactive message (aka AttachmentAction) -// DEPRECATED: use InteractionCallback +// +// Deprecated: use InteractionCallback type AttachmentActionCallback InteractionCallback // ConfirmationField are used to ask users to confirm actions @@ -77,8 +78,11 @@ type Attachment struct { Pretext string `json:"pretext,omitempty"` Text string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` - ThumbURL string `json:"thumb_url,omitempty"` + ImageURL string `json:"image_url,omitempty"` + ImageBytes int `json:"image_bytes,omitempty"` + ImageHeight int `json:"image_height,omitempty"` + ImageWidth int `json:"image_width,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` ServiceName string `json:"service_name,omitempty"` ServiceIcon string `json:"service_icon,omitempty"` diff --git a/attachments_test.go b/attachments_test.go index 4b951f110..68e0cdbf7 100644 --- a/attachments_test.go +++ b/attachments_test.go @@ -58,8 +58,8 @@ func TestAttachment_UnmarshalMarshalJSON_WithBlocks(t *testing.T) { } var ( - actual interface{} - expected interface{} + actual any + expected any ) if err = json.Unmarshal([]byte(originalAttachmentJson), &expected); err != nil { t.Fatal(err) diff --git a/audit.go b/audit.go index 041ffd77a..9ec39f73e 100644 --- a/audit.go +++ b/audit.go @@ -39,6 +39,16 @@ type AuditEntry struct { UA string `json:"ua"` IPAddress string `json:"ip_address"` } `json:"context"` + Details struct { + NewValue any `json:"new_value"` + PreviousValue any `json:"previous_value"` + MobileOnly bool `json:"mobile_only"` + WebOnly bool `json:"web_only"` + NonSSOOnly bool `json:"non_sso_only"` + ExportType string `json:"export_type"` + ExportStart string `json:"export_start_ts"` + ExportEnd string `json:"export_end_ts"` + } `json:"details"` } type AuditUser struct { @@ -97,14 +107,15 @@ type AuditLogParameters struct { func (api *Client) auditLogsRequest(ctx context.Context, path string, values url.Values) (*AuditLogResponse, error) { response := &AuditLogResponse{} - err := api.getMethod(ctx, path, api.token, values, response) + // The Audit Logs API uses a different base URL (api.slack.com instead of slack.com/api) + _, err := getResource(ctx, api.httpclient, api.auditEndpoint+path, api.token, values, response, api) if err != nil { return nil, err } return response, response.Err() } -// GetAuditLogs retrieves a page of audit entires according to the parameters given +// GetAuditLogs retrieves a page of audit entries according to the parameters given func (api *Client) GetAuditLogs(params AuditLogParameters) (entries []AuditEntry, nextCursor string, err error) { return api.GetAuditLogsContext(context.Background(), params) } diff --git a/audit_test.go b/audit_test.go index 631b631e2..d27f8a86f 100644 --- a/audit_test.go +++ b/audit_test.go @@ -48,7 +48,7 @@ func TestGetAuditLogs(t *testing.T) { http.HandleFunc("/audit/v1/logs", getAuditLogs) once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + api := New("testing-token", OptionAuditAPIURL("http://"+serverAddr+"/")) events, nextCursor, err := api.GetAuditLogs(AuditLogParameters{}) if err != nil { diff --git a/auth.go b/auth.go index f4f7f003a..972f59ea6 100644 --- a/auth.go +++ b/auth.go @@ -3,6 +3,7 @@ package slack import ( "context" "net/url" + "strconv" ) // AuthRevokeResponse contains our Auth response from the auth.revoke endpoint @@ -22,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 @@ -38,3 +41,42 @@ func (api *Client) SendAuthRevokeContext(ctx context.Context, token string) (*Au return api.authRequest(ctx, "auth.revoke", values) } + +type listTeamsResponse struct { + Teams []Team `json:"teams"` + SlackResponse +} + +type ListTeamsParameters struct { + Limit int + Cursor string + IncludeIcon *bool +} + +// ListTeams returns all workspaces a token can access. +// For more details, see ListTeamsContext documentation. +func (api *Client) ListTeams(params ListTeamsParameters) ([]Team, string, error) { + return api.ListTeamsContext(context.Background(), params) +} + +// 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}, + } + if params.Cursor != "" { + values.Add("cursor", params.Cursor) + } + if params.IncludeIcon != nil { + values.Add("include_icon", strconv.FormatBool(*params.IncludeIcon)) + } + + response := &listTeamsResponse{} + err := api.postMethod(ctx, "auth.teams.list", values, response) + if err != nil { + return nil, "", err + } + + return response.Teams, response.ResponseMetadata.Cursor, response.Err() +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 000000000..5fe9900eb --- /dev/null +++ b/auth_test.go @@ -0,0 +1,51 @@ +package slack + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func getTeamList(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": true, + "teams": [ + { + "name": "Shinichi's workspace", + "id": "T12345678" + }, + { + "name": "Migi's workspace", + "id": "T12345679" + } + ], + "response_metadata": { + "next_cursor": "dXNlcl9pZDo5MTQyOTI5Mzkz" + } +}`) + rw.Write(response) +} + +func TestListTeams(t *testing.T) { + http.HandleFunc("/auth.teams.list", getTeamList) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + teams, cursor, err := api.ListTeams(ListTeamsParameters{}) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + assert.Len(t, teams, 2) + assert.Equal(t, "T12345678", teams[0].ID) + assert.Equal(t, "Shinichi's workspace", teams[0].Name) + + assert.Equal(t, "T12345679", teams[1].ID) + assert.Equal(t, "Migi's workspace", teams[1].Name) + + assert.Equal(t, "dXNlcl9pZDo5MTQyOTI5Mzkz", cursor) +} diff --git a/block.go b/block.go index 240f55279..41c1cef4f 100644 --- a/block.go +++ b/block.go @@ -1,29 +1,39 @@ package slack -// @NOTE: Blocks are in beta and subject to change. - -// More Information: https://api.slack.com/block-kit - // MessageBlockType defines a named string type to define each block type // as a constant for use within the package. type MessageBlockType string const ( - MBTSection MessageBlockType = "section" - MBTDivider MessageBlockType = "divider" - MBTImage MessageBlockType = "image" - MBTAction MessageBlockType = "actions" - MBTContext MessageBlockType = "context" - MBTFile MessageBlockType = "file" - MBTInput MessageBlockType = "input" - MBTHeader MessageBlockType = "header" - MBTRichText MessageBlockType = "rich_text" + MBTSection MessageBlockType = "section" + MBTDivider MessageBlockType = "divider" + MBTImage MessageBlockType = "image" + MBTAction MessageBlockType = "actions" + MBTContext MessageBlockType = "context" + MBTContextActions MessageBlockType = "context_actions" + MBTFile MessageBlockType = "file" + MBTInput MessageBlockType = "input" + MBTHeader MessageBlockType = "header" + MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" + MBTMarkdown MessageBlockType = "markdown" + MBTTable MessageBlockType = "table" + MBTDataTable MessageBlockType = "data_table" + MBTDataVisualization MessageBlockType = "data_visualization" + MBTTaskCard MessageBlockType = "task_card" + MBTPlan MessageBlockType = "plan" + MBTAlert MessageBlockType = "alert" + MBTCard MessageBlockType = "card" + MBTCarousel MessageBlockType = "carousel" + MBTContainer MessageBlockType = "container" ) // Block defines an interface all block types should implement // to ensure consistency between blocks. type Block interface { BlockType() MessageBlockType + ID() string } // Blocks is a convenience struct defined to allow dynamic unmarshalling of @@ -39,6 +49,7 @@ type BlockAction struct { Type ActionType `json:"type"` Text TextBlockObject `json:"text"` Value string `json:"value"` + Files []File `json:"files"` ActionTs string `json:"action_ts"` SelectedOption OptionBlockObject `json:"selected_option"` SelectedOptions []OptionBlockObject `json:"selected_options"` @@ -50,12 +61,15 @@ type BlockAction struct { SelectedConversations []string `json:"selected_conversations"` SelectedDate string `json:"selected_date"` SelectedTime string `json:"selected_time"` + SelectedDateTime int64 `json:"selected_date_time"` + Timezone string `json:"timezone"` InitialOption OptionBlockObject `json:"initial_option"` InitialUser string `json:"initial_user"` InitialChannel string `json:"initial_channel"` InitialConversation string `json:"initial_conversation"` InitialDate string `json:"initial_date"` InitialTime string `json:"initial_time"` + RichTextValue RichTextBlock `json:"rich_text_value"` } // actionType returns the type of the action diff --git a/block_action.go b/block_action.go index c15e4a3f7..819c0ef0d 100644 --- a/block_action.go +++ b/block_action.go @@ -14,6 +14,11 @@ func (s ActionBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s ActionBlock) ID() string { + return s.BlockID +} + // NewActionBlock returns a new instance of an Action Block func NewActionBlock(blockID string, elements ...BlockElement) *ActionBlock { return &ActionBlock{ diff --git a/block_action_test.go b/block_action_test.go index 32bd0c2c8..bb22f45e0 100644 --- a/block_action_test.go +++ b/block_action_test.go @@ -7,13 +7,13 @@ import ( ) func TestNewActionBlock(t *testing.T) { - approveBtnTxt := NewTextBlockObject("plain_text", "Approve", false, false) approveBtn := NewButtonBlockElement("", "click_me_123", approveBtnTxt) - actionBlock := NewActionBlock("test", approveBtn) + + assert.Equal(t, actionBlock.BlockType(), MBTAction) assert.Equal(t, string(actionBlock.Type), "actions") assert.Equal(t, actionBlock.BlockID, "test") + assert.Equal(t, actionBlock.ID(), "test") assert.Equal(t, len(actionBlock.Elements.ElementSet), 1) - } diff --git a/block_alert.go b/block_alert.go new file mode 100644 index 000000000..ddb151b44 --- /dev/null +++ b/block_alert.go @@ -0,0 +1,70 @@ +package slack + +// AlertLevel defines the severity for an AlertBlock. +type AlertLevel string + +const ( + AlertLevelDefault AlertLevel = "default" + AlertLevelInfo AlertLevel = "info" + AlertLevelWarning AlertLevel = "warning" + AlertLevelError AlertLevel = "error" + AlertLevelSuccess AlertLevel = "success" +) + +// AlertBlock defines a block of type alert used to surface a notification +// message with an optional severity level. +// +// Surface: modal only. Slack rejects alert blocks sent via chat.postMessage +// or the streaming APIs — use OpenView / UpdateView / PushView with a +// ModalViewRequest whose Blocks include the alert. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/alert-block/ +type AlertBlock struct { + Type MessageBlockType `json:"type"` + Text *TextBlockObject `json:"text"` + Level AlertLevel `json:"level,omitempty"` + BlockID string `json:"block_id,omitempty"` +} + +// BlockType returns the type of the block +func (s AlertBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s AlertBlock) ID() string { + return s.BlockID +} + +// AlertBlockOption allows configuration of options for a new alert block +type AlertBlockOption func(*AlertBlock) + +// AlertBlockOptionLevel sets the severity level for the alert block +func AlertBlockOptionLevel(level AlertLevel) AlertBlockOption { + return func(block *AlertBlock) { + block.Level = level + } +} + +// AlertBlockOptionBlockID sets the block ID for the alert block +func AlertBlockOptionBlockID(blockID string) AlertBlockOption { + return func(block *AlertBlock) { + block.BlockID = blockID + } +} + +// NewAlertBlock returns a new instance of an alert block +func NewAlertBlock(text *TextBlockObject, options ...AlertBlockOption) *AlertBlock { + block := AlertBlock{ + Type: MBTAlert, + Text: text, + } + + for _, option := range options { + if option != nil { + option(&block) + } + } + + return &block +} diff --git a/block_alert_test.go b/block_alert_test.go new file mode 100644 index 000000000..c5673c2a1 --- /dev/null +++ b/block_alert_test.go @@ -0,0 +1,80 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAlertBlock(t *testing.T) { + text := NewTextBlockObject("mrkdwn", "The work is mysterious and important.", false, false) + block := NewAlertBlock(text, + AlertBlockOptionLevel(AlertLevelInfo), + AlertBlockOptionBlockID("alert-1"), + ) + + assert.Equal(t, MBTAlert, block.BlockType()) + assert.Equal(t, "alert", string(block.Type)) + assert.Equal(t, "alert-1", block.ID()) + assert.Equal(t, AlertLevelInfo, block.Level) + assert.Equal(t, text, block.Text) +} + +func TestNewAlertBlockWithNilOption(t *testing.T) { + text := NewTextBlockObject("plain_text", "hi", false, false) + assert.NotPanics(t, func() { + NewAlertBlock(text, nil) + }, "should not panic when nil option passed") +} + +func TestAlertBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "alert", + "text": { + "type": "mrkdwn", + "text": "The work is mysterious and important." + }, + "level": "info", + "block_id": "alert-1" + }` + + var block AlertBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTAlert, block.BlockType()) + assert.Equal(t, "alert-1", block.ID()) + assert.Equal(t, AlertLevelInfo, block.Level) + require.NotNil(t, block.Text) + assert.Equal(t, "mrkdwn", block.Text.Type) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + + assert.Equal(t, expected, actual) +} + +func TestAlertBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "alert", + "text": {"type": "plain_text", "text": "Heads up"}, + "level": "warning" + } + ]` + + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte(payload), &blocks)) + require.Len(t, blocks.BlockSet, 1) + + alert, ok := blocks.BlockSet[0].(*AlertBlock) + require.True(t, ok, "expected *AlertBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTAlert, alert.BlockType()) + assert.Equal(t, AlertLevelWarning, alert.Level) +} diff --git a/block_call.go b/block_call.go new file mode 100644 index 000000000..621a8b644 --- /dev/null +++ b/block_call.go @@ -0,0 +1,93 @@ +package slack + +// 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"` + BlockID string `json:"block_id,omitempty"` + CallID string `json:"call_id"` + // Call is populated by Slack when retrieving messages containing a call block. + // When creating a call block to post, only CallID is required. + // Note: The structure differs from the Call type used in API responses. + Call *CallBlockData `json:"call,omitempty"` + APIDecorationAvailable bool `json:"api_decoration_available,omitempty"` +} + +// CallBlockData represents the call data structure as it appears in CallBlocks. +// This differs from the Call type used in API responses - CallBlock data is nested under V1. +type CallBlockData struct { + V1 *CallBlockDataV1 `json:"v1,omitempty"` + MediaBackendType string `json:"media_backend_type,omitempty"` +} + +// CallBlockDataV1 contains the actual call information within a CallBlock. +type CallBlockDataV1 struct { + ID string `json:"id"` + AppID string `json:"app_id,omitempty"` + AppIconURLs *CallBlockIconURLs `json:"app_icon_urls,omitempty"` + DateStart int64 `json:"date_start"` + DateEnd int64 `json:"date_end"` + ActiveParticipants []CallParticipant `json:"active_participants,omitempty"` + AllParticipants []CallParticipant `json:"all_participants,omitempty"` + DisplayID string `json:"display_id,omitempty"` + JoinURL string `json:"join_url,omitempty"` + DesktopAppJoinURL string `json:"desktop_app_join_url,omitempty"` + Name string `json:"name,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Channels []string `json:"channels,omitempty"` + 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"` +} + +// CallBlockIconURLs contains app icon URLs at various sizes for a call integration. +type CallBlockIconURLs struct { + Image32 string `json:"image_32,omitempty"` + Image36 string `json:"image_36,omitempty"` + Image48 string `json:"image_48,omitempty"` + Image64 string `json:"image_64,omitempty"` + Image72 string `json:"image_72,omitempty"` + Image96 string `json:"image_96,omitempty"` + Image128 string `json:"image_128,omitempty"` + Image192 string `json:"image_192,omitempty"` + Image512 string `json:"image_512,omitempty"` + Image1024 string `json:"image_1024,omitempty"` + ImageOriginal string `json:"image_original,omitempty"` +} + +// BlockType returns the type of the block +func (s CallBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s CallBlock) ID() string { + return s.BlockID +} + +// CallBlockOption allows configuration of options for a new call block +type CallBlockOption func(*CallBlock) + +// CallBlockOptionBlockID sets the block_id for the call block +func CallBlockOptionBlockID(blockID string) CallBlockOption { + return func(block *CallBlock) { + block.BlockID = blockID + } +} + +// NewCallBlock returns a new instance of a call block +func NewCallBlock(callID string, options ...CallBlockOption) *CallBlock { + block := &CallBlock{ + Type: MBTCall, + CallID: callID, + } + + for _, option := range options { + option(block) + } + + return block +} diff --git a/block_call_test.go b/block_call_test.go new file mode 100644 index 000000000..8e92ce6fe --- /dev/null +++ b/block_call_test.go @@ -0,0 +1,235 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCallBlock(t *testing.T) { + callBlock := NewCallBlock("ACallID") + + assert.Equal(t, MBTCall, callBlock.BlockType()) + assert.Equal(t, "call", string(callBlock.Type)) + assert.Equal(t, "ACallID", callBlock.CallID) + assert.Equal(t, "", callBlock.BlockID) + assert.Equal(t, "", callBlock.ID()) +} + +func TestNewCallBlockWithBlockID(t *testing.T) { + callBlock := NewCallBlock("ACallID", CallBlockOptionBlockID("block-123")) + + assert.Equal(t, MBTCall, callBlock.BlockType()) + assert.Equal(t, "ACallID", callBlock.CallID) + assert.Equal(t, "block-123", callBlock.BlockID) + assert.Equal(t, "block-123", callBlock.ID()) +} + +func TestCallBlockJSONRoundTrip(t *testing.T) { + // Create block with call data using the V1 structure + callBlock := NewCallBlock("R123", CallBlockOptionBlockID("block-1")) + callBlock.Call = &CallBlockData{ + V1: &CallBlockDataV1{ + ID: "R123", + Name: "Team Standup", + JoinURL: "https://example.com/call", + }, + MediaBackendType: "platform_call", + } + + // Marshal to JSON + data, err := json.Marshal(callBlock) + require.NoError(t, err) + + // Verify expected JSON structure + var jsonMap map[string]any + err = json.Unmarshal(data, &jsonMap) + require.NoError(t, err) + assert.Equal(t, "call", jsonMap["type"]) + assert.Equal(t, "R123", jsonMap["call_id"]) + assert.Equal(t, "block-1", jsonMap["block_id"]) + + // Verify nested structure + callData, ok := jsonMap["call"].(map[string]any) + require.True(t, ok, "call should be a map") + assert.Equal(t, "platform_call", callData["media_backend_type"]) + v1Data, ok := callData["v1"].(map[string]any) + require.True(t, ok, "v1 should be a map") + assert.Equal(t, "R123", v1Data["id"]) + + // Unmarshal back + var parsed CallBlock + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, MBTCall, parsed.Type) + assert.Equal(t, callBlock.CallID, parsed.CallID) + assert.Equal(t, callBlock.BlockID, parsed.BlockID) + require.NotNil(t, parsed.Call.V1) + assert.Equal(t, callBlock.Call.V1.ID, parsed.Call.V1.ID) + assert.Equal(t, callBlock.Call.V1.Name, parsed.Call.V1.Name) + assert.Equal(t, callBlock.Call.V1.JoinURL, parsed.Call.V1.JoinURL) + assert.Equal(t, callBlock.Call.MediaBackendType, parsed.Call.MediaBackendType) +} + +func TestCallBlockInBlocks(t *testing.T) { + // Test that call block can be unmarshalled as part of a Blocks collection + // Using the actual Slack structure with v1 wrapper + jsonData := []byte(`[ + { + "type": "call", + "block_id": "call-block-1", + "call_id": "R123456", + "api_decoration_available": false, + "call": { + "v1": { + "id": "R123456", + "name": "Team Standup", + "join_url": "https://example.com/join/123", + "desktop_app_join_url": "slack://call/123", + "date_start": 1769457524, + "date_end": 0, + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false + }, + "media_backend_type": "platform_call" + } + } + ]`) + + var blocks Blocks + err := json.Unmarshal(jsonData, &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + assert.Equal(t, MBTCall, blocks.BlockSet[0].BlockType()) + assert.Equal(t, "call-block-1", blocks.BlockSet[0].ID()) + + callBlock, ok := blocks.BlockSet[0].(*CallBlock) + require.True(t, ok, "expected *CallBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, "R123456", callBlock.CallID) + assert.False(t, callBlock.APIDecorationAvailable) + assert.Equal(t, "platform_call", callBlock.Call.MediaBackendType) + require.NotNil(t, callBlock.Call.V1) + assert.Equal(t, "R123456", callBlock.Call.V1.ID) + assert.Equal(t, "Team Standup", callBlock.Call.V1.Name) + assert.Equal(t, "https://example.com/join/123", callBlock.Call.V1.JoinURL) + assert.Equal(t, "slack://call/123", callBlock.Call.V1.DesktopAppJoinURL) + assert.Equal(t, int64(1769457524), callBlock.Call.V1.DateStart) + assert.False(t, callBlock.Call.V1.HasEnded) +} + +func TestCallBlockWithParticipants(t *testing.T) { + jsonData := []byte(`{ + "type": "call", + "call_id": "R789", + "call": { + "v1": { + "id": "R789", + "name": "Design Review", + "date_start": 0, + "date_end": 0, + "active_participants": [ + {"slack_id": "U123", "display_name": "Alice"}, + {"slack_id": "U456", "display_name": "Bob"} + ], + "all_participants": [ + {"slack_id": "U123", "display_name": "Alice"}, + {"slack_id": "U456", "display_name": "Bob"}, + {"slack_id": "U789", "display_name": "Charlie"} + ], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false + } + } + }`) + + var callBlock CallBlock + err := json.Unmarshal(jsonData, &callBlock) + require.NoError(t, err) + + assert.Equal(t, "R789", callBlock.CallID) + require.NotNil(t, callBlock.Call.V1) + assert.Equal(t, "Design Review", callBlock.Call.V1.Name) + require.Len(t, callBlock.Call.V1.ActiveParticipants, 2) + assert.Equal(t, "U123", callBlock.Call.V1.ActiveParticipants[0].SlackID) + assert.Equal(t, "Alice", callBlock.Call.V1.ActiveParticipants[0].DisplayName) + assert.Equal(t, "U456", callBlock.Call.V1.ActiveParticipants[1].SlackID) + assert.Equal(t, "Bob", callBlock.Call.V1.ActiveParticipants[1].DisplayName) + + require.Len(t, callBlock.Call.V1.AllParticipants, 3) + assert.Equal(t, "U789", callBlock.Call.V1.AllParticipants[2].SlackID) + assert.Equal(t, "Charlie", callBlock.Call.V1.AllParticipants[2].DisplayName) +} + +func TestCallBlockWithAppIcons(t *testing.T) { + // Test parsing of app icon URLs as seen in real Zoom integration + jsonData := []byte(`{ + "type": "call", + "call_id": "R0ABF31RWGH", + "block_id": "+cgoe", + "api_decoration_available": false, + "call": { + "v1": { + "id": "R0ABF31RWGH", + "app_id": "A5GE9BMQC", + "app_icon_urls": { + "image_32": "https://example.com/icon_32.png", + "image_48": "https://example.com/icon_48.png", + "image_72": "https://example.com/icon_72.png", + "image_192": "https://example.com/icon_192.png" + }, + "date_start": 1769457524, + "date_end": 0, + "display_id": "863-5835-0956", + "join_url": "https://zoom.us/j/123", + "desktop_app_join_url": "zoommtg://zoom.us/join?confno=123", + "name": "Zoom meeting started by user", + "created_by": "U0ABF1CJPG9", + "channels": ["C0AACTVQ2EB"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false + }, + "media_backend_type": "platform_call" + } + }`) + + var callBlock CallBlock + err := json.Unmarshal(jsonData, &callBlock) + require.NoError(t, err) + + assert.Equal(t, "R0ABF31RWGH", callBlock.CallID) + assert.Equal(t, "+cgoe", callBlock.BlockID) + assert.False(t, callBlock.APIDecorationAvailable) + assert.Equal(t, "platform_call", callBlock.Call.MediaBackendType) + + require.NotNil(t, callBlock.Call.V1) + v1 := callBlock.Call.V1 + assert.Equal(t, "R0ABF31RWGH", v1.ID) + assert.Equal(t, "A5GE9BMQC", v1.AppID) + assert.Equal(t, "863-5835-0956", v1.DisplayID) + assert.Equal(t, "Zoom meeting started by user", v1.Name) + assert.Equal(t, "U0ABF1CJPG9", v1.CreatedBy) + assert.Equal(t, int64(1769457524), v1.DateStart) + assert.Equal(t, int64(0), v1.DateEnd) + assert.False(t, v1.HasEnded) + require.Len(t, v1.Channels, 1) + assert.Equal(t, "C0AACTVQ2EB", v1.Channels[0]) + + require.NotNil(t, v1.AppIconURLs) + assert.Equal(t, "https://example.com/icon_32.png", v1.AppIconURLs.Image32) + assert.Equal(t, "https://example.com/icon_48.png", v1.AppIconURLs.Image48) + assert.Equal(t, "https://example.com/icon_72.png", v1.AppIconURLs.Image72) + assert.Equal(t, "https://example.com/icon_192.png", v1.AppIconURLs.Image192) +} diff --git a/block_card.go b/block_card.go new file mode 100644 index 000000000..06b03ccf0 --- /dev/null +++ b/block_card.go @@ -0,0 +1,109 @@ +package slack + +// CardBlock defines a block of type card used to display a rich, self-contained +// piece of content with an optional hero image, icon (or Slack icon), title, +// subtitle, body, subtext, and action buttons. Cards can stand alone or be +// grouped inside a CarouselBlock. +// +// Only one of Icon and SlackIcon can be set, as they render in the same +// location. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/card-block/ +type CardBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + HeroImage *ImageBlockElement `json:"hero_image,omitempty"` + Icon *ImageBlockElement `json:"icon,omitempty"` + SlackIcon *SlackIconObject `json:"slack_icon,omitempty"` + Title *TextBlockObject `json:"title,omitempty"` + Subtitle *TextBlockObject `json:"subtitle,omitempty"` + Body *TextBlockObject `json:"body,omitempty"` + Subtext *TextBlockObject `json:"subtext,omitempty"` + Actions *BlockElements `json:"actions,omitempty"` +} + +// BlockType returns the type of the block +func (s CardBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s CardBlock) ID() string { + return s.BlockID +} + +// CardBlockOption allows configuration of options for a new card block +type CardBlockOption func(*CardBlock) + +// CardBlockOptionBlockID sets the block ID for the card block +func CardBlockOptionBlockID(blockID string) CardBlockOption { + return func(block *CardBlock) { + block.BlockID = blockID + } +} + +// NewCardBlock returns a new instance of a card block. Use the chainable +// With* methods or provide options to populate its fields. +func NewCardBlock(options ...CardBlockOption) *CardBlock { + block := CardBlock{ + Type: MBTCard, + } + + for _, option := range options { + if option != nil { + option(&block) + } + } + + return &block +} + +// WithTitle sets the title text for the CardBlock +func (s *CardBlock) WithTitle(title *TextBlockObject) *CardBlock { + s.Title = title + return s +} + +// WithSubtitle sets the subtitle text for the CardBlock +func (s *CardBlock) WithSubtitle(subtitle *TextBlockObject) *CardBlock { + s.Subtitle = subtitle + return s +} + +// WithBody sets the body text for the CardBlock +func (s *CardBlock) WithBody(body *TextBlockObject) *CardBlock { + s.Body = body + return s +} + +// WithSubtext sets the subtext displayed below the body of the CardBlock +func (s *CardBlock) WithSubtext(subtext *TextBlockObject) *CardBlock { + s.Subtext = subtext + return s +} + +// WithIcon sets the icon image for the CardBlock. It is mutually exclusive with +// SlackIcon, as both render in the same location. +func (s *CardBlock) WithIcon(icon *ImageBlockElement) *CardBlock { + s.Icon = icon + return s +} + +// WithSlackIcon sets the built-in Slack icon for the CardBlock. It is mutually +// exclusive with Icon, as both render in the same location. +func (s *CardBlock) WithSlackIcon(slackIcon *SlackIconObject) *CardBlock { + s.SlackIcon = slackIcon + return s +} + +// WithHeroImage sets the hero image for the CardBlock +func (s *CardBlock) WithHeroImage(heroImage *ImageBlockElement) *CardBlock { + s.HeroImage = heroImage + return s +} + +// WithActions sets the action buttons displayed at the bottom of the card +func (s *CardBlock) WithActions(elements ...BlockElement) *CardBlock { + s.Actions = &BlockElements{ElementSet: elements} + return s +} diff --git a/block_card_test.go b/block_card_test.go new file mode 100644 index 000000000..a98dff6e4 --- /dev/null +++ b/block_card_test.go @@ -0,0 +1,159 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCardBlock(t *testing.T) { + title := NewTextBlockObject("mrkdwn", "Card title", false, false) + subtitle := NewTextBlockObject("mrkdwn", "Card subtitle", false, false) + body := NewTextBlockObject("mrkdwn", "Card body text.", false, false) + subtext := NewTextBlockObject("mrkdwn", "Card subtext.", false, false) + + iconURL := "https://example.com/icon.png" + icon := &ImageBlockElement{Type: METImage, ImageURL: &iconURL, AltText: "icon"} + heroURL := "https://example.com/hero.png" + hero := &ImageBlockElement{Type: METImage, ImageURL: &heroURL, AltText: "hero"} + + btnText := NewTextBlockObject("plain_text", "Open", false, false) + btn := NewButtonBlockElement("open_action", "go", btnText) + + block := NewCardBlock(CardBlockOptionBlockID("card-1")). + WithTitle(title). + WithSubtitle(subtitle). + WithBody(body). + WithSubtext(subtext). + WithIcon(icon). + WithHeroImage(hero). + WithActions(btn) + + assert.Equal(t, MBTCard, block.BlockType()) + assert.Equal(t, "card", string(block.Type)) + assert.Equal(t, "card-1", block.ID()) + assert.Equal(t, title, block.Title) + assert.Equal(t, subtitle, block.Subtitle) + assert.Equal(t, body, block.Body) + assert.Equal(t, subtext, block.Subtext) + assert.Equal(t, icon, block.Icon) + assert.Equal(t, hero, block.HeroImage) + require.NotNil(t, block.Actions) + require.Len(t, block.Actions.ElementSet, 1) +} + +func TestNewCardBlockWithSlackIcon(t *testing.T) { + slackIcon := NewSlackIconObject("rocket") + + block := NewCardBlock(). + WithTitle(NewTextBlockObject("mrkdwn", "Card title", false, false)). + WithSlackIcon(slackIcon) + + require.NotNil(t, block.SlackIcon) + assert.Equal(t, "icon", block.SlackIcon.Type) + assert.Equal(t, "rocket", block.SlackIcon.Name) + assert.Equal(t, slackIcon, block.SlackIcon) +} + +func TestCardBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "card", + "block_id": "card-1", + "hero_image": { + "type": "image", + "image_url": "https://example.com/hero.png", + "alt_text": "hero" + }, + "icon": { + "type": "image", + "image_url": "https://example.com/icon.png", + "alt_text": "icon" + }, + "title": {"type": "mrkdwn", "text": "Lumon Industries"}, + "subtitle": {"type": "mrkdwn", "text": "Macrodata Refinement"}, + "body": {"type": "mrkdwn", "text": "The work is mysterious and important."}, + "subtext": {"type": "mrkdwn", "text": "Praise Kier."}, + "actions": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Enter"}, + "action_id": "enter", + "value": "mdr" + } + ] + }` + + var block CardBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTCard, block.BlockType()) + assert.Equal(t, "card-1", block.ID()) + require.NotNil(t, block.HeroImage) + require.NotNil(t, block.Icon) + require.NotNil(t, block.Title) + require.NotNil(t, block.Subtitle) + require.NotNil(t, block.Body) + require.NotNil(t, block.Subtext) + require.NotNil(t, block.Actions) + require.Len(t, block.Actions.ElementSet, 1) + + btn, ok := block.Actions.ElementSet[0].(*ButtonBlockElement) + require.True(t, ok, "expected *ButtonBlockElement, got %T", block.Actions.ElementSet[0]) + assert.Equal(t, "enter", btn.ActionID) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + + assert.Equal(t, expected, actual) +} + +func TestCardBlockSlackIconJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "card", + "slack_icon": {"type": "icon", "name": "rocket"}, + "title": {"type": "mrkdwn", "text": "Sample Card Title"} + }` + + var block CardBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + require.NotNil(t, block.SlackIcon) + assert.Equal(t, "icon", block.SlackIcon.Type) + assert.Equal(t, "rocket", block.SlackIcon.Name) + assert.Nil(t, block.Icon) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + + assert.Equal(t, expected, actual) +} + +func TestCardBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "card", + "title": {"type": "mrkdwn", "text": "Hello"} + } + ]` + + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte(payload), &blocks)) + require.Len(t, blocks.BlockSet, 1) + + card, ok := blocks.BlockSet[0].(*CardBlock) + require.True(t, ok, "expected *CardBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTCard, card.BlockType()) + require.NotNil(t, card.Title) + assert.Equal(t, "Hello", card.Title.Text) +} diff --git a/block_carousel.go b/block_carousel.go new file mode 100644 index 000000000..5d407db9c --- /dev/null +++ b/block_carousel.go @@ -0,0 +1,42 @@ +package slack + +// CarouselBlock defines a block of type carousel that displays a scrollable +// list of cards. A carousel must contain between 1 and 10 cards. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/carousel-block/ +type CarouselBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Elements []*CardBlock `json:"elements"` +} + +// BlockType returns the type of the block +func (s CarouselBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s CarouselBlock) ID() string { + return s.BlockID +} + +// NewCarouselBlock returns a new instance of a carousel block containing the +// given cards. +func NewCarouselBlock(cards ...*CardBlock) *CarouselBlock { + return &CarouselBlock{ + Type: MBTCarousel, + Elements: cards, + } +} + +// WithBlockID sets the block ID for the CarouselBlock +func (s *CarouselBlock) WithBlockID(blockID string) *CarouselBlock { + s.BlockID = blockID + return s +} + +// AddCard appends a card to the carousel +func (s *CarouselBlock) AddCard(card *CardBlock) *CarouselBlock { + s.Elements = append(s.Elements, card) + return s +} diff --git a/block_carousel_test.go b/block_carousel_test.go new file mode 100644 index 000000000..ab624e000 --- /dev/null +++ b/block_carousel_test.go @@ -0,0 +1,87 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCarouselBlock(t *testing.T) { + cardA := NewCardBlock().WithTitle(NewTextBlockObject("mrkdwn", "A", false, false)) + cardB := NewCardBlock().WithTitle(NewTextBlockObject("mrkdwn", "B", false, false)) + + block := NewCarouselBlock(cardA, cardB).WithBlockID("carousel-1") + + assert.Equal(t, MBTCarousel, block.BlockType()) + assert.Equal(t, "carousel", string(block.Type)) + assert.Equal(t, "carousel-1", block.ID()) + require.Len(t, block.Elements, 2) + assert.Equal(t, cardA, block.Elements[0]) + assert.Equal(t, cardB, block.Elements[1]) + + cardC := NewCardBlock().WithTitle(NewTextBlockObject("mrkdwn", "C", false, false)) + block.AddCard(cardC) + require.Len(t, block.Elements, 3) + assert.Equal(t, cardC, block.Elements[2]) +} + +func TestCarouselBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "carousel", + "block_id": "carousel-1", + "elements": [ + { + "type": "card", + "title": {"type": "mrkdwn", "text": "MDR"}, + "body": {"type": "mrkdwn", "text": "Macrodata Refinement"} + }, + { + "type": "card", + "title": {"type": "mrkdwn", "text": "O&D"}, + "body": {"type": "mrkdwn", "text": "Optics and Design"} + } + ] + }` + + var block CarouselBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTCarousel, block.BlockType()) + assert.Equal(t, "carousel-1", block.ID()) + require.Len(t, block.Elements, 2) + assert.Equal(t, "MDR", block.Elements[0].Title.Text) + assert.Equal(t, "O&D", block.Elements[1].Title.Text) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + + assert.Equal(t, expected, actual) +} + +func TestCarouselBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "carousel", + "elements": [ + {"type": "card", "title": {"type": "mrkdwn", "text": "Only"}} + ] + } + ]` + + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte(payload), &blocks)) + require.Len(t, blocks.BlockSet, 1) + + carousel, ok := blocks.BlockSet[0].(*CarouselBlock) + require.True(t, ok, "expected *CarouselBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTCarousel, carousel.BlockType()) + require.Len(t, carousel.Elements, 1) + assert.Equal(t, "Only", carousel.Elements[0].Title.Text) +} diff --git a/block_container.go b/block_container.go new file mode 100644 index 000000000..b96c85667 --- /dev/null +++ b/block_container.go @@ -0,0 +1,182 @@ +package slack + +import "fmt" + +// ContainerWidth controls the rendered width of a ContainerBlock. When unset, +// Slack defaults to ContainerWidthStandard. +type ContainerWidth string + +const ( + ContainerWidthNarrow ContainerWidth = "narrow" + ContainerWidthStandard ContainerWidth = "standard" + ContainerWidthWide ContainerWidth = "wide" + ContainerWidthFull ContainerWidth = "full" +) + +// containerMaxChildBlocks is the maximum number of child blocks Slack permits +// inside a container block. +const containerMaxChildBlocks = 10 + +// ContainerBlock groups a set of child blocks so they render together as a +// single, optionally collapsible, unit with a title, subtitle, and icon. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/container-block/ +type ContainerBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + // Title is the container heading rendered as a plain_text object. One of + // Title or RichTextTitle is required; RichTextTitle takes precedence when + // both are set. Slack requires a maximum of 150 characters. + Title *TextBlockObject `json:"title,omitempty"` + // RichTextTitle is the container heading rendered as a rich_text block. It + // takes precedence over Title when both are set. + RichTextTitle *RichTextBlock `json:"rich_text_title,omitempty"` + // Subtitle is descriptive text below the title, rendered as a plain_text or + // mrkdwn object. Slack requires a maximum of 150 characters. + Subtitle *TextBlockObject `json:"subtitle,omitempty"` + // Icon is a small image displayed beside the title and subtitle. + Icon *ImageBlockElement `json:"icon,omitempty"` + // Width controls the container width. Slack defaults to standard when unset. + Width ContainerWidth `json:"width,omitempty"` + // IsCollapsible enables the container's collapse control. + IsCollapsible bool `json:"is_collapsible,omitempty"` + // DefaultCollapsed starts the container collapsed. It only applies when + // IsCollapsible is true. + DefaultCollapsed bool `json:"default_collapsed,omitempty"` + // HasHeaderDivider draws a border below the header. Slack only supports it on + // non-collapsible containers. + HasHeaderDivider bool `json:"has_header_divider,omitempty"` + // ChildBlocks are the blocks rendered inside the container. Slack requires 1 + // to 10 blocks: actions, context, divider, file, header, image, input, + // rich_text, section, table, and video blocks are supported. + ChildBlocks Blocks `json:"child_blocks"` +} + +// BlockType returns the type of the block. +func (s ContainerBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block. +func (s ContainerBlock) ID() string { + return s.BlockID +} + +// Validate checks whether the block satisfies Slack's documented container +// constraints. +func (s ContainerBlock) Validate() error { + if s.Type != MBTContainer { + return fmt.Errorf("type must be %q", MBTContainer) + } + if s.Title == nil && s.RichTextTitle == nil { + return fmt.Errorf("one of title or rich_text_title is required") + } + if s.Title != nil { + if s.Title.Type != PlainTextType { + return fmt.Errorf("title must be a plain_text object") + } + if runeLen(s.Title.Text) > 150 { + return fmt.Errorf("title cannot be longer than 150 characters") + } + } + if s.Subtitle != nil { + if s.Subtitle.Type != PlainTextType && s.Subtitle.Type != MarkdownType { + return fmt.Errorf("subtitle must be a plain_text or mrkdwn object") + } + if runeLen(s.Subtitle.Text) > 150 { + return fmt.Errorf("subtitle cannot be longer than 150 characters") + } + } + switch s.Width { + case "", ContainerWidthNarrow, ContainerWidthStandard, ContainerWidthWide, ContainerWidthFull: + default: + return fmt.Errorf("width must be one of narrow, standard, wide, or full") + } + if s.Icon != nil { + if runeLen(s.Icon.AltText) > 2000 { + return fmt.Errorf("icon alt_text cannot be longer than 2000 characters") + } + if s.Icon.ImageURL != nil && runeLen(*s.Icon.ImageURL) > 3000 { + return fmt.Errorf("icon image_url cannot be longer than 3000 characters") + } + } + if s.HasHeaderDivider && s.IsCollapsible { + return fmt.Errorf("has_header_divider is only supported on non-collapsible containers") + } + if s.DefaultCollapsed && !s.IsCollapsible { + return fmt.Errorf("default_collapsed requires is_collapsible to be true") + } + if n := len(s.ChildBlocks.BlockSet); n < 1 { + return fmt.Errorf("child_blocks must have at least 1 block") + } else if n > containerMaxChildBlocks { + return fmt.Errorf("child_blocks cannot have more than %d blocks", containerMaxChildBlocks) + } + return nil +} + +// NewContainerBlock returns a new container block wrapping the given child +// blocks. Use the With* methods to set the title, subtitle, and other optional +// fields. +func NewContainerBlock(childBlocks ...Block) *ContainerBlock { + return &ContainerBlock{ + Type: MBTContainer, + ChildBlocks: Blocks{BlockSet: childBlocks}, + } +} + +// WithBlockID sets the block ID for the ContainerBlock. +func (s *ContainerBlock) WithBlockID(blockID string) *ContainerBlock { + s.BlockID = blockID + return s +} + +// WithTitle sets the plain_text title for the ContainerBlock. +func (s *ContainerBlock) WithTitle(title *TextBlockObject) *ContainerBlock { + s.Title = title + return s +} + +// WithRichTextTitle sets the rich_text title for the ContainerBlock. It takes +// precedence over a plain_text title set with WithTitle. +func (s *ContainerBlock) WithRichTextTitle(title *RichTextBlock) *ContainerBlock { + s.RichTextTitle = title + return s +} + +// WithSubtitle sets the subtitle for the ContainerBlock. +func (s *ContainerBlock) WithSubtitle(subtitle *TextBlockObject) *ContainerBlock { + s.Subtitle = subtitle + return s +} + +// WithIcon sets the icon displayed beside the title for the ContainerBlock. +func (s *ContainerBlock) WithIcon(icon *ImageBlockElement) *ContainerBlock { + s.Icon = icon + return s +} + +// WithWidth sets the rendered width of the ContainerBlock. +func (s *ContainerBlock) WithWidth(width ContainerWidth) *ContainerBlock { + s.Width = width + return s +} + +// WithCollapsible marks the ContainerBlock collapsible and controls whether it +// starts collapsed. +func (s *ContainerBlock) WithCollapsible(collapsible, defaultCollapsed bool) *ContainerBlock { + s.IsCollapsible = collapsible + s.DefaultCollapsed = defaultCollapsed + return s +} + +// WithHeaderDivider draws a border below the container header. +func (s *ContainerBlock) WithHeaderDivider(hasHeaderDivider bool) *ContainerBlock { + s.HasHeaderDivider = hasHeaderDivider + return s +} + +// AddChildBlock appends a block to the container's child blocks. +func (s *ContainerBlock) AddChildBlock(block Block) *ContainerBlock { + s.ChildBlocks.BlockSet = append(s.ChildBlocks.BlockSet, block) + return s +} diff --git a/block_container_test.go b/block_container_test.go new file mode 100644 index 000000000..9943b257c --- /dev/null +++ b/block_container_test.go @@ -0,0 +1,143 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewContainerBlock(t *testing.T) { + section := NewSectionBlock(NewTextBlockObject("mrkdwn", "Content", false, false), nil, nil) + divider := NewDividerBlock() + + block := NewContainerBlock(section). + WithBlockID("container-1"). + WithTitle(NewTextBlockObject(PlainTextType, "Title", false, false)). + WithSubtitle(NewTextBlockObject(MarkdownType, "Subtitle", false, false)). + WithIcon(NewImageBlockElement("https://example.com/icon.png", "icon")). + WithWidth(ContainerWidthWide). + WithCollapsible(true, true) + + assert.Equal(t, MBTContainer, block.BlockType()) + assert.Equal(t, "container", string(block.Type)) + assert.Equal(t, "container-1", block.ID()) + assert.Equal(t, ContainerWidthWide, block.Width) + assert.True(t, block.IsCollapsible) + assert.True(t, block.DefaultCollapsed) + require.Len(t, block.ChildBlocks.BlockSet, 1) + assert.Equal(t, section, block.ChildBlocks.BlockSet[0]) + + block.AddChildBlock(divider) + require.Len(t, block.ChildBlocks.BlockSet, 2) + assert.Equal(t, divider, block.ChildBlocks.BlockSet[1]) + + assert.NoError(t, block.Validate()) +} + +func TestContainerBlockValidate(t *testing.T) { + child := NewSectionBlock(NewTextBlockObject("mrkdwn", "Content", false, false), nil, nil) + plainTitle := NewTextBlockObject(PlainTextType, "Title", false, false) + + tests := []struct { + name string + block *ContainerBlock + wantErr bool + }{ + { + name: "valid", + block: NewContainerBlock(child).WithTitle(plainTitle), + }, + { + name: "valid with rich_text_title", + block: NewContainerBlock(child).WithRichTextTitle(NewRichTextBlock("rtt")), + }, + { + name: "missing title", + block: NewContainerBlock(child), + wantErr: true, + }, + { + name: "non plain_text title", + block: NewContainerBlock(child).WithTitle(NewTextBlockObject(MarkdownType, "Title", false, false)), + wantErr: true, + }, + { + name: "no child blocks", + block: NewContainerBlock().WithTitle(plainTitle), + wantErr: true, + }, + { + name: "too many child blocks", + block: NewContainerBlock( + child, child, child, child, child, child, child, child, child, child, child, + ).WithTitle(plainTitle), + wantErr: true, + }, + { + name: "invalid width", + block: NewContainerBlock(child).WithTitle(plainTitle).WithWidth("gigantic"), + wantErr: true, + }, + { + name: "header divider on collapsible", + block: NewContainerBlock(child).WithTitle(plainTitle).WithCollapsible(true, false).WithHeaderDivider(true), + wantErr: true, + }, + { + name: "default collapsed without collapsible", + block: &ContainerBlock{Type: MBTContainer, Title: plainTitle, DefaultCollapsed: true, ChildBlocks: Blocks{BlockSet: []Block{child}}}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.block.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestContainerBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "container", + "block_id": "container-1", + "title": {"type": "plain_text", "text": "Deploy status"}, + "subtitle": {"type": "mrkdwn", "text": "*production*"}, + "icon": {"type": "image", "image_url": "https://example.com/icon.png", "alt_text": "icon"}, + "width": "wide", + "is_collapsible": true, + "default_collapsed": true, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "All systems go"}}, + {"type": "divider"} + ] + }` + + var block ContainerBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + assert.Equal(t, MBTContainer, block.BlockType()) + assert.Equal(t, "container-1", block.ID()) + assert.Equal(t, ContainerWidthWide, block.Width) + assert.True(t, block.IsCollapsible) + require.NotNil(t, block.Title) + assert.Equal(t, "Deploy status", block.Title.Text) + require.Len(t, block.ChildBlocks.BlockSet, 2) + assert.Equal(t, MBTSection, block.ChildBlocks.BlockSet[0].BlockType()) + assert.Equal(t, MBTDivider, block.ChildBlocks.BlockSet[1].BlockType()) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var want, got any + require.NoError(t, json.Unmarshal([]byte(payload), &want)) + require.NoError(t, json.Unmarshal(marshalled, &got)) + assert.Equal(t, want, got) +} diff --git a/block_context.go b/block_context.go index 384fee22b..879ee61ee 100644 --- a/block_context.go +++ b/block_context.go @@ -15,6 +15,11 @@ func (s ContextBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s ContextBlock) ID() string { + return s.BlockID +} + type ContextElements struct { Elements []MixedElement } diff --git a/block_context_actions.go b/block_context_actions.go new file mode 100644 index 000000000..d1cf532c3 --- /dev/null +++ b/block_context_actions.go @@ -0,0 +1,31 @@ +package slack + +// ContextActionsBlock defines data that is used to hold interactive action elements. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/context-actions-block/ +type ContextActionsBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Elements *BlockElements `json:"elements"` +} + +// BlockType returns the type of the block +func (s ContextActionsBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s ContextActionsBlock) ID() string { + return s.BlockID +} + +// NewContextActionsBlock returns a new instance of a Context Actions Block +func NewContextActionsBlock(blockID string, elements ...BlockElement) *ContextActionsBlock { + return &ContextActionsBlock{ + Type: MBTContextActions, + BlockID: blockID, + Elements: &BlockElements{ + ElementSet: elements, + }, + } +} diff --git a/block_context_actions_test.go b/block_context_actions_test.go new file mode 100644 index 000000000..07bd3001b --- /dev/null +++ b/block_context_actions_test.go @@ -0,0 +1,139 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewContextActionsBlock(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + contextActionsBlock := NewContextActionsBlock("test_block", feedbackElement) + + assert.Equal(t, contextActionsBlock.BlockType(), MBTContextActions) + assert.Equal(t, string(contextActionsBlock.Type), "context_actions") + assert.Equal(t, contextActionsBlock.BlockID, "test_block") + assert.Equal(t, contextActionsBlock.ID(), "test_block") + assert.Equal(t, len(contextActionsBlock.Elements.ElementSet), 1) +} + +func TestContextActionsBlockWithIconButton(t *testing.T) { + deleteText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", deleteText, "delete_action") + + contextActionsBlock := NewContextActionsBlock("icon_block", iconButton) + + assert.Equal(t, contextActionsBlock.BlockType(), MBTContextActions) + assert.Equal(t, string(contextActionsBlock.Type), "context_actions") + assert.Equal(t, contextActionsBlock.BlockID, "icon_block") + assert.Equal(t, len(contextActionsBlock.Elements.ElementSet), 1) +} + +func TestContextActionsBlockWithMultipleElements(t *testing.T) { + // Create feedback buttons + positiveBtnText := NewTextBlockObject("plain_text", "👍", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "👎", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + // Create icon button + deleteText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", deleteText, "delete_action") + + contextActionsBlock := NewContextActionsBlock("multi_block", feedbackElement, iconButton) + + assert.Equal(t, contextActionsBlock.BlockType(), MBTContextActions) + assert.Equal(t, len(contextActionsBlock.Elements.ElementSet), 2) +} + +func TestContextActionsBlockJSONMarshalling(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_buttons_1", positiveBtn, negativeBtn) + + contextActionsBlock := NewContextActionsBlock("test_block", feedbackElement) + + // Marshal to JSON + data, err := json.Marshal(contextActionsBlock) + assert.NoError(t, err) + assert.NotNil(t, data) + + // Unmarshal back + var unmarshalled ContextActionsBlock + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + assert.Equal(t, "context_actions", string(unmarshalled.Type)) + assert.Equal(t, "test_block", unmarshalled.BlockID) + assert.Equal(t, 1, len(unmarshalled.Elements.ElementSet)) +} + +func TestContextActionsBlockUnmarshalJSON(t *testing.T) { + jsonData := []byte(`{ + "type": "context_actions", + "block_id": "test_block", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "feedback_buttons_1", + "positive_button": { + "text": { + "type": "plain_text", + "text": "Good" + }, + "value": "positive_feedback" + }, + "negative_button": { + "text": { + "type": "plain_text", + "text": "Bad" + }, + "value": "negative_feedback" + } + } + ] + }`) + + var block ContextActionsBlock + err := json.Unmarshal(jsonData, &block) + assert.NoError(t, err) + assert.Equal(t, "context_actions", string(block.Type)) + assert.Equal(t, "test_block", block.BlockID) + assert.Equal(t, 1, len(block.Elements.ElementSet)) +} + +func TestContextActionsBlockInBlocks(t *testing.T) { + // Test that context_actions block can be unmarshalled as part of a Blocks collection + jsonData := []byte(`[ + { + "type": "context_actions", + "block_id": "actions_block", + "elements": [ + { + "type": "icon_button", + "icon": "trash", + "text": { + "type": "plain_text", + "text": "Delete" + }, + "action_id": "delete_button_1", + "value": "delete_item" + } + ] + } + ]`) + + var blocks Blocks + err := json.Unmarshal(jsonData, &blocks) + assert.NoError(t, err) + assert.Equal(t, 1, len(blocks.BlockSet)) + assert.Equal(t, MBTContextActions, blocks.BlockSet[0].BlockType()) +} diff --git a/block_context_test.go b/block_context_test.go index 20fb39631..0ecfb8089 100644 --- a/block_context_test.go +++ b/block_context_test.go @@ -7,15 +7,14 @@ import ( ) func TestNewContextBlock(t *testing.T) { - locationPinImage := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Location Pin Icon") textExample := NewTextBlockObject("plain_text", "Location: Central Business District", true, false) - elements := []MixedElement{locationPinImage, textExample} - contextBlock := NewContextBlock("test", elements...) + + assert.Equal(t, contextBlock.BlockType(), MBTContext) assert.Equal(t, string(contextBlock.Type), "context") assert.Equal(t, contextBlock.BlockID, "test") + assert.Equal(t, contextBlock.ID(), "test") assert.Equal(t, len(contextBlock.ContextElements.Elements), 2) - } diff --git a/block_conv.go b/block_conv.go index 1a2c57e9f..2a298522b 100644 --- a/block_conv.go +++ b/block_conv.go @@ -2,7 +2,6 @@ package slack import ( "encoding/json" - "errors" "fmt" ) @@ -54,6 +53,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &ActionBlock{} case "context": block = &ContextBlock{} + case "context_actions": + block = &ContextActionsBlock{} case "divider": block = &DividerBlock{} case "file": @@ -64,12 +65,43 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &ImageBlock{} case "input": block = &InputBlock{} + case "markdown": + block = &MarkdownBlock{} case "rich_text": block = &RichTextBlock{} + case "rich_text_input": + block = &RichTextBlock{} case "section": block = &SectionBlock{} + case "call": + block = &CallBlock{} + case "video": + block = &VideoBlock{} + case "table": + block = &TableBlock{} + case "data_table": + block = &DataTableBlock{} + case "data_visualization": + block = &DataVisualizationBlock{} + case "task_card": + block = &TaskCardBlock{} + case "alert": + block = &AlertBlock{} + case "plan": + block = &PlanBlock{} + case "card": + block = &CardBlock{} + case "carousel": + block = &CarouselBlock{} + case "container": + block = &ContainerBlock{} default: - block = &UnknownBlock{} + b := &UnknownBlock{raw: r} + if err = json.Unmarshal(r, b); err != nil { + return err + } + blocks.BlockSet = append(blocks.BlockSet, b) + continue } err = json.Unmarshal(r, block) @@ -110,8 +142,16 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &DatePickerBlockElement{} case "timepicker": e = &TimePickerBlockElement{} + case "datetimepicker": + e = &DateTimePickerBlockElement{} case "plain_text_input": e = &PlainTextInputBlockElement{} + case "rich_text_input": + e = &RichTextInputBlockElement{} + case "email_text_input": + e = &EmailTextInputBlockElement{} + case "url_text_input": + e = &URLTextInputBlockElement{} case "static_select", "external_select", "users_select", "conversations_select", "channels_select": e = &SelectBlockElement{} case "multi_static_select", "multi_external_select", "multi_users_select", "multi_conversations_select", "multi_channels_select": @@ -122,8 +162,18 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &OverflowBlockElement{} case "radio_buttons": e = &RadioButtonsBlockElement{} + case "number_input": + e = &NumberInputBlockElement{} + case "file_input": + e = &FileInputBlockElement{} + case "feedback_buttons": + e = &FeedbackButtonsBlockElement{} + case "icon_button": + e = &IconButtonBlockElement{} + case "workflow_button": + e = &WorkflowButtonBlockElement{} 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 { @@ -184,14 +234,34 @@ func (b *BlockElements) UnmarshalJSON(data []byte) error { blockElement = &DatePickerBlockElement{} case "timepicker": blockElement = &TimePickerBlockElement{} + case "datetimepicker": + blockElement = &DateTimePickerBlockElement{} case "plain_text_input": blockElement = &PlainTextInputBlockElement{} + case "rich_text_input": + blockElement = &RichTextInputBlockElement{} + case "email_text_input": + blockElement = &EmailTextInputBlockElement{} + case "url_text_input": + blockElement = &URLTextInputBlockElement{} case "checkboxes": blockElement = &CheckboxGroupsBlockElement{} case "radio_buttons": blockElement = &RadioButtonsBlockElement{} case "static_select", "external_select", "users_select", "conversations_select", "channels_select": blockElement = &SelectBlockElement{} + case "multi_static_select", "multi_external_select", "multi_users_select", "multi_conversations_select", "multi_channels_select": + blockElement = &MultiSelectBlockElement{} + case "number_input": + blockElement = &NumberInputBlockElement{} + case "file_input": + blockElement = &FileInputBlockElement{} + case "feedback_buttons": + blockElement = &FeedbackButtonsBlockElement{} + case "icon_button": + blockElement = &IconButtonBlockElement{} + case "workflow_button": + blockElement = &WorkflowButtonBlockElement{} default: return fmt.Errorf("unsupported block element type %v", blockElementType) } @@ -221,6 +291,7 @@ func (a *Accessory) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements the Unmarshaller interface for Accessory, so that any JSON // unmarshalling is delegated and proper type determination can be made before unmarshal +// Note: datetimepicker is not supported in Accessory func (a *Accessory) UnmarshalJSON(data []byte) error { var r json.RawMessage @@ -281,6 +352,12 @@ func (a *Accessory) UnmarshalJSON(data []byte) error { return err } a.PlainTextInputElement = element.(*PlainTextInputBlockElement) + case "rich_text_input": + element, err := unmarshalBlockElement(r, &RichTextInputBlockElement{}) + if err != nil { + return err + } + a.RichTextInputElement = element.(*RichTextInputBlockElement) case "radio_buttons": element, err := unmarshalBlockElement(r, &RadioButtonsBlockElement{}) if err != nil { @@ -305,6 +382,12 @@ func (a *Accessory) UnmarshalJSON(data []byte) error { return err } a.CheckboxGroupsBlockElement = element.(*CheckboxGroupsBlockElement) + case "workflow_button": + element, err := unmarshalBlockElement(r, &WorkflowButtonBlockElement{}) + if err != nil { + return err + } + a.WorkflowButtonElement = element.(*WorkflowButtonBlockElement) default: element, err := unmarshalBlockElement(r, &UnknownBlockElement{}) if err != nil { @@ -355,6 +438,12 @@ func toBlockElement(element *Accessory) BlockElement { if element.MultiSelectElement != nil { return element.MultiSelectElement } + if element.RichTextInputElement != nil { + return element.RichTextInputElement + } + if element.WorkflowButtonElement != nil { + return element.WorkflowButtonElement + } return nil } @@ -412,7 +501,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_conv_test.go b/block_conv_test.go new file mode 100644 index 000000000..9bce11507 --- /dev/null +++ b/block_conv_test.go @@ -0,0 +1,189 @@ +package slack + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkflowButtonBlockElementUnmarshal(t *testing.T) { + workflowButtonJSON := `{ + "type": "workflow_button", + "text": {"type": "plain_text", "text": "Run Workflow"}, + "workflow": {"trigger": {"url": "https://slack.com/shortcuts/Ft0123ABC/xyz"}}, + "action_id": "start_workflow" + }` + + t.Run("BlockElements", func(t *testing.T) { + elementsJSON := fmt.Sprintf("[%s]", workflowButtonJSON) + var elements BlockElements + err := json.Unmarshal([]byte(elementsJSON), &elements) + require.NoError(t, err) + require.Len(t, elements.ElementSet, 1) + assert.IsType(t, &WorkflowButtonBlockElement{}, elements.ElementSet[0]) + + wb := elements.ElementSet[0].(*WorkflowButtonBlockElement) + assert.Equal(t, METWorkflowButton, wb.Type) + assert.Equal(t, "Run Workflow", wb.Text.Text) + assert.Equal(t, "start_workflow", wb.ActionID) + assert.Equal(t, "https://slack.com/shortcuts/Ft0123ABC/xyz", wb.Workflow.Trigger.URL) + }) + + t.Run("InputBlock", func(t *testing.T) { + inputJSON := fmt.Sprintf(`{ + "type": "input", + "label": {"type": "plain_text", "text": "Workflow"}, + "element": %s + }`, workflowButtonJSON) + var input InputBlock + err := json.Unmarshal([]byte(inputJSON), &input) + require.NoError(t, err) + require.NotNil(t, input.Element) + assert.IsType(t, &WorkflowButtonBlockElement{}, input.Element) + }) + + t.Run("Accessory", func(t *testing.T) { + var accessory Accessory + err := json.Unmarshal([]byte(workflowButtonJSON), &accessory) + require.NoError(t, err) + require.NotNil(t, accessory.WorkflowButtonElement) + assert.Equal(t, METWorkflowButton, accessory.WorkflowButtonElement.Type) + }) +} + +// TestAllBlockElementTypesUnmarshal ensures every known MET* element type can be +// unmarshalled through BlockElements.UnmarshalJSON without error. This test acts +// as a safety net: when a new element type constant is added to block_element.go +// but its case is not added to the switch statement, this test will fail. +func TestAllBlockElementTypesUnmarshal(t *testing.T) { + allTypes := []string{ + string(METCheckboxGroups), + string(METImage), + string(METButton), + string(METOverflow), + string(METDatepicker), + string(METTimepicker), + string(METDatetimepicker), + string(METPlainTextInput), + string(METRadioButtons), + string(METRichTextInput), + string(METEmailTextInput), + string(METURLTextInput), + string(METNumber), + string(METFileInput), + string(METFeedbackButtons), + string(METIconButton), + string(METWorkflowButton), + OptTypeStatic, + OptTypeExternal, + OptTypeUser, + OptTypeConversations, + OptTypeChannels, + MultiOptTypeStatic, + MultiOptTypeExternal, + MultiOptTypeUser, + MultiOptTypeConversations, + MultiOptTypeChannels, + } + + for _, typ := range allTypes { + t.Run(typ, func(t *testing.T) { + elemJSON := fmt.Sprintf(`[{"type": "%s"}]`, typ) + var elements BlockElements + err := json.Unmarshal([]byte(elemJSON), &elements) + require.NoError(t, err, "BlockElements.UnmarshalJSON should handle type %q", typ) + require.Len(t, elements.ElementSet, 1) + }) + } +} + +func TestRichTextUnknownRoundTrip(t *testing.T) { + input := `{"type":"rich_text","block_id":"b1","elements":[{"type":"rich_text_unknown_type","key":"val"}]}` + var block RichTextBlock + err := json.Unmarshal([]byte(input), &block) + require.NoError(t, err) + require.Len(t, block.Elements, 1) + + u, ok := block.Elements[0].(*RichTextUnknown) + require.True(t, ok, "expected *RichTextUnknown") + assert.Equal(t, RichTextElementType("rich_text_unknown_type"), u.Type) + + out, err := json.Marshal(block) + require.NoError(t, err) + + var roundTripped map[string]any + err = json.Unmarshal(out, &roundTripped) + require.NoError(t, err) + elems := roundTripped["elements"].([]any) + elem := elems[0].(map[string]any) + assert.Equal(t, "rich_text_unknown_type", elem["type"]) + assert.Equal(t, "val", elem["key"]) +} + +func TestRichTextSectionUnknownElementRoundTrip(t *testing.T) { + input := `{"type":"rich_text_section","elements":[{"type":"unknown_elem","data":42}]}` + var section RichTextSection + err := json.Unmarshal([]byte(input), §ion) + require.NoError(t, err) + require.Len(t, section.Elements, 1) + + _, ok := section.Elements[0].(*RichTextSectionUnknownElement) + require.True(t, ok, "expected *RichTextSectionUnknownElement") + + out, err := json.Marshal(section) + require.NoError(t, err) + + var roundTripped map[string]any + err = json.Unmarshal(out, &roundTripped) + require.NoError(t, err) + elems := roundTripped["elements"].([]any) + elem := elems[0].(map[string]any) + assert.Equal(t, "unknown_elem", elem["type"]) + assert.Equal(t, float64(42), elem["data"]) +} + +// TestAllAccessoryTypesRoundTrip ensures every Accessory field can survive a +// marshal→unmarshal round trip. When a new field is added to the Accessory struct +// and wired into NewAccessory but not into Accessory.UnmarshalJSON, this test +// will fail. +func TestAllAccessoryTypesRoundTrip(t *testing.T) { + text := NewTextBlockObject("plain_text", "test", false, false) + workflow := &Workflow{Trigger: &WorkflowTrigger{URL: "https://example.com"}} + + cases := []struct { + name string + element BlockElement + check func(t *testing.T, a *Accessory) + }{ + {"image", &ImageBlockElement{Type: METImage}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.ImageElement) }}, + {"button", &ButtonBlockElement{Type: METButton}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.ButtonElement) }}, + {"overflow", &OverflowBlockElement{Type: METOverflow}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.OverflowElement) }}, + {"datepicker", &DatePickerBlockElement{Type: METDatepicker}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.DatePickerElement) }}, + {"timepicker", &TimePickerBlockElement{Type: METTimepicker}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.TimePickerElement) }}, + {"plain_text_input", &PlainTextInputBlockElement{Type: METPlainTextInput}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.PlainTextInputElement) }}, + {"rich_text_input", &RichTextInputBlockElement{Type: METRichTextInput}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.RichTextInputElement) }}, + {"radio_buttons", &RadioButtonsBlockElement{Type: METRadioButtons}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.RadioButtonsElement) }}, + {"static_select", &SelectBlockElement{Type: OptTypeStatic}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.SelectElement) }}, + {"multi_static_select", &MultiSelectBlockElement{Type: MultiOptTypeStatic}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.MultiSelectElement) }}, + {"checkboxes", &CheckboxGroupsBlockElement{Type: METCheckboxGroups}, func(t *testing.T, a *Accessory) { assert.NotNil(t, a.CheckboxGroupsBlockElement) }}, + {"workflow_button", NewWorkflowButtonBlockElement(text, workflow, "action1"), func(t *testing.T, a *Accessory) { assert.NotNil(t, a.WorkflowButtonElement) }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + accessory := NewAccessory(tc.element) + + data, err := json.Marshal(accessory) + require.NoError(t, err, "Marshal failed for %s", tc.name) + + var unmarshalled Accessory + err = json.Unmarshal(data, &unmarshalled) + require.NoError(t, err, "Unmarshal failed for %s", tc.name) + + tc.check(t, &unmarshalled) + }) + } +} diff --git a/block_data_table.go b/block_data_table.go new file mode 100644 index 000000000..074b29c0e --- /dev/null +++ b/block_data_table.go @@ -0,0 +1,212 @@ +package slack + +import ( + "encoding/json" + "fmt" +) + +// DataTableCellType identifies the variant of a cell inside a DataTableBlock row. +type DataTableCellType string + +const ( + DataTableCellRawText DataTableCellType = "raw_text" + DataTableCellRawNumber DataTableCellType = "raw_number" + DataTableCellRichText DataTableCellType = "rich_text" +) + +// DataTableCell is implemented by every cell type valid inside a DataTableBlock row: +// DataTableRawTextCell, DataTableRawNumberCell, and DataTableRichTextCell. +type DataTableCell interface { + DataTableCellType() DataTableCellType +} + +// DataTableRawTextCell is a plain-text cell in a DataTableBlock. +type DataTableRawTextCell struct { + Type DataTableCellType `json:"type"` + Text string `json:"text"` +} + +// DataTableCellType returns the cell variant. +func (c DataTableRawTextCell) DataTableCellType() DataTableCellType { + return c.Type +} + +// NewDataTableRawTextCell returns a raw_text cell with the given text. +func NewDataTableRawTextCell(text string) *DataTableRawTextCell { + return &DataTableRawTextCell{Type: DataTableCellRawText, Text: text} +} + +// DataTableRawNumberCell is a numeric cell in a DataTableBlock. When every cell in a +// column is a raw_number cell, Slack performs a numeric sort on that column instead of +// the default alphabetic sort. Text, when set, overrides the displayed value. +type DataTableRawNumberCell struct { + Type DataTableCellType `json:"type"` + Value float64 `json:"value"` + Text string `json:"text,omitempty"` +} + +// DataTableCellType returns the cell variant. +func (c DataTableRawNumberCell) DataTableCellType() DataTableCellType { + return c.Type +} + +// NewDataTableRawNumberCell returns a raw_number cell with the given value. +func NewDataTableRawNumberCell(value float64) *DataTableRawNumberCell { + return &DataTableRawNumberCell{Type: DataTableCellRawNumber, Value: value} +} + +// WithText sets the display text shown in place of the numeric value. +func (c *DataTableRawNumberCell) WithText(text string) *DataTableRawNumberCell { + c.Text = text + return c +} + +// DataTableRichTextCell is a cell holding rich text formatting. Rich text cells cannot +// appear in the header row. +type DataTableRichTextCell struct { + Type DataTableCellType `json:"type"` + Elements []RichTextElement `json:"elements"` +} + +// DataTableCellType returns the cell variant. +func (c DataTableRichTextCell) DataTableCellType() DataTableCellType { + return c.Type +} + +// NewDataTableRichTextCell returns a rich_text cell with the given rich text elements. +func NewDataTableRichTextCell(elements ...RichTextElement) *DataTableRichTextCell { + return &DataTableRichTextCell{Type: DataTableCellRichText, Elements: elements} +} + +// UnmarshalJSON delegates rich text element parsing to RichTextBlock so the cell handles +// the same set of inner elements (sections, lists, quotes, preformatted, unknown). +func (c *DataTableRichTextCell) UnmarshalJSON(data []byte) error { + var rt RichTextBlock + if err := json.Unmarshal(data, &rt); err != nil { + return err + } + c.Type = DataTableCellRichText + c.Elements = rt.Elements + return nil +} + +// DataTableBlock displays paginated tabular data with optional numeric sorting. +// +// Caption is required. Rows is an array of cell arrays; the first row is the header and +// must contain only raw_text cells. PageSize defaults to 5 (min 1, max 100) and +// RowHeaderColumnIndex defaults to 0 when omitted. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/data-table-block/ +type DataTableBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Caption string `json:"caption"` + Rows [][]DataTableCell `json:"rows"` + PageSize int `json:"page_size,omitempty"` + RowHeaderColumnIndex int `json:"row_header_column_index,omitempty"` +} + +// BlockType returns the type of the block. +func (s DataTableBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block. +func (s DataTableBlock) ID() string { + return s.BlockID +} + +// UnmarshalJSON parses the heterogeneous cell types in each row. +func (s *DataTableBlock) UnmarshalJSON(data []byte) error { + var raw struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id"` + Caption string `json:"caption"` + PageSize int `json:"page_size"` + RowHeaderColumnIndex int `json:"row_header_column_index"` + Rows [][]json.RawMessage `json:"rows"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + rows := make([][]DataTableCell, 0, len(raw.Rows)) + for _, rawRow := range raw.Rows { + row := make([]DataTableCell, 0, len(rawRow)) + for _, rawCell := range rawRow { + var probe struct { + Type DataTableCellType `json:"type"` + } + if err := json.Unmarshal(rawCell, &probe); err != nil { + return err + } + var cell DataTableCell + switch probe.Type { + case DataTableCellRawText: + cell = &DataTableRawTextCell{} + case DataTableCellRawNumber: + cell = &DataTableRawNumberCell{} + case DataTableCellRichText: + cell = &DataTableRichTextCell{} + default: + return fmt.Errorf("unsupported data_table cell type %q", probe.Type) + } + if err := json.Unmarshal(rawCell, cell); err != nil { + return err + } + row = append(row, cell) + } + rows = append(rows, row) + } + + s.Type = raw.Type + s.BlockID = raw.BlockID + s.Caption = raw.Caption + s.PageSize = raw.PageSize + s.RowHeaderColumnIndex = raw.RowHeaderColumnIndex + s.Rows = rows + return nil +} + +// DataTableBlockOption configures optional fields on a new DataTableBlock. +type DataTableBlockOption func(*DataTableBlock) + +// DataTableBlockOptionBlockID sets the block ID. +func DataTableBlockOptionBlockID(blockID string) DataTableBlockOption { + return func(b *DataTableBlock) { b.BlockID = blockID } +} + +// NewDataTableBlock returns a new DataTableBlock with the given caption. Add header and +// data rows with AddRow. +func NewDataTableBlock(caption string, options ...DataTableBlockOption) *DataTableBlock { + block := &DataTableBlock{ + Type: MBTDataTable, + Caption: caption, + Rows: make([][]DataTableCell, 0), + } + for _, opt := range options { + if opt != nil { + opt(block) + } + } + return block +} + +// WithPageSize sets the number of rows per page (min 1, max 100). +func (s *DataTableBlock) WithPageSize(pageSize int) *DataTableBlock { + s.PageSize = pageSize + return s +} + +// WithRowHeaderColumnIndex sets the 0-based index of the column that uniquely identifies +// each row. +func (s *DataTableBlock) WithRowHeaderColumnIndex(idx int) *DataTableBlock { + s.RowHeaderColumnIndex = idx + return s +} + +// AddRow appends a row of cells to the DataTableBlock. +func (s *DataTableBlock) AddRow(cells ...DataTableCell) *DataTableBlock { + s.Rows = append(s.Rows, append([]DataTableCell{}, cells...)) + return s +} diff --git a/block_data_table_test.go b/block_data_table_test.go new file mode 100644 index 000000000..f6dea87f6 --- /dev/null +++ b/block_data_table_test.go @@ -0,0 +1,181 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDataTableBlock(t *testing.T) { + block := NewDataTableBlock("A Fabulous Table", + DataTableBlockOptionBlockID("dt-1"), + ). + WithPageSize(10). + WithRowHeaderColumnIndex(1) + + assert.Equal(t, MBTDataTable, block.BlockType()) + assert.Equal(t, "data_table", string(block.Type)) + assert.Equal(t, "dt-1", block.ID()) + assert.Equal(t, "A Fabulous Table", block.Caption) + assert.Equal(t, 10, block.PageSize) + assert.Equal(t, 1, block.RowHeaderColumnIndex) + assert.Empty(t, block.Rows) +} + +func TestNewDataTableBlockWithNilOption(t *testing.T) { + assert.NotPanics(t, func() { + NewDataTableBlock("caption", nil) + }, "should not panic when nil option passed") +} + +func TestDataTableBlockAddRow(t *testing.T) { + block := NewDataTableBlock("caption") + block.AddRow(NewDataTableRawTextCell("Name"), NewDataTableRawTextCell("Score")) + block.AddRow( + NewDataTableRawTextCell("Helly"), + NewDataTableRawNumberCell(42).WithText("forty-two"), + ) + + require.Len(t, block.Rows, 2) + require.Len(t, block.Rows[0], 2) + require.Len(t, block.Rows[1], 2) + + assert.Equal(t, DataTableCellRawText, block.Rows[0][0].DataTableCellType()) + assert.Equal(t, DataTableCellRawNumber, block.Rows[1][1].DataTableCellType()) + + num, ok := block.Rows[1][1].(*DataTableRawNumberCell) + require.True(t, ok) + assert.Equal(t, float64(42), num.Value) + assert.Equal(t, "forty-two", num.Text) +} + +func TestDataTableBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "data_table", + "block_id": "dt-1", + "caption": "A Fabulous Table", + "page_size": 5, + "rows": [ + [ + {"type": "raw_text", "text": "Name"}, + {"type": "raw_text", "text": "Department"}, + {"type": "raw_text", "text": "Badge"} + ], + [ + {"type": "raw_text", "text": "Data Refinement Department"}, + {"type": "raw_text", "text": "MDR"}, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "Blue", "style": {"bold": true}} + ] + } + ] + } + ], + [ + {"type": "raw_text", "text": "Wellness Department"}, + {"type": "raw_number", "value": 7, "text": "seven"}, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "Limited", "style": {"bold": true}} + ] + } + ] + } + ] + ] + }` + + var block DataTableBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + assert.Equal(t, MBTDataTable, block.BlockType()) + assert.Equal(t, "dt-1", block.ID()) + assert.Equal(t, "A Fabulous Table", block.Caption) + assert.Equal(t, 5, block.PageSize) + require.Len(t, block.Rows, 3) + require.Len(t, block.Rows[0], 3) + + header, ok := block.Rows[0][0].(*DataTableRawTextCell) + require.True(t, ok) + assert.Equal(t, "Name", header.Text) + + num, ok := block.Rows[2][1].(*DataTableRawNumberCell) + require.True(t, ok) + assert.Equal(t, float64(7), num.Value) + assert.Equal(t, "seven", num.Text) + + rich, ok := block.Rows[1][2].(*DataTableRichTextCell) + require.True(t, ok) + require.Len(t, rich.Elements, 1) + section, ok := rich.Elements[0].(*RichTextSection) + require.True(t, ok) + require.Len(t, section.Elements, 1) + text, ok := section.Elements[0].(*RichTextSectionTextElement) + require.True(t, ok) + assert.Equal(t, "Blue", text.Text) + require.NotNil(t, text.Style) + assert.True(t, text.Style.Bold) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestDataTableBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "data_table", + "caption": "Tiny Table", + "rows": [ + [{"type": "raw_text", "text": "Col"}], + [{"type": "raw_number", "value": 1}] + ] + } + ]` + + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte(payload), &blocks)) + require.Len(t, blocks.BlockSet, 1) + + dt, ok := blocks.BlockSet[0].(*DataTableBlock) + require.True(t, ok, "expected *DataTableBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTDataTable, dt.BlockType()) + assert.Equal(t, "Tiny Table", dt.Caption) + require.Len(t, dt.Rows, 2) + + num, ok := dt.Rows[1][0].(*DataTableRawNumberCell) + require.True(t, ok) + assert.Equal(t, float64(1), num.Value) + assert.Empty(t, num.Text) +} + +func TestDataTableBlockUnknownCellType(t *testing.T) { + payload := `{ + "type": "data_table", + "caption": "Bad Cell", + "rows": [ + [{"type": "raw_text", "text": "ok"}], + [{"type": "mystery_cell", "text": "huh"}] + ] + }` + + var block DataTableBlock + err := json.Unmarshal([]byte(payload), &block) + require.Error(t, err) + assert.Contains(t, err.Error(), "mystery_cell") +} diff --git a/block_data_visualization.go b/block_data_visualization.go new file mode 100644 index 000000000..d6c96a640 --- /dev/null +++ b/block_data_visualization.go @@ -0,0 +1,455 @@ +package slack + +import ( + "encoding/json" + "fmt" + "unicode/utf8" +) + +// DataVisualizationChartType identifies the chart payload inside a +// DataVisualizationBlock. +type DataVisualizationChartType string + +const ( + DataVisualizationChartPie DataVisualizationChartType = "pie" + DataVisualizationChartBar DataVisualizationChartType = "bar" + DataVisualizationChartArea DataVisualizationChartType = "area" + DataVisualizationChartLine DataVisualizationChartType = "line" +) + +// DataVisualizationChart is implemented by every chart payload valid inside a +// DataVisualizationBlock: pie, bar, area, and line. +type DataVisualizationChart interface { + DataVisualizationChartType() DataVisualizationChartType +} + +// DataVisualizationSegment is a labeled slice in a pie chart. +type DataVisualizationSegment struct { + // Label is the display name for this slice, shown in the legend and on hover. + // Slack requires a maximum of 20 characters. + Label string `json:"label"` + // Value is the numeric weight of this slice. Slack requires it to be greater + // than 0. + Value float64 `json:"value"` +} + +// NewDataVisualizationSegment returns a pie-chart segment. +func NewDataVisualizationSegment(label string, value float64) DataVisualizationSegment { + return DataVisualizationSegment{Label: label, Value: value} +} + +// DataVisualizationDataPoint is a labeled point in a bar, area, or line chart. +type DataVisualizationDataPoint struct { + // Label is the x-axis category this point belongs to. Slack requires it to + // match one of AxisConfig.Categories and be at most 20 characters. + Label string `json:"label"` + // Value is the numeric y-axis value. Slack permits negative values. + Value float64 `json:"value"` +} + +// NewDataVisualizationDataPoint returns a chart data point. +func NewDataVisualizationDataPoint(label string, value float64) DataVisualizationDataPoint { + return DataVisualizationDataPoint{Label: label, Value: value} +} + +// DataVisualizationDataSeries is a named sequence of data points. +type DataVisualizationDataSeries struct { + // Name is the human-readable identifier displayed in the chart legend. Slack + // requires it to be unique across all series in the same chart and at most 20 + // characters. + Name string `json:"name"` + // Data is the ordered set of data points. Slack requires 1 to 20 points and + // exactly one point for every AxisConfig.Categories entry. + Data []DataVisualizationDataPoint `json:"data"` +} + +// NewDataVisualizationDataSeries returns a named chart series. +func NewDataVisualizationDataSeries(name string, data ...DataVisualizationDataPoint) DataVisualizationDataSeries { + return DataVisualizationDataSeries{Name: name, Data: append([]DataVisualizationDataPoint{}, data...)} +} + +// DataVisualizationAxisConfig configures axis categories and optional labels. +type DataVisualizationAxisConfig struct { + // Categories defines valid data point labels and their left-to-right display + // order. Slack requires each category label to be at most 20 characters. + Categories []string `json:"categories"` + // XLabel is an optional descriptive title displayed below the x-axis. Slack + // requires a maximum of 50 characters. + XLabel string `json:"x_label,omitempty"` + // YLabel is an optional descriptive title displayed beside the y-axis. Slack + // requires a maximum of 50 characters. + YLabel string `json:"y_label,omitempty"` +} + +// NewDataVisualizationAxisConfig returns an axis configuration with the given +// categories. +func NewDataVisualizationAxisConfig(categories ...string) DataVisualizationAxisConfig { + return DataVisualizationAxisConfig{Categories: append([]string{}, categories...)} +} + +// WithXLabel sets the x-axis label. +func (c DataVisualizationAxisConfig) WithXLabel(label string) DataVisualizationAxisConfig { + c.XLabel = label + return c +} + +// WithYLabel sets the y-axis label. +func (c DataVisualizationAxisConfig) WithYLabel(label string) DataVisualizationAxisConfig { + c.YLabel = label + return c +} + +// DataVisualizationPieChart is a pie chart payload. +type DataVisualizationPieChart struct { + Type DataVisualizationChartType `json:"type"` + // Segments are the labeled slices that make up the pie. Slack requires 1 to + // 6 segments. + Segments []DataVisualizationSegment `json:"segments"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationPieChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationPieChart returns a pie chart with the given segments. +func NewDataVisualizationPieChart(segments ...DataVisualizationSegment) *DataVisualizationPieChart { + return &DataVisualizationPieChart{ + Type: DataVisualizationChartPie, + Segments: append([]DataVisualizationSegment{}, segments...), + } +} + +// DataVisualizationBarChart is a bar chart payload. +type DataVisualizationBarChart struct { + Type DataVisualizationChartType `json:"type"` + // Series are plotted as bar groups. Slack requires 1 to 6 series; for + // multiple series, bars are grouped by data point label. + Series []DataVisualizationDataSeries `json:"series"` + // AxisConfig defines x-axis categories and axis titles. Slack requires this + // field for bar charts. + AxisConfig DataVisualizationAxisConfig `json:"axis_config"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationBarChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationBarChart returns a bar chart. +func NewDataVisualizationBarChart(axisConfig DataVisualizationAxisConfig, series ...DataVisualizationDataSeries) *DataVisualizationBarChart { + return &DataVisualizationBarChart{ + Type: DataVisualizationChartBar, + Series: append([]DataVisualizationDataSeries{}, series...), + AxisConfig: axisConfig, + } +} + +// DataVisualizationAreaChart is an area chart payload. +type DataVisualizationAreaChart struct { + Type DataVisualizationChartType `json:"type"` + // Series are plotted as filled areas. Slack requires 1 to 6 series and + // layers them in array order, with the first series at the back. + Series []DataVisualizationDataSeries `json:"series"` + // AxisConfig defines x-axis categories and axis titles. Slack requires this + // field for area charts. + AxisConfig DataVisualizationAxisConfig `json:"axis_config"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationAreaChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationAreaChart returns an area chart. +func NewDataVisualizationAreaChart(axisConfig DataVisualizationAxisConfig, series ...DataVisualizationDataSeries) *DataVisualizationAreaChart { + return &DataVisualizationAreaChart{ + Type: DataVisualizationChartArea, + Series: append([]DataVisualizationDataSeries{}, series...), + AxisConfig: axisConfig, + } +} + +// DataVisualizationLineChart is a line chart payload. +type DataVisualizationLineChart struct { + Type DataVisualizationChartType `json:"type"` + // Series are plotted as lines. Slack requires 1 to 6 series. + Series []DataVisualizationDataSeries `json:"series"` + // AxisConfig defines x-axis categories and axis titles. Slack requires this + // field for line charts. + AxisConfig DataVisualizationAxisConfig `json:"axis_config"` +} + +// DataVisualizationChartType returns the chart variant. +func (c DataVisualizationLineChart) DataVisualizationChartType() DataVisualizationChartType { + return c.Type +} + +// NewDataVisualizationLineChart returns a line chart. +func NewDataVisualizationLineChart(axisConfig DataVisualizationAxisConfig, series ...DataVisualizationDataSeries) *DataVisualizationLineChart { + return &DataVisualizationLineChart{ + Type: DataVisualizationChartLine, + Series: append([]DataVisualizationDataSeries{}, series...), + AxisConfig: axisConfig, + } +} + +// DataVisualizationBlock defines a block that displays data as a pie, bar, +// area, or line chart. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/ +type DataVisualizationBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + // Title is the short label displayed above the chart. Slack requires a + // maximum of 50 characters. + Title string `json:"title"` + // Chart is the chart-specific payload. Slack requires one of pie, bar, area, + // or line. + Chart DataVisualizationChart `json:"chart"` +} + +// BlockType returns the type of the block. +func (s DataVisualizationBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block. +func (s DataVisualizationBlock) ID() string { + return s.BlockID +} + +// Validate checks whether the block satisfies Slack's documented data +// visualization constraints. +func (s DataVisualizationBlock) Validate() error { + if s.Type != MBTDataVisualization { + return fmt.Errorf("type must be %q", MBTDataVisualization) + } + if s.Title == "" { + return fmt.Errorf("title must have a minimum length of 1") + } + if runeLen(s.Title) > 50 { + return fmt.Errorf("title cannot be longer than 50 characters") + } + if isNilDataVisualizationChart(s.Chart) { + return fmt.Errorf("chart is required") + } + + switch chart := s.Chart.(type) { + case *DataVisualizationPieChart: + return validateDataVisualizationPieChart(chart) + case DataVisualizationPieChart: + return validateDataVisualizationPieChart(&chart) + case *DataVisualizationBarChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartBar, chart.Type, chart.Series, chart.AxisConfig) + case DataVisualizationBarChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartBar, chart.Type, chart.Series, chart.AxisConfig) + case *DataVisualizationAreaChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartArea, chart.Type, chart.Series, chart.AxisConfig) + case DataVisualizationAreaChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartArea, chart.Type, chart.Series, chart.AxisConfig) + case *DataVisualizationLineChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartLine, chart.Type, chart.Series, chart.AxisConfig) + case DataVisualizationLineChart: + return validateDataVisualizationSeriesChart(DataVisualizationChartLine, chart.Type, chart.Series, chart.AxisConfig) + default: + return fmt.Errorf("unsupported data_visualization chart type %q", s.Chart.DataVisualizationChartType()) + } +} + +func isNilDataVisualizationChart(chart DataVisualizationChart) bool { + switch chart := chart.(type) { + case nil: + return true + case *DataVisualizationPieChart: + return chart == nil + case *DataVisualizationBarChart: + return chart == nil + case *DataVisualizationAreaChart: + return chart == nil + case *DataVisualizationLineChart: + return chart == nil + default: + return false + } +} + +func validateDataVisualizationPieChart(chart *DataVisualizationPieChart) error { + if chart.Type != DataVisualizationChartPie { + return fmt.Errorf("chart type must be %q", DataVisualizationChartPie) + } + if len(chart.Segments) < 1 { + return fmt.Errorf("pie chart must have at least 1 segment") + } + if len(chart.Segments) > 6 { + return fmt.Errorf("pie chart cannot have more than 6 segments") + } + for i, segment := range chart.Segments { + if segment.Label == "" { + return fmt.Errorf("segment %d label must have a minimum length of 1", i) + } + if runeLen(segment.Label) > 20 { + return fmt.Errorf("segment %d label cannot be longer than 20 characters", i) + } + if segment.Value <= 0 { + return fmt.Errorf("segment %d value must be greater than 0", i) + } + } + return nil +} + +func validateDataVisualizationSeriesChart( + expectedType DataVisualizationChartType, + actualType DataVisualizationChartType, + series []DataVisualizationDataSeries, + axisConfig DataVisualizationAxisConfig, +) error { + if actualType != expectedType { + return fmt.Errorf("chart type must be %q", expectedType) + } + if len(series) < 1 { + return fmt.Errorf("%s chart must have at least 1 series", expectedType) + } + if len(series) > 6 { + return fmt.Errorf("%s chart cannot have more than 6 series", expectedType) + } + if len(axisConfig.Categories) == 0 { + return fmt.Errorf("axis_config.categories must have at least 1 category") + } + if runeLen(axisConfig.XLabel) > 50 { + return fmt.Errorf("axis_config.x_label cannot be longer than 50 characters") + } + if runeLen(axisConfig.YLabel) > 50 { + return fmt.Errorf("axis_config.y_label cannot be longer than 50 characters") + } + + categories := make(map[string]struct{}, len(axisConfig.Categories)) + for i, category := range axisConfig.Categories { + if category == "" { + return fmt.Errorf("axis_config.categories[%d] must have a minimum length of 1", i) + } + if runeLen(category) > 20 { + return fmt.Errorf("axis_config.categories[%d] cannot be longer than 20 characters", i) + } + if _, exists := categories[category]; exists { + return fmt.Errorf("axis_config.categories must not contain duplicate category %q", category) + } + categories[category] = struct{}{} + } + + seriesNames := make(map[string]struct{}, len(series)) + for i, s := range series { + if s.Name == "" { + return fmt.Errorf("series %d name must have a minimum length of 1", i) + } + if runeLen(s.Name) > 20 { + return fmt.Errorf("series %d name cannot be longer than 20 characters", i) + } + if _, exists := seriesNames[s.Name]; exists { + return fmt.Errorf("series names must be unique: %q", s.Name) + } + seriesNames[s.Name] = struct{}{} + + if len(s.Data) < 1 { + return fmt.Errorf("series %d data must have at least 1 point", i) + } + if len(s.Data) > 20 { + return fmt.Errorf("series %d data cannot have more than 20 points", i) + } + if len(s.Data) != len(axisConfig.Categories) { + return fmt.Errorf("series %d data must contain exactly one point for every category", i) + } + + seenLabels := make(map[string]struct{}, len(s.Data)) + for j, point := range s.Data { + if point.Label == "" { + return fmt.Errorf("series %d data point %d label must have a minimum length of 1", i, j) + } + if runeLen(point.Label) > 20 { + return fmt.Errorf("series %d data point %d label cannot be longer than 20 characters", i, j) + } + if _, exists := categories[point.Label]; !exists { + return fmt.Errorf("series %d data point %d label %q must match axis_config.categories", i, j, point.Label) + } + if _, exists := seenLabels[point.Label]; exists { + return fmt.Errorf("series %d data must not contain duplicate label %q", i, point.Label) + } + seenLabels[point.Label] = struct{}{} + } + } + + return nil +} + +func runeLen(s string) int { + return utf8.RuneCountInString(s) +} + +// UnmarshalJSON parses the chart-specific payload. +func (s *DataVisualizationBlock) UnmarshalJSON(data []byte) error { + var raw struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id"` + Title string `json:"title"` + Chart json.RawMessage `json:"chart"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var probe struct { + Type DataVisualizationChartType `json:"type"` + } + if err := json.Unmarshal(raw.Chart, &probe); err != nil { + return err + } + + var chart DataVisualizationChart + switch probe.Type { + case DataVisualizationChartPie: + chart = &DataVisualizationPieChart{} + case DataVisualizationChartBar: + chart = &DataVisualizationBarChart{} + case DataVisualizationChartArea: + chart = &DataVisualizationAreaChart{} + case DataVisualizationChartLine: + chart = &DataVisualizationLineChart{} + default: + return fmt.Errorf("unsupported data_visualization chart type %q", probe.Type) + } + + if err := json.Unmarshal(raw.Chart, chart); err != nil { + return err + } + + s.Type = raw.Type + s.BlockID = raw.BlockID + s.Title = raw.Title + s.Chart = chart + return nil +} + +// DataVisualizationBlockOption configures optional fields on a new +// DataVisualizationBlock. +type DataVisualizationBlockOption func(*DataVisualizationBlock) + +// DataVisualizationBlockOptionBlockID sets the block ID. +func DataVisualizationBlockOptionBlockID(blockID string) DataVisualizationBlockOption { + return func(b *DataVisualizationBlock) { b.BlockID = blockID } +} + +// NewDataVisualizationBlock returns a new DataVisualizationBlock with the given +// title and chart payload. +func NewDataVisualizationBlock(title string, chart DataVisualizationChart, options ...DataVisualizationBlockOption) *DataVisualizationBlock { + block := &DataVisualizationBlock{ + Type: MBTDataVisualization, + Title: title, + Chart: chart, + } + for _, opt := range options { + if opt != nil { + opt(block) + } + } + return block +} diff --git a/block_data_visualization_test.go b/block_data_visualization_test.go new file mode 100644 index 000000000..f112b5bb9 --- /dev/null +++ b/block_data_visualization_test.go @@ -0,0 +1,471 @@ +package slack + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDataVisualizationBlock(t *testing.T) { + chart := NewDataVisualizationPieChart( + NewDataVisualizationSegment("Kit Kat", 45), + NewDataVisualizationSegment("Twix", 28), + ) + block := NewDataVisualizationBlock("Candy Bars", chart, + DataVisualizationBlockOptionBlockID("dv-1"), + ) + + assert.Equal(t, MBTDataVisualization, block.BlockType()) + assert.Equal(t, "data_visualization", string(block.Type)) + assert.Equal(t, "dv-1", block.ID()) + assert.Equal(t, "Candy Bars", block.Title) + assert.Equal(t, DataVisualizationChartPie, block.Chart.DataVisualizationChartType()) +} + +func TestNewDataVisualizationBlockWithNilOption(t *testing.T) { + assert.NotPanics(t, func() { + NewDataVisualizationBlock("title", NewDataVisualizationPieChart(), nil) + }, "should not panic when nil option passed") +} + +func TestDataVisualizationBlockPieJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "data_visualization", + "block_id": "dv-pie", + "title": "My Favorite Candy Bars", + "chart": { + "type": "pie", + "segments": [ + {"label": "Kit Kat", "value": 45}, + {"label": "Twix", "value": 28}, + {"label": "Crunch", "value": 18}, + {"label": "Milky Way", "value": 9} + ] + } + }` + + var block DataVisualizationBlock + require.NoError(t, json.Unmarshal([]byte(payload), &block)) + + assert.Equal(t, MBTDataVisualization, block.BlockType()) + assert.Equal(t, "dv-pie", block.ID()) + assert.Equal(t, "My Favorite Candy Bars", block.Title) + pie, ok := block.Chart.(*DataVisualizationPieChart) + require.True(t, ok) + require.Len(t, pie.Segments, 4) + assert.Equal(t, "Kit Kat", pie.Segments[0].Label) + assert.Equal(t, float64(45), pie.Segments[0].Value) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestDataVisualizationBlockSeriesChartsJSONRoundTrip(t *testing.T) { + payload := `[ + { + "type": "data_visualization", + "block_id": "dv-bar", + "title": "Pie Tastiness", + "chart": { + "type": "bar", + "series": [ + { + "name": "Pies", + "data": [ + {"label": "Pumpkin", "value": 70}, + {"label": "Blueberry", "value": 90} + ] + } + ], + "axis_config": { + "categories": ["Pumpkin", "Blueberry"], + "x_label": "Pies", + "y_label": "Percentage of Tastiness" + } + } + }, + { + "type": "data_visualization", + "block_id": "dv-area", + "title": "Daily Active Users", + "chart": { + "type": "area", + "series": [ + { + "name": "Free Tier", + "data": [ + {"label": "Mon", "value": 12000}, + {"label": "Tue", "value": 13500} + ] + }, + { + "name": "Paid Tier", + "data": [ + {"label": "Mon", "value": 4500}, + {"label": "Tue", "value": 4800} + ] + } + ], + "axis_config": { + "categories": ["Mon", "Tue"], + "x_label": "Day", + "y_label": "Users" + } + } + }, + { + "type": "data_visualization", + "block_id": "dv-line", + "title": "Weekly Paper Sales", + "chart": { + "type": "line", + "series": [ + { + "name": "Website", + "data": [ + {"label": "Week 1", "value": 32000}, + {"label": "Week 2", "value": 35000} + ] + } + ], + "axis_config": { + "categories": ["Week 1", "Week 2"], + "x_label": "Week", + "y_label": "Paper Sales (USD)" + } + } + } + ]` + + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte(payload), &blocks)) + require.Len(t, blocks.BlockSet, 3) + + bar, ok := blocks.BlockSet[0].(*DataVisualizationBlock) + require.True(t, ok, "expected *DataVisualizationBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTDataVisualization, bar.BlockType()) + assert.Equal(t, "dv-bar", bar.ID()) + barChart, ok := bar.Chart.(*DataVisualizationBarChart) + require.True(t, ok) + require.Len(t, barChart.Series, 1) + assert.Equal(t, "Pies", barChart.Series[0].Name) + assert.Equal(t, []string{"Pumpkin", "Blueberry"}, barChart.AxisConfig.Categories) + + area := blocks.BlockSet[1].(*DataVisualizationBlock) + areaChart, ok := area.Chart.(*DataVisualizationAreaChart) + require.True(t, ok) + require.Len(t, areaChart.Series, 2) + assert.Equal(t, DataVisualizationChartArea, areaChart.DataVisualizationChartType()) + + line := blocks.BlockSet[2].(*DataVisualizationBlock) + lineChart, ok := line.Chart.(*DataVisualizationLineChart) + require.True(t, ok) + assert.Equal(t, "Week", lineChart.AxisConfig.XLabel) + assert.Equal(t, "Paper Sales (USD)", lineChart.AxisConfig.YLabel) + + marshalled, err := json.Marshal(blocks) + require.NoError(t, err) + + var expected, actual []map[string]any + require.NoError(t, json.Unmarshal([]byte(payload), &expected)) + require.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestDataVisualizationBlockConstructorsMarshal(t *testing.T) { + axis := NewDataVisualizationAxisConfig("Week 1", "Week 2"). + WithXLabel("Week"). + WithYLabel("Paper Sales") + series := NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + NewDataVisualizationDataPoint("Week 2", 35000), + ) + block := NewDataVisualizationBlock( + "Weekly Paper Sales", + NewDataVisualizationLineChart(axis, series), + ) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + assert.JSONEq(t, `{ + "type": "data_visualization", + "title": "Weekly Paper Sales", + "chart": { + "type": "line", + "series": [ + { + "name": "Website", + "data": [ + {"label": "Week 1", "value": 32000}, + {"label": "Week 2", "value": 35000} + ] + } + ], + "axis_config": { + "categories": ["Week 1", "Week 2"], + "x_label": "Week", + "y_label": "Paper Sales" + } + } + }`, string(marshalled)) +} + +func TestDataVisualizationBlockUnknownChartType(t *testing.T) { + payload := `{ + "type": "data_visualization", + "title": "Mystery Chart", + "chart": { + "type": "scatter", + "series": [] + } + }` + + var block DataVisualizationBlock + err := json.Unmarshal([]byte(payload), &block) + require.Error(t, err) + assert.Contains(t, err.Error(), "scatter") +} + +func TestDataVisualizationBlockValidate(t *testing.T) { + validPie := NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 45)), + ) + require.NoError(t, validPie.Validate()) + + validLine := NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1", "Week 2"). + WithXLabel("Week"). + WithYLabel("Paper Sales"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + NewDataVisualizationDataPoint("Week 2", -10), + ), + ), + ) + require.NoError(t, validLine.Validate(), "negative data point values are allowed") +} + +func TestDataVisualizationBlockValidateFieldLimits(t *testing.T) { + tests := []struct { + name string + block *DataVisualizationBlock + errorText string + }{ + { + name: "missing title", + block: NewDataVisualizationBlock("", + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 45)), + ), + errorText: "title must have a minimum length of 1", + }, + { + name: "title too long", + block: NewDataVisualizationBlock(strings.Repeat("a", 51), + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 45)), + ), + errorText: "title cannot be longer than 50 characters", + }, + { + name: "missing chart", + block: &DataVisualizationBlock{ + Type: MBTDataVisualization, + Title: "No Chart", + }, + errorText: "chart is required", + }, + { + name: "typed nil chart", + block: &DataVisualizationBlock{ + Type: MBTDataVisualization, + Title: "Typed Nil Chart", + Chart: (*DataVisualizationLineChart)(nil), + }, + errorText: "chart is required", + }, + { + name: "segment label too long", + block: NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart(NewDataVisualizationSegment(strings.Repeat("a", 21), 45)), + ), + errorText: "segment 0 label cannot be longer than 20 characters", + }, + { + name: "segment value is zero", + block: NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart(NewDataVisualizationSegment("Kit Kat", 0)), + ), + errorText: "segment 0 value must be greater than 0", + }, + { + name: "series name too long", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries(strings.Repeat("a", 21), + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "series 0 name cannot be longer than 20 characters", + }, + { + name: "category too long", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig(strings.Repeat("a", 21)), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint(strings.Repeat("a", 21), 32000), + ), + ), + ), + errorText: "axis_config.categories[0] cannot be longer than 20 characters", + }, + { + name: "axis label too long", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1").WithXLabel(strings.Repeat("a", 51)), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "axis_config.x_label cannot be longer than 50 characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.block.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorText) + }) + } +} + +func TestDataVisualizationBlockValidatePieConstraints(t *testing.T) { + noSegments := NewDataVisualizationBlock("Candy Bars", NewDataVisualizationPieChart()) + err := noSegments.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "pie chart must have at least 1 segment") + + tooManySegments := NewDataVisualizationBlock("Candy Bars", + NewDataVisualizationPieChart( + NewDataVisualizationSegment("A", 1), + NewDataVisualizationSegment("B", 1), + NewDataVisualizationSegment("C", 1), + NewDataVisualizationSegment("D", 1), + NewDataVisualizationSegment("E", 1), + NewDataVisualizationSegment("F", 1), + NewDataVisualizationSegment("G", 1), + ), + ) + err = tooManySegments.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "pie chart cannot have more than 6 segments") +} + +func TestDataVisualizationBlockValidateSeriesRuntimeRules(t *testing.T) { + tests := []struct { + name string + block *DataVisualizationBlock + errorText string + }{ + { + name: "missing axis config categories", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart(DataVisualizationAxisConfig{}, + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "axis_config.categories must have at least 1 category", + }, + { + name: "data point label outside categories", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 2", 32000), + ), + ), + ), + errorText: `label "Week 2" must match axis_config.categories`, + }, + { + name: "omitted category point", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1", "Week 2"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + ), + ), + errorText: "series 0 data must contain exactly one point for every category", + }, + { + name: "duplicate data label", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1", "Week 2"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + NewDataVisualizationDataPoint("Week 1", 35000), + ), + ), + ), + errorText: `series 0 data must not contain duplicate label "Week 1"`, + }, + { + name: "duplicate series name", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 32000), + ), + NewDataVisualizationDataSeries("Website", + NewDataVisualizationDataPoint("Week 1", 35000), + ), + ), + ), + errorText: `series names must be unique: "Website"`, + }, + { + name: "too many series", + block: NewDataVisualizationBlock("Weekly Paper Sales", + NewDataVisualizationLineChart( + NewDataVisualizationAxisConfig("Week 1"), + NewDataVisualizationDataSeries("A", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("B", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("C", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("D", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("E", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("F", NewDataVisualizationDataPoint("Week 1", 1)), + NewDataVisualizationDataSeries("G", NewDataVisualizationDataPoint("Week 1", 1)), + ), + ), + errorText: "line chart cannot have more than 6 series", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.block.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorText) + }) + } +} diff --git a/block_divider.go b/block_divider.go index 2d442ba11..e10d7b053 100644 --- a/block_divider.go +++ b/block_divider.go @@ -13,10 +13,14 @@ func (s DividerBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s DividerBlock) ID() string { + return s.BlockID +} + // NewDividerBlock returns a new instance of a divider block func NewDividerBlock() *DividerBlock { return &DividerBlock{ Type: MBTDivider, } - } diff --git a/block_divider_test.go b/block_divider_test.go index 35e3d48d5..27b81aad6 100644 --- a/block_divider_test.go +++ b/block_divider_test.go @@ -7,8 +7,10 @@ import ( ) func TestNewDividerBlock(t *testing.T) { - dividerBlock := NewDividerBlock() - assert.Equal(t, string(dividerBlock.Type), "divider") + assert.Equal(t, dividerBlock.BlockType(), MBTDivider) + assert.Equal(t, string(dividerBlock.Type), "divider") + assert.Equal(t, dividerBlock.BlockID, "") + assert.Equal(t, dividerBlock.ID(), "") } diff --git a/block_element.go b/block_element.go index 21abb018a..128493407 100644 --- a/block_element.go +++ b/block_element.go @@ -3,14 +3,23 @@ package slack // https://api.slack.com/reference/messaging/block-elements const ( - METCheckboxGroups MessageElementType = "checkboxes" - METImage MessageElementType = "image" - METButton MessageElementType = "button" - METOverflow MessageElementType = "overflow" - METDatepicker MessageElementType = "datepicker" - METTimepicker MessageElementType = "timepicker" - METPlainTextInput MessageElementType = "plain_text_input" - METRadioButtons MessageElementType = "radio_buttons" + METCheckboxGroups MessageElementType = "checkboxes" + METImage MessageElementType = "image" + METButton MessageElementType = "button" + METOverflow MessageElementType = "overflow" + METDatepicker MessageElementType = "datepicker" + METTimepicker MessageElementType = "timepicker" + METDatetimepicker MessageElementType = "datetimepicker" + METPlainTextInput MessageElementType = "plain_text_input" + METRadioButtons MessageElementType = "radio_buttons" + METRichTextInput MessageElementType = "rich_text_input" + METEmailTextInput MessageElementType = "email_text_input" + METURLTextInput MessageElementType = "url_text_input" + METNumber MessageElementType = "number_input" + METFileInput MessageElementType = "file_input" + METFeedbackButtons MessageElementType = "feedback_buttons" + METIconButton MessageElementType = "icon_button" + METWorkflowButton MessageElementType = "workflow_button" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -47,36 +56,42 @@ type Accessory struct { DatePickerElement *DatePickerBlockElement TimePickerElement *TimePickerBlockElement PlainTextInputElement *PlainTextInputBlockElement + RichTextInputElement *RichTextInputBlockElement RadioButtonsElement *RadioButtonsBlockElement SelectElement *SelectBlockElement MultiSelectElement *MultiSelectBlockElement CheckboxGroupsBlockElement *CheckboxGroupsBlockElement + WorkflowButtonElement *WorkflowButtonBlockElement UnknownElement *UnknownBlockElement } // NewAccessory returns a new Accessory for a given block element func NewAccessory(element BlockElement) *Accessory { - switch element.(type) { + switch element := element.(type) { case *ImageBlockElement: - return &Accessory{ImageElement: element.(*ImageBlockElement)} + return &Accessory{ImageElement: element} case *ButtonBlockElement: - return &Accessory{ButtonElement: element.(*ButtonBlockElement)} + return &Accessory{ButtonElement: element} case *OverflowBlockElement: - return &Accessory{OverflowElement: element.(*OverflowBlockElement)} + return &Accessory{OverflowElement: element} case *DatePickerBlockElement: - return &Accessory{DatePickerElement: element.(*DatePickerBlockElement)} + return &Accessory{DatePickerElement: element} case *TimePickerBlockElement: - return &Accessory{TimePickerElement: element.(*TimePickerBlockElement)} + return &Accessory{TimePickerElement: element} case *PlainTextInputBlockElement: - return &Accessory{PlainTextInputElement: element.(*PlainTextInputBlockElement)} + return &Accessory{PlainTextInputElement: element} + case *RichTextInputBlockElement: + return &Accessory{RichTextInputElement: element} case *RadioButtonsBlockElement: - return &Accessory{RadioButtonsElement: element.(*RadioButtonsBlockElement)} + return &Accessory{RadioButtonsElement: element} case *SelectBlockElement: - return &Accessory{SelectElement: element.(*SelectBlockElement)} + return &Accessory{SelectElement: element} case *MultiSelectBlockElement: - return &Accessory{MultiSelectElement: element.(*MultiSelectBlockElement)} + return &Accessory{MultiSelectElement: element} case *CheckboxGroupsBlockElement: - return &Accessory{CheckboxGroupsBlockElement: element.(*CheckboxGroupsBlockElement)} + return &Accessory{CheckboxGroupsBlockElement: element} + case *WorkflowButtonBlockElement: + return &Accessory{WorkflowButtonElement: element} default: return &Accessory{UnknownElement: element.(*UnknownBlockElement)} } @@ -108,9 +123,10 @@ func (s UnknownBlockElement) ElementType() MessageElementType { // // More Information: https://api.slack.com/reference/messaging/block-elements#image type ImageBlockElement struct { - Type MessageElementType `json:"type"` - ImageURL string `json:"image_url"` - AltText string `json:"alt_text"` + Type MessageElementType `json:"type"` + ImageURL *string `json:"image_url,omitempty"` + AltText string `json:"alt_text"` + SlackFile *SlackFileObject `json:"slack_file,omitempty"` } // ElementType returns the type of the Element @@ -126,11 +142,21 @@ func (s ImageBlockElement) MixedElementType() MixedElementType { func NewImageBlockElement(imageURL, altText string) *ImageBlockElement { return &ImageBlockElement{ Type: METImage, - ImageURL: imageURL, + ImageURL: &imageURL, AltText: altText, } } +// NewImageBlockElementSlackFile returns a new instance of an image block element +// TODO: BREAKING CHANGE - This should be combined with the function above +func NewImageBlockElementSlackFile(slackFile *SlackFileObject, altText string) *ImageBlockElement { + return &ImageBlockElement{ + Type: METImage, + SlackFile: slackFile, + AltText: altText, + } +} + // Style is a style of Button element // https://api.slack.com/reference/block-kit/block-elements#button__fields type Style string @@ -167,6 +193,18 @@ func (s *ButtonBlockElement) WithStyle(style Style) *ButtonBlockElement { return s } +// WithConfirm adds a confirmation dialogue to the button object and returns the modified ButtonBlockElement +func (s *ButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *ButtonBlockElement { + s.Confirm = confirm + 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{ @@ -210,6 +248,7 @@ type SelectBlockElement struct { Filter *SelectBlockElementFilter `json:"filter,omitempty"` MinQueryLength *int `json:"min_query_length,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // SelectBlockElementFilter allows to filter select element conversation options by type. @@ -237,6 +276,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( @@ -267,9 +336,11 @@ type MultiSelectBlockElement struct { InitialUsers []string `json:"initial_users,omitempty"` InitialConversations []string `json:"initial_conversations,omitempty"` InitialChannels []string `json:"initial_channels,omitempty"` + Filter *SelectBlockElementFilter `json:"filter,omitempty"` MinQueryLength *int `json:"min_query_length,omitempty"` MaxSelectedItems *int `json:"max_selected_items,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -288,6 +359,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( @@ -331,6 +444,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. @@ -342,6 +461,7 @@ type DatePickerBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialDate string `json:"initial_date,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -368,6 +488,8 @@ type TimePickerBlockElement struct { Placeholder *TextBlockObject `json:"placeholder,omitempty"` InitialTime string `json:"initial_time,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + Timezone string `json:"timezone,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -383,6 +505,88 @@ func NewTimePickerBlockElement(actionID string) *TimePickerBlockElement { } } +// DateTimePickerBlockElement defines an element that allows the selection of both +// a date and a time of day formatted as a UNIX timestamp. +// More Information: https://api.slack.com/reference/messaging/block-elements#datetimepicker +type DateTimePickerBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + InitialDateTime int64 `json:"initial_date_time,omitempty"` + Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` +} + +// ElementType returns the type of the Element +func (s DateTimePickerBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewDatePickerBlockElement returns an instance of a datetime picker element +func NewDateTimePickerBlockElement(actionID string) *DateTimePickerBlockElement { + return &DateTimePickerBlockElement{ + Type: METDatetimepicker, + ActionID: actionID, + } +} + +// EmailTextInputBlockElement creates a field where a user can enter email +// data. +// email-text-input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#email +type EmailTextInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_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 EmailTextInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewEmailTextInputBlockElement returns an instance of a plain-text input +// element +func NewEmailTextInputBlockElement(placeholder *TextBlockObject, actionID string) *EmailTextInputBlockElement { + return &EmailTextInputBlockElement{ + Type: METEmailTextInput, + ActionID: actionID, + Placeholder: placeholder, + } +} + +// URLTextInputBlockElement creates a field where a user can enter url data. +// +// url-text-input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#url +type URLTextInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_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 URLTextInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewURLTextInputBlockElement returns an instance of a plain-text input +// element +func NewURLTextInputBlockElement(placeholder *TextBlockObject, actionID string) *URLTextInputBlockElement { + return &URLTextInputBlockElement{ + Type: METURLTextInput, + ActionID: actionID, + Placeholder: placeholder, + } +} + // PlainTextInputBlockElement creates a field where a user can enter freeform // data. // Plain-text input elements are currently only available in modals. @@ -397,6 +601,7 @@ type PlainTextInputBlockElement struct { MinLength int `json:"min_length,omitempty"` MaxLength int `json:"max_length,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } type DispatchActionConfig struct { @@ -418,6 +623,62 @@ 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 +type RichTextInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue *RichTextBlock `json:"initial_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 RichTextInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewRichTextInputBlockElement returns an instance of a rich-text input element +func NewRichTextInputBlockElement(placeholder *TextBlockObject, actionID string) *RichTextInputBlockElement { + return &RichTextInputBlockElement{ + Type: METRichTextInput, + ActionID: actionID, + Placeholder: placeholder, + } +} + // CheckboxGroupsBlockElement defines an element which allows users to choose // one or more items from a list of possible options. // @@ -428,6 +689,7 @@ type CheckboxGroupsBlockElement struct { Options []*OptionBlockObject `json:"options"` InitialOptions []*OptionBlockObject `json:"initial_options,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -454,6 +716,7 @@ type RadioButtonsBlockElement struct { Options []*OptionBlockObject `json:"options"` InitialOption *OptionBlockObject `json:"initial_option,omitempty"` Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` } // ElementType returns the type of the Element @@ -469,3 +732,263 @@ 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 +} + +// FeedbackButton defines a button within a feedback buttons element +type FeedbackButton struct { + Text *TextBlockObject `json:"text"` + Value string `json:"value"` + AccessibilityLabel string `json:"accessibility_label,omitempty"` +} + +// NewFeedbackButton returns a new instance of a feedback button +func NewFeedbackButton(text *TextBlockObject, value string) *FeedbackButton { + return &FeedbackButton{ + Text: text, + Value: value, + } +} + +// WithAccessibilityLabel sets the accessibility label for the feedback button +func (fb *FeedbackButton) WithAccessibilityLabel(label string) *FeedbackButton { + fb.AccessibilityLabel = label + return fb +} + +// FeedbackButtonsBlockElement defines an element that provides positive/negative feedback options +// +// More Information: https://docs.slack.dev/reference/block-kit/block-elements/feedback-buttons-element +type FeedbackButtonsBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + PositiveButton *FeedbackButton `json:"positive_button"` + NegativeButton *FeedbackButton `json:"negative_button"` +} + +// ElementType returns the type of the element +func (s FeedbackButtonsBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewFeedbackButtonsBlockElement returns a new instance of a feedback buttons element +func NewFeedbackButtonsBlockElement(actionID string, positiveButton, negativeButton *FeedbackButton) *FeedbackButtonsBlockElement { + return &FeedbackButtonsBlockElement{ + Type: METFeedbackButtons, + ActionID: actionID, + PositiveButton: positiveButton, + NegativeButton: negativeButton, + } +} + +// WithPositiveButton sets the positive button for the feedback buttons element +func (s *FeedbackButtonsBlockElement) WithPositiveButton(button *FeedbackButton) *FeedbackButtonsBlockElement { + s.PositiveButton = button + return s +} + +// WithNegativeButton sets the negative button for the feedback buttons element +func (s *FeedbackButtonsBlockElement) WithNegativeButton(button *FeedbackButton) *FeedbackButtonsBlockElement { + s.NegativeButton = button + return s +} + +// IconButtonBlockElement defines an element that displays icon-based interactive buttons +// +// More Information: https://docs.slack.dev/reference/block-kit/block-elements/icon-button-element +type IconButtonBlockElement struct { + Type MessageElementType `json:"type"` + Icon string `json:"icon"` + Text *TextBlockObject `json:"text"` + ActionID string `json:"action_id,omitempty"` + Value string `json:"value,omitempty"` + Confirm *ConfirmationBlockObject `json:"confirm,omitempty"` + AccessibilityLabel string `json:"accessibility_label,omitempty"` + VisibleToUserIDs []string `json:"visible_to_user_ids,omitempty"` +} + +// ElementType returns the type of the element +func (s IconButtonBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewIconButtonBlockElement returns a new instance of an icon button element +func NewIconButtonBlockElement(icon string, text *TextBlockObject, actionID string) *IconButtonBlockElement { + return &IconButtonBlockElement{ + Type: METIconButton, + Icon: icon, + Text: text, + ActionID: actionID, + } +} + +// WithValue sets the value for the icon button element +func (s *IconButtonBlockElement) WithValue(value string) *IconButtonBlockElement { + s.Value = value + return s +} + +// WithConfirm sets the confirmation dialog for the icon button element +func (s *IconButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *IconButtonBlockElement { + s.Confirm = confirm + return s +} + +// WithAccessibilityLabel sets the accessibility label for the icon button element +func (s *IconButtonBlockElement) WithAccessibilityLabel(label string) *IconButtonBlockElement { + s.AccessibilityLabel = label + return s +} + +// WithVisibleToUserIDs sets the user IDs who can see the icon button element +func (s *IconButtonBlockElement) WithVisibleToUserIDs(userIDs []string) *IconButtonBlockElement { + s.VisibleToUserIDs = userIDs + return s +} + +// WorkflowTrigger defines the workflow to be executed when a workflow button is clicked +type WorkflowTrigger struct { + URL string `json:"url"` + CustomizableInputParameters []CustomizableInputParameter `json:"customizable_input_parameters,omitempty"` +} + +// CustomizableInputParameter defines a parameter that can be passed to a workflow +type CustomizableInputParameter struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Workflow contains the trigger details for a workflow button +type Workflow struct { + Trigger *WorkflowTrigger `json:"trigger"` +} + +// WorkflowButtonBlockElement defines an element that triggers a workflow when clicked +// +// More Information: https://docs.slack.dev/reference/block-kit/block-elements/workflow-button-element +type WorkflowButtonBlockElement struct { + Type MessageElementType `json:"type"` + Text *TextBlockObject `json:"text"` + Workflow *Workflow `json:"workflow"` + ActionID string `json:"action_id"` + Style Style `json:"style,omitempty"` + AccessibilityLabel string `json:"accessibility_label,omitempty"` +} + +// ElementType returns the type of the element +func (s WorkflowButtonBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewWorkflowButtonBlockElement returns a new instance of a workflow button element +func NewWorkflowButtonBlockElement(text *TextBlockObject, workflow *Workflow, actionID string) *WorkflowButtonBlockElement { + return &WorkflowButtonBlockElement{ + Type: METWorkflowButton, + Text: text, + Workflow: workflow, + ActionID: actionID, + } +} + +// WithStyle sets the style for the workflow button element +func (s *WorkflowButtonBlockElement) WithStyle(style Style) *WorkflowButtonBlockElement { + s.Style = style + return s +} + +// WithAccessibilityLabel sets the accessibility label for the workflow button element +func (s *WorkflowButtonBlockElement) WithAccessibilityLabel(label string) *WorkflowButtonBlockElement { + s.AccessibilityLabel = label + return s +} diff --git a/block_element_test.go b/block_element_test.go index 7da816eab..2579cdf2a 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -1,23 +1,31 @@ package slack import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" ) func TestNewImageBlockElement(t *testing.T) { - imageElement := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Location Pin Icon") assert.Equal(t, string(imageElement.Type), "image") - assert.Contains(t, imageElement.ImageURL, "tripAgentLocationMarker") + assert.Contains(t, *imageElement.ImageURL, "tripAgentLocationMarker") assert.Equal(t, imageElement.AltText, "Location Pin Icon") +} + +func TestNewImageBlockElementSlackFile(t *testing.T) { + slackFile := &SlackFileObject{URL: "https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png"} + imageElement := NewImageBlockElementSlackFile(slackFile, "Location Pin Icon") + assert.Equal(t, string(imageElement.Type), "image") + assert.Contains(t, imageElement.SlackFile.URL, "tripAgentLocationMarker") + assert.Equal(t, imageElement.AltText, "Location Pin Icon") + assert.Nil(t, imageElement.ImageURL, "ImageURL should be nil when SlackFile is provided") } func TestNewButtonBlockElement(t *testing.T) { - btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) @@ -25,11 +33,9 @@ func TestNewButtonBlockElement(t *testing.T) { assert.Equal(t, btnElement.ActionID, "test") assert.Equal(t, btnElement.Value, "click_me_123") assert.Equal(t, btnElement.Text.Text, "Next 2 Results") - } func TestWithStyleForButtonElement(t *testing.T) { - // these values are irrelevant in this test btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) @@ -40,11 +46,17 @@ func TestWithStyleForButtonElement(t *testing.T) { assert.Equal(t, btnElement.Style, Style("primary")) btnElement.WithStyle(StyleDanger) assert.Equal(t, btnElement.Style, Style("danger")) +} +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) testOption := NewOptionBlockObject("test", testOptionText, nil) @@ -52,11 +64,9 @@ func TestNewOptionsSelectBlockElement(t *testing.T) { assert.Equal(t, option.Type, "static_select") assert.Equal(t, len(option.Options), 1) assert.Nil(t, option.OptionGroups) - } func TestNewOptionsGroupSelectBlockElement(t *testing.T) { - testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) testOption := NewOptionBlockObject("test", testOptionText, nil) testLabel := NewTextBlockObject("plain_text", "Test Label", false, false) @@ -67,11 +77,9 @@ func TestNewOptionsGroupSelectBlockElement(t *testing.T) { assert.Equal(t, optGroup.Type, "static_select") assert.Equal(t, optGroup.ActionID, "test") assert.Equal(t, len(optGroup.OptionGroups), 1) - } func TestNewOptionsMultiSelectBlockElement(t *testing.T) { - testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) testDescriptionText := NewTextBlockObject("plain_text", "Description One", false, false) testOption := NewOptionBlockObject("test", testOptionText, testDescriptionText) @@ -80,7 +88,6 @@ func TestNewOptionsMultiSelectBlockElement(t *testing.T) { assert.Equal(t, option.Type, "static_select") assert.Equal(t, len(option.Options), 1) assert.Nil(t, option.OptionGroups) - } func TestNewOptionsGroupMultiSelectBlockElement(t *testing.T) { @@ -133,6 +140,12 @@ func TestNewTimePickerBlockElement(t *testing.T) { assert.Equal(t, timepickerElement.ActionID, "test") } +func TestNewDateTimePickerBlockElement(t *testing.T) { + datetimepickerElement := NewDateTimePickerBlockElement("test") + assert.Equal(t, string(datetimepickerElement.Type), "datetimepicker") + assert.Equal(t, datetimepickerElement.ActionID, "test") +} + func TestNewPlainTextInputBlockElement(t *testing.T) { plainTextInputElement := NewPlainTextInputBlockElement(nil, "test") @@ -142,6 +155,26 @@ func TestNewPlainTextInputBlockElement(t *testing.T) { } +func TestNewRichTextInputBlockElement(t *testing.T) { + richTextInputElement := NewRichTextInputBlockElement(nil, "test") + assert.Equal(t, string(richTextInputElement.Type), "rich_text_input") + assert.Equal(t, richTextInputElement.ActionID, "test") +} + +func TestNewEmailTextInputBlockElement(t *testing.T) { + emailTextInputElement := NewEmailTextInputBlockElement(nil, "example@example.com") + + assert.Equal(t, string(emailTextInputElement.Type), "email_text_input") + assert.Equal(t, emailTextInputElement.ActionID, "example@example.com") +} + +func TestNewURLTextInputBlockElement(t *testing.T) { + urlTextInputElement := NewURLTextInputBlockElement(nil, "www.example.com") + + assert.Equal(t, string(urlTextInputElement.Type), "url_text_input") + assert.Equal(t, urlTextInputElement.ActionID, "www.example.com") +} + func TestNewCheckboxGroupsBlockElement(t *testing.T) { // Build Text Objects associated with each option checkBoxOptionTextOne := NewTextBlockObject("plain_text", "Check One", false, false) @@ -185,3 +218,258 @@ 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) +} + +func TestNewFeedbackButton(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Good", false, false) + feedbackButton := NewFeedbackButton(btnText, "positive_feedback") + + assert.Equal(t, feedbackButton.Text.Text, "Good") + assert.Equal(t, feedbackButton.Value, "positive_feedback") + assert.Equal(t, feedbackButton.AccessibilityLabel, "") + + feedbackButton.WithAccessibilityLabel("Mark as good") + assert.Equal(t, feedbackButton.AccessibilityLabel, "Mark as good") +} + +func TestNewFeedbackButtonsBlockElement(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "👍", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "👎", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative") + + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + assert.Equal(t, string(feedbackElement.Type), "feedback_buttons") + assert.Equal(t, feedbackElement.ActionID, "feedback_1") + assert.Equal(t, feedbackElement.PositiveButton.Value, "positive") + assert.Equal(t, feedbackElement.NegativeButton.Value, "negative") +} + +func TestFeedbackButtonsFluentMethods(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "pos") + negativeBtn := NewFeedbackButton(negativeBtnText, "neg") + + feedbackElement := NewFeedbackButtonsBlockElement("feedback_1", positiveBtn, negativeBtn) + + newPositiveText := NewTextBlockObject("plain_text", "Excellent", false, false) + newPositiveBtn := NewFeedbackButton(newPositiveText, "excellent") + feedbackElement.WithPositiveButton(newPositiveBtn) + assert.Equal(t, feedbackElement.PositiveButton.Value, "excellent") + + newNegativeText := NewTextBlockObject("plain_text", "Poor", false, false) + newNegativeBtn := NewFeedbackButton(newNegativeText, "poor") + feedbackElement.WithNegativeButton(newNegativeBtn) + assert.Equal(t, feedbackElement.NegativeButton.Value, "poor") +} + +func TestFeedbackButtonsJSONMarshalling(t *testing.T) { + positiveBtnText := NewTextBlockObject("plain_text", "Good", false, false) + negativeBtnText := NewTextBlockObject("plain_text", "Bad", false, false) + positiveBtn := NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := NewFeedbackButtonsBlockElement("feedback_buttons_1", positiveBtn, negativeBtn) + + data, err := json.Marshal(feedbackElement) + assert.NoError(t, err) + assert.NotNil(t, data) + + var unmarshalled FeedbackButtonsBlockElement + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + assert.Equal(t, "feedback_buttons", string(unmarshalled.Type)) + assert.Equal(t, "feedback_buttons_1", unmarshalled.ActionID) + assert.Equal(t, "positive_feedback", unmarshalled.PositiveButton.Value) + assert.Equal(t, "negative_feedback", unmarshalled.NegativeButton.Value) +} + +func TestNewIconButtonBlockElement(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", btnText, "delete_action") + + assert.Equal(t, string(iconButton.Type), "icon_button") + assert.Equal(t, iconButton.Icon, "trash") + assert.Equal(t, iconButton.Text.Text, "Delete") + assert.Equal(t, iconButton.ActionID, "delete_action") +} + +func TestIconButtonFluentMethods(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", btnText, "delete_action") + + iconButton.WithValue("item_123") + assert.Equal(t, iconButton.Value, "item_123") + + iconButton.WithAccessibilityLabel("Delete this item") + assert.Equal(t, iconButton.AccessibilityLabel, "Delete this item") + + iconButton.WithVisibleToUserIDs([]string{"U123", "U456"}) + assert.Equal(t, len(iconButton.VisibleToUserIDs), 2) + assert.Contains(t, iconButton.VisibleToUserIDs, "U123") + + titleText := NewTextBlockObject("plain_text", "Are you sure?", false, false) + messageText := NewTextBlockObject("plain_text", "This will delete the item", false, false) + confirmText := NewTextBlockObject("plain_text", "Yes", false, false) + denyText := NewTextBlockObject("plain_text", "No", false, false) + confirmObj := NewConfirmationBlockObject(titleText, messageText, confirmText, denyText) + iconButton.WithConfirm(confirmObj) + assert.NotNil(t, iconButton.Confirm) + assert.Equal(t, iconButton.Confirm.Title.Text, "Are you sure?") +} + +func TestIconButtonJSONMarshalling(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := NewIconButtonBlockElement("trash", btnText, "delete_button_1") + iconButton.WithValue("delete_item") + + data, err := json.Marshal(iconButton) + assert.NoError(t, err) + assert.NotNil(t, data) + + var unmarshalled IconButtonBlockElement + err = json.Unmarshal(data, &unmarshalled) + assert.NoError(t, err) + assert.Equal(t, "icon_button", string(unmarshalled.Type)) + assert.Equal(t, "trash", unmarshalled.Icon) + assert.Equal(t, "delete_button_1", unmarshalled.ActionID) + assert.Equal(t, "delete_item", unmarshalled.Value) +} + +func TestNewWorkflowButtonBlockElement(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Run Workflow", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/xyz123", + CustomizableInputParameters: []CustomizableInputParameter{ + {Name: "input_param_a", Value: "Value for input param A"}, + {Name: "input_param_b", Value: "Value for input param B"}, + }, + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "workflow_action_1") + + assert.Equal(t, string(workflowButton.Type), "workflow_button") + assert.Equal(t, "workflow_action_1", workflowButton.ActionID) + assert.Equal(t, "Run Workflow", workflowButton.Text.Text) + assert.NotNil(t, workflowButton.Workflow) + assert.Equal(t, "https://slack.com/shortcuts/Ft123456/xyz123", workflowButton.Workflow.Trigger.URL) + assert.Equal(t, 2, len(workflowButton.Workflow.Trigger.CustomizableInputParameters)) + assert.Equal(t, "input_param_a", workflowButton.Workflow.Trigger.CustomizableInputParameters[0].Name) + assert.Equal(t, "Value for input param A", workflowButton.Workflow.Trigger.CustomizableInputParameters[0].Value) +} + +func TestWorkflowButtonFluentMethods(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Execute", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/xyz123", + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "workflow_1") + + // Test WithStyle + workflowButton.WithStyle(StylePrimary) + assert.Equal(t, StylePrimary, workflowButton.Style) + + workflowButton.WithStyle(StyleDanger) + assert.Equal(t, StyleDanger, workflowButton.Style) + + // Test WithAccessibilityLabel + workflowButton.WithAccessibilityLabel("This button triggers an important workflow") + assert.Equal(t, "This button triggers an important workflow", workflowButton.AccessibilityLabel) + + // Test method chaining + chainedButton := NewWorkflowButtonBlockElement(btnText, workflow, "workflow_2"). + WithStyle(StylePrimary). + WithAccessibilityLabel("Chained accessibility label") + + assert.Equal(t, StylePrimary, chainedButton.Style) + assert.Equal(t, "Chained accessibility label", chainedButton.AccessibilityLabel) +} + +func TestWorkflowButtonJSONMarshalling(t *testing.T) { + btnText := NewTextBlockObject("plain_text", "Start Process", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/abc789", + CustomizableInputParameters: []CustomizableInputParameter{ + {Name: "user_id", Value: "U123456"}, + {Name: "channel_id", Value: "C789012"}, + }, + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "start_workflow"). + WithStyle(StylePrimary). + WithAccessibilityLabel("Start the approval process") + + jsonData, err := json.Marshal(workflowButton) + assert.NoError(t, err) + + var unmarshalled WorkflowButtonBlockElement + err = json.Unmarshal(jsonData, &unmarshalled) + assert.NoError(t, err) + + assert.Equal(t, "workflow_button", string(unmarshalled.Type)) + assert.Equal(t, "start_workflow", unmarshalled.ActionID) + assert.Equal(t, "Start Process", unmarshalled.Text.Text) + assert.Equal(t, StylePrimary, unmarshalled.Style) + assert.Equal(t, "Start the approval process", unmarshalled.AccessibilityLabel) + assert.NotNil(t, unmarshalled.Workflow) + assert.Equal(t, "https://slack.com/shortcuts/Ft123456/abc789", unmarshalled.Workflow.Trigger.URL) + assert.Equal(t, 2, len(unmarshalled.Workflow.Trigger.CustomizableInputParameters)) + assert.Equal(t, "user_id", unmarshalled.Workflow.Trigger.CustomizableInputParameters[0].Name) + assert.Equal(t, "U123456", unmarshalled.Workflow.Trigger.CustomizableInputParameters[0].Value) +} + +func TestWorkflowButtonMinimalConfiguration(t *testing.T) { + // Test with minimal required fields only + btnText := NewTextBlockObject("plain_text", "Simple Workflow", false, false) + workflow := &Workflow{ + Trigger: &WorkflowTrigger{ + URL: "https://slack.com/shortcuts/Ft123456/minimal", + }, + } + workflowButton := NewWorkflowButtonBlockElement(btnText, workflow, "minimal_workflow") + + // Verify no optional fields are set + assert.Equal(t, Style(""), workflowButton.Style) + assert.Equal(t, "", workflowButton.AccessibilityLabel) + assert.Nil(t, workflowButton.Workflow.Trigger.CustomizableInputParameters) + + // Ensure it marshals correctly without optional fields + jsonData, err := json.Marshal(workflowButton) + assert.NoError(t, err) + + // Check that optional fields are omitted from JSON + jsonStr := string(jsonData) + assert.NotContains(t, jsonStr, "style") + assert.NotContains(t, jsonStr, "accessibility_label") + assert.NotContains(t, jsonStr, "customizable_input_parameters") +} diff --git a/block_file.go b/block_file.go index ac4453f79..2f669b02a 100644 --- a/block_file.go +++ b/block_file.go @@ -15,6 +15,11 @@ func (s FileBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s FileBlock) ID() string { + return s.BlockID +} + // NewFileBlock returns a new instance of a file block func NewFileBlock(blockID string, externalID string, source string) *FileBlock { return &FileBlock{ diff --git a/block_file_test.go b/block_file_test.go index c51a93ebe..f52270acb 100644 --- a/block_file_test.go +++ b/block_file_test.go @@ -8,8 +8,11 @@ import ( func TestNewFileBlock(t *testing.T) { fileBlock := NewFileBlock("test", "external_id", "source") + + assert.Equal(t, fileBlock.BlockType(), MBTFile) assert.Equal(t, string(fileBlock.Type), "file") assert.Equal(t, fileBlock.BlockID, "test") + assert.Equal(t, fileBlock.ID(), "test") assert.Equal(t, fileBlock.ExternalID, "external_id") assert.Equal(t, fileBlock.Source, "source") } diff --git a/block_header.go b/block_header.go index 6dff4b883..157079030 100644 --- a/block_header.go +++ b/block_header.go @@ -7,6 +7,9 @@ type HeaderBlock struct { Type MessageBlockType `json:"type"` Text *TextBlockObject `json:"text,omitempty"` BlockID string `json:"block_id,omitempty"` + // Level sets the heading level. Values 1-4 correspond to H1-H4 heading + // levels, respectively. + Level int `json:"level,omitempty"` } // BlockType returns the type of the block @@ -14,6 +17,11 @@ func (s HeaderBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s HeaderBlock) ID() string { + return s.BlockID +} + // HeaderBlockOption allows configuration of options for a new header block type HeaderBlockOption func(*HeaderBlock) @@ -23,6 +31,14 @@ func HeaderBlockOptionBlockID(blockID string) HeaderBlockOption { } } +// HeaderBlockOptionLevel sets the heading level of the header block. Values 1-4 +// correspond to H1-H4 heading levels, respectively. +func HeaderBlockOptionLevel(level int) HeaderBlockOption { + return func(block *HeaderBlock) { + block.Level = level + } +} + // NewHeaderBlock returns a new instance of a header block to be rendered func NewHeaderBlock(textObj *TextBlockObject, options ...HeaderBlockOption) *HeaderBlock { block := HeaderBlock{ @@ -31,7 +47,9 @@ func NewHeaderBlock(textObj *TextBlockObject, options ...HeaderBlockOption) *Hea } for _, option := range options { - option(&block) + if option != nil { + option(&block) + } } return &block diff --git a/block_header_test.go b/block_header_test.go index d8ed332cf..a7480d448 100644 --- a/block_header_test.go +++ b/block_header_test.go @@ -7,12 +7,31 @@ import ( ) func TestNewHeaderBlock(t *testing.T) { - textInfo := NewTextBlockObject("plain_text", "This is quite the header", false, false) - headerBlock := NewHeaderBlock(textInfo, HeaderBlockOptionBlockID("test_block")) + + assert.Equal(t, headerBlock.BlockType(), MBTHeader) assert.Equal(t, string(headerBlock.Type), "header") + assert.Equal(t, headerBlock.ID(), "test_block") assert.Equal(t, headerBlock.BlockID, "test_block") assert.Equal(t, headerBlock.Text.Type, "plain_text") assert.Contains(t, headerBlock.Text.Text, "quite the header") } + +func TestNewHeaderBlockWithLevel(t *testing.T) { + textInfo := NewTextBlockObject("plain_text", "This is quite the header", false, false) + headerBlock := NewHeaderBlock(textInfo, HeaderBlockOptionLevel(2)) + + assert.Equal(t, headerBlock.BlockType(), MBTHeader) + assert.Equal(t, 2, headerBlock.Level) +} + +// TestNewHeaderBlockWithNilOption reproduces issue #1236: passing nil as an +// option to NewHeaderBlock causes a nil pointer dereference panic. +func TestNewHeaderBlockWithNilOption(t *testing.T) { + textInfo := NewTextBlockObject("plain_text", "Header text", false, false) + + assert.NotPanics(t, func() { + NewHeaderBlock(textInfo, nil) + }, "NewHeaderBlock should not panic when nil is passed as an option") +} diff --git a/block_image.go b/block_image.go index 90cbd14e4..2a914e5a0 100644 --- a/block_image.go +++ b/block_image.go @@ -4,11 +4,26 @@ 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"` +} + +// ID returns the ID of the block +func (s ImageBlock) ID() string { + return s.BlockID +} + +// 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 @@ -26,3 +41,15 @@ func NewImageBlock(imageURL, altText, blockID string, title *TextBlockObject) *I Title: title, } } + +// NewImageBlockSlackFile returns an instance of a new Image Block type +// TODO: BREAKING CHANGE - This should be combined with the function above +func NewImageBlockSlackFile(slackFile *SlackFileObject, altText string, blockID string, title *TextBlockObject) *ImageBlock { + return &ImageBlock{ + Type: MBTImage, + SlackFile: slackFile, + AltText: altText, + BlockID: blockID, + Title: title, + } +} diff --git a/block_image_test.go b/block_image_test.go index 3cdd203c4..0670bb6b1 100644 --- a/block_image_test.go +++ b/block_image_test.go @@ -6,15 +6,29 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewImageBlock(t *testing.T) { - +func TestImageURLForNewImageBlock(t *testing.T) { imageText := NewTextBlockObject("plain_text", "Location", false, false) imageBlock := NewImageBlock("https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png", "Marker", "test", imageText) + assert.Equal(t, imageBlock.BlockType(), MBTImage) assert.Equal(t, string(imageBlock.Type), "image") assert.Equal(t, imageBlock.Title.Type, "plain_text") + assert.Equal(t, imageBlock.ID(), "test") assert.Equal(t, imageBlock.BlockID, "test") assert.Contains(t, imageBlock.Title.Text, "Location") assert.Contains(t, imageBlock.ImageURL, "tripAgentLocationMarker.png") +} + +func TestSlackFileForNewImageBlock(t *testing.T) { + imageText := NewTextBlockObject("plain_text", "Location", false, false) + slackFile := &SlackFileObject{URL: "https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png"} + imageBlock := NewImageBlockSlackFile(slackFile, "Marker", "test", imageText) + assert.Equal(t, imageBlock.BlockType(), MBTImage) + assert.Equal(t, string(imageBlock.Type), "image") + assert.Equal(t, imageBlock.Title.Type, "plain_text") + assert.Equal(t, imageBlock.ID(), "test") + assert.Equal(t, imageBlock.BlockID, "test") + assert.Contains(t, imageBlock.Title.Text, "Location") + assert.Contains(t, imageBlock.SlackFile.URL, "tripAgentLocationMarker.png") } diff --git a/block_input.go b/block_input.go index 087571af4..f74eda6d2 100644 --- a/block_input.go +++ b/block_input.go @@ -18,12 +18,30 @@ func (s InputBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s InputBlock) ID() string { + return s.BlockID +} + // NewInputBlock returns a new instance of an input block -func NewInputBlock(blockID string, label *TextBlockObject, element BlockElement) *InputBlock { +func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockElement) *InputBlock { return &InputBlock{ Type: MBTInput, BlockID: blockID, Label: label, Element: element, + 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_input_test.go b/block_input_test.go index 10c3081c1..fa25f48ee 100644 --- a/block_input_test.go +++ b/block_input_test.go @@ -9,9 +9,12 @@ import ( func TestNewInputBlock(t *testing.T) { label := NewTextBlockObject("plain_text", "label", false, false) element := NewDatePickerBlockElement("action_id") + hint := NewTextBlockObject("plain_text", "hint", false, false) + inputBlock := NewInputBlock("test", label, hint, element) - inputBlock := NewInputBlock("test", label, element) + assert.Equal(t, inputBlock.BlockType(), MBTInput) assert.Equal(t, string(inputBlock.Type), "input") + assert.Equal(t, inputBlock.ID(), "test") assert.Equal(t, inputBlock.BlockID, "test") assert.Equal(t, inputBlock.Label, label) assert.Equal(t, inputBlock.Element, element) diff --git a/block_json.go b/block_json.go new file mode 100644 index 000000000..43798f51e --- /dev/null +++ b/block_json.go @@ -0,0 +1,110 @@ +package slack + +import ( + "encoding/json" + "fmt" +) + +// RawJSONBlock represents a block created from raw JSON that preserves +// the original JSON structure. This is useful for testing new Slack block types +// before the library has full support, or for using blocks copied from Block Kit Builder. +// +// The block stores the original JSON and outputs it unchanged during marshalling, +// ensuring no data is lost through the unmarshal/marshal cycle. +type RawJSONBlock struct { + Type MessageBlockType `json:"-"` + BlockID string `json:"-"` + raw json.RawMessage +} + +// BlockType returns the type of the block +func (r RawJSONBlock) BlockType() MessageBlockType { + return r.Type +} + +// ID returns the block_id of the block +func (r RawJSONBlock) ID() string { + return r.BlockID +} + +// MarshalJSON outputs the original JSON unchanged +func (r RawJSONBlock) MarshalJSON() ([]byte, error) { + return r.raw, nil +} + +// BlockFromJSON creates a RawJSONBlock from a JSON string that preserves +// the original JSON. This is useful for quickly testing blocks from Slack's +// Block Kit Builder or for incorporating new block types before the library +// has full support. +// +// The JSON can be either a single block object or an array of blocks. +// If an array is provided, only the first block is returned. +// +// The returned block stores the original JSON and outputs it unchanged during +// marshalling, ensuring no data is lost. +// +// Returns an error if the JSON is invalid, empty, or missing required fields. +// +// Example: +// +// block, err := slack.BlockFromJSON(`{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}`) +// if err != nil { +// return err +// } +// blocks = append(blocks, block) +func BlockFromJSON(jsonStr string) (Block, error) { + var rawJSON json.RawMessage + var isArray bool + + // Try to unmarshal as an array first + var arrayTest []json.RawMessage + if err := json.Unmarshal([]byte(jsonStr), &arrayTest); err == nil && len(arrayTest) > 0 { + rawJSON = arrayTest[0] + isArray = true + } else { + // Try as a single block object + if err := json.Unmarshal([]byte(jsonStr), &rawJSON); err != nil { + return nil, fmt.Errorf("failed to unmarshal block JSON: %w", err) + } + isArray = false + } + + if !isArray && len(rawJSON) == 0 { + return nil, fmt.Errorf("no blocks found in JSON") + } + + // Extract minimal fields for Block interface + var minimal struct { + Type string `json:"type"` + BlockID string `json:"block_id"` + } + if err := json.Unmarshal(rawJSON, &minimal); err != nil { + return nil, fmt.Errorf("failed to extract block type: %w", err) + } + + if minimal.Type == "" { + return nil, fmt.Errorf("block missing required 'type' field") + } + + return RawJSONBlock{ + Type: MessageBlockType(minimal.Type), + BlockID: minimal.BlockID, + raw: rawJSON, + }, nil +} + +// MustBlockFromJSON creates a Block from a JSON string and panics if there's an error. +// This is primarily intended for use in tests or examples where the JSON is known to be valid. +// For production code, use BlockFromJSON which returns an error instead. +// +// Example: +// +// block := slack.MustBlockFromJSON(`{"type": "divider"}`) +// msg := slack.NewBlockMessage(block) +func MustBlockFromJSON(jsonStr string) Block { + block, err := BlockFromJSON(jsonStr) + if err != nil { + panic(fmt.Sprintf("MustBlockFromJSON: %v", err)) + } + return block +} diff --git a/block_json_test.go b/block_json_test.go new file mode 100644 index 000000000..1d6efdeaf --- /dev/null +++ b/block_json_test.go @@ -0,0 +1,235 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBlockFromJSON(t *testing.T) { + tests := []struct { + name string + json string + wantType MessageBlockType + wantError bool + }{ + { + name: "valid single divider block", + json: `{"type": "divider"}`, + wantType: MBTDivider, + }, + { + name: "valid section block", + json: `{"type": "section", "text": {"type": "mrkdwn", "text": "Hello"}}`, + wantType: MBTSection, + }, + { + name: "valid array with single block", + json: `[{"type": "divider"}]`, + wantType: MBTDivider, + }, + { + name: "valid array with multiple blocks (takes first)", + json: `[{"type": "divider"}, {"type": "section", "text": {"type": "plain_text", "text": "Hi"}}]`, + wantType: MBTDivider, + }, + { + name: "invalid JSON syntax", + json: `{"type": "divider"`, + wantError: true, + }, + { + name: "empty JSON object", + json: `{}`, + wantError: true, // Cannot determine block type without wrapping in array + }, + { + name: "empty array", + json: `[]`, + wantError: true, + }, + { + name: "null", + json: `null`, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + block, err := BlockFromJSON(tt.json) + + if tt.wantError { + assert.Error(t, err) + assert.Nil(t, block) + } else { + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, tt.wantType, block.BlockType()) + } + }) + } +} + +func TestMustBlockFromJSON(t *testing.T) { + t.Run("valid JSON does not panic", func(t *testing.T) { + assert.NotPanics(t, func() { + block := MustBlockFromJSON(`{"type": "divider"}`) + assert.NotNil(t, block) + assert.Equal(t, MBTDivider, block.BlockType()) + }) + }) + + t.Run("invalid JSON panics", func(t *testing.T) { + assert.Panics(t, func() { + MustBlockFromJSON(`invalid json`) + }) + }) + + t.Run("empty array panics", func(t *testing.T) { + assert.Panics(t, func() { + MustBlockFromJSON(`[]`) + }) + }) +} + +func TestRawJSONBlockRoundTrip(t *testing.T) { + t.Run("simple block preserves all fields", func(t *testing.T) { + originalJSON := `{"type": "section", "text": {"type": "mrkdwn", "text": "Hello World"}, "block_id": "section1"}` + + block, err := BlockFromJSON(originalJSON) + assert.NoError(t, err) + assert.Equal(t, MBTSection, block.BlockType()) + assert.Equal(t, "section1", block.ID()) + + // Marshal back to JSON + marshalled, err := json.Marshal(block) + assert.NoError(t, err) + + // Unmarshal both to compare (ignoring whitespace differences) + var original, result map[string]any + assert.NoError(t, json.Unmarshal([]byte(originalJSON), &original)) + assert.NoError(t, json.Unmarshal(marshalled, &result)) + + assert.Equal(t, original, result, "Round-trip should preserve all fields") + }) + + t.Run("complex block with nested elements preserves everything", func(t *testing.T) { + // Using a complex context_actions block as an example + originalJSON := `{ + "type": "context_actions", + "block_id": "feedback_block", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "ai_feedback", + "positive_button": { + "text": {"type": "plain_text", "text": "👍"}, + "value": "positive" + }, + "negative_button": { + "text": {"type": "plain_text", "text": "👎"}, + "value": "negative" + } + }, + { + "type": "icon_button", + "icon": "trash", + "text": {"type": "plain_text", "text": "Delete"}, + "action_id": "delete_action", + "value": "delete_response" + } + ] + }` + + block, err := BlockFromJSON(originalJSON) + assert.NoError(t, err) + assert.Equal(t, MessageBlockType("context_actions"), block.BlockType()) + assert.Equal(t, "feedback_block", block.ID()) + + // Marshal back to JSON + marshalled, err := json.Marshal(block) + assert.NoError(t, err) + + // Unmarshal both to compare + var original, result map[string]any + assert.NoError(t, json.Unmarshal([]byte(originalJSON), &original)) + assert.NoError(t, json.Unmarshal(marshalled, &result)) + + assert.Equal(t, original, result, "Complex block should preserve all nested fields") + + // Specifically verify elements array is preserved + resultElements, ok := result["elements"].([]any) + assert.True(t, ok, "elements should be an array") + assert.Equal(t, 2, len(resultElements), "should have 2 elements") + }) +} + +func TestRawJSONBlockInDeepStructure(t *testing.T) { + t.Run("RawJSONBlock in Message with mixed blocks", func(t *testing.T) { + // Create a regular divider block + divider := NewDividerBlock() + + // Create a RawJSONBlock with complex content + contextActionsJSON := `{ + "type": "context_actions", + "block_id": "feedback_block", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "ai_feedback", + "positive_button": { + "text": {"type": "plain_text", "text": "👍"}, + "value": "positive" + }, + "negative_button": { + "text": {"type": "plain_text", "text": "👎"}, + "value": "negative" + } + } + ] + }` + rawBlock := MustBlockFromJSON(contextActionsJSON) + + // Create a regular section block + sectionText := NewTextBlockObject("mrkdwn", "*Regular Section*", false, false) + section := NewSectionBlock(sectionText, nil, nil) + + // Create a Message with all three blocks + msg := NewBlockMessage(divider, rawBlock, section) + + // Marshal the entire message + marshalled, err := json.Marshal(msg) + assert.NoError(t, err) + + // Unmarshal to verify structure + var result struct { + Blocks []json.RawMessage `json:"blocks"` + } + assert.NoError(t, json.Unmarshal(marshalled, &result)) + assert.Equal(t, 3, len(result.Blocks), "should have 3 blocks") + + // Verify the middle block (our RawJSONBlock) has all fields preserved + var contextActionsBlock map[string]any + assert.NoError(t, json.Unmarshal(result.Blocks[1], &contextActionsBlock)) + + assert.Equal(t, "context_actions", contextActionsBlock["type"]) + assert.Equal(t, "feedback_block", contextActionsBlock["block_id"]) + + // Verify elements array is present and intact + elements, ok := contextActionsBlock["elements"].([]any) + assert.True(t, ok, "elements should be an array") + assert.Equal(t, 1, len(elements), "should have 1 element") + + // Verify the nested feedback_buttons element + firstElement, ok := elements[0].(map[string]any) + assert.True(t, ok, "first element should be an object") + assert.Equal(t, "feedback_buttons", firstElement["type"]) + assert.Equal(t, "ai_feedback", firstElement["action_id"]) + + // Verify the nested buttons exist + assert.NotNil(t, firstElement["positive_button"], "should have positive_button") + assert.NotNil(t, firstElement["negative_button"], "should have negative_button") + }) +} diff --git a/block_markdown.go b/block_markdown.go new file mode 100644 index 000000000..e22a0d16d --- /dev/null +++ b/block_markdown.go @@ -0,0 +1,34 @@ +package slack + +// MarkdownBlock defines a block that lets you use markdown to format your text. +// +// This block can be used with AI apps when you expect a markdown response from an LLM +// that can get lost in translation rendering in Slack. Providing it in a markdown block +// leaves the translating to Slack to ensure your message appears as intended. Note that +// passing a single block may result in multiple blocks after translation. +// +// More Information: https://api.slack.com/reference/block-kit/blocks#markdown +type MarkdownBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Text string `json:"text"` +} + +// BlockType returns the type of the block +func (s MarkdownBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s MarkdownBlock) ID() string { + return s.BlockID +} + +// NewMarkdownBlock returns an instance of a new Markdown Block type +func NewMarkdownBlock(blockID, text string) *MarkdownBlock { + return &MarkdownBlock{ + Type: MBTMarkdown, + BlockID: blockID, + Text: text, + } +} diff --git a/block_markdown_test.go b/block_markdown_test.go new file mode 100644 index 000000000..45fe3c5e8 --- /dev/null +++ b/block_markdown_test.go @@ -0,0 +1,17 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewMarkdownBlock(t *testing.T) { + markdownBlock := NewMarkdownBlock("test", "*asfd*") + + assert.Equal(t, markdownBlock.BlockType(), MBTMarkdown) + assert.Equal(t, string(markdownBlock.Type), "markdown") + assert.Equal(t, markdownBlock.ID(), "test") + assert.Equal(t, markdownBlock.BlockID, "test") + assert.Equal(t, markdownBlock.Text, "*asfd*") +} diff --git a/block_object.go b/block_object.go index 5ced7f92a..48eac47d2 100644 --- a/block_object.go +++ b/block_object.go @@ -11,7 +11,6 @@ import ( // BlockObject defines an interface that all block object types should // implement. -// @TODO: Is this interface needed? // blockObject object types const ( @@ -47,7 +46,7 @@ func (b *BlockObjects) UnmarshalJSON(data []byte) error { } for _, r := range raw { - var obj map[string]interface{} + var obj map[string]any err := json.Unmarshal(r, &obj) if err != nil { return err @@ -90,7 +89,7 @@ func (b *BlockObjects) UnmarshalJSON(data []byte) error { // Ideally would have a better way to identify the block objects for // type casting at time of unmarshalling, should be adapted if possible // to accomplish in a more reliable manner. -func getBlockObjectType(obj map[string]interface{}) string { +func getBlockObjectType(obj map[string]any) string { if t, ok := obj["type"].(string); ok { return t } @@ -122,7 +121,7 @@ func unmarshalBlockObject(r json.RawMessage, object blockObject) (blockObject, e type TextBlockObject struct { Type string `json:"type"` Text string `json:"text"` - Emoji bool `json:"emoji,omitempty"` + Emoji *bool `json:"emoji,omitempty"` Verbatim bool `json:"verbatim,omitempty"` } @@ -142,20 +141,46 @@ func (s TextBlockObject) Validate() error { return errors.New("type must be either of plain_text or mrkdwn") } - // https://github.com/slack-go/slack/issues/881 - if s.Type == "mrkdwn" && s.Emoji { - return errors.New("emoji cannot be true in mrkdown") + if s.Type == "mrkdwn" && s.Emoji != nil { + return errors.New("emoji cannot be set for mrkdwn type") + } + + // 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 } // NewTextBlockObject returns an instance of a new Text Block Object -func NewTextBlockObject(elementType, text string, emoji, verbatim bool) *TextBlockObject { +// +// If you want to create a mrkdwn object, you should set the emoji parameter to false. The +// reason is that Slack doesn't accept emoji in mrkdwn. +func NewTextBlockObject(elementType, text string, emoji bool, verbatim bool) *TextBlockObject { + // If we're trying to build a mrkdwn object, we can't send emoji at all. I think the + // right approach here is to be a bit clever, and not break the function interface. + // + // So, here's the plan: + // 1. If the type is mrkdwn, set emoji to nil, regardless of what the user passed in + // 2. Else, set emoji to the value passed in + var emojiPtr *bool + + if elementType == "mrkdwn" { + emojiPtr = nil + } else { + emojiPtr = &emoji + } + return &TextBlockObject{ Type: elementType, Text: text, - Emoji: emoji, + Emoji: emojiPtr, Verbatim: verbatim, } } @@ -177,7 +202,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"` } @@ -187,8 +212,9 @@ func (s ConfirmationBlockObject) validateType() MessageObjectType { } // WithStyle add styling to confirmation object -func (s *ConfirmationBlockObject) WithStyle(style Style) { +func (s *ConfirmationBlockObject) WithStyle(style Style) *ConfirmationBlockObject { s.Style = style + return s } // NewConfirmationBlockObject returns an instance of a new Confirmation Block Object @@ -245,3 +271,27 @@ func NewOptionGroupBlockElement(label *TextBlockObject, options ...*OptionBlockO Options: options, } } + +// SlackIconObject defines a built-in Slack icon for use in a card block's +// slack_icon field. It is mutually exclusive with the card block's icon field, +// as both render in the same location. +// +// More Information: https://docs.slack.dev/reference/block-kit/composition-objects/slack-icon-object/ +type SlackIconObject struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// validateType enforces block objects for element and block parameters +func (s SlackIconObject) validateType() MessageObjectType { + return MessageObjectType(s.Type) +} + +// NewSlackIconObject returns an instance of a new Slack icon object. The name +// must be one of the icon names supported by Slack. +func NewSlackIconObject(name string) *SlackIconObject { + return &SlackIconObject{ + Type: "icon", + Name: name, + } +} diff --git a/block_object_test.go b/block_object_test.go index 9889fae41..8e2954c0c 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -1,35 +1,33 @@ package slack import ( + "encoding/json" "errors" + "strings" "testing" + "github.com/go-test/deep" "github.com/stretchr/testify/assert" ) func TestNewImageBlockObject(t *testing.T) { - imageObject := NewImageBlockElement("https://api.slack.com/img/blocks/bkb_template_images/beagle.png", "Beagle") assert.Equal(t, string(imageObject.Type), "image") assert.Equal(t, imageObject.AltText, "Beagle") - assert.Contains(t, imageObject.ImageURL, "beagle.png") - + assert.Contains(t, *imageObject.ImageURL, "beagle.png") } func TestNewTextBlockObject(t *testing.T) { - textObject := NewTextBlockObject("plain_text", "test", true, false) assert.Equal(t, textObject.Type, "plain_text") assert.Equal(t, textObject.Text, "test") - assert.True(t, textObject.Emoji, "Emoji property should be true") + assert.True(t, *textObject.Emoji, "Emoji property should be true") assert.False(t, textObject.Verbatim, "Verbatim should be false") - } func TestNewConfirmationBlockObject(t *testing.T) { - titleObj := NewTextBlockObject("plain_text", "testTitle", false, false) textObj := NewTextBlockObject("plain_text", "testText", false, false) confirmObj := NewTextBlockObject("plain_text", "testConfirm", false, false) @@ -40,11 +38,9 @@ func TestNewConfirmationBlockObject(t *testing.T) { assert.Equal(t, confirmation.Text.Text, "testText") assert.Equal(t, confirmation.Confirm.Text, "testConfirm") assert.Nil(t, confirmation.Deny, "Deny should be nil") - } func TestWithStyleForConfirmation(t *testing.T) { - // these values are irrelevant in this test titleObj := NewTextBlockObject("plain_text", "testTitle", false, false) textObj := NewTextBlockObject("plain_text", "testText", false, false) @@ -57,11 +53,9 @@ func TestWithStyleForConfirmation(t *testing.T) { assert.Equal(t, confirmation.Style, Style("primary")) confirmation.WithStyle(StyleDanger) assert.Equal(t, confirmation.Style, Style("danger")) - } func TestNewOptionBlockObject(t *testing.T) { - valTextObj := NewTextBlockObject("plain_text", "testText", false, false) valDescriptionObj := NewTextBlockObject("plain_text", "testDescription", false, false) optObj := NewOptionBlockObject("testOpt", valTextObj, valDescriptionObj) @@ -69,11 +63,9 @@ func TestNewOptionBlockObject(t *testing.T) { assert.Equal(t, optObj.Text.Text, "testText") assert.Equal(t, optObj.Description.Text, "testDescription") assert.Equal(t, optObj.Value, "testOpt") - } func TestNewOptionGroupBlockElement(t *testing.T) { - labelObj := NewTextBlockObject("plain_text", "testLabel", false, false) valTextObj := NewTextBlockObject("plain_text", "testText", false, false) optObj := NewOptionBlockObject("testOpt", valTextObj, nil) @@ -82,10 +74,14 @@ func TestNewOptionGroupBlockElement(t *testing.T) { assert.Equal(t, optGroup.Label.Text, "testLabel") assert.Len(t, optGroup.Options, 1, "Options should contain one element") - } func TestValidateTextBlockObject(t *testing.T) { + emojiTrue := new(bool) + emojiFalse := new(bool) + *emojiTrue = true + *emojiFalse = false + tests := []struct { input TextBlockObject expected error @@ -94,7 +90,25 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "plain_text", Text: "testText", - Emoji: false, + Emoji: emojiFalse, + Verbatim: false, + }, + expected: nil, + }, + { + input: TextBlockObject{ + Type: "plain_text", + Text: "testText", + Emoji: emojiTrue, + Verbatim: false, + }, + expected: nil, + }, + { + input: TextBlockObject{ + Type: "plain_text", + Text: "testText", + Emoji: nil, Verbatim: false, }, expected: nil, @@ -103,7 +117,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: "testText", - Emoji: false, + Emoji: nil, Verbatim: false, }, expected: nil, @@ -112,7 +126,7 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "invalid", Text: "testText", - Emoji: false, + Emoji: emojiFalse, Verbatim: false, }, expected: errors.New("type must be either of plain_text or mrkdwn"), @@ -121,15 +135,122 @@ func TestValidateTextBlockObject(t *testing.T) { input: TextBlockObject{ Type: "mrkdwn", Text: "testText", - Emoji: true, + Emoji: emojiTrue, + Verbatim: false, + }, + expected: errors.New("emoji cannot be set for mrkdwn type"), + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: "testText", + Emoji: emojiFalse, Verbatim: false, }, - expected: errors.New("emoji cannot be true in mrkdown"), + expected: errors.New("emoji cannot be set for mrkdwn type"), + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: "", + Emoji: nil, + Verbatim: false, + }, + expected: errors.New("text must have a minimum length of 1"), + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: strings.Repeat("a", 3001), + Emoji: nil, + Verbatim: false, + }, + expected: errors.New("text cannot be longer than 3000 characters"), }, } for _, test := range tests { err := test.input.Validate() - assert.Equal(t, err, test.expected) + assert.Equal(t, test.expected, err) + } +} + +func TestTextBlockObject_UnmarshalJSON(t *testing.T) { + emojiTrue := new(bool) + emojiFalse := new(bool) + *emojiTrue = true + *emojiFalse = false + + cases := []struct { + raw []byte + expected TextBlockObject + err error + }{ + { + []byte(`{"type":"plain_text","text":"testText"}`), + TextBlockObject{ + Type: "plain_text", + Text: "testText", + Emoji: nil, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"plain_text","text":":+1:","emoji":true}`), + TextBlockObject{ + Type: "plain_text", + Text: ":+1:", + Emoji: emojiTrue, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"plain_text","text":"No emojis allowed :(","emoji":false}`), + TextBlockObject{ + Type: "plain_text", + Text: "No emojis allowed :(", + Emoji: emojiFalse, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"mrkdwn","text":"testText"}`), + TextBlockObject{ + Type: "mrkdwn", + Text: "testText", + Emoji: nil, + Verbatim: false, + }, + nil, + }, + { + []byte(`{"type":"mrkdwn","text":"No emojis allowed :(","emoji":false}`), + TextBlockObject{ + Type: "mrkdwn", + Text: "No emojis allowed :(", + Emoji: emojiFalse, + Verbatim: false, + }, + nil, + }, + } + for _, tc := range cases { + var actual TextBlockObject + err := json.Unmarshal(tc.raw, &actual) + if err != nil { + if tc.err == nil { + t.Errorf("unexpected error: %s", err) + } + t.Errorf("expected error is %v, but got %v", tc.err, err) + } + if tc.err != nil { + t.Errorf("expected to raise an error %v", tc.err) + } + if diff := deep.Equal(actual, tc.expected); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } } } diff --git a/block_plan.go b/block_plan.go new file mode 100644 index 000000000..8b83d2994 --- /dev/null +++ b/block_plan.go @@ -0,0 +1,57 @@ +package slack + +// PlanBlock defines a block of type plan used by AI agents +// to group multiple task cards under a shared title. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/plan-block/ +type PlanBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Title string `json:"title"` + Tasks []TaskCardBlock `json:"tasks,omitempty"` +} + +// BlockType returns the type of the block +func (s PlanBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s PlanBlock) ID() string { + return s.BlockID +} + +// PlanBlockOption allows configuration of options for a new plan block +type PlanBlockOption func(*PlanBlock) + +// PlanBlockOptionBlockID sets the block ID for the plan block +func PlanBlockOptionBlockID(blockID string) PlanBlockOption { + return func(block *PlanBlock) { + block.BlockID = blockID + } +} + +// NewPlanBlock returns a new instance of a plan block +func NewPlanBlock(title string, options ...PlanBlockOption) *PlanBlock { + block := PlanBlock{ + Type: MBTPlan, + Title: title, + } + + for _, option := range options { + if option != nil { + option(&block) + } + } + + return &block +} + +// WithTasks sets the tasks for the PlanBlock +func (s *PlanBlock) WithTasks(tasks ...*TaskCardBlock) *PlanBlock { + s.Tasks = make([]TaskCardBlock, len(tasks)) + for i, t := range tasks { + s.Tasks[i] = *t + } + return s +} diff --git a/block_plan_test.go b/block_plan_test.go new file mode 100644 index 000000000..74776839a --- /dev/null +++ b/block_plan_test.go @@ -0,0 +1,130 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewPlanBlock(t *testing.T) { + block := NewPlanBlock("My Plan", PlanBlockOptionBlockID("plan-block-1")) + + assert.Equal(t, MBTPlan, block.BlockType()) + assert.Equal(t, "plan", string(block.Type)) + assert.Equal(t, "plan-block-1", block.ID()) + assert.Equal(t, "My Plan", block.Title) +} + +func TestPlanBlockWithTasks(t *testing.T) { + task1 := NewTaskCardBlock("task-1", "First task").WithStatus(TaskCardStatusComplete) + task2 := NewTaskCardBlock("task-2", "Second task").WithStatus(TaskCardStatusInProgress) + + block := NewPlanBlock("My Plan").WithTasks(task1, task2) + + require.Len(t, block.Tasks, 2) + assert.Equal(t, "task-1", block.Tasks[0].TaskID) + assert.Equal(t, "First task", block.Tasks[0].Title) + assert.Equal(t, TaskCardStatusComplete, block.Tasks[0].Status) + assert.Equal(t, "task-2", block.Tasks[1].TaskID) + assert.Equal(t, TaskCardStatusInProgress, block.Tasks[1].Status) +} + +func TestPlanBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "plan", + "block_id": "plan-1", + "title": "Research Plan", + "tasks": [ + { + "type": "task_card", + "task_id": "task-1", + "title": "Search the web", + "status": "complete", + "sources": [ + { + "type": "url", + "url": "https://example.com", + "text": "Example" + } + ] + }, + { + "type": "task_card", + "task_id": "task-2", + "title": "Analyze results", + "status": "in_progress", + "details": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Processing data..." + } + ] + } + ] + } + } + ] + }` + + var block PlanBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTPlan, block.BlockType()) + assert.Equal(t, "plan-1", block.ID()) + assert.Equal(t, "Research Plan", block.Title) + require.Len(t, block.Tasks, 2) + assert.Equal(t, "task-1", block.Tasks[0].TaskID) + assert.Equal(t, TaskCardStatusComplete, block.Tasks[0].Status) + require.Len(t, block.Tasks[0].Sources, 1) + assert.Equal(t, "task-2", block.Tasks[1].TaskID) + require.NotNil(t, block.Tasks[1].Details) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + err = json.Unmarshal([]byte(payload), &expected) + require.NoError(t, err) + err = json.Unmarshal(marshalled, &actual) + require.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +func TestPlanBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "plan", + "title": "Agent Plan", + "tasks": [ + { + "type": "task_card", + "task_id": "t1", + "title": "Step 1", + "status": "pending" + } + ] + } + ]` + + var blocks Blocks + err := json.Unmarshal([]byte(payload), &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + plan, ok := blocks.BlockSet[0].(*PlanBlock) + require.True(t, ok, "expected *PlanBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTPlan, plan.BlockType()) + assert.Equal(t, "Agent Plan", plan.Title) + require.Len(t, plan.Tasks, 1) + assert.Equal(t, "t1", plan.Tasks[0].TaskID) + assert.Equal(t, TaskCardStatusPending, plan.Tasks[0].Status) +} diff --git a/block_rich_text.go b/block_rich_text.go index 281db213a..3b0ead2ee 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -16,6 +16,11 @@ func (b RichTextBlock) BlockType() MessageBlockType { return b.Type } +// ID returns the ID of the block +func (s RichTextBlock) ID() string { + return s.BlockID +} + func (e *RichTextBlock) UnmarshalJSON(b []byte) error { var raw struct { Type MessageBlockType `json:"type"` @@ -40,6 +45,12 @@ func (e *RichTextBlock) UnmarshalJSON(b []byte) error { switch s.Type { case RTESection: elem = &RichTextSection{} + case RTEList: + elem = &RichTextList{} + case RTEQuote: + elem = &RichTextQuote{} + case RTEPreformatted: + elem = &RichTextPreformatted{} default: elems = append(elems, &RichTextUnknown{ Type: s.Type, @@ -92,19 +103,110 @@ func (u RichTextUnknown) RichTextElementType() RichTextElementType { return u.Type } +func (u RichTextUnknown) MarshalJSON() ([]byte, error) { + return []byte(u.Raw), nil +} + +type RichTextListElementType string + +const ( + RTEListOrdered RichTextListElementType = "ordered" + RTEListBullet RichTextListElementType = "bullet" +) + +type RichTextList struct { + Type RichTextElementType `json:"type"` + Elements []RichTextElement `json:"elements"` + Style RichTextListElementType `json:"style"` + Indent int `json:"indent"` + Border int `json:"border"` + Offset int `json:"offset"` +} + +// NewRichTextList returns a new rich text list element. +func NewRichTextList(style RichTextListElementType, indent int, elements ...RichTextElement) *RichTextList { + return &RichTextList{ + Type: RTEList, + Elements: elements, + Style: style, + Indent: indent, + } +} + +// RichTextElementType returns the type of the Element +func (s RichTextList) RichTextElementType() RichTextElementType { + return s.Type +} + +func (e *RichTextList) UnmarshalJSON(b []byte) error { + var raw struct { + RawElements []json.RawMessage `json:"elements"` + Style RichTextListElementType `json:"style"` + Indent int `json:"indent"` + Border int `json:"border"` + Offset int `json:"offset"` + } + if string(b) == "{}" { + return nil + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + elems := make([]RichTextElement, 0, len(raw.RawElements)) + for _, r := range raw.RawElements { + var s struct { + Type RichTextElementType `json:"type"` + } + if err := json.Unmarshal(r, &s); err != nil { + return err + } + var elem RichTextElement + switch s.Type { + case RTESection: + elem = &RichTextSection{} + case RTEList: + elem = &RichTextList{} + case RTEQuote: + elem = &RichTextQuote{} + case RTEPreformatted: + elem = &RichTextPreformatted{} + default: + elems = append(elems, &RichTextUnknown{ + Type: s.Type, + Raw: string(r), + }) + continue + } + if err := json.Unmarshal(r, elem); err != nil { + return err + } + elems = append(elems, elem) + } + *e = RichTextList{ + Type: RTEList, + Elements: elems, + Style: raw.Style, + Indent: raw.Indent, + Border: raw.Border, + Offset: raw.Offset, + } + return nil +} + type RichTextSection struct { Type RichTextElementType `json:"type"` Elements []RichTextSectionElement `json:"elements"` } -// ElementType returns the type of the Element +// RichTextElementType returns the type of the Element func (s RichTextSection) RichTextElementType() RichTextElementType { return s.Type } func (e *RichTextSection) UnmarshalJSON(b []byte) error { var raw struct { - RawElements []json.RawMessage `json:"elements"` + RawElements []json.RawMessage `json:"elements"` + Type RichTextElementType `json:"type"` } if string(b) == "{}" { return nil @@ -154,14 +256,19 @@ func (e *RichTextSection) UnmarshalJSON(b []byte) error { } elems = append(elems, elem) } + if raw.Type == "" { + raw.Type = RTESection + } *e = RichTextSection{ - Type: RTESection, + Type: raw.Type, Elements: elems, } return nil } -// NewRichTextSectionBlockElement . +// NewRichTextSection creates a new rich text section from the provided elements. The +// section type will default to "rich_text_section", as it's the only currently supported +// section type. func NewRichTextSection(elements ...RichTextSectionElement) *RichTextSection { return &RichTextSection{ Type: RTESection, @@ -191,10 +298,14 @@ type RichTextSectionElement interface { } type RichTextSectionTextStyle struct { - Bold bool `json:"bold,omitempty"` - Italic bool `json:"italic,omitempty"` - Strike bool `json:"strike,omitempty"` - Code bool `json:"code,omitempty"` + Bold bool `json:"bold,omitempty"` + Italic bool `json:"italic,omitempty"` + Strike bool `json:"strike,omitempty"` + Code bool `json:"code,omitempty"` + Underline bool `json:"underline,omitempty"` + Highlight bool `json:"highlight,omitempty"` + ClientHighlight bool `json:"client_highlight,omitempty"` + Unlink bool `json:"unlink,omitempty"` } type RichTextSectionTextElement struct { @@ -227,7 +338,7 @@ func (r RichTextSectionChannelElement) RichTextSectionElementType() RichTextSect func NewRichTextSectionChannelElement(channelID string, style *RichTextSectionTextStyle) *RichTextSectionChannelElement { return &RichTextSectionChannelElement{ - Type: RTSEText, + Type: RTSEChannel, ChannelID: channelID, Style: style, } @@ -254,7 +365,8 @@ func NewRichTextSectionUserElement(userID string, style *RichTextSectionTextStyl type RichTextSectionEmojiElement struct { Type RichTextSectionElementType `json:"type"` Name string `json:"name"` - SkinTone int `json:"skin_tone"` + SkinTone int `json:"skin_tone,omitempty"` + Unicode string `json:"unicode,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } @@ -274,7 +386,7 @@ func NewRichTextSectionEmojiElement(name string, skinTone int, style *RichTextSe type RichTextSectionLinkElement struct { Type RichTextSectionElementType `json:"type"` URL string `json:"url"` - Text string `json:"text"` + Text string `json:"text,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } @@ -294,7 +406,7 @@ func NewRichTextSectionLinkElement(url, text string, style *RichTextSectionTextS type RichTextSectionTeamElement struct { Type RichTextSectionElementType `json:"type"` TeamID string `json:"team_id"` - Style *RichTextSectionTextStyle `json:"style.omitempty"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionTeamElement) RichTextSectionElementType() RichTextSectionElementType { @@ -312,6 +424,7 @@ func NewRichTextSectionTeamElement(teamID string, style *RichTextSectionTextStyl type RichTextSectionUserGroupElement struct { Type RichTextSectionElementType `json:"type"` UsergroupID string `json:"usergroup_id"` + Style *RichTextSectionTextStyle `json:"style,omitempty"` } func (r RichTextSectionUserGroupElement) RichTextSectionElementType() RichTextSectionElementType { @@ -327,17 +440,23 @@ func NewRichTextSectionUserGroupElement(usergroupID string) *RichTextSectionUser type RichTextSectionDateElement struct { Type RichTextSectionElementType `json:"type"` - Timestamp string `json:"timestamp"` + Timestamp JSONTime `json:"timestamp"` + Format string `json:"format"` + URL *string `json:"url,omitempty"` + Fallback *string `json:"fallback,omitempty"` } func (r RichTextSectionDateElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionDateElement(timestamp string) *RichTextSectionDateElement { +func NewRichTextSectionDateElement(timestamp int64, format string, url *string, fallback *string) *RichTextSectionDateElement { return &RichTextSectionDateElement{ Type: RTSEDate, - Timestamp: timestamp, + Timestamp: JSONTime(timestamp), + Format: format, + URL: url, + Fallback: fallback, } } @@ -381,3 +500,83 @@ type RichTextSectionUnknownElement struct { func (r RichTextSectionUnknownElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } + +func (r RichTextSectionUnknownElement) MarshalJSON() ([]byte, error) { + return []byte(r.Raw), nil +} + +// RichTextQuote represents rich_text_quote element type. +type RichTextQuote struct { + Type RichTextElementType `json:"type"` + Elements []RichTextSectionElement `json:"elements"` + Border int `json:"border,omitempty"` +} + +// RichTextElementType returns the type of the Element +func (s *RichTextQuote) RichTextElementType() RichTextElementType { + return s.Type +} + +func (s *RichTextQuote) UnmarshalJSON(b []byte) error { + // reusing the RichTextSection struct, as it's the same as RichTextQuote. + var rts RichTextSection + if err := json.Unmarshal(b, &rts); err != nil { + return err + } + var standalone struct { + Border int `json:"border"` + } + if err := json.Unmarshal(b, &standalone); err != nil { + return err + } + *s = RichTextQuote{ + Type: RTEQuote, + Elements: rts.Elements, + Border: standalone.Border, + } + return nil +} + +// RichTextPreformatted represents rich_text_quote element type. +type RichTextPreformatted struct { + Type RichTextElementType `json:"type"` + Elements []RichTextSectionElement `json:"elements"` + Border int `json:"border"` + Language string `json:"language,omitempty"` +} + +// RichTextElementType returns the type of the Element +func (s *RichTextPreformatted) RichTextElementType() RichTextElementType { + return s.Type +} + +func (s *RichTextPreformatted) UnmarshalJSON(b []byte) error { + var rts RichTextSection + if err := json.Unmarshal(b, &rts); err != nil { + return err + } + // we define standalone fields because we need to unmarshal the border + // field. We can not directly unmarshal the data into + // RichTextPreformatted because it will cause an infinite loop. We also + // can not define a struct with embedded RichTextSection and Border fields + // because the json package will not unmarshal the data into the + // standalone fields, once it sees UnmarshalJSON method on the embedded + // struct. The drawback is that we have to process the data twice, and + // have to define a standalone struct with the same set of fields as the + // original struct, which may become a maintenance burden (i.e. update the + // fields in two places, should it ever change). + var standalone struct { + Border int `json:"border"` + Language string `json:"language"` + } + if err := json.Unmarshal(b, &standalone); err != nil { + return err + } + *s = RichTextPreformatted{ + Type: RTEPreformatted, + Elements: rts.Elements, + Border: standalone.Border, + Language: standalone.Language, + } + return nil +} diff --git a/block_rich_text_test.go b/block_rich_text_test.go index 4c889eba5..6abe7ccb3 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/go-test/deep" + "github.com/stretchr/testify/assert" ) const ( @@ -12,23 +13,86 @@ const ( "type":"rich_text", "block_id":"FaYCD", "elements": [ - { - "type":"rich_text_section", - "elements": [ - { - "type":"channel", - "channel_id":"C012345678" - }, - { - "type":"text", - "text":"dummy_text" - } - ] - } + { + "type":"rich_text_section", + "elements": [ + { + "type":"channel", + "channel_id":"C012345678" + }, + { + "type":"text", + "text":"dummy_text" + } + ] + } ] }` + + richTextQuotePayload = `{ + "type": "rich_text", + "block_id": "G7G", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Holy moly\n\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Preformatted\n\n" + } + ], + "border": 2 + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "Quote\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "Another quote" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Another preformatted\n\n" + } + ], + "border": 42 + } + ] + }` ) +func TestNewRichTextBlock(t *testing.T) { + richTextBlock := NewRichTextBlock("test_block") + + assert.Equal(t, richTextBlock.BlockType(), MBTRichText) + assert.Equal(t, string(richTextBlock.Type), "rich_text") + assert.Equal(t, richTextBlock.BlockID, "test_block") + assert.Equal(t, richTextBlock.ID(), "test_block") +} + func TestRichTextBlock_UnmarshalJSON(t *testing.T) { cases := []struct { raw []byte @@ -36,11 +100,12 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"rich_text_unknown"},{"type":"rich_text_section"}]}`), + []byte(`{"elements":[{"type":"rich_text_unknown"},{"type":"rich_text_section"},{"type":"rich_text_list"}]}`), RichTextBlock{ Elements: []RichTextElement{ &RichTextUnknown{Type: RTEUnknown, Raw: `{"type":"rich_text_unknown"}`}, &RichTextSection{Type: RTESection, Elements: []RichTextSectionElement{}}, + &RichTextList{Type: RTEList, Elements: []RichTextElement{}}, }, }, nil, @@ -54,6 +119,38 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(dummyPayload), + RichTextBlock{ + Type: MBTRichText, + BlockID: "FaYCD", + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionChannelElement{Type: RTSEChannel, ChannelID: "C012345678"}, + &RichTextSectionTextElement{Type: RTSEText, Text: "dummy_text"}, + }, + }, + }, + }, + nil, + }, + { + []byte(richTextQuotePayload), + RichTextBlock{ + Type: MBTRichText, + BlockID: "G7G", + Elements: []RichTextElement{ + &RichTextSection{Type: RTESection, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Holy moly\n\n"}}}, + &RichTextPreformatted{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Preformatted\n\n"}}, Border: 2}, + &RichTextQuote{Type: RTEQuote, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Quote\n\n"}}}, + &RichTextQuote{Type: RTEQuote, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another quote"}}}, + &RichTextPreformatted{Type: RTEPreformatted, Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Another preformatted\n\n"}}, Border: 42}, + }, + }, + nil, + }, } for _, tc := range cases { var actual RichTextBlock @@ -62,10 +159,10 @@ func TestRichTextBlock_UnmarshalJSON(t *testing.T) { if tc.err == nil { t.Errorf("unexpected error: %s", err) } - t.Errorf("expected error is %s, but got %s", tc.err, err) + t.Errorf("expected error is %v, but got %v", tc.err, err) } if tc.err != nil { - t.Errorf("expected to raise an error %s", tc.err) + t.Errorf("expected to raise an error %v", tc.err) } if diff := deep.Equal(actual, tc.expected); diff != nil { t.Errorf("actual value does not match expected one\n%s", diff) @@ -80,12 +177,14 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"}]}`), + []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629,"format":"{date_short_pretty}"},{"type":"date","timestamp":1636961629,"format":"{date_short_pretty}","url":"https://example.com","fallback":"default"}]}`), RichTextSection{ Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}"}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}", URL: new("https://example.com"), Fallback: new("default")}, }, }, nil, @@ -98,6 +197,26 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(`{"type": "rich_text_section","elements":[{"type": "emoji","name": "+1"}]}`), + RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1"}, + }, + }, + nil, + }, + { + []byte(`{"type": "rich_text_section","elements":[{"type": "emoji","name": "+1","unicode": "1f44d-1f3fb","skin_tone": 2}]}`), + RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1", Unicode: "1f44d-1f3fb", SkinTone: 2}, + }, + }, + nil, + }, } for _, tc := range cases { var actual RichTextSection @@ -116,3 +235,222 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { } } } + +func TestRichTextList_UnmarshalJSON(t *testing.T) { + cases := []struct { + raw []byte + expected RichTextList + err error + }{ + { + []byte(`{"style":"ordered","elements":[{"type":"rich_text_unknown","value":10},{"type":"rich_text_section","elements":[{"type":"text","text":"hi"}]}]}`), + RichTextList{ + Type: RTEList, + Style: RTEListOrdered, + Elements: []RichTextElement{ + &RichTextUnknown{Type: RTEUnknown, Raw: `{"type":"rich_text_unknown","value":10}`}, + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, + }, + }, + }, + }, + nil, + }, + { + []byte(`{"style":"ordered","elements":[{"type":"rich_text_list","style":"bullet","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"hi"}]}]}]}`), + RichTextList{ + Type: RTEList, + Style: RTEListOrdered, + Elements: []RichTextElement{ + &RichTextList{ + Type: RTEList, + Style: RTEListBullet, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, + }, + }, + }, + }, + }, + }, + nil, + }, + { + []byte(`{"type": "rich_text_list","elements":[]}`), + RichTextList{ + Type: RTEList, + Elements: []RichTextElement{}, + }, + nil, + }, + { + []byte(`{"type": "rich_text_list","elements":[],"indent":2}`), + RichTextList{ + Type: RTEList, + Indent: 2, + Elements: []RichTextElement{}, + }, + nil, + }, + } + for _, tc := range cases { + var actual RichTextList + err := json.Unmarshal(tc.raw, &actual) + if err != nil { + if tc.err == nil { + t.Errorf("unexpected error: %s", err) + } + t.Errorf("expected error is %s, but got %s", tc.err, err) + } + if tc.err != nil { + t.Errorf("expected to raise an error %s", tc.err) + } + if diff := deep.Equal(actual, tc.expected); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + } +} + +func TestRichTextQuote_Marshal(t *testing.T) { + t.Run("rich_text_section", func(t *testing.T) { + const rawRSE = "{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"Some Text\"},{\"type\":\"emoji\",\"name\":\"+1\"},{\"type\":\"emoji\",\"name\":\"+1\",\"skin_tone\":2}]}" + + var got RichTextSection + if err := json.Unmarshal([]byte(rawRSE), &got); err != nil { + t.Fatal(err) + } + want := RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Some Text"}, + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1"}, + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1", SkinTone: 2}, + }, + } + + if diff := deep.Equal(got, want); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(string(b), rawRSE); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + }) + t.Run("rich_text_quote", func(t *testing.T) { + const rawRTS = "{\"type\":\"rich_text_quote\",\"elements\":[{\"type\":\"text\",\"text\":\"Some text\"}]}" + + var got RichTextQuote + if err := json.Unmarshal([]byte(rawRTS), &got); err != nil { + t.Fatal(err) + } + want := RichTextQuote{ + Type: RTEQuote, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Some text"}, + }, + } + if diff := deep.Equal(got, want); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(string(b), rawRTS); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + }) + t.Run("rich_text_preformatted", func(t *testing.T) { + const rawRTP = "{\"type\":\"rich_text_preformatted\",\"elements\":[{\"type\":\"text\",\"text\":\"Some other text\"}],\"border\":2}" + want := RichTextPreformatted{ + Type: RTEPreformatted, + Elements: []RichTextSectionElement{&RichTextSectionTextElement{Type: RTSEText, Text: "Some other text"}}, + Border: 2, + } + var got RichTextPreformatted + if err := json.Unmarshal([]byte(rawRTP), &got); err != nil { + t.Fatal(err) + } + if diff := deep.Equal(got, want); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if diff := deep.Equal(string(b), rawRTP); diff != nil { + t.Errorf("actual value does not match expected one\n%s", diff) + } + }) +} + +func TestNewRichTextSectionChannelElement(t *testing.T) { + style := &RichTextSectionTextStyle{Bold: true} + element := NewRichTextSectionChannelElement("C012345678", style) + + assert.Equal(t, RTSEChannel, element.Type) + assert.Equal(t, "C012345678", element.ChannelID) + assert.Equal(t, style, element.Style) + + // Test JSON marshaling to ensure correct type + data, err := json.Marshal(element) + assert.NoError(t, err) + assert.Contains(t, string(data), `"type":"channel"`) + assert.Contains(t, string(data), `"channel_id":"C012345678"`) +} + +func TestRichTextSectionUsergroupStyleExtendedFields(t *testing.T) { + raw := `{ + "type": "rich_text_section", + "elements": [ + { + "type": "usergroup", + "usergroup_id": "ausergroupid", + "style": { + "bold": true, + "underline": true, + "highlight": true, + "client_highlight": true, + "unlink": true + } + } + ] + }` + + var section RichTextSection + err := json.Unmarshal([]byte(raw), §ion) + assert.NoError(t, err) + assert.Len(t, section.Elements, 1) + + elem, ok := section.Elements[0].(*RichTextSectionUserGroupElement) + assert.True(t, ok) + assert.Equal(t, "ausergroupid", elem.UsergroupID) + assert.NotNil(t, elem.Style) + assert.True(t, elem.Style.Bold) + assert.True(t, elem.Style.Underline) + assert.True(t, elem.Style.Highlight) + assert.True(t, elem.Style.ClientHighlight) + assert.True(t, elem.Style.Unlink) + assert.False(t, elem.Style.Italic) + assert.False(t, elem.Style.Strike) + assert.False(t, elem.Style.Code) + + // Round-trip: marshal and verify fields survive + data, err := json.Marshal(section) + assert.NoError(t, err) + assert.Contains(t, string(data), `"underline":true`) + assert.Contains(t, string(data), `"highlight":true`) + assert.Contains(t, string(data), `"client_highlight":true`) + assert.Contains(t, string(data), `"unlink":true`) + assert.NotContains(t, string(data), `"italic"`) + assert.NotContains(t, string(data), `"strike"`) +} diff --git a/block_roundtrip_test.go b/block_roundtrip_test.go new file mode 100644 index 000000000..d5f3077c9 --- /dev/null +++ b/block_roundtrip_test.go @@ -0,0 +1,132 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBlockRealPayloadRoundTrip is a guardrail against silent data loss when decoding the +// JSON that Slack actually sends. +// +// Each case is a single block payload written as raw JSON in the shape Slack delivers +// (i.e. authored from the JSON in, not built via the SDK constructors — that distinction +// matters, see below). Every case is decoded through the same path inbound events take — +// Blocks.UnmarshalJSON, which dispatches to the concrete block type — and the test asserts: +// +// 1. it decodes into a recognised concrete block (never UnknownBlock), and +// 2. re-marshalling the decoded block reproduces the input exactly (semantic JSON equality). +// +// (2) is the important part: a field the concrete type fails to model — like the per-cell +// text that went missing in issue #1558 — disappears on the way back out and fails the +// comparison. Constructor-based tests can't catch this, because they only ever serialise +// what the SDK already knows how to represent. +// +// Note this deliberately does NOT use BlockFromJSON: that helper preserves the raw bytes and +// echoes them back unchanged, so it would round-trip any payload perfectly while exercising +// none of the concrete UnmarshalJSON logic this guardrail exists to protect. +// +// To cover a new block, add a case below with a payload in the shape Slack sends. If it +// doesn't round-trip, the SDK is dropping something Slack sent — fix the type, don't trim +// the payload. +func TestBlockRealPayloadRoundTrip(t *testing.T) { + tests := []struct { + name string + payload string + }{ + { + // A table pasted from a spreadsheet (issue #1558): a bold rich_text header + // row, raw_text/raw_number data cells, and a null cell for an empty space. + name: "table with mixed cell types and an empty cell", + payload: `{ + "type": "table", + "block_id": "tbl_pasted", + "rows": [ + [ + {"type": "rich_text", "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "Name", "style": {"bold": true}}]}]}, + {"type": "rich_text", "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "Score", "style": {"bold": true}}]}]} + ], + [ + {"type": "raw_text", "text": "Alice"}, + {"type": "raw_number", "value": 42} + ], + [ + {"type": "raw_text", "text": "Bob"}, + null + ] + ] + }`, + }, + { + // A collapsible container wrapping a section and a divider, with a + // plain_text title, mrkdwn subtitle, and an icon. + name: "container with title, subtitle, icon and child blocks", + payload: `{ + "type": "container", + "block_id": "container-1", + "title": {"type": "plain_text", "text": "Deploy status"}, + "subtitle": {"type": "mrkdwn", "text": "*production*"}, + "icon": {"type": "image", "image_url": "https://example.com/icon.png", "alt_text": "icon"}, + "width": "wide", + "is_collapsible": true, + "default_collapsed": true, + "child_blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "All systems go"}}, + {"type": "divider"} + ] + }`, + }, + { + name: "data_table with raw_text, raw_number and rich_text cells", + payload: `{ + "type": "data_table", + "block_id": "dt-1", + "caption": "A Fabulous Table", + "page_size": 5, + "rows": [ + [ + {"type": "raw_text", "text": "Name"}, + {"type": "raw_text", "text": "Department"}, + {"type": "raw_text", "text": "Badge"} + ], + [ + {"type": "raw_text", "text": "Helly"}, + {"type": "raw_text", "text": "MDR"}, + {"type": "rich_text", "elements": [{"type": "rich_text_section", "elements": [{"type": "text", "text": "Blue", "style": {"bold": true}}]}]} + ], + [ + {"type": "raw_text", "text": "Score"}, + {"type": "raw_text", "text": "Wellness"}, + {"type": "raw_number", "value": 97} + ] + ] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Slack delivers blocks as a JSON array; Blocks.UnmarshalJSON is what + // dispatches each element to its concrete type. + var blocks Blocks + require.NoError(t, json.Unmarshal([]byte("["+tt.payload+"]"), &blocks), "payload failed to decode") + require.Len(t, blocks.BlockSet, 1) + + block := blocks.BlockSet[0] + _, isUnknown := block.(*UnknownBlock) + assert.False(t, isUnknown, + "payload decoded to an UnknownBlock; the block type is not modelled") + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var want, got any + require.NoError(t, json.Unmarshal([]byte(tt.payload), &want)) + require.NoError(t, json.Unmarshal(marshalled, &got)) + assert.Equal(t, want, got, + "round-trip lost or altered data: the concrete block type is not preserving everything Slack sent") + }) + } +} diff --git a/block_section.go b/block_section.go index 01ffd5a1d..0d2352a03 100644 --- a/block_section.go +++ b/block_section.go @@ -9,6 +9,7 @@ type SectionBlock struct { BlockID string `json:"block_id,omitempty"` Fields []*TextBlockObject `json:"fields,omitempty"` Accessory *Accessory `json:"accessory,omitempty"` + Expand bool `json:"expand,omitempty"` } // BlockType returns the type of the block @@ -16,6 +17,11 @@ func (s SectionBlock) BlockType() MessageBlockType { return s.Type } +// ID returns the ID of the block +func (s SectionBlock) ID() string { + return s.BlockID +} + // SectionBlockOption allows configuration of options for a new section block type SectionBlockOption func(*SectionBlock) @@ -25,6 +31,15 @@ func SectionBlockOptionBlockID(blockID string) SectionBlockOption { } } +// SectionBlockOptionExpand allows long text to be auto-expanded when displaying +// +// @see https://api.slack.com/reference/block-kit/blocks#section +func SectionBlockOptionExpand(shouldExpand bool) SectionBlockOption { + return func(block *SectionBlock) { + block.Expand = shouldExpand + } +} + // NewSectionBlock returns a new instance of a section block to be rendered func NewSectionBlock(textObj *TextBlockObject, fields []*TextBlockObject, accessory *Accessory, options ...SectionBlockOption) *SectionBlock { block := SectionBlock{ diff --git a/block_section_test.go b/block_section_test.go index 68329679b..373e1fe71 100644 --- a/block_section_test.go +++ b/block_section_test.go @@ -7,17 +7,17 @@ import ( ) func TestNewSectionBlock(t *testing.T) { - textInfo := NewTextBlockObject("mrkdwn", "**\n★★★★★\n$340 per night\nRated: 9.1 - Excellent", false, false) - sectionBlock := NewSectionBlock(textInfo, nil, nil, SectionBlockOptionBlockID("test_block")) + + assert.Equal(t, sectionBlock.BlockType(), MBTSection) assert.Equal(t, string(sectionBlock.Type), "section") assert.Equal(t, sectionBlock.BlockID, "test_block") + assert.Equal(t, sectionBlock.ID(), "test_block") assert.Equal(t, len(sectionBlock.Fields), 0) assert.Nil(t, sectionBlock.Accessory) assert.Equal(t, sectionBlock.Text.Type, "mrkdwn") assert.Contains(t, sectionBlock.Text.Text, "New Orleans") - } func TestNewBlockSectionContainsAddedTextBlockAndAccessory(t *testing.T) { @@ -30,7 +30,23 @@ func TestNewBlockSectionContainsAddedTextBlockAndAccessory(t *testing.T) { textBlockInSection := sectionBlock.Text assert.Equal(t, textBlockInSection.Text, textBlockObject.Text) assert.Equal(t, textBlockInSection.Type, textBlockObject.Type) - assert.True(t, textBlockInSection.Emoji) + assert.Nil(t, textBlockInSection.Emoji) assert.False(t, textBlockInSection.Verbatim) assert.Equal(t, sectionBlock.Accessory.ImageElement, conflictImage) } + +func TestSectionBlockOptionExpand(t *testing.T) { + textInfo := NewTextBlockObject("mrkdwn", "This is a long text that should be auto-expanded", false, false) + + // Create a section block with expand option set to true + sectionBlock := NewSectionBlock(textInfo, nil, nil, SectionBlockOptionExpand(true)) + + // Verify that the expand field is set correctly + assert.True(t, sectionBlock.Expand) + + // Create another section block with expand option set to false + sectionBlock = NewSectionBlock(textInfo, nil, nil, SectionBlockOptionExpand(false)) + + // Verify that the expand field is set correctly + assert.False(t, sectionBlock.Expand) +} diff --git a/block_table.go b/block_table.go new file mode 100644 index 000000000..1eaf092e5 --- /dev/null +++ b/block_table.go @@ -0,0 +1,213 @@ +package slack + +import ( + "encoding/json" + "fmt" +) + +// TableCellType identifies the variant of a cell inside a TableBlock row. +type TableCellType string + +const ( + TableCellRawText TableCellType = "raw_text" + TableCellRawNumber TableCellType = "raw_number" + TableCellRichText TableCellType = "rich_text" +) + +// TableCell is implemented by every cell type valid inside a TableBlock row: +// TableRichTextCell, TableRawTextCell, and TableRawNumberCell. A nil TableCell +// represents an empty cell, which Slack sends as null (common in user-pasted tables). +type TableCell interface { + TableCellType() TableCellType +} + +// TableRichTextCell is a cell holding rich text formatting. Slack uses rich_text cells +// for styled content such as the bold header row of a pasted table. +type TableRichTextCell struct { + Type TableCellType `json:"type"` + Elements []RichTextElement `json:"elements"` +} + +// TableCellType returns the cell variant. +func (c TableRichTextCell) TableCellType() TableCellType { + return c.Type +} + +// NewTableRichTextCell returns a rich_text cell with the given rich text elements. +func NewTableRichTextCell(elements ...RichTextElement) *TableRichTextCell { + return &TableRichTextCell{Type: TableCellRichText, Elements: elements} +} + +// UnmarshalJSON delegates rich text element parsing to RichTextBlock so the cell handles +// the same set of inner elements (sections, lists, quotes, preformatted, unknown). +func (c *TableRichTextCell) UnmarshalJSON(data []byte) error { + var rt RichTextBlock + if err := json.Unmarshal(data, &rt); err != nil { + return err + } + c.Type = TableCellRichText + c.Elements = rt.Elements + return nil +} + +// TableRawTextCell is a plain-text cell in a TableBlock. Slack sends raw_text cells for +// non-numeric data, including text pasted from a spreadsheet. +type TableRawTextCell struct { + Type TableCellType `json:"type"` + Text string `json:"text"` +} + +// TableCellType returns the cell variant. +func (c TableRawTextCell) TableCellType() TableCellType { + return c.Type +} + +// NewTableRawTextCell returns a raw_text cell with the given text. +func NewTableRawTextCell(text string) *TableRawTextCell { + return &TableRawTextCell{Type: TableCellRawText, Text: text} +} + +// TableRawNumberCell is a numeric cell in a TableBlock. Text, when set, overrides the +// displayed value. +// +// raw_number cells are receive-only: Slack emits them for numeric columns in user-pasted +// tables, but chat.postMessage rejects them. To post a number, use a TableRawTextCell. See +// TableBlock for the full list of postable vs receive-only cell types. +type TableRawNumberCell struct { + Type TableCellType `json:"type"` + Value float64 `json:"value"` + Text string `json:"text,omitempty"` +} + +// TableCellType returns the cell variant. +func (c TableRawNumberCell) TableCellType() TableCellType { + return c.Type +} + +// NewTableRawNumberCell returns a raw_number cell with the given value. +func NewTableRawNumberCell(value float64) *TableRawNumberCell { + return &TableRawNumberCell{Type: TableCellRawNumber, Value: value} +} + +// WithText sets the display text shown in place of the numeric value. +func (c *TableRawNumberCell) WithText(text string) *TableRawNumberCell { + c.Text = text + return c +} + +// TableBlock defines a block that lets you use a table to display your data. +// +// Rows is an array of cell arrays. Each cell is a TableRichTextCell, TableRawTextCell, +// or TableRawNumberCell; a nil cell represents an empty cell (Slack sends null). +// +// Not every cell type can be posted. chat.postMessage validates a table cell against two +// schemas only: raw_text (TableRawTextCell, requires "text") and rich_text +// (TableRichTextCell, requires "elements"). TableRawNumberCell and nil cells are produced +// by Slack on output — numeric columns and empty cells in user-pasted tables — but the API +// rejects them on input with invalid_blocks. So you may receive all cell types, but post +// only raw_text and rich_text; render a number you want to send as a raw_text cell. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/table-block/ +type TableBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + Rows [][]TableCell `json:"rows"` + ColumnSettings []ColumnSetting `json:"column_settings,omitempty"` +} + +type ColumnAlignment string + +const ( + ColumnAlignmentLeft ColumnAlignment = "left" + ColumnAlignmentCenter ColumnAlignment = "center" + ColumnAlignmentRight ColumnAlignment = "right" +) + +type ColumnSetting struct { + Align ColumnAlignment `json:"align"` + IsWrapped bool `json:"is_wrapped"` +} + +// BlockType returns the type of the block +func (s TableBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s TableBlock) ID() string { + return s.BlockID +} + +// UnmarshalJSON parses the heterogeneous cell types in each row. A null cell is decoded +// as a nil TableCell. +func (s *TableBlock) UnmarshalJSON(data []byte) error { + var raw struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id"` + ColumnSettings []ColumnSetting `json:"column_settings"` + Rows [][]json.RawMessage `json:"rows"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + rows := make([][]TableCell, 0, len(raw.Rows)) + for _, rawRow := range raw.Rows { + row := make([]TableCell, 0, len(rawRow)) + for _, rawCell := range rawRow { + if len(rawCell) == 0 || string(rawCell) == "null" { + row = append(row, nil) + continue + } + var probe struct { + Type TableCellType `json:"type"` + } + if err := json.Unmarshal(rawCell, &probe); err != nil { + return err + } + var cell TableCell + switch probe.Type { + case TableCellRawText: + cell = &TableRawTextCell{} + case TableCellRawNumber: + cell = &TableRawNumberCell{} + case TableCellRichText: + cell = &TableRichTextCell{} + default: + return fmt.Errorf("unsupported table cell type %q", probe.Type) + } + if err := json.Unmarshal(rawCell, cell); err != nil { + return err + } + row = append(row, cell) + } + rows = append(rows, row) + } + + s.Type = raw.Type + s.BlockID = raw.BlockID + s.ColumnSettings = raw.ColumnSettings + s.Rows = rows + return nil +} + +// WithColumnSettings sets the column settings for the Table Block +func (s *TableBlock) WithColumnSettings(columnSettings ...ColumnSetting) *TableBlock { + s.ColumnSettings = columnSettings + return s +} + +// AddRow adds a new row of cells to the Table Block +func (s *TableBlock) AddRow(cells ...TableCell) *TableBlock { + s.Rows = append(s.Rows, append([]TableCell{}, cells...)) + return s +} + +// NewTableBlock returns an instance of a Table Block type +func NewTableBlock(blockID string) *TableBlock { + return &TableBlock{ + Type: MBTTable, + BlockID: blockID, + Rows: make([][]TableCell, 0), + } +} diff --git a/block_table_test.go b/block_table_test.go new file mode 100644 index 000000000..fbd36098a --- /dev/null +++ b/block_table_test.go @@ -0,0 +1,191 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewTableBlock(t *testing.T) { + testPayload := `{ + "type":"table", + "block_id":"test1", + "rows": [ + [{ + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Col1" + } + ] + } + ] + }, + { + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Col2" + } + ] + } + ] + }], + [{ + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Val1" + } + ] + } + ] + }, + { + "type":"rich_text", + "elements": [ + { + "type":"rich_text_section", + "elements": [ + { + "type":"text", + "text":"Val2" + } + ] + } + ] + }] + ] + }` + + tableBlock := NewTableBlock("test1") + + tableBlock.AddRow(NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Col1"}, + }, + }, + ), NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Col2"}, + }, + }, + )) + + tableBlock.AddRow(NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Val1"}, + }, + }, + ), NewTableRichTextCell( + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Val2"}, + }, + }, + )) + + assert.Equal(t, tableBlock.BlockType(), MBTTable) + assert.Equal(t, string(tableBlock.Type), "table") + assert.Equal(t, tableBlock.BlockID, "test1") + assert.Equal(t, tableBlock.ID(), "test1") + assert.Equal(t, len(tableBlock.Rows), 2) + assert.Equal(t, len(tableBlock.ColumnSettings), 0) + + // Check if marshalled payload matches expected JSON + marshalled, err := json.Marshal(tableBlock) + assert.NoError(t, err) + + var expected, actual map[string]any + err = json.Unmarshal([]byte(testPayload), &expected) + assert.NoError(t, err) + err = json.Unmarshal(marshalled, &actual) + assert.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +// TestTableBlockHeterogeneousCells covers the case reported in issue #1558: tables +// pasted from a spreadsheet deliver header cells as rich_text and data cells as +// raw_text/raw_number, with null for empty cells. All cell text must be preserved +// across an unmarshal/marshal round trip. +func TestTableBlockHeterogeneousCells(t *testing.T) { + payload := `{ + "type":"table", + "block_id":"t1", + "rows":[ + [ + {"type":"rich_text","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"Name","style":{"bold":true}}]}]}, + {"type":"rich_text","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"Score","style":{"bold":true}}]}]} + ], + [ + {"type":"raw_text","text":"Alice"}, + {"type":"raw_number","value":42} + ], + [ + {"type":"raw_text","text":"Bob"}, + null + ] + ] + }` + + var tb TableBlock + err := json.Unmarshal([]byte(payload), &tb) + assert.NoError(t, err) + assert.Equal(t, MBTTable, tb.BlockType()) + assert.Len(t, tb.Rows, 3) + + // Header row: rich_text cells. + header, ok := tb.Rows[0][0].(*TableRichTextCell) + assert.True(t, ok) + assert.Equal(t, TableCellRichText, header.TableCellType()) + assert.Len(t, header.Elements, 1) + + // Data row: raw_text + raw_number must keep their values. + rawText, ok := tb.Rows[1][0].(*TableRawTextCell) + assert.True(t, ok) + assert.Equal(t, "Alice", rawText.Text) + + rawNumber, ok := tb.Rows[1][1].(*TableRawNumberCell) + assert.True(t, ok) + assert.Equal(t, float64(42), rawNumber.Value) + + // Empty cell decodes as nil. + assert.Nil(t, tb.Rows[2][1]) + + // Round trip preserves the payload. + marshalled, err := json.Marshal(&tb) + assert.NoError(t, err) + + var expected, actual map[string]any + assert.NoError(t, json.Unmarshal([]byte(payload), &expected)) + assert.NoError(t, json.Unmarshal(marshalled, &actual)) + assert.Equal(t, expected, actual) +} + +func TestTableBlockUnsupportedCellType(t *testing.T) { + payload := `{"type":"table","rows":[[{"type":"bogus","text":"x"}]]}` + var tb TableBlock + err := json.Unmarshal([]byte(payload), &tb) + assert.Error(t, err) +} diff --git a/block_task_card.go b/block_task_card.go new file mode 100644 index 000000000..d7647cc14 --- /dev/null +++ b/block_task_card.go @@ -0,0 +1,103 @@ +package slack + +// TaskCardStatus defines the status of a task card block. +type TaskCardStatus string + +const ( + TaskCardStatusPending TaskCardStatus = "pending" + TaskCardStatusInProgress TaskCardStatus = "in_progress" + TaskCardStatusComplete TaskCardStatus = "complete" + TaskCardStatusError TaskCardStatus = "error" +) + +// TaskCardSource represents a URL reference in a task card block. +type TaskCardSource struct { + Type string `json:"type"` + URL string `json:"url"` + Text string `json:"text"` +} + +// NewTaskCardSource creates a new TaskCardSource with type "url". +func NewTaskCardSource(url, text string) TaskCardSource { + return TaskCardSource{ + Type: "url", + URL: url, + Text: text, + } +} + +// TaskCardBlock defines a block of type task_card used by AI agents +// to display thinking steps and task execution. +// +// More Information: https://docs.slack.dev/reference/block-kit/blocks/task-card-block/ +type TaskCardBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Status TaskCardStatus `json:"status,omitempty"` + Details *RichTextBlock `json:"details,omitempty"` + Output *RichTextBlock `json:"output,omitempty"` + Sources []TaskCardSource `json:"sources,omitempty"` +} + +// BlockType returns the type of the block +func (s TaskCardBlock) BlockType() MessageBlockType { + return s.Type +} + +// ID returns the ID of the block +func (s TaskCardBlock) ID() string { + return s.BlockID +} + +// TaskCardBlockOption allows configuration of options for a new task card block +type TaskCardBlockOption func(*TaskCardBlock) + +// TaskCardBlockOptionBlockID sets the block ID for the task card block +func TaskCardBlockOptionBlockID(blockID string) TaskCardBlockOption { + return func(block *TaskCardBlock) { + block.BlockID = blockID + } +} + +// NewTaskCardBlock returns a new instance of a task card block +func NewTaskCardBlock(taskID, title string, options ...TaskCardBlockOption) *TaskCardBlock { + block := TaskCardBlock{ + Type: MBTTaskCard, + TaskID: taskID, + Title: title, + } + + for _, option := range options { + if option != nil { + option(&block) + } + } + + return &block +} + +// WithStatus sets the status for the TaskCardBlock +func (s *TaskCardBlock) WithStatus(status TaskCardStatus) *TaskCardBlock { + s.Status = status + return s +} + +// WithDetails sets the details rich text block for the TaskCardBlock +func (s *TaskCardBlock) WithDetails(details *RichTextBlock) *TaskCardBlock { + s.Details = details + return s +} + +// WithOutput sets the output rich text block for the TaskCardBlock +func (s *TaskCardBlock) WithOutput(output *RichTextBlock) *TaskCardBlock { + s.Output = output + return s +} + +// WithSources sets the sources for the TaskCardBlock +func (s *TaskCardBlock) WithSources(sources ...TaskCardSource) *TaskCardBlock { + s.Sources = sources + return s +} diff --git a/block_task_card_test.go b/block_task_card_test.go new file mode 100644 index 000000000..0e337c626 --- /dev/null +++ b/block_task_card_test.go @@ -0,0 +1,155 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTaskCardBlock(t *testing.T) { + block := NewTaskCardBlock("task-1", "Search the web", TaskCardBlockOptionBlockID("block-1")) + + assert.Equal(t, MBTTaskCard, block.BlockType()) + assert.Equal(t, "task_card", string(block.Type)) + assert.Equal(t, "block-1", block.ID()) + assert.Equal(t, "task-1", block.TaskID) + assert.Equal(t, "Search the web", block.Title) +} + +func TestTaskCardBlockChainableMethods(t *testing.T) { + details := &RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Searching..."}, + }, + }, + }, + } + + output := &RichTextBlock{ + Type: MBTRichText, + Elements: []RichTextElement{ + &RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionTextElement{Type: RTSEText, Text: "Found 3 results"}, + }, + }, + }, + } + + block := NewTaskCardBlock("task-1", "Search the web"). + WithStatus(TaskCardStatusComplete). + WithDetails(details). + WithOutput(output). + WithSources( + NewTaskCardSource("https://example.com", "Example"), + NewTaskCardSource("https://other.com", "Other"), + ) + + assert.Equal(t, TaskCardStatusComplete, block.Status) + assert.Equal(t, details, block.Details) + assert.Equal(t, output, block.Output) + assert.Len(t, block.Sources, 2) + assert.Equal(t, "url", block.Sources[0].Type) + assert.Equal(t, "https://example.com", block.Sources[0].URL) + assert.Equal(t, "Example", block.Sources[0].Text) +} + +func TestTaskCardBlockJSONRoundTrip(t *testing.T) { + payload := `{ + "type": "task_card", + "block_id": "block-1", + "task_id": "task-1", + "title": "Search the web", + "status": "in_progress", + "details": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Searching for results" + } + ] + } + ] + }, + "output": { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Found 3 results" + } + ] + } + ] + }, + "sources": [ + { + "type": "url", + "url": "https://example.com", + "text": "Example" + } + ] + }` + + var block TaskCardBlock + err := json.Unmarshal([]byte(payload), &block) + require.NoError(t, err) + + assert.Equal(t, MBTTaskCard, block.BlockType()) + assert.Equal(t, "block-1", block.ID()) + assert.Equal(t, "task-1", block.TaskID) + assert.Equal(t, "Search the web", block.Title) + assert.Equal(t, TaskCardStatusInProgress, block.Status) + require.NotNil(t, block.Details) + require.NotNil(t, block.Output) + require.Len(t, block.Sources, 1) + assert.Equal(t, "https://example.com", block.Sources[0].URL) + + marshalled, err := json.Marshal(block) + require.NoError(t, err) + + var expected, actual map[string]any + err = json.Unmarshal([]byte(payload), &expected) + require.NoError(t, err) + err = json.Unmarshal(marshalled, &actual) + require.NoError(t, err) + + assert.Equal(t, expected, actual) +} + +func TestTaskCardBlockUnmarshalViaBlocks(t *testing.T) { + payload := `[ + { + "type": "task_card", + "task_id": "task-1", + "title": "Analyze data", + "status": "complete" + } + ]` + + var blocks Blocks + err := json.Unmarshal([]byte(payload), &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + taskCard, ok := blocks.BlockSet[0].(*TaskCardBlock) + require.True(t, ok, "expected *TaskCardBlock, got %T", blocks.BlockSet[0]) + assert.Equal(t, MBTTaskCard, taskCard.BlockType()) + assert.Equal(t, "task-1", taskCard.TaskID) + assert.Equal(t, "Analyze data", taskCard.Title) + assert.Equal(t, TaskCardStatusComplete, taskCard.Status) +} diff --git a/block_unknown.go b/block_unknown.go index 97054c73e..71b2a90c9 100644 --- a/block_unknown.go +++ b/block_unknown.go @@ -1,13 +1,36 @@ package slack -// UnknownBlock represents a block type that is not yet known. This block type exists to prevent Slack from introducing -// new and unknown block types that break this library. +import "encoding/json" + +// UnknownBlock represents a block type that is not yet known. This block type +// exists to prevent Slack from introducing new and unknown block types that +// break this library. It preserves the raw JSON so that unrecognized blocks +// survive round-trip marshaling. +// +// If you encounter an UnknownBlock for a block type that Slack documents, +// please open an issue at https://github.com/slack-go/slack/issues so we can +// add first-class support for it. type UnknownBlock struct { Type MessageBlockType `json:"type"` BlockID string `json:"block_id,omitempty"` + raw json.RawMessage } // BlockType returns the type of the block func (b UnknownBlock) BlockType() MessageBlockType { return b.Type } + +// ID returns the ID of the block +func (s UnknownBlock) ID() string { + return s.BlockID +} + +// MarshalJSON returns the original raw JSON if available, preserving all fields +func (b UnknownBlock) MarshalJSON() ([]byte, error) { + if b.raw != nil { + return b.raw, nil + } + type alias UnknownBlock + return json.Marshal(alias(b)) +} diff --git a/block_unknown_test.go b/block_unknown_test.go new file mode 100644 index 000000000..8686de5e7 --- /dev/null +++ b/block_unknown_test.go @@ -0,0 +1,25 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnknownBlockRoundTrip(t *testing.T) { + input := `[{"type":"some_future_block","block_id":"fb1","custom_field":"value","nested":{"key":"val"}}]` + + var blocks Blocks + err := json.Unmarshal([]byte(input), &blocks) + require.NoError(t, err) + require.Len(t, blocks.BlockSet, 1) + + assert.Equal(t, MessageBlockType("some_future_block"), blocks.BlockSet[0].BlockType()) + assert.Equal(t, "fb1", blocks.BlockSet[0].ID()) + + output, err := json.Marshal(blocks) + require.NoError(t, err) + assert.JSONEq(t, input, string(output)) +} diff --git a/block_video.go b/block_video.go new file mode 100644 index 000000000..4d6739c46 --- /dev/null +++ b/block_video.go @@ -0,0 +1,70 @@ +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 +} + +// ID returns the ID of the block +func (s VideoBlock) ID() string { + return s.BlockID +} + +// 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..1ccece7f7 --- /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, videoBlock.Type, MBTVideo) + assert.Equal(t, string(videoBlock.Type), "video") + assert.Equal(t, videoBlock.Title.Type, "plain_text") + assert.Equal(t, videoBlock.BlockID, "blockID") + assert.Equal(t, videoBlock.ID(), "blockID") + assert.Contains(t, videoBlock.Title.Text, "VideoTitle") + assert.Contains(t, videoBlock.VideoURL, "example.mp4") +} diff --git a/bookmarks.go b/bookmarks.go new file mode 100644 index 000000000..1f07e59b0 --- /dev/null +++ b/bookmarks.go @@ -0,0 +1,169 @@ +package slack + +import ( + "context" + "net/url" +) + +type Bookmark struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Title string `json:"title"` + Link string `json:"link"` + Emoji string `json:"emoji"` + IconURL string `json:"icon_url"` + Type string `json:"type"` + Created JSONTime `json:"date_created"` + Updated JSONTime `json:"date_updated"` + Rank string `json:"rank"` + + LastUpdatedByUserID string `json:"last_updated_by_user_id"` + LastUpdatedByTeamID string `json:"last_updated_by_team_id"` + + ShortcutID string `json:"shortcut_id"` + EntityID string `json:"entity_id"` + AppID string `json:"app_id"` +} + +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 +} + +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 +} + +type addBookmarkResponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse +} + +type editBookmarkResponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse +} + +type listBookmarksResponse struct { + Bookmarks []Bookmark `json:"bookmarks"` + SlackResponse +} + +// 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 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}, + "title": {params.Title}, + "type": {params.Type}, + } + if params.Link != "" { + values.Set("link", params.Link) + } + if params.Emoji != "" { + values.Set("emoji", params.Emoji) + } + if params.EntityID != "" { + values.Set("entity_id", params.EntityID) + } + if params.ParentID != "" { + values.Set("parent_id", params.ParentID) + } + + response := &addBookmarkResponse{} + if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil { + return Bookmark{}, 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}, + } + + response := &SlackResponse{} + if err := api.postMethod(ctx, "bookmarks.remove", values, response); err != nil { + return err + } + + return response.Err() +} + +// 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) +} + +// 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{ + "channel_id": {channelID}, + "token": {api.token}, + } + + response := &listBookmarksResponse{} + err := api.postMethod(ctx, "bookmarks.list", values, response) + if err != nil { + return nil, err + } + return response.Bookmarks, response.Err() +} + +// 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 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{ + "channel_id": {channelID}, + "token": {api.token}, + "bookmark_id": {bookmarkID}, + } + if params.Link != "" { + values.Set("link", params.Link) + } + if params.Emoji != nil { + values.Set("emoji", *params.Emoji) + } + if params.Title != nil { + values.Set("title", *params.Title) + } + + response := &editBookmarkResponse{} + if err := api.postMethod(ctx, "bookmarks.edit", values, response); err != nil { + return Bookmark{}, err + } + + return response.Bookmark, response.Err() +} diff --git a/bookmarks_test.go b/bookmarks_test.go new file mode 100644 index 000000000..43e5bb9c7 --- /dev/null +++ b/bookmarks_test.go @@ -0,0 +1,237 @@ +package slack + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "testing" +) + +func getTestBookmark(channelID, bookmarkID string) Bookmark { + return Bookmark{ + ID: bookmarkID, + ChannelID: channelID, + Title: "bookmark", + Type: "link", + Link: "https://example.com", + IconURL: "https://example.com/icon.png", + } +} + +func addBookmarkLinkHandler(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + title := r.FormValue("title") + bookmarkType := r.FormValue("type") + link := r.FormValue("link") + + rw.Header().Set("Content-Type", "application/json") + + if bookmarkType == "link" && link != "" && channelID != "" && title != "" { + bookmark := getTestBookmark(channelID, "Bk123RBZG8GZ") + bookmark.Title = title + bookmark.Type = bookmarkType + bookmark.Link = link + + resp, _ := json.Marshal(&addBookmarkResponse{ + SlackResponse: SlackResponse{Ok: true}, + Bookmark: bookmark}) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestAddBookmarkLink(t *testing.T) { + http.HandleFunc("/bookmarks.add", addBookmarkLinkHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + params := AddBookmarkParameters{ + Title: "test", + Type: "link", + Link: "https://example.com", + } + _, err := api.AddBookmark("CXXXXXXXX", params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func listBookmarksHandler(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + + rw.Header().Set("Content-Type", "application/json") + + if channelID != "" { + bookmarks := []Bookmark{ + getTestBookmark(channelID, "Bk001"), + getTestBookmark(channelID, "Bk002"), + getTestBookmark(channelID, "Bk003"), + getTestBookmark(channelID, "Bk004"), + } + + resp, _ := json.Marshal(&listBookmarksResponse{ + SlackResponse: SlackResponse{Ok: true}, + Bookmarks: bookmarks}) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestListBookmarks(t *testing.T) { + http.HandleFunc("/bookmarks.list", listBookmarksHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + channel := "CXXXXXXXX" + bookmarks, err := api.ListBookmarks(channel) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !reflect.DeepEqual([]Bookmark{ + getTestBookmark(channel, "Bk001"), + getTestBookmark(channel, "Bk002"), + getTestBookmark(channel, "Bk003"), + getTestBookmark(channel, "Bk004"), + }, bookmarks) { + t.Fatal(ErrIncorrectResponse) + } +} + +func removeBookmarkHandler(bookmark *Bookmark) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + bookmarkID := r.FormValue("bookmark_id") + + rw.Header().Set("Content-Type", "application/json") + + if channelID == bookmark.ChannelID && bookmarkID == bookmark.ID { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } + } +} + +func TestRemoveBookmark(t *testing.T) { + channel := "CXXXXXXXX" + bookmark := getTestBookmark(channel, "BkXXXXX") + http.HandleFunc("/bookmarks.remove", removeBookmarkHandler(&bookmark)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.RemoveBookmark(channel, bookmark.ID) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func editBookmarkHandler(bookmarks []Bookmark) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + channelID := r.FormValue("channel_id") + bookmarkID := r.FormValue("bookmark_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 _, bookmark := range bookmarks { + if bookmark.ID == bookmarkID && bookmark.ChannelID == channelID { + if v := r.Form.Get("link"); v != "" { + bookmark.Link = v + } + // Emoji and title require special handling since empty string sets to null + if _, ok := r.Form["emoji"]; ok { + bookmark.Emoji = r.Form.Get("emoji") + } + if _, ok := r.Form["title"]; ok { + bookmark.Title = r.Form.Get("title") + } + resp, _ := json.Marshal(&editBookmarkResponse{ + SlackResponse: SlackResponse{Ok: true}, + Bookmark: bookmark}) + rw.Write(resp) + return + } + } + // Fail if the bookmark doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestEditBookmark(t *testing.T) { + channel := "CXXXXXXXX" + bookmarks := []Bookmark{ + getTestBookmark(channel, "Bk001"), + getTestBookmark(channel, "Bk002"), + getTestBookmark(channel, "Bk003"), + getTestBookmark(channel, "Bk004"), + } + http.HandleFunc("/bookmarks.edit", editBookmarkHandler(bookmarks)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + smileEmoji := ":smile:" + empty := "" + title := "hello, world!" + changes := []struct { + ID string + Params EditBookmarkParameters + }{ + { // add emoji + ID: "Bk001", + Params: EditBookmarkParameters{Emoji: &smileEmoji}, + }, + { // delete emoji + ID: "Bk001", + Params: EditBookmarkParameters{Emoji: &empty}, + }, + { // add title + ID: "Bk002", + Params: EditBookmarkParameters{Title: &title}, + }, + { // delete title + ID: "Bk002", + Params: EditBookmarkParameters{Title: &empty}, + }, + { // Change multiple fields at once + ID: "Bk003", + Params: EditBookmarkParameters{ + Title: &title, + Emoji: &empty, + Link: "https://example.com/changed", + }, + }, + { // noop + ID: "Bk004", + }, + } + + for _, change := range changes { + bookmark, err := api.EditBookmark(channel, change.ID, change.Params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if change.ID != bookmark.ID { + t.Fatalf("expected to modify bookmark with ID = %s, got %s", change.ID, bookmark.ID) + } + if change.Params.Emoji != nil && bookmark.Emoji != *change.Params.Emoji { + t.Fatalf("expected bookmark.Emoji = %s, got %s", *change.Params.Emoji, bookmark.Emoji) + } + if change.Params.Title != nil && bookmark.Title != *change.Params.Title { + t.Fatalf("expected bookmark.Title = %s, got %s", *change.Params.Title, bookmark.Emoji) + } + if change.Params.Link != "" && change.Params.Link != bookmark.Link { + t.Fatalf("expected bookmark.Link = %s, got %s", change.Params.Link, bookmark.Link) + } + } + + // Cover the final case of trying to edit a bookmark which doesn't exist + bookmark, err := api.EditBookmark(channel, "BkMissing", EditBookmarkParameters{}) + if err == nil { + t.Fatalf("Expected not found error, but got bookmark %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..d01ce823f 100644 --- a/channels.go +++ b/channels.go @@ -19,15 +19,16 @@ 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) { response := &channelResponseFull{} - err := postForm(ctx, api.httpclient, api.endpoint+path, values, response, api) + _, err := postForm(ctx, api.httpclient, api.endpoint+path, values, response, api) if err != nil { return nil, err } diff --git a/chat.go b/chat.go index 34848d151..7fbcc191c 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" @@ -28,15 +29,16 @@ 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 - Text string `json:"text"` + 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 + Text string `json:"text"` + Message Message `json:"message"` // Full message object, as returned by chat.postMessage and chat.update 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 { @@ -64,6 +66,12 @@ type PostMessageParameters struct { // chat.postEphemeral support Channel string `json:"channel"` User string `json:"user"` + + // chat metadata support + MetaData SlackMetadata `json:"metadata"` + + // file_ids support + FileIDs []string `json:"file_ids,omitempty"` } // NewPostMessageParameters provides an instance of PostMessageParameters with all the sane default values set @@ -84,12 +92,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, @@ -102,32 +112,33 @@ 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( + respChannel, scheduledMessageID, _, err := api.SendMessageContext( ctx, channelID, MsgOptionSchedule(postAt), MsgOptionCompose(options...), ) - return respChannel, respTimestamp, err + return respChannel, scheduledMessageID, err } // 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, @@ -138,15 +149,43 @@ func (api *Client) PostMessageContext(ctx context.Context, channelID string, opt return respChannel, respTimestamp, err } +// PostMessageWithResponse sends a message to a channel and returns the full +// message object from the Slack API response. +// For more details, see PostMessageWithResponseContext documentation. +func (api *Client) PostMessageWithResponse(channelID string, options ...MsgOption) (string, string, Message, error) { + return api.PostMessageWithResponseContext(context.Background(), channelID, options...) +} + +// PostMessageWithResponseContext sends a message to a channel with a custom +// context and returns the full message object from the Slack API response. +// Unlike PostMessageContext, it exposes fields that are only available in the +// response's message object, such as Message.ThreadTimestamp, which can be +// used to detect that a threaded reply was posted un-threaded because its +// parent message was deleted. +// Slack API docs: https://api.slack.com/methods/chat.postMessage +func (api *Client) PostMessageWithResponseContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, Message, error) { + response, err := api.sendResponseFull( + ctx, + channelID, + MsgOptionPost(), + MsgOptionCompose(options...), + ) + if response == nil { + return "", "", Message{}, err + } + return response.Channel, response.getMessageTimestamp(), response.Message, err +} + // 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, @@ -157,12 +196,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, @@ -172,63 +213,116 @@ 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...)) } +// UnfurlMessageWorkObject unfurls a message with Work Objects metadata. +// For more details, see UnfurlMessageWorkObjectContext documentation. +func (api *Client) UnfurlMessageWorkObject(channelID, timestamp string, unfurls map[string]Attachment, metadata WorkObjectMetadata, options ...MsgOption) (string, string, string, error) { + return api.UnfurlMessageWorkObjectContext(context.Background(), channelID, timestamp, unfurls, metadata, options...) +} + +// UnfurlMessageWorkObjectContext unfurls a message with Work Objects metadata with a custom context. +// This enables rich Work Object previews as described in https://docs.slack.dev/messaging/work-objects/ +// unfurls may be nil to send only Work Object metadata (no legacy attachment unfurls). +func (api *Client) UnfurlMessageWorkObjectContext(ctx context.Context, channelID, timestamp string, unfurls map[string]Attachment, metadata WorkObjectMetadata, options ...MsgOption) (string, string, string, error) { + return api.SendMessageContext(ctx, channelID, MsgOptionUnfurlWorkObject(timestamp, unfurls, metadata), MsgOptionCompose(options...)) +} + +// UnfurlMessageByID unfurls a link in the message composer using unfurl_id and source. +// Use this when Slack sends link_shared with unfurl_id (e.g. before the message is posted). +// For more details, see UnfurlMessageByIDContext documentation. +func (api *Client) UnfurlMessageByID(unfurlID, source string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { + return api.UnfurlMessageByIDContext(context.Background(), unfurlID, source, unfurls, options...) +} + +// UnfurlMessageByIDContext unfurls by unfurl_id and source with a custom context. +// Both unfurl_id and source must be provided together (alternative to channel + ts). +// Slack API docs: https://api.slack.com/methods/chat.unfurl +func (api *Client) UnfurlMessageByIDContext(ctx context.Context, unfurlID, source string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { + return api.SendMessageContext(ctx, "", MsgOptionUnfurlByID(unfurlID, source, unfurls), 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. -func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestamp string, _text string, err error) { +// Slack API docs: https://api.slack.com/methods/chat.postMessage +func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestampOrScheduledMessageID string, _text string, err error) { + response, err := api.sendResponseFull(ctx, channelID, options...) + if response == nil { + return "", "", "", err + } + + if response.ScheduledMessageID != "" { + return response.Channel, response.ScheduledMessageID, response.Text, err + } else { + return response.Channel, response.getMessageTimestamp(), response.Text, err + } +} + +// sendResponseFull sends a message and returns the full response. +// It returns a nil response if the request could not be built or sent; +// otherwise the returned error is the response's error, if any. +func (api *Client) sendResponseFull(ctx context.Context, channelID string, options ...MsgOption) (*chatResponseFull, error) { var ( req *http.Request parser func(*chatResponseFull) responseParser response chatResponseFull + err error ) if req, parser, err = buildSender(api.endpoint, options...).BuildRequestContext(ctx, api.token, channelID); err != nil { - return "", "", "", err + return nil, err } if api.Debug() { - reqBody, err := ioutil.ReadAll(req.Body) + reqBody, err := io.ReadAll(req.Body) if err != nil { - return "", "", "", err + return nil, 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 { - return "", "", "", err + if _, err = doPost(api.httpclient, req, parser(&response), api); err != nil { + return nil, err } - return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() + return &response, response.Err() +} + +func redactToken(b []byte) []byte { + // See https://api.slack.com/authentication/token-types + // and https://api.slack.com/authentication/rotation + re := regexp.MustCompile(`(token=x[a-z.]+)-[0-9A-Za-z-]+`) + // 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. @@ -276,6 +370,9 @@ const ( chatResponse sendMode = "chat.responseURL" chatMeMessage sendMode = "chat.meMessage" chatUnfurl sendMode = "chat.unfurl" + chatStartStream sendMode = "chat.startStream" + chatAppendStream sendMode = "chat.appendStream" + chatStopStream sendMode = "chat.stopStream" ) type sendConfig struct { @@ -285,6 +382,7 @@ type sendConfig struct { endpoint string values url.Values attachments []Attachment + metadata SlackMetadata blocks Blocks responseType string replaceOriginal bool @@ -306,19 +404,22 @@ func (t sendConfig) BuildRequestContext(ctx context.Context, token, channelID st endpoint: t.endpoint, values: t.values, attachments: t.attachments, + metadata: t.metadata, blocks: t.blocks, responseType: t.responseType, replaceOriginal: t.replaceOriginal, deleteOriginal: t.deleteOriginal, }.BuildRequestContext(ctx) default: - return formSender{endpoint: t.endpoint, values: t.values}.BuildRequestContext(ctx) + return formSender{endpoint: t.endpoint, values: t.values, attachments: t.attachments, blocks: t.blocks}.BuildRequestContext(ctx) } } type formSender struct { - endpoint string - values url.Values + endpoint string + values url.Values + attachments []Attachment + blocks Blocks } func (t formSender) BuildRequest() (*http.Request, func(*chatResponseFull) responseParser, error) { @@ -326,6 +427,22 @@ func (t formSender) BuildRequest() (*http.Request, func(*chatResponseFull) respo } func (t formSender) BuildRequestContext(ctx context.Context) (*http.Request, func(*chatResponseFull) responseParser, error) { + if t.attachments != nil { + attachmentBytes, err := json.Marshal(t.attachments) + if err != nil { + return nil, nil, err + } + t.values.Set("attachments", string(attachmentBytes)) + } + + if t.blocks.BlockSet != nil { + blockBytes, err := json.Marshal(t.blocks.BlockSet) + if err != nil { + return nil, nil, err + } + t.values.Set("blocks", string(blockBytes)) + } + req, err := formReq(ctx, t.endpoint, t.values) return req, func(resp *chatResponseFull) responseParser { return newJSONParser(resp) @@ -336,6 +453,7 @@ type responseURLSender struct { endpoint string values url.Values attachments []Attachment + metadata SlackMetadata blocks Blocks responseType string replaceOriginal bool @@ -350,8 +468,10 @@ 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, ResponseType: t.responseType, ReplaceOriginal: t.replaceOriginal, DeleteOriginal: t.deleteOriginal, @@ -432,6 +552,51 @@ func MsgOptionUnfurl(timestamp string, unfurls map[string]Attachment) MsgOption } } +// MsgOptionUnfurlByID unfurls using unfurl_id and source (e.g. when link is in the composer). +// Use instead of channel+ts when Slack provides unfurl_id in the link_shared event. +// unfurls may be nil; the API expects a JSON object so nil is sent as {}. +func MsgOptionUnfurlByID(unfurlID, source string, unfurls map[string]Attachment) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatUnfurl) + config.values.Del("channel") + config.values.Del("ts") + config.values.Set("unfurl_id", unfurlID) + config.values.Set("source", source) + if unfurls == nil { + unfurls = make(map[string]Attachment) + } + unfurlsStr, err := json.Marshal(unfurls) + if err == nil { + config.values.Set("unfurls", string(unfurlsStr)) + } + return err + } +} + +// MsgOptionUnfurlMetadataOnly sets chat.unfurl endpoint with only Work Object metadata (no unfurls). +func MsgOptionUnfurlMetadataOnly(timestamp string, metadata WorkObjectMetadata) MsgOption { + return MsgOptionCompose( + func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatUnfurl) + config.values.Add("ts", timestamp) + return nil + }, + MsgOptionWorkObjectMetadata(metadata), + ) +} + +// MsgOptionUnfurlWorkObject unfurls a message with Work Objects metadata. +// When unfurls is nil, only metadata is sent (no legacy attachment unfurls). +func MsgOptionUnfurlWorkObject(timestamp string, unfurls map[string]Attachment, metadata WorkObjectMetadata) MsgOption { + if len(unfurls) > 0 { + return MsgOptionCompose( + MsgOptionUnfurl(timestamp, unfurls), + MsgOptionWorkObjectMetadata(metadata), + ) + } + return MsgOptionUnfurlMetadataOnly(timestamp, metadata) +} + // MsgOptionUnfurlAuthURL unfurls a message using an auth url based on the timestamp. func MsgOptionUnfurlAuthURL(timestamp string, userAuthURL string) MsgOption { return func(config *sendConfig) error { @@ -464,6 +629,23 @@ func MsgOptionUnfurlAuthMessage(timestamp string, msg string) MsgOption { } } +// MsgOptionUnfurlAuthBlocks sets Block Kit blocks for the auth prompt (overrides default buttons). +// See https://docs.slack.com/methods/chat.unfurl for user_auth_blocks. +func MsgOptionUnfurlAuthBlocks(timestamp string, blocks ...Block) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatUnfurl) + config.values.Add("ts", timestamp) + if len(blocks) == 0 { + return nil + } + blocksStr, err := json.Marshal(blocks) + if err == nil { + config.values.Set("user_auth_blocks", string(blocksStr)) + } + return err + } +} + // MsgOptionResponseURL supplies a url to use as the endpoint. func MsgOptionResponseURL(url string, responseType string) MsgOption { return func(config *sendConfig) error { @@ -498,6 +680,7 @@ func MsgOptionDeleteOriginal(responseURL string) MsgOption { // MsgOptionAsUser whether or not to send the message as the user. func MsgOptionAsUser(b bool) MsgOption { return func(config *sendConfig) error { + //lint:ignore S1002 - we want to explicitly check against the constant if b != DEFAULT_MESSAGE_ASUSER { config.values.Set("as_user", "true") } @@ -542,33 +725,24 @@ func MsgOptionAttachments(attachments ...Attachment) MsgOption { config.attachments = attachments - // FIXME: We are setting the attachments on the message twice: above for - // the json version, and below for the html version. The marshalled bytes - // we put into config.values below don't work directly in the Msg version. - - attachmentBytes, err := json.Marshal(attachments) - if err == nil { - config.values.Set("attachments", string(attachmentBytes)) - } - - return err + return nil } } -// MsgOptionBlocks sets blocks for the message +// MsgOptionBlocks sets blocks for the message. +// Calling with no arguments or an empty slice sends "blocks=[]" to clear blocks. +// To skip setting blocks entirely, do not include this option. func MsgOptionBlocks(blocks ...Block) MsgOption { return func(config *sendConfig) error { - if blocks == nil { - return nil + if len(blocks) == 0 { + // Explicitly set to empty slice (not nil) so the sender + // knows to marshal "[]" and clear blocks on the message. + config.blocks.BlockSet = []Block{} + } else { + config.blocks.BlockSet = append(config.blocks.BlockSet, blocks...) } - config.blocks.BlockSet = append(config.blocks.BlockSet, blocks...) - - blocks, err := json.Marshal(blocks) - if err == nil { - config.values.Set("blocks", string(blocks)) - } - return err + return nil } } @@ -662,6 +836,136 @@ func MsgOptionIconEmoji(iconEmoji string) MsgOption { } } +// MsgOptionMetadata sets message metadata +func MsgOptionMetadata(metadata SlackMetadata) MsgOption { + return func(config *sendConfig) error { + config.metadata = metadata + meta, err := json.Marshal(metadata) + if err == nil { + config.values.Set("metadata", string(meta)) + } + return err + } +} + +// MsgOptionWorkObjectMetadata sets Work Objects metadata for unfurls and messages +// This enables Work Objects support as described in https://docs.slack.dev/messaging/work-objects/ +// If metadata.Entities is nil, it is marshaled as [] so the API receives a valid entities array. +func MsgOptionWorkObjectMetadata(metadata WorkObjectMetadata) MsgOption { + return func(config *sendConfig) error { + metaToMarshal := metadata + if metaToMarshal.Entities == nil { + metaToMarshal.Entities = []WorkObjectEntity{} + } + meta, err := json.Marshal(metaToMarshal) + if err == nil { + config.values.Set("metadata", string(meta)) + } + return err + } +} + +// MsgOptionWorkObjectEntity creates Work Objects metadata with a single entity +// This is a convenience function for the common case of unfurling a single Work Object +func MsgOptionWorkObjectEntity(entity WorkObjectEntity) MsgOption { + return MsgOptionWorkObjectMetadata(WorkObjectMetadata{ + Entities: []WorkObjectEntity{entity}, + }) +} + +// 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 + } +} + +// MsgOptionFileIDs sets file IDs for the message +func MsgOptionFileIDs(fileIDs []string) MsgOption { + return func(config *sendConfig) error { + if len(fileIDs) == 0 { + return nil + } + + fileIDsBytes, err := json.Marshal(fileIDs) + if err != nil { + return err + } + + config.values.Set("file_ids", string(fileIDsBytes)) + return nil + } +} + +// MsgOptionStartStream starts a streaming message. +func MsgOptionStartStream() MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatStartStream) + return nil + } +} + +// MsgOptionAppendStream appends to a streaming message. +func MsgOptionAppendStream(timestamp string) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatAppendStream) + config.values.Add("ts", timestamp) + return nil + } +} + +// MsgOptionStopStream stops a streaming message. +func MsgOptionStopStream(timestamp string) MsgOption { + return func(config *sendConfig) error { + config.endpoint = config.apiurl + string(chatStopStream) + config.values.Add("ts", timestamp) + return nil + } +} + +// MsgOptionRecipientTeamID sets the recipient team ID for streaming messages. +func MsgOptionRecipientTeamID(teamID string) MsgOption { + return func(config *sendConfig) error { + config.values.Set("recipient_team_id", teamID) + return nil + } +} + +// MsgOptionRecipientUserID sets the recipient user ID for streaming messages. +func MsgOptionRecipientUserID(userID string) MsgOption { + return func(config *sendConfig) error { + config.values.Set("recipient_user_id", userID) + return nil + } +} + +// MsgOptionMarkdownText sets the markdown text for streaming messages. +func MsgOptionMarkdownText(text string) MsgOption { + return func(config *sendConfig) error { + config.values.Set("markdown_text", text) + return nil + } +} + +// TaskDisplayMode controls how task_card / task_update chunks render in a +// streamed message. Used with chat.startStream. +type TaskDisplayMode string + +const ( + TaskDisplayModeTimeline TaskDisplayMode = "timeline" + TaskDisplayModePlan TaskDisplayMode = "plan" +) + +// MsgOptionTaskDisplayMode sets task_display_mode on chat.startStream, +// controlling whether tasks render as a sequential timeline or a grouped plan. +func MsgOptionTaskDisplayMode(mode TaskDisplayMode) MsgOption { + return func(config *sendConfig) error { + config.values.Set("task_display_mode", string(mode)) + 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 @@ -696,15 +1000,19 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { config.values.Set("link_names", "1") } + //lint:ignore S1002 - we want to explicitly check against the constant if params.UnfurlLinks != DEFAULT_MESSAGE_UNFURL_LINKS { config.values.Set("unfurl_links", "true") } // I want to send a message with explicit `as_user` `true` and `unfurl_links` `false` in request. // Because setting `as_user` to `true` will change the default value for `unfurl_links` to `true` on Slack API side. + //lint:ignore S1002 - we want to explicitly check against the constants if params.AsUser != DEFAULT_MESSAGE_ASUSER && params.UnfurlLinks == DEFAULT_MESSAGE_UNFURL_LINKS { config.values.Set("unfurl_links", "false") } + + //lint:ignore S1002 - we want to explicitly check against the constant if params.UnfurlMedia != DEFAULT_MESSAGE_UNFURL_MEDIA { config.values.Set("unfurl_media", "false") } @@ -714,6 +1022,7 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { if params.IconEmoji != DEFAULT_MESSAGE_ICON_EMOJI { config.values.Set("icon_emoji", params.IconEmoji) } + //lint:ignore S1002 - we want to explicitly check against the constant if params.Markdown != DEFAULT_MESSAGE_MARKDOWN { config.values.Set("mrkdwn", "false") } @@ -721,30 +1030,40 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { if params.ThreadTimestamp != DEFAULT_MESSAGE_THREAD_TIMESTAMP { config.values.Set("thread_ts", params.ThreadTimestamp) } + //lint:ignore S1002 - we want to explicitly check against the constant if params.ReplyBroadcast != DEFAULT_MESSAGE_REPLY_BROADCAST { config.values.Set("reply_broadcast", "true") } + if params.MetaData.EventType != "" { + if err := MsgOptionMetadata(params.MetaData)(config); err != nil { + return err + } + } + + if len(params.FileIDs) > 0 { + return MsgOptionFileIDs(params.FileIDs)(config) + } + return nil } } -// 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}, @@ -765,18 +1084,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}, @@ -784,6 +1106,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) } @@ -816,12 +1141,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}, @@ -840,3 +1167,57 @@ func (api *Client) DeleteScheduledMessageContext(ctx context.Context, params *De return response.Ok, response.Err() } + +// StartStream starts a streaming message in a channel. +// For more details, see StartStreamContext documentation. +func (api *Client) StartStream(channelID string, options ...MsgOption) (string, string, error) { + return api.StartStreamContext(context.Background(), channelID, options...) +} + +// StartStreamContext starts a streaming message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.startStream +func (api *Client) StartStreamContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, error) { + respChannel, respTimestamp, _, err := api.SendMessageContext( + ctx, + channelID, + MsgOptionStartStream(), + MsgOptionCompose(options...), + ) + return respChannel, respTimestamp, err +} + +// AppendStream appends text to a streaming message. +// For more details, see AppendStreamContext documentation. +func (api *Client) AppendStream(channelID, timestamp string, options ...MsgOption) (string, string, error) { + return api.AppendStreamContext(context.Background(), channelID, timestamp, options...) +} + +// AppendStreamContext appends text to a streaming message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.appendStream +func (api *Client) AppendStreamContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, error) { + respChannel, respTimestamp, _, err := api.SendMessageContext( + ctx, + channelID, + MsgOptionAppendStream(timestamp), + MsgOptionCompose(options...), + ) + return respChannel, respTimestamp, err +} + +// StopStream stops a streaming message. +// For more details, see StopStreamContext documentation. +func (api *Client) StopStream(channelID, timestamp string, options ...MsgOption) (string, string, error) { + return api.StopStreamContext(context.Background(), channelID, timestamp, options...) +} + +// StopStreamContext stops a streaming message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.stopStream +func (api *Client) StopStreamContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, error) { + respChannel, respTimestamp, _, err := api.SendMessageContext( + ctx, + channelID, + MsgOptionStopStream(timestamp), + MsgOptionCompose(options...), + ) + return respChannel, respTimestamp, err +} diff --git a/chat_stream_chunks.go b/chat_stream_chunks.go new file mode 100644 index 000000000..fbdab3be9 --- /dev/null +++ b/chat_stream_chunks.go @@ -0,0 +1,95 @@ +package slack + +import ( + "encoding/json" +) + +// StreamChunkType identifies a chunk in the chat.startStream / chat.appendStream +// / chat.stopStream streaming-message protocol. +// +// More information: https://docs.slack.dev/reference/methods/chat.appendStream/ +type StreamChunkType string + +const ( + StreamChunkMarkdownText StreamChunkType = "markdown_text" + StreamChunkTaskUpdate StreamChunkType = "task_update" + StreamChunkPlanUpdate StreamChunkType = "plan_update" + StreamChunkBlocks StreamChunkType = "blocks" +) + +// StreamChunk represents a single chunk in the streaming-message chunks array. +type StreamChunk interface { + ChunkType() StreamChunkType +} + +// MarkdownTextChunk streams markdown-formatted text. +type MarkdownTextChunk struct { + Type StreamChunkType `json:"type"` + Text string `json:"text"` +} + +func (c MarkdownTextChunk) ChunkType() StreamChunkType { return c.Type } + +// NewMarkdownTextChunk returns a markdown_text chunk. +func NewMarkdownTextChunk(text string) MarkdownTextChunk { + return MarkdownTextChunk{Type: StreamChunkMarkdownText, Text: text} +} + +// TaskUpdateChunk streams a task status update that renders as a task card. +type TaskUpdateChunk struct { + Type StreamChunkType `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + Status TaskCardStatus `json:"status,omitempty"` + Details string `json:"details,omitempty"` + Output string `json:"output,omitempty"` + Sources []TaskCardSource `json:"sources,omitempty"` +} + +func (c TaskUpdateChunk) ChunkType() StreamChunkType { return c.Type } + +// NewTaskUpdateChunk returns a task_update chunk with the given id and title. +func NewTaskUpdateChunk(id, title string) TaskUpdateChunk { + return TaskUpdateChunk{Type: StreamChunkTaskUpdate, ID: id, Title: title} +} + +// PlanUpdateChunk streams an update to the current plan's title. +type PlanUpdateChunk struct { + Type StreamChunkType `json:"type"` + Title string `json:"title"` +} + +func (c PlanUpdateChunk) ChunkType() StreamChunkType { return c.Type } + +// NewPlanUpdateChunk returns a plan_update chunk. +func NewPlanUpdateChunk(title string) PlanUpdateChunk { + return PlanUpdateChunk{Type: StreamChunkPlanUpdate, Title: title} +} + +// BlocksChunk streams a group of Block Kit blocks. Up to 50 blocks per chunk. +type BlocksChunk struct { + Type StreamChunkType `json:"type"` + Blocks []Block `json:"blocks"` +} + +func (c BlocksChunk) ChunkType() StreamChunkType { return c.Type } + +// NewBlocksChunk returns a blocks chunk containing the given blocks. +func NewBlocksChunk(blocks ...Block) BlocksChunk { + return BlocksChunk{Type: StreamChunkBlocks, Blocks: blocks} +} + +// MsgOptionChunks sets the `chunks` parameter for the streaming chat methods +// (chat.startStream / chat.appendStream / chat.stopStream). It is the +// transport for Block Kit agent-UI blocks (Alert, Card, Carousel, etc.) which +// chat.postMessage rejects as "Unsupported block type". +func MsgOptionChunks(chunks ...StreamChunk) MsgOption { + return func(config *sendConfig) error { + encoded, err := json.Marshal(chunks) + if err != nil { + return err + } + config.values.Set("chunks", string(encoded)) + return nil + } +} diff --git a/chat_test.go b/chat_test.go index 40cea908b..d7f936cce 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" ) @@ -34,12 +37,51 @@ func TestPostMessageInvalidChannel(t *testing.T) { } } +func TestPostMessageWithResponse(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/chat.postMessage", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + rw.Write([]byte(`{ + "ok": true, + "channel": "CXXX", + "ts": "1503435956.000247", + "message": { + "text": "hello", + "ts": "1503435956.000247", + "thread_ts": "1503435950.000000" + } + }`)) + }) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + channel, timestamp, message, err := api.PostMessageWithResponse("CXXX", MsgOptionText("hello", false)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if got, want := channel, "CXXX"; got != want { + t.Errorf("unexpected channel: got %s, want %s", got, want) + } + if got, want := timestamp, "1503435956.000247"; got != want { + t.Errorf("unexpected timestamp: got %s, want %s", got, want) + } + if got, want := message.Text, "hello"; got != want { + t.Errorf("unexpected message text: got %s, want %s", got, want) + } + if got, want := message.Timestamp, "1503435956.000247"; got != want { + t.Errorf("unexpected message timestamp: got %s, want %s", got, want) + } + if got, want := message.ThreadTimestamp, "1503435950.000000"; got != want { + t.Errorf("unexpected message thread timestamp: got %s, want %s", got, want) + } +} + func TestGetPermalink(t *testing.T) { channel := "C1H9RESGA" 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) } @@ -79,9 +121,17 @@ func TestPostMessage(t *testing.T) { } blocks := []Block{NewContextBlock("context", NewTextBlockObject(PlainTextType, "hello", false, false))} - blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"hello"}]}]` + blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"hello","emoji":false}]}]` tests := map[string]messageTest{ + "OnlyBasicProperties": { + endpoint: "/chat.postMessage", + opt: []MsgOption{}, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + }, + }, "Blocks": { endpoint: "/chat.postMessage", opt: []MsgOption{ @@ -95,6 +145,32 @@ func TestPostMessage(t *testing.T) { "token": []string{"testing-token"}, }, }, + "EmptyBlocksExplicit": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionBlocks([]Block{}...), + MsgOptionText("text only", false), + }, + expected: url.Values{ + "blocks": []string{"[]"}, + "channel": []string{"CXXX"}, + "text": []string{"text only"}, + "token": []string{"testing-token"}, + }, + }, + "EmptyBlocksNoArgs": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionBlocks(), + MsgOptionText("text only", false), + }, + expected: url.Values{ + "blocks": []string{"[]"}, + "channel": []string{"CXXX"}, + "text": []string{"text only"}, + "token": []string{"testing-token"}, + }, + }, "Attachment": { endpoint: "/chat.postMessage", opt: []MsgOption{ @@ -109,6 +185,24 @@ func TestPostMessage(t *testing.T) { "token": []string{"testing-token"}, }, }, + "Metadata": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionMetadata( + SlackMetadata{ + EventType: "testing-event", + EventPayload: map[string]any{ + "id": 13, + "name": "testing-name", + }, + }), + }, + expected: url.Values{ + "metadata": []string{`{"event_type":"testing-event","event_payload":{"id":13,"name":"testing-name"}}`}, + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + }, + }, "Unfurl": { endpoint: "/chat.unfurl", opt: []MsgOption{ @@ -157,6 +251,104 @@ func TestPostMessage(t *testing.T) { "user_auth_message": []string{"Please!"}, }, }, + "UnfurlAuthBlocks": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlAuthBlocks("123", NewSectionBlock(NewTextBlockObject(MarkdownType, "*Authenticate* to view", false, false), nil, nil)), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"123"}, + "user_auth_blocks": []string{`[{"type":"section","text":{"type":"mrkdwn","text":"*Authenticate* to view"}}]`}, + }, + }, + "UnfurlByID": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlByID("Uxxxxxxx-909b5454-75f8-4ac4-b325-1b40e230bbd8", "composer", map[string]Attachment{"https://example.com": {Text: "Preview"}}), + }, + expected: url.Values{ + "token": []string{"testing-token"}, + "unfurl_id": []string{"Uxxxxxxx-909b5454-75f8-4ac4-b325-1b40e230bbd8"}, + "source": []string{"composer"}, + "unfurls": []string{`{"https://example.com":{"text":"Preview","blocks":null}}`}, + }, + }, + "UnfurlByIDWithNilUnfurls": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlByID("uf-123", "composer", nil), + }, + expected: url.Values{ + "token": []string{"testing-token"}, + "unfurl_id": []string{"uf-123"}, + "source": []string{"composer"}, + "unfurls": []string{`{}`}, + }, + }, + "UnfurlWorkObjectMetadataOnly": { + endpoint: "/chat.unfurl", + opt: []MsgOption{ + MsgOptionUnfurlWorkObject("123", nil, WorkObjectMetadata{ + Entities: []WorkObjectEntity{{ + URL: "https://example.com/doc/1", + ExternalRef: WorkObjectExternalRef{ID: "1"}, + EntityType: EntityTypeFile, + EntityPayload: map[string]any{"title": "Doc"}, + }}, + }), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"123"}, + "metadata": []string{`{"entities":[{"url":"https://example.com/doc/1","external_ref":{"id":"1"},"entity_type":"slack#/entities/file","entity_payload":{"title":"Doc"}}]}`}, + }, + }, + "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"}, + }, + }, + "MetadataViaPostMessageParameters": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionPostMessageParameters(PostMessageParameters{ + MetaData: SlackMetadata{ + EventType: "testing-event", + EventPayload: map[string]any{ + "id": 13, + "name": "testing-name", + }, + }, + }), + }, + expected: url.Values{ + "metadata": []string{`{"event_type":"testing-event","event_payload":{"id":13,"name":"testing-name"}}`}, + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "mrkdwn": []string{"false"}, + "unfurl_media": []string{"false"}, + }, + }, } once.Do(startServer) @@ -166,7 +358,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 @@ -192,7 +384,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 @@ -220,7 +412,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 @@ -247,7 +439,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 @@ -270,3 +462,523 @@ 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) + } + }) + } +} + +func TestUpdateMessage(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + tests := map[string]messageTest{ + "empty file_ids": { + endpoint: "/chat.update", + opt: []MsgOption{}, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + }, + }, + "with file_ids": { + endpoint: "/chat.update", + opt: []MsgOption{ + MsgOptionFileIDs([]string{"F123", "F456"}), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + "file_ids": []string{`["F123","F456"]`}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + 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 := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + }) + + _, _, _, _ = api.UpdateMessage("CXXX", "1234567890.123456", test.opt...) + }) + } +} + +func TestStartStream(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + tests := map[string]messageTest{ + "basic": { + endpoint: "/chat.startStream", + opt: []MsgOption{ + MsgOptionTS("1234567890.123456"), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "thread_ts": []string{"1234567890.123456"}, + }, + }, + "with recipients": { + endpoint: "/chat.startStream", + opt: []MsgOption{ + MsgOptionTS("1234567890.123456"), + MsgOptionRecipientTeamID("T12345"), + MsgOptionRecipientUserID("U12345"), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "thread_ts": []string{"1234567890.123456"}, + "recipient_team_id": []string{"T12345"}, + "recipient_user_id": []string{"U12345"}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + 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 := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(chatResponseFull{ + Channel: "CXXX", + Timestamp: "1234567890.123456", + SlackResponse: SlackResponse{ + Ok: true, + }, + }) + rw.Write(response) + }) + + _, _, _ = api.StartStream("CXXX", test.opt...) + }) + } +} + +func TestWorkObjectMetadata(t *testing.T) { + // Test WorkObjectMetadata marshaling + metadata := WorkObjectMetadata{ + Entities: []WorkObjectEntity{ + { + AppUnfurlURL: "https://example.com/document/123?eid=123456&edit=abcxyz", + URL: "https://example.com/document/123", + ExternalRef: WorkObjectExternalRef{ + ID: "123", + Type: "document", + }, + EntityType: "slack#/entities/file", + EntityPayload: map[string]any{ + "title": "Test Document", + "description": "A test document for Work Objects", + }, + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(metadata) + if err != nil { + t.Errorf("Failed to marshal WorkObjectMetadata: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled WorkObjectMetadata + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Errorf("Failed to unmarshal WorkObjectMetadata: %v", err) + } + + // Verify the data + if len(unmarshaled.Entities) != 1 { + t.Errorf("Expected 1 entity, got %d", len(unmarshaled.Entities)) + } + + entity := unmarshaled.Entities[0] + if entity.URL != "https://example.com/document/123" { + t.Errorf("Expected URL 'https://example.com/document/123', got '%s'", entity.URL) + } + + if entity.ExternalRef.ID != "123" { + t.Errorf("Expected external ref ID '123', got '%s'", entity.ExternalRef.ID) + } + + if entity.EntityType != "slack#/entities/file" { + t.Errorf("Expected entity type 'slack#/entities/file', got '%s'", entity.EntityType) + } +} + +func TestMsgOptionWorkObjectMetadata(t *testing.T) { + metadata := WorkObjectMetadata{ + Entities: []WorkObjectEntity{ + { + URL: "https://example.com/task/456", + ExternalRef: WorkObjectExternalRef{ + ID: "456", + }, + EntityType: "slack#/entities/task", + EntityPayload: map[string]any{ + "title": "Test Task", + "status": "in_progress", + }, + }, + }, + } + + // Create a sendConfig to test the option + config := &sendConfig{ + values: url.Values{}, + } + + // Apply the option + opt := MsgOptionWorkObjectMetadata(metadata) + err := opt(config) + if err != nil { + t.Errorf("MsgOptionWorkObjectMetadata returned error: %v", err) + } + + // Check that metadata was set + metadataValue := config.values.Get("metadata") + if metadataValue == "" { + t.Error("Expected metadata to be set, but it was empty") + } + + // Verify the JSON structure + var result WorkObjectMetadata + err = json.Unmarshal([]byte(metadataValue), &result) + if err != nil { + t.Errorf("Failed to unmarshal metadata JSON: %v", err) + } + + if len(result.Entities) != 1 { + t.Errorf("Expected 1 entity, got %d", len(result.Entities)) + } +} + +func TestMsgOptionWorkObjectMetadataNilEntities(t *testing.T) { + // When Entities is nil, we should marshal as "entities":[] for API compatibility + metadata := WorkObjectMetadata{Entities: nil} + config := &sendConfig{values: url.Values{}} + opt := MsgOptionWorkObjectMetadata(metadata) + if err := opt(config); err != nil { + t.Errorf("MsgOptionWorkObjectMetadata with nil Entities returned error: %v", err) + } + metadataValue := config.values.Get("metadata") + if metadataValue == "" { + t.Error("Expected metadata to be set") + } + if metadataValue != `{"entities":[]}` { + t.Errorf("Expected metadata with empty entities array, got %q", metadataValue) + } +} + +func TestMsgOptionWorkObjectEntity(t *testing.T) { + entity := WorkObjectEntity{ + URL: "https://example.com/incident/789", + ExternalRef: WorkObjectExternalRef{ + ID: "789", + Type: "incident", + }, + EntityType: "slack#/entities/incident", + EntityPayload: map[string]any{ + "title": "Production Outage", + "severity": "high", + }, + } + + // Create a sendConfig to test the option + config := &sendConfig{ + values: url.Values{}, + } + + // Apply the option + opt := MsgOptionWorkObjectEntity(entity) + err := opt(config) + if err != nil { + t.Errorf("MsgOptionWorkObjectEntity returned error: %v", err) + } + + // Check that metadata was set + metadataValue := config.values.Get("metadata") + if metadataValue == "" { + t.Error("Expected metadata to be set, but it was empty") + } + + // Verify the JSON structure + var result WorkObjectMetadata + err = json.Unmarshal([]byte(metadataValue), &result) + if err != nil { + t.Errorf("Failed to unmarshal metadata JSON: %v", err) + } + + if len(result.Entities) != 1 { + t.Errorf("Expected 1 entity, got %d", len(result.Entities)) + } + + resultEntity := result.Entities[0] + if resultEntity.URL != entity.URL { + t.Errorf("Expected URL '%s', got '%s'", entity.URL, resultEntity.URL) + } + + if resultEntity.ExternalRef.ID != entity.ExternalRef.ID { + t.Errorf("Expected external ref ID '%s', got '%s'", entity.ExternalRef.ID, resultEntity.ExternalRef.ID) + } +} + +func TestAppendStream(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + tests := map[string]messageTest{ + "basic": { + endpoint: "/chat.appendStream", + opt: []MsgOption{ + MsgOptionMarkdownText("Hello, world!"), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + "markdown_text": []string{"Hello, world!"}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + 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 := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(chatResponseFull{ + Channel: "CXXX", + Timestamp: "1234567890.123456", + SlackResponse: SlackResponse{ + Ok: true, + }, + }) + rw.Write(response) + }) + + _, _, _ = api.AppendStream("CXXX", "1234567890.123456", test.opt...) + }) + } +} + +func TestStopStream(t *testing.T) { + type messageTest struct { + endpoint string + opt []MsgOption + expected url.Values + } + + blocks := []Block{NewContextBlock("context", NewTextBlockObject(PlainTextType, "feedback", false, false))} + blockStr := `[{"type":"context","block_id":"context","elements":[{"type":"plain_text","text":"feedback","emoji":false}]}]` + + tests := map[string]messageTest{ + "basic": { + endpoint: "/chat.stopStream", + opt: []MsgOption{}, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + }, + }, + "with final text and blocks": { + endpoint: "/chat.stopStream", + opt: []MsgOption{ + MsgOptionMarkdownText("Final message"), + MsgOptionBlocks(blocks...), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "ts": []string{"1234567890.123456"}, + "markdown_text": []string{"Final message"}, + "blocks": []string{blockStr}, + }, + }, + } + + once.Do(startServer) + api := New(validToken, OptionAPIURL("http://"+serverAddr+"/")) + + for name, test := range tests { + 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 := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + actual, err := url.ParseQuery(string(body)) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if !reflect.DeepEqual(actual, test.expected) { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + return + } + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(chatResponseFull{ + Channel: "CXXX", + Timestamp: "1234567890.123456", + SlackResponse: SlackResponse{ + Ok: true, + }, + }) + rw.Write(response) + }) + + _, _, _ = api.StopStream("CXXX", "1234567890.123456", test.opt...) + }) + } +} + +func TestRedactToken(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "bot token", + input: "token=xoxb-1234567890-0987654321-AbCdEfGhIjKlMnOpQrStUvWx&channel=CXXX", + expected: "token=xoxb-REDACTED&channel=CXXX", + }, + { + name: "user token", + input: "channel=CXXX&token=xoxp-1234567890-1234567890123-1234567890123-abcdef", + expected: "channel=CXXX&token=xoxp-REDACTED", + }, + { + name: "app-level token", + input: "token=xapp-1-A012BCDEF-1234567890123-abcdef0123456789", + expected: "token=xapp-REDACTED", + }, + { + name: "rotated token type with dot is preserved", + input: "token=xoxe.xoxp-1-abc123def456&channel=CXXX", + expected: "token=xoxe.xoxp-REDACTED&channel=CXXX", + }, + { + name: "no token is left untouched", + input: "channel=CXXX&text=hello+world", + expected: "channel=CXXX&text=hello+world", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := string(redactToken([]byte(test.input))) + if actual != test.expected { + t.Errorf("\nexpected: %s\n actual: %s", test.expected, actual) + } + }) + } +} diff --git a/conversation.go b/conversation.go index 299362601..a0ce707b0 100644 --- a/conversation.go +++ b/conversation.go @@ -2,9 +2,12 @@ package slack import ( "context" + "encoding/json" + "errors" "net/url" "strconv" "strings" + "time" ) // Conversation is the foundation for IM and BaseGroupConversation @@ -21,17 +24,24 @@ 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"` + IsUserDeleted bool `json:"is_user_deleted"` Unlinked int `json:"unlinked"` NameNormalized string `json:"name_normalized"` NumMembers int `json:"num_members"` Priority float64 `json:"priority"` User string `json:"user"` - - // TODO support pending_shared - // TODO support previous_names + ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` + SharedTeamIDs []string `json:"shared_team_ids,omitempty"` + InternalTeamIDs []string `json:"internal_team_ids,omitempty"` + ContextTeamID string `json:"context_team_id,omitempty"` + ConversationHostID string `json:"conversation_host_id,omitempty"` + PreviousNames []string `json:"previous_names,omitempty"` + PendingShared []string `json:"pending_shared,omitempty"` } // GroupConversation is the foundation for Group and Channel @@ -59,6 +69,39 @@ type Purpose struct { LastSet JSONTime `json:"last_set"` } +// Properties contains additional fields that appear based on the context of the conversation +type Properties struct { + Canvas Canvas `json:"canvas"` + PostingRestrictedTo RestrictedTo `json:"posting_restricted_to"` + Tabs []Tab `json:"tabs"` + ThreadsRestrictedTo RestrictedTo `json:"threads_restricted_to"` + RecordChannel RecordChannel `json:"record_channel"` +} + +type RestrictedTo struct { + Type []string `json:"type"` + User []string `json:"user"` +} + +type Tab struct { + ID string `json:"id"` + Label string `json:"label"` + Type string `json:"type"` +} + +type Canvas struct { + FileId string `json:"file_id"` + IsEmpty bool `json:"is_empty"` + QuipThreadId string `json:"quip_thread_id"` +} + +type RecordChannel struct { + RecordID string `json:"record_id"` + RecordType string `json:"record_type"` + RecordLabel string `json:"record_label"` + RecordLabelPlural string `json:"record_label_plural"` +} + type GetUsersInConversationParameters struct { ChannelID string Cursor string @@ -71,18 +114,21 @@ type GetConversationsForUserParameters struct { Types []string Limit int ExcludeArchived bool + TeamID string } 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}, @@ -112,12 +158,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}, @@ -137,6 +185,10 @@ func (api *Client) GetConversationsForUserContext(ctx context.Context, params *G if params.ExcludeArchived { values.Add("exclude_archived", "true") } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } + response := struct { Channels []Channel `json:"channels"` ResponseMetaData responseMetaData `json:"response_metadata"` @@ -150,12 +202,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}, @@ -171,12 +225,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}, @@ -191,12 +247,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}, @@ -215,12 +273,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}, @@ -240,12 +300,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}, @@ -265,12 +327,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}, @@ -290,12 +354,135 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } -// KickUserFromConversation removes a user from a conversation +/********************************************************************************** +The following functions are for inviting users to a channel but setting the `force` +parameter to true. We have added this so that we don't break the existing API. + +IMPORTANT: If we ever get here for _another_ parameter, we should consider refactoring +this to be more flexible. +*/ + +// ForceInviteUsersToConversation invites users to a channel but sets the `force` +// parameter to true. +// +// For more details, see ForceInviteUsersToConversationContext documentation. +func (api *Client) ForceInviteUsersToConversation(channelID string, users ...string) (*Channel, error) { + return api.ForceInviteUsersToConversationContext(context.Background(), channelID, users...) +} + +// ForceInviteUsersToConversationContext invites users to a channel with a custom context +// while setting the `force` argument to true. +// +// Slack API docs: https://api.slack.com/methods/conversations.invite +func (api *Client) ForceInviteUsersToConversationContext(ctx context.Context, channelID string, users ...string) (*Channel, error) { + values := url.Values{ + "token": {api.token}, + "channel": {channelID}, + "users": {strings.Join(users, ",")}, + "force": {"true"}, + } + response := struct { + SlackResponse + Channel *Channel `json:"channel"` + }{} + + err := api.postMethod(ctx, "conversations.invite", values, &response) + if err != nil { + return nil, err + } + + return response.Channel, response.Err() +} + +// 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}, @@ -303,7 +490,7 @@ func (api *Client) KickUserFromConversationContext(ctx context.Context, channelI "user": {user}, } - response := SlackResponse{} + response := KickUserFromConversationSlackResponse{} err := api.postMethod(ctx, "conversations.kick", values, &response) if err != nil { return err @@ -312,12 +499,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}, @@ -337,17 +526,28 @@ func (api *Client) CloseConversationContext(ctx context.Context, channelID strin return response.NoOp, response.AlreadyClosed, response.Err() } -// CreateConversation initiates a public or private channel-based conversation -func (api *Client) CreateConversation(channelName string, isPrivate bool) (*Channel, error) { - return api.CreateConversationContext(context.Background(), channelName, isPrivate) +type CreateConversationParams struct { + ChannelName string + IsPrivate bool + TeamID string } -// CreateConversationContext initiates a public or private channel-based conversation with a custom context -func (api *Client) CreateConversationContext(ctx context.Context, channelName string, isPrivate bool) (*Channel, error) { +// 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. +// 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}, - "name": {channelName}, - "is_private": {strconv.FormatBool(isPrivate)}, + "name": {params.ChannelName}, + "is_private": {strconv.FormatBool(params.IsPrivate)}, + } + if params.TeamID != "" { + values.Set("team_id", params.TeamID) } response, err := api.channelRequest(ctx, "conversations.create", values) if err != nil { @@ -357,17 +557,35 @@ func (api *Client) CreateConversationContext(ctx context.Context, channelName st return &response.Channel, nil } -// GetConversationInfo retrieves information about a conversation -func (api *Client) GetConversationInfo(channelID string, includeLocale bool) (*Channel, error) { - return api.GetConversationInfoContext(context.Background(), channelID, includeLocale) +// GetConversationInfoInput Defines the parameters of a GetConversationInfo and GetConversationInfoContext function +type GetConversationInfoInput struct { + ChannelID string + IncludeLocale bool + IncludeNumMembers bool +} + +// 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 -func (api *Client) GetConversationInfoContext(ctx context.Context, channelID string, includeLocale bool) (*Channel, error) { +// 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") + } + + if input.ChannelID == "" { + return nil, errors.New("ChannelID must be defined") + } + values := url.Values{ - "token": {api.token}, - "channel": {channelID}, - "include_locale": {strconv.FormatBool(includeLocale)}, + "token": {api.token}, + "channel": {input.ChannelID}, + "include_locale": {strconv.FormatBool(input.IncludeLocale)}, + "include_num_members": {strconv.FormatBool(input.IncludeNumMembers)}, } response, err := api.channelRequest(ctx, "conversations.info", values) if err != nil { @@ -377,12 +595,14 @@ func (api *Client) GetConversationInfoContext(ctx context.Context, channelID str 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}, @@ -398,21 +618,24 @@ func (api *Client) LeaveConversationContext(ctx context.Context, channelID strin } type GetConversationRepliesParameters struct { - ChannelID string - Timestamp string - Cursor string - Inclusive bool - Latest string - Limit int - Oldest string + ChannelID string + Timestamp string + Cursor string + Inclusive bool + Latest string + Limit int + Oldest string + 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}, @@ -436,6 +659,11 @@ func (api *Client) GetConversationRepliesContext(ctx context.Context, params *Ge } else { values.Add("inclusive", "0") } + if params.IncludeAllMetadata { + values.Add("include_all_metadata", "1") + } else { + values.Add("include_all_metadata", "0") + } response := struct { SlackResponse HasMore bool `json:"has_more"` @@ -461,12 +689,158 @@ type GetConversationsParameters struct { TeamID string } -// GetConversations returns the list of channels in a Slack team +// GetConversationsOption options for the GetAllConversationsContext method call. +type GetConversationsOption func(*ConversationPagination) + +// GetConversationsOptionLimit limit the number of conversations returned +func GetConversationsOptionLimit(n int) GetConversationsOption { + return func(p *ConversationPagination) { + p.limit = n + } +} + +// GetConversationsOptionExcludeArchived exclude archived conversations +func GetConversationsOptionExcludeArchived(exclude bool) GetConversationsOption { + return func(p *ConversationPagination) { + p.excludeArchived = exclude + } +} + +// GetConversationsOptionTypes filter conversations by type +func GetConversationsOptionTypes(types []string) GetConversationsOption { + return func(p *ConversationPagination) { + p.types = types + } +} + +// GetConversationsOptionTeamID include team Id +func GetConversationsOptionTeamID(teamId string) GetConversationsOption { + return func(p *ConversationPagination) { + p.teamId = teamId + } +} + +func newConversationPagination(c *Client, options ...GetConversationsOption) (cp ConversationPagination) { + cp = ConversationPagination{ + c: c, + limit: 200, // per slack api documentation. + } + + for _, opt := range options { + opt(&cp) + } + + return cp +} + +// ConversationPagination allows for paginating over the conversations +type ConversationPagination struct { + Conversations []Channel + limit int + excludeArchived bool + types []string + teamId string + previousResp *ResponseMetadata + c *Client +} + +// Done checks if the pagination has completed +func (ConversationPagination) Done(err error) bool { + return errors.Is(err, errPaginationComplete) +} + +// Failure checks if pagination failed. +func (t ConversationPagination) Failure(err error) error { + if t.Done(err) { + return nil + } + + return err +} + +func (t ConversationPagination) Next(ctx context.Context) (_ ConversationPagination, err error) { + if t.c == nil || (t.previousResp != nil && t.previousResp.Cursor == "") { + return t, errPaginationComplete + } + + t.previousResp = t.previousResp.initialize() + + values := url.Values{ + "token": {t.c.token}, + "limit": {strconv.Itoa(t.limit)}, + "cursor": {t.previousResp.Cursor}, + } + if t.excludeArchived { + values.Add("exclude_archived", strconv.FormatBool(t.excludeArchived)) + } + if t.types != nil { + values.Add("types", strings.Join(t.types, ",")) + } + if t.teamId != "" { + values.Add("team_id", t.teamId) + } + + response := struct { + Channels []Channel `json:"channels"` + ResponseMetaData responseMetaData `json:"response_metadata"` + SlackResponse + }{} + + err = t.c.postMethod(ctx, "conversations.list", values, &response) + if err != nil { + return t, err + } + + if err := response.Err(); err != nil { + return t, err + } + + t.c.Debugf("GetAllConversationsContext: got %d conversations; cursor %s", len(response.Channels), response.ResponseMetaData.NextCursor) + t.Conversations = response.Channels + t.previousResp = &ResponseMetadata{Cursor: response.ResponseMetaData.NextCursor} + + return t, nil +} + +// GetConversationsPaginated fetches conversations in a paginated fashion, see GetAllConversationsContext for usage. +func (api *Client) GetConversationsPaginated(options ...GetConversationsOption) ConversationPagination { + return newConversationPagination(api, options...) +} + +// GetAllConversations returns the list of all conversations, handling pagination and rate limiting +func (api *Client) GetAllConversations(options ...GetConversationsOption) (results []Channel, err error) { + return api.GetAllConversationsContext(context.Background(), options...) +} + +// GetAllConversationsContext returns the list of all conversations with a custom context, handling pagination and rate limiting +func (api *Client) GetAllConversationsContext(ctx context.Context, options ...GetConversationsOption) (results []Channel, err error) { + results = []Channel{} + p := api.GetConversationsPaginated(options...) + for err == nil { + p, err = p.Next(ctx) + if err == nil { + results = append(results, p.Conversations...) + } else if rateLimitedError, ok := err.(*RateLimitedError); ok { + select { + case <-ctx.Done(): + err = ctx.Err() + case <-time.After(rateLimitedError.RetryAfter): + err = nil + } + } + } + + return results, p.Failure(err) +} + +// 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}, @@ -507,12 +881,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}, @@ -539,12 +915,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 { @@ -571,12 +949,13 @@ func (api *Client) JoinConversationContext(ctx context.Context, channelID string } type GetConversationHistoryParameters struct { - ChannelID string - Cursor string - Inclusive bool - Latest string - Limit int - Oldest string + ChannelID string + Cursor string + Inclusive bool + Latest string + Limit int + Oldest string + IncludeAllMetadata bool } type GetConversationHistoryResponse struct { @@ -590,12 +969,14 @@ type GetConversationHistoryResponse struct { Messages []Message `json:"messages"` } -// GetConversationHistory joins an existing conversation +// GetConversationHistory retrieves the message history from the specified 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 retrieves the message history from the specified 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 != "" { @@ -615,6 +996,11 @@ func (api *Client) GetConversationHistoryContext(ctx context.Context, params *Ge if params.Oldest != "" { values.Add("oldest", params.Oldest) } + if params.IncludeAllMetadata { + values.Add("include_all_metadata", "1") + } else { + values.Add("include_all_metadata", "0") + } response := GetConversationHistoryResponse{} @@ -626,12 +1012,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}, @@ -647,3 +1035,70 @@ func (api *Client) MarkConversationContext(ctx context.Context, channel, ts stri } return response.Err() } + +// createChannelCanvasParams contains arguments for CreateChannelCanvas method call. +type createChannelCanvasParams struct { + title string + documentContent *DocumentContent +} + +// CreateChannelCanvasOption options for the CreateChannelCanvas method call. +type CreateChannelCanvasOption func(*createChannelCanvasParams) + +// CreateChannelCanvasOptionTitle sets the title of the canvas. +func CreateChannelCanvasOptionTitle(title string) CreateChannelCanvasOption { + return func(params *createChannelCanvasParams) { + params.title = title + } +} + +// CreateChannelCanvasOptionDocumentContent sets the document content of the canvas. +func CreateChannelCanvasOptionDocumentContent(documentContent DocumentContent) CreateChannelCanvasOption { + return func(params *createChannelCanvasParams) { + params.documentContent = &documentContent + } +} + +// CreateChannelCanvas creates a new canvas in a channel. +// For more details, see CreateChannelCanvasContext documentation. +func (api *Client) CreateChannelCanvas(channel string, documentContent DocumentContent, options ...CreateChannelCanvasOption) (string, error) { + return api.CreateChannelCanvasContext(context.Background(), channel, documentContent, options...) +} + +// 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, options ...CreateChannelCanvasOption) (string, error) { + params := createChannelCanvasParams{ + documentContent: &documentContent, + } + + for _, opt := range options { + opt(¶ms) + } + + values := url.Values{ + "token": {api.token}, + "channel_id": {channel}, + } + if params.title != "" { + values.Add("title", params.title) + } + if params.documentContent != nil && params.documentContent.Type != "" { + documentContentJSON, err := json.Marshal(params.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 61a4e92b1..1c7a83e93 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -1,10 +1,15 @@ package slack import ( + "context" "encoding/json" + "fmt" "net/http" "reflect" + "strconv" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -82,6 +87,98 @@ func TestCreateSimpleChannel(t *testing.T) { assertSimpleChannel(t, channel) } +// Shared Channel +var sharedChannel = `{ + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "created": 1360782804, + "creator": "U024BE7LH", + "is_archived": false, + "is_general": false, + "members": [ + "U024BE7LH" + ], + "is_shared": true, + "context_team_id": "T1ABCD2E12", + "is_ext_shared": true, + "shared_team_ids": [ + "T07XY8FPJ5C" + ], + "internal_team_ids": [], + "connected_team_ids": [ + "T07XY8FPJ5C", + "T1ABCD2E12" + ], + "connected_limited_team_ids": [], + "pending_connected_team_ids": [], + "conversation_host_id": "T07XY8FPJ5C", + "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 +}` + +func unmarshalSharedChannel(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestSharedChannel(t *testing.T) { + channel, err := unmarshalSharedChannel(sharedChannel) + assert.Nil(t, err) + assertSharedChannel(t, channel) +} + +func assertSharedChannel(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, true, channel.IsShared) + assert.Equal(t, true, channel.IsExtShared) + assert.Equal(t, "T1ABCD2E12", channel.ContextTeamID) + assert.Equal(t, "T07XY8FPJ5C", channel.ConversationHostID) + if !reflect.DeepEqual([]string{"T07XY8FPJ5C"}, channel.SharedTeamIDs) { + t.Fatal(ErrIncorrectResponse) + } + if !reflect.DeepEqual([]string{"T07XY8FPJ5C", "T1ABCD2E12"}, channel.ConnectedTeamIDs) { + t.Fatal(ErrIncorrectResponse) + } +} + +func TestCreateSharedChannel(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.IsShared = true + channel.IsExtShared = true + channel.ContextTeamID = "T1ABCD2E12" + channel.ConversationHostID = "T07XY8FPJ5C" + channel.SharedTeamIDs = []string{"T07XY8FPJ5C"} + channel.ConnectedTeamIDs = []string{"T07XY8FPJ5C", "T1ABCD2E12"} + assertSharedChannel(t, channel) +} + // Group var simpleGroup = `{ "id": "G024BE91L", @@ -149,6 +246,167 @@ 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) +} + +// Channel with RecordChannel +var channelWithRecordChannel = `{ + "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": { + "record_channel": { + "record_id": "S:00D0000000000000EAU:0010000000000000AA2", + "record_type": "Account", + "record_label": "Account", + "record_label_plural": "Accounts" + } + } +}` + +func unmarshalChannelWithRecordChannel(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestChannelWithRecordChannel(t *testing.T) { + channel, err := unmarshalChannelWithRecordChannel(channelWithRecordChannel) + assert.Nil(t, err) + assertChannelWithRecordChannel(t, channel) +} + +func assertChannelWithRecordChannel(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, "S:00D0000000000000EAU:0010000000000000AA2", channel.Properties.RecordChannel.RecordID) + assert.Equal(t, "Account", channel.Properties.RecordChannel.RecordType) + assert.Equal(t, "Account", channel.Properties.RecordChannel.RecordLabel) + assert.Equal(t, "Accounts", channel.Properties.RecordChannel.RecordLabelPlural) +} + +func TestCreateChannelWithRecordChannel(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{ + RecordChannel: RecordChannel{ + RecordID: "S:00D0000000000000EAU:0010000000000000AA2", + RecordType: "Account", + RecordLabel: "Account", + RecordLabelPlural: "Accounts", + }, + } + assertChannelWithRecordChannel(t, channel) +} + // IM var simpleIM = `{ "id": "D024BFF1M", @@ -162,8 +420,8 @@ var simpleIM = `{ "unread_count_display": 0 }` -func unmarshalIM(j string) (*IM, error) { - im := &IM{} +func unmarshalIM(j string) (*Conversation, error) { + im := &Conversation{} if err := json.Unmarshal([]byte(j), &im); err != nil { return nil, err } @@ -176,7 +434,7 @@ func TestSimpleIM(t *testing.T) { assertSimpleIM(t, im) } -func assertSimpleIM(t *testing.T, im *IM) { +func assertSimpleIM(t *testing.T, im *Conversation) { assert.NotNil(t, im) assert.Equal(t, "D024BFF1M", im.ID) assert.Equal(t, true, im.IsIM) @@ -190,7 +448,7 @@ func assertSimpleIM(t *testing.T, im *IM) { } func TestCreateSimpleIM(t *testing.T) { - im := &IM{} + im := &Conversation{} im.ID = "D024BFF1M" im.IsIM = true im.User = "U024BE7LH" @@ -287,6 +545,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) @@ -348,6 +620,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) @@ -385,7 +716,7 @@ func TestCreateConversation(t *testing.T) { http.HandleFunc("/conversations.create", okChannelJsonHandler) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - channel, err := api.CreateConversation("CXXXXXXXX", false) + channel, err := api.CreateConversation(CreateConversationParams{ChannelName: "CXXXXXXXX"}) if err != nil { t.Errorf("Unexpected error: %s", err) return @@ -400,7 +731,9 @@ func TestGetConversationInfo(t *testing.T) { http.HandleFunc("/conversations.info", okChannelJsonHandler) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - channel, err := api.GetConversationInfo("CXXXXXXXX", false) + channel, err := api.GetConversationInfo(&GetConversationInfoInput{ + ChannelID: "CXXXXXXXX", + }) if err != nil { t.Errorf("Unexpected error: %s", err) return @@ -409,6 +742,22 @@ func TestGetConversationInfo(t *testing.T) { t.Error("channel should not be nil") return } + + // Nil Input Error + api = New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err = api.GetConversationInfo(nil) + if err == nil { + t.Errorf("Unexpected pass where there should have been nil input error") + return + } + + // No Channel Error + api = New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + _, err = api.GetConversationInfo(&GetConversationInfoInput{}) + if err == nil { + t.Errorf("Unexpected pass where there should have been missing channel error") + return + } } func leaveConversationHandler(rw http.ResponseWriter, r *http.Request) { @@ -472,7 +821,29 @@ func getConversationsHandler(rw http.ResponseWriter, r *http.Request) { Channels []Channel `json:"channels"` }{ SlackResponse: SlackResponse{Ok: true}, - Channels: []Channel{}}) + Channels: []Channel{ + { + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: "CXXXXXXXX", + }, + }, + }, + { + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: "CYYYYYYYY", + }, + }, + }, + { + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: "CZZZZZZZZ", + }, + }, + }, + }}) rw.Write(response) } @@ -486,6 +857,16 @@ func TestGetConversations(t *testing.T) { t.Errorf("Unexpected error: %s", err) return } + + conversations, err := api.GetAllConversationsContext(context.Background()) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if len(conversations) != 3 { + t.Errorf("Expected 3 conversations, got %d", len(conversations)) + return + } } func openConversationHandler(rw http.ResponseWriter, r *http.Request) { @@ -573,3 +954,183 @@ 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) +} + +func TestCreateChannelCanvasWithTitle(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "> channel canvas with title!", + } + + canvasID, err := api.CreateChannelCanvas( + "C1234567890", + documentContent, + CreateChannelCanvasOptionTitle("Test Canvas Title"), + ) + if err != nil { + t.Errorf("Failed to create channel canvas with title: %v", err) + return + } + + assert.Equal(t, "F05RQ01LJU0", canvasID) +} + +func getTestChannelWithId(id string) Channel { + return Channel{ + GroupConversation: GroupConversation{ + Conversation: Conversation{ + ID: id, + }, + Name: "Test Channel", + Topic: Topic{ + Value: "Test topic", + }, + Purpose: Purpose{ + Value: "Test purpose", + }, + }, + IsChannel: true, + IsGeneral: false, + IsMember: true, + } +} + +// returns n pages of conversations and sends rate limited errors in between successful pages. +func getConversationPagesWithRateLimitErrors(max int64) func(rw http.ResponseWriter, r *http.Request) { + var n int64 + doRateLimit := false + return func(rw http.ResponseWriter, r *http.Request) { + defer func() { + doRateLimit = !doRateLimit + }() + if doRateLimit { + rw.Header().Set("Retry-After", "1") + rw.WriteHeader(http.StatusTooManyRequests) + return + } + var cpage int64 + sresp := SlackResponse{ + Ok: true, + } + channels := []Channel{ + getTestChannelWithId(fmt.Sprintf("C%03d", n)), + } + rw.Header().Set("Content-Type", "application/json") + if cpage = atomic.AddInt64(&n, 1); cpage == max { + response, _ := json.Marshal(struct { + SlackResponse + Channels []Channel `json:"channels"` + }{ + SlackResponse: sresp, + Channels: channels, + }) + rw.Write(response) + return + } + response, _ := json.Marshal(struct { + SlackResponse + Channels []Channel `json:"channels"` + ResponseMetaData responseMetaData `json:"response_metadata"` + }{ + SlackResponse: sresp, + Channels: channels, + ResponseMetaData: responseMetaData{ + NextCursor: strconv.Itoa(int(cpage)), + }, + }) + rw.Write(response) + } +} + +func TestGetAllConversationsHandlesRateLimit(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/conversations.list", getConversationPagesWithRateLimitErrors(3)) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + start := time.Now() + conversations, err := api.GetAllConversations() + elapsed := time.Since(start) + + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + + // Should have 3 conversations (one per page) + if len(conversations) != 3 { + t.Errorf("Expected 3 conversations, got %d", len(conversations)) + return + } + + // Should have taken at least 2 seconds due to rate limiting (2 rate limit delays) + if elapsed < 2*time.Second { + t.Errorf("Expected at least 2 seconds due to rate limiting, took %v", elapsed) + return + } + + // Verify conversation IDs + expectedIDs := []string{"C000", "C001", "C002"} + for i, conv := range conversations { + if conv.ID != expectedIDs[i] { + t.Errorf("Expected conversation ID %s, got %s", expectedIDs[i], conv.ID) + } + } +} + +func TestGetAllConversationsReturnsServerError(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/conversations.list", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + _, err := api.GetAllConversations() + + if err == nil { + t.Errorf("Expected error but got nil") + return + } + + expectedErr := "slack server error: 500 Internal Server Error" + if err.Error() != expectedErr { + t.Errorf("Expected: %s. Got: %s", expectedErr, err.Error()) + } +} diff --git a/dialog.go b/dialog.go index f94113f4d..35d4fdfe9 100644 --- a/dialog.go +++ b/dialog.go @@ -30,8 +30,8 @@ type DialogInput struct { // DialogTrigger ... type DialogTrigger struct { - TriggerID string `json:"trigger_id"` //Required. Must respond within 3 seconds. - Dialog Dialog `json:"dialog"` //Required. + TriggerID string `json:"trigger_id"` // Required. Must respond within 3 seconds. + Dialog Dialog `json:"dialog"` // Required. } // Dialog as in Slack dialogs @@ -47,9 +47,11 @@ type Dialog struct { } // DialogElement abstract type for dialogs. -type DialogElement interface{} +type DialogElement any -// DialogCallback DEPRECATED use InteractionCallback +// DialogCallback +// +// Deprecated: use InteractionCallback type DialogCallback InteractionCallback // DialogSubmissionCallback is sent from Slack when a user submits a form from within a dialog @@ -106,8 +108,7 @@ func (api *Client) OpenDialogContext(ctx context.Context, triggerID string, dial } response := &DialogOpenResponse{} - endpoint := api.endpoint + "dialog.open" - if err := postJSON(ctx, api.httpclient, endpoint, api.token, encoded, response, api); err != nil { + if err := api.postJSONMethod(ctx, "dialog.open", api.token, encoded, response); err != nil { return err } diff --git a/dialog_select.go b/dialog_select.go index 3d6be989e..95bf9ebcc 100644 --- a/dialog_select.go +++ b/dialog_select.go @@ -19,13 +19,13 @@ const ( // DialogInputSelect dialog support for select boxes. type DialogInputSelect struct { DialogInput - Value string `json:"value,omitempty"` //Optional. - DataSource SelectDataSource `json:"data_source,omitempty"` //Optional. Allowed values: "users", "channels", "conversations", "external". - SelectedOptions []DialogSelectOption `json:"selected_options,omitempty"` //Optional. May hold at most one element, for use with "external" only. - Options []DialogSelectOption `json:"options,omitempty"` //One of options or option_groups is required. - OptionGroups []DialogOptionGroup `json:"option_groups,omitempty"` //Provide up to 100 options. - MinQueryLength int `json:"min_query_length,omitempty"` //Optional. minimum characters before query is sent. - Hint string `json:"hint,omitempty"` //Optional. Additional hint text. + Value string `json:"value,omitempty"` // Optional. + DataSource SelectDataSource `json:"data_source,omitempty"` // Optional. Allowed values: "users", "channels", "conversations", "external". + SelectedOptions []DialogSelectOption `json:"selected_options,omitempty"` // Optional. May hold at most one element, for use with "external" only. + Options []DialogSelectOption `json:"options,omitempty"` // One of options or option_groups is required. + OptionGroups []DialogOptionGroup `json:"option_groups,omitempty"` // Provide up to 100 options. + MinQueryLength int `json:"min_query_length,omitempty"` // Optional. minimum characters before query is sent. + Hint string `json:"hint,omitempty"` // Optional. Additional hint text. } // DialogSelectOption is an option for the user to select from the menu diff --git a/dialog_text.go b/dialog_text.go index da06bd6de..25fa1b693 100644 --- a/dialog_text.go +++ b/dialog_text.go @@ -18,7 +18,7 @@ const ( ) // TextInputElement subtype of DialogInput -// https://api.slack.com/dialogs#option_element_attributes#text_element_attributes +// https://api.slack.com/dialogs#option_element_attributes#text_element_attributes type TextInputElement struct { DialogInput MaxLength int `json:"max_length,omitempty"` diff --git a/dnd.go b/dnd.go index a3aa680cd..4f6b35a58 100644 --- a/dnd.go +++ b/dnd.go @@ -7,6 +7,14 @@ import ( "strings" ) +// DNDOptionTeamID sets the team_id parameter for DND methods. Required after +// workspace migration when the API returns missing_argument: team_id. +func DNDOptionTeamID(teamID string) ParamOption { + return func(v *url.Values) { + v.Set("team_id", teamID) + } +} + type SnoozeDebug struct { SnoozeEndDate string `json:"snooze_end_date"` } @@ -45,12 +53,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://docs.slack.dev/reference/methods/dnd.endDnd func (api *Client) EndDNDContext(ctx context.Context) error { values := url.Values{ "token": {api.token}, @@ -65,12 +75,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://docs.slack.dev/reference/methods/dnd.endSnooze func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -84,18 +96,23 @@ func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { } // GetDNDInfo provides information about a user's current Do Not Disturb settings. -func (api *Client) GetDNDInfo(user *string) (*DNDStatus, error) { - return api.GetDNDInfoContext(context.Background(), user) +// For more information see the GetDNDInfoContext documentation. +func (api *Client) GetDNDInfo(user *string, options ...ParamOption) (*DNDStatus, error) { + return api.GetDNDInfoContext(context.Background(), user, options...) } // GetDNDInfoContext provides information about a user's current Do Not Disturb settings with a custom context. -func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDStatus, error) { +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.info/ +func (api *Client) GetDNDInfoContext(ctx context.Context, user *string, options ...ParamOption) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, } if user != nil { values.Set("user", *user) } + for _, opt := range options { + opt(&values) + } response, err := api.dndRequest(ctx, "dnd.info", values) if err != nil { @@ -105,16 +122,21 @@ func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDSta } // GetDNDTeamInfo provides information about a user's current Do Not Disturb settings. -func (api *Client) GetDNDTeamInfo(users []string) (map[string]DNDStatus, error) { - return api.GetDNDTeamInfoContext(context.Background(), users) +// For more information see the GetDNDTeamInfoContext documentation. +func (api *Client) GetDNDTeamInfo(users []string, options ...ParamOption) (map[string]DNDStatus, error) { + return api.GetDNDTeamInfoContext(context.Background(), users, options...) } // GetDNDTeamInfoContext provides information about a user's current Do Not Disturb settings with a custom context. -func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (map[string]DNDStatus, error) { +// Slack API docs: https://docs.slack.dev/reference/methods/dnd.teamInfo +func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string, options ...ParamOption) (map[string]DNDStatus, error) { values := url.Values{ "token": {api.token}, "users": {strings.Join(users, ",")}, } + for _, opt := range options { + opt(&values) + } response := &dndTeamInfoResponse{} if err := api.postMethod(ctx, "dnd.teamInfo", values, response); err != nil { @@ -128,15 +150,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://docs.slack.dev/reference/methods/dnd.setSnooze func (api *Client) SetSnoozeContext(ctx context.Context, minutes int) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, diff --git a/dnd_test.go b/dnd_test.go index 81f20b95c..264f641b8 100644 --- a/dnd_test.go +++ b/dnd_test.go @@ -2,6 +2,7 @@ package slack import ( "net/http" + "net/http/httptest" "reflect" "testing" ) @@ -124,6 +125,55 @@ func TestSlack_GetDNDTeamInfo(t *testing.T) { } } +func TestSlack_GetDNDInfoWithTeamID(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.FormValue("team_id") != "T12345" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":false,"error":"missing_argument"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"dnd_enabled":true,"next_dnd_start_ts":1450416600,"next_dnd_end_ts":1450452600}`)) + })) + defer ts.Close() + + api := New("testing-token", OptionAPIURL(ts.URL+"/")) + _, err := api.GetDNDInfo(nil, DNDOptionTeamID("T12345")) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func TestSlack_GetDNDTeamInfoWithTeamID(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.FormValue("team_id") != "T12345" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":false,"error":"missing_argument"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"users":{"U023BECGF":{"dnd_enabled":true,"next_dnd_start_ts":1450387800,"next_dnd_end_ts":1450423800}}}`)) + })) + defer ts.Close() + + api := New("testing-token", OptionAPIURL(ts.URL+"/")) + result, err := api.GetDNDTeamInfo(nil, DNDOptionTeamID("T12345")) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + if _, ok := result["U023BECGF"]; !ok { + t.Fatal("expected U023BECGF in result") + } +} + func TestSlack_SetSnooze(t *testing.T) { http.HandleFunc("/dnd.setSnooze", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") 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/entity.go b/entity.go new file mode 100644 index 000000000..10bfcba33 --- /dev/null +++ b/entity.go @@ -0,0 +1,132 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// EntityPresentDetailsParameters contains the parameters for entity.presentDetails API method +type EntityPresentDetailsParameters struct { + TriggerID string `json:"trigger_id"` + Metadata *EntityDetailsMetadata `json:"metadata,omitempty"` + Error *EntityDetailsError `json:"error,omitempty"` + UserAuthRequired bool `json:"user_auth_required,omitempty"` + UserAuthURL string `json:"user_auth_url,omitempty"` + UserAuthMessage string `json:"user_auth_message,omitempty"` +} + +// EntityDetailsMetadata represents the metadata for entity details +type EntityDetailsMetadata struct { + EntityType string `json:"entity_type"` + URL string `json:"url,omitempty"` + ExternalRef WorkObjectExternalRef `json:"external_ref,omitempty"` + EntityPayload map[string]any `json:"entity_payload"` +} + +// EntityDetailsError represents an error response for entity details +type EntityDetailsError struct { + Status string `json:"status"` + CustomTitle string `json:"custom_title,omitempty"` + CustomMessage string `json:"custom_message,omitempty"` + MessageFormat string `json:"message_format,omitempty"` + Actions []EntityDetailsAction `json:"actions,omitempty"` +} + +// EntityDetailsAction represents an action button in entity details error +type EntityDetailsAction struct { + Text string `json:"text"` + ActionID string `json:"action_id"` + Value string `json:"value,omitempty"` + Style string `json:"style,omitempty"` + URL string `json:"url,omitempty"` + ProcessingState *EntityDetailsProcessingState `json:"processing_state,omitempty"` +} + +// EntityDetailsProcessingState represents the processing state of an action +type EntityDetailsProcessingState struct { + Enabled bool `json:"enabled"` +} + +// EntityPresentDetailsResponse represents the response from entity.presentDetails +type EntityPresentDetailsResponse struct { + SlackResponse +} + +// EntityPresentDetails presents entity details in the flexpane +// For more details, see EntityPresentDetailsContext documentation. +func (api *Client) EntityPresentDetails(params EntityPresentDetailsParameters) error { + return api.EntityPresentDetailsContext(context.Background(), params) +} + +// EntityPresentDetailsContext presents entity details in the flexpane with a custom context. +// Slack API docs: https://docs.slack.dev/reference/methods/entity.presentDetails +func (api *Client) EntityPresentDetailsContext(ctx context.Context, params EntityPresentDetailsParameters) error { + values := url.Values{ + "token": {api.token}, + "trigger_id": {params.TriggerID}, + } + + // Add metadata if provided + if params.Metadata != nil { + metadataJSON, err := json.Marshal(params.Metadata) + if err != nil { + return err + } + values.Set("metadata", string(metadataJSON)) + } + + // Add error if provided + if params.Error != nil { + errorJSON, err := json.Marshal(params.Error) + if err != nil { + return err + } + values.Set("error", string(errorJSON)) + } + + // Add user auth parameters if provided + if params.UserAuthRequired { + values.Set("user_auth_required", "true") + } + if params.UserAuthURL != "" { + values.Set("user_auth_url", params.UserAuthURL) + } + if params.UserAuthMessage != "" { + values.Set("user_auth_message", params.UserAuthMessage) + } + + response := &EntityPresentDetailsResponse{} + err := api.postMethod(ctx, "entity.presentDetails", values, response) + if err != nil { + return err + } + + return response.Err() +} + +// EntityPresentDetailsWithMetadata is a convenience method for presenting entity details with metadata +func (api *Client) EntityPresentDetailsWithMetadata(triggerID string, metadata EntityDetailsMetadata) error { + return api.EntityPresentDetailsContext(context.Background(), EntityPresentDetailsParameters{ + TriggerID: triggerID, + Metadata: &metadata, + }) +} + +// EntityPresentDetailsWithError is a convenience method for presenting entity details with an error +func (api *Client) EntityPresentDetailsWithError(triggerID string, errPayload EntityDetailsError) error { + return api.EntityPresentDetailsContext(context.Background(), EntityPresentDetailsParameters{ + TriggerID: triggerID, + Error: &errPayload, + }) +} + +// EntityPresentDetailsWithAuth is a convenience method for presenting entity details with authentication required +func (api *Client) EntityPresentDetailsWithAuth(triggerID, authURL, authMessage string) error { + return api.EntityPresentDetailsContext(context.Background(), EntityPresentDetailsParameters{ + TriggerID: triggerID, + UserAuthRequired: true, + UserAuthURL: authURL, + UserAuthMessage: authMessage, + }) +} diff --git a/entity_test.go b/entity_test.go new file mode 100644 index 000000000..abeaba57e --- /dev/null +++ b/entity_test.go @@ -0,0 +1,322 @@ +package slack + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestEntityPresentDetailsParameters(t *testing.T) { + // Test basic parameter structure + params := EntityPresentDetailsParameters{ + TriggerID: "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + Metadata: &EntityDetailsMetadata{ + EntityType: "slack#/entities/file", + URL: "https://example.com/document/123", + ExternalRef: WorkObjectExternalRef{ + ID: "123", + Type: "document", + }, + EntityPayload: map[string]any{ + "title": "Test Document", + "description": "A test document for Work Objects", + "status": "active", + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(params) + if err != nil { + t.Errorf("Failed to marshal EntityPresentDetailsParameters: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled EntityPresentDetailsParameters + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Errorf("Failed to unmarshal EntityPresentDetailsParameters: %v", err) + } + + // Verify the data + if unmarshaled.TriggerID != params.TriggerID { + t.Errorf("Expected trigger_id '%s', got '%s'", params.TriggerID, unmarshaled.TriggerID) + } + + if unmarshaled.Metadata.EntityType != params.Metadata.EntityType { + t.Errorf("Expected entity_type '%s', got '%s'", params.Metadata.EntityType, unmarshaled.Metadata.EntityType) + } + + if unmarshaled.Metadata.ExternalRef.ID != params.Metadata.ExternalRef.ID { + t.Errorf("Expected external_ref.id '%s', got '%s'", params.Metadata.ExternalRef.ID, unmarshaled.Metadata.ExternalRef.ID) + } +} + +func TestEntityDetailsError(t *testing.T) { + // Test error structure + errorObj := EntityDetailsError{ + Status: "restricted", + CustomTitle: "Access Denied", + CustomMessage: "You do not have permission to view this entity.", + MessageFormat: "markdown", + Actions: []EntityDetailsAction{ + { + Text: "Request Access", + ActionID: "request_access", + Value: "entity_123", + Style: "primary", + URL: "https://example.com/request-access", + ProcessingState: &EntityDetailsProcessingState{ + Enabled: true, + }, + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(errorObj) + if err != nil { + t.Errorf("Failed to marshal EntityDetailsError: %v", err) + } + + // Test JSON unmarshaling + var unmarshaled EntityDetailsError + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Errorf("Failed to unmarshal EntityDetailsError: %v", err) + } + + // Verify the data + if unmarshaled.Status != errorObj.Status { + t.Errorf("Expected status '%s', got '%s'", errorObj.Status, unmarshaled.Status) + } + + if unmarshaled.CustomTitle != errorObj.CustomTitle { + t.Errorf("Expected custom_title '%s', got '%s'", errorObj.CustomTitle, unmarshaled.CustomTitle) + } + + if len(unmarshaled.Actions) != 1 { + t.Errorf("Expected 1 action, got %d", len(unmarshaled.Actions)) + } + + if len(unmarshaled.Actions) > 0 { + action := unmarshaled.Actions[0] + if action.Text != "Request Access" { + t.Errorf("Expected action text 'Request Access', got '%s'", action.Text) + } + if action.ProcessingState == nil || !action.ProcessingState.Enabled { + t.Error("Expected processing state to be enabled") + } + } +} + +func TestEntityPresentDetailsWithMetadata(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Verify the request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Parse form data + err := r.ParseForm() + if err != nil { + t.Errorf("Failed to parse form: %v", err) + return + } + + // Check required fields + triggerID := r.FormValue("trigger_id") + if triggerID != "1234567890123.1234567890123.abcdef01234567890abcdef012345689" { + t.Errorf("Expected trigger_id '1234567890123.1234567890123.abcdef01234567890abcdef012345689', got '%s'", triggerID) + } + + // Check metadata + metadataStr := r.FormValue("metadata") + if metadataStr == "" { + t.Error("Expected metadata to be present") + } else { + var metadata EntityDetailsMetadata + err := json.Unmarshal([]byte(metadataStr), &metadata) + if err != nil { + t.Errorf("Failed to unmarshal metadata: %v", err) + } + if metadata.EntityType != "slack#/entities/file" { + t.Errorf("Expected entity_type 'slack#/entities/file', got '%s'", metadata.EntityType) + } + } + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + metadata := EntityDetailsMetadata{ + EntityType: "slack#/entities/file", + URL: "https://example.com/document/123", + ExternalRef: WorkObjectExternalRef{ + ID: "123", + Type: "document", + }, + EntityPayload: map[string]any{ + "title": "Test Document", + "description": "A test document for Work Objects", + }, + } + + err := api.EntityPresentDetailsWithMetadata("1234567890123.1234567890123.abcdef01234567890abcdef012345689", metadata) + if err != nil { + t.Errorf("EntityPresentDetailsWithMetadata returned error: %v", err) + } +} + +func TestEntityPresentDetailsWithError(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Parse form data + err := r.ParseForm() + if err != nil { + t.Errorf("Failed to parse form: %v", err) + return + } + + // Check error field + errorStr := r.FormValue("error") + if errorStr == "" { + t.Error("Expected error to be present") + } else { + var errorObj EntityDetailsError + err := json.Unmarshal([]byte(errorStr), &errorObj) + if err != nil { + t.Errorf("Failed to unmarshal error: %v", err) + } + if errorObj.Status != "restricted" { + t.Errorf("Expected error status 'restricted', got '%s'", errorObj.Status) + } + } + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + errorObj := EntityDetailsError{ + Status: "restricted", + CustomTitle: "Access Denied", + CustomMessage: "You do not have permission to view this entity.", + Actions: []EntityDetailsAction{ + { + Text: "Request Access", + ActionID: "request_access", + Style: "primary", + }, + }, + } + + err := api.EntityPresentDetailsWithError("1234567890123.1234567890123.abcdef01234567890abcdef012345689", errorObj) + if err != nil { + t.Errorf("EntityPresentDetailsWithError returned error: %v", err) + } +} + +func TestEntityPresentDetailsWithAuth(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Parse form data + err := r.ParseForm() + if err != nil { + t.Errorf("Failed to parse form: %v", err) + return + } + + // Check auth fields + userAuthRequired := r.FormValue("user_auth_required") + if userAuthRequired != "true" { + t.Errorf("Expected user_auth_required 'true', got '%s'", userAuthRequired) + } + + userAuthURL := r.FormValue("user_auth_url") + if userAuthURL != "https://example.com/auth" { + t.Errorf("Expected user_auth_url 'https://example.com/auth', got '%s'", userAuthURL) + } + + userAuthMessage := r.FormValue("user_auth_message") + if userAuthMessage != "Please authenticate to view this entity." { + t.Errorf("Expected user_auth_message 'Please authenticate to view this entity.', got '%s'", userAuthMessage) + } + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.EntityPresentDetailsWithAuth( + "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + "https://example.com/auth", + "Please authenticate to view this entity.", + ) + if err != nil { + t.Errorf("EntityPresentDetailsWithAuth returned error: %v", err) + } +} + +func TestEntityPresentDetailsContext(t *testing.T) { + http.DefaultServeMux = new(http.ServeMux) + http.HandleFunc("/entity.presentDetails", func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + // Return success response + response := EntityPresentDetailsResponse{ + SlackResponse: SlackResponse{Ok: true}, + } + jsonResponse, _ := json.Marshal(response) + rw.Write(jsonResponse) + }) + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := EntityPresentDetailsParameters{ + TriggerID: "1234567890123.1234567890123.abcdef01234567890abcdef012345689", + Metadata: &EntityDetailsMetadata{ + EntityType: "slack#/entities/task", + URL: "https://example.com/task/456", + ExternalRef: WorkObjectExternalRef{ + ID: "456", + }, + EntityPayload: map[string]any{ + "title": "Test Task", + "status": "in_progress", + }, + }, + } + + err := api.EntityPresentDetails(params) + if err != nil { + t.Errorf("EntityPresentDetails returned error: %v", err) + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..ead19a0bd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,95 @@ +# Slack Examples + +This directory contains examples demonstrating how to use the slack-go library for various Slack API operations. + +## Development Guidelines + +### Environment Variables vs Command Line Arguments + +When developing examples, follow these patterns for handling different types of data: + +#### Environment Variables (Sensitive Data) + +Use environment variables for **sensitive information** that should not be exposed in command history or process lists: + +- **Tokens**: `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_USER_TOKEN` +- **Secrets**: `SLACK_SIGNING_SECRET` +- **URLs with credentials**: webhook URLs, etc. + +**Pattern:** +```go +token := os.Getenv("SLACK_BOT_TOKEN") +if token == "" { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n") + os.Exit(1) +} + +// Optional: Validate token format +if !strings.HasPrefix(token, "xoxb-") { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be a bot token (xoxb-)\n") + os.Exit(1) +} +``` + +**Common environment variables:** +- `SLACK_BOT_TOKEN` - Bot user OAuth token (starts with `xoxb-`) +- `SLACK_APP_TOKEN` - App-level token (starts with `xapp-`) +- `SLACK_USER_TOKEN` - User OAuth token (starts with `xoxp-`) +- `SLACK_SIGNING_SECRET` - For webhook signature verification + +#### Command Line Arguments (Non-Sensitive Data) + +Use command line flags for **operational parameters** that are safe to expose: + +- **IDs**: channel IDs, user IDs, team IDs +- **Configuration**: timeouts, limits, modes +- **Options**: boolean flags, enum values + +**Pattern:** +```go +import "flag" + +var ( + channelID = flag.String("channel", "", "Channel ID (required)") + userID = flag.String("user", "", "User ID (required)") + verbose = flag.Bool("verbose", false, "Enable verbose logging") +) + +func main() { + flag.Parse() + + if *channelID == "" { + fmt.Fprintf(os.Stderr, "Error: -channel flag is required\n") + os.Exit(1) + } + + if *userID == "" { + fmt.Fprintf(os.Stderr, "Error: -user flag is required\n") + os.Exit(1) + } + + // Use the flags + fmt.Printf("Operating on channel %s with user %s\n", *channelID, *userID) +} +``` + +### Error Handling Standards + +**Environment variable errors:** +- Use `fmt.Fprintf(os.Stderr, ...)` for error output +- Include clear variable name in error message +- Exit with `os.Exit(1)` for missing required variables + +**Command line argument errors:** +- Use `fmt.Fprintf(os.Stderr, ...)` for error output +- Include flag name in error message +- Exit with `os.Exit(1)` for missing required flags + +### Security Considerations + +1. **Never hardcode sensitive values** in example code +2. **Think of validating required environment variables** before using them +3. **Use token format validation** when applicable (e.g., `xoxb-` prefix for bot tokens) +4. **Keep sensitive data out of command line arguments** to prevent exposure in process lists + +This pattern ensures consistent security practices across all examples and makes them easier to understand and use safely. diff --git a/examples/admin_conversations/admin_conversations.go b/examples/admin_conversations/admin_conversations.go new file mode 100644 index 000000000..95473ca15 --- /dev/null +++ b/examples/admin_conversations/admin_conversations.go @@ -0,0 +1,567 @@ +// This example demonstrates the admin.conversations.* API methods. +// These methods require an Enterprise Grid organization and an app installed +// at the org level with admin.* scopes. +// +// Usage: +// +// export SLACK_USER_TOKEN="xoxp-..." +// export SLACK_TEAM_ID="T..." # Optional: workspace ID for scoping operations +// go run admin_conversations.go +// +// The example provides a menu to test different operations. Read-only operations +// are safe to run. Destructive operations are clearly marked. +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/slack-go/slack" +) + +var ( + api *slack.Client + teamID string +) + +func main() { + token := os.Getenv("SLACK_USER_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "SLACK_USER_TOKEN environment variable is required") + fmt.Fprintln(os.Stderr, "This must be an org-level token with admin.conversations:* scopes") + os.Exit(1) + } + + teamID = os.Getenv("SLACK_TEAM_ID") + + api = slack.New(token) + + reader := bufio.NewReader(os.Stdin) + + for { + printMenu() + fmt.Print("\nChoice: ") + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + switch input { + case "1": + testSearch() + case "2": + testGetTeams(reader) + case "3": + testGetConversationPrefs(reader) + case "4": + testGetCustomRetention(reader) + case "5": + testLookup() + case "6": + testRestrictAccessListGroups(reader) + case "7": + testEKMListOriginalConnectedChannelInfo() + case "8": + testCreate(reader) + case "9": + testInvite(reader) + case "10": + testRename(reader) + case "11": + testSetConversationPrefs(reader) + case "12": + testSetCustomRetention(reader) + case "13": + testArchive(reader) + case "14": + testUnarchive(reader) + case "15": + testDelete(reader) + case "q", "Q": + fmt.Println("Goodbye!") + return + default: + fmt.Println("Invalid choice") + } + fmt.Println() + } +} + +func printMenu() { + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("Admin Conversations API Demo") + fmt.Println(strings.Repeat("=", 60)) + fmt.Println("\nREAD-ONLY (safe):") + fmt.Println(" 1. Search conversations") + fmt.Println(" 2. Get teams for a channel") + fmt.Println(" 3. Get conversation preferences") + fmt.Println(" 4. Get custom retention policy") + fmt.Println(" 5. Lookup channels by activity") + fmt.Println(" 6. List restrict access groups") + fmt.Println(" 7. List EKM original connected channel info") + fmt.Println("\nCREATE/MODIFY:") + fmt.Println(" 8. Create a test channel") + fmt.Println(" 9. Invite users to a channel") + fmt.Println(" 10. Rename a channel") + fmt.Println(" 11. Set conversation preferences") + fmt.Println(" 12. Set custom retention policy") + fmt.Println("\nARCHIVE/DELETE (use with caution):") + fmt.Println(" 13. Archive a channel") + fmt.Println(" 14. Unarchive a channel") + fmt.Println(" 15. Delete a channel [DESTRUCTIVE]") + fmt.Println("\n q. Quit") +} + +func testSearch() { + fmt.Println("\n--- Searching conversations ---") + + options := []slack.AdminConversationsSearchOption{ + slack.AdminConversationsSearchOptionLimit(10), + slack.AdminConversationsSearchOptionSort("member_count"), + } + + if teamID != "" { + options = append(options, slack.AdminConversationsSearchOptionTeamIDs([]string{teamID})) + } + + response, err := api.AdminConversationsSearch(context.Background(), options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Found %d conversations (showing up to 10)\n", response.TotalCount) + fmt.Printf("Next cursor: %q\n\n", response.NextCursor) + + for _, conv := range response.Conversations { + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Channel: #%s (%s)\n", conv.Name, conv.ID) + fmt.Printf(" Purpose: %s\n", conv.Purpose) + fmt.Printf(" Created: %d | Creator: %s\n", conv.Created, conv.CreatorID) + fmt.Printf(" Members: %d | External users: %d | Channel managers: %d\n", + conv.MemberCount, conv.ExternalUserCount, conv.ChannelManagerCount) + fmt.Printf(" Last activity: %d\n", conv.LastActivityTimestamp) + + // Visibility flags + fmt.Printf(" Flags: ") + flags := []string{} + if conv.IsPrivate { + flags = append(flags, "private") + } else { + flags = append(flags, "public") + } + if conv.IsArchived { + flags = append(flags, "archived") + } + if conv.IsGeneral { + flags = append(flags, "general") + } + if conv.IsFrozen { + flags = append(flags, "frozen") + } + if conv.IsOrgShared { + flags = append(flags, "org-shared") + } + if conv.IsExtShared { + flags = append(flags, "ext-shared") + } + if conv.IsGlobalShared { + flags = append(flags, "global-shared") + } + if conv.IsPendingExtShared { + flags = append(flags, "pending-ext-shared") + } + if conv.IsDisconnectInProgress { + flags = append(flags, "disconnect-in-progress") + } + if conv.IsOrgDefault { + flags = append(flags, "org-default") + } + if conv.IsOrgMandatory { + flags = append(flags, "org-mandatory") + } + fmt.Printf("%s\n", strings.Join(flags, ", ")) + + // Team IDs + if len(conv.ConnectedTeamIDs) > 0 { + fmt.Printf(" Connected teams: %v\n", conv.ConnectedTeamIDs) + } + if len(conv.ConnectedLimitedTeamIDs) > 0 { + fmt.Printf(" Connected limited teams: %v\n", conv.ConnectedLimitedTeamIDs) + } + if len(conv.PendingConnectedTeamIDs) > 0 { + fmt.Printf(" Pending connected teams: %v\n", conv.PendingConnectedTeamIDs) + } + if len(conv.InternalTeamIDs) > 0 { + fmt.Printf(" Internal teams: %v\n", conv.InternalTeamIDs) + } + if conv.InternalTeamIDsCount > 0 { + fmt.Printf(" Internal teams count: %d (sample: %s)\n", + conv.InternalTeamIDsCount, conv.InternalTeamIDsSampleTeam) + } + if conv.ContextTeamID != "" { + fmt.Printf(" Context team: %s\n", conv.ContextTeamID) + } + if conv.ConversationHostID != "" { + fmt.Printf(" Conversation host: %s\n", conv.ConversationHostID) + } + + // Email addresses + if len(conv.ChannelEmailAddresses) > 0 { + fmt.Printf(" Email addresses:\n") + for _, email := range conv.ChannelEmailAddresses { + fmt.Printf(" - %s (team: %s, creator: %s)\n", + email.Address, email.TeamID, email.CreatorID) + } + } + + // Canvas/Lists + if conv.Canvas != nil { + fmt.Printf(" Canvas: total_count=%d\n", conv.Canvas.TotalCount) + for _, od := range conv.Canvas.OwnershipDetails { + fmt.Printf(" - team %s: %d\n", od.TeamID, od.Count) + } + } + if conv.Lists != nil { + fmt.Printf(" Lists: total_count=%d\n", conv.Lists.TotalCount) + for _, od := range conv.Lists.OwnershipDetails { + fmt.Printf(" - team %s: %d\n", od.TeamID, od.Count) + } + } + + // Properties + if conv.Properties != nil { + fmt.Printf(" Properties: present\n") + } + + fmt.Println() + } +} + +func testGetTeams(reader *bufio.Reader) { + fmt.Println("\n--- Get teams for channel ---") + channelID := prompt(reader, "Channel ID (e.g., C1234567890): ") + + teamIDs, cursor, err := api.AdminConversationsGetTeams(context.Background(), slack.AdminConversationsGetTeamsParams{ + ChannelID: channelID, + Limit: 100, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Teams connected to %s:\n", channelID) + for _, tid := range teamIDs { + fmt.Printf(" - %s\n", tid) + } + if cursor != "" { + fmt.Printf("(more results available, cursor: %s)\n", cursor) + } +} + +func testGetConversationPrefs(reader *bufio.Reader) { + fmt.Println("\n--- Get conversation preferences ---") + channelID := prompt(reader, "Channel ID: ") + + prefs, err := api.AdminConversationsGetConversationPrefs(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Preferences for %s:\n", channelID) + if prefs.WhoCanPost != nil { + fmt.Printf(" Who can post: types=%v, users=%v\n", prefs.WhoCanPost.Type, prefs.WhoCanPost.User) + } + if prefs.CanThread != nil { + fmt.Printf(" Can thread: types=%v\n", prefs.CanThread.Type) + } + if prefs.CanHuddle != nil { + fmt.Printf(" Can huddle: types=%v\n", prefs.CanHuddle.Type) + } +} + +func testGetCustomRetention(reader *bufio.Reader) { + fmt.Println("\n--- Get custom retention policy ---") + channelID := prompt(reader, "Channel ID: ") + + resp, err := api.AdminConversationsGetCustomRetention(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + if !resp.IsPolicyEnabled { + fmt.Printf("Channel %s does not have a custom retention policy enabled\n", channelID) + } else { + fmt.Printf("Channel %s has custom retention: %d days\n", channelID, resp.DurationDays) + } +} + +func testLookup() { + fmt.Println("\n--- Lookup channels by activity ---") + + if teamID == "" { + fmt.Println("Error: SLACK_TEAM_ID environment variable is required for lookup") + return + } + + // Find channels with no activity in the last 90 days + cutoff := time.Now().AddDate(0, 0, -90).Unix() + + channels, cursor, err := api.AdminConversationsLookup(context.Background(), + []string{teamID}, cutoff, + slack.AdminConversationsLookupOptionLimit(10), + ) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Channels with no activity in last 90 days:\n") + if len(channels) == 0 { + fmt.Println(" (none found)") + } + for _, ch := range channels { + fmt.Printf(" - %s\n", ch) + } + if cursor != "" { + fmt.Printf("(more results available)\n") + } +} + +func testRestrictAccessListGroups(reader *bufio.Reader) { + fmt.Println("\n--- List restrict access groups ---") + channelID := prompt(reader, "Channel ID: ") + + var options []slack.AdminConversationsRestrictAccessListGroupsOption + if teamID != "" { + options = append(options, slack.AdminConversationsRestrictAccessListGroupsOptionTeamID(teamID)) + } + + groupIDs, err := api.AdminConversationsRestrictAccessListGroups(context.Background(), channelID, options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("IDP groups with access to %s:\n", channelID) + if len(groupIDs) == 0 { + fmt.Println(" (no IDP group restrictions)") + } + for _, gid := range groupIDs { + fmt.Printf(" - %s\n", gid) + } +} + +func testEKMListOriginalConnectedChannelInfo() { + fmt.Println("\n--- List EKM original connected channel info ---") + + var options []slack.AdminConversationsEKMListOriginalConnectedChannelInfoOption + options = append(options, slack.AdminConversationsEKMListOriginalConnectedChannelInfoOptionLimit(10)) + + if teamID != "" { + options = append(options, slack.AdminConversationsEKMListOriginalConnectedChannelInfoOptionTeamIDs([]string{teamID})) + } + + response, err := api.AdminConversationsEKMListOriginalConnectedChannelInfo(context.Background(), options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Found %d channels\n\n", len(response.Channels)) + + for _, ch := range response.Channels { + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Channel: %s\n", ch.ID) + fmt.Printf(" Original connected host ID: %s\n", ch.OriginalConnectedHostID) + fmt.Printf(" Original connected channel ID: %s\n", ch.OriginalConnectedChannelID) + fmt.Printf(" Internal team IDs: %v\n", ch.InternalTeamIDs) + fmt.Println() + } + + if len(response.Channels) == 0 { + fmt.Println(" (no EKM connected channel info found)") + } +} + +func testCreate(reader *bufio.Reader) { + fmt.Println("\n--- Create test channel ---") + name := prompt(reader, "Channel name (e.g., test-admin-api): ") + isPrivate := strings.ToLower(prompt(reader, "Private? (y/n): ")) == "y" + description := prompt(reader, "Description (optional): ") + + var options []slack.AdminConversationsCreateOption + if description != "" { + options = append(options, slack.AdminConversationsCreateOptionDescription(description)) + } + if teamID != "" { + options = append(options, slack.AdminConversationsCreateOptionTeamID(teamID)) + } + + channelID, err := api.AdminConversationsCreate(context.Background(), name, isPrivate, options...) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Created channel: %s\n", channelID) + fmt.Println("Save this ID to test other operations!") +} + +func testInvite(reader *bufio.Reader) { + fmt.Println("\n--- Invite users to channel ---") + channelID := prompt(reader, "Channel ID: ") + userIDsStr := prompt(reader, "User IDs (comma-separated, e.g., U123,U456): ") + + userIDs := strings.Split(userIDsStr, ",") + for i := range userIDs { + userIDs[i] = strings.TrimSpace(userIDs[i]) + } + + err := api.AdminConversationsInvite(context.Background(), slack.AdminConversationsInviteParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Invited %d user(s) to %s\n", len(userIDs), channelID) +} + +func testRename(reader *bufio.Reader) { + fmt.Println("\n--- Rename channel ---") + channelID := prompt(reader, "Channel ID: ") + newName := prompt(reader, "New name: ") + + err := api.AdminConversationsRename(context.Background(), channelID, newName) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Renamed %s to #%s\n", channelID, newName) +} + +func testSetConversationPrefs(reader *bufio.Reader) { + fmt.Println("\n--- Set conversation preferences ---") + channelID := prompt(reader, "Channel ID: ") + + fmt.Println("Who can post?") + fmt.Println(" 1. Everyone") + fmt.Println(" 2. Admins only") + fmt.Println(" 3. Org admins only") + choice := prompt(reader, "Choice: ") + + var whoCanPost []string + switch choice { + case "1": + whoCanPost = []string{"owner", "admin", "org_admin", "member", "ra_member", "guest"} + case "2": + whoCanPost = []string{"owner", "admin", "org_admin"} + case "3": + whoCanPost = []string{"owner", "org_admin"} + default: + fmt.Println("Invalid choice") + return + } + + err := api.AdminConversationsSetConversationPrefs(context.Background(), slack.AdminConversationsSetConversationPrefsParams{ + ChannelID: channelID, + Prefs: slack.AdminConversationPrefs{ + WhoCanPost: &slack.AdminConversationPref{Type: whoCanPost}, + }, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Updated posting preferences for %s\n", channelID) +} + +func testSetCustomRetention(reader *bufio.Reader) { + fmt.Println("\n--- Set custom retention policy ---") + channelID := prompt(reader, "Channel ID: ") + daysStr := prompt(reader, "Retention days (e.g., 90): ") + + var days int + _, err := fmt.Sscanf(daysStr, "%d", &days) + if err != nil || days <= 0 { + fmt.Println("Invalid number of days") + return + } + + err = api.AdminConversationsSetCustomRetention(context.Background(), channelID, days) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Set %d-day retention policy for %s\n", days, channelID) +} + +func testArchive(reader *bufio.Reader) { + fmt.Println("\n--- Archive channel ---") + fmt.Println("WARNING: This will archive the channel!") + channelID := prompt(reader, "Channel ID: ") + + confirm := prompt(reader, "Type 'archive' to confirm: ") + if confirm != "archive" { + fmt.Println("Cancelled") + return + } + + err := api.AdminConversationsArchive(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Archived %s\n", channelID) +} + +func testUnarchive(reader *bufio.Reader) { + fmt.Println("\n--- Unarchive channel ---") + channelID := prompt(reader, "Channel ID: ") + + err := api.AdminConversationsUnarchive(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Unarchived %s\n", channelID) +} + +func testDelete(reader *bufio.Reader) { + fmt.Println("\n--- Delete channel ---") + fmt.Println("!!! WARNING: THIS IS PERMANENT AND CANNOT BE UNDONE !!!") + channelID := prompt(reader, "Channel ID: ") + + confirm := prompt(reader, "Type 'DELETE' (all caps) to confirm: ") + if confirm != "DELETE" { + fmt.Println("Cancelled") + return + } + + err := api.AdminConversationsDelete(context.Background(), channelID) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + fmt.Printf("Deleted %s\n", channelID) +} + +func prompt(reader *bufio.Reader, message string) string { + fmt.Print(message) + input, _ := reader.ReadString('\n') + return strings.TrimSpace(input) +} diff --git a/examples/ai_apps/ai_apps_handler.go b/examples/ai_apps/ai_apps_handler.go new file mode 100644 index 000000000..d9d61e2ba --- /dev/null +++ b/examples/ai_apps/ai_apps_handler.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +// This is an example of AI apps https://api.slack.com/docs/apps/ai +// Developing and using AI apps requires a paid plan or joining the Developer Program and provision a sandbox with access to all Slack features for free. +// +// This example also have calling https://api.slack.com/docs/apps/data-access-api sample code. +// This API is currently in a limited access stage. You may be able to obtain a token and call the API, but to get a valid response, you must be enrolled in the program. Contact Customer Experience at feedback@slack.com to request to be added. +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + panic("SLACK_APP_TOKEN must be set.\n") + } + + if !strings.HasPrefix(appToken, "xapp-") { + panic("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + panic("SLACK_BOT_TOKEN must be set.\n") + } + + if !strings.HasPrefix(botToken, "xoxb-") { + panic("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + socketmodeHandler := socketmode.NewSocketmodeHandler(client) + + // Handle a specific event from EventsAPI + socketmodeHandler.HandleEvents(slackevents.AssistantThreadStarted, middlewareAssistantThreadStartedEvent) + socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) + + socketmodeHandler.RunEventLoop() +} + +func middlewareAssistantThreadStartedEvent(evt *socketmode.Event, client *socketmode.Client) { + ctx := context.Background() + fmt.Printf("assistant thread started event: %+v\n", evt) + + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + innerEvent, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AssistantThreadStartedEvent) + if !ok { + fmt.Printf("Can't get inner event: %+v\n", evt) + return + } + + fmt.Printf("Inner event: %+v\n", innerEvent) + + params := slack.AssistantThreadsSetSuggestedPromptsParameters{ + Title: "Welcome. What can I do for you?", + ChannelID: innerEvent.AssistantThread.ChannelID, + ThreadTS: innerEvent.AssistantThread.ThreadTimeStamp, + Prompts: []slack.AssistantThreadsPrompt{ + { + Title: "Generate ideas", + Message: "Pretend you are a marketing associate and you need new ideas for an enterprise productivity feature. Generate 10 ideas for a new feature launch.", + }, + { + Title: "Explain what SLACK stands for", + Message: "What does SLACK stand for?", + }, + }, + } + + if err := client.SetAssistantThreadsSuggestedPromptsContext(ctx, params); err != nil { + fmt.Printf("Can't SetAssistantThreadsSuggestedPromptsContext: %v\n", err) + return + } +} + +func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) { + ctx := context.Background() + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + client.Ack(*evt.Request) + + ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + fmt.Printf("We have been mentioned in %v\n", ev.Channel) + if err := searchAssistantContext(ctx, client, ev.AssistantThread, ev.Text, ev.Channel, ev.TimeStamp); err != nil { + fmt.Printf("Failed searchAssistantContext: %+v\n", err) + return + } +} + +// This is sample of calling https://api.slack.com/docs/apps/data-access-api +// This API is currently in a limited access stage. You may be able to obtain a token and call the API, but to get a valid response, you must be enrolled in the program. Contact Customer Experience at feedback@slack.com to request to be added. +func searchAssistantContext(ctx context.Context, client *socketmode.Client, at *slackevents.AssistantThreadActionToken, text, channel, ts string) error { + // Assistant thread message handling + if at != nil { + fmt.Printf("Assistant thread message received - text: %s, channel: %s", + text, channel) + + // Call Data Access API for context search + resp, err := client.SearchAssistantContextContext(ctx, slack.AssistantSearchContextParameters{ + Query: text, + ActionToken: at.ActionToken, + ChannelTypes: []string{"public_channel"}, + ContentTypes: []string{"messages"}, + Limit: 10, + }) + if err != nil { + return err + } + fmt.Printf("SearchAssistantContextContext response: %+v\n", resp) + + if len(resp.Results.Messages) > 0 { + _, _, err = client.Client.PostMessage(channel, slack.MsgOptionText("Hello! I searched your query.text:\n: "+text+"\nSearch first result:\n"+resp.Results.Messages[0].Content, false), slack.MsgOptionTS(ts)) + if err != nil { + return err + } + } + } + return nil +} diff --git a/examples/assistant_search/assistant_search.go b/examples/assistant_search/assistant_search.go new file mode 100644 index 000000000..3929b3454 --- /dev/null +++ b/examples/assistant_search/assistant_search.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/slack-go/slack" +) + +// This example demonstrates how to use the assistant.search.context API +// to search across a Slack workspace. This API is designed for AI/LLM +// consumption and returns messages, files, and channels matching a query. +// +// Usage: go run assistant_search.go +// +// This example uses a user token (xoxp-...), which can call the API directly. +// Bot tokens (xoxb-...) require an action_token received from message events; +// see the ai_apps example for that pattern. +// +// Required scopes (user token): search:read.public, search:read.private, +// +// search:read.im, search:read.mpim, search:read.files, search:read.users +// +// See https://docs.slack.dev/reference/methods/assistant.search.context +func main() { + token := os.Getenv("SLACK_USER_TOKEN") + if token == "" { + fmt.Println("SLACK_USER_TOKEN environment variable is required (xoxp-... token)") + os.Exit(1) + } + + if len(os.Args) < 2 { + fmt.Println("Usage: go run assistant_search.go ") + os.Exit(1) + } + + query := strings.Join(os.Args[1:], " ") + api := slack.New(token) + + // Search across public channels for messages, files, and channels + resp, err := api.SearchAssistantContext(slack.AssistantSearchContextParameters{ + Query: query, + ChannelTypes: []string{"public_channel"}, + ContentTypes: []string{"messages", "files", "channels"}, + Limit: 10, + }) + if err != nil { + fmt.Printf("Error: %s\n", err) + os.Exit(1) + } + + printResults(resp) + + // Paginate using cursor + if resp.ResponseMetadata.NextCursor != "" { + fmt.Printf("\nNext page cursor: %s\n", resp.ResponseMetadata.NextCursor) + } + + // Advanced search: keyword-only, sorted by time, with context messages + advanced, err := api.SearchAssistantContext(slack.AssistantSearchContextParameters{ + Query: query, + ChannelTypes: []string{"public_channel", "private_channel"}, + ContentTypes: []string{"messages", "files"}, + Sort: "timestamp", + SortDir: "desc", + IncludeContextMessages: true, + Highlight: true, + DisableSemanticSearch: true, + Limit: 5, + }) + if err != nil { + fmt.Printf("Advanced search error: %s\n", err) + os.Exit(1) + } + + fmt.Println("\n--- Advanced Search (keyword-only, with context) ---") + printResults(advanced) +} + +func printResults(resp *slack.AssistantSearchContextResponse) { + fmt.Printf("=== Messages (%d) ===\n", len(resp.Results.Messages)) + for _, msg := range resp.Results.Messages { + bot := "" + if msg.IsAuthorBot { + bot = " [bot]" + } + fmt.Printf(" %s (%s)%s in #%s:\n %s\n %s\n", + msg.AuthorName, msg.AuthorUserID, bot, + msg.ChannelName, msg.Content, msg.Permalink) + + if msg.ContextMessages != nil { + for _, before := range msg.ContextMessages.Before { + fmt.Printf(" [before] %s: %s\n", before.AuthorUserID, before.Content) + } + for _, after := range msg.ContextMessages.After { + fmt.Printf(" [after] %s: %s\n", after.AuthorUserID, after.Content) + } + } + } + + fmt.Printf("\n=== Files (%d) ===\n", len(resp.Results.Files)) + for _, f := range resp.Results.Files { + fmt.Printf(" %s (%s) by %s\n %s\n", + f.Title, f.FileType, f.AuthorName, f.Permalink) + } + + fmt.Printf("\n=== Channels (%d) ===\n", len(resp.Results.Channels)) + for _, ch := range resp.Results.Channels { + fmt.Printf(" #%s — %s\n %s\n", + ch.Name, ch.Purpose, ch.Permalink) + } +} diff --git a/examples/audit/audit.go b/examples/audit/audit.go new file mode 100644 index 000000000..778080802 --- /dev/null +++ b/examples/audit/audit.go @@ -0,0 +1,104 @@ +// This example demonstrates the Audit Logs API. +// The Audit Logs API requires an Enterprise Grid organization with an app +// that has the auditlogs:read scope. +// +// Usage: +// +// export SLACK_USER_TOKEN="xoxp-..." # User token with auditlogs:read scope +// go run audit.go +// +// Note: The Audit Logs API uses a different endpoint (api.slack.com) than +// the regular Slack API (slack.com/api). This is handled automatically by +// the library. +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/slack-go/slack" +) + +func main() { + token := os.Getenv("SLACK_USER_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "SLACK_USER_TOKEN environment variable is required") + fmt.Fprintln(os.Stderr, "This must be a user token with auditlogs:read scope") + os.Exit(1) + } + + api := slack.New(token) + + // Fetch the last 24 hours of audit logs + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + + fmt.Println("Fetching audit logs from the last 24 hours...") + fmt.Printf(" From: %s\n", yesterday.Format(time.RFC3339)) + fmt.Printf(" To: %s\n\n", now.Format(time.RFC3339)) + + params := slack.AuditLogParameters{ + Limit: 10, + Oldest: int(yesterday.Unix()), + Latest: int(now.Unix()), + } + + entries, nextCursor, err := api.GetAuditLogsContext(context.Background(), params) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching audit logs: %s\n", err) + os.Exit(1) + } + + fmt.Printf("Found %d audit log entries (limited to 10)\n", len(entries)) + if nextCursor != "" { + fmt.Printf("More entries available (cursor: %s)\n", nextCursor) + } + fmt.Println() + + for i, entry := range entries { + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("Entry %d: %s\n", i+1, entry.ID) + fmt.Printf(" Action: %s\n", entry.Action) + fmt.Printf(" Date: %s\n", time.Unix(int64(entry.DateCreate), 0).Format(time.RFC3339)) + + // Actor info + fmt.Printf(" Actor: %s (%s)\n", entry.Actor.User.Name, entry.Actor.User.Email) + + // Entity info + fmt.Printf(" Entity Type: %s\n", entry.Entity.Type) + switch entry.Entity.Type { + case "user": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.User.Name, entry.Entity.User.Email) + case "channel": + fmt.Printf(" Entity: #%s (%s)\n", entry.Entity.Channel.Name, entry.Entity.Channel.ID) + case "file": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.File.Name, entry.Entity.File.ID) + case "app": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.App.Name, entry.Entity.App.ID) + case "workspace": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.Workspace.Name, entry.Entity.Workspace.Domain) + case "enterprise": + fmt.Printf(" Entity: %s (%s)\n", entry.Entity.Enterprise.Name, entry.Entity.Enterprise.Domain) + } + + // Context + if entry.Context.Location.Name != "" { + fmt.Printf(" Location: %s (%s)\n", entry.Context.Location.Name, entry.Context.Location.Type) + } + if entry.Context.IPAddress != "" { + fmt.Printf(" IP Address: %s\n", entry.Context.IPAddress) + } + + fmt.Println() + } + + if len(entries) == 0 { + fmt.Println("No audit log entries found in the specified time range.") + fmt.Println("This could mean:") + fmt.Println(" - No actions were taken in the last 24 hours") + fmt.Println(" - The token doesn't have the auditlogs:read scope") + fmt.Println(" - The workspace is not part of an Enterprise Grid") + } +} diff --git a/examples/blocks/README.md b/examples/blocks/README.md index 588f2485a..b677cf0b7 100644 --- a/examples/blocks/README.md +++ b/examples/blocks/README.md @@ -9,7 +9,7 @@ The examples below should cover implementing most supported block elements. For additional information on Blocks, see the [Block Kit website](https://api.slack.com/block-kit). ### Using examples with the Block Kit Builder website -When generating examples, they will be printed to the screen as a complete message that is meant to be sent back to slack as a direct response, or throuogh the ResponseURL provided. To test your examples in the Block Kit Builder, you must take the contents of the `blocks` property and paste the results into the builder. +When generating examples, they will be printed to the screen as a complete message that is meant to be sent back to slack as a direct response, or throuogh the ResponseURL provided. To test your examples in the Block Kit Builder, you must take the contents of the `blocks` property and paste the results into the builder. For example, when printing a simple header, the output will be @@ -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. @@ -66,4 +66,16 @@ This example introduces overflow elements, allowing you to populate a select sty #### Example 6 - Search Results with Options and Actions -Using a combination of overflow elements containing selectable options and actions, this examples allows you to prompt the user with multiple actions in a single response. You can view the rendered [Search Results with Actions](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%22%3Amag%3A%20Search%20results%20for%20*Cata*%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.toYourApp.com%7CUse%20Case%20Catalogue%3E*%5CnUse%20Case%20Catalogue%20for%20the%20following%20departments%2Froles...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Edit%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CCustomer%20Support%20-%20Workflow%20Diagram%20Catalogue%3E*%5CnThis%20resource%20was%20put%20together%20by%20members%20of...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CSelf-Serve%20Learning%20Options%20Catalogue%3E*%5CnSee%20the%20learning%20and%20development%20options%20we...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CUse%20Case%20Catalogue%20-%20CF%20Presentation%20-%20%5BJune%2012%2C%202018%5D%3E*%5CnThis%20is%20presentation%20will%20continue%20to%20be%20updated%20as...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CComprehensive%20Benefits%20Catalogue%20-%202019%3E*%5CnInformation%20about%20all%20the%20benfits%20we%20offer%20is...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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%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%22Next%205%20Results%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) example on the block kit builder website. Refer to the function named `exampleSix` for more information on building this block. \ No newline at end of file +Using a combination of overflow elements containing selectable options and actions, this examples allows you to prompt the user with multiple actions in a single response. You can view the rendered [Search Results with Actions](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%22%3Amag%3A%20Search%20results%20for%20*Cata*%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.toYourApp.com%7CUse%20Case%20Catalogue%3E*%5CnUse%20Case%20Catalogue%20for%20the%20following%20departments%2Froles...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Edit%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CCustomer%20Support%20-%20Workflow%20Diagram%20Catalogue%3E*%5CnThis%20resource%20was%20put%20together%20by%20members%20of...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CSelf-Serve%20Learning%20Options%20Catalogue%3E*%5CnSee%20the%20learning%20and%20development%20options%20we...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CUse%20Case%20Catalogue%20-%20CF%20Presentation%20-%20%5BJune%2012%2C%202018%5D%3E*%5CnThis%20is%20presentation%20will%20continue%20to%20be%20updated%20as...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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.toYourApp.com%7CComprehensive%20Benefits%20Catalogue%20-%202019%3E*%5CnInformation%20about%20all%20the%20benfits%20we%20offer%20is...%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22static_select%22%2C%0A%09%09%09%22placeholder%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%22Manage%22%0A%09%09%09%7D%2C%0A%09%09%09%22options%22%3A%20%5B%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Manage%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-0%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Read%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-1%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%7B%0A%09%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%09%22text%22%3A%20%22Save%20it%22%0A%09%09%09%09%09%7D%2C%0A%09%09%09%09%09%22value%22%3A%20%22value-2%22%0A%09%09%09%09%7D%0A%09%09%09%5D%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%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%22Next%205%20Results%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) example on the block kit builder website. Refer to the function named `exampleSix` for more information on building this block. + +#### Context Actions Example +This example demonstrates the `context_actions` block, which is designed for interactive feedback elements like thumbs up/down buttons and icon-based actions. This block type is particularly useful for AI-generated content where you want to collect user feedback. + +The example shows two use cases: + - Using feedback buttons combined with an icon button (delete action) + - Using simple feedback buttons with accessibility labels + +You can view the [rendered example](https://app.slack.com/block-kit-builder/T01DMDZT3PD#%7B%22blocks%22:%5B%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*AI%20Assistant%20Response:*%5CnBased%20on%20your%20query,%20I%20recommend%20using%20the%20%60context_actions%60%20block%20for%20interactive%20feedback.%20This%20allows%20users%20to%20provide%20quick%20feedback%20on%20AI-generated%20content.%22%7D%7D,%7B%22type%22:%22context_actions%22,%22block_id%22:%22actions_1%22,%22elements%22:%5B%7B%22type%22:%22feedback_buttons%22,%22action_id%22:%22ai_feedback_1%22,%22positive_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%F0%9F%91%8D%22,%22emoji%22:false%7D,%22value%22:%22positive_feedback%22%7D,%22negative_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22%F0%9F%91%8E%22,%22emoji%22:false%7D,%22value%22:%22negative_feedback%22%7D%7D,%7B%22type%22:%22icon_button%22,%22icon%22:%22trash%22,%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Delete%22,%22emoji%22:false%7D,%22action_id%22:%22delete_response_1%22,%22value%22:%22response_123%22%7D%5D%7D,%7B%22type%22:%22divider%22%7D,%7B%22type%22:%22section%22,%22text%22:%7B%22type%22:%22mrkdwn%22,%22text%22:%22*Another%20AI%20Response:*%5CnHere's%20an%20alternative%20solution%20to%20your%20problem...%22%7D%7D,%7B%22type%22:%22context_actions%22,%22block_id%22:%22actions_2%22,%22elements%22:%5B%7B%22type%22:%22feedback_buttons%22,%22action_id%22:%22ai_feedback_2%22,%22positive_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Good%22,%22emoji%22:false%7D,%22value%22:%22good%22,%22accessibility_label%22:%22Mark%20this%20response%20as%20good%22%7D,%22negative_button%22:%7B%22text%22:%7B%22type%22:%22plain_text%22,%22text%22:%22Bad%22,%22emoji%22:false%7D,%22value%22:%22bad%22,%22accessibility_label%22:%22Mark%20this%20response%20as%20bad%22%7D%7D%5D%7D%5D%7D) on the block kit builder site. + +Refer to the function named `contextActionsExample` for more information. For additional +details on the context_actions block, see the [Slack API documentation](https://docs.slack.dev/reference/block-kit/blocks/context-actions-block/). \ No newline at end of file diff --git a/examples/blocks/blocks.go b/examples/blocks/blocks.go index 28d54d141..028b3bfae 100644 --- a/examples/blocks/blocks.go +++ b/examples/blocks/blocks.go @@ -37,6 +37,10 @@ func main() { exampleSix() fmt.Println("--- End Example Six ---") + fmt.Println("--- Begin Context Actions Example ---") + contextActionsExample() + fmt.Println("--- End Context Actions Example ---") + fmt.Println("--- Begin Example Unmarshalling ---") unmarshalExample() fmt.Println("--- End Example Unmarshalling ---") @@ -483,7 +487,8 @@ func unmarshalExample() { case slack.MixedElementImage: // Assert the block's type to manipulate/extract values imageBlockElem := elem.(*slack.ImageBlockElement) - imageBlockElem.ImageURL = "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png" + imageURL := "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png" + imageBlockElem.ImageURL = &imageURL imageBlockElem.AltText = "MichaelScott" respMixedElements = append(respMixedElements, imageBlockElem) case slack.MixedElementText: @@ -537,3 +542,57 @@ func unmarshalExample() { fmt.Println(string(b)) } + +// contextActionsExample demonstrates the context_actions block with feedback buttons and icon buttons +func contextActionsExample() { + // Section with AI-generated response + responseText := slack.NewTextBlockObject("mrkdwn", "*AI Assistant Response:*\nBased on your query, I recommend using the `context_actions` block for interactive feedback. This allows users to provide quick feedback on AI-generated content.", false, false) + responseSection := slack.NewSectionBlock(responseText, nil, nil) + + // Divider + divider := slack.NewDividerBlock() + + // Create feedback buttons for the AI response + positiveBtnText := slack.NewTextBlockObject("plain_text", "👍", false, false) + negativeBtnText := slack.NewTextBlockObject("plain_text", "👎", false, false) + positiveBtn := slack.NewFeedbackButton(positiveBtnText, "positive_feedback") + negativeBtn := slack.NewFeedbackButton(negativeBtnText, "negative_feedback") + feedbackElement := slack.NewFeedbackButtonsBlockElement("ai_feedback_1", positiveBtn, negativeBtn) + + // Create icon button for delete action + deleteText := slack.NewTextBlockObject("plain_text", "Delete", false, false) + iconButton := slack.NewIconButtonBlockElement("trash", deleteText, "delete_response_1"). + WithValue("response_123") + + // Context actions block with both feedback and delete + contextActionsBlock := slack.NewContextActionsBlock("actions_1", feedbackElement, iconButton) + + // Another example: just feedback buttons + anotherResponseText := slack.NewTextBlockObject("mrkdwn", "*Another AI Response:*\nHere's an alternative solution to your problem...", false, false) + anotherSection := slack.NewSectionBlock(anotherResponseText, nil, nil) + + goodBtnText := slack.NewTextBlockObject("plain_text", "Good", false, false) + badBtnText := slack.NewTextBlockObject("plain_text", "Bad", false, false) + goodBtn := slack.NewFeedbackButton(goodBtnText, "good").WithAccessibilityLabel("Mark this response as good") + badBtn := slack.NewFeedbackButton(badBtnText, "bad").WithAccessibilityLabel("Mark this response as bad") + simpleFeedback := slack.NewFeedbackButtonsBlockElement("ai_feedback_2", goodBtn, badBtn) + + simpleContextActions := slack.NewContextActionsBlock("actions_2", simpleFeedback) + + // Build Message with blocks created above + msg := slack.NewBlockMessage( + responseSection, + contextActionsBlock, + divider, + anotherSection, + simpleContextActions, + ) + + b, err := json.MarshalIndent(msg, "", " ") + if err != nil { + fmt.Println(err) + return + } + + fmt.Println(string(b)) +} diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index 82772faa1..6ccede3cc 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "flag" "fmt" "net/http" "os" @@ -10,16 +11,19 @@ import ( ) func main() { - var token, channel string - var ok bool - token, ok = os.LookupEnv("SLACK_TOKEN") - if !ok { - fmt.Println("Missing SLACK_TOKEN in environment") + channelID := flag.String("channel", "", "Channel ID (required)") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") os.Exit(1) } - channel, ok = os.LookupEnv("SLACK_CHANNEL") - if !ok { - fmt.Println("Missing SLACK_CHANNEL in environment") + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") os.Exit(1) } api := slack.New(token) @@ -46,11 +50,12 @@ func main() { } message := slack.MsgOptionAttachments(attachment) - channelID, timestamp, err := api.PostMessage(channel, slack.MsgOptionText("", false), message) + respChannelID, timestamp, err := api.PostMessage(*channelID, slack.MsgOptionText("", false), message) if err != nil { - fmt.Printf("Could not send message: %v", err) + fmt.Printf("Could not send message: %v\n", err) + os.Exit(1) } - 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", respChannelID, timestamp) http.HandleFunc("/actions", actionHandler) http.ListenAndServe(":3000", nil) } diff --git a/examples/chat_streaming/chat_streaming.go b/examples/chat_streaming/chat_streaming.go new file mode 100644 index 000000000..2c6996893 --- /dev/null +++ b/examples/chat_streaming/chat_streaming.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +// This example demonstrates using Slack's chat streaming API. +// It listens for app mentions and streams a response back in real-time. +// +// Required environment variables: +// - SLACK_APP_TOKEN: Your Slack app token (starts with xapp-) +// - SLACK_BOT_TOKEN: Your Slack bot token (starts with xoxb-) +// +// Required Slack app scopes: +// - app_mentions:read +// - chat:write +// +// Required Event Subscriptions: +// - app_mention + +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + panic("SLACK_APP_TOKEN must be set.\n") + } + + if !strings.HasPrefix(appToken, "xapp-") { + panic("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + panic("SLACK_BOT_TOKEN must be set.\n") + } + + if !strings.HasPrefix(botToken, "xoxb-") { + panic("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + socketmodeHandler := socketmode.NewSocketmodeHandler(client) + + // Handle app mentions + socketmodeHandler.HandleEvents(slackevents.AppMention, func(evt *socketmode.Event, clt *socketmode.Client) { + handleAppMention(evt, clt) + }) + + log.Println("Starting chat streaming bot...") + socketmodeHandler.RunEventLoop() +} + +func handleAppMention(evt *socketmode.Event, client *socketmode.Client) { + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + // Acknowledge the event + client.Ack(*evt.Request) + + ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) + if !ok { + fmt.Printf("Ignored: %+v\n", evt) + return + } + + log.Printf("Received app mention in channel %s: %s", ev.Channel, ev.Text) + + // Start the stream + channel, streamTS, err := client.Client.StartStream( + ev.Channel, + slack.MsgOptionTS(ev.TimeStamp), // Reply in thread + ) + if err != nil { + log.Printf("Failed to start stream: %v", err) + return + } + + log.Printf("Started stream in channel %s with timestamp %s", channel, streamTS) + + // Simulate a streaming response by breaking up a message into chunks + response := "Here's a streaming response! " + + "This example demonstrates how to use Slack's chat streaming API. " + + "The streaming API consists of three methods: " + + "StartStream to begin streaming, " + + "AppendStream to add content incrementally, " + + "and StopStream to finish the stream. " + + "This is perfect for AI-powered apps that generate responses progressively." + + // Stream the response in chunks + if err := streamResponse(&client.Client, channel, streamTS, response); err != nil { + log.Printf("Error during streaming: %v", err) + + // Try to stop stream with error message + _, _, _ = client.Client.StopStream( + channel, + streamTS, + slack.MsgOptionMarkdownText("\n\n_Error occurred while streaming response._"), + ) + return + } + + // Create feedback buttons + thumbsUpText := slack.NewTextBlockObject(slack.PlainTextType, "👍 Helpful", true, false) + thumbsDownText := slack.NewTextBlockObject(slack.PlainTextType, "👎 Not Helpful", true, false) + + feedbackButtons := slack.NewActionBlock( + "feedback", + slack.NewButtonBlockElement("thumbs_up", "thumbs_up", thumbsUpText), + slack.NewButtonBlockElement("thumbs_down", "thumbs_down", thumbsDownText), + ) + + // Stop the stream with feedback buttons + _, _, err = client.Client.StopStream( + channel, + streamTS, + slack.MsgOptionBlocks(feedbackButtons), + ) + if err != nil { + log.Printf("Failed to stop stream: %v", err) + return + } + + log.Printf("Successfully completed streaming response in channel %s", channel) +} + +// streamResponse demonstrates buffering and streaming text chunks to Slack +func streamResponse(api *slack.Client, channel, streamTS, response string) error { + const ( + chunkSize = 5 // Characters to simulate per "chunk" + bufferSize = 20 // Send to Slack when buffer reaches this size + delayMS = 50 // Milliseconds between chunks (simulates generation) + ) + + buffer := strings.Builder{} + words := strings.Split(response, " ") + + for i, word := range words { + // Add word and space to buffer + if i > 0 { + buffer.WriteString(" ") + } + buffer.WriteString(word) + + // Simulate streaming delay + time.Sleep(time.Duration(delayMS) * time.Millisecond) + + // Send to Slack when buffer reaches threshold or at the end + if buffer.Len() >= bufferSize || i == len(words)-1 { + _, _, err := api.AppendStream( + channel, + streamTS, + slack.MsgOptionMarkdownText(buffer.String()), + ) + if err != nil { + return fmt.Errorf("failed to append to stream: %w", err) + } + + log.Printf("Appended %d characters to stream", buffer.Len()) + buffer.Reset() + } + } + + return nil +} diff --git a/examples/connparams/connparams.go b/examples/connparams/connparams.go index 2bf2dce1d..c7437a1d6 100644 --- a/examples/connparams/connparams.go +++ b/examples/connparams/connparams.go @@ -1,20 +1,46 @@ package main import ( + "flag" "fmt" "log" "net/url" "os" + "strings" "github.com/slack-go/slack" ) func main() { - token, ok := os.LookupEnv("SLACK_TOKEN") - if !ok { - fmt.Println("Missing SLACK_TOKEN in environment") + channelID := flag.String("channel", "", "Channel ID (required)") + userIDs := flag.String("users", "", "Comma-separated user IDs for presence monitoring (required)") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + // Get user IDs from flag + if *userIDs == "" { + fmt.Println("User IDs are required: use -users flag (comma-separated)") os.Exit(1) } + + // Parse comma-separated user IDs + userIDList := strings.Split(*userIDs, ",") + for i, userID := range userIDList { + userIDList[i] = strings.TrimSpace(userID) + } + api := slack.New( token, slack.OptionDebug(true), @@ -31,17 +57,14 @@ func main() { fmt.Print("Event Received: ") switch ev := msg.Data.(type) { case *slack.HelloEvent: - // Replace USER-ID-N here with your User IDs - rtm.SendMessage(rtm.NewSubscribeUserPresence([]string{ - "USER-ID-1", - "USER-ID-2", - })) + // Subscribe to user presence using provided user IDs + rtm.SendMessage(rtm.NewSubscribeUserPresence(userIDList)) case *slack.ConnectedEvent: fmt.Println("Infos:", ev.Info) fmt.Println("Connection counter:", ev.ConnectionCount) - // Replace C2147483705 with your Channel ID - rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", "C2147483705")) + // Send message to provided channel ID + rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", *channelID)) case *slack.MessageEvent: fmt.Printf("Message: %v\n", ev) diff --git a/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go new file mode 100644 index 000000000..fc96b8ec0 --- /dev/null +++ b/examples/conversation_history/conversation_history.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + api := slack.New(token) + params := slack.GetConversationHistoryParameters{ + ChannelID: *channelID, + } + messages, err := api.GetConversationHistoryContext(context.Background(), ¶ms) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, message := range messages.Messages { + if len(message.Attachments) > 0 { + fmt.Printf("Message: %s\n", message.Attachments[0].Color) + } else { + fmt.Printf("Message: %s\n", message.Text) + } + } +} diff --git a/examples/conversations/conversations.go b/examples/conversations/conversations.go new file mode 100644 index 000000000..556fbaacb --- /dev/null +++ b/examples/conversations/conversations.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + userToken := os.Getenv("SLACK_USER_TOKEN") + if userToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n") + os.Exit(1) + } + + api := slack.New(userToken) + params := slack.GetConversationsParameters{ + ExcludeArchived: true, + Limit: 100, + } + channels, _, err := api.GetConversations(¶ms) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, channel := range channels { + info, err := api.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: channel.ID, + IncludeNumMembers: true, + IncludeLocale: true, + }) + if err != nil { + fmt.Printf("Error getting info for channel %s: %s\n", channel.ID, err) + continue + } + fmt.Printf("Channel: %s\n", channel.ID) + if info.Properties != nil { + fmt.Printf("Canvas: %+v\n", info.Properties.Canvas) + fmt.Printf("Tabs: %+v\n", info.Properties.Tabs) + } + } +} diff --git a/examples/conversations_invite/conversations_invite.go b/examples/conversations_invite/conversations_invite.go new file mode 100644 index 000000000..4a5a8d8be --- /dev/null +++ b/examples/conversations_invite/conversations_invite.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + userID := flag.String("user", "", "User ID to invite (required)") + + flag.Parse() + + // Get token from environment variable + userToken := os.Getenv("SLACK_USER_TOKEN") + if userToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + // Get user ID from flag + if *userID == "" { + fmt.Println("User ID is required: use -user flag") + os.Exit(1) + } + + api := slack.New(userToken) + _, err := api.InviteUsersToConversation(*channelID, *userID) + if err != nil { + if errorResponse, ok := errors.AsType[slack.SlackErrorResponse](err); ok { + for _, e := range errorResponse.Errors { + if e.ConversationsInviteResponseError != nil { + fmt.Fprintf(os.Stderr, "error inviting user (%s) to conversation: %s\n", e.ConversationsInviteResponseError.User, e.ConversationsInviteResponseError.Error) + } + } + } else { + fmt.Fprintf(os.Stderr, "error inviting user to conversation: %s\n", err.Error()) + } + os.Exit(1) + } + fmt.Println("User invited successfully to the conversation.") +} diff --git a/examples/dialog/dialog.go b/examples/dialog/dialog.go index 4b4d6ab18..9b83d0c1b 100644 --- a/examples/dialog/dialog.go +++ b/examples/dialog/dialog.go @@ -2,22 +2,37 @@ package main import ( "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" + "os" "strings" "github.com/slack-go/slack" ) -var api = slack.New("YOUR_TOKEN") -var signingSecret = "YOUR_SIGNING_SECRET" +var api *slack.Client +var signingSecret string // You can open a dialog with a user interaction. (like pushing buttons, slash commands ...) // https://api.slack.com/surfaces/modals // https://api.slack.com/interactivity/entry-points func main() { + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + log.Fatal("SLACK_BOT_TOKEN environment variable is required") + } + + // Get signing secret from environment variable + signingSecret = os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + log.Fatal("SLACK_SIGNING_SECRET environment variable is required") + } + + api = slack.New(token) + http.HandleFunc("/", handler) http.ListenAndServe(":3000", nil) } @@ -26,7 +41,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/README.md b/examples/eventsapi/README.md new file mode 100644 index 000000000..82e723402 --- /dev/null +++ b/examples/eventsapi/README.md @@ -0,0 +1,17 @@ +# Events Example + +This is a very simple example but should give you a glimpse of how to use the events API. + +## How to enable this + +1. Disable socket mode in the app if it's enabled: this will reveal the `Request URL`. +2. Set up the events `Request URL` in a way that matches the endpoint in + [events.go](./events.go). + + You can find this [here](https://api.slack.com/apps//event-subscriptions). +3. Set up the events you want to be subscribed to. +4. Copy the bot token and signing secret and set up the environment variables (per code). +5. Run the example: + ```bash + go run events.go + ``` diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go index 25049ad07..826f8c95f 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" @@ -11,14 +11,14 @@ import ( "github.com/slack-go/slack/slackevents" ) -// You more than likely want your "Bot User OAuth Access Token" which starts with "xoxb-" -var api = slack.New("TOKEN") - func main() { + botToken := os.Getenv("SLACK_BOT_TOKEN") signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + api := slack.New(botToken, slack.OptionDebug(true), slack.OptionLog(nil)) + 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 @@ -42,24 +42,34 @@ func main() { return } - if eventsAPIEvent.Type == slackevents.URLVerification { + fmt.Println("[INFO] Received event:", eventsAPIEvent.Type) + switch eventsAPIEvent.Type { + case slackevents.URLVerification: var r *slackevents.ChallengeResponse - err := json.Unmarshal([]byte(body), &r) + err := json.Unmarshal(body, &r) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text") w.Write([]byte(r.Challenge)) - } - if eventsAPIEvent.Type == slackevents.CallbackEvent { + case slackevents.CallbackEvent: innerEvent := eventsAPIEvent.InnerEvent + fmt.Println("[INFO] Received inner event:", innerEvent.Type) switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + case *slackevents.MessageEvent: + fmt.Printf("[INFO] Message from %s: %s\n", ev.User, ev.Text) + if len(ev.Blocks.BlockSet) > 0 { + fmt.Printf("[INFO] Message contains %d block(s):\n", len(ev.Blocks.BlockSet)) + for i, block := range ev.Blocks.BlockSet { + fmt.Printf("[INFO] Block %d: type=%s\n", i, block.BlockType()) + } + } } } }) - fmt.Println("[INFO] Server listening") + fmt.Println("[INFO] Server listening on :3000") http.ListenAndServe(":3000", nil) } diff --git a/examples/files/files.go b/examples/files/files.go index 0eb136a50..4af226034 100644 --- a/examples/files/files.go +++ b/examples/files/files.go @@ -1,30 +1,41 @@ package main import ( + "context" "fmt" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") - params := slack.FileUploadParameters{ - Title: "Batman Example", - //Filetype: "txt", - File: "example.txt", - //Content: "Nan Nan Nan Nan Nan Nan Nan Nan Batman", + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) } - file, err := api.UploadFile(params) + api := slack.New(token, slack.OptionDebug(true)) + + ctx := context.Background() + + // Upload a file + params := slack.UploadFileParameters{ + Title: "Batman Example", + Filename: "example.txt", + File: "example.txt", + FileSize: 38, + } + file, err := api.UploadFileContext(ctx, params) if err != nil { fmt.Printf("%s\n", err) return } - fmt.Printf("Name: %s, URL: %s\n", file.Name, file.URL) + fmt.Printf("ID: %s, title: %s\n", file.ID, file.Title) err = api.DeleteFile(file.ID) if err != nil { fmt.Printf("%s\n", err) return } - fmt.Printf("File %s deleted successfully.\n", file.Name) + fmt.Printf("File %s deleted successfully.\n", file.ID) } diff --git a/examples/files/multiple_files/batman.txt b/examples/files/multiple_files/batman.txt new file mode 100644 index 000000000..0e3504a57 --- /dev/null +++ b/examples/files/multiple_files/batman.txt @@ -0,0 +1 @@ +Nan Nan Nan Nan Nan Nan Nan Nan Batman diff --git a/examples/files/multiple_files/multiple_files.go b/examples/files/multiple_files/multiple_files.go new file mode 100644 index 000000000..0fbfe2fad --- /dev/null +++ b/examples/files/multiple_files/multiple_files.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + token, ok := os.LookupEnv("SLACK_BOT_TOKEN") + if !ok { + fmt.Println("Missing SLACK_BOT_TOKEN in environment") + os.Exit(1) + } + channelID := "CXXXXXXXX" // Replace with your channel ID + + api := slack.New(token, slack.OptionDebug(true)) + ctx := context.Background() + + files := []slack.UploadFileParameters{ + { + Title: "Batman Example", + Filename: "batman.txt", + File: "batman.txt", + FileSize: 39, + }, + { + Title: "Superman Example", + Filename: "superman.txt", + File: "superman.txt", + FileSize: 37, + }, + } + + uploads := []*slack.GetUploadURLExternalResponse{} + filesToComplete := []slack.FileSummary{} + + for _, file := range files { + u, err := api.GetUploadURLExternalContext(ctx, slack.GetUploadURLExternalParameters{ + AltTxt: "An alt text for superheroes", + FileName: file.Filename, + FileSize: file.FileSize, + }) + if err != nil { + fmt.Printf("%s\n", err) + return + } + uploads = append(uploads, u) + } + + for i, file := range files { + fmt.Printf("Uploading file %s to %s\n", file.Filename, uploads[i].UploadURL) + + err := api.UploadToURL(ctx, slack.UploadToURLParameters{ + UploadURL: uploads[i].UploadURL, + Filename: file.Filename, + File: file.File, + }) + if err != nil { + fmt.Printf("%s\n", err) + return + } + filesToComplete = append(filesToComplete, slack.FileSummary{ + ID: uploads[i].FileID, + Title: file.Title, + }) + } + + c, err := api.CompleteUploadExternalContext(ctx, slack.CompleteUploadExternalParameters{ + Files: filesToComplete, + Channel: channelID, + }) + + if err != nil { + fmt.Printf("%s\n", err) + return + } + + fmt.Printf("Files uploaded successfully: %+v\n", c.Files) +} diff --git a/examples/files/multiple_files/superman.txt b/examples/files/multiple_files/superman.txt new file mode 100644 index 000000000..62bb7239a --- /dev/null +++ b/examples/files/multiple_files/superman.txt @@ -0,0 +1 @@ +Whoosh Whoosh Whoosh Whoosh Superman diff --git a/examples/files_remote/files_remote.go b/examples/files_remote/files_remote.go new file mode 100644 index 000000000..9594d986f --- /dev/null +++ b/examples/files_remote/files_remote.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New(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..de9643fe7 --- /dev/null +++ b/examples/function/function.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "os" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +func main() { + // Get tokens from environment variables + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + fmt.Println("SLACK_APP_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionAppLevelToken(appToken), + ) + 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 + if ev, ok := innerEvent.Data.(*slackevents.FunctionExecutedEvent); ok { + callbackID := ev.Function.CallbackID + if callbackID == "sample_function" { + userId := ev.Inputs["user_id"] + payload := map[string]string{ + "user_id": userId.(string), + } + + 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..739937b07 --- /dev/null +++ b/examples/manifests/manifest.go @@ -0,0 +1,46 @@ +package main + +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/markdown/markdown.go b/examples/markdown/markdown.go new file mode 100644 index 000000000..c678e5c44 --- /dev/null +++ b/examples/markdown/markdown.go @@ -0,0 +1,65 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + channelID := flag.String("channel", "", "Channel ID (required)") + flag.Parse() + + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + api := slack.New(token) + + // Slack uses its own markdown-like syntax called mrkdwn. + // See https://api.slack.com/reference/surfaces/formatting for the full spec. + mrkdwnText := `*Bold text* and _italic text_ and ~strikethrough~ + +Inline ` + "`code`" + ` and a code block: +` + "```" + ` +func main() { + fmt.Println("Hello, Slack!") +} +` + "```" + ` + +A link: + +> A blockquote for emphasis + +And a list: +• First item +• Second item +• Third item` + + section := slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", mrkdwnText, false, false), + nil, + nil, + ) + + respChannelID, timestamp, err := api.PostMessage( + *channelID, + slack.MsgOptionText("Markdown formatting example (fallback text)", false), + slack.MsgOptionBlocks(section), + ) + if err != nil { + fmt.Printf("Error sending message: %s\n", err) + os.Exit(1) + } + + fmt.Printf("Message successfully sent to channel %s at %s\n", respChannelID, timestamp) +} diff --git a/examples/messages/messages.go b/examples/messages/messages.go index dd7f02da0..7993dd710 100644 --- a/examples/messages/messages.go +++ b/examples/messages/messages.go @@ -1,13 +1,31 @@ package main import ( + "flag" "fmt" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") + channelID := flag.String("channel", "", "Channel ID (required)") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") + os.Exit(1) + } + + api := slack.New(token) attachment := slack.Attachment{ Pretext: "some pretext", Text: "some text", @@ -22,8 +40,8 @@ func main() { */ } - channelID, timestamp, err := api.PostMessage( - "CHANNEL_ID", + respChannelID, timestamp, err := api.PostMessage( + *channelID, slack.MsgOptionText("Some text", false), slack.MsgOptionAttachments(attachment), slack.MsgOptionAsUser(true), // Add this if you want that the bot would post message as a user, otherwise it will send response using the default slackbot @@ -32,5 +50,5 @@ func main() { fmt.Printf("%s\n", err) return } - fmt.Printf("Message successfully sent to channel %s at %s", channelID, timestamp) + fmt.Printf("Message successfully sent to channel %s at %s", respChannelID, timestamp) } diff --git a/examples/modal/modal.go b/examples/modal/modal.go index 78bb60e03..636586338 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -5,7 +5,7 @@ // 3. This will send a request to http://URL/modal and send a greeting message to the user // Note: Within your slack app you will need to enable and provide a URL for "Interactivity & Shortcuts" and "Slash Commands" -// Note: Be sure to update YOUR_SIGNING_SECRET_HERE and YOUR_TOKEN_HERE +// Note: Set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET environment variables // You can use ngrok to test this example: https://api.slack.com/tutorials/tunneling-with-ngrok // Helpful slack documentation to learn more: https://api.slack.com/interactivity/handling @@ -15,8 +15,11 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" + "os" + + "time" "github.com/slack-go/slack" ) @@ -31,15 +34,17 @@ func generateModalRequest() slack.ModalViewRequest { headerSection := slack.NewSectionBlock(headerText, nil, nil) firstNameText := slack.NewTextBlockObject("plain_text", "First Name", false, false) + firstNameHint := slack.NewTextBlockObject("plain_text", "First Name Hint", false, false) firstNamePlaceholder := slack.NewTextBlockObject("plain_text", "Enter your first name", false, false) firstNameElement := slack.NewPlainTextInputBlockElement(firstNamePlaceholder, "firstName") // Notice that blockID is a unique identifier for a block - firstName := slack.NewInputBlock("First Name", firstNameText, firstNameElement) + firstName := slack.NewInputBlock("First Name", firstNameText, firstNameHint, firstNameElement) lastNameText := slack.NewTextBlockObject("plain_text", "Last Name", false, false) + lastNameHint := slack.NewTextBlockObject("plain_text", "Last Name Hint", false, false) lastNamePlaceholder := slack.NewTextBlockObject("plain_text", "Enter your first name", false, false) lastNameElement := slack.NewPlainTextInputBlockElement(lastNamePlaceholder, "lastName") - lastName := slack.NewInputBlock("Last Name", lastNameText, lastNameElement) + lastName := slack.NewInputBlock("Last Name", lastNameText, lastNameHint, lastNameElement) blocks := slack.Blocks{ BlockSet: []slack.Block{ @@ -58,23 +63,51 @@ func generateModalRequest() slack.ModalViewRequest { return modalRequest } -// This was taken from the slash example -// https://github.com/slack-go/slack/blob/master/examples/slash/slash.go +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 +} + func verifySigningSecret(r *http.Request) error { - signingSecret := "YOUR_SIGNING_SECRET_HERE" + // Get signing secret from environment variable + signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + fmt.Println("SLACK_SIGNING_SECRET environment variable is required") + os.Exit(1) + } + verifier, err := slack.NewSecretsVerifier(r.Header, signingSecret) if err != nil { fmt.Println(err.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 { @@ -85,72 +118,87 @@ func verifySigningSecret(r *http.Request) error { return nil } -func handleSlash(w http.ResponseWriter, r *http.Request) { - - err := verifySigningSecret(r) - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } - - s, err := slack.SlashCommandParse(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Println(err.Error()) - return - } - - switch s.Command { - case "/humboldttest": - api := slack.New("YOUR_TOKEN_HERE") - modalRequest := generateModalRequest() - _, err = api.OpenView(s.TriggerID, modalRequest) +func handleSlash(token string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + err := verifySigningSecret(r) if err != nil { - fmt.Printf("Error opening view: %s", err) + fmt.Printf("%s", err.Error()) + w.WriteHeader(http.StatusUnauthorized) + return } - default: - w.WriteHeader(http.StatusInternalServerError) - return - } -} - -func handleModal(w http.ResponseWriter, r *http.Request) { - err := verifySigningSecret(r) - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } + s, err := slack.SlashCommandParse(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Println(err.Error()) + return + } - var i slack.InteractionCallback - err = json.Unmarshal([]byte(r.FormValue("payload")), &i) - if err != nil { - fmt.Printf(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return + switch s.Command { + case "/slash": + api := slack.New(token) + modalRequest := generateModalRequest() + _, err = api.OpenView(s.TriggerID, modalRequest) + if err != nil { + fmt.Printf("Error opening view: %s", err) + } + default: + w.WriteHeader(http.StatusInternalServerError) + 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 +func handleModal(token string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + err := verifySigningSecret(r) + if err != nil { + fmt.Printf("%s", err.Error()) + w.WriteHeader(http.StatusUnauthorized) + return + } - msg := fmt.Sprintf("Hello %s %s, nice to meet you!", firstName, lastName) + var i slack.InteractionCallback + err = json.Unmarshal([]byte(r.FormValue("payload")), &i) + if err != nil { + fmt.Printf("%s", err.Error()) + w.WriteHeader(http.StatusUnauthorized) + return + } - 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 + api := slack.New(token) + + // 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 + } + default: + fmt.Println("Fallback case") + } } } func main() { - http.HandleFunc("/slash", handleSlash) - http.HandleFunc("/modal", handleModal) + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + http.HandleFunc("/slash", handleSlash(token)) + http.HandleFunc("/modal", handleModal(token)) http.ListenAndServe(":4390", nil) } diff --git a/examples/modal_users/users.go b/examples/modal_users/users.go index 8f1d36f79..578ee51f6 100644 --- a/examples/modal_users/users.go +++ b/examples/modal_users/users.go @@ -20,12 +20,12 @@ func main() { // Only the inputs in input blocks will be included in view_submission’s view.state.values: https://slack.dev/java-slack-sdk/guides/modals // This means the inputs will not be interactive either because they do not trigger block_actions messages: https://api.slack.com/surfaces/modals/using#interactions channelNameText := slack.NewTextBlockObject(slack.PlainTextType, "Channel Name", false, false) + channelNameHint := slack.NewTextBlockObject(slack.PlainTextType, "Channel names may only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less", false, false) channelPlaceholder := slack.NewTextBlockObject(slack.PlainTextType, "New channel name", false, false) channelNameElement := slack.NewPlainTextInputBlockElement(channelPlaceholder, "channel_name") // Slack channel names can be maximum 80 characters: https://api.slack.com/methods/conversations.create channelNameElement.MaxLength = 80 - channelNameBlock := slack.NewInputBlock("channel_name", channelNameText, channelNameElement) - channelNameBlock.Hint = slack.NewTextBlockObject(slack.PlainTextType, "Channel names may only contain lowercase letters, numbers, hyphens, and underscores, and must be 80 characters or less", false, false) + channelNameBlock := slack.NewInputBlock("channel_name", channelNameText, channelNameHint, channelNameElement) // Provide a static list of users to choose from, those provided now are just made up user IDs // Get user IDs by right clicking on them in Slack, select "Copy link", and inspect the last part of the link @@ -33,30 +33,32 @@ func main() { memberOptions := createOptionBlockObjects([]string{"U9911MMAA", "U2233KKNN", "U00112233"}, true) inviteeText := slack.NewTextBlockObject(slack.PlainTextType, "Invitee from static list", false, false) inviteeOption := slack.NewOptionsSelectBlockElement(slack.OptTypeStatic, nil, "invitee", memberOptions...) - inviteeBlock := slack.NewInputBlock("invitee", inviteeText, inviteeOption) + inviteeBlock := slack.NewInputBlock("invitee", inviteeText, nil, inviteeOption) // Section with users select - this input will not be included in the view_submission's view.state.values, // but instead be sent as a "block_actions" request additionalInviteeText := slack.NewTextBlockObject(slack.PlainTextType, "Invitee from complete list of users", false, false) + additionalInviteeHintText := slack.NewTextBlockObject(slack.PlainTextType, "", false, false) additionalInviteeOption := slack.NewOptionsSelectBlockElement(slack.OptTypeUser, additionalInviteeText, "user") additionalInviteeSection := slack.NewSectionBlock(additionalInviteeText, nil, slack.NewAccessory(additionalInviteeOption)) // Input with users select - this input will be included in the view_submission's view.state.values // It can be fetched as for example "payload.View.State.Values["user"]["user"].SelectedUser" - additionalInviteeBlock := slack.NewInputBlock("user", additionalInviteeText, additionalInviteeOption) + additionalInviteeBlock := slack.NewInputBlock("user", additionalInviteeText, additionalInviteeHintText, additionalInviteeOption) checkboxTxt := slack.NewTextBlockObject(slack.PlainTextType, "Checkbox", false, false) checkboxOptions := createOptionBlockObjects([]string{"option 1", "option 2", "option 3"}, false) checkboxOptionsBlock := slack.NewCheckboxGroupsBlockElement("chkbox", checkboxOptions...) - checkboxBlock := slack.NewInputBlock("chkbox", checkboxTxt, checkboxOptionsBlock) + checkboxBlock := slack.NewInputBlock("chkbox", checkboxTxt, nil, checkboxOptionsBlock) summaryText := slack.NewTextBlockObject(slack.PlainTextType, "Summary", false, false) + summaryHint := slack.NewTextBlockObject(slack.PlainTextType, "Summary Hint", false, false) summaryPlaceholder := slack.NewTextBlockObject(slack.PlainTextType, "Summary of reason for creating channel", false, false) summaryElement := slack.NewPlainTextInputBlockElement(summaryPlaceholder, "summary") // Just set an arbitrary max length to avoid too prose summary summaryElement.MaxLength = 200 summaryElement.Multiline = true - summaryBlock := slack.NewInputBlock("summary", summaryText, summaryElement) + summaryBlock := slack.NewInputBlock("summary", summaryText, summaryHint, summaryElement) blocks := slack.Blocks{ BlockSet: []slack.Block{ diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go new file mode 100644 index 000000000..f2523ff31 --- /dev/null +++ b/examples/pagination/pagination.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "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 { + if rateLimited, ok := errors.AsType[*slack.RateLimitedError](failedErr); ok && 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() { + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + client := slack.New(token) + + uids, err := getAllUserUIDs(context.Background(), client, 1000) + if err != nil { + panic(err) + } + + fmt.Printf("Collected %d UIDs\n", len(uids)) +} diff --git a/examples/parse_action_event/main.go b/examples/parse_action_event/main.go new file mode 100644 index 000000000..ff298d5d9 --- /dev/null +++ b/examples/parse_action_event/main.go @@ -0,0 +1,196 @@ +// This example shows how to handle both Events API and Interactions API +// using HTTP endpoints. It listens for messages (Events API), replies with +// a button (Block Kit), and handles the button click (Interactions API). +// +// This is also a migration guide for users of slackevents.ParseActionEvent, +// which is deprecated because it cannot parse block_actions payloads. The +// correct approach is to use slack.InteractionCallback directly: +// +// // Before (broken for block_actions): +// action, err := slackevents.ParseActionEvent(payload, slackevents.OptionNoVerifyToken()) +// +// // After (handles all interaction types): +// var ic slack.InteractionCallback +// err := json.Unmarshal([]byte(payload), &ic) +// // Use ic.ActionCallback.BlockActions for block actions +// // Use ic.ActionCallback.AttachmentActions for legacy attachment actions +// +// Note: block_actions are delivered to your Interactivity Request URL, not +// your Events API Request URL. These are two separate endpoints in your +// Slack app configuration. +// +// Setup: +// 1. export SLACK_BOT_TOKEN=xoxb-... +// 2. export SLACK_SIGNING_SECRET=... +// 3. go run ./examples/parse_action_event +// 4. Expose port 3000 with ngrok: ngrok http 3000 +// 5. In your Slack app config: +// - Events API Request URL: https:///events +// - Interactivity Request URL: https:///interactions +// - Subscribe to bot events: message.channels (so the bot can post) +// - Bot scopes: chat:write, channels:read +// 6. Invite the bot to a channel, then send any message — the bot will +// reply with a message containing a block action button. +// 7. Click the button and watch the logs. +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" +) + +func main() { + botToken := os.Getenv("SLACK_BOT_TOKEN") + signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + if botToken == "" || signingSecret == "" { + fmt.Fprintln(os.Stderr, "Set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET") + os.Exit(1) + } + + api := slack.New(botToken) + + // Events API endpoint — receives subscription events (message, app_mention, etc.) + http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + sv, err := slack.NewSecretsVerifier(r.Header, signingSecret) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if _, err := sv.Write(body); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if err := sv.Ensure(); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) + if err != nil { + fmt.Printf("[EVENTS] parse error: %v\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + fmt.Printf("[EVENTS] received type=%q\n", eventsAPIEvent.Type) + + switch eventsAPIEvent.Type { + case slackevents.URLVerification: + var cr *slackevents.ChallengeResponse + if err := json.Unmarshal(body, &cr); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(cr.Challenge)) + + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + fmt.Printf("[EVENTS] inner type=%q\n", innerEvent.Type) + + if ev, ok := innerEvent.Data.(*slackevents.MessageEvent); ok { + // Ignore bot messages to avoid loops. + if ev.BotID != "" { + return + } + fmt.Printf("[EVENTS] message from user %s: %q\n", ev.User, ev.Text) + + // Reply with a message containing a block action button. + _, _, err := api.PostMessage(ev.Channel, slack.MsgOptionBlocks( + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", "Click the button to test block_actions delivery:", false, false), + nil, nil, + ), + slack.NewActionBlock("test_actions_block", + slack.NewButtonBlockElement("test_button", "clicked", + slack.NewTextBlockObject("plain_text", "Click me", false, false), + ), + ), + )) + if err != nil { + fmt.Printf("[EVENTS] PostMessage error: %v\n", err) + } + } + } + }) + + // Interactions endpoint — receives interactive callbacks (block_actions, + // interactive_message, view_submission, etc.). + // + // If you were previously using slackevents.ParseActionEvent, this is the + // correct replacement: unmarshal into slack.InteractionCallback and switch + // on ic.Type. + http.HandleFunc("/interactions", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + sv, err := slack.NewSecretsVerifier(r.Header, signingSecret) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if _, err := sv.Write(body); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if err := sv.Ensure(); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Interactions come as form-encoded with a "payload" field. + payload, err := url.QueryUnescape(string(body)) + if err != nil { + fmt.Printf("[INTERACTIONS] unescape error: %v\n", err) + w.WriteHeader(http.StatusBadRequest) + return + } + // Strip the "payload=" prefix. + const prefix = "payload=" + if len(payload) > len(prefix) { + payload = payload[len(prefix):] + } + + var ic slack.InteractionCallback + if err := json.Unmarshal([]byte(payload), &ic); err != nil { + fmt.Printf("[INTERACTIONS] parse error: %v\n", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + fmt.Printf("[INTERACTIONS] type=%q callback_id=%q\n", ic.Type, ic.CallbackID) + switch ic.Type { + case slack.InteractionTypeBlockActions: + for i, a := range ic.ActionCallback.BlockActions { + fmt.Printf("[INTERACTIONS] block_action[%d]: action_id=%q block_id=%q type=%q value=%q\n", + i, a.ActionID, a.BlockID, a.Type, a.Value) + } + case slack.InteractionTypeInteractionMessage: + for i, a := range ic.ActionCallback.AttachmentActions { + fmt.Printf("[INTERACTIONS] attachment_action[%d]: name=%q type=%q value=%q\n", + i, a.Name, a.Type, a.Value) + } + } + }) + + fmt.Println("[INFO] Listening on :3000") + fmt.Println("[INFO] Events API: http://localhost:3000/events") + fmt.Println("[INFO] Interactions: http://localhost:3000/interactions") + http.ListenAndServe(":3000", nil) +} diff --git a/examples/pins/pins.go b/examples/pins/pins.go index d13d2d2c8..f4604dafc 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -3,24 +3,24 @@ package main import ( "flag" "fmt" + "os" "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 - debug bool - ) - - flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") - flag.BoolVar(&debug, "debug", false, "Show JSON output") + debug := flag.Bool("debug", false, "Show JSON output") flag.Parse() - api := slack.New(apiToken, slack.OptionDebug(debug)) + // Get token from environment variable + apiToken := os.Getenv("SLACK_BOT_TOKEN") + if apiToken == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New(apiToken, slack.OptionDebug(*debug)) var ( postAsUserName string @@ -43,7 +43,7 @@ func main() { postAsUserID = authTest.UserID // Create a temporary channel - channel, err := api.CreateConversation(channelName, false) + channel, err := api.CreateConversation(slack.CreateConversationParams{ChannelName: channelName}) if err != nil { // If the channel exists, that means we just need to unarchive it diff --git a/examples/reactions/reactions.go b/examples/reactions/reactions.go index 4e19f508a..eede3d413 100644 --- a/examples/reactions/reactions.go +++ b/examples/reactions/reactions.go @@ -3,28 +3,28 @@ package main import ( "flag" "fmt" + "os" "github.com/slack-go/slack" ) func main() { - var ( - apiToken string - debug bool - ) - - flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") - flag.BoolVar(&debug, "debug", false, "Show JSON output") + debug := flag.Bool("debug", false, "Show JSON output") + channelID := flag.String("channel", "", "Channel ID (required)") flag.Parse() - api := slack.New(apiToken, slack.OptionDebug(debug)) + // Get token from environment variable + apiToken := os.Getenv("SLACK_USER_TOKEN") + if apiToken == "" { + fmt.Println("SLACK_USER_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New(apiToken, slack.OptionDebug(*debug)) var ( - postAsUserName string - postAsUserID string - postToUserName string - postToUserID string - postToChannelID string + postAsUserName string + postAsUserID string ) // Find the user to post as. @@ -38,29 +38,19 @@ func main() { postAsUserName = authTest.User postAsUserID = authTest.UserID - // Posting to DM with self causes a conversation with slackbot. - postToUserName = authTest.User - postToUserID = authTest.UserID - - // Find the channel. - channel, _, _, err := api.OpenConversation(&slack.OpenConversationParameters{ChannelID: postToUserID}) - if err != nil { - fmt.Printf("Error opening IM: %s\n", err) - return - } - postToChannelID = channel.ID - - fmt.Printf("Posting as %s (%s) in DM with %s (%s), channel %s\n", postAsUserName, postAsUserID, postToUserName, postToUserID, postToChannelID) + fmt.Printf("Posting as %s (%s) in channel %s\n", postAsUserName, postAsUserID, *channelID) // Post a message. - channelID, timestamp, err := api.PostMessage(postToChannelID, slack.MsgOptionText("Is this any good?", false)) + _, timestamp, err := api.PostMessage(*channelID, slack.MsgOptionText("Is this any good?", false)) if err != nil { fmt.Printf("Error posting message: %s\n", err) return } - // Grab a reference to the message. - msgRef := slack.NewRefToMessage(channelID, timestamp) + // // Grab a reference to the message. + msgRef := slack.NewRefToMessage(*channelID, timestamp) + + fmt.Printf("Adding reaction to message with reference %v\n", msgRef) // React with :+1: if err = api.AddReaction("+1", msgRef); err != nil { @@ -75,21 +65,27 @@ func main() { } // Get all reactions on the message. - msgReactions, err := api.GetReactions(msgRef, slack.NewGetReactionsParameters()) + msgReactionsResp, err := api.GetReactions(msgRef, slack.NewGetReactionsParameters()) if err != nil { fmt.Printf("Error getting reactions: %s\n", err) return } fmt.Printf("\n") - fmt.Printf("%d reactions to message...\n", len(msgReactions)) - for _, r := range msgReactions { - fmt.Printf(" %d users say %s\n", r.Count, r.Name) + fmt.Printf("%d reactions to message...\n", len(msgReactionsResp.Reactions)) + for _, r := range msgReactionsResp.Reactions { + fmt.Printf(" %d users say %s in channel %s\n", r.Count, r.Name, msgReactionsResp.Item.Channel) } // List all of the users reactions. - listReactions, _, err := api.ListReactions(slack.NewListReactionsParameters()) + listParams := slack.NewListReactionsParameters() + fmt.Printf("Listing reactions with params: User=%q, TeamID=%q, Cursor=%q, Limit=%d, Full=%v\n", + listParams.User, listParams.TeamID, listParams.Cursor, listParams.Limit, listParams.Full) + listReactions, _, err := api.ListReactions(listParams) if err != nil { - fmt.Printf("Error listing reactions: %s\n", err) + fmt.Printf("Error listing reactions: %v\n", err) + if slackErr, ok := err.(slack.SlackErrorResponse); ok { + fmt.Printf(" ResponseMetadata.Messages: %v\n", slackErr.ResponseMetadata.Messages) + } return } fmt.Printf("\n") @@ -109,14 +105,14 @@ func main() { } // Get all reactions on the message. - msgReactions, err = api.GetReactions(msgRef, slack.NewGetReactionsParameters()) + msgReactionsResp, err = api.GetReactions(msgRef, slack.NewGetReactionsParameters()) if err != nil { fmt.Printf("Error getting reactions: %s\n", err) return } fmt.Printf("\n") - fmt.Printf("%d reactions to message after removing cry...\n", len(msgReactions)) - for _, r := range msgReactions { + fmt.Printf("%d reactions to message after removing cry...\n", len(msgReactionsResp.Reactions)) + for _, r := range msgReactionsResp.Reactions { fmt.Printf(" %d users say %s\n", r.Count, r.Name) } } diff --git a/examples/remotefiles/remotefiles.go b/examples/remotefiles/remotefiles.go new file mode 100644 index 000000000..53569e19c --- /dev/null +++ b/examples/remotefiles/remotefiles.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN_HERE") + r, err := os.Open("slack-go.png") + if err != nil { + fmt.Printf("%s\n", err) + return + } + defer r.Close() + remotefile, err := api.AddRemoteFile(slack.RemoteFileParameters{ + ExternalID: "slack-go", + ExternalURL: "https://github.com/slack-go/slack", + Title: "slack-go", + Filetype: "go", + IndexableFileContents: "golang, slack", + // PreviewImage: "slack-go.png", + PreviewImageReader: r, + }) + if err != nil { + fmt.Printf("add remote file failed: %s\n", err) + return + } + fmt.Printf("remote file: %v\n", remotefile) + + _, err = api.ShareRemoteFile([]string{"CPB8DC1CM"}, remotefile.ExternalID, "") + if err != nil { + fmt.Printf("share remote file failed: %s\n", err) + return + } + fmt.Printf("share remote file %s successfully.\n", remotefile.Name) + + remotefiles, err := api.ListRemoteFiles(slack.ListRemoteFilesParameters{ + Channel: "YOUR_CHANNEL_HERE", + }) + if err != nil { + fmt.Printf("list remote files failed: %s\n", err) + return + } + fmt.Printf("remote files: %v\n", remotefiles) + + remotefile, err = api.UpdateRemoteFile(remotefile.ID, slack.RemoteFileParameters{ + ExternalID: "slack-go", + ExternalURL: "https://github.com/slack-go/slack", + Title: "slack-go", + Filetype: "go", + IndexableFileContents: "golang, slack, github", + }) + if err != nil { + fmt.Printf("update remote file failed: %s\n", err) + return + } + fmt.Printf("remote file: %v\n", remotefile) + + info, err := api.GetRemoteFileInfo(remotefile.ExternalID, "") + if err != nil { + fmt.Printf("get remote file info failed: %s\n", err) + return + } + fmt.Printf("remote file info: %v\n", info) + + err = api.RemoveRemoteFile(remotefile.ExternalID, "") + if err != nil { + fmt.Printf("remove remote file failed: %s\n", err) + return + } + fmt.Printf("remote file %s deleted successfully.\n", remotefile.Name) +} diff --git a/examples/remotefiles/slack-go.png b/examples/remotefiles/slack-go.png new file mode 100644 index 000000000..51d93bc67 Binary files /dev/null and b/examples/remotefiles/slack-go.png differ diff --git a/examples/rtm_call_events/rtm_call_events.go b/examples/rtm_call_events/rtm_call_events.go new file mode 100644 index 000000000..4c717698a --- /dev/null +++ b/examples/rtm_call_events/rtm_call_events.go @@ -0,0 +1,66 @@ +// This example connects via RTM and prints Slack Call/Huddle room events +// (sh_room_join, sh_room_leave). Start a call or huddle in a channel where +// the bot is present to see the events. +// +// To run: +// +// export SLACK_BOT_TOKEN=xoxb-... +// go run examples/rtm_call_events/rtm_call_events.go +package main + +import ( + "fmt" + "log" + "os" + + "github.com/slack-go/slack" +) + +func main() { + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New( + token, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "rtm: ", log.Lshortfile|log.LstdFlags)), + ) + + rtm := api.NewRTM() + go rtm.ManageConnection() + + fmt.Println("Listening for call/huddle events... start a call in a channel where this bot is present.") + + for msg := range rtm.IncomingEvents { + switch ev := msg.Data.(type) { + case *slack.ConnectedEvent: + fmt.Printf("Connected as %s\n", ev.Info.User.Name) + + case *slack.SHRoomJoinEvent: + fmt.Printf("User %s joined call in room %s (channels: %v, participants: %v)\n", + ev.User, ev.Room.ID, ev.Room.Channels, ev.Room.Participants) + + case *slack.SHRoomLeaveEvent: + fmt.Printf("User %s left call in room %s (remaining: %v)\n", + ev.User, ev.Room.ID, ev.Room.Participants) + + case *slack.SHRoomUpdateEvent: + name := "" + if ev.Room.Name != nil { + name = *ev.Room.Name + } + fmt.Printf("Room %s updated: %q (family: %s, participants: %v)\n", + ev.Room.ID, name, ev.Room.CallFamily, ev.Room.Participants) + + case *slack.RTMError: + fmt.Printf("RTM Error: %s\n", ev.Error()) + + case *slack.InvalidAuthEvent: + fmt.Fprintln(os.Stderr, "Invalid credentials") + return + } + } +} diff --git a/examples/slash/slash.go b/examples/slash/slash.go index c70c865ff..9f75841f0 100644 --- a/examples/slash/slash.go +++ b/examples/slash/slash.go @@ -2,32 +2,30 @@ package main import ( "encoding/json" - "flag" "fmt" "io" - "io/ioutil" "net/http" + "os" "github.com/slack-go/slack" ) func main() { - var ( - signingSecret string - ) - - flag.StringVar(&signingSecret, "secret", "YOUR_SIGNING_SECRET_HERE", "Your Slack app's signing secret") - flag.Parse() + // Get signing secret from environment variable + signingSecret := os.Getenv("SLACK_SIGNING_SECRET") + if signingSecret == "" { + fmt.Println("SLACK_SIGNING_SECRET environment variable is required") + os.Exit(1) + } http.HandleFunc("/slash", func(w http.ResponseWriter, r *http.Request) { - verifier, err := slack.NewSecretsVerifier(r.Header, signingSecret) if err != nil { w.WriteHeader(http.StatusInternalServerError) 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/large_payload_ack/large_payload_ack.go b/examples/socketmode/large_payload_ack/large_payload_ack.go new file mode 100644 index 000000000..e8861aa28 --- /dev/null +++ b/examples/socketmode/large_payload_ack/large_payload_ack.go @@ -0,0 +1,108 @@ +// This example demonstrates Socket Mode's behavior with large Ack payloads. +// +// Slack's Socket Mode silently drops WebSocket responses that are 20KB or +// larger. The library detects this and returns an error from Ack(). +// +// To run: +// +// export SLACK_APP_TOKEN=xapp-... +// export SLACK_BOT_TOKEN=xoxb-... +// go run examples/socketmode/large_payload_ack/large_payload_ack.go +// +// Then use your slash command with a byte count as the argument: +// +// /your-command 1000 -> small payload, works +// /your-command 19000 -> near the 20KB limit, works +// /your-command 21000 -> over the limit, Ack() returns an error +// /your-command -> defaults to 100 bytes +package main + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" +) + +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN environment variable is required\n") + os.Exit(1) + } + + if !strings.HasPrefix(appToken, "xapp-") { + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\"\n") + os.Exit(1) + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n") + os.Exit(1) + } + + if !strings.HasPrefix(botToken, "xoxb-") { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\"\n") + os.Exit(1) + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeConnecting: + fmt.Println("Connecting to Slack with Socket Mode...") + case socketmode.EventTypeConnectionError: + fmt.Println("Connection failed. Retrying later...") + case socketmode.EventTypeConnected: + fmt.Println("Connected to Slack with Socket Mode.") + case socketmode.EventTypeSlashCommand: + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + continue + } + + size := 100 + if cmd.Text != "" { + if n, err := strconv.Atoi(strings.TrimSpace(cmd.Text)); err == nil && n > 0 { + size = n + } + } + + payload := map[string]any{ + "text": fmt.Sprintf("[%d bytes] %s", size, strings.Repeat("x", size)), + } + + if err := client.Ack(*evt.Request, payload); err != nil { + fmt.Printf("Ack() error: %v\n", err) + fmt.Println("Use the Web API (e.g. chat.PostMessage) for large payloads.") + + // Ack without payload so Slack knows we received the event. + client.Ack(*evt.Request) + } + + default: + } + } + }() + + client.Run() +} diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index 6f1e65b48..c2f7ae6ba 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -1,3 +1,24 @@ +// This example demonstrates a basic Socket Mode client that listens for +// Events API events, interactive components, and slash commands. +// +// Socket Mode requires two tokens: +// +// - App-level token (xapp-…): opens the WebSocket connection via +// apps.connections.open. Generate one in your app settings under +// Basic Information → App-Level Tokens with the connections:write scope. +// +// - Bot token (xoxb-…): used for all Web API calls (posting messages, +// opening views, etc.). This is the token you get after installing the +// app to a workspace. +// +// The bot token is passed to slack.New() as the primary credential; the +// app-level token is passed via slack.OptionAppLevelToken(). +// +// To run: +// +// export SLACK_APP_TOKEN=xapp-... +// export SLACK_BOT_TOKEN=xoxb-... +// go run examples/socketmode/socketmode.go package main import ( @@ -6,31 +27,32 @@ import ( "os" "strings" - "github.com/slack-go/slack/socketmode" - "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" ) func main() { appToken := os.Getenv("SLACK_APP_TOKEN") if appToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN environment variable is required\n") os.Exit(1) } if !strings.HasPrefix(appToken, "xapp-") { - fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\".") + fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\"\n") + os.Exit(1) } botToken := os.Getenv("SLACK_BOT_TOKEN") if botToken == "" { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n") + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n") os.Exit(1) } if !strings.HasPrefix(botToken, "xoxb-") { - fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\"\n") + os.Exit(1) } api := slack.New( @@ -72,10 +94,18 @@ 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) } + case *slackevents.MessageEvent: + fmt.Printf("Message from %s: %s\n", ev.User, ev.Text) + if len(ev.Blocks.BlockSet) > 0 { + fmt.Printf("Message contains %d block(s):\n", len(ev.Blocks.BlockSet)) + for i, block := range ev.Blocks.BlockSet { + fmt.Printf(" Block %d: type=%s\n", i, block.BlockType()) + } + } case *slackevents.MemberJoinedChannelEvent: fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) } @@ -92,7 +122,7 @@ func main() { fmt.Printf("Interaction received: %+v\n", callback) - var payload interface{} + var payload any switch callback.Type { case slack.InteractionTypeBlockActions: @@ -118,7 +148,7 @@ func main() { client.Debugf("Slash command received: %+v", cmd) - payload := map[string]interface{}{ + payload := map[string]any{ "blocks": []slack.Block{ slack.NewSectionBlock( &slack.TextBlockObject{ @@ -137,9 +167,12 @@ func main() { ), ), ), - }} + }, + } client.Ack(*evt.Request, payload) + case socketmode.EventTypeHello: + client.Debugf("Hello received!") default: fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) } diff --git a/examples/socketmode_handler/socketmode_handler.go b/examples/socketmode_handler/socketmode_handler.go new file mode 100644 index 000000000..04105ed5b --- /dev/null +++ b/examples/socketmode_handler/socketmode_handler.go @@ -0,0 +1,230 @@ +// This example demonstrates the SocketmodeHandler, a higher-level API that +// routes Socket Mode events to registered handler functions instead of +// requiring a manual event-loop switch. +// +// Socket Mode requires two tokens: +// +// - App-level token (xapp-…): opens the WebSocket connection via +// apps.connections.open. Generate one in your app settings under +// Basic Information → App-Level Tokens with the connections:write scope. +// +// - Bot token (xoxb-…): used for all Web API calls (posting messages, +// opening views, etc.). This is the token you get after installing the +// app to a workspace. +// +// The bot token is passed to slack.New() as the primary credential; the +// app-level token is passed via slack.OptionAppLevelToken(). +// +// To run: +// +// export SLACK_APP_TOKEN=xapp-... +// export SLACK_BOT_TOKEN=xoxb-... +// go run examples/socketmode_handler/socketmode_handler.go +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +func main() { + appToken := os.Getenv("SLACK_APP_TOKEN") + if appToken == "" { + panic("SLACK_APP_TOKEN must be set.\n") + } + + if !strings.HasPrefix(appToken, "xapp-") { + panic("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + } + + botToken := os.Getenv("SLACK_BOT_TOKEN") + if botToken == "" { + panic("SLACK_BOT_TOKEN must be set.\n") + } + + if !strings.HasPrefix(botToken, "xoxb-") { + panic("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + } + + api := slack.New( + botToken, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(true), + socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + ) + + socketmodeHandler := socketmode.NewSocketmodeHandler(client) + + socketmodeHandler.Handle(socketmode.EventTypeConnecting, middlewareConnecting) + socketmodeHandler.Handle(socketmode.EventTypeConnectionError, middlewareConnectionError) + socketmodeHandler.Handle(socketmode.EventTypeConnected, middlewareConnected) + socketmodeHandler.Handle(socketmode.EventTypeHello, middlewareHello) + + // EventTypeEventsAPI: handle all EventsAPI + socketmodeHandler.Handle(socketmode.EventTypeEventsAPI, middlewareEventsAPI) + + // Handle a specific event from EventsAPI + socketmodeHandler.HandleEvents(slackevents.AppMention, middlewareAppMentionEvent) + + // EventTypeInteractive: handle all Interactive Events + socketmodeHandler.Handle(socketmode.EventTypeInteractive, middlewareInteractive) + + // Handle a specific Interaction + socketmodeHandler.HandleInteraction(slack.InteractionTypeBlockActions, middlewareInteractionTypeBlockActions) + + // Handle all SlashCommand + socketmodeHandler.Handle(socketmode.EventTypeSlashCommand, middlewareSlashCommand) + socketmodeHandler.HandleSlashCommand("/rocket", middlewareSlashCommand) + + // Handle all other events + socketmodeHandler.HandleDefault(middlewareDefault) + + socketmodeHandler.RunEventLoop() +} + +func middlewareConnecting(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("Connecting to Slack with Socket Mode...") +} + +func middlewareConnectionError(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("Connection failed. Retrying later...") +} + +func middlewareConnected(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("Connected to Slack with Socket Mode.") +} + +func middlewareHello(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("Received a hello message. Howdy to you too.") +} + +func middlewareEventsAPI(evt *socketmode.Event, client *socketmode.Client) { + fmt.Println("middlewareEventsAPI") + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + 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.AppMentionEvent: + fmt.Printf("We have been mentioned in %v", ev.Channel) + _, _, err := client.Client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + if err != nil { + fmt.Printf("failed posting message: %v", err) + } + case *slackevents.MemberJoinedChannelEvent: + fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) + } + default: + client.Debugf("unsupported Events API event received") + } +} + +func middlewareAppMentionEvent(evt *socketmode.Event, client *socketmode.Client) { + + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + client.Ack(*evt.Request) + + ev, ok := eventsAPIEvent.InnerEvent.Data.(*slackevents.AppMentionEvent) + if !ok { + fmt.Printf("Ignored %+v\n", ev) + return + } + + fmt.Printf("We have been mentioned in %v\n", ev.Channel) + _, _, err := client.Client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + if err != nil { + fmt.Printf("failed posting message: %v", err) + } +} + +func middlewareInteractive(evt *socketmode.Event, client *socketmode.Client) { + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + fmt.Printf("Interaction received: %+v\n", callback) + + var payload any + + switch callback.Type { + case slack.InteractionTypeBlockActions: + // See https://api.slack.com/apis/connections/socket-implement#button + client.Debugf("button clicked!") + case slack.InteractionTypeShortcut: + case slack.InteractionTypeViewSubmission: + // See https://api.slack.com/apis/connections/socket-implement#modal + case slack.InteractionTypeDialogSubmission: + default: + + } + + client.Ack(*evt.Request, payload) +} + +func middlewareInteractionTypeBlockActions(evt *socketmode.Event, client *socketmode.Client) { + client.Debugf("button clicked!") +} + +func middlewareSlashCommand(evt *socketmode.Event, client *socketmode.Client) { + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + return + } + + client.Debugf("Slash command received: %+v", cmd) + + payload := map[string]any{ + "blocks": []slack.Block{ + slack.NewSectionBlock( + &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: "foo", + }, + nil, + slack.NewAccessory( + slack.NewButtonBlockElement( + "", + "somevalue", + &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: "bar", + }, + ), + ), + ), + }} + client.Ack(*evt.Request, payload) +} + +func middlewareDefault(evt *socketmode.Event, client *socketmode.Client) { + fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) +} diff --git a/examples/stars/stars.go b/examples/stars/stars.go index cc3f9bacb..72f615b5c 100644 --- a/examples/stars/stars.go +++ b/examples/stars/stars.go @@ -3,27 +3,37 @@ package main import ( "flag" "fmt" + "os" "github.com/slack-go/slack" ) func main() { var ( - apiToken string - debug bool + debug bool + team string ) - flag.StringVar(&apiToken, "token", "YOUR_TOKEN_HERE", "Your Slack API Token") + // Get token from environment variable + apiToken := os.Getenv("SLACK_USER_TOKEN") + if apiToken == "" { + fmt.Println("SLACK_USER_TOKEN environment variable is required") + os.Exit(1) + } + flag.BoolVar(&debug, "debug", false, "Show JSON output") + flag.StringVar(&team, "team", "", "Team ID (required for Enterprise Grid)") flag.Parse() api := slack.New(apiToken, slack.OptionDebug(debug)) - // Get all stars for the usr. + // Get all stars for the user. params := slack.NewStarsParameters() + params.TeamID = team + starredItems, _, err := api.GetStarred(params) if err != nil { - fmt.Printf("Error getting stars: %s\n", err) + fmt.Printf("Error getting stars: %v\n", err) return } for _, s := range starredItems { diff --git a/examples/team/team.go b/examples/team/team.go index 8d2fcdbc6..7bf14c488 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -1,25 +1,43 @@ package main import ( + "flag" "fmt" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") - //Example for single user - billingActive, err := api.GetBillableInfo("U023BECGF") - if err != nil { - fmt.Printf("%s\n", err) - return - } - fmt.Printf("ID: U023BECGF, BillingActive: %v\n\n\n", billingActive["U023BECGF"]) + userID := flag.String("user", "", "User ID for billing info (optional)") + flag.Parse() - //Example for team - billingActiveForTeam, _ := api.GetBillableInfoForTeam() - for id, value := range billingActiveForTeam { - fmt.Printf("ID: %v, BillingActive: %v\n", id, value) + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) } + api := slack.New(token) + + if *userID != "" { + // Example for single user + billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: *userID}) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("ID: %s, BillingActive: %v\n\n\n", *userID, billingActive[*userID]) + } else { + // Example for team. Note: passing empty TeamID just uses the current user team. + billingActiveForTeam, err := api.GetBillableInfo(slack.GetBillableInfoParams{}) + if err != nil { + fmt.Printf("%s\n", err) + return + } + 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..c5607e97a --- /dev/null +++ b/examples/tokens/tokens.go @@ -0,0 +1,34 @@ +package main + +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/unmapped_events/unmapped_events.go b/examples/unmapped_events/unmapped_events.go new file mode 100644 index 000000000..7fadf405b --- /dev/null +++ b/examples/unmapped_events/unmapped_events.go @@ -0,0 +1,74 @@ +// This example connects to Slack via RTM and listens for events. +// +// Before the fix for #1544, the events "apps_uninstalled", "activity", and +// "badge_counts_updated" were not mapped and would appear as +// UnmarshallingErrorEvent with an "Received unmapped event" message. +// +// Usage: +// +// export SLACK_BOT_TOKEN=xoxb-... +// go run ./examples/unmapped_events/ +// +// Then interact with your workspace (open channels, browse around) and watch +// for unmapped event errors in the output. The "activity" and +// "badge_counts_updated" events tend to appear during normal workspace usage. +// The "apps_uninstalled" event appears when an app is removed. +package main + +import ( + "fmt" + "log" + "os" + + "github.com/slack-go/slack" +) + +func main() { + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New( + token, + slack.OptionDebug(true), + slack.OptionLog(log.New(os.Stdout, "slack-bot: ", log.Lshortfile|log.LstdFlags)), + ) + + rtm := api.NewRTM() + go rtm.ManageConnection() + + for msg := range rtm.IncomingEvents { + switch ev := msg.Data.(type) { + case *slack.ConnectedEvent: + fmt.Printf("Connected: %s (connection count: %d)\n", ev.Info.User.ID, ev.ConnectionCount) + + case *slack.AppsUninstalledEvent: + fmt.Printf("Apps uninstalled: %+v\n", ev) + + case *slack.ActivityEvent: + fmt.Printf("Activity: subtype=%s key=%s\n", ev.SubType, ev.Key) + + case *slack.BadgeCountsUpdatedEvent: + fmt.Printf("Badge counts updated: %+v\n", ev) + + case *slack.UnmarshallingErrorEvent: + // Before the fix, apps_uninstalled/activity/badge_counts_updated + // end up here as unmapped events. + fmt.Printf("UNMAPPED EVENT ERROR: %v\n", ev.ErrorObj) + + case *slack.InvalidAuthEvent: + fmt.Println("Invalid credentials") + return + + case *slack.DisconnectedEvent: + if ev.Intentional { + return + } + + default: + fmt.Printf("Event: type=%s data=%T\n", msg.Type, ev) + } + } +} diff --git a/examples/users/users.go b/examples/users/users.go index d6669b18b..4d4835613 100644 --- a/examples/users/users.go +++ b/examples/users/users.go @@ -1,17 +1,75 @@ package main import ( + "encoding/json" + "flag" "fmt" + "log" + "os" "github.com/slack-go/slack" ) func main() { - api := slack.New("YOUR_TOKEN_HERE") - user, err := api.GetUserInfo("U023BECGF") + userID := flag.String("user", "", "User ID to fetch info for") + list := flag.Bool("list", false, "List all users") + teamID := flag.String("team", "", "Team ID (required for Enterprise Grid)") + flag.Parse() + + userToken := os.Getenv("SLACK_USER_TOKEN") + if userToken == "" { + fmt.Fprintf(os.Stderr, "SLACK_USER_TOKEN environment variable is required\n") + os.Exit(1) + } + + if *userID == "" && !*list { + fmt.Fprintf(os.Stderr, "Use -user to fetch a user or -list to list all users\n") + os.Exit(1) + } + + api := slack.New(userToken) + + if *list { + var opts []slack.GetUsersOption + if *teamID != "" { + opts = append(opts, slack.GetUsersOptionTeamID(*teamID)) + } + listUsers(api, opts...) + return + } + + user, err := api.GetUserInfo(*userID) if err != nil { fmt.Printf("%s\n", err) return } + b, err := json.MarshalIndent(user, "", " ") + if err != nil { + log.Fatal(err) + } + + fmt.Println(string(b)) fmt.Printf("ID: %s, Fullname: %s, Email: %s\n", user.ID, user.Profile.RealName, user.Profile.Email) } + +func listUsers(api *slack.Client, opts ...slack.GetUsersOption) { + users, err := api.GetUsers(opts...) + if err != nil { + log.Fatal(err) + } + + for _, user := range users { + guestType := "" + if user.IsUltraRestricted { + guestType = " (single-channel guest)" + } else if user.IsRestricted { + guestType = " (multi-channel guest)" + } + + fmt.Printf("%-12s %-30s %s%s\n", user.ID, user.Profile.RealName, user.Profile.Email, guestType) + + if (user.IsRestricted || user.IsUltraRestricted) && user.Profile.GuestInvitedBy != "" { + fmt.Printf(" invited_by: %s\n", user.Profile.GuestInvitedBy) + } + } +} diff --git a/examples/websocket/websocket.go b/examples/websocket/websocket.go index 96e110e39..b2525f0de 100644 --- a/examples/websocket/websocket.go +++ b/examples/websocket/websocket.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "log" "os" @@ -9,9 +10,19 @@ import ( ) func main() { - token, ok := os.LookupEnv("SLACK_TOKEN") - if !ok { - fmt.Println("Missing SLACK_TOKEN in environment") + channelID := flag.String("channel", "", "Channel ID (required)") + flag.Parse() + + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + // Get channel ID from flag + if *channelID == "" { + fmt.Println("Channel ID is required: use -channel flag") os.Exit(1) } api := slack.New( @@ -32,8 +43,8 @@ func main() { case *slack.ConnectedEvent: fmt.Println("Infos:", ev.Info) fmt.Println("Connection counter:", ev.ConnectionCount) - // Replace C2147483705 with your Channel ID - rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", "C2147483705")) + // Send message to provided channel ID + rtm.SendMessage(rtm.NewOutgoingMessage("Hello world", *channelID)) case *slack.MessageEvent: fmt.Printf("Message: %v\n", ev) diff --git a/examples/websocket_respond/respond.go b/examples/websocket_respond/respond.go index eed3fa834..7c74da41a 100644 --- a/examples/websocket_respond/respond.go +++ b/examples/websocket_respond/respond.go @@ -2,15 +2,21 @@ package main import ( "fmt" + "os" "strings" "github.com/slack-go/slack" ) func main() { - api := slack.New( - "YOUR-TOKEN-HERE", - ) + // Get token from environment variable + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Println("SLACK_BOT_TOKEN environment variable is required") + os.Exit(1) + } + + api := slack.New(token) rtm := api.NewRTM() go rtm.ManageConnection() diff --git a/examples/workflow_step/README.md b/examples/workflow_step/README.md deleted file mode 100644 index da378b894..000000000 --- a/examples/workflow_step/README.md +++ /dev/null @@ -1,59 +0,0 @@ -#WorkflowStep - -Have you ever wanted to run an app from a Slack workflow? This sample app shows you how it works. - -Slack describes some of the basics here: -https://api.slack.com/workflows/steps -https://api.slack.com/tutorials/workflow-builder-steps - - -1. Start the example app localy on port 8080 - - -2. Use ngrok to expose your app to the internet - -```shell - ./ngrok http 8080 -``` -Copy the https forwarding URL and paste it into the app manifest down below (event_subscription request_url and interactivity request_url) - - -3. Create a new Slack App at api.slack.com/apps from an app manifest - -The manifest of a sample Slack App looks like this: -```yaml -display_information: - name: Workflowstep-Example -features: - bot_user: - display_name: Workflowstep-Example - always_online: false - workflow_steps: - - name: Example Step - callback_id: example-step -oauth_config: - scopes: - bot: - - workflow.steps:execute -settings: - event_subscriptions: - request_url: https://*****.ngrok.io/api/v1/example-step - bot_events: - - workflow_step_execute - interactivity: - is_enabled: true - request_url: https://*****.ngrok.io/api/v1/interaction - org_deploy_enabled: false - socket_mode_enabled: false - token_rotation_enabled: false -``` - -("Interactivity" and "Enable Events" should be turned on) - -4. Slack Workflow (**paid plan required!**) - 1. Create a new Workflow at app.slack.com/workflow-builder - 2. give it a name - 3. select "Planned date & time" - 4. add another step and select "Example Step" from App Workflowstep-Example - 5. configure your app and hit save - 6. don't forget to publish your workflow \ No newline at end of file diff --git a/examples/workflow_step/go.mod b/examples/workflow_step/go.mod deleted file mode 100644 index 1df243ce5..000000000 --- a/examples/workflow_step/go.mod +++ /dev/null @@ -1,9 +0,0 @@ -module workflowstep-example - -go 1.17 - -require ( - github.com/gorilla/websocket v1.4.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/slack-go/slack v0.10.1 // indirect -) diff --git a/examples/workflow_step/go.sum b/examples/workflow_step/go.sum deleted file mode 100644 index e95148d32..000000000 --- a/examples/workflow_step/go.sum +++ /dev/null @@ -1,11 +0,0 @@ -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/slack-go/slack v0.10.1 h1:BGbxa0kMsGEvLOEoZmYs8T1wWfoZXwmQFBb6FgYCXUA= -github.com/slack-go/slack v0.10.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/examples/workflow_step/handler.go b/examples/workflow_step/handler.go deleted file mode 100644 index f0a88b08b..000000000 --- a/examples/workflow_step/handler.go +++ /dev/null @@ -1,210 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "github.com/slack-go/slack" - "github.com/slack-go/slack/slackevents" - "io/ioutil" - "log" - "net/http" - "net/url" - "time" -) - -const ( - IDSelectOptionBlock = "select-option-block" - IDExampleSelectInput = "example-select-input" -) - -func handleMyWorkflowStep(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - // see: https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go - body, err := ioutil.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) - if err != nil { - log.Printf("[ERROR] Failed on parsing event: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - // see: https://api.slack.com/apis/connections/events-api#subscriptions - if eventsAPIEvent.Type == slackevents.URLVerification { - var r *slackevents.ChallengeResponse - err := json.Unmarshal([]byte(body), &r) - if err != nil { - log.Printf("[ERROR] Failed to decode json message on event url_verification: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text") - w.Write([]byte(r.Challenge)) - return - } - - // see: https://api.slack.com/apis/connections/events-api#receiving_events - if eventsAPIEvent.Type == slackevents.CallbackEvent { - innerEvent := eventsAPIEvent.InnerEvent - - switch ev := innerEvent.Data.(type) { - - // see: https://api.slack.com/events/workflow_step_execute - case *slackevents.WorkflowStepExecuteEvent: - if ev.CallbackID == MyExampleWorkflowStepCallbackID { - go doHeavyLoad(ev.WorkflowStep) - - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusBadRequest) - log.Printf("[WARN] unknown callbackID: %s", ev.CallbackID) - return - - default: - w.WriteHeader(http.StatusBadRequest) - log.Printf("[WARN] unknown inner event type: %s", eventsAPIEvent.InnerEvent.Type) - return - } - } - - w.WriteHeader(http.StatusBadRequest) - log.Printf("[WARN] unknown event type: %s", eventsAPIEvent.Type) -} - -func handleInteraction(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - body, err := ioutil.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - jsonStr, err := url.QueryUnescape(string(body)[8:]) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - var message slack.InteractionCallback - if err := json.Unmarshal([]byte(jsonStr), &message); err != nil { - log.Printf("[ERROR] Failed to decode json message from slack: %s", jsonStr) - w.WriteHeader(http.StatusInternalServerError) - return - } - - switch message.Type { - case slack.InteractionTypeWorkflowStepEdit: - // https://api.slack.com/workflows/steps#handle_config_view - err := replyWithConfigurationView(message, "", "") - if err != nil { - log.Printf("[ERROR] Failed to open configuration modal in slack: %s", err.Error()) - } - - case slack.InteractionTypeViewSubmission: - // https://api.slack.com/workflows/steps#handle_view_submission - - // process user inputs - // this is just for demonstration, so we print it to console only - blockAction := message.View.State.Values - selectedOption := blockAction[IDSelectOptionBlock][IDExampleSelectInput].SelectedOption.Value - log.Println(fmt.Sprintf("user selected: %s", selectedOption)) - - in := &slack.WorkflowStepInputs{ - IDExampleSelectInput: slack.WorkflowStepInputElement{ - Value: selectedOption, - SkipVariableReplacement: false, - }, - } - - err := saveUserSettingsForWorkflowStep(message.WorkflowStep.WorkflowStepEditID, in, nil) - if err != nil { - log.Printf("[ERROR] Failed on doing a POST request to workflows.updateStep: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - } - - default: - log.Printf("[WARN] unknown message type: %s", message.Type) - w.WriteHeader(http.StatusInternalServerError) - } -} - -func replyWithConfigurationView(message slack.InteractionCallback, privateMetaData string, externalID string) error { - headerText := slack.NewTextBlockObject("mrkdwn", "Hello World!\nThis is your workflow step app configuration view", false, false) - headerSection := slack.NewSectionBlock(headerText, nil, nil) - - options := []*slack.OptionBlockObject{} - options = append( - options, - slack.NewOptionBlockObject("one", slack.NewTextBlockObject("plain_text", "One", false, false), nil), - ) - - options = append( - options, - slack.NewOptionBlockObject("two", slack.NewTextBlockObject("plain_text", "Two", false, false), nil), - ) - - options = append( - options, - slack.NewOptionBlockObject("three", slack.NewTextBlockObject("plain_text", "Three", false, false), nil), - ) - - selection := slack.NewOptionsSelectBlockElement( - "static_select", - slack.NewTextBlockObject("plain_text", "your choice", false, false), - IDExampleSelectInput, - options..., - ) - - // preselect option, if workflow step input is defined - initialOption, ok := slack.GetInitialOptionFromWorkflowStepInput(selection, message.WorkflowStep.Inputs, options) - if ok { - selection.InitialOption = initialOption - } - - inputBlock := slack.NewInputBlock( - IDSelectOptionBlock, - slack.NewTextBlockObject("plain_text", "Select an option", false, false), - selection, - ) - - blocks := slack.Blocks{ - BlockSet: []slack.Block{ - headerSection, - inputBlock, - }, - } - - cmr := slack.NewConfigurationModalRequest(blocks, privateMetaData, externalID) - _, err := appCtx.slack.OpenView(message.TriggerID, cmr.ModalViewRequest) - return err -} - -func saveUserSettingsForWorkflowStep(workflowStepEditID string, inputs *slack.WorkflowStepInputs, outputs *[]slack.WorkflowStepOutput) error { - return appCtx.slack.SaveWorkflowStepConfiguration(workflowStepEditID, inputs, outputs) -} - -func doHeavyLoad(workflowStep slackevents.EventWorkflowStep) { - // process user configuration e.g. inputs - log.Printf("Inputs:") - for name, input := range *workflowStep.Inputs { - log.Printf(fmt.Sprintf("%s: %s", name, input.Value)) - } - - // do heavy load - time.Sleep(10 * time.Second) - log.Println("Done") -} diff --git a/examples/workflow_step/main.go b/examples/workflow_step/main.go deleted file mode 100644 index a5058bb0f..000000000 --- a/examples/workflow_step/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "fmt" - "github.com/slack-go/slack" - "log" - "net/http" - "os" -) - -type ( - appContext struct { - slack *slack.Client - config configuration - } - configuration struct { - botToken string - signingSecret string - } - SecretsVerifierMiddleware struct { - handler http.Handler - } -) - -const ( - APIBaseURL = "/api/v1" - // MyExampleWorkflowStepCallbackID is configured in slack (api.slack.com/apps). - // Select your app or create a new one. Then choose menu "Workflow Steps"... - MyExampleWorkflowStepCallbackID = "example-step" -) - -var appCtx appContext - -func main() { - appCtx.config.botToken = os.Getenv("SLACK_BOT_TOKEN") - appCtx.config.signingSecret = os.Getenv("SLACK_SIGNING_SECRET") - - appCtx.slack = slack.New(appCtx.config.botToken) - - mux := http.NewServeMux() - mux.HandleFunc(fmt.Sprintf("%s/interaction", APIBaseURL), handleInteraction) - mux.HandleFunc(fmt.Sprintf("%s/%s", APIBaseURL, MyExampleWorkflowStepCallbackID), handleMyWorkflowStep) - middleware := NewSecretsVerifierMiddleware(mux) - - log.Printf("starting server on :8080") - log.Fatal(http.ListenAndServe(":8080", middleware)) -} diff --git a/examples/workflow_step/middleware.go b/examples/workflow_step/middleware.go deleted file mode 100644 index fd7d15297..000000000 --- a/examples/workflow_step/middleware.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "bytes" - "github.com/slack-go/slack" - "io/ioutil" - "net/http" -) - -func (v *SecretsVerifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - r.Body.Close() - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) - - sv, err := slack.NewSecretsVerifier(r.Header, appCtx.config.signingSecret) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - if _, err := sv.Write(body); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - if err := sv.Ensure(); err != nil { - w.WriteHeader(http.StatusUnauthorized) - return - } - - v.handler.ServeHTTP(w, r) -} - -func NewSecretsVerifierMiddleware(h http.Handler) *SecretsVerifierMiddleware { - return &SecretsVerifierMiddleware{h} -} diff --git a/examples/workflows_featured/workflows_featured.go b/examples/workflows_featured/workflows_featured.go new file mode 100644 index 000000000..07e267084 --- /dev/null +++ b/examples/workflows_featured/workflows_featured.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + + "github.com/slack-go/slack" +) + +func main() { + action := flag.String("action", "", "Action to perform: list, add, remove, set (required)") + channel := flag.String("channel", "", "Channel ID (required for add, remove, set)") + channels := flag.String("channels", "", "Comma-separated channel IDs (required for list)") + triggers := flag.String("triggers", "", "Comma-separated trigger IDs (required for add, remove, set)") + flag.Parse() + + token := os.Getenv("SLACK_BOT_TOKEN") + if token == "" { + fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN environment variable is required\n") + os.Exit(1) + } + + if *action == "" { + fmt.Fprintf(os.Stderr, "Error: -action flag is required (list, add, remove, set)\n") + os.Exit(1) + } + + api := slack.New(token) + + switch *action { + case "list": + listFeatured(api, *channels) + case "add": + addFeatured(api, *channel, *triggers) + case "remove": + removeFeatured(api, *channel, *triggers) + case "set": + setFeatured(api, *channel, *triggers) + default: + fmt.Fprintf(os.Stderr, "Error: unknown action %q (must be list, add, remove, set)\n", *action) + os.Exit(1) + } +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func listFeatured(api *slack.Client, channelsFlag string) { + channelIDs := splitCSV(channelsFlag) + if len(channelIDs) == 0 { + fmt.Fprintf(os.Stderr, "Error: -channels flag is required for list action\n") + os.Exit(1) + } + + resp, err := api.WorkflowsFeaturedList(context.Background(), &slack.WorkflowsFeaturedListInput{ + ChannelIDs: channelIDs, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing featured workflows: %s\n", err) + os.Exit(1) + } + + for _, fw := range resp.FeaturedWorkflows { + fmt.Printf("Channel %s:\n", fw.ChannelID) + if len(fw.Triggers) == 0 { + fmt.Println(" (no featured workflows)") + continue + } + for _, t := range fw.Triggers { + fmt.Printf(" - %s (ID: %s)\n", t.Title, t.ID) + } + } +} + +func addFeatured(api *slack.Client, channelID, triggersFlag string) { + if channelID == "" { + fmt.Fprintf(os.Stderr, "Error: -channel flag is required for add action\n") + os.Exit(1) + } + triggerIDs := splitCSV(triggersFlag) + if len(triggerIDs) == 0 { + fmt.Fprintf(os.Stderr, "Error: -triggers flag is required for add action\n") + os.Exit(1) + } + + err := api.WorkflowsFeaturedAdd(context.Background(), &slack.WorkflowsFeaturedAddInput{ + ChannelID: channelID, + TriggerIDs: triggerIDs, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error adding featured workflows: %s\n", err) + os.Exit(1) + } + + fmt.Println("Featured workflows added successfully") +} + +func removeFeatured(api *slack.Client, channelID, triggersFlag string) { + if channelID == "" { + fmt.Fprintf(os.Stderr, "Error: -channel flag is required for remove action\n") + os.Exit(1) + } + triggerIDs := splitCSV(triggersFlag) + if len(triggerIDs) == 0 { + fmt.Fprintf(os.Stderr, "Error: -triggers flag is required for remove action\n") + os.Exit(1) + } + + err := api.WorkflowsFeaturedRemove(context.Background(), &slack.WorkflowsFeaturedRemoveInput{ + ChannelID: channelID, + TriggerIDs: triggerIDs, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error removing featured workflows: %s\n", err) + os.Exit(1) + } + + fmt.Println("Featured workflows removed successfully") +} + +func setFeatured(api *slack.Client, channelID, triggersFlag string) { + if channelID == "" { + fmt.Fprintf(os.Stderr, "Error: -channel flag is required for set action\n") + os.Exit(1) + } + triggerIDs := splitCSV(triggersFlag) + if len(triggerIDs) == 0 { + fmt.Fprintf(os.Stderr, "Error: -triggers flag is required for set action\n") + os.Exit(1) + } + + err := api.WorkflowsFeaturedSet(context.Background(), &slack.WorkflowsFeaturedSetInput{ + ChannelID: channelID, + TriggerIDs: triggerIDs, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error setting featured workflows: %s\n", err) + os.Exit(1) + } + + fmt.Println("Featured workflows set successfully") +} diff --git a/files.go b/files.go index e7e71c495..155beba2e 100644 --- a/files.go +++ b/files.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "fmt" "io" "net/url" @@ -10,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 @@ -88,6 +89,28 @@ type File struct { NumStars int `json:"num_stars"` IsStarred bool `json:"is_starred"` Shares Share `json:"shares"` + + Subject string `json:"subject"` + To []EmailFileUserInfo `json:"to"` + From []EmailFileUserInfo `json:"from"` + Cc []EmailFileUserInfo `json:"cc"` + Headers EmailHeaders `json:"headers"` + + PlainText string `json:"plain_text"` + PreviewPlainText string `json:"preview_plain_text"` +} + +type EmailFileUserInfo struct { + Address string `json:"address"` + Name string `json:"name"` + Original string `json:"original"` +} + +type EmailHeaders struct { + Date string `json:"date"` + InReplyTo string `json:"in_reply_to"` + ReplyTo string `json:"reply_to"` + MessageID string `json:"message_id"` } type Share struct { @@ -106,28 +129,11 @@ type ShareFileInfo struct { TeamID string `json:"team_id"` } -// FileUploadParameters contains all the parameters necessary (including the optional ones) for an UploadFile() request. -// -// There are three ways to upload a file. You can either set Content if file is small, set Reader if file is large, -// or provide a local file path in File to upload it from your filesystem. -// -// Note that when using the Reader option, you *must* specify the Filename, otherwise the Slack API isn't happy. -type FileUploadParameters struct { - File string - Content string - Reader io.Reader - Filetype string - Filename string - Title string - InitialComment string - Channels []string - ThreadTimestamp string -} - // GetFilesParameters contains all the parameters necessary (including the optional ones) for a GetFiles() request type GetFilesParameters struct { User string Channel string + TeamID string TimestampFrom JSONTime TimestampTo JSONTime Types string @@ -141,10 +147,67 @@ type ListFilesParameters struct { Limit int User string Channel string + TeamID string Types string Cursor string } +type UploadFileParameters struct { + File string + FileSize int + Content string + Reader io.Reader + Filename string + Title string + InitialComment string + Blocks Blocks + Channel string + Channels []string + ThreadTimestamp string + AltTxt string + SnippetType string +} + +type GetUploadURLExternalParameters struct { + AltTxt string + FileSize int + FileName string + SnippetType string +} + +type GetUploadURLExternalResponse struct { + UploadURL string `json:"upload_url"` + FileID string `json:"file_id"` + SlackResponse +} + +type UploadToURLParameters struct { + UploadURL string + Reader io.Reader + File string + Content string + Filename string +} + +type FileSummary struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type CompleteUploadExternalParameters struct { + Files []FileSummary + Blocks Blocks + Channel string + Channels []string + InitialComment string + ThreadTimestamp string +} + +type CompleteUploadExternalResponse struct { + SlackResponse + Files []FileSummary `json:"files"` +} + type fileResponseFull struct { File `json:"file"` Paging `json:"paging"` @@ -179,12 +242,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}, @@ -200,24 +265,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}, @@ -228,6 +294,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)) } @@ -243,6 +312,7 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter if params.Page != DEFAULT_FILES_PAGE { values.Add("page", strconv.Itoa(params.Page)) } + //lint:ignore S1002 - we want to explicitly check against the constant if params.ShowHidden != DEFAULT_FILES_SHOW_HIDDEN { values.Add("show_files_hidden_by_limit", strconv.FormatBool(params.ShowHidden)) } @@ -255,13 +325,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}, @@ -273,6 +343,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)) } @@ -290,65 +363,14 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet return response.Files, ¶ms, nil } -// UploadFile uploads a file -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 -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. - _, err = api.AuthTest() - if err != nil { - return nil, err - } - response := &fileResponseFull{} - values := url.Values{} - if params.Filetype != "" { - values.Add("filetype", params.Filetype) - } - if params.Filename != "" { - values.Add("filename", params.Filename) - } - if params.Title != "" { - values.Add("title", params.Title) - } - if params.InitialComment != "" { - values.Add("initial_comment", params.InitialComment) - } - if params.ThreadTimestamp != "" { - values.Add("thread_ts", params.ThreadTimestamp) - } - if len(params.Channels) != 0 { - values.Add("channels", strings.Join(params.Channels, ",")) - } - if params.Content != "" { - values.Add("content", params.Content) - values.Add("token", api.token) - err = api.postMethod(ctx, "files.upload", values, response) - } else if params.File != "" { - err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.upload", params.File, "file", api.token, values, response, api) - } else if params.Reader != nil { - if params.Filename == "" { - return nil, fmt.Errorf("files.upload: FileUploadParameters.Filename is mandatory when using FileUploadParameters.Reader") - } - err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.upload", params.Filename, "file", api.token, values, params.Reader, response, api) - } - - if err != nil { - return nil, err - } - - 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 @@ -363,12 +385,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}, @@ -379,12 +403,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}, @@ -398,12 +424,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}, @@ -416,3 +444,154 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) } return &response.File, response.Comments, &response.Paging, nil } + +// GetUploadURLExternalContext gets a URL and fileID from slack which can later be used to upload a file. +// Slack API docs: https://api.slack.com/methods/files.getUploadURLExternal +func (api *Client) GetUploadURLExternalContext(ctx context.Context, params GetUploadURLExternalParameters) (*GetUploadURLExternalResponse, error) { + if params.FileName == "" { + return nil, fmt.Errorf("FileName cannot be empty") + } + if params.FileSize == 0 { + return nil, fmt.Errorf("FileSize cannot be 0") + } + + values := url.Values{ + "token": {api.token}, + "filename": {params.FileName}, + "length": {strconv.Itoa(params.FileSize)}, + } + if params.AltTxt != "" { + values.Add("alt_txt", params.AltTxt) + } + if params.SnippetType != "" { + values.Add("snippet_type", params.SnippetType) + } + response := &GetUploadURLExternalResponse{} + err := api.postMethod(ctx, "files.getUploadURLExternal", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// UploadToURL uploads the file to the provided URL using post method +// This is not a Slack API method, but a helper function to upload files to the URL +func (api *Client) UploadToURL(ctx context.Context, params UploadToURLParameters) (err error) { + values := url.Values{} + switch { + case params.Content != "": + contentReader := strings.NewReader(params.Content) + err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, contentReader, nil, api) + case params.File != "": + err = postLocalWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.File, "file", api.token, values, nil, api) + case params.Reader != nil: + err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, params.Reader, nil, api) + } + return err +} + +// CompleteUploadExternalContext once files are uploaded, this completes the upload and shares it to the specified channels +// Slack API docs: https://api.slack.com/methods/files.completeUploadExternal +func (api *Client) CompleteUploadExternalContext(ctx context.Context, params CompleteUploadExternalParameters) (file *CompleteUploadExternalResponse, err error) { + filesBytes, err := json.Marshal(params.Files) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {api.token}, + "files": {string(filesBytes)}, + } + + if params.Channel != "" { + values.Add("channel_id", params.Channel) + } + if len(params.Channels) > 0 { + values.Add("channels", strings.Join(params.Channels, ",")) + } + if params.InitialComment != "" { + values.Add("initial_comment", params.InitialComment) + } + if params.Blocks.BlockSet != nil && params.InitialComment == "" { + blocksBytes, err := json.Marshal(params.Blocks) + if err != nil { + return nil, err + } + values.Add("blocks", string(blocksBytes)) + } + if params.ThreadTimestamp != "" { + values.Add("thread_ts", params.ThreadTimestamp) + } + response := &CompleteUploadExternalResponse{} + err = api.postMethod(ctx, "files.completeUploadExternal", values, response) + if err != nil { + return nil, err + } + if response.Err() != nil { + return nil, response.Err() + } + return response, nil +} + +// UploadFile uploads file to a given slack channel using 3 steps. +// For more details, see UploadFileContext documentation. +func (api *Client) UploadFile(params UploadFileParameters) (*FileSummary, error) { + return api.UploadFileContext(context.Background(), params) +} + +// UploadFileContext 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 channels using files.completeUploadExternal +// +// Slack Docs: https://api.slack.com/messaging/files#uploading_files +func (api *Client) UploadFileContext(ctx context.Context, params UploadFileParameters) (file *FileSummary, err error) { + if params.Filename == "" { + return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") + } + if params.FileSize == 0 { + return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") + } + + u, err := api.GetUploadURLExternalContext(ctx, GetUploadURLExternalParameters{ + AltTxt: params.AltTxt, + FileName: params.Filename, + FileSize: params.FileSize, + SnippetType: params.SnippetType, + }) + if err != nil { + return nil, fmt.Errorf("GetUploadURLExternal: %w", err) + } + + err = api.UploadToURL(ctx, UploadToURLParameters{ + UploadURL: u.UploadURL, + Reader: params.Reader, + File: params.File, + Content: params.Content, + Filename: params.Filename, + }) + if err != nil { + return nil, fmt.Errorf("UploadToURL: %w", err) + } + + c, err := api.CompleteUploadExternalContext(ctx, CompleteUploadExternalParameters{ + Files: []FileSummary{{ + ID: u.FileID, + Title: params.Title, + }}, + Channel: params.Channel, + Channels: params.Channels, + InitialComment: params.InitialComment, + ThreadTimestamp: params.ThreadTimestamp, + Blocks: params.Blocks, + }) + if err != nil { + return nil, fmt.Errorf("CompleteUploadExternal: %w", err) + } + if len(c.Files) != 1 { + return nil, fmt.Errorf("file.upload.v2: something went wrong; received %d files instead of 1", len(c.Files)) + } + + return &c.Files[0], nil +} diff --git a/files_test.go b/files_test.go index 5361c06d3..3e543f2b8 100644 --- a/files_test.go +++ b/files_test.go @@ -2,13 +2,14 @@ package slack import ( "bytes" + "context" "encoding/json" - "io/ioutil" + "fmt" + "io" "log" "net/http" "net/url" "reflect" - "strings" "testing" ) @@ -44,7 +45,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) { @@ -149,65 +150,387 @@ func TestSlack_DeleteFileComment(t *testing.T) { } } -func authTestHandler(rw http.ResponseWriter, r *http.Request) { +func uploadURLHandler(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(authTestResponseFull{ + response, _ := json.Marshal(GetUploadURLExternalResponse{ + FileID: "RandomID", + UploadURL: "http://" + serverAddr + "/abc", SlackResponse: SlackResponse{Ok: true}}) rw.Write(response) } -func uploadFileHandler(rw http.ResponseWriter, r *http.Request) { +func urlFileUploadHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "text") + rw.Write([]byte("Ok: 200, file uploaded")) +} + +func completeURLUpload(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") - response, _ := json.Marshal(fileResponseFull{ + response, _ := json.Marshal(CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "RandomID", + Title: "", + }, + }, SlackResponse: SlackResponse{Ok: true}}) rw.Write(response) } func TestUploadFile(t *testing.T) { - http.HandleFunc("/auth.test", authTestHandler) - http.HandleFunc("/files.upload", uploadFileHandler) + http.HandleFunc("/files.getUploadURLExternal", uploadURLHandler) + http.HandleFunc("/abc", urlFileUploadHandler) + http.HandleFunc("/files.completeUploadExternal", completeURLUpload) once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - params := FileUploadParameters{ - Filename: "test.txt", Content: "test content", - Channels: []string{"CXXXXXXXX"}} + + params := UploadFileParameters{ + Filename: "test.txt", Content: "test content", FileSize: 10, + Channel: "CXXXXXXXX", + } if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } reader := bytes.NewBufferString("test reader") - params = FileUploadParameters{ + params = UploadFileParameters{ Filename: "test.txt", Reader: reader, - Channels: []string{"CXXXXXXXX"}} + FileSize: 10, + Channel: "CXXXXXXXX"} if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } largeByt := make([]byte, 107374200) reader = bytes.NewBuffer(largeByt) - params = FileUploadParameters{ - Filename: "test.txt", Reader: reader, - Channels: []string{"CXXXXXXXX"}} + params = UploadFileParameters{ + Filename: "test.txt", Reader: reader, FileSize: len(largeByt), + Channel: "CXXXXXXXX"} + if _, err := api.UploadFile(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } + + reader = bytes.NewBufferString("test no channel") + params = UploadFileParameters{ + Filename: "test.txt", + Reader: reader, + FileSize: 15} if _, err := api.UploadFile(params); err != nil { t.Errorf("Unexpected error: %s", err) } } -func TestUploadFileWithoutFilename(t *testing.T) { - once.Do(startServer) - api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) +type mockGetUploadURLExternalHttpClient struct { + ResponseStatus int + ResponseBody []byte +} - reader := bytes.NewBufferString("test reader") - params := FileUploadParameters{ - Reader: reader, - Channels: []string{"CXXXXXXXX"}} - _, err := api.UploadFile(params) - if err == nil { - t.Fatal("Expected error when omitting filename, instead got nil") +func (m *mockGetUploadURLExternalHttpClient) Do(req *http.Request) (*http.Response, error) { + if req.URL.Path != "files.getUploadURLExternal" { + return nil, fmt.Errorf("invalid path: %s", req.URL.Path) + } + + return &http.Response{ + StatusCode: m.ResponseStatus, + Body: io.NopCloser(bytes.NewBuffer(m.ResponseBody)), + }, nil +} + +func TestGetUploadURLExternalContext(t *testing.T) { + type testCase struct { + title string + params GetUploadURLExternalParameters + wantSlackResponse []byte + wantResponse GetUploadURLExternalResponse + wantErr error + } + testCases := []testCase{ + { + title: "Testing with required parameters", + params: GetUploadURLExternalParameters{ + FileName: "test.txt", + FileSize: 10, + }, + wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`), + wantResponse: GetUploadURLExternalResponse{ + FileID: "RandomID", + UploadURL: "http://test-server/abc", + SlackResponse: SlackResponse{ + Ok: true, + }, + }, + }, + { + title: "Testing with optional parameters", + params: GetUploadURLExternalParameters{ + FileSize: 10, + FileName: "test.txt", + AltTxt: "test-alt-text", + SnippetType: "test-snippet-type", + }, + wantSlackResponse: []byte(`{"ok":true,"file_id":"RandomID","upload_url":"http://test-server/abc"}`), + wantResponse: GetUploadURLExternalResponse{ + FileID: "RandomID", + UploadURL: "http://test-server/abc", + SlackResponse: SlackResponse{ + Ok: true, + }, + }, + }, + { + title: "Testing with request error", + params: GetUploadURLExternalParameters{ + FileName: "test.txt", + FileSize: 10, + }, + wantSlackResponse: []byte(`{"ok":false,"error":"errored"}`), + wantErr: fmt.Errorf("errored"), + }, + { + title: "Testing with invalid parameters: empty file name", + params: GetUploadURLExternalParameters{ + FileName: "", + FileSize: 10, + }, + wantErr: fmt.Errorf("FileName cannot be empty"), + }, + { + title: "Testing with invalid parameters: file size 0", + params: GetUploadURLExternalParameters{ + FileName: "test.txt", + FileSize: 0, + }, + wantErr: fmt.Errorf("FileSize cannot be 0"), + }, } - if !strings.Contains(err.Error(), ".Filename is mandatory") { - t.Errorf("Error message should mention empty FileUploadParameters.Filename") + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + api := &Client{ + token: validToken, + httpclient: &mockGetUploadURLExternalHttpClient{ + ResponseStatus: 200, + ResponseBody: tc.wantSlackResponse, + }, + } + + gotResponse, err := api.GetUploadURLExternalContext(context.Background(), tc.params) + + if err != nil { + if tc.wantErr == nil { + t.Fatalf("GetUploadURLExternalContext() error = %v, want nil", err) + } + if err.Error() != tc.wantErr.Error() { + t.Errorf("GetUploadURLExternalContext() error = %v, want %v", err, tc.wantErr) + } + } else { + if tc.wantErr != nil { + t.Fatalf("GetUploadURLExternalContext() error = nil, want %v", tc.wantErr) + } + if !reflect.DeepEqual(gotResponse, &tc.wantResponse) { + t.Errorf("GetUploadURLExternalContext() = %v, want %v", gotResponse, tc.wantResponse) + } + } + }) + } +} + +type mockCompleteUploadExternalHttpClient struct { + ResponseStatus int + ResponseBody []byte +} + +func (m *mockCompleteUploadExternalHttpClient) Do(req *http.Request) (*http.Response, error) { + if req.URL.Path != "files.completeUploadExternal" { + return nil, fmt.Errorf("invalid path: %s", req.URL.Path) + } + + return &http.Response{ + StatusCode: m.ResponseStatus, + Body: io.NopCloser(bytes.NewBuffer(m.ResponseBody)), + }, nil +} + +func TestCompleteUploadExternalContext(t *testing.T) { + type testCase struct { + title string + params CompleteUploadExternalParameters + wantResponse CompleteUploadExternalResponse + wantErr bool + } + testCases := []testCase{ + { + title: "Testing with required parameters", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + }, + }, + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, + { + title: "Testing with optional parameters", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + Channel: "test-channel", + InitialComment: "test-comment", + ThreadTimestamp: "1234567890.123456", + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, + { + title: "Testing with multiple channels", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + }, + Channels: []string{"test-channel-1", "test-channel-2"}, + InitialComment: "test-comment", + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, + { + title: "Testing with blocks", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + Channel: "test-channel", + ThreadTimestamp: "1234567890.123456", + Blocks: Blocks{BlockSet: []Block{ + NewSectionBlock( + NewTextBlockObject("plain_text", "This is a section block", false, false), nil, nil), + }, + }, + }, + wantResponse: CompleteUploadExternalResponse{ + Files: []FileSummary{ + { + ID: "ID1", + }, + { + ID: "ID2", + Title: "Title2", + }, + }, + SlackResponse: SlackResponse{Ok: true}, + }, + }, + { + title: "Testing with error", + params: CompleteUploadExternalParameters{ + Files: []FileSummary{ + { + ID: "ID1", + }, + }, + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + var resBody map[string]any + if !tc.wantErr { + resBody = map[string]any{ + "ok": true, + } + files := make([]map[string]string, 0) + for _, file := range tc.params.Files { + m := map[string]string{ + "id": file.ID, + } + if file.Title != "" { + m["title"] = file.Title + } + files = append(files, m) + } + resBody["files"] = files + } else { + resBody = map[string]any{ + "ok": false, + "error": "errored", + } + } + + resBodyBytes, err := json.Marshal(resBody) + if err != nil { + t.Fatalf("failed to marshal response body: %v", err) + } + + api := &Client{ + token: validToken, + httpclient: &mockCompleteUploadExternalHttpClient{ + ResponseStatus: 200, + ResponseBody: resBodyBytes, + }, + } + + gotResponse, err := api.CompleteUploadExternalContext(context.Background(), tc.params) + + if err != nil { + if !tc.wantErr { + t.Errorf("CompleteUploadExternalContext() error = %v, want nil", err) + } + } else { + if tc.wantErr { + t.Fatalf("CompleteUploadExternalContext() error = nil, want %v", tc.wantErr) + } + if !reflect.DeepEqual(gotResponse, &tc.wantResponse) { + t.Errorf("CompleteUploadExternalContext() = %v, want %v", gotResponse, tc.wantResponse) + } + } + }) } } diff --git a/function_execute.go b/function_execute.go new file mode 100644 index 000000000..97bc7e150 --- /dev/null +++ b/function_execute.go @@ -0,0 +1,91 @@ +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) + } + + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := api.postJSONMethod(ctx, "functions.completeSuccess", api.token, jsonData, response); 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 + + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := api.postJSONMethod(ctx, "functions.completeError", api.token, jsonData, response); 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..01f43e9cb --- /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) + case "function-failure": + postFailure(rw) + } + } +} + +func postSuccess(rw http.ResponseWriter) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": true + }`) + rw.Write(response) +} + +func postFailure(rw http.ResponseWriter) { + 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/go.mod b/go.mod index 5cc8e1a7e..f0d35fbdb 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,13 @@ module github.com/slack-go/slack -go 1.16 +go 1.26 + +toolchain go1.26.7 require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-test/deep v1.0.4 - github.com/google/go-cmp v0.5.7 - github.com/gorilla/websocket v1.4.2 - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.2.2 + github.com/go-test/deep v1.1.1 + github.com/gorilla/websocket v1.5.3 + github.com/stretchr/testify v1.12.1 ) + +require go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum index 194956433..85c647c12 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,8 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/huddle.go b/huddle.go new file mode 100644 index 000000000..701236471 --- /dev/null +++ b/huddle.go @@ -0,0 +1,64 @@ +package slack + +// HuddleRoom represents a Slack huddle room as it appears in message events +// with subtype "huddle_thread". This is different from CallBlock which is used +// for external call integrations (Zoom, etc.). +type HuddleRoom struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + MediaServer string `json:"media_server,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + DateStart int64 `json:"date_start"` + DateEnd int64 `json:"date_end"` + Participants []string `json:"participants,omitempty"` + ParticipantHistory []string `json:"participant_history,omitempty"` + ParticipantsEvents map[string]HuddleParticipantEvent `json:"participants_events,omitempty"` + ParticipantsCameraOn []string `json:"participants_camera_on,omitempty"` + ParticipantsCameraOff []string `json:"participants_camera_off,omitempty"` + ParticipantsScreenshareOn []string `json:"participants_screenshare_on,omitempty"` + ParticipantsScreenshareOff []string `json:"participants_screenshare_off,omitempty"` + CanvasThreadTs string `json:"canvas_thread_ts,omitempty"` + ThreadRootTs string `json:"thread_root_ts,omitempty"` + Channels []string `json:"channels,omitempty"` + 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"` + BackgroundID string `json:"background_id,omitempty"` + CanvasBackground string `json:"canvas_background,omitempty"` + IsPrewarmed bool `json:"is_prewarmed"` + IsScheduled bool `json:"is_scheduled"` + Recording *HuddleRecording `json:"recording,omitempty"` + Locale string `json:"locale,omitempty"` + AttachedFileIDs []string `json:"attached_file_ids,omitempty"` + MediaBackendType string `json:"media_backend_type,omitempty"` + DisplayID string `json:"display_id,omitempty"` + ExternalUniqueID string `json:"external_unique_id,omitempty"` + AppID string `json:"app_id,omitempty"` + CallFamily string `json:"call_family,omitempty"` + PendingInvitees map[string]any `json:"pending_invitees,omitempty"` + LastInviteStatusByUser map[string]any `json:"last_invite_status_by_user,omitempty"` + Knocks map[string]any `json:"knocks,omitempty"` + HuddleLink string `json:"huddle_link,omitempty"` +} + +// HuddleParticipantEvent tracks a participant's activity in a huddle. +type HuddleParticipantEvent struct { + UserTeam map[string]any `json:"user_team,omitempty"` + Joined bool `json:"joined"` + CameraOn bool `json:"camera_on"` + CameraOff bool `json:"camera_off"` + ScreenshareOn bool `json:"screenshare_on"` + ScreenshareOff bool `json:"screenshare_off"` +} + +// HuddleRecording contains recording status for a huddle. +type HuddleRecording struct { + CanRecordSummary string `json:"can_record_summary,omitempty"` + NoteTaking bool `json:"note_taking,omitempty"` + Summary bool `json:"summary,omitempty"` + SummaryStatus string `json:"summary_status,omitempty"` + Transcript bool `json:"transcript,omitempty"` + RecordingUser string `json:"recording_user,omitempty"` +} diff --git a/huddle_test.go b/huddle_test.go new file mode 100644 index 000000000..92bf37369 --- /dev/null +++ b/huddle_test.go @@ -0,0 +1,174 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHuddleRoomUnmarshal(t *testing.T) { + jsonData := []byte(`{ + "id": "R0AAZKMD88M", + "name": "", + "media_server": "", + "created_by": "U031L4VDD", + "date_start": 1769466090, + "date_end": 0, + "participants": [], + "participant_history": ["U031L4VDD"], + "participants_events": { + "U031L4VDD": { + "user_team": {}, + "joined": true, + "camera_on": false, + "camera_off": false, + "screenshare_on": false, + "screenshare_off": false + } + }, + "participants_camera_on": [], + "participants_camera_off": [], + "participants_screenshare_on": [], + "participants_screenshare_off": [], + "canvas_thread_ts": "1769466090.342109", + "thread_root_ts": "1769466090.342109", + "channels": ["D0QJK3LDA"], + "is_dm_call": true, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false, + "background_id": "GRADIENT_03", + "canvas_background": "GRADIENT_03", + "is_prewarmed": false, + "is_scheduled": false, + "recording": { + "can_record_summary": "unavailable" + }, + "locale": "en-US", + "attached_file_ids": [], + "media_backend_type": "free_willy", + "display_id": "", + "external_unique_id": "7069679a-3cb6-4622-a900-51b8d2ff2713", + "app_id": "A00", + "call_family": "huddle", + "pending_invitees": {}, + "last_invite_status_by_user": {}, + "knocks": {}, + "huddle_link": "https://app.slack.com/huddle/T031L4VD9/D0QJK3LDA" + }`) + + var room HuddleRoom + err := json.Unmarshal(jsonData, &room) + require.NoError(t, err) + + assert.Equal(t, "R0AAZKMD88M", room.ID) + assert.Equal(t, "U031L4VDD", room.CreatedBy) + assert.Equal(t, int64(1769466090), room.DateStart) + assert.Equal(t, int64(0), room.DateEnd) + assert.True(t, room.IsDMCall) + assert.False(t, room.HasEnded) + assert.Equal(t, "GRADIENT_03", room.BackgroundID) + assert.Equal(t, "GRADIENT_03", room.CanvasBackground) + assert.Equal(t, "free_willy", room.MediaBackendType) + assert.Equal(t, "huddle", room.CallFamily) + assert.Equal(t, "https://app.slack.com/huddle/T031L4VD9/D0QJK3LDA", room.HuddleLink) + assert.Equal(t, "1769466090.342109", room.CanvasThreadTs) + assert.Equal(t, "1769466090.342109", room.ThreadRootTs) + assert.Equal(t, "en-US", room.Locale) + + require.Len(t, room.Channels, 1) + assert.Equal(t, "D0QJK3LDA", room.Channels[0]) + + require.Len(t, room.ParticipantHistory, 1) + assert.Equal(t, "U031L4VDD", room.ParticipantHistory[0]) + + require.NotNil(t, room.Recording) + assert.Equal(t, "unavailable", room.Recording.CanRecordSummary) + + require.Contains(t, room.ParticipantsEvents, "U031L4VDD") + pe := room.ParticipantsEvents["U031L4VDD"] + assert.True(t, pe.Joined) + assert.False(t, pe.CameraOn) + assert.False(t, pe.ScreenshareOn) +} + +func TestHuddleRoomWithActiveParticipants(t *testing.T) { + jsonData := []byte(`{ + "id": "R123", + "date_start": 1769466090, + "date_end": 0, + "participants": ["U001", "U002"], + "participant_history": ["U001", "U002", "U003"], + "participants_events": { + "U001": {"joined": true, "camera_on": true, "camera_off": false, "screenshare_on": false, "screenshare_off": false}, + "U002": {"joined": true, "camera_on": false, "camera_off": false, "screenshare_on": true, "screenshare_off": false} + }, + "participants_camera_on": ["U001"], + "participants_screenshare_on": ["U002"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": true, + "has_ended": false, + "call_family": "huddle" + }`) + + var room HuddleRoom + err := json.Unmarshal(jsonData, &room) + require.NoError(t, err) + + assert.Equal(t, "R123", room.ID) + require.Len(t, room.Participants, 2) + assert.Equal(t, "U001", room.Participants[0]) + assert.Equal(t, "U002", room.Participants[1]) + + require.Len(t, room.ParticipantHistory, 3) + + require.Len(t, room.ParticipantsCameraOn, 1) + assert.Equal(t, "U001", room.ParticipantsCameraOn[0]) + + require.Len(t, room.ParticipantsScreenshareOn, 1) + assert.Equal(t, "U002", room.ParticipantsScreenshareOn[0]) + + assert.True(t, room.WasAccepted) + assert.False(t, room.IsDMCall) + + // Check participant events + u1 := room.ParticipantsEvents["U001"] + assert.True(t, u1.Joined) + assert.True(t, u1.CameraOn) + + u2 := room.ParticipantsEvents["U002"] + assert.True(t, u2.Joined) + assert.True(t, u2.ScreenshareOn) +} + +func TestHuddleRoomEndedState(t *testing.T) { + jsonData := []byte(`{ + "id": "R456", + "date_start": 1769454922, + "date_end": 1769455026, + "participants": [], + "participant_history": ["U031L4VDD"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": true, + "call_family": "huddle" + }`) + + var room HuddleRoom + err := json.Unmarshal(jsonData, &room) + require.NoError(t, err) + + assert.Equal(t, "R456", room.ID) + assert.Equal(t, int64(1769454922), room.DateStart) + assert.Equal(t, int64(1769455026), room.DateEnd) + assert.True(t, room.HasEnded) + assert.Empty(t, room.Participants) + require.Len(t, room.ParticipantHistory, 1) +} diff --git a/im.go b/im.go deleted file mode 100644 index 7c4bc2572..000000000 --- a/im.go +++ /dev/null @@ -1,21 +0,0 @@ -package slack - -type imChannel struct { - ID string `json:"id"` -} - -type imResponseFull struct { - NoOp bool `json:"no_op"` - AlreadyClosed bool `json:"already_closed"` - AlreadyOpen bool `json:"already_open"` - Channel imChannel `json:"channel"` - IMs []IM `json:"ims"` - History - SlackResponse -} - -// IM contains information related to the Direct Message channel -type IM struct { - Conversation - IsUserDeleted bool `json:"is_user_deleted"` -} diff --git a/info.go b/info.go index fde2bc98e..8fb1f1367 100644 --- a/info.go +++ b/info.go @@ -340,8 +340,8 @@ func (api *Client) MuteChat(channelID string) (*UserPrefsCarrier, error) { if err != nil { return nil, err } - chnls := strings.Split(prefs.UserPrefs.MutedChannels, ",") - for _, chn := range chnls { + chnls := strings.SplitSeq(prefs.UserPrefs.MutedChannels, ",") + for chn := range chnls { if chn == channelID { return nil, nil // noop } @@ -409,6 +409,11 @@ func (t JSONTime) Time() time.Time { func (t *JSONTime) UnmarshalJSON(buf []byte) error { s := bytes.Trim(buf, `"`) + if bytes.EqualFold(s, []byte("null")) { + *t = JSONTime(0) + return nil + } + v, err := strconv.Atoi(string(s)) if err != nil { return err @@ -420,16 +425,21 @@ func (t *JSONTime) UnmarshalJSON(buf []byte) error { // Team contains details about a team type Team struct { - ID string `json:"id"` - Name string `json:"name"` - Domain string `json:"domain"` + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + EnterpriseID string `json:"enterprise_id,omitempty"` + EnterpriseName string `json:"enterprise_name,omitempty"` + Icons *Icons `json:"icon,omitempty"` } -// Icons XXX: needs further investigation +// Icons contains the image URLs for the team icons in various sizes type Icons struct { - Image36 string `json:"image_36,omitempty"` - Image48 string `json:"image_48,omitempty"` - Image72 string `json:"image_72,omitempty"` + Image36 string `json:"image_36,omitempty"` + Image48 string `json:"image_48,omitempty"` + Image72 string `json:"image_72,omitempty"` + Image132 string `json:"image_132,omitempty"` + Image230 string `json:"image_230,omitempty"` } // Info contains various details about the authenticated user and team. @@ -444,28 +454,3 @@ type infoResponseFull struct { Info SlackResponse } - -// GetBotByID is deprecated and returns nil -func (info Info) GetBotByID(botID string) *Bot { - return nil -} - -// GetUserByID is deprecated and returns nil -func (info Info) GetUserByID(userID string) *User { - return nil -} - -// GetChannelByID is deprecated and returns nil -func (info Info) GetChannelByID(channelID string) *Channel { - return nil -} - -// GetGroupByID is deprecated and returns nil -func (info Info) GetGroupByID(groupID string) *Group { - return nil -} - -// GetIMByID is deprecated and returns nil -func (info Info) GetIMByID(imID string) *IM { - return nil -} diff --git a/info_test.go b/info_test.go new file mode 100644 index 000000000..baf91d0b9 --- /dev/null +++ b/info_test.go @@ -0,0 +1,44 @@ +package slack + +import ( + "testing" +) + +func TestJSONTime_UnmarshalJSON(t *testing.T) { + type args struct { + buf []byte + } + tests := []struct { + name string + args args + wantTr JSONTime + wantErr bool + }{ + { + "acceptable int64 timestamp", + args{[]byte(`1643435556`)}, + JSONTime(1643435556), + false, + }, + { + "acceptable string timestamp", + args{[]byte(`"1643435556"`)}, + JSONTime(1643435556), + false, + }, + { + "null", + args{[]byte(`null`)}, + JSONTime(0), + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tr JSONTime + if err := tr.UnmarshalJSON(tt.args.buf); (err != nil) != tt.wantErr { + t.Errorf("JSONTime.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/interactions.go b/interactions.go index e362caa86..6170e1fdd 100644 --- a/interactions.go +++ b/interactions.go @@ -3,6 +3,8 @@ package slack import ( "bytes" "encoding/json" + "errors" + "net/http" ) // InteractionType type of interactions @@ -33,29 +35,29 @@ 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"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback @@ -74,6 +76,24 @@ type BlockActionStates struct { Values map[string]map[string]BlockAction `json:"values"` } +// InteractionCallbackParse parses the HTTP form value "payload" from r, unmarshals +// it as JSON into an InteractionCallback, and returns the result. +// It returns an error if the payload is missing or cannot be decoded. +// +// See https://github.com/slack-go/slack/issues/660 for context. +func InteractionCallbackParse(r *http.Request) (InteractionCallback, error) { + payload := r.FormValue("payload") + if len(payload) == 0 { + return InteractionCallback{}, errors.New("payload is empty") + } + + var ic InteractionCallback + if err := json.Unmarshal([]byte(payload), &ic); err != nil { + return InteractionCallback{}, err + } + return ic, nil +} + func (ic *InteractionCallback) MarshalJSON() ([]byte, error) { type alias InteractionCallback tmp := alias(*ic) @@ -136,14 +156,6 @@ type Enterprise struct { Name string `json:"name"` } -type InteractionWorkflowStep struct { - WorkflowStepEditID string `json:"workflow_step_edit_id,omitempty"` - WorkflowID string `json:"workflow_id"` - StepID string `json:"step_id"` - Inputs *WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"` -} - // ActionCallback is a convenience struct defined to allow dynamic unmarshalling of // the "actions" value in Slack's JSON response, which varies depending on block type type ActionCallbacks struct { @@ -159,7 +171,7 @@ func (a ActionCallbacks) MarshalJSON() ([]byte, error) { length := len(a.AttachmentActions) + len(a.BlockActions) buffer := bytes.NewBufferString("[") - f := func(obj interface{}) error { + f := func(obj any) error { js, err := json.Marshal(obj) if err != nil { return err @@ -203,7 +215,7 @@ func (a *ActionCallbacks) UnmarshalJSON(data []byte) error { } for _, r := range raw { - var obj map[string]interface{} + var obj map[string]any err := json.Unmarshal(r, &obj) if err != nil { return err diff --git a/interactions_test.go b/interactions_test.go index f662425dd..4f5b6b061 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -2,6 +2,9 @@ package slack import ( "encoding/json" + "net/http" + "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -51,7 +54,8 @@ const ( "type": "modal", "title": { "type": "plain_text", - "text": "launch project" + "text": "launch project", + "emoji": false }, "blocks": [{ "type": "section", @@ -65,10 +69,12 @@ const ( "initial_date": "1990-04-28", "placeholder": { "type": "plain_text", - "text": "Select a date" + "text": "Select a date", + "emoji": false } } - }] + }], + "app_installed_team_id": "T1ABCD2E12" }, "api_app_id": "A123ABC", "is_cleared": false @@ -91,7 +97,8 @@ const ( "type": "modal", "title": { "type": "plain_text", - "text": "meal choice" + "text": "meal choice", + "emoji": false }, "blocks": [ { @@ -99,7 +106,8 @@ const ( "block_id": "multi-line", "label": { "type": "plain_text", - "text": "dietary restrictions" + "text": "dietary restrictions", + "emoji": false }, "element": { "type": "plain_text_input", @@ -112,7 +120,8 @@ const ( "block_id": "target_channel", "label": { "type": "plain_text", - "text": "Select a channel to post the result on" + "text": "Select a channel to post the result on", + "emoji": false }, "element": { "type": "conversations_select", @@ -135,9 +144,23 @@ const ( "type": "conversations_select", "value": "C1AB2C3DE" } + }, + "some_datetime": { + "value": { + "type": "datetimepicker", + "selected_date_time": null + } + }, + "some_timepicker": { + "value": { + "type": "timepicker", + "timezone": "Europe/Berlin", + "initial_time": "12:00" + } } } - } + }, + "app_installed_team_id": "T1ABCD2E12" }, "hash": "156663117.cd33ad1f", "response_urls": [ @@ -221,6 +244,7 @@ func TestViewClosedck(t *testing.T) { ), }, }, + AppInstalledTeamID: "T1ABCD2E12", }, APIAppID: "A123ABC", } @@ -256,6 +280,7 @@ func TestViewSubmissionCallback(t *testing.T) { false, false, ), + nil, &PlainTextInputBlockElement{ Type: "plain_text_input", ActionID: "ml-value", @@ -270,6 +295,7 @@ func TestViewSubmissionCallback(t *testing.T) { false, false, ), + nil, &SelectBlockElement{ Type: "conversations_select", ActionID: "target_select", @@ -281,20 +307,34 @@ func TestViewSubmissionCallback(t *testing.T) { }, State: &ViewState{ Values: map[string]map[string]BlockAction{ - "multi-line": map[string]BlockAction{ - "ml-value": BlockAction{ + "multi-line": { + "ml-value": { Type: "plain_text_input", Value: "No onions", }, }, - "target_channel": map[string]BlockAction{ - "target_select": BlockAction{ + "target_channel": { + "target_select": { Type: "conversations_select", Value: "C1AB2C3DE", }, }, + "some_datetime": { + "value": BlockAction{ + Type: "datetimepicker", + // No selected datetime! + }, + }, + "some_timepicker": { + "value": BlockAction{ + Type: "timepicker", + InitialTime: "12:00", + Timezone: "Europe/Berlin", + }, + }, }, }, + AppInstalledTeamID: "T1ABCD2E12", }, ViewSubmissionCallback: ViewSubmissionCallback{ Hash: "156663117.cd33ad1f", @@ -455,6 +495,56 @@ func TestInteractionCallback_InteractionTypeBlockActions_Unmarshal(t *testing.T) []string{"G12345"}) } +func TestInteractionCallback_UserUsername(t *testing.T) { + raw := []byte(`{ + "type": "block_actions", + "user": { + "id": "UA8RXUSPL", + "username": "jtorrance", + "name": "jtorrance", + "team_id": "T9TK3CUKW" + }, + "actions": [] + }`) + var cb InteractionCallback + assert.NoError(t, json.Unmarshal(raw, &cb)) + assert.Equal(t, "UA8RXUSPL", cb.User.ID) + assert.Equal(t, "jtorrance", cb.User.Username) + assert.Equal(t, "jtorrance", cb.User.Name) + assert.Equal(t, "T9TK3CUKW", cb.User.TeamID) +} + +func TestInteractionCallback_BlockSuggestionTeamEnterpriseFields(t *testing.T) { + raw := []byte(`{ + "type": "block_suggestion", + "user": { + "id": "U123456", + "name": "example", + "team_id": "T123456" + }, + "team": { + "id": "T123456", + "name": "example-team", + "domain": "example", + "enterprise_id": "E123456", + "enterprise_name": "Example Enterprise" + }, + "api_app_id": "A123456", + "block_id": "enterprise_team", + "action_id": "team_lookup", + "value": "test" + }`) + + var cb InteractionCallback + assert.NoError(t, json.Unmarshal(raw, &cb)) + assert.Equal(t, InteractionTypeBlockSuggestion, cb.Type) + assert.Equal(t, "T123456", cb.Team.ID) + assert.Equal(t, "example-team", cb.Team.Name) + assert.Equal(t, "example", cb.Team.Domain) + assert.Equal(t, "E123456", cb.Team.EnterpriseID) + assert.Equal(t, "Example Enterprise", cb.Team.EnterpriseName) +} + func TestInteractionCallback_Container_Marshal_And_Unmarshal(t *testing.T) { // Contrived - you generally won't see all of the fields set in a single message raw := []byte( @@ -482,7 +572,6 @@ func TestInteractionCallback_Container_Marshal_And_Unmarshal(t *testing.T) { IsEphemeral: false, IsAppUnfurl: false, }, - RawState: json.RawMessage(`{}`), } actual := new(InteractionCallback) @@ -525,7 +614,6 @@ func TestInteractionCallback_In_Thread_Container_Marshal_And_Unmarshal(t *testin IsEphemeral: false, IsAppUnfurl: false, }, - RawState: json.RawMessage(`{}`), } actual := new(InteractionCallback) @@ -538,3 +626,74 @@ func TestInteractionCallback_In_Thread_Container_Marshal_And_Unmarshal(t *testin assert.NoError(t, err) assert.Equal(t, expectedJSON, actualJSON) } + +func TestInteractionCallback_Parser(t *testing.T) { + payload := `{ + "type": "block_actions", + "actions": [ + { + "type": "multi_conversations_select", + "action_id": "multi_convos", + "block_id": "test123", + "selected_conversations": ["G12345"] + } + ], + "container": { + "type": "view", + "view_id": "V12345" + }, + "state": { + "values": { + "section_block_id": { + "multi_convos": { + "type": "multi_conversations_select", + "selected_conversations": ["G12345"] + } + }, + "other_block_id": { + "other_action_id": { + "type": "plain_text_input", + "value": "test123" + } + } + } + } + }` + + // create request body + body := url.Values{} + body.Set("payload", payload) + + // create new request + req, err := http.NewRequest(http.MethodPost, "http://slack.example.org/interactions", strings.NewReader(body.Encode())) + assert.NoError(t, err) + assert.NotNil(t, req) + + // without this header, the parser will not decode the payload + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // parse payload from request + ic, err := InteractionCallbackParse(req) + assert.NoError(t, err) + assert.NotNil(t, ic) + + // test parsed InteractionCallback payload + assert.Equal(t, ic.Type, InteractionTypeBlockActions) + + assert.Equal(t, len(ic.ActionCallback.BlockActions), 1) + assert.Equal(t, ic.ActionCallback.BlockActions[0].ActionID, "multi_convos") + assert.Equal(t, ic.ActionCallback.BlockActions[0].BlockID, "test123") + assert.Equal(t, ic.ActionCallback.BlockActions[0].Type, ActionType(MultiOptTypeConversations)) + assert.Equal(t, len(ic.ActionCallback.BlockActions[0].SelectedConversations), 1) + assert.Equal(t, ic.ActionCallback.BlockActions[0].SelectedConversations[0], "G12345") + + assert.Equal(t, ic.Container.Type, "view") + assert.Equal(t, ic.Container.ViewID, "V12345") + + assert.Equal(t, len(ic.BlockActionState.Values), 2) + assert.Equal(t, ic.BlockActionState.Values["section_block_id"]["multi_convos"].Type, ActionType(MultiOptTypeConversations)) + assert.Equal(t, len(ic.BlockActionState.Values["section_block_id"]["multi_convos"].SelectedConversations), 1) + assert.Equal(t, ic.BlockActionState.Values["section_block_id"]["multi_convos"].SelectedConversations[0], "G12345") + assert.Equal(t, ic.BlockActionState.Values["other_block_id"]["other_action_id"].Type, ActionType(METPlainTextInput)) + assert.Equal(t, ic.BlockActionState.Values["other_block_id"]["other_action_id"].Value, "test123") +} diff --git a/internal/backoff/backoff.go b/internal/backoff/backoff.go index df210f80d..87f22c92d 100644 --- a/internal/backoff/backoff.go +++ b/internal/backoff/backoff.go @@ -7,10 +7,10 @@ import ( // This one was ripped from https://github.com/jpillora/backoff/blob/master/backoff.go -// Backoff is a time.Duration counter. It starts at Min. After every -// call to Duration() it is multiplied by Factor. It is capped at -// Max. It returns to Min on every call to Reset(). Used in -// conjunction with the time package. +// Backoff is a time.Duration counter. It starts at Initial. After every +// call to Duration() it is doubled. It is capped at Max. It returns to +// Initial on every call to Reset(). Used in conjunction with the time +// package. type Backoff struct { attempts int // Initial value to scale out @@ -21,8 +21,9 @@ type Backoff struct { Max time.Duration } -// Returns the current value of the counter and then multiplies it -// Factor +// Duration returns the current value of the counter, then doubles it for the +// next call. Optional jitter is added to the returned value, and the result is +// capped at Max. func (b *Backoff) Duration() (dur time.Duration) { // Zero-values are nonsensical, so we use // them to apply defaults @@ -36,13 +37,13 @@ func (b *Backoff) Duration() (dur time.Duration) { // calculate this duration if dur = time.Duration(1 << uint(b.attempts)); dur > 0 { - dur = dur * b.Initial + dur *= b.Initial } else { dur = b.Max } if b.Jitter > 0 { - dur = dur + time.Duration(rand.Intn(int(b.Jitter))) + dur += time.Duration(rand.Intn(int(b.Jitter))) } // bump attempts count @@ -51,7 +52,7 @@ func (b *Backoff) Duration() (dur time.Duration) { return dur } -//Resets the current value of the counter back to Min +// Reset sets the current value of the counter back to Initial func (b *Backoff) Reset() { b.attempts = 0 } diff --git a/logger.go b/logger.go index 90cb3caab..ba2f78cae 100644 --- a/logger.go +++ b/logger.go @@ -13,18 +13,18 @@ type logger interface { // ilogger represents the internal logging api we use. type ilogger interface { logger - Print(...interface{}) - Printf(string, ...interface{}) - Println(...interface{}) + Print(...any) + Printf(string, ...any) + Println(...any) } type Debug interface { Debug() bool // Debugf print a formatted debug line. - Debugf(format string, v ...interface{}) + Debugf(format string, v ...any) // Debugln print a debug line. - Debugln(v ...interface{}) + Debugln(v ...any) } // internalLog implements the additional methods used by our internal logging. @@ -33,17 +33,17 @@ type internalLog struct { } // Println replicates the behaviour of the standard logger. -func (t internalLog) Println(v ...interface{}) { +func (t internalLog) Println(v ...any) { t.Output(2, fmt.Sprintln(v...)) } // Printf replicates the behaviour of the standard logger. -func (t internalLog) Printf(format string, v ...interface{}) { +func (t internalLog) Printf(format string, v ...any) { t.Output(2, fmt.Sprintf(format, v...)) } // Print replicates the behaviour of the standard logger. -func (t internalLog) Print(v ...interface{}) { +func (t internalLog) Print(v ...any) { t.Output(2, fmt.Sprint(v...)) } @@ -54,7 +54,7 @@ func (t discard) Debug() bool { } // Debugf print a formatted debug line. -func (t discard) Debugf(format string, v ...interface{}) {} +func (t discard) Debugf(format string, v ...any) {} // Debugln print a debug line. -func (t discard) Debugln(v ...interface{}) {} +func (t discard) Debugln(v ...any) {} diff --git a/manifests.go b/manifests.go new file mode 100644 index 000000000..59f6a70bc --- /dev/null +++ b/manifests.go @@ -0,0 +1,299 @@ +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"` + BotOptional []string `json:"bot_optional,omitempty" yaml:"bot_optional,omitempty"` + UserOptional []string `json:"user_optional,omitempty" yaml:"user_optional,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..0e263ce7d --- /dev/null +++ b/manifests_test.go @@ -0,0 +1,187 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "strings" + "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 TestOAuthScopesOptionalFields(t *testing.T) { + scopes := OAuthScopes{ + Bot: []string{"chat:write", "commands"}, + User: []string{"users:read"}, + BotOptional: []string{"files:read", "reactions:read"}, + UserOptional: []string{"channels:read"}, + } + + data, err := json.Marshal(scopes) + if err != nil { + t.Fatalf("Marshal error: %s", err) + } + + var roundtrip OAuthScopes + if err := json.Unmarshal(data, &roundtrip); err != nil { + t.Fatalf("Unmarshal error: %s", err) + } + + if !reflect.DeepEqual(scopes, roundtrip) { + t.Errorf("Round-trip mismatch: got %+v, want %+v", roundtrip, scopes) + } + + // Verify omitempty: empty optional fields should not appear + minimal := OAuthScopes{Bot: []string{"chat:write"}} + data, err = json.Marshal(minimal) + if err != nil { + t.Fatalf("Marshal error: %s", err) + } + s := string(data) + if strings.Contains(s, "bot_optional") { + t.Errorf("Expected bot_optional to be omitted from JSON: %s", s) + } + if strings.Contains(s, "user_optional") { + t.Errorf("Expected user_optional to be omitted from JSON: %s", s) + } +} + +func getTestManifestResponse() *ManifestResponse { + return &ManifestResponse{ + SlackResponse: SlackResponse{ + Ok: true, + }, + } +} diff --git a/messages.go b/messages.go index 2cc31d5bf..332e15e99 100644 --- a/messages.go +++ b/messages.go @@ -17,6 +17,10 @@ type Message struct { Msg SubMessage *Msg `json:"message,omitempty"` PreviousMessage *Msg `json:"previous_message,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. + Root *Msg `json:"root,omitempty"` } // Msg SubTypes (https://api.slack.com/events/message) @@ -49,6 +53,7 @@ const ( MsgSubTypeUnpinnedItem = "unpinned_item" // [RTM] An item was unpinned from a channel MsgSubTypeEkmAccessDenied = "ekm_access_denied" // [Events API, RTM] Message content redacted due to Enterprise Key Management (EKM) MsgSubTypeChannelPostingPermissions = "channel_posting_permissions" // [Events API, RTM] The posting permissions for a channel changed + MsgSubTypeAssistantAppThread = "assistant_app_thread" // [Events API, RTM] The message is an app assistant thread ) // Msg contains information about a slack message @@ -83,6 +88,16 @@ type Msg struct { Icons *Icon `json:"icons,omitempty"` BotProfile *BotProfile `json:"bot_profile,omitempty"` + // These tend to be present in some of the messages, especially when triggered through + // a workflow. The API documentation is not clear about which ones are present in + // which messages, so we make them all optional. + // + // I'm adding them here for completeness but none of the Slack official libraries seem + // to support these fields. Be warned that they may be removed in future versions of + // the API, and that they may not be present in all messages. + TriggerID string `json:"trigger_id,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` + // channel_join, group_join Inviter string `json:"inviter,omitempty"` @@ -100,10 +115,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"` @@ -129,8 +145,13 @@ type Msg struct { ReplaceOriginal bool `json:"replace_original"` DeleteOriginal bool `json:"delete_original"` + // metadata + Metadata SlackMetadata `json:"metadata,omitempty"` + // Block type Message Blocks Blocks `json:"blocks,omitempty"` + // permalink + Permalink string `json:"permalink,omitempty"` } const ( diff --git a/messages_test.go b/messages_test.go index 62fd759fa..1704837b8 100644 --- a/messages_test.go +++ b/messages_test.go @@ -9,11 +9,11 @@ import ( ) var simpleMessage = `{ - "type": "message", - "channel": "C2147483705", - "user": "U2147483697", - "text": "Hello world", - "ts": "1355517523.000005" + "type": "message", + "channel": "C2147483705", + "user": "U2147483697", + "text": "Hello world", + "ts": "1355517523.000005" }` func unmarshalMessage(j string) (*Message, error) { @@ -36,12 +36,12 @@ func TestSimpleMessage(t *testing.T) { } var starredMessage = `{ - "text": "is testing", - "type": "message", - "subtype": "me_message", - "user": "U2147483697", - "ts": "1433314126.000003", - "is_starred": true + "text": "is testing", + "type": "message", + "subtype": "me_message", + "user": "U2147483697", + "ts": "1433314126.000003", + "is_starred": true }` func TestStarredMessage(t *testing.T) { @@ -57,14 +57,14 @@ func TestStarredMessage(t *testing.T) { } var editedMessage = `{ - "type": "message", - "user": "U2147483697", - "text": "hello edited", - "edited": { - "user": "U2147483697", - "ts": "1433314416.000000" - }, - "ts": "1433314408.000004" + "type": "message", + "user": "U2147483697", + "text": "hello edited", + "edited": { + "user": "U2147483697", + "ts": "1433314416.000000" + }, + "ts": "1433314408.000004" }` func TestEditedMessage(t *testing.T) { @@ -81,54 +81,54 @@ func TestEditedMessage(t *testing.T) { } var uploadedFile = `{ - "type": "message", - "subtype": "file_share", - "text": "<@U2147483697|tester> uploaded a file: and commented: test comment here", - "files": [{ - "id": "abc", - "created": 1433314757, - "timestamp": 1433314757, - "name": "test.txt", - "title": "test.txt", - "mimetype": "text\/plain", - "filetype": "text", - "pretty_type": "Plain Text", - "user": "U2147483697", - "editable": true, - "size": 5, - "mode": "snippet", - "is_external": false, - "external_type": "", - "is_public": true, - "public_url_shared": false, - "url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test.txt", - "url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test.txt", - "url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test.txt", - "url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test.txt", - "permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt", - "permalink_public": "https:\/\/slack-files.com\/abc-def-ghi", - "edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt\/edit", - "preview": "test\n", - "preview_highlight": "
test<\/pre><\/div>\n
<\/pre><\/div>\n<\/div>",
-        "lines": 2,
-        "lines_more": 0,
-        "channels": [
-            "C2147483705"
-        ],
-        "groups": [],
-        "ims": [],
-        "comments_count": 1,
-        "initial_comment": {
-            "id": "Fc066YLGKH",
-            "created": 1433314757,
-            "timestamp": 1433314757,
-            "user": "U2147483697",
-            "comment": "test comment here"
-        }
-    }],
-    "user": "U2147483697",
-    "upload": true,
-    "ts": "1433314757.000006"
+	"type": "message",
+	"subtype": "file_share",
+	"text": "<@U2147483697|tester> uploaded a file:  and commented: test comment here",
+	"files": [{
+		"id": "abc",
+		"created": 1433314757,
+		"timestamp": 1433314757,
+		"name": "test.txt",
+		"title": "test.txt",
+		"mimetype": "text\/plain",
+		"filetype": "text",
+		"pretty_type": "Plain Text",
+		"user": "U2147483697",
+		"editable": true,
+		"size": 5,
+		"mode": "snippet",
+		"is_external": false,
+		"external_type": "",
+		"is_public": true,
+		"public_url_shared": false,
+		"url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test.txt",
+		"url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test.txt",
+		"url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test.txt",
+		"url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test.txt",
+		"permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt",
+		"permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
+		"edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test.txt\/edit",
+		"preview": "test\n",
+		"preview_highlight": "
test<\/pre><\/div>\n
<\/pre><\/div>\n<\/div>",
+		"lines": 2,
+		"lines_more": 0,
+		"channels": [
+			"C2147483705"
+		],
+		"groups": [],
+		"ims": [],
+		"comments_count": 1,
+		"initial_comment": {
+			"id": "Fc066YLGKH",
+			"created": 1433314757,
+			"timestamp": 1433314757,
+			"user": "U2147483697",
+			"comment": "test comment here"
+		}
+	}],
+	"user": "U2147483697",
+	"upload": true,
+	"ts": "1433314757.000006"
 }`
 
 func TestUploadedFile(t *testing.T) {
@@ -138,51 +138,68 @@ func TestUploadedFile(t *testing.T) {
 	assert.Equal(t, "message", message.Type)
 	assert.Equal(t, MsgSubTypeFileShare, message.SubType)
 	assert.Equal(t, "<@U2147483697|tester> uploaded a file:  and commented: test comment here", message.Text)
-	// TODO: Assert File
+	assert.Len(t, message.Files, 1)
+	file := message.Files[0]
+	assert.Equal(t, "abc", file.ID)
+	assert.Equal(t, JSONTime(1433314757), file.Created)
+	assert.Equal(t, "test.txt", file.Name)
+	assert.Equal(t, "test.txt", file.Title)
+	assert.Equal(t, "text/plain", file.Mimetype)
+	assert.Equal(t, "text", file.Filetype)
+	assert.Equal(t, "Plain Text", file.PrettyType)
+	assert.Equal(t, "U2147483697", file.User)
+	assert.True(t, file.Editable)
+	assert.Equal(t, 5, file.Size)
+	assert.Equal(t, "snippet", file.Mode)
+	assert.False(t, file.IsExternal)
+	assert.True(t, file.IsPublic)
+	assert.Equal(t, "https://test.slack.com/files/tester/abc/test.txt", file.Permalink)
+	assert.Equal(t, []string{"C2147483705"}, file.Channels)
+	assert.Equal(t, 1, file.CommentsCount)
 	assert.Equal(t, "U2147483697", message.User)
 	assert.True(t, message.Upload)
 	assert.Equal(t, "1433314757.000006", message.Timestamp)
 }
 
 var testPost = `{
-    "type": "message",
-    "subtype": "file_share",
-    "text": "<@U2147483697|tester> shared a file: ",
-    "files": [{
-        "id": "abc",
-        "created": 1433315398,
-        "timestamp": 1433315398,
-        "name": "test_post",
-        "title": "test post",
-        "mimetype": "text\/plain",
-        "filetype": "post",
-        "pretty_type": "Post",
-        "user": "U2147483697",
-        "editable": true,
-        "size": 14,
-        "mode": "post",
-        "is_external": false,
-        "external_type": "",
-        "is_public": true,
-        "public_url_shared": false,
-        "url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
-        "url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
-        "url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
-        "url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
-        "permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
-        "permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
-        "edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
-        "preview": "test post body",
-        "channels": [
-            "C2147483705"
-        ],
-        "groups": [],
-        "ims": [],
-        "comments_count": 1
-    }],
-    "user": "U2147483697",
-    "upload": false,
-    "ts": "1433315416.000008"
+	"type": "message",
+	"subtype": "file_share",
+	"text": "<@U2147483697|tester> shared a file: ",
+	"files": [{
+		"id": "abc",
+		"created": 1433315398,
+		"timestamp": 1433315398,
+		"name": "test_post",
+		"title": "test post",
+		"mimetype": "text\/plain",
+		"filetype": "post",
+		"pretty_type": "Post",
+		"user": "U2147483697",
+		"editable": true,
+		"size": 14,
+		"mode": "post",
+		"is_external": false,
+		"external_type": "",
+		"is_public": true,
+		"public_url_shared": false,
+		"url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
+		"url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
+		"url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
+		"url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
+		"permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
+		"permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
+		"edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
+		"preview": "test post body",
+		"channels": [
+			"C2147483705"
+		],
+		"groups": [],
+		"ims": [],
+		"comments_count": 1
+	}],
+	"user": "U2147483697",
+	"upload": false,
+	"ts": "1433315416.000008"
 }`
 
 func TestPost(t *testing.T) {
@@ -192,56 +209,63 @@ func TestPost(t *testing.T) {
 	assert.Equal(t, "message", message.Type)
 	assert.Equal(t, MsgSubTypeFileShare, message.SubType)
 	assert.Equal(t, "<@U2147483697|tester> shared a file: ", message.Text)
-	// TODO: Assert File
+	assert.Len(t, message.Files, 1)
+	file := message.Files[0]
+	assert.Equal(t, "abc", file.ID)
+	assert.Equal(t, "test_post", file.Name)
+	assert.Equal(t, "test post", file.Title)
+	assert.Equal(t, "post", file.Mode)
+	assert.Equal(t, 14, file.Size)
+	assert.Equal(t, 1, file.CommentsCount)
 	assert.Equal(t, "U2147483697", message.User)
 	assert.False(t, message.Upload)
 	assert.Equal(t, "1433315416.000008", message.Timestamp)
 }
 
 var testComment = `{
-    "type": "message",
-    "subtype": "file_comment",
-    "text": "<@U2147483697|tester> commented on <@U2147483697|tester>'s file : another comment",
-    "files": [{
-        "id": "abc",
-        "created": 1433315398,
-        "timestamp": 1433315398,
-        "name": "test_post",
-        "title": "test post",
-        "mimetype": "text\/plain",
-        "filetype": "post",
-        "pretty_type": "Post",
-        "user": "U2147483697",
-        "editable": true,
-        "size": 14,
-        "mode": "post",
-        "is_external": false,
-        "external_type": "",
-        "is_public": true,
-        "public_url_shared": false,
-        "url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
-        "url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
-        "url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
-        "url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
-        "permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
-        "permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
-        "edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
-        "preview": "test post body",
-        "channels": [
-            "C2147483705"
-        ],
-        "groups": [],
-        "ims": [],
-        "comments_count": 2
-    }],
-    "comment": {
-        "id": "xyz",
-        "created": 1433316360,
-        "timestamp": 1433316360,
-        "user": "U2147483697",
-        "comment": "another comment"
-    },
-    "ts": "1433316360.000009"
+	"type": "message",
+	"subtype": "file_comment",
+	"text": "<@U2147483697|tester> commented on <@U2147483697|tester>'s file : another comment",
+	"files": [{
+		"id": "abc",
+		"created": 1433315398,
+		"timestamp": 1433315398,
+		"name": "test_post",
+		"title": "test post",
+		"mimetype": "text\/plain",
+		"filetype": "post",
+		"pretty_type": "Post",
+		"user": "U2147483697",
+		"editable": true,
+		"size": 14,
+		"mode": "post",
+		"is_external": false,
+		"external_type": "",
+		"is_public": true,
+		"public_url_shared": false,
+		"url": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/test_post",
+		"url_download": "https:\/\/slack-files.com\/files-pub\/abc-def-ghi\/download\/test_post",
+		"url_private": "https:\/\/files.slack.com\/files-pri\/abc-def\/test_post",
+		"url_private_download": "https:\/\/files.slack.com\/files-pri\/abc-def\/download\/test_post",
+		"permalink": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post",
+		"permalink_public": "https:\/\/slack-files.com\/abc-def-ghi",
+		"edit_link": "https:\/\/test.slack.com\/files\/tester\/abc\/test_post\/edit",
+		"preview": "test post body",
+		"channels": [
+			"C2147483705"
+		],
+		"groups": [],
+		"ims": [],
+		"comments_count": 2
+	}],
+	"comment": {
+		"id": "xyz",
+		"created": 1433316360,
+		"timestamp": 1433316360,
+		"user": "U2147483697",
+		"comment": "another comment"
+	},
+	"ts": "1433316360.000009"
 }`
 
 func TestComment(t *testing.T) {
@@ -252,39 +276,79 @@ func TestComment(t *testing.T) {
 	assert.Equal(t, "message", message.Type)
 	assert.Equal(t, MsgSubTypeFileComment, message.SubType)
 	assert.Equal(t, "<@U2147483697|tester> commented on <@U2147483697|tester>'s file : another comment", message.Text)
-	// TODO: Assert File
-	// TODO: Assert Comment
+	assert.Len(t, message.Files, 1)
+	file := message.Files[0]
+	assert.Equal(t, "abc", file.ID)
+	assert.Equal(t, "test_post", file.Name)
+	assert.Equal(t, "test post", file.Title)
+	assert.Equal(t, "post", file.Mode)
+	assert.Equal(t, 14, file.Size)
+	assert.Equal(t, 2, file.CommentsCount)
+
+	assert.NotNil(t, message.Comment)
+	assert.Equal(t, "xyz", message.Comment.ID)
+	assert.Equal(t, JSONTime(1433316360), message.Comment.Created)
+	assert.Equal(t, "U2147483697", message.Comment.User)
+	assert.Equal(t, "another comment", message.Comment.Comment)
+
 	assert.Equal(t, "1433316360.000009", message.Timestamp)
 }
 
 var botMessage = `{
-    "type": "message",
-    "subtype": "bot_message",
-    "text": "Pushing is the answer",
-    "suppress_notification": false,
-    "bot_id": "BB12033",
-    "username": "github",
-    "icons": {},
-    "team": "T01A9CUMPQA",
-    "bot_profile": {
-        "id": "BB12033",
-        "deleted": false,
-        "name": "github",
-        "updated": 1599574335,
-        "app_id": "A6DB2SWUW",
-        "icons": {
-            "image_36": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_36.png",
-            "image_48": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_48.png",
-            "image_72": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_72.png"
-        },
-        "team_id": "T01A9CUMPQA"
-    },
-    "blocks": [],
-    "channel": "C01AZ844Z32",
-    "event_ts": "1358877455.000010",
-    "ts": "1358877455.000010"
+	"type": "message",
+	"subtype": "bot_message",
+	"text": "Pushing is the answer",
+	"suppress_notification": false,
+	"bot_id": "BB12033",
+	"username": "github",
+	"icons": {},
+	"team": "T01A9CUMPQA",
+	"bot_profile": {
+		"id": "BB12033",
+		"deleted": false,
+		"name": "github",
+		"updated": 1599574335,
+		"app_id": "A6DB2SWUW",
+		"icons": {
+			"image_36": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_36.png",
+			"image_48": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_48.png",
+			"image_72": "https://slack-files2.s3-us-west-2.amazonaws.com/avatars/2017-10-24/261138718469_ada58732a18da119678d_72.png"
+		},
+		"team_id": "T01A9CUMPQA"
+	},
+	"blocks": [],
+	"channel": "C01AZ844Z32",
+	"event_ts": "1358877455.000010",
+	"ts": "1358877455.000010"
+}`
+
+var workflowBotMessage = `{
+	"type": "message",
+	"subtype": "bot_message",
+	"text": "Can you create a TODO.md file",
+	"bot_id": "BB12033",
+	"username": "Test Workflow",
+	"workflow_id": "Wf0123456789",
+	"trigger_id": "Ft0123456789",
+	"blocks": [],
+	"channel": "C2147483705",
+	"ts": "1358877455.000010"
 }`
 
+func TestWorkflowBotMessage(t *testing.T) {
+	message, err := unmarshalMessage(workflowBotMessage)
+	assert.Nil(t, err)
+	assert.NotNil(t, message)
+	assert.Equal(t, "message", message.Type)
+	assert.Equal(t, MsgSubTypeBotMessage, message.SubType)
+	assert.Equal(t, "BB12033", message.BotID)
+	assert.Equal(t, "Test Workflow", message.Username)
+	assert.Equal(t, "Wf0123456789", message.WorkflowID)
+	assert.Equal(t, "Ft0123456789", message.TriggerID)
+	assert.Equal(t, "C2147483705", message.Channel)
+	assert.Equal(t, "1358877455.000010", message.Timestamp)
+}
+
 func TestBotMessage(t *testing.T) {
 	message, err := unmarshalMessage(botMessage)
 	assert.Nil(t, err)
@@ -312,12 +376,12 @@ func TestBotMessage(t *testing.T) {
 }
 
 var meMessage = `{
-    "type": "message",
-    "subtype": "me_message",
-    "channel": "C2147483705",
-    "user": "U2147483697",
-    "text": "is doing that thing",
-    "ts": "1355517523.000005"
+	"type": "message",
+	"subtype": "me_message",
+	"channel": "C2147483705",
+	"user": "U2147483697",
+	"text": "is doing that thing",
+	"ts": "1355517523.000005"
 }`
 
 func TestMeMessage(t *testing.T) {
@@ -333,21 +397,21 @@ func TestMeMessage(t *testing.T) {
 }
 
 var messageChangedMessage = `{
-    "type": "message",
-    "subtype": "message_changed",
-    "hidden": true,
-    "channel": "C2147483705",
-    "ts": "1358878755.000001",
-    "message": {
-        "type": "message",
-        "user": "U2147483697",
-        "text": "Hello, world!",
-        "ts": "1355517523.000005",
-        "edited": {
-            "user": "U2147483697",
-            "ts": "1358878755.000001"
-        }
-    }
+	"type": "message",
+	"subtype": "message_changed",
+	"hidden": true,
+	"channel": "C2147483705",
+	"ts": "1358878755.000001",
+	"message": {
+		"type": "message",
+		"user": "U2147483697",
+		"text": "Hello, world!",
+		"ts": "1355517523.000005",
+		"edited": {
+			"user": "U2147483697",
+			"ts": "1358878755.000001"
+		}
+	}
 }`
 
 func TestMessageChangedMessage(t *testing.T) {
@@ -370,12 +434,12 @@ func TestMessageChangedMessage(t *testing.T) {
 }
 
 var messageDeletedMessage = `{
-    "type": "message",
-    "subtype": "message_deleted",
-    "hidden": true,
-    "channel": "C2147483705",
-    "ts": "1358878755.000001",
-    "deleted_ts": "1358878749.000002"
+	"type": "message",
+	"subtype": "message_deleted",
+	"hidden": true,
+	"channel": "C2147483705",
+	"ts": "1358878755.000001",
+	"deleted_ts": "1358878749.000002"
 }`
 
 func TestMessageDeletedMessage(t *testing.T) {
@@ -391,11 +455,11 @@ func TestMessageDeletedMessage(t *testing.T) {
 }
 
 var channelJoinMessage = `{
-    "type": "message",
-    "subtype": "channel_join",
-    "ts": "1358877458.000011",
-    "user": "U2147483828",
-    "text": "<@U2147483828|cal> has joined the channel"
+	"type": "message",
+	"subtype": "channel_join",
+	"ts": "1358877458.000011",
+	"user": "U2147483828",
+	"text": "<@U2147483828|cal> has joined the channel"
 }`
 
 func TestChannelJoinMessage(t *testing.T) {
@@ -410,11 +474,11 @@ func TestChannelJoinMessage(t *testing.T) {
 }
 
 var channelJoinInvitedMessage = `{
-    "type": "message",
-    "subtype": "channel_join",
-    "ts": "1358877458.000011",
-    "user": "U2147483828",
-    "text": "<@U2147483828|cal> has joined the channel",
+	"type": "message",
+	"subtype": "channel_join",
+	"ts": "1358877458.000011",
+	"user": "U2147483828",
+	"text": "<@U2147483828|cal> has joined the channel",
 		"inviter": "U2147483829"
 }`
 
@@ -431,11 +495,11 @@ func TestChannelJoinInvitedMessage(t *testing.T) {
 }
 
 var channelLeaveMessage = `{
-    "type": "message",
-    "subtype": "channel_leave",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "text": "<@U2147483828|cal> has left the channel"
+	"type": "message",
+	"subtype": "channel_leave",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"text": "<@U2147483828|cal> has left the channel"
 }`
 
 func TestChannelLeaveMessage(t *testing.T) {
@@ -450,12 +514,12 @@ func TestChannelLeaveMessage(t *testing.T) {
 }
 
 var channelTopicMessage = `{
-    "type": "message",
-    "subtype": "channel_topic",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "topic": "hello world",
-    "text": "<@U2147483828|cal> set the channel topic: hello world"
+	"type": "message",
+	"subtype": "channel_topic",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"topic": "hello world",
+	"text": "<@U2147483828|cal> set the channel topic: hello world"
 }`
 
 func TestChannelTopicMessage(t *testing.T) {
@@ -471,12 +535,12 @@ func TestChannelTopicMessage(t *testing.T) {
 }
 
 var channelPurposeMessage = `{
-    "type": "message",
-    "subtype": "channel_purpose",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "purpose": "whatever",
-    "text": "<@U2147483828|cal> set the channel purpose: whatever"
+	"type": "message",
+	"subtype": "channel_purpose",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"purpose": "whatever",
+	"text": "<@U2147483828|cal> set the channel purpose: whatever"
 }`
 
 func TestChannelPurposeMessage(t *testing.T) {
@@ -492,13 +556,13 @@ func TestChannelPurposeMessage(t *testing.T) {
 }
 
 var channelNameMessage = `{
-    "type": "message",
-    "subtype": "channel_name",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "old_name": "random",
-    "name": "watercooler",
-    "text": "<@U2147483828|cal> has renamed the channel from \"random\" to \"watercooler\""
+	"type": "message",
+	"subtype": "channel_name",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"old_name": "random",
+	"name": "watercooler",
+	"text": "<@U2147483828|cal> has renamed the channel from \"random\" to \"watercooler\""
 }`
 
 func TestChannelNameMessage(t *testing.T) {
@@ -515,12 +579,12 @@ func TestChannelNameMessage(t *testing.T) {
 }
 
 var channelArchiveMessage = `{
-    "type": "message",
-    "subtype": "channel_archive",
-    "ts": "1361482916.000003",
-    "text": " archived the channel",
-    "user": "U1234",
-    "members": ["U1234", "U5678"]
+	"type": "message",
+	"subtype": "channel_archive",
+	"ts": "1361482916.000003",
+	"text": " archived the channel",
+	"user": "U1234",
+	"members": ["U1234", "U5678"]
 }`
 
 func TestChannelArchiveMessage(t *testing.T) {
@@ -537,11 +601,11 @@ func TestChannelArchiveMessage(t *testing.T) {
 }
 
 var channelUnarchiveMessage = `{
-    "type": "message",
-    "subtype": "channel_unarchive",
-    "ts": "1361482916.000003",
-    "text": " un-archived the channel",
-    "user": "U1234"
+	"type": "message",
+	"subtype": "channel_unarchive",
+	"ts": "1361482916.000003",
+	"text": " un-archived the channel",
+	"user": "U1234"
 }`
 
 func TestChannelUnarchiveMessage(t *testing.T) {
@@ -556,25 +620,25 @@ func TestChannelUnarchiveMessage(t *testing.T) {
 }
 
 var channelRepliesParentMessage = `{
-    "type": "message",
-    "user": "U1234",
-    "text": "test",
-    "thread_ts": "1493305433.915644",
-    "reply_count": 2,
-    "replies": [
-        {
-            "user": "U5678",
-            "ts": "1493305444.920992"
-        },
-        {
-            "user": "U9012",
-            "ts": "1493305894.133936"
-        }
-    ],
-    "subscribed": true,
-    "last_read": "1493305894.133936",
-    "unread_count": 0,
-    "ts": "1493305433.915644"
+	"type": "message",
+	"user": "U1234",
+	"text": "test",
+	"thread_ts": "1493305433.915644",
+	"reply_count": 2,
+	"replies": [
+		{
+			"user": "U5678",
+			"ts": "1493305444.920992"
+		},
+		{
+			"user": "U9012",
+			"ts": "1493305894.133936"
+		}
+	],
+	"subscribed": true,
+	"last_read": "1493305894.133936",
+	"unread_count": 0,
+	"ts": "1493305433.915644"
 }`
 
 func TestChannelRepliesParentMessage(t *testing.T) {
@@ -594,12 +658,12 @@ func TestChannelRepliesParentMessage(t *testing.T) {
 }
 
 var channelRepliesChildMessage = `{
-    "type": "message",
-    "user": "U5678",
-    "text": "foo",
-    "thread_ts": "1493305433.915644",
-    "parent_user_id": "U1234",
-    "ts": "1493305444.920992"
+	"type": "message",
+	"user": "U5678",
+	"text": "foo",
+	"thread_ts": "1493305433.915644",
+	"parent_user_id": "U1234",
+	"ts": "1493305444.920992"
 }`
 
 func TestChannelRepliesChildMessage(t *testing.T) {
@@ -615,11 +679,11 @@ func TestChannelRepliesChildMessage(t *testing.T) {
 }
 
 var groupJoinMessage = `{
-    "type": "message",
-    "subtype": "group_join",
-    "ts": "1358877458.000011",
-    "user": "U2147483828",
-    "text": "<@U2147483828|cal> has joined the group"
+	"type": "message",
+	"subtype": "group_join",
+	"ts": "1358877458.000011",
+	"user": "U2147483828",
+	"text": "<@U2147483828|cal> has joined the group"
 }`
 
 func TestGroupJoinMessage(t *testing.T) {
@@ -634,11 +698,11 @@ func TestGroupJoinMessage(t *testing.T) {
 }
 
 var groupJoinInvitedMessage = `{
-    "type": "message",
-    "subtype": "group_join",
-    "ts": "1358877458.000011",
-    "user": "U2147483828",
-    "text": "<@U2147483828|cal> has joined the group",
+	"type": "message",
+	"subtype": "group_join",
+	"ts": "1358877458.000011",
+	"user": "U2147483828",
+	"text": "<@U2147483828|cal> has joined the group",
 		"inviter": "U2147483829"
 }`
 
@@ -655,11 +719,11 @@ func TestGroupJoinInvitedMessage(t *testing.T) {
 }
 
 var groupLeaveMessage = `{
-    "type": "message",
-    "subtype": "group_leave",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "text": "<@U2147483828|cal> has left the group"
+	"type": "message",
+	"subtype": "group_leave",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"text": "<@U2147483828|cal> has left the group"
 }`
 
 func TestGroupLeaveMessage(t *testing.T) {
@@ -674,12 +738,12 @@ func TestGroupLeaveMessage(t *testing.T) {
 }
 
 var groupTopicMessage = `{
-    "type": "message",
-    "subtype": "group_topic",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "topic": "hello world",
-    "text": "<@U2147483828|cal> set the group topic: hello world"
+	"type": "message",
+	"subtype": "group_topic",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"topic": "hello world",
+	"text": "<@U2147483828|cal> set the group topic: hello world"
 }`
 
 func TestGroupTopicMessage(t *testing.T) {
@@ -695,12 +759,12 @@ func TestGroupTopicMessage(t *testing.T) {
 }
 
 var groupPurposeMessage = `{
-    "type": "message",
-    "subtype": "group_purpose",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "purpose": "whatever",
-    "text": "<@U2147483828|cal> set the group purpose: whatever"
+	"type": "message",
+	"subtype": "group_purpose",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"purpose": "whatever",
+	"text": "<@U2147483828|cal> set the group purpose: whatever"
 }`
 
 func TestGroupPurposeMessage(t *testing.T) {
@@ -716,13 +780,13 @@ func TestGroupPurposeMessage(t *testing.T) {
 }
 
 var groupNameMessage = `{
-    "type": "message",
-    "subtype": "group_name",
-    "ts": "1358877455.000010",
-    "user": "U2147483828",
-    "old_name": "random",
-    "name": "watercooler",
-    "text": "<@U2147483828|cal> has renamed the group from \"random\" to \"watercooler\""
+	"type": "message",
+	"subtype": "group_name",
+	"ts": "1358877455.000010",
+	"user": "U2147483828",
+	"old_name": "random",
+	"name": "watercooler",
+	"text": "<@U2147483828|cal> has renamed the group from \"random\" to \"watercooler\""
 }`
 
 func TestGroupNameMessage(t *testing.T) {
@@ -739,12 +803,12 @@ func TestGroupNameMessage(t *testing.T) {
 }
 
 var groupArchiveMessage = `{
-    "type": "message",
-    "subtype": "group_archive",
-    "ts": "1361482916.000003",
-    "text": " archived the group",
-    "user": "U1234",
-    "members": ["U1234", "U5678"]
+	"type": "message",
+	"subtype": "group_archive",
+	"ts": "1361482916.000003",
+	"text": " archived the group",
+	"user": "U1234",
+	"members": ["U1234", "U5678"]
 }`
 
 func TestGroupArchiveMessage(t *testing.T) {
@@ -761,11 +825,11 @@ func TestGroupArchiveMessage(t *testing.T) {
 }
 
 var groupUnarchiveMessage = `{
-    "type": "message",
-    "subtype": "group_unarchive",
-    "ts": "1361482916.000003",
-    "text": " un-archived the group",
-    "user": "U1234"
+	"type": "message",
+	"subtype": "group_unarchive",
+	"ts": "1361482916.000003",
+	"text": " un-archived the group",
+	"user": "U1234"
 }`
 
 func TestGroupUnarchiveMessage(t *testing.T) {
@@ -780,52 +844,52 @@ func TestGroupUnarchiveMessage(t *testing.T) {
 }
 
 var fileShareMessage = `{
-    "type": "message",
-    "subtype": "file_share",
-    "ts": "1358877455.000010",
-    "text": "<@cal> uploaded a file: ",
-    "files": [{
-        "id" : "F2147483862",
-        "created" : 1356032811,
-        "timestamp" : 1356032811,
-        "name" : "file.htm",
-        "title" : "My HTML file",
-        "mimetype" : "text\/plain",
-        "filetype" : "text",
-        "pretty_type": "Text",
-        "user" : "U2147483697",
-        "mode" : "hosted",
-        "editable" : true,
-        "is_external": false,
-        "external_type": "",
-        "size" : 12345,
-        "url": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/1.png",
-        "url_download": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/download\/1.png",
-        "url_private": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/1.png",
-        "url_private_download": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/download\/1.png",
-        "thumb_64": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_64.png",
-        "thumb_80": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_80.png",
-        "thumb_360": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.png",
-        "thumb_360_gif": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.gif",
-        "thumb_360_w": 100,
-        "thumb_360_h": 100,
-        "permalink" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png",
-        "edit_link" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png/edit",
-        "preview" : "<!DOCTYPE html>\n<html>\n<meta charset='utf-8'>",
-        "preview_highlight" : "<div class=\"sssh-code\"><div class=\"sssh-line\"><pre><!DOCTYPE html...",
-        "lines" : 123,
-        "lines_more": 118,
-        "is_public": true,
-        "public_url_shared": false,
-        "channels": ["C024BE7LT"],
-        "groups": ["G12345"],
-        "ims": ["D12345"],
-        "initial_comment": {},
-        "num_stars": 7,
-        "is_starred": true
-    }],
-    "user": "U2147483697",
-    "upload": true
+	"type": "message",
+	"subtype": "file_share",
+	"ts": "1358877455.000010",
+	"text": "<@cal> uploaded a file: ",
+	"files": [{
+		"id" : "F2147483862",
+		"created" : 1356032811,
+		"timestamp" : 1356032811,
+		"name" : "file.htm",
+		"title" : "My HTML file",
+		"mimetype" : "text\/plain",
+		"filetype" : "text",
+		"pretty_type": "Text",
+		"user" : "U2147483697",
+		"mode" : "hosted",
+		"editable" : true,
+		"is_external": false,
+		"external_type": "",
+		"size" : 12345,
+		"url": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/1.png",
+		"url_download": "https:\/\/slack-files.com\/files-pub\/T024BE7LD-F024BERPE-09acb6\/download\/1.png",
+		"url_private": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/1.png",
+		"url_private_download": "https:\/\/slack.com\/files-pri\/T024BE7LD-F024BERPE\/download\/1.png",
+		"thumb_64": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_64.png",
+		"thumb_80": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_80.png",
+		"thumb_360": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.png",
+		"thumb_360_gif": "https:\/\/slack-files.com\/files-tmb\/T024BE7LD-F024BERPE-c66246\/1_360.gif",
+		"thumb_360_w": 100,
+		"thumb_360_h": 100,
+		"permalink" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png",
+		"edit_link" : "https:\/\/tinyspeck.slack.com\/files\/cal\/F024BERPE\/1.png/edit",
+		"preview" : "<!DOCTYPE html>\n<html>\n<meta charset='utf-8'>",
+		"preview_highlight" : "<div class=\"sssh-code\"><div class=\"sssh-line\"><pre><!DOCTYPE html...",
+		"lines" : 123,
+		"lines_more": 118,
+		"is_public": true,
+		"public_url_shared": false,
+		"channels": ["C024BE7LT"],
+		"groups": ["G12345"],
+		"ims": ["D12345"],
+		"initial_comment": {},
+		"num_stars": 7,
+		"is_starred": true
+	}],
+	"user": "U2147483697",
+	"upload": true
 }`
 
 func TestFileShareMessage(t *testing.T) {
diff --git a/metadata.go b/metadata.go
new file mode 100644
index 000000000..2ec435335
--- /dev/null
+++ b/metadata.go
@@ -0,0 +1,38 @@
+package slack
+
+// SlackMetadata https://api.slack.com/reference/metadata
+type SlackMetadata struct {
+	EventType    string         `json:"event_type"`
+	EventPayload map[string]any `json:"event_payload"`
+}
+
+// Work Object entity type constants.
+// See https://docs.slack.dev/messaging/work-objects/
+const (
+	EntityTypeTask        = "slack#/entities/task"
+	EntityTypeFile        = "slack#/entities/file"
+	EntityTypeItem        = "slack#/entities/item"
+	EntityTypeIncident    = "slack#/entities/incident"
+	EntityTypeContentItem = "slack#/entities/content_item"
+)
+
+// WorkObjectExternalRef represents an external reference for a Work Object
+type WorkObjectExternalRef struct {
+	ID   string `json:"id"`
+	Type string `json:"type,omitempty"`
+}
+
+// WorkObjectEntity represents a single Work Object entity
+type WorkObjectEntity struct {
+	AppUnfurlURL  string                `json:"app_unfurl_url,omitempty"`
+	URL           string                `json:"url"`
+	ExternalRef   WorkObjectExternalRef `json:"external_ref"`
+	EntityType    string                `json:"entity_type"`
+	EntityPayload map[string]any        `json:"entity_payload"`
+}
+
+// WorkObjectMetadata represents the metadata for Work Objects
+// Used in chat.unfurl and chat.postMessage for Work Objects support
+type WorkObjectMetadata struct {
+	Entities []WorkObjectEntity `json:"entities"`
+}
diff --git a/migration.go b/migration.go
new file mode 100644
index 000000000..fa6690ee3
--- /dev/null
+++ b/migration.go
@@ -0,0 +1,40 @@
+package slack
+
+import (
+	"context"
+	"net/url"
+)
+
+type migrationExchangeResponseFull struct {
+	TeamID         string            `json:"team_id"`
+	ToOld          bool              `json:"to_old"`
+	EnterpriseID   string            `json:"enterprise_id"`
+	UserIDMap      map[string]string `json:"user_id_map"`
+	InvalidUserIDs []string          `json:"invalid_user_ids"`
+	SlackResponse
+}
+
+// MigrationExchange for Enterprise Grid workspaces, map local user IDs to global user IDs
+func (api *Client) MigrationExchange(ctx context.Context, teamID string, toOld bool, users []string) (map[string]string, []string, error) {
+	values := url.Values{
+		"users": users,
+	}
+	if teamID != "" {
+		values.Add("team_id", teamID)
+	}
+	if toOld {
+		values.Add("to_old", "true")
+	}
+
+	response := &migrationExchangeResponseFull{}
+	err := api.getMethod(ctx, "migration.exchange", api.token, values, response)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if err := response.Err(); err != nil {
+		return nil, nil, err
+	}
+
+	return response.UserIDMap, response.InvalidUserIDs, nil
+}
diff --git a/misc.go b/misc.go
index 804724d7e..46060a720 100644
--- a/misc.go
+++ b/misc.go
@@ -7,7 +7,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"mime"
 	"mime/multipart"
 	"net/http"
@@ -18,15 +17,222 @@ import (
 	"strconv"
 	"strings"
 	"time"
-
-	"github.com/slack-go/slack/internal/misc"
 )
 
+// AppsManifestCreateResponseError ("/apps.manifest.create")
+type AppsManifestCreateResponseError struct {
+	Code             string `json:"code,omitempty"`
+	Message          string `json:"message"`
+	Pointer          string `json:"pointer"`
+	RelatedComponent string `json:"related_component,omitempty"`
+}
+
+// ConversationsInviteResponseError ("/conversations.invite")
+type ConversationsInviteResponseError struct {
+	Error string `json:"error"`
+	Ok    bool   `json:"ok"`
+	User  string `json:"user"`
+}
+
+func (t ConversationsInviteResponseError) Err() error {
+	if !t.Ok {
+		return fmt.Errorf("conversations invite error (user: %s): %s", t.User, t.Error)
+	}
+	return nil
+}
+
+// SlackResponseErrors represents a union type for different error structures
+type SlackResponseErrors struct {
+	AppsManifestCreateResponseError  *AppsManifestCreateResponseError  `json:"-"`
+	ConversationsInviteResponseError *ConversationsInviteResponseError `json:"-"`
+	Message                          *string                           `json:"-"`
+}
+
+// MarshalJSON implements custom marshaling for SlackResponseErrors
+func (e SlackResponseErrors) MarshalJSON() ([]byte, error) {
+	if e.AppsManifestCreateResponseError != nil {
+		return json.Marshal(e.AppsManifestCreateResponseError)
+	}
+	if e.ConversationsInviteResponseError != nil {
+		return json.Marshal(e.ConversationsInviteResponseError)
+	}
+	if e.Message != nil {
+		return json.Marshal(*e.Message)
+	}
+	return json.Marshal(nil)
+}
+
+// UnmarshalJSON implements custom unmarshaling for SlackResponseErrors
+func (e *SlackResponseErrors) UnmarshalJSON(data []byte) error {
+	if bytes.Equal(data, []byte("null")) {
+		return nil
+	}
+
+	// Try to determine the error type by checking for unique fields
+	var raw map[string]any
+	if err := json.Unmarshal(data, &raw); err != nil {
+		// If we can't unmarshal as object, try as string (fallback case)
+		//
+		// For more details on this specific problem look up issue
+		// https://github.com/slack-go/slack/issues/1446.
+		var stringError string
+		if stringErr := json.Unmarshal(data, &stringError); stringErr == nil {
+			e.Message = &stringError
+			return nil
+		}
+		return err
+	}
+
+	if _, hasPointer := raw["pointer"]; hasPointer {
+		if _, hasMessage := raw["message"]; hasMessage {
+			var amc AppsManifestCreateResponseError
+			if err := json.Unmarshal(data, &amc); err != nil {
+				return err
+			}
+			e.AppsManifestCreateResponseError = &amc
+			return nil
+		}
+	}
+
+	if _, hasUser := raw["user"]; hasUser {
+		if _, hasError := raw["error"]; hasError {
+			if _, hasOk := raw["ok"]; hasOk {
+				var ci ConversationsInviteResponseError
+				if err := json.Unmarshal(data, &ci); err != nil {
+					return err
+				}
+				e.ConversationsInviteResponseError = &ci
+				return nil
+			}
+		}
+	}
+
+	return fmt.Errorf("unknown error structure: %s", string(data))
+}
+
 // SlackResponse handles parsing out errors from the web api.
 type SlackResponse struct {
-	Ok               bool             `json:"ok"`
-	Error            string           `json:"error"`
-	ResponseMetadata ResponseMetadata `json:"response_metadata"`
+	Ok               bool                  `json:"ok"`
+	Error            string                `json:"error"`
+	Warning          string                `json:"warning"`
+	Errors           []SlackResponseErrors `json:"errors,omitempty"`
+	ResponseMetadata ResponseMetadata      `json:"response_metadata"`
+}
+
+// Warn returns warning information from the API response, or nil if there
+// are no warnings.
+func (t SlackResponse) Warn() *Warning {
+	if t.Warning == "" && len(t.ResponseMetadata.Warnings) == 0 {
+		return nil
+	}
+	return &Warning{
+		Codes:    strings.Split(t.Warning, ","),
+		Warnings: t.ResponseMetadata.Warnings,
+	}
+}
+
+// warner is satisfied by any response type that can report warnings.
+type warner interface {
+	Warn() *Warning
+}
+
+// Warning provides warning information from the web API.
+// https://docs.slack.dev/apis/web-api/#responses
+type Warning struct {
+	Codes    []string
+	Warnings []string
+}
+
+// httpHeaderSetter is satisfied by response types that can store HTTP
+// response headers. The response parser checks for this interface and
+// injects headers before JSON decoding.
+type httpHeaderSetter interface {
+	setHTTPResponseHeaders(http.Header)
+}
+
+// responseHeaders is a mix-in for internal response types that need to
+// capture HTTP response headers. Embedded in types like authTestResponseFull
+// so the parser can store headers that are then propagated to the public
+// response type (e.g. AuthTestResponse.Header).
+type responseHeaders struct {
+	header http.Header
+}
+
+func (r *responseHeaders) setHTTPResponseHeaders(h http.Header) { r.header = h }
+
+// KickUserFromConversationSlackResponse is a variant of SlackResponse that can handle the case where
+// "errors" can be either an empty object {} or an array of errors.
+// This addresses issue #1446 where conversations.kick endpoint returns {"ok":true,"errors":{}}
+type KickUserFromConversationSlackResponse struct {
+	Ok               bool                  `json:"ok"`
+	Error            string                `json:"error"`
+	Warning          string                `json:"warning"`
+	Errors           []SlackResponseErrors `json:"-"`
+	ResponseMetadata ResponseMetadata      `json:"response_metadata"`
+}
+
+// UnmarshalJSON implements custom unmarshaling for KickUserFromConversationSlackResponse to handle
+// the case where "errors" can be either an empty object {} or an array of errors
+func (s *KickUserFromConversationSlackResponse) UnmarshalJSON(data []byte) error {
+	// First, unmarshal everything except errors
+	type Alias KickUserFromConversationSlackResponse
+	aux := &struct {
+		*Alias
+		ErrorsRaw json.RawMessage `json:"errors,omitempty"`
+	}{
+		Alias: (*Alias)(s),
+	}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	// Handle the errors field
+	if len(aux.ErrorsRaw) > 0 {
+		// Check if it's an empty object by looking for just "{}"
+		trimmed := bytes.TrimSpace(aux.ErrorsRaw)
+		if bytes.Equal(trimmed, []byte("{}")) {
+			// Empty object, leave errors as nil/empty slice
+			s.Errors = nil
+		} else {
+			// Try to unmarshal as array of errors
+			var errors []SlackResponseErrors
+			if err := json.Unmarshal(aux.ErrorsRaw, &errors); err != nil {
+				return err
+			}
+			s.Errors = errors
+		}
+	}
+
+	return nil
+}
+
+// Warn returns warning information from the API response, or nil if there
+// are no warnings.
+func (s KickUserFromConversationSlackResponse) Warn() *Warning {
+	if s.Warning == "" && len(s.ResponseMetadata.Warnings) == 0 {
+		return nil
+	}
+	return &Warning{
+		Codes:    strings.Split(s.Warning, ","),
+		Warnings: s.ResponseMetadata.Warnings,
+	}
+}
+
+// Err returns any API error present in the response.
+func (s KickUserFromConversationSlackResponse) Err() error {
+	if s.Ok {
+		return nil
+	}
+
+	// handle pure text based responses like chat.post
+	// which while they have a slack response in their data structure
+	// it doesn't actually get set during parsing.
+	if strings.TrimSpace(s.Error) == "" {
+		return nil
+	}
+
+	return SlackErrorResponse{Err: s.Error, Errors: s.Errors, ResponseMetadata: s.ResponseMetadata}
 }
 
 func (t SlackResponse) Err() error {
@@ -41,18 +247,19 @@ func (t SlackResponse) Err() error {
 		return nil
 	}
 
-	return SlackErrorResponse{Err: t.Error, ResponseMetadata: t.ResponseMetadata}
+	return SlackErrorResponse{Err: t.Error, Errors: t.Errors, ResponseMetadata: t.ResponseMetadata}
 }
 
 // SlackErrorResponse brings along the metadata of errors returned by the Slack API.
 type SlackErrorResponse struct {
 	Err              string
+	Errors           []SlackResponseErrors
 	ResponseMetadata ResponseMetadata
 }
 
 func (r SlackErrorResponse) Error() string { return r.Err }
 
-// RateLimitedError represents the rate limit respond from slack
+// RateLimitedError represents the rate limit response from slack
 type RateLimitedError struct {
 	RetryAfter time.Duration
 }
@@ -65,13 +272,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
 }
 
@@ -114,7 +320,7 @@ func formReq(ctx context.Context, endpoint string, values url.Values) (req *http
 	return req, nil
 }
 
-func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http.Request, err error) {
+func jsonReq(ctx context.Context, endpoint string, body any) (req *http.Request, err error) {
 	buffer := bytes.NewBuffer([]byte{})
 	if err = json.NewEncoder(buffer).Encode(body); err != nil {
 		return nil, err
@@ -128,20 +334,7 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http.
 	return req, nil
 }
 
-func parseResponseBody(body io.ReadCloser, intf interface{}, d Debug) error {
-	response, err := ioutil.ReadAll(body)
-	if err != nil {
-		return err
-	}
-
-	if d.Debug() {
-		d.Debugln("parseResponseBody", string(response))
-	}
-
-	return json.Unmarshal(response, intf)
-}
-
-func postLocalWithMultipartResponse(ctx context.Context, client httpClient, method, fpath, fieldname, token string, values url.Values, intf interface{}, d Debug) error {
+func postLocalWithMultipartResponse(ctx context.Context, client httpClient, method, fpath, fieldname, token string, values url.Values, intf any, d Debug) error {
 	fullpath, err := filepath.Abs(fpath)
 	if err != nil {
 		return err
@@ -155,12 +348,19 @@ func postLocalWithMultipartResponse(ctx context.Context, client httpClient, meth
 	return postWithMultipartResponse(ctx, client, method, filepath.Base(fpath), fieldname, token, values, file, intf, d)
 }
 
-func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf interface{}, d Debug) error {
+func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf any, 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
@@ -176,7 +376,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
 	}
@@ -202,61 +403,81 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam
 	}
 }
 
-func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error {
+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(client httpClient, req *http.Request, parser responseParser, d Debug) (http.Header, error) {
 	resp, err := client.Do(req)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	defer resp.Body.Close()
 
-	err = checkStatusCode(resp, d)
-	if err != nil {
-		return err
+	if err = checkStatusCode(resp, d); err != nil {
+		return nil, err
 	}
 
-	return parser(resp)
+	return resp.Header, parser(resp)
 }
 
 // post JSON.
-func postJSON(ctx context.Context, client httpClient, endpoint, token string, json []byte, intf interface{}, d Debug) error {
-	reqBody := bytes.NewBuffer(json)
-	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody)
+func postJSON(ctx context.Context, client httpClient, endpoint, token string, jsonBody []byte, intf any, d Debug) (http.Header, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody))
 	if err != nil {
-		return err
+		return nil, err
 	}
 	req.Header.Set("Content-Type", "application/json")
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
-
-	return doPost(ctx, client, req, newJSONParser(intf), d)
+	// allow retry client to re-send the request body on 429/5xx.
+	req.GetBody = func() (io.ReadCloser, error) {
+		return io.NopCloser(bytes.NewReader(jsonBody)), nil
+	}
+	return doPost(client, req, newJSONParser(intf), d)
 }
 
 // post a url encoded form.
-func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf interface{}, d Debug) error {
-	reqBody := strings.NewReader(values.Encode())
-	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reqBody)
+func postForm(ctx context.Context, client httpClient, endpoint string, values url.Values, intf any, d Debug) (http.Header, error) {
+	body := values.Encode()
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body))
 	if err != nil {
-		return err
+		return nil, err
 	}
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	return doPost(ctx, client, req, newJSONParser(intf), d)
+	// allow retry client to re-send the request body on 429/5xx.
+	req.GetBody = func() (io.ReadCloser, error) {
+		return io.NopCloser(strings.NewReader(body)), nil
+	}
+	return doPost(client, req, newJSONParser(intf), d)
 }
 
-func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf interface{}, d Debug) error {
+func getResource(ctx context.Context, client httpClient, endpoint, token string, values url.Values, intf any, d Debug) (http.Header, error) {
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 
 	req.URL.RawQuery = values.Encode()
 
-	return doPost(ctx, client, req, newJSONParser(intf), d)
+	return doPost(client, req, newJSONParser(intf), d)
 }
 
-func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf interface{}, d Debug) error {
+func parseAdminResponse(ctx context.Context, client httpClient, method string, teamName string, values url.Values, intf any, d Debug) error {
 	endpoint := fmt.Sprintf(WEBAPIURLFormat, teamName, method, time.Now().Unix())
-	return postForm(ctx, client, endpoint, values, intf, d)
+	_, err := postForm(ctx, client, endpoint, values, intf, d)
+	return err
 }
 
 func logResponse(resp *http.Response, d Debug) error {
@@ -279,16 +500,8 @@ func okJSONHandler(rw http.ResponseWriter, r *http.Request) {
 	rw.Write(response)
 }
 
-// timerReset safely reset a timer, see time.Timer.Reset for details.
-func timerReset(t *time.Timer, d time.Duration) {
-	if !t.Stop() {
-		<-t.C
-	}
-	t.Reset(d)
-}
-
 func checkStatusCode(resp *http.Response, d Debug) error {
-	if resp.StatusCode == http.StatusTooManyRequests {
+	if resp.StatusCode == http.StatusTooManyRequests && resp.Header.Get("Retry-After") != "" {
 		retry, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 64)
 		if err != nil {
 			return err
@@ -299,7 +512,7 @@ func checkStatusCode(resp *http.Response, d Debug) error {
 	// Slack seems to send an HTML body along with 5xx error codes. Don't parse it.
 	if resp.StatusCode != http.StatusOK {
 		logResponse(resp, d)
-		return misc.StatusCodeError{Code: resp.StatusCode, Status: resp.Status}
+		return StatusCodeError{Code: resp.StatusCode, Status: resp.Status}
 	}
 
 	return nil
@@ -307,15 +520,25 @@ func checkStatusCode(resp *http.Response, d Debug) error {
 
 type responseParser func(*http.Response) error
 
-func newJSONParser(dst interface{}) responseParser {
+func newJSONParser(dst any) responseParser {
 	return func(resp *http.Response) error {
+		if dst == nil {
+			return nil
+		}
+		if hs, ok := dst.(httpHeaderSetter); ok {
+			hs.setHTTPResponseHeaders(resp.Header.Clone())
+		}
 		return json.NewDecoder(resp.Body).Decode(dst)
 	}
 }
 
-func newTextParser(dst interface{}) responseParser {
+func newTextParser(dst any) responseParser {
 	return func(resp *http.Response) error {
-		b, err := ioutil.ReadAll(resp.Body)
+		if dst == nil {
+			return nil
+		}
+
+		b, err := io.ReadAll(resp.Body)
 		if err != nil {
 			return err
 		}
@@ -328,7 +551,7 @@ func newTextParser(dst interface{}) responseParser {
 	}
 }
 
-func newContentTypeParser(dst interface{}) responseParser {
+func newContentTypeParser(dst any) responseParser {
 	return func(req *http.Response) (err error) {
 		var (
 			ctype string
@@ -342,6 +565,10 @@ func newContentTypeParser(dst interface{}) responseParser {
 		case "application/json":
 			return newJSONParser(dst)(req)
 		default:
+			// newTextParser doesn't use dst, so capture headers here.
+			if hs, ok := dst.(httpHeaderSetter); ok {
+				hs.setHTTPResponseHeaders(req.Header.Clone())
+			}
 			return newTextParser(dst)(req)
 		}
 	}
diff --git a/misc_test.go b/misc_test.go
index 4dd2a6378..9f9e9abe4 100644
--- a/misc_test.go
+++ b/misc_test.go
@@ -2,14 +2,13 @@ package slack
 
 import (
 	"context"
+	"encoding/json"
 	"log"
 	"net/http"
 	"net/url"
 	"sync"
 	"testing"
 
-	"github.com/slack-go/slack/internal/misc"
-
 	"github.com/slack-go/slack/slackutilsx"
 )
 
@@ -46,7 +45,7 @@ func TestParseResponse(t *testing.T) {
 	}
 
 	responsePartial := &SlackResponse{}
-	err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
+	_, err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
 	if err != nil {
 		t.Errorf("Unexpected error: %s", err)
 	}
@@ -59,7 +58,7 @@ func TestParseResponseNoToken(t *testing.T) {
 	values := url.Values{}
 
 	responsePartial := &SlackResponse{}
-	err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
+	_, err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
 	if err != nil {
 		t.Errorf("Unexpected error: %s", err)
 		return
@@ -79,7 +78,7 @@ func TestParseResponseInvalidToken(t *testing.T) {
 		"token": {"whatever"},
 	}
 	responsePartial := &SlackResponse{}
-	err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
+	_, err := postForm(context.Background(), http.DefaultClient, APIURL+"parseResponse", values, responsePartial, discard{})
 	if err != nil {
 		t.Errorf("Unexpected error: %s", err)
 		return
@@ -94,8 +93,8 @@ func TestParseResponseInvalidToken(t *testing.T) {
 func TestRetryable(t *testing.T) {
 	for _, e := range []error{
 		&RateLimitedError{},
-		misc.StatusCodeError{Code: http.StatusInternalServerError},
-		misc.StatusCodeError{Code: http.StatusTooManyRequests},
+		StatusCodeError{Code: http.StatusInternalServerError},
+		StatusCodeError{Code: http.StatusTooManyRequests},
 	} {
 		r, ok := e.(slackutilsx.Retryable)
 		if !ok {
@@ -106,3 +105,289 @@ func TestRetryable(t *testing.T) {
 		}
 	}
 }
+
+func TestSlackResponseErrorsMarshaling(t *testing.T) {
+	tests := []struct {
+		name     string
+		errors   SlackResponseErrors
+		expected string
+	}{
+		{
+			name: "AppsManifestCreateResponseError",
+			errors: SlackResponseErrors{
+				AppsManifestCreateResponseError: &AppsManifestCreateResponseError{
+					Message: "Interactivity requires Socket Mode enabled",
+					Pointer: "/settings/interactivity",
+				},
+			},
+			expected: `{"message":"Interactivity requires Socket Mode enabled","pointer":"/settings/interactivity"}`,
+		},
+		{
+			name: "ConversationsInviteResponseError",
+			errors: SlackResponseErrors{
+				ConversationsInviteResponseError: &ConversationsInviteResponseError{
+					Error: "invalid_user",
+					Ok:    false,
+					User:  "U12345678",
+				},
+			},
+			expected: `{"error":"invalid_user","ok":false,"user":"U12345678"}`,
+		},
+		{
+			name: "StringError",
+			errors: SlackResponseErrors{
+				Message: func() *string { s := "failed to match all allowed schemas"; return &s }(),
+			},
+			expected: `"failed to match all allowed schemas"`,
+		},
+		{
+			name:     "EmptyErrors",
+			errors:   SlackResponseErrors{},
+			expected: `null`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			data, err := json.Marshal(tt.errors)
+			if err != nil {
+				t.Fatalf("Marshal failed: %v", err)
+			}
+			if string(data) != tt.expected {
+				t.Errorf("got %s; want %s", string(data), tt.expected)
+			}
+		})
+	}
+}
+
+func TestSlackResponseErrorsUnmarshaling(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected SlackResponseErrors
+	}{
+		{
+			name:  "AppsManifestCreateResponseError",
+			input: `{"pointer":"/settings/interactivity","message":"Interactivity requires Socket Mode enabled"}`,
+			expected: SlackResponseErrors{
+				AppsManifestCreateResponseError: &AppsManifestCreateResponseError{
+					Pointer: "/settings/interactivity",
+					Message: "Interactivity requires Socket Mode enabled",
+				},
+			},
+		},
+		{
+			name:  "ConversationsInviteResponseError",
+			input: `{"error":"invalid_user","ok":false,"user":"U12345678"}`,
+			expected: SlackResponseErrors{
+				ConversationsInviteResponseError: &ConversationsInviteResponseError{
+					Error: "invalid_user",
+					Ok:    false,
+					User:  "U12345678",
+				},
+			},
+		},
+		{
+			name:     "NullInput",
+			input:    `null`,
+			expected: SlackResponseErrors{},
+		},
+		{
+			name:  "StringError",
+			input: `"failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"`,
+			expected: SlackResponseErrors{
+				Message: new("failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var errors SlackResponseErrors
+			err := json.Unmarshal([]byte(tt.input), &errors)
+			if err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+
+			if tt.expected.AppsManifestCreateResponseError != nil {
+				if errors.AppsManifestCreateResponseError == nil {
+					t.Error("expected AppsManifestCreateResponseError, got nil")
+				} else if *errors.AppsManifestCreateResponseError != *tt.expected.AppsManifestCreateResponseError {
+					t.Errorf("got %+v; want %+v", *errors.AppsManifestCreateResponseError, *tt.expected.AppsManifestCreateResponseError)
+				}
+			}
+
+			if tt.expected.ConversationsInviteResponseError != nil {
+				if errors.ConversationsInviteResponseError == nil {
+					t.Error("expected ConversationsInviteResponseError, got nil")
+				} else if *errors.ConversationsInviteResponseError != *tt.expected.ConversationsInviteResponseError {
+					t.Errorf("got %+v; want %+v", *errors.ConversationsInviteResponseError, *tt.expected.ConversationsInviteResponseError)
+				}
+			}
+
+			if tt.expected.Message != nil {
+				if errors.Message == nil {
+					t.Error("expected Message, got nil")
+				} else if *errors.Message != *tt.expected.Message {
+					t.Errorf("got %+v; want %+v", *errors.Message, *tt.expected.Message)
+				}
+			}
+		})
+	}
+}
+
+func TestSlackResponseErrorsUnmarshalingUnknownStructure(t *testing.T) {
+	input := `{"unknown_field":"value","other_field":123}`
+	var errors SlackResponseErrors
+	err := json.Unmarshal([]byte(input), &errors)
+	if err == nil {
+		t.Error("expected error for unknown structure, got nil")
+	}
+	expectedError := "unknown error structure: " + input
+	if err.Error() != expectedError {
+		t.Errorf("got error %q; want %q", err.Error(), expectedError)
+	}
+}
+
+func TestSlackResponseWithErrors(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected SlackResponse
+	}{
+		{
+			name:  "ResponseWithAppsManifestCreateResponseError",
+			input: `{"ok":false,"error":"invalid_manifest","errors":[{"pointer":"/settings/interactivity","message":"Interactivity requires Socket Mode enabled"}]}`,
+			expected: SlackResponse{
+				Ok:    false,
+				Error: "invalid_manifest",
+				Errors: []SlackResponseErrors{
+					{
+						AppsManifestCreateResponseError: &AppsManifestCreateResponseError{
+							Pointer: "/settings/interactivity",
+							Message: "Interactivity requires Socket Mode enabled",
+						},
+					},
+				},
+			},
+		},
+		{
+			name:  "ResponseWithoutErrors",
+			input: `{"ok":true}`,
+			expected: SlackResponse{
+				Ok: true,
+			},
+		},
+		{
+			name:  "ResponseWithStringErrors",
+			input: `{"ok":false,"error":"invalid_blocks","errors":["failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]","invalid additional property: emoji [json-pointer:\\/blocks\\/3\\/text]"]}`,
+			expected: SlackResponse{
+				Ok:    false,
+				Error: "invalid_blocks",
+				Errors: []SlackResponseErrors{
+					{
+						Message: new("failed to match all allowed schemas [json-pointer:\\/blocks\\/3\\/text]"),
+					},
+					{
+						Message: new("invalid additional property: emoji [json-pointer:\\/blocks\\/3\\/text]"),
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var response SlackResponse
+			err := json.Unmarshal([]byte(tt.input), &response)
+			if err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+
+			if response.Ok != tt.expected.Ok {
+				t.Errorf("got Ok=%v; want Ok=%v", response.Ok, tt.expected.Ok)
+			}
+			if response.Error != tt.expected.Error {
+				t.Errorf("got Error=%q; want Error=%q", response.Error, tt.expected.Error)
+			}
+
+			if tt.expected.Errors == nil {
+				if response.Errors != nil {
+					t.Error("expected nil Errors, got non-nil")
+				}
+			} else {
+				if response.Errors == nil {
+					t.Error("expected non-nil Errors, got nil")
+					return
+				}
+				if len(tt.expected.Errors) == 0 {
+					t.Errorf("got Errors=%v; want Errors=%v", response.Errors, tt.expected.Errors)
+				}
+			}
+		})
+	}
+}
+
+func TestKickUserFromConversationSlackResponseWithErrors(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected KickUserFromConversationSlackResponse
+	}{
+		{
+			name:  "ResponseWithEmptyErrorsObject",
+			input: `{"ok":true,"errors":{}}`,
+			expected: KickUserFromConversationSlackResponse{
+				Ok:     true,
+				Errors: nil,
+			},
+		},
+		{
+			name:  "ResponseWithErrorsArray",
+			input: `{"ok":false,"error":"some_error","errors":[]}`,
+			expected: KickUserFromConversationSlackResponse{
+				Ok:     false,
+				Error:  "some_error",
+				Errors: []SlackResponseErrors{},
+			},
+		},
+		{
+			name:  "ResponseWithoutErrors",
+			input: `{"ok":true}`,
+			expected: KickUserFromConversationSlackResponse{
+				Ok: true,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var response KickUserFromConversationSlackResponse
+			err := json.Unmarshal([]byte(tt.input), &response)
+			if err != nil {
+				t.Fatalf("Unmarshal failed: %v", err)
+			}
+
+			if response.Ok != tt.expected.Ok {
+				t.Errorf("got Ok=%v; want Ok=%v", response.Ok, tt.expected.Ok)
+			}
+			if response.Error != tt.expected.Error {
+				t.Errorf("got Error=%q; want Error=%q", response.Error, tt.expected.Error)
+			}
+
+			if tt.expected.Errors == nil {
+				if response.Errors != nil {
+					t.Error("expected nil Errors, got non-nil")
+				}
+			} else {
+				if response.Errors == nil {
+					t.Error("expected non-nil Errors, got nil")
+					return
+				}
+				if len(tt.expected.Errors) != len(response.Errors) {
+					t.Errorf("got Errors length=%d; want length=%d", len(response.Errors), len(tt.expected.Errors))
+				}
+			}
+		})
+	}
+}
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 000000000..43e556563
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,4 @@
+[tools]
+go = "1.26.7"
+golangci-lint = "2.12.2"
+"go:honnef.co/go/tools/cmd/staticcheck" = "2026.1"
diff --git a/oauth.go b/oauth.go
index 94b6546d5..f98284d23 100644
--- a/oauth.go
+++ b/oauth.go
@@ -2,6 +2,9 @@ package slack
 
 import (
 	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
 	"net/url"
 )
 
@@ -33,17 +36,18 @@ type OAuthResponse struct {
 
 // OAuthV2Response ...
 type OAuthV2Response struct {
-	AccessToken     string                       `json:"access_token"`
-	TokenType       string                       `json:"token_type"`
-	Scope           string                       `json:"scope"`
-	BotUserID       string                       `json:"bot_user_id"`
-	AppID           string                       `json:"app_id"`
-	Team            OAuthV2ResponseTeam          `json:"team"`
-	IncomingWebhook OAuthResponseIncomingWebhook `json:"incoming_webhook"`
-	Enterprise      OAuthV2ResponseEnterprise    `json:"enterprise"`
-	AuthedUser      OAuthV2ResponseAuthedUser    `json:"authed_user"`
-	RefreshToken    string                       `json:"refresh_token"`
-	ExpiresIn       int                          `json:"expires_in"`
+	AccessToken         string                       `json:"access_token"`
+	TokenType           string                       `json:"token_type"`
+	Scope               string                       `json:"scope"`
+	BotUserID           string                       `json:"bot_user_id"`
+	AppID               string                       `json:"app_id"`
+	Team                OAuthV2ResponseTeam          `json:"team"`
+	IncomingWebhook     OAuthResponseIncomingWebhook `json:"incoming_webhook"`
+	Enterprise          OAuthV2ResponseEnterprise    `json:"enterprise"`
+	IsEnterpriseInstall bool                         `json:"is_enterprise_install"`
+	AuthedUser          OAuthV2ResponseAuthedUser    `json:"authed_user"`
+	RefreshToken        string                       `json:"refresh_token"`
+	ExpiresIn           int                          `json:"expires_in"`
 	SlackResponse
 }
 
@@ -69,14 +73,56 @@ type OAuthV2ResponseAuthedUser struct {
 	TokenType    string `json:"token_type"`
 }
 
-// GetOAuthToken retrieves an AccessToken
-func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) {
-	return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
+// OpenIDConnectResponse ...
+type OpenIDConnectResponse struct {
+	Ok          bool   `json:"ok"`
+	AccessToken string `json:"access_token"`
+	TokenType   string `json:"token_type"`
+	IdToken     string `json:"id_token"`
+	SlackResponse
+}
+
+type oauthConfig struct {
+	apiURL       string
+	codeVerifier string
+}
+
+// OAuthOption configures package-level OAuth functions.
+type OAuthOption func(*oauthConfig)
+
+// OAuthOptionAPIURL overrides the default Slack API URL. Useful for testing.
+func OAuthOptionAPIURL(url string) OAuthOption {
+	return func(c *oauthConfig) { c.apiURL = url }
 }
 
-// GetOAuthTokenContext retrieves an AccessToken with a custom context
-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)
+// OAuthOptionCodeVerifier sets the PKCE code_verifier for the OAuth token exchange.
+// Use this when your authorization request included a code_challenge.
+func OAuthOptionCodeVerifier(verifier string) OAuthOption {
+	return func(c *oauthConfig) { c.codeVerifier = verifier }
+}
+
+func resolveOAuthConfig(opts []OAuthOption) oauthConfig {
+	c := oauthConfig{apiURL: APIURL}
+	for _, o := range opts {
+		o(&c)
+	}
+	return c
+}
+
+func resolveOAuthAPIURL(opts []OAuthOption) string {
+	return resolveOAuthConfig(opts).apiURL
+}
+
+// GetOAuthToken retrieves an AccessToken.
+// For more details, see GetOAuthTokenContext documentation.
+func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (accessToken string, scope string, err error) {
+	return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
+}
+
+// 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, opts ...OAuthOption) (accessToken string, scope string, err error) {
+	response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI, opts...)
 	if err != nil {
 		return "", "", err
 	}
@@ -84,26 +130,30 @@ 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
-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)
+// For more details, see GetBotOAuthTokenContext documentation.
+func GetBotOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (accessToken string, scope string, bot OAuthResponseBot, err error) {
+	return GetBotOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
 }
 
-// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context
-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)
+// 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, opts ...OAuthOption) (accessToken string, scope string, bot OAuthResponseBot, err error) {
+	response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI, opts...)
 	if err != nil {
 		return "", "", OAuthResponseBot{}, err
 	}
 	return response.AccessToken, response.Scope, response.Bot, nil
 }
 
-// GetOAuthResponse retrieves OAuth response
-func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) {
-	return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
+// GetOAuthResponse retrieves OAuth response.
+// For more details, see GetOAuthResponseContext documentation.
+func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthResponse, err error) {
+	return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
 }
 
-// GetOAuthResponseContext retrieves OAuth response with custom context
-func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) {
+// 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, opts ...OAuthOption) (resp *OAuthResponse, err error) {
 	values := url.Values{
 		"client_id":     {clientID},
 		"client_secret": {clientSecret},
@@ -111,48 +161,170 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c
 		"redirect_uri":  {redirectURI},
 	}
 	response := &OAuthResponse{}
-	if err = postForm(ctx, client, APIURL+"oauth.access", values, response, discard{}); err != nil {
+	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.access", values, response, discard{}); err != nil {
 		return nil, err
 	}
 	return response, response.Err()
 }
 
-// GetOAuthV2Response gets a V2 OAuth access token response - https://api.slack.com/methods/oauth.v2.access
-func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) {
-	return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI)
+// GetOAuthV2Response gets a V2 OAuth access token response.
+// For more details, see GetOAuthV2ResponseContext documentation.
+func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
+	return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
 }
 
-// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response
-func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) {
+// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response.
+// For PKCE flows, pass OAuthOptionCodeVerifier and an empty clientSecret.
+// Slack API docs: https://api.slack.com/methods/oauth.v2.access
+func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
+	cfg := resolveOAuthConfig(opts)
 	values := url.Values{
-		"client_id":     {clientID},
-		"client_secret": {clientSecret},
-		"code":          {code},
-		"redirect_uri":  {redirectURI},
+		"client_id":    {clientID},
+		"code":         {code},
+		"redirect_uri": {redirectURI},
+	}
+	if clientSecret != "" {
+		values.Set("client_secret", clientSecret)
+	}
+	if cfg.codeVerifier != "" {
+		values.Set("code_verifier", cfg.codeVerifier)
 	}
 	response := &OAuthV2Response{}
-	if err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
+	if _, err = postForm(ctx, client, cfg.apiURL+"oauth.v2.access", values, response, discard{}); err != nil {
 		return nil, err
 	}
 	return response, response.Err()
 }
 
-// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response
-func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) {
-	return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken)
+// 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, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
+	return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken, opts...)
 }
 
-// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response
-func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) {
+// RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response.
+// For PKCE public clients, pass an empty clientSecret.
+// Slack API docs: https://api.slack.com/methods/oauth.v2.access
+func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string, opts ...OAuthOption) (resp *OAuthV2Response, err error) {
 	values := url.Values{
 		"client_id":     {clientID},
-		"client_secret": {clientSecret},
 		"refresh_token": {refreshToken},
 		"grant_type":    {"refresh_token"},
 	}
+	if clientSecret != "" {
+		values.Set("client_secret", clientSecret)
+	}
 	response := &OAuthV2Response{}
-	if err = postForm(ctx, client, APIURL+"oauth.v2.access", values, response, discard{}); err != nil {
+	if _, err = postForm(ctx, client, resolveOAuthAPIURL(opts)+"oauth.v2.access", values, response, discard{}); err != nil {
 		return nil, err
 	}
 	return response, response.Err()
 }
+
+// OpenIDConnectUserInfoResponse contains the response from openid.connect.userInfo.
+//
+// Some of the fields in the response to this method are preceded with https://slack.com/.
+// These fields are Slack-specific, and they're from the perspective of Slack.
+type OpenIDConnectUserInfoResponse struct {
+	Ok bool `json:"ok"`
+
+	Sub string `json:"sub"`
+
+	UserID string `json:"https://slack.com/user_id"`
+	TeamID string `json:"https://slack.com/team_id"`
+
+	Email             string `json:"email"`
+	EmailVerified     bool   `json:"email_verified"`
+	DateEmailVerified int64  `json:"date_email_verified"`
+
+	Name       string `json:"name"`
+	Picture    string `json:"picture"`
+	GivenName  string `json:"given_name"`
+	FamilyName string `json:"family_name"`
+	Locale     string `json:"locale"`
+
+	TeamName     string `json:"https://slack.com/team_name"`
+	TeamDomain   string `json:"https://slack.com/team_domain"`
+	TeamImage34  string `json:"https://slack.com/team_image_34"`
+	TeamImage44  string `json:"https://slack.com/team_image_44"`
+	TeamImage68  string `json:"https://slack.com/team_image_68"`
+	TeamImage88  string `json:"https://slack.com/team_image_88"`
+	TeamImage102 string `json:"https://slack.com/team_image_102"`
+	TeamImage132 string `json:"https://slack.com/team_image_132"`
+	TeamImage230 string `json:"https://slack.com/team_image_230"`
+
+	// `TeamImageDefault` indicates whether the image is a default one (true), or someone
+	// uploaded their own (false).
+	TeamImageDefault bool `json:"https://slack.com/team_image_default"`
+
+	UserImage24       string `json:"https://slack.com/user_image_24"`
+	UserImage32       string `json:"https://slack.com/user_image_32"`
+	UserImage48       string `json:"https://slack.com/user_image_48"`
+	UserImage72       string `json:"https://slack.com/user_image_72"`
+	UserImage192      string `json:"https://slack.com/user_image_192"`
+	UserImage512      string `json:"https://slack.com/user_image_512"`
+	UserImage1024     string `json:"https://slack.com/user_image_1024"`
+	UserImageOriginal string `json:"https://slack.com/user_image_original"`
+
+	SlackResponse
+}
+
+// GetOpenIDConnectUserInfo returns the user info for the token.
+// For more details, see GetOpenIDConnectUserInfoContext documentation.
+func (api *Client) GetOpenIDConnectUserInfo() (*OpenIDConnectUserInfoResponse, error) {
+	return api.GetOpenIDConnectUserInfoContext(context.Background())
+}
+
+// GetOpenIDConnectUserInfoContext returns identity information about the user associated with the token.
+// Slack API docs: https://docs.slack.dev/reference/methods/openid.connect.userInfo
+func (api *Client) GetOpenIDConnectUserInfoContext(ctx context.Context) (*OpenIDConnectUserInfoResponse, error) {
+	values := url.Values{
+		"token": {api.token},
+	}
+	response := &OpenIDConnectUserInfoResponse{}
+	err := api.postMethod(ctx, "openid.connect.userInfo", values, response)
+	if err != nil {
+		return nil, err
+	}
+	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, opts ...OAuthOption) (resp *OpenIDConnectResponse, err error) {
+	return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI, opts...)
+}
+
+// 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, opts ...OAuthOption) (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, resolveOAuthAPIURL(opts)+"openid.connect.token", values, response, discard{}); err != nil {
+		return nil, err
+	}
+	return response, response.Err()
+}
+
+// GenerateCodeVerifier creates a cryptographically random PKCE code verifier
+// string suitable for use with OAuth 2.0 PKCE flows. The returned string is
+// 43 characters of URL-safe base64 (no padding).
+func GenerateCodeVerifier() (string, error) {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// GenerateCodeChallenge creates a PKCE code challenge from a code verifier
+// using the S256 method (SHA-256 hash, base64url-encoded without padding).
+func GenerateCodeChallenge(verifier string) string {
+	h := sha256.Sum256([]byte(verifier))
+	return base64.RawURLEncoding.EncodeToString(h[:])
+}
diff --git a/oauth_test.go b/oauth_test.go
new file mode 100644
index 000000000..2aa8b2a0f
--- /dev/null
+++ b/oauth_test.go
@@ -0,0 +1,153 @@
+package slack
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func getOAuthV2Response(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	rw.Write([]byte(`{
+		"ok": true,
+		"access_token": "xoxb-test-token",
+		"token_type": "bot",
+		"scope": "chat:write",
+		"bot_user_id": "U0KRQLJ9H",
+		"app_id": "A0KRD7HC3",
+		"team": {"name": "Test Team", "id": "T0KRQLJ9H"},
+		"enterprise": {"name": "", "id": ""},
+		"is_enterprise_install": false,
+		"authed_user": {"id": "U0KRQLJ9H"}
+	}`))
+}
+
+func TestGetOAuthV2ResponseWithCustomURL(t *testing.T) {
+	http.HandleFunc("/oauth.v2.access", getOAuthV2Response)
+
+	once.Do(startServer)
+
+	resp, err := GetOAuthV2Response(
+		http.DefaultClient,
+		"client-id", "client-secret", "code", "http://localhost/callback",
+		OAuthOptionAPIURL("http://"+serverAddr+"/"),
+	)
+	require.NoError(t, err)
+
+	assert.Equal(t, "xoxb-test-token", resp.AccessToken)
+	assert.Equal(t, "bot", resp.TokenType)
+	assert.Equal(t, "chat:write", resp.Scope)
+	assert.Equal(t, "U0KRQLJ9H", resp.BotUserID)
+	assert.Equal(t, "T0KRQLJ9H", resp.Team.ID)
+}
+
+func getOpenIDConnectUserInfo(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	rw.Write([]byte(`{
+		"ok": true,
+		"sub": "U0R7JM",
+		"https://slack.com/user_id": "U0R7JM",
+		"https://slack.com/team_id": "T0R7GR",
+		"email": "krane@slack-corp.com",
+		"email_verified": true,
+		"date_email_verified": 1622128723,
+		"name": "krane",
+		"picture": "https://secure.gravatar.com/....png",
+		"given_name": "Bront",
+		"family_name": "Kansen",
+		"locale": "en-US",
+		"https://slack.com/team_name": "Slack Corp",
+		"https://slack.com/team_domain": "slackcorp",
+		"https://slack.com/user_image_24": "...",
+		"https://slack.com/user_image_32": "...",
+		"https://slack.com/user_image_48": "...",
+		"https://slack.com/user_image_72": "...",
+		"https://slack.com/user_image_192": "...",
+		"https://slack.com/user_image_512": "...",
+		"https://slack.com/team_image_default": true
+	}`))
+}
+
+func TestGetOpenIDConnectUserInfo(t *testing.T) {
+	http.HandleFunc("/openid.connect.userInfo", getOpenIDConnectUserInfo)
+
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+
+	resp, err := api.GetOpenIDConnectUserInfo()
+	require.NoError(t, err)
+
+	assert.Equal(t, "U0R7JM", resp.Sub)
+	assert.Equal(t, "U0R7JM", resp.UserID)
+	assert.Equal(t, "T0R7GR", resp.TeamID)
+	assert.Equal(t, "krane@slack-corp.com", resp.Email)
+	assert.True(t, resp.EmailVerified)
+	assert.Equal(t, int64(1622128723), resp.DateEmailVerified)
+	assert.Equal(t, "krane", resp.Name)
+	assert.Equal(t, "Bront", resp.GivenName)
+	assert.Equal(t, "Kansen", resp.FamilyName)
+	assert.Equal(t, "en-US", resp.Locale)
+	assert.Equal(t, "Slack Corp", resp.TeamName)
+	assert.Equal(t, "slackcorp", resp.TeamDomain)
+	assert.True(t, resp.TeamImageDefault)
+}
+
+func TestGenerateCodeVerifier(t *testing.T) {
+	v1, err := GenerateCodeVerifier()
+	require.NoError(t, err)
+	assert.Len(t, v1, 43) // 32 bytes -> 43 chars base64url without padding
+
+	v2, err := GenerateCodeVerifier()
+	require.NoError(t, err)
+	assert.NotEqual(t, v1, v2, "two calls should produce different verifiers")
+}
+
+func TestGenerateCodeChallenge(t *testing.T) {
+	// RFC 7636 Appendix B test vector
+	verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+	expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
+
+	challenge := GenerateCodeChallenge(verifier)
+	assert.Equal(t, expected, challenge)
+}
+
+func TestOAuthOptionCodeVerifier(t *testing.T) {
+	cfg := resolveOAuthConfig([]OAuthOption{
+		OAuthOptionCodeVerifier("test-verifier"),
+	})
+	assert.Equal(t, "test-verifier", cfg.codeVerifier)
+	assert.Equal(t, APIURL, cfg.apiURL) // default preserved
+}
+
+func TestGetOAuthV2ResponsePKCE(t *testing.T) {
+	once.Do(startServer)
+
+	// The existing /oauth.v2.access handler returns a valid response.
+	// Verify that PKCE params are accepted without error.
+	resp, err := GetOAuthV2Response(
+		http.DefaultClient,
+		"client-id", "", "code", "http://localhost/callback",
+		OAuthOptionAPIURL("http://"+serverAddr+"/"),
+		OAuthOptionCodeVerifier("test-verifier"),
+	)
+	require.NoError(t, err)
+	assert.Equal(t, "xoxb-test-token", resp.AccessToken)
+}
+
+func TestGetOAuthV2ResponseOmitsEmptySecret(t *testing.T) {
+	// Verify that resolveOAuthConfig + empty secret produces no client_secret key
+	values := url.Values{
+		"client_id":    {"id"},
+		"code":         {"code"},
+		"redirect_uri": {"http://localhost"},
+	}
+	clientSecret := ""
+	if clientSecret != "" {
+		values.Set("client_secret", clientSecret)
+	}
+	_, hasSecret := values["client_secret"]
+	assert.False(t, hasSecret, "empty client_secret should not be in form values")
+}
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..18befa699 100644
--- a/reactions.go
+++ b/reactions.go
@@ -33,54 +33,62 @@ func NewGetReactionsParameters() GetReactionsParameters {
 }
 
 type getReactionsResponseFull struct {
-	Type string
-	M    struct {
-		Reactions []ItemReaction
+	Type    string
+	Channel string `json:"channel,omitempty"` // channel is at the root level for message types
+	M       struct {
+		*Message // message structure already contains reactions
 	} `json:"message"`
 	F struct {
+		*File
 		Reactions []ItemReaction
 	} `json:"file"`
 	FC struct {
+		*Comment
 		Reactions []ItemReaction
 	} `json:"comment"`
 	SlackResponse
 }
 
-func (res getReactionsResponseFull) extractReactions() []ItemReaction {
-	switch res.Type {
+func (res getReactionsResponseFull) extractReactedItem() ReactedItem {
+	item := ReactedItem{}
+	item.Type = res.Type
+
+	switch item.Type {
 	case "message":
-		return res.M.Reactions
+		item.Channel = res.Channel
+		item.Message = res.M.Message
+		item.Reactions = res.M.Reactions
 	case "file":
-		return res.F.Reactions
+		item.File = res.F.File
+		item.Reactions = res.F.Reactions
 	case "file_comment":
-		return res.FC.Reactions
+		item.File = res.F.File
+		item.Comment = res.FC.Comment
+		item.Reactions = res.FC.Reactions
 	}
-	return []ItemReaction{}
+	return item
 }
 
 const (
-	DEFAULT_REACTIONS_USER  = ""
-	DEFAULT_REACTIONS_COUNT = 100
-	DEFAULT_REACTIONS_PAGE  = 1
-	DEFAULT_REACTIONS_FULL  = false
+	DEFAULT_REACTIONS_USER = ""
+	DEFAULT_REACTIONS_FULL = false
 )
 
 // 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
+	Cursor string
+	Limit  int
+	Full   bool
 }
 
 // NewListReactionsParameters initializes the inputs to find all reactions
 // performed by a user.
 func NewListReactionsParameters() ListReactionsParameters {
 	return ListReactionsParameters{
-		User:  DEFAULT_REACTIONS_USER,
-		Count: DEFAULT_REACTIONS_COUNT,
-		Page:  DEFAULT_REACTIONS_PAGE,
-		Full:  DEFAULT_REACTIONS_FULL,
+		User: DEFAULT_REACTIONS_USER,
+		Full: DEFAULT_REACTIONS_FULL,
 	}
 }
 
@@ -100,8 +108,8 @@ type listReactionsResponseFull struct {
 			Reactions []ItemReaction
 		} `json:"comment"`
 	}
-	Paging `json:"paging"`
 	SlackResponse
+	ResponseMetadata `json:"response_metadata"`
 }
 
 func (res listReactionsResponseFull) extractReactedItems() []ReactedItem {
@@ -128,11 +136,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 +172,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},
@@ -195,13 +207,15 @@ func (api *Client) RemoveReactionContext(ctx context.Context, name string, item
 	return response.Err()
 }
 
-// GetReactions returns details about the reactions on an item.
-func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) {
+// GetReactions returns item and details about the reactions on an item.
+// For more details, see GetReactionsContext documentation.
+func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) (ReactedItem, error) {
 	return api.GetReactionsContext(context.Background(), item, params)
 }
 
-// GetReactionsContext returns details about the reactions on an item with a custom context
-func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) {
+// GetReactionsContext returns item and 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) (ReactedItem, error) {
 	values := url.Values{
 		"token": {api.token},
 	}
@@ -217,54 +231,59 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params
 	if item.Comment != "" {
 		values.Set("file_comment", item.Comment)
 	}
-	if params.Full != DEFAULT_REACTIONS_FULL {
+	if params.Full {
 		values.Set("full", strconv.FormatBool(params.Full))
 	}
 
 	response := &getReactionsResponseFull{}
 	if err := api.postMethod(ctx, "reactions.get", values, response); err != nil {
-		return nil, err
+		return ReactedItem{}, err
 	}
 
 	if err := response.Err(); err != nil {
-		return nil, err
+		return ReactedItem{}, err
 	}
 
-	return response.extractReactions(), nil
+	return response.extractReactedItem(), nil
 }
 
 // ListReactions returns information about the items a user reacted to.
-func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, *Paging, error) {
+// For more details, see ListReactionsContext documentation.
+func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, string, error) {
 	return api.ListReactionsContext(context.Background(), params)
 }
 
 // ListReactionsContext returns information about the items a user reacted to with a custom context.
-func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, *Paging, error) {
+// Slack API docs: https://api.slack.com/methods/reactions.list
+func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, string, error) {
 	values := url.Values{
 		"token": {api.token},
 	}
 	if params.User != DEFAULT_REACTIONS_USER {
 		values.Add("user", params.User)
 	}
-	if params.Count != DEFAULT_REACTIONS_COUNT {
-		values.Add("count", strconv.Itoa(params.Count))
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+	if params.Cursor != "" {
+		values.Add("cursor", params.Cursor)
 	}
-	if params.Page != DEFAULT_REACTIONS_PAGE {
-		values.Add("page", strconv.Itoa(params.Page))
+	if params.Limit != 0 {
+		values.Add("limit", strconv.Itoa(params.Limit))
 	}
-	if params.Full != DEFAULT_REACTIONS_FULL {
+	if params.Full {
 		values.Add("full", strconv.FormatBool(params.Full))
 	}
 
 	response := &listReactionsResponseFull{}
 	err := api.postMethod(ctx, "reactions.list", values, response)
 	if err != nil {
-		return nil, nil, err
+		return nil, "", err
 	}
 
 	if err := response.Err(); err != nil {
-		return nil, nil, err
+		return nil, "", err
 	}
 
-	return response.extractReactedItems(), &response.Paging, nil
+	return response.extractReactedItems(), response.ResponseMetadata.Cursor, nil
 }
diff --git a/reactions_test.go b/reactions_test.go
index cf1ed42ed..57eece5d3 100644
--- a/reactions_test.go
+++ b/reactions_test.go
@@ -27,12 +27,12 @@ func (rh *reactionsHandler) accumulateFormValue(k string, r *http.Request) {
 
 func (rh *reactionsHandler) handler(w http.ResponseWriter, r *http.Request) {
 	rh.accumulateFormValue("channel", r)
-	rh.accumulateFormValue("count", r)
+	rh.accumulateFormValue("cursor", r)
 	rh.accumulateFormValue("file", r)
 	rh.accumulateFormValue("file_comment", r)
 	rh.accumulateFormValue("full", r)
+	rh.accumulateFormValue("limit", r)
 	rh.accumulateFormValue("name", r)
-	rh.accumulateFormValue("page", r)
 	rh.accumulateFormValue("timestamp", r)
 	rh.accumulateFormValue("user", r)
 	w.Header().Set("Content-Type", "application/json")
@@ -139,11 +139,11 @@ func TestSlack_GetReactions(t *testing.T) {
 	once.Do(startServer)
 	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
 	tests := []struct {
-		ref           ItemRef
-		params        GetReactionsParameters
-		wantParams    map[string]string
-		json          string
-		wantReactions []ItemReaction
+		ref             ItemRef
+		params          GetReactionsParameters
+		wantParams      map[string]string
+		json            string
+		wantReactedItem ReactedItem
 	}{
 		{
 			NewRefToMessage("ChannelID", "123"),
@@ -153,24 +153,41 @@ func TestSlack_GetReactions(t *testing.T) {
 				"timestamp": "123",
 			},
 			`{"ok": true,
-    "type": "message",
-    "message": {
-        "reactions": [
-            {
-                "name": "astonished",
-                "count": 3,
-                "users": [ "U1", "U2", "U3" ]
-            },
-            {
-                "name": "clock1",
-                "count": 3,
-                "users": [ "U1", "U2" ]
-            }
-        ]
-    }}`,
-			[]ItemReaction{
-				{Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}},
-				{Name: "clock1", Count: 3, Users: []string{"U1", "U2"}},
+		 "type": "message",
+		 "channel": "ChannelID",
+		 "message": {
+			"text": "lorem ipsum dolor sit amet",
+			"ts": "123",
+			"user": "U2147483828",
+		     "reactions": [
+		         {
+		             "name": "astonished",
+		             "count": 3,
+		             "users": [ "U1", "U2", "U3" ]
+		         },
+		         {
+		             "name": "clock1",
+		             "count": 3,
+		             "users": [ "U1", "U2" ]
+		         }
+		     ]
+		 }}`,
+			ReactedItem{
+				Item: Item{
+					Type:    "message",
+					Channel: "ChannelID",
+					Message: &Message{
+						Msg: Msg{
+							Text:      "lorem ipsum dolor sit amet",
+							User:      "U2147483828",
+							Timestamp: "123",
+						},
+					},
+				},
+				Reactions: []ItemReaction{
+					{Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}},
+					{Name: "clock1", Count: 3, Users: []string{"U1", "U2"}},
+				},
 			},
 		},
 		{
@@ -183,6 +200,19 @@ func TestSlack_GetReactions(t *testing.T) {
 			`{"ok": true,
     "type": "file",
     "file": {
+		  "id": "F0A12BCDE",
+		  "created": 1531763342,
+		  "timestamp": 1531763342,
+		  "name": "tedair.gif",
+		  "title": "tedair.gif",
+		  "mimetype": "image/gif",
+		  "filetype": "gif",
+		  "pretty_type": "GIF",
+		  "user": "U012A3BCD",
+		  "editable": false,
+		  "size": 137531,
+		  "mode": "hosted",
+    	  "is_external": false,
         "reactions": [
             {
                 "name": "astonished",
@@ -196,13 +226,25 @@ func TestSlack_GetReactions(t *testing.T) {
             }
         ]
     }}`,
-			[]ItemReaction{
-				{Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}},
-				{Name: "clock1", Count: 3, Users: []string{"U1", "U2"}},
+			ReactedItem{
+				Item: Item{
+					Type: "file", File: &File{
+						Name:      "tedair.gif",
+						ID:        "F0A12BCDE",
+						Created:   1531763342,
+						Timestamp: 1531763342,
+						User:      "U012A3BCD",
+						Editable:  false,
+						Size:      137531,
+					},
+				},
+				Reactions: []ItemReaction{
+					{Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}},
+					{Name: "clock1", Count: 3, Users: []string{"U1", "U2"}},
+				},
 			},
 		},
 		{
-
 			NewRefToComment("FileCommentID"),
 			GetReactionsParameters{},
 			map[string]string{
@@ -210,8 +252,22 @@ func TestSlack_GetReactions(t *testing.T) {
 			},
 			`{"ok": true,
     "type": "file_comment",
-    "file": {},
+    "file": {
+	 	  "id": "F0A12BCDE",
+		  "created": 1531763342,
+		  "timestamp": 1531763342,
+		  "name": "tedair.gif",
+		  "title": "tedair.gif",
+		  "mimetype": "image/gif",
+		  "filetype": "gif",
+		  "pretty_type": "GIF",
+		  "user": "U012A3BCD",
+		  "editable": false,
+		  "size": 137531,
+		  "is_external": false
+	 },
     "comment": {
+		  "comment": "lorem ipsum dolor sit amet comment",
         "reactions": [
             {
                 "name": "astonished",
@@ -225,9 +281,19 @@ func TestSlack_GetReactions(t *testing.T) {
             }
         ]
     }}`,
-			[]ItemReaction{
-				{Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}},
-				{Name: "clock1", Count: 3, Users: []string{"U1", "U2"}},
+			ReactedItem{
+				Item: Item{
+					Type: "file_comment", File: &File{
+						Name: "tedair.gif",
+					},
+					Comment: &Comment{
+						Comment: "lorem ipsum dolor sit amet comment",
+					},
+				},
+				Reactions: []ItemReaction{
+					{Name: "astonished", Count: 3, Users: []string{"U1", "U2", "U3"}},
+					{Name: "clock1", Count: 3, Users: []string{"U1", "U2"}},
+				},
 			},
 		},
 	}
@@ -240,12 +306,70 @@ func TestSlack_GetReactions(t *testing.T) {
 		if err != nil {
 			t.Fatalf("%d: Unexpected error: %s", i, err)
 		}
-		if !reflect.DeepEqual(got, test.wantReactions) {
-			t.Errorf("%d: Got reaction %#v, want %#v", i, got, test.wantReactions)
+		if !reflect.DeepEqual(got.Reactions, test.wantReactedItem.Reactions) {
+			t.Errorf("%d: Got reaction %#v, want %#v", i, got.Reactions, test.wantReactedItem.Reactions)
 		}
 		if !reflect.DeepEqual(rh.gotParams, test.wantParams) {
 			t.Errorf("%d: Got params %#v, want %#v", i, rh.gotParams, test.wantParams)
 		}
+
+		switch got.Type {
+		case "message":
+			if got.Message == nil {
+				t.Fatalf("%d: Got message %#v, want %#v", i, got.Message, test.wantReactedItem.Message)
+			}
+
+			if got.Message.Text != test.wantReactedItem.Message.Text {
+				t.Errorf("%d: Got message text %#v, want %#v", i, got.Message.Text, test.wantReactedItem.Message.Text)
+			}
+			if got.Channel != test.wantReactedItem.Channel {
+				t.Errorf("%d: Got channel %#v, want %#v", i, got.Channel, test.wantReactedItem.Channel)
+			}
+			if got.Message.User != test.wantReactedItem.Message.User {
+				t.Errorf("%d: Got message user %#v, want %#v", i, got.Message.User, test.wantReactedItem.Message.User)
+			}
+			if got.Message.Timestamp != test.wantReactedItem.Message.Timestamp {
+				t.Errorf("%d: Got message timestamp %#v, want %#v", i, got.Message.Timestamp, test.wantReactedItem.Message.Timestamp)
+			}
+		case "file":
+			if got.File == nil {
+				t.Fatalf("%d: Got file %#v, want %#v", i, got.File, test.wantReactedItem.File)
+			}
+			if got.File.Name != test.wantReactedItem.File.Name {
+				t.Errorf("%d: Got file name %#v, want %#v", i, got.File.Name, test.wantReactedItem.File.Name)
+			}
+			if got.File.ID != test.wantReactedItem.File.ID {
+				t.Errorf("%d: Got file ID %#v, want %#v", i, got.File.ID, test.wantReactedItem.File.ID)
+			}
+			if got.File.Created != test.wantReactedItem.File.Created {
+				t.Errorf("%d: Got file created %#v, want %#v", i, got.File.Created, test.wantReactedItem.File.Created)
+			}
+			if got.File.Timestamp != test.wantReactedItem.File.Timestamp {
+				t.Errorf("%d: Got file timestamp %#v, want %#v", i, got.File.Timestamp, test.wantReactedItem.File.Timestamp)
+			}
+			if got.File.User != test.wantReactedItem.File.User {
+				t.Errorf("%d: Got file user %#v, want %#v", i, got.File.User, test.wantReactedItem.File.User)
+			}
+			if got.File.Editable != test.wantReactedItem.File.Editable {
+				t.Errorf("%d: Got file editable %#v, want %#v", i, got.File.Editable, test.wantReactedItem.File.Editable)
+			}
+			if got.File.Size != test.wantReactedItem.File.Size {
+				t.Errorf("%d: Got file size %#v, want %#v", i, got.File.Size, test.wantReactedItem.File.Size)
+			}
+		case "file_comment":
+			if got.Comment == nil {
+				t.Fatalf("%d: Got comment %#v, want %#v", i, got.Comment, test.wantReactedItem.Comment)
+			}
+			if got.File == nil {
+				t.Fatalf("%d: Got file %#v, want %#v", i, got.File, test.wantReactedItem.File)
+			}
+			if got.File.Name != test.wantReactedItem.File.Name {
+				t.Errorf("%d: Got file name %#v, want %#v", i, got.File.Name, test.wantReactedItem.File.Name)
+			}
+			if got.Comment.Comment != test.wantReactedItem.Comment.Comment {
+				t.Errorf("%d: Got comment comment %#v, want %#v", i, got.Comment.Comment, test.wantReactedItem.Comment.Comment)
+			}
+		}
 	}
 }
 
@@ -305,11 +429,8 @@ func TestSlack_ListReactions(t *testing.T) {
             }
         }
     ],
-    "paging": {
-        "count": 100,
-        "total": 4,
-        "page": 1,
-        "pages": 1
+    "response_metadata": {
+        "next_cursor": "dXNlcjpVMDYxTkZUVDI="
     }}`
 	want := []ReactedItem{
 		{
@@ -339,17 +460,18 @@ func TestSlack_ListReactions(t *testing.T) {
 		},
 	}
 	wantParams := map[string]string{
-		"user":  "User",
-		"count": "200",
-		"page":  "2",
-		"full":  "true",
+		"user":   "User",
+		"cursor": "somecursor",
+		"limit":  "200",
+		"full":   "true",
 	}
+	wantCursor := "dXNlcjpVMDYxTkZUVDI="
 	params := NewListReactionsParameters()
 	params.User = "User"
-	params.Count = 200
-	params.Page = 2
+	params.Cursor = "somecursor"
+	params.Limit = 200
 	params.Full = true
-	got, paging, err := api.ListReactions(params)
+	got, nextCursor, err := api.ListReactions(params)
 	if err != nil {
 		t.Fatalf("Unexpected error: %s", err)
 	}
@@ -366,7 +488,7 @@ func TestSlack_ListReactions(t *testing.T) {
 	if !reflect.DeepEqual(rh.gotParams, wantParams) {
 		t.Errorf("Got params %#v, want %#v", rh.gotParams, wantParams)
 	}
-	if reflect.DeepEqual(paging, Paging{}) {
-		t.Errorf("Want paging data, got empty struct")
+	if nextCursor != wantCursor {
+		t.Errorf("Got cursor %q, want %q", nextCursor, wantCursor)
 	}
 }
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
new file mode 100644
index 000000000..46e9de5b8
--- /dev/null
+++ b/remotefiles.go
@@ -0,0 +1,326 @@
+package slack
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+const (
+	DEFAULT_REMOTE_FILES_CHANNEL = ""
+	DEFAULT_REMOTE_FILES_TS_FROM = 0
+	DEFAULT_REMOTE_FILES_TS_TO   = -1
+	DEFAULT_REMOTE_FILES_COUNT   = 100
+)
+
+// RemoteFile contains all the information for a remote file
+// For more details:
+// https://api.slack.com/messaging/files/remote
+type RemoteFile struct {
+	ID              string   `json:"id"`
+	Created         JSONTime `json:"created"`
+	Timestamp       JSONTime `json:"timestamp"`
+	Name            string   `json:"name"`
+	Title           string   `json:"title"`
+	Mimetype        string   `json:"mimetype"`
+	Filetype        string   `json:"filetype"`
+	PrettyType      string   `json:"pretty_type"`
+	User            string   `json:"user"`
+	Editable        bool     `json:"editable"`
+	Size            int      `json:"size"`
+	Mode            string   `json:"mode"`
+	IsExternal      bool     `json:"is_external"`
+	ExternalType    string   `json:"external_type"`
+	IsPublic        bool     `json:"is_public"`
+	PublicURLShared bool     `json:"public_url_shared"`
+	DisplayAsBot    bool     `json:"display_as_bot"`
+	Username        string   `json:"username"`
+	URLPrivate      string   `json:"url_private"`
+	Permalink       string   `json:"permalink"`
+	CommentsCount   int      `json:"comments_count"`
+	IsStarred       bool     `json:"is_starred"`
+	Shares          Share    `json:"shares"`
+	Channels        []string `json:"channels"`
+	Groups          []string `json:"groups"`
+	IMs             []string `json:"ims"`
+	ExternalID      string   `json:"external_id"`
+	ExternalURL     string   `json:"external_url"`
+	HasRichPreview  bool     `json:"has_rich_preview"`
+}
+
+// RemoteFileParameters contains required and optional parameters for a remote file.
+//
+// ExternalID is a user defined GUID, ExternalURL is where the remote file can be accessed,
+// and Title is the name of the file.
+//
+// PreviewImage is a file path to upload as preview. PreviewImageReader is an io.Reader
+// alternative. When using PreviewImageReader, set PreviewImageName to specify the filename
+// with proper extension (e.g., "preview.jpg") to preserve image format.
+//
+// For more details:
+// https://api.slack.com/methods/files.remote.add
+type RemoteFileParameters struct {
+	ExternalID            string // required
+	ExternalURL           string // required
+	Title                 string // required
+	Filetype              string
+	IndexableFileContents string
+	PreviewImage          string
+	PreviewImageReader    io.Reader
+	PreviewImageName      string // filename for PreviewImageReader (e.g., "preview.jpg")
+}
+
+// ListRemoteFilesParameters contains arguments for the ListRemoteFiles method.
+// For more details:
+// https://api.slack.com/methods/files.remote.list
+type ListRemoteFilesParameters struct {
+	Channel       string
+	Cursor        string
+	Limit         int
+	TimestampFrom JSONTime
+	TimestampTo   JSONTime
+}
+
+type remoteFileResponseFull struct {
+	RemoteFile `json:"file"`
+	Paging     `json:"paging"`
+	Files      []RemoteFile `json:"files"`
+	SlackResponse
+}
+
+func (api *Client) remoteFileRequest(ctx context.Context, path string, values url.Values) (*remoteFileResponseFull, error) {
+	response := &remoteFileResponseFull{}
+	err := api.postMethod(ctx, path, values, response)
+	if err != nil {
+		return nil, err
+	}
+
+	return response, response.Err()
+}
+
+// AddRemoteFile adds a remote file. Unlike regular files, remote files must be explicitly shared.
+// 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
+// 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
+	}
+	response := &remoteFileResponseFull{}
+	values := url.Values{
+		"token":        {api.token},
+		"external_id":  {params.ExternalID},
+		"external_url": {params.ExternalURL},
+		"title":        {params.Title},
+	}
+	if params.Filetype != "" {
+		values.Add("filetype", params.Filetype)
+	}
+	if params.IndexableFileContents != "" {
+		values.Add("indexable_file_contents", params.IndexableFileContents)
+	}
+	switch {
+	case params.PreviewImage != "":
+		err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", params.PreviewImage, "preview_image", api.token, values, response, api)
+	case params.PreviewImageReader != nil:
+		name := params.PreviewImageName
+		if name == "" {
+			name = "preview.png"
+		}
+		err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.add", name, "preview_image", api.token, values, params.PreviewImageReader, response, api)
+	default:
+		response, err = api.remoteFileRequest(ctx, "files.remote.add", values)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &response.RemoteFile, response.Err()
+}
+
+// ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination.
+// 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.
+// 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},
+	}
+	if params.Channel != DEFAULT_REMOTE_FILES_CHANNEL {
+		values.Add("channel", params.Channel)
+	}
+	if params.TimestampFrom != DEFAULT_REMOTE_FILES_TS_FROM {
+		values.Add("ts_from", strconv.FormatInt(int64(params.TimestampFrom), 10))
+	}
+	if params.TimestampTo != DEFAULT_REMOTE_FILES_TS_TO {
+		values.Add("ts_to", strconv.FormatInt(int64(params.TimestampTo), 10))
+	}
+	if params.Limit != DEFAULT_REMOTE_FILES_COUNT {
+		values.Add("limit", strconv.Itoa(params.Limit))
+	}
+	if params.Cursor != "" {
+		values.Add("cursor", params.Cursor)
+	}
+
+	response, err := api.remoteFileRequest(ctx, "files.remote.list", values)
+	if err != nil {
+		return nil, err
+	}
+
+	params.Cursor = response.SlackResponse.ResponseMetadata.Cursor
+
+	return response.Files, nil
+}
+
+// GetRemoteFileInfo retrieves the complete remote file information.
+// 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.
+// 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")
+	}
+	if fileID != "" && externalID != "" {
+		return nil, fmt.Errorf("don't provide both externalID and fileID")
+	}
+	values := url.Values{
+		"token": {api.token},
+	}
+	if fileID != "" {
+		values.Add("file", fileID)
+	}
+	if externalID != "" {
+		values.Add("external_id", externalID)
+	}
+	response, err := api.remoteFileRequest(ctx, "files.remote.info", values)
+	if err != nil {
+		return nil, err
+	}
+	return &response.RemoteFile, err
+}
+
+// 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.
+// 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 len(channels) == 0 {
+		return nil, ErrParametersMissing
+	}
+	if fileID == "" && externalID == "" {
+		return nil, fmt.Errorf("either externalID or fileID is required")
+	}
+	values := url.Values{
+		"token":    {api.token},
+		"channels": {strings.Join(channels, ",")},
+	}
+	if fileID != "" {
+		values.Add("file", fileID)
+	}
+	if externalID != "" {
+		values.Add("external_id", externalID)
+	}
+	response, err := api.remoteFileRequest(ctx, "files.remote.share", values)
+	if err != nil {
+		return nil, err
+	}
+	return &response.RemoteFile, err
+}
+
+// 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.
+// 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{}
+	if fileID != "" {
+		values.Add("file", fileID)
+	}
+	if params.ExternalID != "" {
+		values.Add("external_id", params.ExternalID)
+	}
+	if params.ExternalURL != "" {
+		values.Add("external_url", params.ExternalURL)
+	}
+	if params.Title != "" {
+		values.Add("title", params.Title)
+	}
+	if params.Filetype != "" {
+		values.Add("filetype", params.Filetype)
+	}
+	if params.IndexableFileContents != "" {
+		values.Add("indexable_file_contents", params.IndexableFileContents)
+	}
+	switch {
+	case params.PreviewImage != "":
+		err = postLocalWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", params.PreviewImage, "preview_image", api.token, values, response, api)
+	case params.PreviewImageReader != nil:
+		name := params.PreviewImageName
+		if name == "" {
+			name = "preview.png"
+		}
+		err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", name, "preview_image", api.token, values, params.PreviewImageReader, response, api)
+	default:
+		values.Add("token", api.token)
+		response, err = api.remoteFileRequest(ctx, "files.remote.update", values)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &response.RemoteFile, response.Err()
+}
+
+// RemoveRemoteFile removes a remote file.
+// 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
+// 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")
+	}
+	if fileID != "" && externalID != "" {
+		return fmt.Errorf("don't provide both externalID and fileID")
+	}
+	values := url.Values{
+		"token": {api.token},
+	}
+	if fileID != "" {
+		values.Add("file", fileID)
+	}
+	if externalID != "" {
+		values.Add("external_id", externalID)
+	}
+	_, err = api.remoteFileRequest(ctx, "files.remote.remove", values)
+	return err
+}
diff --git a/remotefiles_test.go b/remotefiles_test.go
new file mode 100644
index 000000000..e4d9d794e
--- /dev/null
+++ b/remotefiles_test.go
@@ -0,0 +1,222 @@
+package slack
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func addRemoteFileHandler(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response, _ := json.Marshal(remoteFileResponseFull{
+		SlackResponse: SlackResponse{Ok: true}})
+	rw.Write(response)
+}
+
+func TestAddRemoteFile(t *testing.T) {
+	http.HandleFunc("/files.remote.add", addRemoteFileHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := RemoteFileParameters{
+		ExternalID:  "externalID",
+		ExternalURL: "http://example.com/",
+		Title:       "example",
+	}
+	if _, err := api.AddRemoteFile(params); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func TestAddRemoteFileWithoutTitle(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := RemoteFileParameters{
+		ExternalID:  "externalID",
+		ExternalURL: "http://example.com/",
+	}
+	if _, err := api.AddRemoteFile(params); err != ErrParametersMissing {
+		t.Errorf("Expected ErrParametersMissing. got %s", err)
+	}
+}
+
+func listRemoteFileHandler(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response, _ := json.Marshal(remoteFileResponseFull{
+		SlackResponse: SlackResponse{Ok: true}})
+	rw.Write(response)
+}
+
+func TestListRemoteFile(t *testing.T) {
+	http.HandleFunc("/files.remote.list", listRemoteFileHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := ListRemoteFilesParameters{}
+	if _, err := api.ListRemoteFiles(params); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func getRemoteFileInfoHandler(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response, _ := json.Marshal(remoteFileResponseFull{
+		SlackResponse: SlackResponse{Ok: true}})
+	rw.Write(response)
+}
+
+func TestGetRemoteFileInfo(t *testing.T) {
+	http.HandleFunc("/files.remote.info", getRemoteFileInfoHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	if _, err := api.GetRemoteFileInfo("ExternalID", ""); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func TestGetRemoteFileInfoWithoutID(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	_, err := api.GetRemoteFileInfo("", "")
+	if err == nil {
+		t.Fatal("Expected error when both externalID and fileID is not provided, instead got nil")
+	}
+	if !strings.Contains(err.Error(), "either externalID or fileID is required") {
+		t.Errorf("Error message should mention a required field")
+	}
+}
+
+func TestGetRemoteFileInfoWithFileIDAndExternalID(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	_, err := api.GetRemoteFileInfo("ExternalID", "FileID")
+	if err == nil {
+		t.Fatal("Expected error when both externalID and fileID are both provided, instead got nil")
+	}
+	if !strings.Contains(err.Error(), "don't provide both externalID and fileID") {
+		t.Errorf("Error message should mention don't providing both externalID and fileID")
+	}
+}
+
+func shareRemoteFileHandler(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response, _ := json.Marshal(remoteFileResponseFull{
+		SlackResponse: SlackResponse{Ok: true}})
+	rw.Write(response)
+}
+
+func TestShareRemoteFile(t *testing.T) {
+	http.HandleFunc("/files.remote.share", shareRemoteFileHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	if _, err := api.ShareRemoteFile([]string{"channel"}, "ExternalID", ""); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func TestShareRemoteFileWithoutChannels(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	if _, err := api.ShareRemoteFile([]string{}, "ExternalID", ""); err != ErrParametersMissing {
+		t.Errorf("Expected ErrParametersMissing. got %s", err)
+	}
+}
+
+func TestShareRemoteFileWithoutID(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	_, err := api.ShareRemoteFile([]string{"channel"}, "", "")
+	if err == nil {
+		t.Fatal("Expected error when both externalID and fileID is not provided, instead got nil")
+	}
+	if !strings.Contains(err.Error(), "either externalID or fileID is required") {
+		t.Errorf("Error message should mention a required field")
+	}
+}
+
+func updateRemoteFileHandler(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response, _ := json.Marshal(remoteFileResponseFull{
+		SlackResponse: SlackResponse{Ok: true}})
+	rw.Write(response)
+}
+
+func TestUpdateRemoteFile(t *testing.T) {
+	http.HandleFunc("/files.remote.update", updateRemoteFileHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := RemoteFileParameters{
+		ExternalURL: "http://example.com/",
+		Title:       "example",
+	}
+	if _, err := api.UpdateRemoteFile("fileID", params); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func removeRemoteFileHandler(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response, _ := json.Marshal(remoteFileResponseFull{
+		SlackResponse: SlackResponse{Ok: true}})
+	rw.Write(response)
+}
+
+func TestRemoveRemoteFile(t *testing.T) {
+	http.HandleFunc("/files.remote.remove", removeRemoteFileHandler)
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	if err := api.RemoveRemoteFile("ExternalID", ""); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func TestRemoveRemoteFileWithoutID(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	err := api.RemoveRemoteFile("", "")
+	if err == nil {
+		t.Fatal("Expected error when both externalID and fileID is not provided, instead got nil")
+	}
+	if !strings.Contains(err.Error(), "either externalID or fileID is required") {
+		t.Errorf("Error message should mention a required field")
+	}
+}
+
+func TestRemoveRemoteFileWithFileIDAndExternalID(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	err := api.RemoveRemoteFile("ExternalID", "FileID")
+	if err == nil {
+		t.Fatal("Expected error when both externalID and fileID are both provided, instead got nil")
+	}
+	if !strings.Contains(err.Error(), "don't provide both externalID and fileID") {
+		t.Errorf("Error message should mention don't providing both externalID and fileID")
+	}
+}
+
+func TestAddRemoteFileWithPreviewImageReader(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := RemoteFileParameters{
+		ExternalID:         "externalID",
+		ExternalURL:        "http://example.com/",
+		Title:              "example",
+		PreviewImageReader: strings.NewReader("fake image data"),
+		PreviewImageName:   "preview.jpg",
+	}
+	if _, err := api.AddRemoteFile(params); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
+
+func TestUpdateRemoteFileWithPreviewImageReader(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+	params := RemoteFileParameters{
+		Title:              "updated example",
+		PreviewImageReader: strings.NewReader("fake image data"),
+		PreviewImageName:   "preview.jpg",
+	}
+	if _, err := api.UpdateRemoteFile("fileID", params); err != nil {
+		t.Errorf("Unexpected error: %s", err)
+	}
+}
diff --git a/response_headers_test.go b/response_headers_test.go
new file mode 100644
index 000000000..829fb1183
--- /dev/null
+++ b/response_headers_test.go
@@ -0,0 +1,137 @@
+package slack
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestResponseHeaders(t *testing.T) {
+	t.Run("AuthTest captures headers", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.Header().Set("X-OAuth-Scopes", "users:read,channels:read")
+			w.Header().Set("X-Accepted-OAuth-Scopes", "users:read")
+			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
+		}))
+		defer ts.Close()
+
+		api := New("test-token", OptionAPIURL(ts.URL+"/"))
+		resp, err := api.AuthTestContext(t.Context())
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if got := resp.Header.Get("X-OAuth-Scopes"); got != "users:read,channels:read" {
+			t.Fatalf("expected X-OAuth-Scopes=users:read,channels:read, got %q", got)
+		}
+		if got := resp.Header.Get("X-Accepted-OAuth-Scopes"); got != "users:read" {
+			t.Fatalf("expected X-Accepted-OAuth-Scopes=users:read, got %q", got)
+		}
+	})
+
+	t.Run("SlackResponse embeds headers via callback", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.Header().Set("X-OAuth-Scopes", "admin")
+			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
+		}))
+		defer ts.Close()
+
+		var gotHeaders http.Header
+		api := New("test-token",
+			OptionAPIURL(ts.URL+"/"),
+			OptionOnResponseHeaders(func(path string, headers http.Header) {
+				gotHeaders = headers
+			}),
+		)
+
+		_, err := api.AuthTestContext(t.Context())
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if gotHeaders == nil {
+			t.Fatal("expected headers from SlackResponse, got nil")
+		}
+		if got := gotHeaders.Get("X-OAuth-Scopes"); got != "admin" {
+			t.Fatalf("expected X-OAuth-Scopes=admin, got %q", got)
+		}
+	})
+}
+
+func TestOptionOnResponseHeaders(t *testing.T) {
+	t.Run("callback fires", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.Header().Set("X-OAuth-Scopes", "users:read")
+			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
+		}))
+		defer ts.Close()
+
+		var gotPath string
+		var gotHeaders http.Header
+		api := New("test-token",
+			OptionAPIURL(ts.URL+"/"),
+			OptionOnResponseHeaders(func(path string, headers http.Header) {
+				gotPath = path
+				gotHeaders = headers
+			}),
+		)
+
+		_, err := api.AuthTestContext(t.Context())
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if gotPath != "auth.test" {
+			t.Fatalf("expected path auth.test, got %q", gotPath)
+		}
+		if gotHeaders == nil {
+			t.Fatal("expected headers, got nil")
+		}
+		if got := gotHeaders.Get("X-OAuth-Scopes"); got != "users:read" {
+			t.Fatalf("expected X-OAuth-Scopes=users:read, got %q", got)
+		}
+	})
+
+	t.Run("no callback is safe", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.Header().Set("X-OAuth-Scopes", "users:read")
+			w.Write([]byte(`{"ok":true,"url":"https://example.slack.com","team":"T","user":"U","team_id":"T1","user_id":"U1"}`))
+		}))
+		defer ts.Close()
+
+		api := New("test-token", OptionAPIURL(ts.URL+"/"))
+		_, err := api.AuthTestContext(t.Context())
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+	})
+
+	t.Run("callback fires on error", func(t *testing.T) {
+		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.Header().Set("X-OAuth-Scopes", "users:read")
+			w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
+		}))
+		defer ts.Close()
+
+		var gotHeaders http.Header
+		api := New("test-token",
+			OptionAPIURL(ts.URL+"/"),
+			OptionOnResponseHeaders(func(path string, headers http.Header) {
+				gotHeaders = headers
+			}),
+		)
+
+		_, err := api.AuthTestContext(t.Context())
+		if err == nil {
+			t.Fatal("expected error")
+		}
+		if gotHeaders == nil {
+			t.Fatal("expected headers even on error response, got nil")
+		}
+		if got := gotHeaders.Get("X-OAuth-Scopes"); got != "users:read" {
+			t.Fatalf("expected X-OAuth-Scopes=users:read, got %q", got)
+		}
+	})
+}
diff --git a/retry.go b/retry.go
new file mode 100644
index 000000000..d2f62117d
--- /dev/null
+++ b/retry.go
@@ -0,0 +1,307 @@
+package slack
+
+// Optional HTTP retries improve reliability when Slack is busy or the network is flaky.
+// Retries are off by default; use OptionRetry or OptionRetryConfig to turn them on.
+//
+// Retry behavior is driven by pluggable handlers (parity with the Python SDK:
+// https://github.com/slackapi/python-slack-sdk). When Handlers is nil, only rate limit
+// (429) is retried (NewRateLimitErrorRetryHandler). Use
+// AllBuiltinRetryHandlers(cfg) for connection + 429; ConnectionOnlyRetryHandlers(cfg) for
+// connection-only; add NewServerErrorRetryHandler(cfg) to also retry 5xx.
+//
+// File uploads and other requests that stream the body cannot be retried (the body is sent once).
+// Regular API calls (form or JSON) are retried when a handler matches (429, 5xx, or connection error).
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand/v2"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/slack-go/slack/internal/backoff"
+)
+
+// minRetryAfter429 is the minimum wait before retrying after 429 when Retry-After is missing
+// or zero, to avoid tight retry loops when using a partial RetryConfig.
+const minRetryAfter429 = time.Second
+
+// RetryState holds the current attempt and max retries; passed to handlers.
+// Backoff is set by retryClient for use by handlers that want exponential backoff (e.g. connection, server error).
+// Handlers may call Backoff.Duration() when retrying; each call advances the backoff for the next retry.
+type RetryState struct {
+	Attempt    int // current attempt (0-based)
+	MaxRetries int
+	Backoff    *backoff.Backoff // optional; used by connection/server handlers for exponential backoff
+}
+
+// RetryHandler decides whether to retry a request and how long to wait.
+// The first handler that returns (true, wait) wins. resp may be nil (connection failure); err may be nil (got response).
+type RetryHandler interface {
+	ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (retry bool, wait time.Duration)
+}
+
+// RetryConfig configures HTTP retry behavior.
+// When MaxRetries is 0, retries are disabled.
+// If Handlers is nil, only rate limit (429) is retried (see DefaultRetryHandlers).
+type RetryConfig struct {
+	// MaxRetries is the maximum number of retry attempts (0 = no retries, 1 = one retry, etc.).
+	MaxRetries int
+	// Handlers is the list of handlers to consult; nil means 429 only (DefaultRetryHandlers).
+	Handlers []RetryHandler
+	// RetryAfterDuration is used for 429 when the Retry-After header is missing or invalid.
+	RetryAfterDuration time.Duration
+	// RetryAfterJitter adds random jitter [0, RetryAfterJitter] to 429 wait to avoid thundering herd (0 = no jitter).
+	RetryAfterJitter time.Duration
+	// BackoffInitial is the initial backoff for 5xx and connection errors.
+	BackoffInitial time.Duration
+	// BackoffMax caps the backoff duration.
+	BackoffMax time.Duration
+	// BackoffJitter adds random jitter [0, BackoffJitter] to backoff to avoid thundering herd (0 to disable).
+	BackoffJitter time.Duration
+}
+
+// DefaultRetryConfig returns a retry config with sensible defaults.
+func DefaultRetryConfig() RetryConfig {
+	return RetryConfig{
+		MaxRetries:         3,
+		RetryAfterDuration: 60 * time.Second,
+		RetryAfterJitter:   1 * time.Second,
+		BackoffInitial:     100 * time.Millisecond,
+		BackoffMax:         30 * time.Second,
+		BackoffJitter:      50 * time.Millisecond,
+	}
+}
+
+// connectionErrorRetryHandler retries on connection errors (e.g. connection reset).
+type connectionErrorRetryHandler struct{}
+
+// NewConnectionErrorRetryHandler returns a handler that retries on connection errors.
+func NewConnectionErrorRetryHandler() RetryHandler {
+	return &connectionErrorRetryHandler{}
+}
+
+func (h *connectionErrorRetryHandler) ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (bool, time.Duration) {
+	if err == nil || resp != nil {
+		return false, 0
+	}
+	if !isRetryableConnError(err) || !requestRetryable(req) {
+		return false, 0
+	}
+	if state.Attempt >= state.MaxRetries {
+		return false, 0
+	}
+	// Backoff is always set by retryClient.Do().
+	wait := state.Backoff.Duration()
+	return true, wait
+}
+
+// rateLimitErrorRetryHandler retries on 429 Too Many Requests using Retry-After or config.
+type rateLimitErrorRetryHandler struct {
+	cfg RetryConfig
+}
+
+// NewRateLimitErrorRetryHandler returns a handler that retries on 429.
+func NewRateLimitErrorRetryHandler(cfg RetryConfig) RetryHandler {
+	return &rateLimitErrorRetryHandler{cfg: cfg}
+}
+
+func (h *rateLimitErrorRetryHandler) ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (bool, time.Duration) {
+	if resp == nil || resp.StatusCode != http.StatusTooManyRequests || !requestRetryable(req) {
+		return false, 0
+	}
+	if state.Attempt >= state.MaxRetries {
+		return false, 0
+	}
+	dur := h.cfg.RetryAfterDuration
+	if s := resp.Header.Get("Retry-After"); s != "" {
+		// Parsing only integer seconds is appropriate for Slack (API sends seconds; RFC 7231 also allows HTTP-date).
+		if sec, parseErr := strconv.ParseInt(strings.TrimSpace(s), 10, 64); parseErr == nil {
+			if sec > 0 {
+				dur = time.Duration(sec) * time.Second
+			} else {
+				dur = minRetryAfter429 // Retry-After: 0 means use minimum delay
+			}
+		}
+	}
+	dur = max(dur, minRetryAfter429)
+	if h.cfg.RetryAfterJitter > 0 {
+		dur += time.Duration(rand.IntN(int(h.cfg.RetryAfterJitter)))
+	}
+	return true, dur
+}
+
+// serverErrorRetryHandler retries on 5xx server errors (opt-in).
+type serverErrorRetryHandler struct{}
+
+// NewServerErrorRetryHandler returns a handler that retries on 5xx. Opt-in; not in DefaultRetryHandlers, ConnectionOnlyRetryHandlers, or AllBuiltinRetryHandlers.
+func NewServerErrorRetryHandler(cfg RetryConfig) RetryHandler {
+	return &serverErrorRetryHandler{}
+}
+
+func (h *serverErrorRetryHandler) ShouldRetry(state *RetryState, req *http.Request, resp *http.Response, err error) (bool, time.Duration) {
+	if resp == nil || resp.StatusCode < http.StatusInternalServerError || !requestRetryable(req) {
+		return false, 0
+	}
+	if state.Attempt >= state.MaxRetries {
+		return false, 0
+	}
+	// Backoff is always set by retryClient.Do().
+	wait := state.Backoff.Duration()
+	return true, wait
+}
+
+// DefaultRetryHandlers returns the default handler when retries are on: rate limit (429) only.
+// Used when Handlers is nil. Use AllBuiltinRetryHandlers(cfg) for connection + 429.
+func DefaultRetryHandlers(cfg RetryConfig) []RetryHandler {
+	return []RetryHandler{NewRateLimitErrorRetryHandler(cfg)}
+}
+
+// ConnectionOnlyRetryHandlers returns connection-only handlers (no 429 retries).
+func ConnectionOnlyRetryHandlers() []RetryHandler {
+	return []RetryHandler{NewConnectionErrorRetryHandler()}
+}
+
+// AllBuiltinRetryHandlers returns connection + rate limit (429) handlers; no 5xx.
+func AllBuiltinRetryHandlers(cfg RetryConfig) []RetryHandler {
+	return []RetryHandler{
+		NewConnectionErrorRetryHandler(),
+		NewRateLimitErrorRetryHandler(cfg),
+	}
+}
+
+// retryClient wraps an httpClient and retries according to config.Handlers.
+type retryClient struct {
+	client httpClient
+	config RetryConfig
+	debug  Debug // optional; when set and Debug() is true, retries are logged
+}
+
+var _ httpClient = (*retryClient)(nil)
+
+// handlers returns the list of retry handlers. Empty Handlers slice is treated like nil (default 429 only).
+func (c *retryClient) handlers() []RetryHandler {
+	if len(c.config.Handlers) > 0 {
+		return c.config.Handlers
+	}
+	return DefaultRetryHandlers(c.config)
+}
+
+func (c *retryClient) logRetry(attempt int, reason string, detail any) {
+	if c.debug == nil || !c.debug.Debug() {
+		return
+	}
+	c.debug.Debugf("slack retry: %s (attempt %d/%d), detail: %v", reason, attempt+1, c.config.MaxRetries+1, detail)
+}
+
+// requestRetryable reports whether the request can be safely retried: either it has no body
+// (e.g. GET) or the body can be replayed via GetBody (e.g. POST with GetBody set).
+// Requests with a non-nil body and nil GetBody (e.g. streaming uploads) must not be retried.
+func requestRetryable(req *http.Request) bool {
+	return req.Body == nil || req.GetBody != nil
+}
+
+// sleepWithContext sleeps for up to d, or until ctx is done. Returns true if the full duration
+// elapsed, false if ctx was cancelled (caller should return ctx.Err()).
+func sleepWithContext(ctx context.Context, d time.Duration) bool {
+	if d <= 0 {
+		return true
+	}
+	timer := time.NewTimer(d)
+	defer timer.Stop()
+	select {
+	case <-timer.C:
+		return true
+	case <-ctx.Done():
+		return false
+	}
+}
+
+func (c *retryClient) Do(req *http.Request) (*http.Response, error) {
+	handlers := c.handlers()
+	bo := &backoff.Backoff{
+		Initial: c.config.BackoffInitial,
+		Max:     c.config.BackoffMax,
+		Jitter:  c.config.BackoffJitter,
+	}
+	var lastErr error
+	maxAttempts := c.config.MaxRetries + 1
+
+	for attempt := range maxAttempts {
+		state := &RetryState{Attempt: attempt, MaxRetries: c.config.MaxRetries, Backoff: bo}
+
+		// Rewind body for retries (POST/PUT with GetBody).
+		if attempt > 0 && req.GetBody != nil {
+			newBody, err := req.GetBody()
+			if err != nil {
+				return nil, err
+			}
+			req.Body = newBody
+		}
+
+		resp, err := c.client.Do(req)
+		if err != nil {
+			lastErr = err
+		}
+
+		// Success: got response and status is not 429/5xx.
+		if err == nil && resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < http.StatusInternalServerError {
+			return resp, nil
+		}
+
+		// No retries left or request body cannot be replayed — return now.
+		if attempt >= c.config.MaxRetries || !requestRetryable(req) {
+			if err != nil {
+				return nil, err
+			}
+			return resp, nil
+		}
+
+		// First handler that wants to retry wins.
+		var wait time.Duration
+		retry := false
+		for _, h := range handlers {
+			if r, w := h.ShouldRetry(state, req, resp, err); r {
+				retry, wait = true, w
+				break
+			}
+		}
+		if !retry {
+			if err != nil {
+				return nil, err
+			}
+			return resp, nil
+		}
+
+		// Log, discard response body if we have one, sleep, then next attempt.
+		if err != nil {
+			c.logRetry(attempt, "connection error", err)
+		} else {
+			reason := fmt.Sprintf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
+			c.logRetry(attempt, reason, wait)
+			_, _ = io.Copy(io.Discard, resp.Body)
+			_ = resp.Body.Close()
+		}
+		if !sleepWithContext(req.Context(), wait) {
+			return nil, req.Context().Err()
+		}
+	}
+
+	return nil, lastErr
+}
+
+func isRetryableConnError(err error) bool {
+	for ; err != nil; err = errors.Unwrap(err) {
+		s := err.Error()
+		if strings.Contains(s, "connection reset") ||
+			strings.Contains(s, "connection refused") ||
+			strings.Contains(s, "EOF") {
+			return true
+		}
+	}
+	return false
+}
diff --git a/retry_test.go b/retry_test.go
new file mode 100644
index 000000000..3d3a17cfc
--- /dev/null
+++ b/retry_test.go
@@ -0,0 +1,834 @@
+package slack
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+// TestRetryOn429ThenSuccess verifies that 429 responses are retried (using Retry-After or config)
+// until success; call count = 2×429 + 1×200 = 3.
+func TestRetryOn429ThenSuccess(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		n := callCount.Add(1)
+		if n <= 2 {
+			w.Header().Set("Retry-After", "0")
+			w.WriteHeader(http.StatusTooManyRequests)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.RetryAfterDuration = 1 * time.Millisecond
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = 5 * time.Millisecond
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg)
+
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err != nil {
+		t.Fatalf("postMethod: %v", err)
+	}
+	if !out.Ok {
+		t.Errorf("want ok=true, got ok=%v", out.Ok)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("want 3 calls (2x 429 + 1x 200), got %d", got)
+	}
+}
+
+// TestRetryOn500ThenSuccess verifies that 5xx responses are retried with backoff until success.
+func TestRetryOn500ThenSuccess(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		n := callCount.Add(1)
+		if n <= 2 {
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = 5 * time.Millisecond
+	cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg))
+
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err != nil {
+		t.Fatalf("postMethod: %v", err)
+	}
+	if !out.Ok {
+		t.Errorf("want ok=true, got ok=%v", out.Ok)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("want 3 calls (2x 500 + 1x 200), got %d", got)
+	}
+}
+
+// TestRetryExhaustedReturnsLastError verifies that when all 5xx retries are exhausted we return
+// StatusCodeError and the server was called MaxRetries+1 times (3 for MaxRetries=2).
+func TestRetryExhaustedReturnsLastError(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		callCount.Add(1)
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer srv.Close()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = time.Millisecond
+	cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg))
+
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err == nil {
+		t.Fatal("expected error after exhausting retries")
+	}
+	if _, ok := err.(StatusCodeError); !ok {
+		t.Errorf("expected StatusCodeError, got %T: %v", err, err)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("want 3 calls (all 500), got %d", got)
+	}
+}
+
+// TestRetryExhausted5xxResponseBodyReadable verifies that when retries are exhausted on 5xx,
+// the response body is left readable so checkStatusCode/logResponse can dump it for debug.
+func TestRetryExhausted5xxResponseBodyReadable(t *testing.T) {
+	t.Parallel()
+
+	const bodyContent = "custom error body from server"
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+		w.Write([]byte(bodyContent))
+	}))
+	defer srv.Close()
+
+	buf := bytes.NewBuffer(nil)
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = time.Millisecond
+	cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg))
+
+	api := New("token",
+		OptionAPIURL(srv.URL+"/"),
+		OptionRetryConfig(cfg),
+		OptionDebug(true),
+		OptionLog(log.New(buf, "", 0)),
+	)
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err == nil {
+		t.Fatal("expected error after exhausting retries")
+	}
+	if _, ok := err.(StatusCodeError); !ok {
+		t.Errorf("expected StatusCodeError, got %T: %v", err, err)
+	}
+	logged := buf.String()
+	if !strings.Contains(logged, bodyContent) {
+		t.Errorf("debug log should contain response body %q (so checkStatusCode/logResponse could read it); got: %s", bodyContent, logged)
+	}
+}
+
+// TestRetryExhausted429ResponseBodyReadable verifies that when retries are exhausted on 429
+// without Retry-After, the response body is left readable so logResponse can dump it for debug.
+func TestRetryExhausted429ResponseBodyReadable(t *testing.T) {
+	t.Parallel()
+
+	const bodyContent = "rate limit message from server"
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusTooManyRequests)
+		w.Write([]byte(bodyContent))
+	}))
+	defer srv.Close()
+
+	buf := bytes.NewBuffer(nil)
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.RetryAfterDuration = time.Millisecond
+	cfg.RetryAfterJitter = 0
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg)
+
+	api := New("token",
+		OptionAPIURL(srv.URL+"/"),
+		OptionRetryConfig(cfg),
+		OptionDebug(true),
+		OptionLog(log.New(buf, "", 0)),
+	)
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err == nil {
+		t.Fatal("expected error after exhausting 429 retries")
+	}
+	// 429 without Retry-After returns StatusCodeError and calls logResponse
+	if _, ok := err.(StatusCodeError); !ok {
+		t.Errorf("expected StatusCodeError (429 without Retry-After), got %T: %v", err, err)
+	}
+	logged := buf.String()
+	if !strings.Contains(logged, bodyContent) {
+		t.Errorf("debug log should contain response body %q; got: %s", bodyContent, logged)
+	}
+}
+
+// TestRetryExhausted429ReturnsError verifies that when 429 retries are exhausted (with Retry-After
+// set) we return *RateLimitedError and the server was called MaxRetries+1 times.
+func TestRetryExhausted429ReturnsError(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		callCount.Add(1)
+		w.Header().Set("Retry-After", "1")
+		w.WriteHeader(http.StatusTooManyRequests)
+	}))
+	defer srv.Close()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.RetryAfterDuration = time.Millisecond
+	cfg.RetryAfterJitter = 0
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg)
+
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err == nil {
+		t.Fatal("expected error after exhausting 429 retries")
+	}
+	if _, ok := err.(*RateLimitedError); !ok {
+		t.Errorf("expected *RateLimitedError, got %T: %v", err, err)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("want 3 calls (all 429), got %d", got)
+	}
+}
+
+// TestOptionRetryZeroDisablesRetry verifies OptionRetryConfig(RetryConfig{MaxRetries: 0}) does not
+// wrap the client (no retry layer).
+func TestOptionRetryZeroDisablesRetry(t *testing.T) {
+	t.Parallel()
+
+	api := New("token", OptionRetryConfig(RetryConfig{MaxRetries: 0}))
+	if _, ok := api.httpclient.(*retryClient); ok {
+		t.Error("OptionRetryConfig with MaxRetries=0 should not wrap client")
+	}
+}
+
+// TestOptionRetryNonPositiveDisablesRetry verifies OptionRetry(0), OptionRetry(-1), etc. do not
+// wrap the client.
+func TestOptionRetryNonPositiveDisablesRetry(t *testing.T) {
+	t.Parallel()
+
+	for _, maxRetries := range []int{0, -1, -10} {
+		api := New("token", OptionRetry(maxRetries))
+		if _, ok := api.httpclient.(*retryClient); ok {
+			t.Errorf("OptionRetry(%d) should not wrap client", maxRetries)
+		}
+	}
+}
+
+// TestRetryOn503ThenSuccess verifies 503 is retried like other 5xx until success.
+func TestRetryOn503ThenSuccess(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		n := callCount.Add(1)
+		if n <= 2 {
+			w.WriteHeader(http.StatusServiceUnavailable)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = 5 * time.Millisecond
+	cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg))
+
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err != nil {
+		t.Fatalf("postMethod: %v", err)
+	}
+	if !out.Ok {
+		t.Errorf("want ok=true, got ok=%v", out.Ok)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("want 3 calls (2x 503 + 1x 200), got %d", got)
+	}
+}
+
+// TestRetryOnConnectionErrorThenSuccess verifies connection errors (e.g. "connection reset") are
+// retried until success; call count = 2×error + 1×200 = 3.
+func TestRetryOnConnectionErrorThenSuccess(t *testing.T) {
+	t.Parallel()
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+
+	base := &http.Client{}
+	wrapped := &failingThenOKClient{httpClient: base, failAttempts: 2}
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = 5 * time.Millisecond
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg) // connection + 429; default is 429 only
+
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionHTTPClient(wrapped), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err != nil {
+		t.Fatalf("postMethod: %v", err)
+	}
+	if !out.Ok {
+		t.Errorf("want ok=true, got ok=%v", out.Ok)
+	}
+	if got := wrapped.attempts.Load(); got != 3 {
+		t.Errorf("want 3 calls (2x connection error + 1x 200), got %d", got)
+	}
+}
+
+// TestRetryOnConnectionErrorExhausted verifies that when connection errors persist for all attempts
+// we return the last error (no response) and the underlying client was called MaxRetries+1 times.
+func TestRetryOnConnectionErrorExhausted(t *testing.T) {
+	t.Parallel()
+
+	failClient := &connectionFailingClient{}
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = time.Millisecond
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg) // connection + 429; default is 429 only
+
+	rc := &retryClient{client: failClient, config: cfg, debug: nil}
+	req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost/", strings.NewReader("body"))
+	resp, err := rc.Do(req)
+	if err == nil {
+		t.Fatal("expected error when all connection attempts fail")
+	}
+	if resp != nil {
+		t.Errorf("expected nil response when error returned, got %v", resp)
+	}
+	if got := failClient.attempts.Load(); got != 3 {
+		t.Errorf("want 3 attempts (MaxRetries+1), got %d", got)
+	}
+}
+
+// TestNoRetryWhenGetBodyNil verifies that when a request has a body but no GetBody we do not
+// retry on 429/5xx, so we never send an empty body; the underlying client is called exactly once.
+func TestNoRetryWhenGetBodyNil(t *testing.T) {
+	t.Parallel()
+
+	mock := &countAndRespondClient{code: http.StatusTooManyRequests}
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.RetryAfterDuration = time.Millisecond
+	cfg.RetryAfterJitter = 0
+
+	rc := &retryClient{client: mock, config: cfg, debug: nil}
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://localhost/", io.NopCloser(strings.NewReader("body")))
+	if err != nil {
+		t.Fatal(err)
+	}
+	req.GetBody = nil // request body cannot be replayed; retry must not send empty body
+
+	resp, err := rc.Do(req)
+	if err != nil {
+		t.Fatalf("Do: %v", err)
+	}
+	resp.Body.Close()
+	if got := mock.count.Load(); got != 1 {
+		t.Errorf("when body present and GetBody is nil should not retry, got %d calls", got)
+	}
+}
+
+// TestRetryGetRequestWithNilBody verifies that GET requests (or any request with nil body and
+// no GetBody, as created by getResource) are retried on 429 when using AllBuiltinRetryHandlers.
+func TestRetryGetRequestWithNilBody(t *testing.T) {
+	t.Parallel()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.RetryAfterDuration = time.Millisecond
+	cfg.RetryAfterJitter = 0
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg)
+
+	// Return 429 twice then 200 on third call.
+	wrapped := &retryCountThenSuccessClient{needFail: 2}
+	rc := &retryClient{client: wrapped, config: cfg, debug: nil}
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Body and GetBody are both nil, as with getResource — should still retry.
+	resp, err := rc.Do(req)
+	if err != nil {
+		t.Fatalf("Do: %v", err)
+	}
+	resp.Body.Close()
+	if got := wrapped.calls.Load(); got != 3 {
+		t.Errorf("GET with nil body should retry on 429 (2x 429 + 1x 200), got %d calls", got)
+	}
+}
+
+// contextCancelClient returns context.Canceled when the request context is already cancelled.
+type contextCancelClient struct {
+	httpClient
+	calls atomic.Int32
+}
+
+func (c *contextCancelClient) Do(req *http.Request) (*http.Response, error) {
+	c.calls.Add(1)
+	if err := req.Context().Err(); err != nil {
+		return nil, err
+	}
+	return c.httpClient.Do(req)
+}
+
+// TestRetryRespectsContextCancelation verifies that when the request context is already
+// cancelled before the first attempt, we return the context error without retrying
+// (underlying client is called once and gets context.Canceled).
+func TestRetryRespectsContextCancelation(t *testing.T) {
+	t.Parallel()
+
+	mock := &countAndRespondClient{code: http.StatusTooManyRequests}
+	wrapped := &contextCancelClient{httpClient: mock}
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.RetryAfterDuration = 60 * time.Second
+	cfg.RetryAfterJitter = 0
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel() // cancel before first attempt
+
+	rc := &retryClient{client: wrapped, config: cfg, debug: nil}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://localhost/", strings.NewReader("body"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	req.GetBody = func() (io.ReadCloser, error) {
+		return io.NopCloser(strings.NewReader("body")), nil
+	}
+	resp, err := rc.Do(req)
+	if err == nil {
+		t.Fatal("expected error when context is cancelled")
+	}
+	if resp != nil {
+		resp.Body.Close()
+		t.Errorf("expected nil response when context cancelled, got %v", resp)
+	}
+	if err != context.Canceled {
+		t.Errorf("expected context.Canceled, got %v", err)
+	}
+	if got := wrapped.calls.Load(); got != 1 {
+		t.Errorf("expected 1 call, got %d", got)
+	}
+}
+
+// TestRetryContextCanceledDuringSleep verifies that when the context is cancelled during
+// the retry wait (after a 429), we return immediately with the context error instead of
+// sleeping the full duration, and we do not perform a second request.
+func TestRetryContextCanceledDuringSleep(t *testing.T) {
+	t.Parallel()
+
+	mock := &countAndRespondClient{code: http.StatusTooManyRequests}
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 2
+	cfg.RetryAfterDuration = 60 * time.Second // long sleep; cancel during this
+	cfg.RetryAfterJitter = 0
+	cfg.Handlers = AllBuiltinRetryHandlers(cfg)
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	body := "body"
+	rc := &retryClient{client: mock, config: cfg, debug: nil}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://localhost/", strings.NewReader(body))
+	if err != nil {
+		t.Fatal(err)
+	}
+	req.GetBody = func() (io.ReadCloser, error) {
+		return io.NopCloser(strings.NewReader(body)), nil
+	}
+
+	// Cancel context shortly after first attempt returns 429, so we exit during sleep.
+	go func() {
+		time.Sleep(20 * time.Millisecond)
+		cancel()
+	}()
+
+	resp, err := rc.Do(req)
+	if err == nil {
+		t.Fatal("expected error when context is cancelled during sleep")
+	}
+	if resp != nil {
+		resp.Body.Close()
+		t.Errorf("expected nil response when context cancelled, got %v", resp)
+	}
+	if err != context.Canceled {
+		t.Errorf("expected context.Canceled, got %v", err)
+	}
+	if got := mock.count.Load(); got != 1 {
+		t.Errorf("expected 1 call (cancel during sleep before retry), got %d", got)
+	}
+}
+
+// TestDefaultRetryConfig verifies DefaultRetryConfig returns the documented defaults.
+func TestDefaultRetryConfig(t *testing.T) {
+	t.Parallel()
+
+	cfg := DefaultRetryConfig()
+	if cfg.MaxRetries != 3 {
+		t.Errorf("MaxRetries: got %d, want 3", cfg.MaxRetries)
+	}
+	if cfg.RetryAfterDuration != 60*time.Second {
+		t.Errorf("RetryAfterDuration: got %v, want 60s", cfg.RetryAfterDuration)
+	}
+	if cfg.RetryAfterJitter != 1*time.Second {
+		t.Errorf("RetryAfterJitter: got %v, want 1s", cfg.RetryAfterJitter)
+	}
+	if cfg.BackoffInitial != 100*time.Millisecond {
+		t.Errorf("BackoffInitial: got %v, want 100ms", cfg.BackoffInitial)
+	}
+	if cfg.BackoffMax != 30*time.Second {
+		t.Errorf("BackoffMax: got %v, want 30s", cfg.BackoffMax)
+	}
+	if cfg.BackoffJitter != 50*time.Millisecond {
+		t.Errorf("BackoffJitter: got %v, want 50ms", cfg.BackoffJitter)
+	}
+}
+
+// TestConnectionOnlyRetryHandlersOnlyRetriesConnection verifies that ConnectionOnlyRetryHandlers
+// (connection only) does not retry 429 or 5xx; one call each, then error.
+func TestConnectionOnlyRetryHandlersOnlyRetriesConnection(t *testing.T) {
+	t.Parallel()
+
+	t.Run("429_not_retried", func(t *testing.T) {
+		t.Parallel()
+		var callCount atomic.Int32
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			callCount.Add(1)
+			w.WriteHeader(http.StatusTooManyRequests)
+		}))
+		defer srv.Close()
+		cfg := DefaultRetryConfig()
+		cfg.MaxRetries = 3
+		cfg.Handlers = ConnectionOnlyRetryHandlers()
+		api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+		var out SlackResponse
+		err := api.postMethod(context.Background(), "auth.test", nil, &out)
+		if err == nil {
+			t.Fatal("expected error on 429 with connection-only handlers")
+		}
+		if got := callCount.Load(); got != 1 {
+			t.Errorf("429 should not be retried with ConnectionOnlyRetryHandlers, got %d calls", got)
+		}
+	})
+
+	t.Run("5xx_not_retried", func(t *testing.T) {
+		t.Parallel()
+		var callCount atomic.Int32
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			callCount.Add(1)
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer srv.Close()
+		cfg := DefaultRetryConfig()
+		cfg.MaxRetries = 3
+		cfg.Handlers = ConnectionOnlyRetryHandlers()
+		api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+		var out SlackResponse
+		err := api.postMethod(context.Background(), "auth.test", nil, &out)
+		if err == nil {
+			t.Fatal("expected error on 500 with connection-only handlers")
+		}
+		if got := callCount.Load(); got != 1 {
+			t.Errorf("5xx should not be retried with ConnectionOnlyRetryHandlers, got %d calls", got)
+		}
+	})
+}
+
+// TestOptionRetryRetries429Not5xx verifies OptionRetry(n) uses DefaultRetryHandlers (429 only): 429 retried, 5xx not retried.
+func TestOptionRetryRetries429Not5xx(t *testing.T) {
+	t.Parallel()
+
+	t.Run("429_retried", func(t *testing.T) {
+		t.Parallel()
+		var callCount atomic.Int32
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			n := callCount.Add(1)
+			if n <= 2 {
+				w.Header().Set("Retry-After", "0")
+				w.WriteHeader(http.StatusTooManyRequests)
+				return
+			}
+			w.Header().Set("Content-Type", "application/json")
+			w.Write([]byte(`{"ok":true}`))
+		}))
+		defer srv.Close()
+		api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetry(3))
+		var out SlackResponse
+		err := api.postMethod(context.Background(), "auth.test", nil, &out)
+		if err != nil {
+			t.Fatalf("postMethod: %v", err)
+		}
+		if got := callCount.Load(); got != 3 {
+			t.Errorf("OptionRetry should retry 429, want 3 calls, got %d", got)
+		}
+	})
+
+	t.Run("5xx_not_retried", func(t *testing.T) {
+		t.Parallel()
+		var callCount atomic.Int32
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			callCount.Add(1)
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer srv.Close()
+		api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetry(3))
+		var out SlackResponse
+		err := api.postMethod(context.Background(), "auth.test", nil, &out)
+		if err == nil {
+			t.Fatal("expected error on 500 with OptionRetry (no 5xx handler)")
+		}
+		if got := callCount.Load(); got != 1 {
+			t.Errorf("OptionRetry should not retry 5xx by default, got %d calls", got)
+		}
+	})
+}
+
+// TestOptionRetryConfigWithNilHandlersDefaultsTo429Only verifies that OptionRetryConfig(cfg)
+// with cfg.Handlers == nil defaults to DefaultRetryHandlers (429 only); 429 is retried until success.
+func TestOptionRetryConfigWithNilHandlersDefaultsTo429Only(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		n := callCount.Add(1)
+		if n <= 2 {
+			w.Header().Set("Retry-After", "0")
+			w.WriteHeader(http.StatusTooManyRequests)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	// Handlers explicitly nil; OptionRetryConfig sets DefaultRetryHandlers(cfg) (429 only).
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err != nil {
+		t.Fatalf("postMethod: %v", err)
+	}
+	if !out.Ok {
+		t.Errorf("want ok=true, got ok=%v", out.Ok)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("with nil Handlers default is DefaultRetryHandlers (429 only), 429 should retry; want 3 calls, got %d", got)
+	}
+}
+
+// TestOptionRetryConfigWithNilHandlersDoesNotRetryConnection verifies that with default (429 only)
+// handlers, connection errors are not retried; one call then error.
+func TestOptionRetryConfigWithNilHandlersDoesNotRetryConnection(t *testing.T) {
+	t.Parallel()
+
+	base := &http.Client{}
+	wrapped := &failingThenOKClient{httpClient: base, failAttempts: 2}
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	// Handlers nil = DefaultRetryHandlers (429 only); connection errors not retried
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionHTTPClient(wrapped), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err == nil {
+		t.Fatal("expected error when connection fails with default (429-only) handlers")
+	}
+	if got := wrapped.attempts.Load(); got != 1 {
+		t.Errorf("with default (429 only) handlers connection should not be retried, got %d calls", got)
+	}
+}
+
+// TestServerErrorRetryHandlerOptIn verifies that adding NewServerErrorRetryHandler retries 5xx.
+func TestServerErrorRetryHandlerOptIn(t *testing.T) {
+	t.Parallel()
+
+	var callCount atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		n := callCount.Add(1)
+		if n <= 2 {
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		w.Write([]byte(`{"ok":true}`))
+	}))
+	defer srv.Close()
+	cfg := DefaultRetryConfig()
+	cfg.MaxRetries = 3
+	cfg.BackoffInitial = time.Millisecond
+	cfg.BackoffMax = 5 * time.Millisecond
+	cfg.Handlers = append(AllBuiltinRetryHandlers(cfg), NewServerErrorRetryHandler(cfg))
+	api := New("token", OptionAPIURL(srv.URL+"/"), OptionRetryConfig(cfg))
+	var out SlackResponse
+	err := api.postMethod(context.Background(), "auth.test", nil, &out)
+	if err != nil {
+		t.Fatalf("postMethod: %v", err)
+	}
+	if !out.Ok {
+		t.Errorf("want ok=true, got ok=%v", out.Ok)
+	}
+	if got := callCount.Load(); got != 3 {
+		t.Errorf("with ServerErrorRetryHandler want 3 calls (2x 500 + 1x 200), got %d", got)
+	}
+}
+
+// TestIsRetryableConnError verifies which errors are treated as retryable connection failures.
+func TestIsRetryableConnError(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		err  error
+		want bool
+	}{
+		{"nil", nil, false},
+		{"connection reset", errConnectionReset, true},
+		{"connection refused", errConnectionRefused, true},
+		{"EOF", errEOF, true},
+		{"other", errOther, false},
+		{"wrapped connection reset", fmt.Errorf("request failed: %w", errConnectionReset), true},
+		{"wrapped other", fmt.Errorf("request failed: %w", errOther), false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := isRetryableConnError(tt.err)
+			if got != tt.want {
+				t.Errorf("isRetryableConnError(%v) = %v, want %v", tt.err, got, tt.want)
+			}
+		})
+	}
+}
+
+// --- Test helpers ---
+
+// failingThenOKClient fails with a retryable connection error the first N times, then delegates.
+type failingThenOKClient struct {
+	httpClient
+	failAttempts int32
+	attempts     atomic.Int32
+}
+
+func (c *failingThenOKClient) Do(req *http.Request) (*http.Response, error) {
+	n := c.attempts.Add(1)
+	if n <= c.failAttempts {
+		return nil, &mockErr{msg: "connection reset by peer"}
+	}
+	return c.httpClient.Do(req)
+}
+
+// countAndRespondClient counts Do calls and returns a fixed HTTP status code.
+type countAndRespondClient struct {
+	count atomic.Int32
+	code  int
+}
+
+func (c *countAndRespondClient) Do(req *http.Request) (*http.Response, error) {
+	c.count.Add(1)
+	return &http.Response{
+		StatusCode: c.code,
+		Body:       io.NopCloser(strings.NewReader("")),
+		Header:     make(http.Header),
+	}, nil
+}
+
+// retryCountThenSuccessClient returns 429 for the first needFail Do calls, then 200.
+type retryCountThenSuccessClient struct {
+	needFail int32
+	calls    atomic.Int32
+}
+
+func (c *retryCountThenSuccessClient) Do(req *http.Request) (*http.Response, error) {
+	n := c.calls.Add(1)
+	code := http.StatusTooManyRequests
+	if n > c.needFail {
+		code = http.StatusOK
+	}
+	return &http.Response{
+		StatusCode: code,
+		Body:       io.NopCloser(strings.NewReader(`{"ok":true}`)),
+		Header:     make(http.Header),
+	}, nil
+}
+
+// connectionFailingClient always returns a retryable connection error; attempts is incremented each Do.
+type connectionFailingClient struct {
+	attempts atomic.Int32
+}
+
+func (c *connectionFailingClient) Do(*http.Request) (*http.Response, error) {
+	c.attempts.Add(1)
+	return nil, &mockErr{msg: "connection reset by peer"}
+}
+
+var (
+	errConnectionReset   = &mockErr{msg: "connection reset by peer"}
+	errConnectionRefused = &mockErr{msg: "connection refused"}
+	errEOF               = &mockErr{msg: "EOF"}
+	errOther             = &mockErr{msg: "something else"}
+)
+
+type mockErr struct{ msg string }
+
+func (e *mockErr) Error() string { return e.msg }
diff --git a/rtm.go b/rtm.go
index ef6ba3434..9b30eb315 100644
--- a/rtm.go
+++ b/rtm.go
@@ -25,6 +25,9 @@ const (
 // StartRTM calls the "rtm.start" endpoint and returns the provided URL and the full Info block.
 //
 // To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` on it.
+//
+// Deprecated: Use [ConnectRTM] instead.
+// For more details, see: https://api.slack.com/changelog/2021-10-rtm-start-to-stop
 func (api *Client) StartRTM() (info *Info, websocketURL string, err error) {
 	ctx, cancel := context.WithTimeout(context.Background(), websocketDefaultTimeout)
 	defer cancel()
@@ -35,6 +38,9 @@ func (api *Client) StartRTM() (info *Info, websocketURL string, err error) {
 // StartRTMContext calls the "rtm.start" endpoint and returns the provided URL and the full Info block with a custom context.
 //
 // To have a fully managed Websocket connection, use `NewRTM`, and call `ManageConnection()` on it.
+//
+// Deprecated: Use [ConnectRTMContext] instead.
+// For more details, see: https://api.slack.com/changelog/2021-10-rtm-start-to-stop
 func (api *Client) StartRTMContext(ctx context.Context) (info *Info, websocketURL string, err error) {
 	response := &infoResponseFull{}
 	err = api.postMethod(ctx, "rtm.start", url.Values{"token": {api.token}}, response)
diff --git a/search.go b/search.go
index de6b40acb..9df433501 100644
--- a/search.go
+++ b/search.go
@@ -15,6 +15,7 @@ const (
 )
 
 type SearchParameters struct {
+	TeamID        string
 	Sort          string
 	SortDirection string
 	Highlight     bool
@@ -88,18 +89,21 @@ func NewSearchParameters() SearchParameters {
 	}
 }
 
-func (api *Client) _search(ctx context.Context, path, query string, params SearchParameters, files, messages bool) (response *searchResponseFull, error error) {
+func (api *Client) _search(ctx context.Context, path, query string, params SearchParameters) (response *searchResponseFull, error error) {
 	values := url.Values{
 		"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)
 	}
 	if params.SortDirection != DEFAULT_SEARCH_SORT_DIR {
 		values.Add("sort_dir", params.SortDirection)
 	}
-	if params.Highlight != DEFAULT_SEARCH_HIGHLIGHT {
+	if params.Highlight {
 		values.Add("highlight", strconv.Itoa(1))
 	}
 	if params.Count != DEFAULT_SEARCH_COUNT {
@@ -124,7 +128,7 @@ func (api *Client) Search(query string, params SearchParameters) (*SearchMessage
 }
 
 func (api *Client) SearchContext(ctx context.Context, query string, params SearchParameters) (*SearchMessages, *SearchFiles, error) {
-	response, err := api._search(ctx, "search.all", query, params, true, true)
+	response, err := api._search(ctx, "search.all", query, params)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -136,7 +140,7 @@ func (api *Client) SearchFiles(query string, params SearchParameters) (*SearchFi
 }
 
 func (api *Client) SearchFilesContext(ctx context.Context, query string, params SearchParameters) (*SearchFiles, error) {
-	response, err := api._search(ctx, "search.files", query, params, true, false)
+	response, err := api._search(ctx, "search.files", query, params)
 	if err != nil {
 		return nil, err
 	}
@@ -148,7 +152,7 @@ func (api *Client) SearchMessages(query string, params SearchParameters) (*Searc
 }
 
 func (api *Client) SearchMessagesContext(ctx context.Context, query string, params SearchParameters) (*SearchMessages, error) {
-	response, err := api._search(ctx, "search.messages", query, params, false, true)
+	response, err := api._search(ctx, "search.messages", query, params)
 	if err != nil {
 		return nil, err
 	}
diff --git a/security.go b/security.go
index 451035293..8124c2c09 100644
--- a/security.go
+++ b/security.go
@@ -30,6 +30,10 @@ func unsafeSignatureVerifier(header http.Header, secret string) (_ SecretsVerifi
 		bsignature []byte
 	)
 
+	if secret == "" {
+		return SecretsVerifier{}, ErrInvalidConfiguration
+	}
+
 	signature := header.Get(hSignature)
 	stimestamp := header.Get(hTimestamp)
 
@@ -42,7 +46,7 @@ func unsafeSignatureVerifier(header http.Header, secret string) (_ SecretsVerifi
 	}
 
 	hash := hmac.New(sha256.New, []byte(secret))
-	if _, err = hash.Write([]byte(fmt.Sprintf("v0:%s:", stimestamp))); err != nil {
+	if _, err = fmt.Fprintf(hash, "v0:%s:", stimestamp); err != nil {
 		return SecretsVerifier{}, err
 	}
 
@@ -95,7 +99,7 @@ func (v SecretsVerifier) Ensure() error {
 	if v.d != nil && v.d.Debug() {
 		v.d.Debugln(fmt.Sprintf("Expected signing signature: %s, but computed: %s", hex.EncodeToString(v.signature), hex.EncodeToString(computed)))
 	}
-	return fmt.Errorf("Computed unexpected signature of: %s", hex.EncodeToString(computed))
+	return fmt.Errorf("computed unexpected signature of: %s", hex.EncodeToString(computed))
 }
 
 func abs64(n int64) int64 {
diff --git a/security_test.go b/security_test.go
index cbd61f107..959331eb9 100644
--- a/security_test.go
+++ b/security_test.go
@@ -1,6 +1,7 @@
 package slack
 
 import (
+	"errors"
 	"io"
 	"log"
 	"net/http"
@@ -32,6 +33,20 @@ func TestExpiredTimestamp(t *testing.T) {
 	}
 }
 
+func TestNewSecretsVerifierRejectsEmptySigningSecret(t *testing.T) {
+	_, err := NewSecretsVerifier(newHeader(true), "")
+	if !errors.Is(err, ErrInvalidConfiguration) {
+		t.Fatalf("expected ErrInvalidConfiguration, got %v", err)
+	}
+}
+
+func TestUnsafeSignatureVerifierRejectsEmptySigningSecret(t *testing.T) {
+	_, err := unsafeSignatureVerifier(newHeader(true), "")
+	if !errors.Is(err, ErrInvalidConfiguration) {
+		t.Fatalf("expected ErrInvalidConfiguration, got %v", err)
+	}
+}
+
 func TestUnsafeSignatureVerifier(t *testing.T) {
 	tests := []struct {
 		title         string
diff --git a/slack.go b/slack.go
index ea3aab6d6..45b9cd3bc 100644
--- a/slack.go
+++ b/slack.go
@@ -12,6 +12,8 @@ import (
 const (
 	// APIURL of the slack api.
 	APIURL = "https://slack.com/api/"
+	// AuditAPIURL is the base URL for the Audit Logs API.
+	AuditAPIURL = "https://api.slack.com/"
 	// WEBAPIURLFormat ...
 	WEBAPIURLFormat = "https://%s.slack.com/api/users.admin.%s?t=%d"
 )
@@ -44,25 +46,32 @@ type AuthTestResponse struct {
 	TeamID string `json:"team_id"`
 	UserID string `json:"user_id"`
 	// EnterpriseID is only returned when an enterprise id present
-	EnterpriseID string `json:"enterprise_id,omitempty"`
-	BotID        string `json:"bot_id"`
+	EnterpriseID string      `json:"enterprise_id,omitempty"`
+	BotID        string      `json:"bot_id"`
+	Header       http.Header `json:"-"`
 }
 
 type authTestResponseFull struct {
 	SlackResponse
 	AuthTestResponse
+	responseHeaders
 }
 
-// Client for the slack api.
 type ParamOption func(*url.Values)
 
+// Client for the slack api.
 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
+	auditEndpoint      string
+	debug              bool
+	log                ilogger
+	httpclient         httpClient
+	onWarning          func(path string, request any, w *Warning)
+	onResponseHeaders  func(path string, headers http.Header)
 }
 
 // Option defines an option for a Client
@@ -89,23 +98,100 @@ func OptionLog(l logger) func(*Client) {
 	}
 }
 
+// OptionOnWarning sets a callback invoked whenever an API response contains
+// warnings. The callback receives the API method path (e.g.
+// "conversations.join"), the request payload ([url.Values] for form-encoded
+// requests or []byte for JSON requests), and a [Warning] with the warning
+// codes and messages.
+//
+// Example:
+//
+//	api := slack.New("YOUR_TOKEN",
+//		slack.OptionOnWarning(func(path string, request any, w *slack.Warning) {
+//			log.Printf("slack warnings for %s: codes=%v warnings=%v", path, w.Codes, w.Warnings)
+//		}),
+//	)
+func OptionOnWarning(fn func(path string, request any, w *Warning)) func(*Client) {
+	return func(c *Client) {
+		c.onWarning = fn
+	}
+}
+
+// OptionOnResponseHeaders sets a callback invoked after every API request
+// with the API method path and the HTTP response headers. This allows
+// accessing headers like X-OAuth-Scopes and X-Ratelimit-* for any request.
+func OptionOnResponseHeaders(fn func(path string, headers http.Header)) func(*Client) {
+	return func(c *Client) {
+		c.onResponseHeaders = fn
+	}
+}
+
 // OptionAPIURL set the url for the client. only useful for testing.
 func OptionAPIURL(u string) func(*Client) {
 	return func(c *Client) { c.endpoint = u }
 }
 
+// OptionAuditAPIURL set the url for the Audit Logs API. only useful for testing.
+func OptionAuditAPIURL(u string) func(*Client) {
+	return func(c *Client) { c.auditEndpoint = u }
+}
+
 // OptionAppLevelToken sets an app-level token for the client.
 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 }
+}
+
+// OptionRetry enables HTTP retries for rate limit (429) only; 5xx and connection errors are not retried.
+// Uses DefaultRetryHandlers. Use OptionRetryConfig with AllBuiltinRetryHandlers for connection + 429.
+// If maxRetries is zero or negative, the client is not wrapped (no retries).
+// When using a custom HTTP client, pass OptionRetry after OptionHTTPClient so the retry wrapper is applied to it.
+func OptionRetry(maxRetries int) func(*Client) {
+	return func(c *Client) {
+		if maxRetries <= 0 {
+			return
+		}
+		cfg := DefaultRetryConfig()
+		cfg.MaxRetries = maxRetries
+		cfg.Handlers = DefaultRetryHandlers(cfg)
+		c.httpclient = &retryClient{client: c.httpclient, config: cfg, debug: c}
+	}
+}
+
+// OptionRetryConfig enables HTTP retries with a custom config.
+// If config.MaxRetries is 0, the client is not wrapped (no retries).
+// If config.Handlers is nil, DefaultRetryHandlers(cfg) is used (429 only).
+// When using a custom HTTP client, pass OptionRetryConfig after OptionHTTPClient so the retry wrapper is applied to it.
+func OptionRetryConfig(config RetryConfig) func(*Client) {
+	return func(c *Client) {
+		if config.MaxRetries <= 0 {
+			return
+		}
+		cfg := config
+		if cfg.Handlers == nil {
+			cfg.Handlers = DefaultRetryHandlers(cfg)
+		}
+		c.httpclient = &retryClient{client: c.httpclient, config: cfg, debug: c}
+	}
+}
+
 // New builds a slack client from the provided token and options.
 func New(token string, options ...Option) *Client {
 	s := &Client{
-		token:      token,
-		endpoint:   APIURL,
-		httpclient: &http.Client{},
-		log:        log.New(os.Stderr, "slack-go/slack", log.LstdFlags|log.Lshortfile),
+		token:         token,
+		endpoint:      APIURL,
+		auditEndpoint: AuditAPIURL,
+		httpclient:    &http.Client{},
+		log:           log.New(os.Stderr, "slack-go/slack", log.LstdFlags|log.Lshortfile),
 	}
 
 	for _, opt := range options {
@@ -129,18 +215,19 @@ func (api *Client) AuthTestContext(ctx context.Context) (response *AuthTestRespo
 		return nil, err
 	}
 
+	responseFull.AuthTestResponse.Header = responseFull.responseHeaders.header
 	return &responseFull.AuthTestResponse, responseFull.Err()
 }
 
 // Debugf print a formatted debug line.
-func (api *Client) Debugf(format string, v ...interface{}) {
+func (api *Client) Debugf(format string, v ...any) {
 	if api.debug {
 		api.log.Output(2, fmt.Sprintf(format, v...))
 	}
 }
 
 // Debugln print a debug line.
-func (api *Client) Debugln(v ...interface{}) {
+func (api *Client) Debugln(v ...any) {
 	if api.debug {
 		api.log.Output(2, fmt.Sprintln(v...))
 	}
@@ -152,11 +239,42 @@ func (api *Client) Debug() bool {
 }
 
 // post to a slack web method.
-func (api *Client) postMethod(ctx context.Context, path string, values url.Values, intf interface{}) error {
-	return postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api)
+func (api *Client) postMethod(ctx context.Context, path string, values url.Values, intf any) error {
+	headers, err := postForm(ctx, api.httpclient, api.endpoint+path, values, intf, api)
+	api.checkWarnings(intf, path, values)
+	api.fireResponseHeaders(path, headers)
+	return err
 }
 
 // get a slack web method.
-func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf interface{}) error {
-	return getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api)
+func (api *Client) getMethod(ctx context.Context, path string, token string, values url.Values, intf any) error {
+	headers, err := getResource(ctx, api.httpclient, api.endpoint+path, token, values, intf, api)
+	api.checkWarnings(intf, path, values)
+	api.fireResponseHeaders(path, headers)
+	return err
+}
+
+// postJSONMethod posts JSON to a slack web method.
+func (api *Client) postJSONMethod(ctx context.Context, path string, token string, jsonBody []byte, intf any) error {
+	headers, err := postJSON(ctx, api.httpclient, api.endpoint+path, token, jsonBody, intf, api)
+	api.checkWarnings(intf, path, jsonBody)
+	api.fireResponseHeaders(path, headers)
+	return err
+}
+
+func (api *Client) checkWarnings(intf any, path string, request any) {
+	if api.onWarning == nil {
+		return
+	}
+	if w, ok := intf.(warner); ok {
+		if warning := w.Warn(); warning != nil {
+			api.onWarning(path, request, warning)
+		}
+	}
+}
+
+func (api *Client) fireResponseHeaders(path string, headers http.Header) {
+	if api.onResponseHeaders != nil && headers != nil {
+		api.onResponseHeaders(path, headers)
+	}
 }
diff --git a/slackevents/action_events.go b/slackevents/action_events.go
index c6016f107..e7e4ed105 100644
--- a/slackevents/action_events.go
+++ b/slackevents/action_events.go
@@ -6,18 +6,25 @@ import (
 	"github.com/slack-go/slack"
 )
 
+// Deprecated: MessageActionResponse is associated with [MessageAction] which cannot
+// handle block_actions. Use [slack.InteractionCallback] instead.
 type MessageActionResponse struct {
 	ResponseType    string `json:"response_type"`
 	ReplaceOriginal bool   `json:"replace_original"`
 	Text            string `json:"text"`
 }
 
+// Deprecated: MessageActionEntity is associated with [MessageAction] which cannot
+// handle block_actions. Use [slack.InteractionCallback] instead.
 type MessageActionEntity struct {
 	ID     string `json:"id"`
 	Domain string `json:"domain"`
 	Name   string `json:"name"`
 }
 
+// Deprecated: MessageAction cannot represent block_actions payloads. Use
+// [slack.InteractionCallback] instead, which handles all interaction types.
+// See [slack.InteractionCallbackParse] for parsing from an HTTP request.
 type MessageAction struct {
 	Type             string                   `json:"type"`
 	Actions          []slack.AttachmentAction `json:"actions"`
diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go
index de98c280e..cc0433606 100644
--- a/slackevents/inner_events.go
+++ b/slackevents/inner_events.go
@@ -11,18 +11,52 @@ import (
 // EventsAPIInnerEvent the inner event of a EventsAPI event_callback Event.
 type EventsAPIInnerEvent struct {
 	Type string `json:"type"`
-	Data interface{}
+	Data any
+}
+
+// AssistantThreadMessageEvent is an (inner) EventsAPI subscribable event.
+type AssistantThreadStartedEvent struct {
+	Type            string          `json:"type"`
+	AssistantThread AssistantThread `json:"assistant_thread"`
+	EventTimestamp  string          `json:"event_ts"`
+}
+
+// AssistantThreadChangedEvent is an (inner) EventsAPI subscribable event.
+type AssistantThreadContextChangedEvent struct {
+	Type            string          `json:"type"`
+	AssistantThread AssistantThread `json:"assistant_thread"`
+	EventTimestamp  string          `json:"event_ts"`
+}
+
+// AssistantThread is an object that represents a thread of messages between a user and an assistant.
+type AssistantThread struct {
+	UserID          string                 `json:"user_id"`
+	Context         AssistantThreadContext `json:"context"`
+	ChannelID       string                 `json:"channel_id"`
+	ThreadTimeStamp string                 `json:"thread_ts"`
+}
+
+// AssistantThreadActionToken contains the action token for Data Access API queries
+type AssistantThreadActionToken struct {
+	ActionToken string `json:"action_token"`
+}
+
+// AssistantThreadContext is an object that represents the context of an assistant thread.
+type AssistantThreadContext struct {
+	ChannelID    string `json:"channel_id"`
+	TeamID       string `json:"team_id"`
+	EnterpriseID string `json:"enterprise_id"`
 }
 
 // AppMentionEvent is an (inner) EventsAPI subscribable event.
 type AppMentionEvent struct {
-	Type            string      `json:"type"`
-	User            string      `json:"user"`
-	Text            string      `json:"text"`
-	TimeStamp       string      `json:"ts"`
-	ThreadTimeStamp string      `json:"thread_ts"`
-	Channel         string      `json:"channel"`
-	EventTimeStamp  json.Number `json:"event_ts"`
+	Type            string `json:"type"`
+	User            string `json:"user"`
+	Text            string `json:"text"`
+	TimeStamp       string `json:"ts"`
+	ThreadTimeStamp string `json:"thread_ts"`
+	Channel         string `json:"channel"`
+	EventTimeStamp  string `json:"event_ts"`
 
 	// When Message comes from a channel that is shared between workspaces
 	UserTeam   string `json:"user_team,omitempty"`
@@ -30,6 +64,20 @@ type AppMentionEvent struct {
 
 	// BotID is filled out when a bot triggers the app_mention event
 	BotID string `json:"bot_id,omitempty"`
+
+	// Fields shared with message events
+	Blocks      slack.Blocks       `json:"blocks,omitempty"`
+	Attachments []slack.Attachment `json:"attachments,omitempty"`
+	Files       []slack.File       `json:"files,omitempty"`
+	Upload      bool               `json:"upload,omitempty"`
+
+	// When the app is mentioned in the edited message
+	Edited *Edited `json:"edited,omitempty"`
+
+	// AssistantThread contains action token for Data Access API queries when app is mentioned
+	AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"`
+	// ActionToken contains the top-level action token for Data Access API queries.
+	ActionToken string `json:"action_token,omitempty"`
 }
 
 // AppHomeOpenedEvent Your Slack app home was opened.
@@ -37,9 +85,9 @@ type AppHomeOpenedEvent struct {
 	Type           string      `json:"type"`
 	User           string      `json:"user"`
 	Channel        string      `json:"channel"`
-	EventTimeStamp json.Number `json:"event_ts"`
+	EventTimeStamp string      `json:"event_ts"`
 	Tab            string      `json:"tab"`
-	View           slack.View  `json:"view"`
+	View           *slack.View `json:"view,omitempty"`
 }
 
 // AppUninstalledEvent Your Slack app was uninstalled.
@@ -56,34 +104,39 @@ type ChannelCreatedEvent struct {
 
 // ChannelDeletedEvent represents the Channel deleted event
 type ChannelDeletedEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // ChannelArchiveEvent represents the Channel archive event
 type ChannelArchiveEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
-	User    string `json:"user"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	User           string `json:"user"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // ChannelUnarchiveEvent represents the Channel unarchive event
 type ChannelUnarchiveEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
-	User    string `json:"user"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	User           string `json:"user"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // ChannelLeftEvent represents the Channel left event
 type ChannelLeftEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // ChannelRenameEvent represents the Channel rename event
 type ChannelRenameEvent struct {
-	Type    string            `json:"type"`
-	Channel ChannelRenameInfo `json:"channel"`
+	Type           string            `json:"type"`
+	Channel        ChannelRenameInfo `json:"channel"`
+	EventTimestamp string            `json:"event_ts"`
 }
 
 // ChannelIDChangedEvent represents the Channel identifier changed event
@@ -110,34 +163,48 @@ type ChannelRenameInfo struct {
 	Created int    `json:"created"`
 }
 
+// ChannelUnsharedEvent represents a channel has been unshared with an external workspace event
+type ChannelUnsharedEvent struct {
+	Type                      string `json:"type"`
+	PreviouslyConnectedTeamID string `json:"previously_connected_team_id"`
+	Channel                   string `json:"channel"`
+	IsExtShared               bool   `json:"is_ext_shared"`
+	EventTimestamp            string `json:"event_ts"`
+}
+
 // GroupDeletedEvent represents the Group deleted event
 type GroupDeletedEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // GroupArchiveEvent represents the Group archive event
 type GroupArchiveEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // GroupUnarchiveEvent represents the Group unarchive event
 type GroupUnarchiveEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // GroupLeftEvent represents the Group left event
 type GroupLeftEvent struct {
-	Type    string `json:"type"`
-	Channel string `json:"channel"`
+	Type           string `json:"type"`
+	Channel        string `json:"channel"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // GroupRenameEvent represents the Group rename event
 type GroupRenameEvent struct {
-	Type    string          `json:"type"`
-	Channel GroupRenameInfo `json:"channel"`
+	Type           string          `json:"type"`
+	Channel        GroupRenameInfo `json:"channel"`
+	EventTimestamp string          `json:"event_ts"`
 }
 
 // GroupRenameInfo represents the information associated with the Group rename event
@@ -147,6 +214,47 @@ type GroupRenameInfo struct {
 	Created int    `json:"created"`
 }
 
+// FileChangeEvent represents the information associated with the File change
+// event.
+type FileChangeEvent struct {
+	Type   string        `json:"type"`
+	FileID string        `json:"file_id"`
+	File   FileEventFile `json:"file"`
+}
+
+// FileDeletedEvent represents the information associated with the File deleted
+// event.
+type FileDeletedEvent struct {
+	Type           string `json:"type"`
+	FileID         string `json:"file_id"`
+	EventTimestamp string `json:"event_ts"`
+}
+
+// FileSharedEvent represents the information associated with the File shared
+// event.
+type FileSharedEvent struct {
+	Type           string        `json:"type"`
+	ChannelID      string        `json:"channel_id"`
+	FileID         string        `json:"file_id"`
+	UserID         string        `json:"user_id"`
+	File           FileEventFile `json:"file"`
+	EventTimestamp string        `json:"event_ts"`
+}
+
+// FileUnsharedEvent represents the information associated with the File
+// unshared event.
+type FileUnsharedEvent struct {
+	Type   string        `json:"type"`
+	FileID string        `json:"file_id"`
+	File   FileEventFile `json:"file"`
+}
+
+// FileEventFile represents information on the specific file being shared in a
+// file-related Slack event.
+type FileEventFile struct {
+	ID string `json:"id"`
+}
+
 // GridMigrationFinishedEvent An enterprise grid migration has finished on this workspace.
 type GridMigrationFinishedEvent struct {
 	Type         string `json:"type"`
@@ -170,78 +278,137 @@ type LinkSharedEvent struct {
 	// compose text area.
 	MessageTimeStamp string        `json:"message_ts"`
 	ThreadTimeStamp  string        `json:"thread_ts"`
-	Links            []sharedLinks `json:"links"`
+	Links            []SharedLinks `json:"links"`
+	EventTimestamp   string        `json:"event_ts"`
 }
 
-type sharedLinks struct {
+type SharedLinks struct {
 	Domain string `json:"domain"`
 	URL    string `json:"url"`
 }
 
+const (
+	ChannelTypeChannel = "channel" // Public channel message
+	ChannelTypeGroup   = "group"   // Private channel message
+	ChannelTypeIM      = "im"      // Direct message
+	ChannelTypeMPIM    = "mpim"    // Multiparty direct message
+)
+
 // MessageEvent occurs when a variety of types of messages has been posted.
 // Parse ChannelType to see which
 // if ChannelType = "group", this is a private channel message
 // if ChannelType = "channel", this message was sent to a channel
 // if ChannelType = "im", this is a private message
-// if ChannelType = "mim", A message was posted in a multiparty direct message channel
-// TODO: Improve this so that it is not required to manually parse ChannelType
+// if ChannelType = "mpim", A message was posted in a multiparty direct message channel
 type MessageEvent struct {
 	// Basic Message Event - https://api.slack.com/events/message
-	ClientMsgID     string      `json:"client_msg_id"`
-	Type            string      `json:"type"`
-	User            string      `json:"user"`
-	Text            string      `json:"text"`
-	ThreadTimeStamp string      `json:"thread_ts"`
-	TimeStamp       string      `json:"ts"`
-	Channel         string      `json:"channel"`
-	ChannelType     string      `json:"channel_type"`
-	EventTimeStamp  json.Number `json:"event_ts"`
+	ClientMsgID     string       `json:"client_msg_id"`
+	Type            string       `json:"type"`
+	User            string       `json:"user"`
+	Text            string       `json:"text"`
+	Blocks          slack.Blocks `json:"blocks,omitempty"`
+	ThreadTimeStamp string       `json:"thread_ts"`
+	TimeStamp       string       `json:"ts"`
+	Channel         string       `json:"channel"`
+	ChannelType     string       `json:"channel_type"`
+	EventTimeStamp  string       `json:"event_ts"`
 
 	// When Message comes from a channel that is shared between workspaces
 	UserTeam   string `json:"user_team,omitempty"`
 	SourceTeam string `json:"source_team,omitempty"`
 
+	// When we get a 'message' event with no subtype, i.e. telling us about a new
+	// message, the message information is stored at the top level. But when we get
+	// a 'message_changed' event, the message information is stored in
+	// the Message property. This is really hard to represent nicely in Go, so we use
+	// a custom JSON unmarshaller to populate the Message field in both cases.
+	Message *slack.Msg `json:"message,omitempty"`
+	// Root is set if the SubType is `thread_broadcast`.
+	Root *slack.Msg `json:"root,omitempty"`
 	// Edited Message
-	Message         *MessageEvent `json:"message,omitempty"`
-	PreviousMessage *MessageEvent `json:"previous_message,omitempty"`
-	Edited          *Edited       `json:"edited,omitempty"`
+	PreviousMessage *slack.Msg `json:"previous_message,omitempty"`
+
+	// Deleted Message
+	DeletedTimeStamp string `json:"deleted_ts,omitempty"`
 
 	// Message Subtypes
 	SubType string `json:"subtype,omitempty"`
 
 	// bot_message (https://api.slack.com/events/message/bot_message)
-	BotID    string `json:"bot_id,omitempty"`
-	Username string `json:"username,omitempty"`
-	Icons    *Icon  `json:"icons,omitempty"`
-
-	Upload bool   `json:"upload"`
-	Files  []File `json:"files"`
-
-	Attachments []slack.Attachment `json:"attachments,omitempty"`
+	BotID      string `json:"bot_id,omitempty"`
+	Username   string `json:"username,omitempty"`
+	Icons      *Icon  `json:"icons,omitempty"`
+	WorkflowID string `json:"workflow_id,omitempty"`
+
+	// AssistantThread contains action token for Data Access API queries in message events
+	AssistantThread *AssistantThreadActionToken `json:"assistant_thread,omitempty"`
+	// ActionToken contains the top-level action token for Data Access API queries.
+	ActionToken string `json:"action_token,omitempty"`
+
+	// Huddle-related fields (subtype "huddle_thread")
+	Room            *slack.HuddleRoom `json:"room,omitempty"`
+	NoNotifications bool              `json:"no_notifications,omitempty"`
+	Permalink       string            `json:"permalink,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.
-	Root *MessageEvent `json:"root"`
+func (e *MessageEvent) IsIM() bool      { return e.ChannelType == ChannelTypeIM }
+func (e *MessageEvent) IsChannel() bool { return e.ChannelType == ChannelTypeChannel }
+func (e *MessageEvent) IsGroup() bool   { return e.ChannelType == ChannelTypeGroup }
+func (e *MessageEvent) IsMpIM() bool    { return e.ChannelType == ChannelTypeMPIM }
+
+// UnmarshalJSON implements the json.Unmarshaler interface for MessageEvent.
+// This custom unmarshaler handles both regular messages and message_changed events
+// by normalizing the message data into the Message field.
+func (e *MessageEvent) UnmarshalJSON(data []byte) error {
+	// First, unmarshal into an anonymous struct to avoid infinite recursion
+	// when calling json.Unmarshal on the MessageEvent type itself
+	type MessageEventAlias MessageEvent
+	alias := struct {
+		MessageEventAlias
+	}{}
+
+	if err := json.Unmarshal(data, &alias.MessageEventAlias); err != nil {
+		return err
+	}
+
+	// Copy all fields from alias to the original struct
+	*e = MessageEvent(alias.MessageEventAlias)
+
+	// Now check if there's no Message field (which would happen for regular messages)
+	if e.Message == nil {
+		// For regular messages, the message content is at the top level,
+		// so we need to unmarshal the data again into a slack.Msg
+		msg := &slack.Msg{}
+		if err := json.Unmarshal(data, msg); err != nil {
+			return err
+		}
+
+		// Set the Message field to the unmarshaled msg
+		e.Message = msg
+	}
+
+	return nil
 }
 
 // MemberJoinedChannelEvent A member joined a public or private channel
 type MemberJoinedChannelEvent struct {
-	Type        string `json:"type"`
-	User        string `json:"user"`
-	Channel     string `json:"channel"`
-	ChannelType string `json:"channel_type"`
-	Team        string `json:"team"`
-	Inviter     string `json:"inviter"`
+	Type           string `json:"type"`
+	User           string `json:"user"`
+	Channel        string `json:"channel"`
+	ChannelType    string `json:"channel_type"`
+	Team           string `json:"team"`
+	Inviter        string `json:"inviter"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // MemberLeftChannelEvent A member left a public or private channel
 type MemberLeftChannelEvent struct {
-	Type        string `json:"type"`
-	User        string `json:"user"`
-	Channel     string `json:"channel"`
-	ChannelType string `json:"channel_type"`
-	Team        string `json:"team"`
+	Type           string `json:"type"`
+	User           string `json:"user"`
+	Channel        string `json:"channel"`
+	ChannelType    string `json:"channel_type"`
+	Team           string `json:"team"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 type pinEvent struct {
@@ -281,21 +448,23 @@ type tokens struct {
 
 // TeamJoinEvent A new member joined a workspace -  https://api.slack.com/events/team_join
 type TeamJoinEvent struct {
-	Type string      `json:"type"`
-	User *slack.User `json:"user"`
+	Type           string      `json:"type"`
+	User           *slack.User `json:"user"`
+	EventTimestamp string      `json:"event_ts"`
 }
 
-// TokensRevokedEvent APP's API tokes are revoked - https://api.slack.com/events/tokens_revoked
+// TokensRevokedEvent APP's API tokens are revoked - https://api.slack.com/events/tokens_revoked
 type TokensRevokedEvent struct {
-	Type   string `json:"type"`
-	Tokens tokens `json:"tokens"`
+	Type           string `json:"type"`
+	Tokens         tokens `json:"tokens"`
+	EventTimestamp string `json:"event_ts"`
 }
 
 // EmojiChangedEvent is the event of custom emoji has been added or changed
 type EmojiChangedEvent struct {
-	Type           string      `json:"type"`
-	Subtype        string      `json:"subtype"`
-	EventTimeStamp json.Number `json:"event_ts"`
+	Type           string `json:"type"`
+	Subtype        string `json:"subtype"`
+	EventTimeStamp string `json:"event_ts"`
 
 	// filled out when custom emoji added
 	Name string `json:"name,omitempty"`
@@ -311,21 +480,45 @@ type EmojiChangedEvent struct {
 	Value string `json:"value,omitempty"`
 }
 
-// WorkflowStepExecuteEvent is fired, if a workflow step of your app is invoked
-type WorkflowStepExecuteEvent struct {
-	Type         string            `json:"type"`
-	CallbackID   string            `json:"callback_id"`
-	WorkflowStep EventWorkflowStep `json:"workflow_step"`
-	EventTS      string            `json:"event_ts"`
+// MessageMetadataPostedEvent is sent, if a message with metadata is posted
+type MessageMetadataPostedEvent struct {
+	Type             string               `json:"type"`
+	AppId            string               `json:"app_id"`
+	BotId            string               `json:"bot_id"`
+	UserId           string               `json:"user_id"`
+	TeamId           string               `json:"team_id"`
+	ChannelId        string               `json:"channel_id"`
+	Metadata         *slack.SlackMetadata `json:"metadata"`
+	MessageTimestamp string               `json:"message_ts"`
+	EventTimestamp   string               `json:"event_ts"`
 }
 
-type EventWorkflowStep struct {
-	WorkflowStepExecuteID string                      `json:"workflow_step_execute_id"`
-	WorkflowID            string                      `json:"workflow_id"`
-	WorkflowInstanceID    string                      `json:"workflow_instance_id"`
-	StepID                string                      `json:"step_id"`
-	Inputs                *slack.WorkflowStepInputs   `json:"inputs,omitempty"`
-	Outputs               *[]slack.WorkflowStepOutput `json:"outputs,omitempty"`
+// MessageMetadataUpdatedEvent is sent, if a message with metadata is deleted
+type MessageMetadataUpdatedEvent struct {
+	Type             string               `json:"type"`
+	ChannelId        string               `json:"channel_id"`
+	EventTimestamp   string               `json:"event_ts"`
+	PreviousMetadata *slack.SlackMetadata `json:"previous_metadata"`
+	AppId            string               `json:"app_id"`
+	BotId            string               `json:"bot_id"`
+	UserId           string               `json:"user_id"`
+	TeamId           string               `json:"team_id"`
+	MessageTimestamp string               `json:"message_ts"`
+	Metadata         *slack.SlackMetadata `json:"metadata"`
+}
+
+// MessageMetadataDeletedEvent is sent, if a message with metadata is deleted
+type MessageMetadataDeletedEvent struct {
+	Type             string               `json:"type"`
+	ChannelId        string               `json:"channel_id"`
+	EventTimestamp   string               `json:"event_ts"`
+	PreviousMetadata *slack.SlackMetadata `json:"previous_metadata"`
+	AppId            string               `json:"app_id"`
+	BotId            string               `json:"bot_id"`
+	UserId           string               `json:"user_id"`
+	TeamId           string               `json:"team_id"`
+	MessageTimestamp string               `json:"message_ts"`
+	DeletedTimestamp string               `json:"deleted_ts"`
 }
 
 // JSONTime exists so that we can have a String method converting the date
@@ -361,6 +554,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"`
@@ -429,98 +623,862 @@ func (e MessageEvent) IsEdited() bool {
 		e.Message.Edited != nil
 }
 
+// TeamAccessGrantedEvent is sent if access to teams was granted for your org-wide app.
+type TeamAccessGrantedEvent struct {
+	Type    string   `json:"type"`
+	TeamIDs []string `json:"team_ids"`
+}
+
+// TeamAccessRevokedEvent is sent if access to teams was revoked for your org-wide app.
+type TeamAccessRevokedEvent struct {
+	Type    string   `json:"type"`
+	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    int      `json:"added_users_count"`
+	RemovedUsers       []string `json:"removed_users"`
+	RemovedUsersCount  int      `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 any `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]any `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]any `json:"fields"`
+	StatusText             string         `json:"status_text"`
+	StatusEmoji            string         `json:"status_emoji"`
+	StatusEmojiDisplayInfo []any          `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"`
+}
+
+// EntityDetailsRequestedEvent is sent when entity details are requested
+// This event is fired when a user clicks on a Work Object link and Slack requests
+// details about the entity from your app.
+type EntityDetailsRequestedEvent struct {
+	Type         string                            `json:"type"`
+	User         string                            `json:"user"`
+	ExternalRef  EntityDetailsRequestedExternalRef `json:"external_ref"`
+	EntityURL    string                            `json:"entity_url"`
+	Link         EntityDetailsRequestedLink        `json:"link"`
+	AppUnfurlURL string                            `json:"app_unfurl_url"`
+	EventTS      string                            `json:"event_ts"`
+	TriggerID    string                            `json:"trigger_id"`
+	UserLocale   string                            `json:"user_locale"`
+	Channel      string                            `json:"channel,omitempty"`
+	MessageTs    string                            `json:"message_ts,omitempty"`
+	ThreadTs     string                            `json:"thread_ts,omitempty"`
+}
+
+// EntityDetailsRequestedExternalRef represents the external reference in entity_details_requested event
+type EntityDetailsRequestedExternalRef struct {
+	ID   string `json:"id"`
+	Type string `json:"type,omitempty"`
+}
+
+// EntityDetailsRequestedLink represents the link information in entity_details_requested event
+type EntityDetailsRequestedLink struct {
+	URL    string `json:"url"`
+	Domain string `json:"domain"`
+}
+
+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 (
-	// AppMention is an Events API subscribable event
-	AppMention = "app_mention"
+	// AppDeleted is an event when an app is deleted from a workspace
+	AppDeleted = EventsAPIType("app_deleted")
 	// AppHomeOpened Your Slack app home was opened
-	AppHomeOpened = "app_home_opened"
+	AppHomeOpened = EventsAPIType("app_home_opened")
+	// AppInstalled is an event when an app is installed to a workspace
+	AppInstalled = EventsAPIType("app_installed")
+	// AppMention is an Events API subscribable event
+	AppMention = EventsAPIType("app_mention")
+	// AppRequested is an event when a user requests to install an app to a workspace
+	AppRequested = EventsAPIType("app_requested")
 	// AppUninstalled Your Slack app was uninstalled.
-	AppUninstalled = "app_uninstalled"
+	AppUninstalled = EventsAPIType("app_uninstalled")
+	// AppUninstalledTeam is an event when an app is uninstalled from a team
+	AppUninstalledTeam = EventsAPIType("app_uninstalled_team")
+	// AssistantThreadContextChanged Your Slack AI Assistant has changed the context of a thread
+	AssistantThreadContextChanged = EventsAPIType("assistant_thread_context_changed")
+	// AssistantThreadStarted Your Slack AI Assistant has started a new thread
+	AssistantThreadStarted = EventsAPIType("assistant_thread_started")
+	// CallRejected is an event when a Slack call is rejected
+	CallRejected = EventsAPIType("call_rejected")
+	// ChannelArchive is sent when a channel is archived.
+	ChannelArchive = EventsAPIType("channel_archive")
 	// ChannelCreated is sent when a new channel is created.
-	ChannelCreated = "channel_created"
+	ChannelCreated = EventsAPIType("channel_created")
 	// ChannelDeleted is sent when a channel is deleted.
-	ChannelDeleted = "channel_deleted"
-	// ChannelArchive is sent when a channel is archived.
-	ChannelArchive = "channel_archive"
-	// ChannelUnarchive is sent when a channel is unarchived.
-	ChannelUnarchive = "channel_unarchive"
+	ChannelDeleted = EventsAPIType("channel_deleted")
+	// ChannelHistoryChanged The history of a channel changed
+	ChannelHistoryChanged = EventsAPIType("channel_history_changed")
+	// ChannelIDChanged is sent when a channel identifier is changed.
+	ChannelIDChanged = EventsAPIType("channel_id_changed")
 	// ChannelLeft is sent when a channel is left.
-	ChannelLeft = "channel_left"
+	ChannelLeft = EventsAPIType("channel_left")
 	// ChannelRename is sent when a channel is rename.
-	ChannelRename = "channel_rename"
-	// ChannelIDChanged is sent when a channel identifier is changed.
-	ChannelIDChanged = "channel_id_changed"
-	// GroupDeleted is sent when a group is deleted.
-	GroupDeleted = "group_deleted"
+	ChannelRename = EventsAPIType("channel_rename")
+	// ChannelShared is an event when a channel is shared with another workspace
+	ChannelShared = EventsAPIType("channel_shared")
+	// ChannelUnarchive is sent when a channel is unarchived.
+	ChannelUnarchive = EventsAPIType("channel_unarchive")
+	// ChannelUnshared is sent when a channel is unshared.
+	ChannelUnshared = EventsAPIType("channel_unshared")
+	// 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")
+	// EmojiChanged A custom emoji has been added or changed
+	EmojiChanged = EventsAPIType("emoji_changed")
+	// FileChange is sent when a file is changed.
+	FileChange = EventsAPIType("file_change")
+	// FileCreated is an event when a file is created in a workspace
+	FileCreated = EventsAPIType("file_created")
+	// FileDeleted is sent when a file is deleted.
+	FileDeleted = EventsAPIType("file_deleted")
+	// FilePublic is an event when a file is made public in a workspace
+	FilePublic = EventsAPIType("file_public")
+	// FileShared is sent when a file is shared.
+	FileShared = EventsAPIType("file_shared")
+	// FileUnshared is sent when a file is unshared.
+	FileUnshared = EventsAPIType("file_unshared")
+	// FunctionExecuted is an event when a Slack function is executed
+	FunctionExecuted = EventsAPIType("function_executed")
+	// GridMigrationFinished An enterprise grid migration has finished on this workspace.
+	GridMigrationFinished = EventsAPIType("grid_migration_finished")
+	// GridMigrationStarted An enterprise grid migration has started on this workspace.
+	GridMigrationStarted = EventsAPIType("grid_migration_started")
 	// GroupArchive is sent when a group is archived.
-	GroupArchive = "group_archive"
-	// GroupUnarchive is sent when a group is unarchived.
-	GroupUnarchive = "group_unarchive"
+	GroupArchive = EventsAPIType("group_archive")
+	// GroupClose A group was closed
+	GroupClose = EventsAPIType("group_close")
+	// GroupDeleted is sent when a group is deleted.
+	GroupDeleted = EventsAPIType("group_deleted")
+	// GroupHistoryChanged The history of a group changed
+	GroupHistoryChanged = EventsAPIType("group_history_changed")
 	// GroupLeft is sent when a group is left.
-	GroupLeft = "group_left"
+	GroupLeft = EventsAPIType("group_left")
+	// GroupOpen A group was opened
+	GroupOpen = EventsAPIType("group_open")
 	// GroupRename is sent when a group is renamed.
-	GroupRename = "group_rename"
-	// GridMigrationFinished An enterprise grid migration has finished on this workspace.
-	GridMigrationFinished = "grid_migration_finished"
-	// GridMigrationStarted An enterprise grid migration has started on this workspace.
-	GridMigrationStarted = "grid_migration_started"
+	GroupRename = EventsAPIType("group_rename")
+	// GroupUnarchive is sent when a group is unarchived.
+	GroupUnarchive = EventsAPIType("group_unarchive")
+	// 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")
+	// InviteRequested is an event when a user requests an invite to a workspace
+	InviteRequested = EventsAPIType("invite_requested")
 	// LinkShared A message was posted containing one or more links relevant to your application
-	LinkShared = "link_shared"
+	LinkShared = EventsAPIType("link_shared")
+	// MemberJoinedChannel is sent if a member joined a channel.
+	MemberJoinedChannel = EventsAPIType("member_joined_channel")
+	// MemberLeftChannel is sent if a member left a channel.
+	MemberLeftChannel = EventsAPIType("member_left_channel")
 	// Message A message was posted to a channel, private channel (group), im, or mim
-	Message = "message"
-	// Member Joined Channel
-	MemberJoinedChannel = "member_joined_channel"
-	// Member Left Channel
-	MemberLeftChannel = "member_left_channel"
+	Message = EventsAPIType("message")
+	// MessageMetadataDeleted A message with metadata was deleted
+	MessageMetadataDeleted = EventsAPIType("message_metadata_deleted")
+	// MessageMetadataPosted A message with metadata was posted
+	MessageMetadataPosted = EventsAPIType("message_metadata_posted")
+	// MessageMetadataUpdated A message with metadata was updated
+	MessageMetadataUpdated = EventsAPIType("message_metadata_updated")
 	// PinAdded An item was pinned to a channel
-	PinAdded = "pin_added"
+	PinAdded = EventsAPIType("pin_added")
 	// PinRemoved An item was unpinned from a channel
-	PinRemoved = "pin_removed"
+	PinRemoved = EventsAPIType("pin_removed")
 	// ReactionAdded An reaction was added to a message
-	ReactionAdded = "reaction_added"
+	ReactionAdded = EventsAPIType("reaction_added")
 	// ReactionRemoved An reaction was removed from a message
-	ReactionRemoved = "reaction_removed"
+	ReactionRemoved = EventsAPIType("reaction_removed")
+	// SharedChannelInviteAccepted Slack connect channel invite accepted by an end user
+	SharedChannelInviteAccepted = EventsAPIType("shared_channel_invite_accepted")
+	// SharedChannelInviteApproved Slack connect channel invite approved
+	SharedChannelInviteApproved = EventsAPIType("shared_channel_invite_approved")
+	// SharedChannelInviteDeclined Slack connect channel invite declined
+	SharedChannelInviteDeclined = EventsAPIType("shared_channel_invite_declined")
+	// SharedChannelInviteReceived Slack connect app or bot invite received
+	SharedChannelInviteReceived = EventsAPIType("shared_channel_invite_received")
+	// 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")
+	// 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")
+	// 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")
+	// TeamDomainChange The team's domain changed
+	TeamDomainChange = EventsAPIType("team_domain_change")
 	// TeamJoin A new user joined the workspace
-	TeamJoin = "team_join"
+	TeamJoin = EventsAPIType("team_join")
+	// TeamRename The team was renamed
+	TeamRename = EventsAPIType("team_rename")
 	// TokensRevoked APP's API tokes are revoked
-	TokensRevoked = "tokens_revoked"
-	// EmojiChanged A custom emoji has been added or changed
-	EmojiChanged = "emoji_changed"
+	TokensRevoked = EventsAPIType("tokens_revoked")
+	// UserChange A user object has changed
+	UserChange = EventsAPIType("user_change")
+	// UserHuddleChanged is an event when a user's huddle status changes
+	UserHuddleChanged = EventsAPIType("user_huddle_changed")
+	// UserProfileChanged is sent if a user's profile information has changed.
+	UserProfileChanged = EventsAPIType("user_profile_changed")
+	// UserStatusChanged is an event when a user's status changes
+	UserStatusChanged = EventsAPIType("user_status_changed")
 	// WorkflowStepExecute Happens, if a workflow step of your app is invoked
-	WorkflowStepExecute = "workflow_step_execute"
+	WorkflowStepExecute = EventsAPIType("workflow_step_execute")
+	// EntityDetailsRequested is sent when entity details are requested
+	EntityDetailsRequested = EventsAPIType("entity_details_requested")
 )
 
 // 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[string]interface{}{
-	AppMention:            AppMentionEvent{},
-	AppHomeOpened:         AppHomeOpenedEvent{},
-	AppUninstalled:        AppUninstalledEvent{},
-	ChannelCreated:        ChannelCreatedEvent{},
-	ChannelDeleted:        ChannelDeletedEvent{},
-	ChannelArchive:        ChannelArchiveEvent{},
-	ChannelUnarchive:      ChannelUnarchiveEvent{},
-	ChannelLeft:           ChannelLeftEvent{},
-	ChannelRename:         ChannelRenameEvent{},
-	ChannelIDChanged:      ChannelIDChangedEvent{},
-	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{},
-	WorkflowStepExecute:   WorkflowStepExecuteEvent{},
+var EventsAPIInnerEventMapping = map[EventsAPIType]any{
+	AppDeleted:                    AppDeletedEvent{},
+	AppHomeOpened:                 AppHomeOpenedEvent{},
+	AppInstalled:                  AppInstalledEvent{},
+	AppMention:                    AppMentionEvent{},
+	AppRequested:                  AppRequestedEvent{},
+	AppUninstalled:                AppUninstalledEvent{},
+	AppUninstalledTeam:            AppUninstalledTeamEvent{},
+	AssistantThreadContextChanged: AssistantThreadContextChangedEvent{},
+	AssistantThreadStarted:        AssistantThreadStartedEvent{},
+	CallRejected:                  CallRejectedEvent{},
+	ChannelArchive:                ChannelArchiveEvent{},
+	ChannelCreated:                ChannelCreatedEvent{},
+	ChannelDeleted:                ChannelDeletedEvent{},
+	ChannelHistoryChanged:         ChannelHistoryChangedEvent{},
+	ChannelIDChanged:              ChannelIDChangedEvent{},
+	ChannelLeft:                   ChannelLeftEvent{},
+	ChannelRename:                 ChannelRenameEvent{},
+	ChannelShared:                 ChannelSharedEvent{},
+	ChannelUnarchive:              ChannelUnarchiveEvent{},
+	ChannelUnshared:               ChannelUnsharedEvent{},
+	CommandsChanged:               CommandsChangedEvent{},
+	DndUpdated:                    DndUpdatedEvent{},
+	DndUpdatedUser:                DndUpdatedUserEvent{},
+	EmailDomainChanged:            EmailDomainChangedEvent{},
+	EmojiChanged:                  EmojiChangedEvent{},
+	FileChange:                    FileChangeEvent{},
+	FileCreated:                   FileCreatedEvent{},
+	FileDeleted:                   FileDeletedEvent{},
+	FilePublic:                    FilePublicEvent{},
+	FileShared:                    FileSharedEvent{},
+	FileUnshared:                  FileUnsharedEvent{},
+	FunctionExecuted:              FunctionExecutedEvent{},
+	GridMigrationFinished:         GridMigrationFinishedEvent{},
+	GridMigrationStarted:          GridMigrationStartedEvent{},
+	GroupArchive:                  GroupArchiveEvent{},
+	GroupClose:                    GroupCloseEvent{},
+	GroupDeleted:                  GroupDeletedEvent{},
+	GroupHistoryChanged:           GroupHistoryChangedEvent{},
+	GroupLeft:                     GroupLeftEvent{},
+	GroupOpen:                     GroupOpenEvent{},
+	GroupRename:                   GroupRenameEvent{},
+	GroupUnarchive:                GroupUnarchiveEvent{},
+	ImClose:                       ImCloseEvent{},
+	ImCreated:                     ImCreatedEvent{},
+	ImHistoryChanged:              ImHistoryChangedEvent{},
+	ImOpen:                        ImOpenEvent{},
+	InviteRequested:               InviteRequestedEvent{},
+	LinkShared:                    LinkSharedEvent{},
+	MemberJoinedChannel:           MemberJoinedChannelEvent{},
+	MemberLeftChannel:             MemberLeftChannelEvent{},
+	Message:                       MessageEvent{},
+	MessageMetadataDeleted:        MessageMetadataDeletedEvent{},
+	MessageMetadataPosted:         MessageMetadataPostedEvent{},
+	MessageMetadataUpdated:        MessageMetadataUpdatedEvent{},
+	PinAdded:                      PinAddedEvent{},
+	PinRemoved:                    PinRemovedEvent{},
+	ReactionAdded:                 ReactionAddedEvent{},
+	ReactionRemoved:               ReactionRemovedEvent{},
+	SharedChannelInviteAccepted:   SharedChannelInviteAcceptedEvent{},
+	SharedChannelInviteApproved:   SharedChannelInviteApprovedEvent{},
+	SharedChannelInviteDeclined:   SharedChannelInviteDeclinedEvent{},
+	SharedChannelInviteReceived:   SharedChannelInviteReceivedEvent{},
+	SharedChannelInviteRequested:  SharedChannelInviteRequestedEvent{},
+	StarAdded:                     StarAddedEvent{},
+	StarRemoved:                   StarRemovedEvent{},
+	SubteamCreated:                SubteamCreatedEvent{},
+	SubteamMembersChanged:         SubteamMembersChangedEvent{},
+	SubteamSelfAdded:              SubteamSelfAddedEvent{},
+	SubteamSelfRemoved:            SubteamSelfRemovedEvent{},
+	SubteamUpdated:                SubteamUpdatedEvent{},
+	TeamAccessGranted:             TeamAccessGrantedEvent{},
+	TeamAccessRevoked:             TeamAccessRevokedEvent{},
+	TeamDomainChange:              TeamDomainChangeEvent{},
+	TeamJoin:                      TeamJoinEvent{},
+	TeamRename:                    TeamRenameEvent{},
+	TokensRevoked:                 TokensRevokedEvent{},
+	UserChange:                    UserChangeEvent{},
+	UserHuddleChanged:             UserHuddleChangedEvent{},
+	UserProfileChanged:            UserProfileChangedEvent{},
+	UserStatusChanged:             UserStatusChangedEvent{},
+	EntityDetailsRequested:        EntityDetailsRequestedEvent{},
 }
diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go
index 2a0ac56e8..be9ac3405 100644
--- a/slackevents/inner_events_test.go
+++ b/slackevents/inner_events_test.go
@@ -2,9 +2,66 @@ package slackevents
 
 import (
 	"encoding/json"
+	"fmt"
 	"testing"
+
+	"github.com/slack-go/slack"
+	"github.com/stretchr/testify/assert"
 )
 
+func TestAssistantThreadStartedEvent(t *testing.T) {
+
+	rawE := []byte(`
+		{
+			"type": "assistant_thread_started",
+			"assistant_thread": {
+				"user_id": "U123ABC456",
+				"context": {
+					"channel_id": "C123ABC456",
+					"team_id": "T07XY8FPJ5C",
+					"enterprise_id": "E480293PS82"
+					},
+				"channel_id": "D123ABC456",
+				"thread_ts": "1729999327.187299"
+
+			},
+			"event_ts": "1715873754.429808"
+		}
+	`)
+
+	err := json.Unmarshal(rawE, &AssistantThreadStartedEvent{})
+	if err != nil {
+		t.Error(err)
+	}
+
+}
+
+func TestAssistantThreadContextChangedEvent(t *testing.T) {
+
+	rawE := []byte(`
+		{
+			"type": "assistant_thread_context_changed",
+			"assistant_thread": {
+				"user_id": "U123ABC456",
+				"context": {
+					"channel_id": "C123ABC456",
+					"team_id": "T07XY8FPJ5C",
+					"enterprise_id": "E480293PS82"
+					},
+				"channel_id": "D123ABC456",
+				"thread_ts": "1729999327.187299"
+			},
+			"event_ts": "17298244.022142"
+		}
+	`)
+
+	err := json.Unmarshal(rawE, &AssistantThreadContextChangedEvent{})
+	if err != nil {
+		t.Error(err)
+	}
+
+}
+
 func TestAppMention(t *testing.T) {
 	rawE := []byte(`
 			{
@@ -26,6 +83,123 @@ func TestAppMention(t *testing.T) {
 	}
 }
 
+func TestAppMentionWithAssistantThread(t *testing.T) {
+	rawE := []byte(`
+			{
+				"type": "app_mention",
+				"user": "U061F7AUR",
+				"text": "<@U0LAN0Z89> is it everything a river should be?",
+				"ts": "1515449522.000016",
+				"thread_ts": "1515449522.000016",
+				"channel": "C0LAN2Q65",
+				"event_ts": "1515449522000016",
+				"source_team": "T3MQV36V7",
+				"user_team": "T3MQV36V7",
+				"assistant_thread": {
+					"action_token": "1234567.abcdefg"
+				}
+		}
+	`)
+	var event AppMentionEvent
+	err := json.Unmarshal(rawE, &event)
+	if err != nil {
+		t.Error(err)
+	}
+
+	if event.AssistantThread == nil {
+		t.Error("Expected AssistantThread to be non-nil")
+	}
+
+	if event.AssistantThread.ActionToken != "1234567.abcdefg" {
+		t.Errorf("Expected ActionToken to be '1234567.abcdefg', got %s", event.AssistantThread.ActionToken)
+	}
+}
+
+func TestAppMentionWithActionToken(t *testing.T) {
+	rawE := []byte(`
+			{
+				"type": "app_mention",
+				"user": "U061F7AUR",
+				"text": "<@U0LAN0Z89> search slack",
+				"ts": "1515449522.000016",
+				"channel": "C0LAN2Q65",
+				"event_ts": "1515449522000016",
+				"action_token": "1234567.top-level"
+			}
+	`)
+	var event AppMentionEvent
+	if err := json.Unmarshal(rawE, &event); err != nil {
+		t.Fatal(err)
+	}
+
+	if event.ActionToken != "1234567.top-level" {
+		t.Errorf("Expected ActionToken to be '1234567.top-level', got %s", event.ActionToken)
+	}
+}
+
+func TestAppMentionWithBlocksFilesAttachments(t *testing.T) {
+	rawE := []byte(`{
+		"type": "app_mention",
+		"user": "U061F7AUR",
+		"text": "<@U0LAN0Z89> check this file",
+		"ts": "1628259917.003000",
+		"thread_ts": "1628259917.003000",
+		"channel": "C0LAN2Q65",
+		"event_ts": "1628259917.003000",
+		"bot_id": "B12345",
+		"blocks": [
+			{
+				"type": "section",
+				"block_id": "HNku",
+				"text": {
+					"type": "mrkdwn",
+					"text": "<@U0LAN0Z89> check this file"
+				}
+			}
+		],
+		"files": [
+			{
+				"id": "F12345",
+				"name": "test.png",
+				"mimetype": "image/png",
+				"filetype": "png"
+			}
+		],
+		"upload": true,
+		"attachments": [
+			{
+				"color": "29AF7B",
+				"fallback": "[no preview available]",
+				"id": 1,
+				"text": "attachment text"
+			}
+		]
+	}`)
+	var event AppMentionEvent
+	if err := json.Unmarshal(rawE, &event); err != nil {
+		t.Fatal(err)
+	}
+
+	if len(event.Blocks.BlockSet) != 1 {
+		t.Errorf("Expected 1 block, got %d", len(event.Blocks.BlockSet))
+	}
+	if len(event.Files) != 1 {
+		t.Errorf("Expected 1 file, got %d", len(event.Files))
+	}
+	if event.Files[0].ID != "F12345" {
+		t.Errorf("Expected file ID 'F12345', got %s", event.Files[0].ID)
+	}
+	if !event.Upload {
+		t.Error("Expected Upload to be true")
+	}
+	if len(event.Attachments) != 1 {
+		t.Errorf("Expected 1 attachment, got %d", len(event.Attachments))
+	}
+	if event.Attachments[0].Text != "attachment text" {
+		t.Errorf("Expected attachment text 'attachment text', got %s", event.Attachments[0].Text)
+	}
+}
+
 func TestAppUninstalled(t *testing.T) {
 	rawE := []byte(`
 		{
@@ -38,6 +212,120 @@ func TestAppUninstalled(t *testing.T) {
 	}
 }
 
+func TestFileChangeEvent(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "file_change",
+			"file_id": "F1234567890",
+			"file": {
+				"id": "F1234567890"
+			}
+		}
+	`)
+
+	var e FileChangeEvent
+	if err := json.Unmarshal(rawE, &e); err != nil {
+		t.Fatal(err)
+	}
+	if e.Type != "file_change" {
+		t.Errorf("type should be file_change, was %s", e.Type)
+	}
+	if e.FileID != "F1234567890" {
+		t.Errorf("file ID should be F1234567890, was %s", e.FileID)
+	}
+	if e.File.ID != "F1234567890" {
+		t.Errorf("file.id should be F1234567890, was %s", e.File.ID)
+	}
+}
+
+func TestFileDeletedEvent(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "file_deleted",
+			"file_id": "F1234567890",
+			"event_ts": "1234567890.123456"
+		}
+	`)
+
+	var e FileDeletedEvent
+	if err := json.Unmarshal(rawE, &e); err != nil {
+		t.Fatal(err)
+	}
+	if e.Type != "file_deleted" {
+		t.Errorf("type should be file_deleted, was %s", e.Type)
+	}
+	if e.FileID != "F1234567890" {
+		t.Errorf("file ID should be F1234567890, was %s", e.FileID)
+	}
+	if e.EventTimestamp != "1234567890.123456" {
+		t.Errorf("event timestamp should be 1234567890.123456, was %s", e.EventTimestamp)
+	}
+}
+
+func TestFileSharedEvent(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "file_shared",
+			"channel_id": "C1234567890",
+			"file_id": "F1234567890",
+			"user_id": "U11235813",
+			"file": {
+				"id": "F1234567890"
+			},
+			"event_ts": "1234567890.123456"
+		}
+	`)
+
+	var e FileSharedEvent
+	if err := json.Unmarshal(rawE, &e); err != nil {
+		t.Fatal(err)
+	}
+	if e.Type != "file_shared" {
+		t.Errorf("type should be file_shared, was %s", e.Type)
+	}
+	if e.ChannelID != "C1234567890" {
+		t.Errorf("channel ID should be C1234567890, was %s", e.ChannelID)
+	}
+	if e.FileID != "F1234567890" {
+		t.Errorf("file ID should be F1234567890, was %s", e.FileID)
+	}
+	if e.UserID != "U11235813" {
+		t.Errorf("user ID should be U11235813, was %s", e.UserID)
+	}
+	if e.File.ID != "F1234567890" {
+		t.Errorf("file.id should be F1234567890, was %s", e.File.ID)
+	}
+	if e.EventTimestamp != "1234567890.123456" {
+		t.Errorf("event timestamp should be 1234567890.123456, was %s", e.EventTimestamp)
+	}
+}
+
+func TestFileUnsharedEvent(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "file_unshared",
+			"file_id": "F1234567890",
+			"file": {
+				"id": "F1234567890"
+			}
+		}
+	`)
+
+	var e FileUnsharedEvent
+	if err := json.Unmarshal(rawE, &e); err != nil {
+		t.Fatal(err)
+	}
+	if e.Type != "file_unshared" {
+		t.Errorf("type should be file_shared, was %s", e.Type)
+	}
+	if e.FileID != "F1234567890" {
+		t.Errorf("file ID should be F1234567890, was %s", e.FileID)
+	}
+	if e.File.ID != "F1234567890" {
+		t.Errorf("file.id should be F1234567890, was %s", e.File.ID)
+	}
+}
+
 func TestGridMigrationFinishedEvent(t *testing.T) {
 	rawE := []byte(`
 			{
@@ -103,6 +391,34 @@ func TestLinkSharedEvent(t *testing.T) {
 	}
 }
 
+func TestLinkSharedEvent_struct(t *testing.T) {
+	e := LinkSharedEvent{
+		Type:             "link_shared",
+		User:             "Uxxxxxxx",
+		TimeStamp:        "123456789.9876",
+		Channel:          "Cxxxxxx",
+		MessageTimeStamp: "123456789.9875",
+		ThreadTimeStamp:  "123456789.9876",
+		Links: []SharedLinks{
+			{Domain: "example.com", URL: "https://example.com/12345"},
+			{Domain: "example.com", URL: "https://example.com/67890"},
+			{Domain: "another-example.com", URL: "https://yet.another-example.com/v/abcde"},
+		},
+		EventTimestamp: "123456789.9876",
+	}
+	rawE, err := json.Marshal(e)
+	if err != nil {
+		t.Error(err)
+	}
+	expected := `{"type":"link_shared","user":"Uxxxxxxx","ts":"123456789.9876","channel":"Cxxxxxx",` +
+		`"message_ts":"123456789.9875","thread_ts":"123456789.9876","links":[{"domain":"example.com",` +
+		`"url":"https://example.com/12345"},{"domain":"example.com","url":"https://example.com/67890"},` +
+		`{"domain":"another-example.com","url":"https://yet.another-example.com/v/abcde"}],"event_ts":"123456789.9876"}`
+	if string(rawE) != expected {
+		t.Errorf("expected %s, but got %s", expected, string(rawE))
+	}
+}
+
 func TestLinkSharedComposerEvent(t *testing.T) {
 	rawE := []byte(`
 			{
@@ -148,22 +464,138 @@ func TestMessageEvent(t *testing.T) {
 				"channel_type": "channel",
 				"source_team": "T3MQV36V7",
 				"user_team": "T3MQV36V7",
-				"message": {
-					"text": "To infinity and beyond.",
-					"edited": {
-						"user": "U2147483697",
-						"ts": "1355517524.000000"
+				"metadata": {
+					"event_type": "example",
+					"event_payload": {
+						"key": "value"
 					}
-				},
-				"previous_message": {
-					"text": "Live long and prospect."
 				}
 		}
 	`)
-	err := json.Unmarshal(rawE, &MessageEvent{})
+	var e MessageEvent
+	err := json.Unmarshal(rawE, &e)
+	if err != nil {
+		t.Error(err)
+	}
+
+	if e.Channel != "G024BE91L" {
+		t.Error(fmt.Errorf("expected channel G024BE91L, got %s", e.Channel))
+	}
+	if e.User != "U2147483697" {
+		t.Error(fmt.Errorf("expected user U2147483697, got %s", e.User))
+	}
+	if e.Text != "Live long and prospect." {
+		t.Error(fmt.Errorf("expected e.Text Live long and prospect., got %s", e.Text))
+	}
+	if e.Message.Text != "Live long and prospect." {
+		t.Error(fmt.Errorf("expected e.Message.Text Live long and prospect., got %s", e.Message.Text))
+	}
+	if !e.IsChannel() {
+		t.Error(fmt.Errorf("expected IsChannelMessage true, got false"))
+	}
+}
+
+func TestMessageEventWithAssistantThread(t *testing.T) {
+	rawE := []byte(`
+			{
+				"client_msg_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+				"type": "message",
+				"channel": "D024BE91L",
+				"user": "U2147483697",
+				"text": "Hello, I need help with something.",
+				"ts": "1355517523.000005",
+				"event_ts": "1355517523.000005",
+				"channel_type": "im",
+				"assistant_thread": {
+					"action_token": "9876543.hijklmnop"
+				}
+		}
+	`)
+	var e MessageEvent
+	err := json.Unmarshal(rawE, &e)
+	if err != nil {
+		t.Error(err)
+	}
+
+	if e.Channel != "D024BE91L" {
+		t.Error(fmt.Errorf("expected channel D024BE91L, got %s", e.Channel))
+	}
+	if e.User != "U2147483697" {
+		t.Error(fmt.Errorf("expected user U2147483697, got %s", e.User))
+	}
+	if e.Text != "Hello, I need help with something." {
+		t.Error(fmt.Errorf("expected e.Text Hello, I need help with something., got %s", e.Text))
+	}
+	if e.AssistantThread == nil {
+		t.Error("Expected AssistantThread to be non-nil")
+	}
+	if e.AssistantThread.ActionToken != "9876543.hijklmnop" {
+		t.Errorf("Expected ActionToken to be '9876543.hijklmnop', got %s", e.AssistantThread.ActionToken)
+	}
+	if !e.IsIM() {
+		t.Error(fmt.Errorf("expected IsIM true, got false"))
+	}
+}
+
+func TestMessageEventWithActionToken(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "message",
+			"channel": "D024BE91L",
+			"user": "U2147483697",
+			"text": "Search slack",
+			"ts": "1355517523.000005",
+			"event_ts": "1355517523.000005",
+			"channel_type": "im",
+			"action_token": "9876543.top-level"
+		}
+	`)
+	var event MessageEvent
+	if err := json.Unmarshal(rawE, &event); err != nil {
+		t.Fatal(err)
+	}
+
+	if event.ActionToken != "9876543.top-level" {
+		t.Errorf("Expected ActionToken to be '9876543.top-level', got %s", event.ActionToken)
+	}
+}
+
+func TestMessageChangedEvent(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "message",
+			"subtype": "message_changed",
+			"hidden": true,
+			"channel": "G024BE91L",
+			"ts": "1358878755.000001",
+			"message": {
+				"type": "message",
+				"user": "U123ABC456",
+				"text": "Live long and prospect.",
+				"ts": "1355517523.000005",
+				"edited": {
+					"user": "U123ABC456",
+					"ts": "1358878755.000001"
+				}
+			}
+		}
+	`)
+
+	var e MessageEvent
+	err := json.Unmarshal(rawE, &e)
 	if err != nil {
 		t.Error(err)
 	}
+
+	if e.Channel != "G024BE91L" {
+		t.Error(fmt.Errorf("expected channel G024BE91L, got %s", e.Channel))
+	}
+	if e.Message.Text != "Live long and prospect." {
+		t.Error(fmt.Errorf("expected e.Message.Text Live long and prospect., got %s", e.Message.Text))
+	}
+	if e.Message.Edited.User != "U123ABC456" {
+		t.Error(fmt.Errorf("expected e.Message.Edited.User U123ABC456, got %s", e.Message.Edited.User))
+	}
 }
 
 func TestBotMessageEvent(t *testing.T) {
@@ -184,34 +616,121 @@ func TestBotMessageEvent(t *testing.T) {
 	}
 }
 
+func TestMessageEventWithBlocks(t *testing.T) {
+	rawE := []byte(`
+		{
+			"type": "message",
+			"channel": "C024BE91L",
+			"user": "U2147483697",
+			"text": "ERROR",
+			"ts": "1355517523.000005",
+			"event_ts": "1355517523.000005",
+			"channel_type": "channel",
+			"blocks": [
+				{
+					"type": "section",
+					"text": {
+						"type": "mrkdwn",
+						"text": "> Danny Torrence left the following review for your property:"
+					}
+				}
+			]
+		}
+	`)
+	var e MessageEvent
+	err := json.Unmarshal(rawE, &e)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if e.Text != "ERROR" {
+		t.Errorf("expected e.Text ERROR, got %s", e.Text)
+	}
+
+	// Blocks should be directly accessible on MessageEvent
+	if len(e.Blocks.BlockSet) != 1 {
+		t.Fatalf("expected 1 block in e.Blocks, got %d", len(e.Blocks.BlockSet))
+	}
+	if e.Blocks.BlockSet[0].BlockType() != slack.MBTSection {
+		t.Errorf("expected section block, got %s", e.Blocks.BlockSet[0].BlockType())
+	}
+
+	// Blocks should also be accessible via Message (populated by UnmarshalJSON)
+	if e.Message == nil {
+		t.Fatal("expected e.Message to be non-nil")
+	}
+	if len(e.Message.Blocks.BlockSet) != 1 {
+		t.Fatalf("expected 1 block in e.Message.Blocks, got %d", len(e.Message.Blocks.BlockSet))
+	}
+	if e.Message.Blocks.BlockSet[0].BlockType() != slack.MBTSection {
+		t.Errorf("expected section block in e.Message.Blocks, got %s", e.Message.Blocks.BlockSet[0].BlockType())
+	}
+}
+
 func TestThreadBroadcastEvent(t *testing.T) {
 	rawE := []byte(`
 			{
 				"type": "message",
 				"subtype": "thread_broadcast",
-				"channel": "G024BE91L",
-				"user": "U2147483697",
-				"text": "Live long and prospect.",
-				"ts": "1355517523.000005",
-				"event_ts": "1355517523.000005",
-				"channel_type": "channel",
-				"source_team": "T3MQV36V7",
-				"user_team": "T3MQV36V7",
-				"message": {
-					"text": "To infinity and beyond.",
-					"root": {
-						"text": "To infinity and beyond.",
-						"ts": "1355517523.000005"
-					},
-					"edited": {
-						"user": "U2147483697",
-						"ts": "1355517524.000000"
-					}
+				"text": "broadcasting this reply",
+				"user": "U123ABC456",
+				"ts": "1673464745.620769",
+				"thread_ts": "1673464730.703009",
+				"root": {
+					"client_msg_id": "123abc456-...",
+					"type": "message",
+					"text": "This is the original message",
+					"user": "U123ABC456",
+					"ts": "1673464730.703009",
+					"blocks": [
+						{
+							"type": "rich_text",
+							"block_id": "qTg",
+							"elements": [
+								{
+									"type": "rich_text_section",
+									"elements": [
+										{
+											"type": "text",
+											"text": "This is the original message"
+										}
+									]
+								}
+							]
+						}
+					],
+					"team": "T123ABC456",
+					"thread_ts": "1673464730.703009",
+					"reply_count": 1,
+					"reply_users_count": 1,
+					"latest_reply": "1673464745.620769",
+					"reply_users": [
+						"U123ABC456"
+					],
+					"is_locked": false
 				},
-				"previous_message": {
-					"text": "Live long and prospect."
-				}
-		}
+				"blocks": [
+					{
+						"type": "rich_text",
+						"block_id": "BVp",
+						"elements": [
+							{
+								"type": "rich_text_section",
+								"elements": [
+									{
+										"type": "text",
+										"text": "broadcasting this reply"
+									}
+								]
+							}
+						]
+					}
+				],
+				"client_msg_id": "123abc456-...",
+				"channel": "C123ABC456",
+				"event_ts": "1673464745.620769",
+				"channel_type": "channel"
+			}
 	`)
 
 	var me MessageEvent
@@ -219,34 +738,38 @@ func TestThreadBroadcastEvent(t *testing.T) {
 		t.Error(err)
 	}
 
-	if me.Root != nil {
-		t.Error("me.Root should be nil")
-	}
-
-	if me.Message.Root == nil {
-		t.Fatal("me.Message.Root is nil")
-	}
-
-	if me.Message.Root.TimeStamp != "1355517523.000005" {
-		t.Errorf("me.Message.Root.TimeStamp = %q, want %q", me.Root.TimeStamp, "1355517523.000005")
+	if me.Root.Timestamp != "1673464730.703009" {
+		t.Errorf("me.Root.Timestamp = %q, want %q", me.Root.Timestamp, "1673464730.703009")
 	}
 }
 
 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) {
@@ -433,63 +956,2405 @@ func TestEmojiChanged(t *testing.T) {
 	}
 }
 
-func TestWorkflowStepExecute(t *testing.T) {
-	// see: https://api.slack.com/events/workflow_step_execute
+func TestMessageMetadataPosted(t *testing.T) {
 	rawE := []byte(`
 	{
-		"type":"workflow_step_execute",
-		"callback_id":"open_ticket",
-		"workflow_step":{
-			"workflow_step_execute_id":"1036669284371.19077474947.c94bcf942e047298d21f89faf24f1326",
-			"workflow_id":"123456789012345678",
-			"workflow_instance_id":"987654321098765432",
-			"step_id":"12a345bc-1a23-4567-8b90-1234a567b8c9",
-			"inputs":{
-				"example-select-input":{
-					"value": "value-two",
-					"skip_variable_replacement": false
-				}
-			},
-			"outputs":[
-			]
+		"type":"message_metadata_posted",
+		"app_id":"APPXXX",
+		"bot_id":"BOTXXX",
+		"user_id":"USERXXX",
+		"team_id":"TEAMXXX",
+		"channel_id":"CHANNELXXX",
+		"metadata":{
+			"event_type":"type",
+			"event_payload":{"key": "value"}
 		},
-		"event_ts":"1643290847.766536"
+		"message_ts":"1660398079.756349",
+		"event_ts":"1660398079.756349"
 	}
 	`)
 
-	wse := WorkflowStepExecuteEvent{}
-	err := json.Unmarshal(rawE, &wse)
+	mmp := MessageMetadataPostedEvent{}
+	err := json.Unmarshal(rawE, &mmp)
 	if err != nil {
 		t.Error(err)
 	}
 
-	if wse.Type != "workflow_step_execute" {
+	if mmp.Type != "message_metadata_posted" {
 		t.Fail()
 	}
-	if wse.CallbackID != "open_ticket" {
+	if mmp.AppId != "APPXXX" {
 		t.Fail()
 	}
-	if wse.WorkflowStep.WorkflowStepExecuteID != "1036669284371.19077474947.c94bcf942e047298d21f89faf24f1326" {
+	if mmp.BotId != "BOTXXX" {
 		t.Fail()
 	}
-	if wse.WorkflowStep.WorkflowID != "123456789012345678" {
+	if mmp.UserId != "USERXXX" {
 		t.Fail()
 	}
-	if wse.WorkflowStep.WorkflowInstanceID != "987654321098765432" {
+	if mmp.TeamId != "TEAMXXX" {
 		t.Fail()
 	}
-	if wse.WorkflowStep.StepID != "12a345bc-1a23-4567-8b90-1234a567b8c9" {
+	if mmp.ChannelId != "CHANNELXXX" {
 		t.Fail()
 	}
-	if len(*wse.WorkflowStep.Inputs) == 0 {
+	if mmp.Metadata.EventType != "type" {
 		t.Fail()
 	}
-	if inputElement, ok := (*wse.WorkflowStep.Inputs)["example-select-input"]; ok {
-		if inputElement.Value != "value-two" {
-			t.Fail()
-		}
-		if inputElement.SkipVariableReplacement != false {
-			t.Fail()
+	payload := mmp.Metadata.EventPayload
+	if len(payload) == 0 {
+		t.Fail()
+	}
+	if mmp.EventTimestamp != "1660398079.756349" {
+		t.Fail()
+	}
+	if mmp.MessageTimestamp != "1660398079.756349" {
+		t.Fail()
+	}
+}
+
+func TestMessageMetadataUpdated(t *testing.T) {
+	rawE := []byte(`
+	{
+		"type":"message_metadata_updated",
+		"channel_id":"CHANNELXXX",
+		"event_ts":"1660398079.756349",
+		"previous_metadata":{
+			"event_type":"type1",
+			"event_payload":{"key1": "value1"}
+		},
+		"app_id":"APPXXX",
+		"bot_id":"BOTXXX",
+		"user_id":"USERXXX",
+		"team_id":"TEAMXXX",
+		"message_ts":"1660398079.756349",
+		"metadata":{
+			"event_type":"type2",
+			"event_payload":{"key2": "value2"}
+		}
+	}
+	`)
+
+	mmp := MessageMetadataUpdatedEvent{}
+	err := json.Unmarshal(rawE, &mmp)
+	if err != nil {
+		t.Error(err)
+	}
+
+	if mmp.Type != "message_metadata_updated" {
+		t.Fail()
+	}
+	if mmp.ChannelId != "CHANNELXXX" {
+		t.Fail()
+	}
+	if mmp.EventTimestamp != "1660398079.756349" {
+		t.Fail()
+	}
+	if mmp.PreviousMetadata.EventType != "type1" {
+		t.Fail()
+	}
+	payload := mmp.PreviousMetadata.EventPayload
+	if len(payload) == 0 {
+		t.Fail()
+	}
+	if mmp.AppId != "APPXXX" {
+		t.Fail()
+	}
+	if mmp.BotId != "BOTXXX" {
+		t.Fail()
+	}
+	if mmp.UserId != "USERXXX" {
+		t.Fail()
+	}
+	if mmp.TeamId != "TEAMXXX" {
+		t.Fail()
+	}
+	if mmp.MessageTimestamp != "1660398079.756349" {
+		t.Fail()
+	}
+	if mmp.Metadata.EventType != "type2" {
+		t.Fail()
+	}
+	payload = mmp.Metadata.EventPayload
+	if len(payload) == 0 {
+		t.Fail()
+	}
+}
+
+func TestMessageMetadataDeleted(t *testing.T) {
+	rawE := []byte(`
+	{
+		"type":"message_metadata_deleted",
+		"channel_id":"CHANNELXXX",
+		"event_ts":"1660398079.756349",
+		"previous_metadata":{
+			"event_type":"type",
+			"event_payload":{"key": "value"}
+		},
+		"app_id":"APPXXX",
+		"bot_id":"BOTXXX",
+		"user_id":"USERXXX",
+		"team_id":"TEAMXXX",
+		"message_ts":"1660398079.756349",
+		"deleted_ts":"1660398079.756349"
+	}
+	`)
+
+	mmp := MessageMetadataDeletedEvent{}
+	err := json.Unmarshal(rawE, &mmp)
+	if err != nil {
+		t.Error(err)
+	}
+
+	if mmp.Type != "message_metadata_deleted" {
+		t.Fail()
+	}
+	if mmp.ChannelId != "CHANNELXXX" {
+		t.Fail()
+	}
+	if mmp.EventTimestamp != "1660398079.756349" {
+		t.Fail()
+	}
+	if mmp.PreviousMetadata.EventType != "type" {
+		t.Fail()
+	}
+	payload := mmp.PreviousMetadata.EventPayload
+	if len(payload) == 0 {
+		t.Fail()
+	}
+	if mmp.AppId != "APPXXX" {
+		t.Fail()
+	}
+	if mmp.BotId != "BOTXXX" {
+		t.Fail()
+	}
+	if mmp.UserId != "USERXXX" {
+		t.Fail()
+	}
+	if mmp.TeamId != "TEAMXXX" {
+		t.Fail()
+	}
+	if mmp.MessageTimestamp != "1660398079.756349" {
+		t.Fail()
+	}
+	if mmp.DeletedTimestamp != "1660398079.756349" {
+		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 TestChannelUnsharedEvent(t *testing.T) {
+	jsonStr := `{
+		"type": "channel_unshared",
+		"previously_connected_team_id": "E163Q94DX",
+		"channel": "C123ABC456",
+		"is_ext_shared": false,
+		"event_ts": "1561064063.001100"
+	}`
+
+	var event ChannelUnsharedEvent
+	if err := json.Unmarshal([]byte(jsonStr), &event); err != nil {
+		t.Errorf("Failed to unmarshal ChannelUnsharedEvent: %v", err)
+	}
+
+	if event.Type != "channel_unshared" {
+		t.Errorf("Expected type to be 'channel_unshared', got %s", event.Type)
+	}
+
+	if event.PreviouslyConnectedTeamID != "E163Q94DX" {
+		t.Errorf("Expected previously_connected_team_id to be 'E163Q94DX', got %s", event.PreviouslyConnectedTeamID)
+	}
+
+	if event.IsExtShared {
+		t.Errorf("Expected is_ext_shared to be false, got %t", event.IsExtShared)
+	}
+
+	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/message_context",
+					"name": "message_context",
+					"description": "",
+					"title": "Message Context",
+					"is_required": true
+				},
+				{
+					"type": "slack#/types/user_id",
+					"name": "user_id",
+					"description": "Message recipient",
+					"title": "User",
+					"is_required": true
+				},
+				{
+					"type": "integer",
+					"name": "timestamp",
+					"description": "Timestamp of the event",
+					"title": "Timestamp",
+					"is_required": true
+				},
+				{
+					"type": "boolean",
+					"name": "enabled",
+					"description": "Indicates if the feature is enabled",
+					"title": "Enabled",
+					"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",
+			"timestamp": 1698947481,
+			"enabled": true,
+			"message_context": {
+				"channel_id": "C0123456789",
+				"message_ts": "1733331835.871019"
+			}
+		},
+		"function_execution_id": "Fx1234567O9L",
+		"workflow_execution_id": "WxABC123DEF0",
+		"event_ts": "1698958075.998738",
+		"bot_access_token": "abcd-1325532282098-1322446258629-6123648410839-527a1cab3979cad288c9e20330d212cf"
+	}`
+
+	type MessageContext struct {
+		ChannelId string `json:"channel_id"`
+		MessageTs string `json:"message_ts"`
+	}
+	type TestInputs struct {
+		UserId    string         `json:"user_id"`
+		Timestamp int            `json:"timestamp"`
+		Enabled   bool           `json:"enabled"`
+		Context   MessageContext `json:"message_context"`
+	}
+
+	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()
+	}
+
+	inputStr, err := json.Marshal(event.Inputs)
+	if err != nil {
+		t.Errorf("Failed to marshal Inputs of FunctionExecutedEvent: %v", err)
+	}
+	testInputs := new(TestInputs)
+	err = json.Unmarshal(inputStr, testInputs)
+	if err != nil {
+		t.Errorf("Failed to unmarshal Inputs of FunctionExecutedEvent: %v", err)
+	}
+	assert.Equal(t, "USER12345678", testInputs.UserId)
+	assert.Equal(t, 1698947481, testInputs.Timestamp)
+	assert.True(t, testInputs.Enabled)
+	assert.Equal(t, "C0123456789", testInputs.Context.ChannelId)
+	assert.Equal(t, "1733331835.871019", testInputs.Context.MessageTs)
+}
+
+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)
+	}
+}
+
+func TestAppHomeOpenedEvent_WithView(t *testing.T) {
+	eventJSON := []byte(`{
+		"type": "app_home_opened",
+		"user": "U12345678",
+		"channel": "D12345678",
+		"tab": "home",
+		"event_ts": "1747319568.267214",
+		"view": {
+			"id": "V12345678",
+			"team_id": "T12345678",
+			"type": "home",
+			"blocks": [],
+			"private_metadata": "",
+			"callback_id": "",
+			"state": {
+				"values": {}
+			},
+			"hash": "1234567890.abcdef",
+			"title": {
+				"type": "plain_text",
+				"text": "App Home"
+			},
+			"clear_on_close": false,
+			"notify_on_close": false,
+			"close": null,
+			"submit": null,
+			"previous_view_id": "",
+			"root_view_id": "V12345678",
+			"app_id": "A12345678",
+			"external_id": "",
+			"app_installed_team_id": "T12345678",
+			"bot_id": "B12345678"
+		}
+	}`)
+
+	var event AppHomeOpenedEvent
+	err := json.Unmarshal(eventJSON, &event)
+
+	assert.NoError(t, err)
+	assert.Equal(t, "app_home_opened", event.Type)
+	assert.Equal(t, "U12345678", event.User)
+	assert.Equal(t, "D12345678", event.Channel)
+	assert.Equal(t, "home", event.Tab)
+	assert.Equal(t, "1747319568.267214", event.EventTimeStamp)
+	assert.NotNil(t, event.View)
+	assert.Equal(t, "V12345678", event.View.ID)
+	assert.Equal(t, "T12345678", event.View.TeamID)
+	assert.Equal(t, slack.ViewType("home"), event.View.Type)
+}
+
+func TestAppHomeOpenedEvent_WithoutView(t *testing.T) {
+	eventJSON := []byte(`{
+		"type": "app_home_opened",
+		"user": "U12345678",
+		"channel": "D12345678",
+		"tab": "home",
+		"event_ts": "1747319568.267214"
+	}`)
+
+	var event AppHomeOpenedEvent
+	err := json.Unmarshal(eventJSON, &event)
+
+	assert.NoError(t, err)
+	assert.Equal(t, "app_home_opened", event.Type)
+	assert.Equal(t, "U12345678", event.User)
+	assert.Equal(t, "D12345678", event.Channel)
+	assert.Equal(t, "home", event.Tab)
+	assert.Equal(t, "1747319568.267214", event.EventTimeStamp)
+	assert.Nil(t, event.View)
+}
+
+func TestAppHomeOpenedEvent_FullEventParsing_WithView(t *testing.T) {
+	fullEventJSON := []byte(`{
+		"token": "verification-token",
+		"team_id": "T12345678",
+		"api_app_id": "A12345678",
+		"event": {
+			"type": "app_home_opened",
+			"user": "U12345678",
+			"channel": "D12345678",
+			"tab": "home",
+			"event_ts": "1747319568.267214",
+			"view": {
+				"id": "V12345678",
+				"team_id": "T12345678",
+				"type": "home",
+				"blocks": [],
+				"private_metadata": "",
+				"callback_id": "",
+				"state": {
+					"values": {}
+				},
+				"hash": "1234567890.abcdef",
+				"title": {
+					"type": "plain_text",
+					"text": "App Home"
+				},
+				"clear_on_close": false,
+				"notify_on_close": false,
+				"close": null,
+				"submit": null,
+				"previous_view_id": "",
+				"root_view_id": "V12345678",
+				"app_id": "A12345678",
+				"external_id": "",
+				"app_installed_team_id": "T12345678",
+				"bot_id": "B12345678"
+			}
+		},
+		"type": "event_callback",
+		"event_id": "Ev12345678",
+		"event_time": 1747319568,
+		"authorizations": [{
+			"enterprise_id": null,
+			"team_id": "T12345678",
+			"user_id": "U12345678",
+			"is_bot": true,
+			"is_enterprise_install": false
+		}],
+		"is_ext_shared_channel": false
+	}`)
+
+	parsedEvent, err := ParseEvent(fullEventJSON, OptionNoVerifyToken())
+
+	assert.NoError(t, err)
+	assert.Equal(t, "T12345678", parsedEvent.TeamID)
+	assert.Equal(t, "A12345678", parsedEvent.APIAppID)
+	assert.Equal(t, "app_home_opened", parsedEvent.InnerEvent.Type)
+	appHomeEvent, ok := parsedEvent.InnerEvent.Data.(*AppHomeOpenedEvent)
+	assert.True(t, ok)
+	assert.Equal(t, "app_home_opened", appHomeEvent.Type)
+	assert.Equal(t, "U12345678", appHomeEvent.User)
+	assert.Equal(t, "D12345678", appHomeEvent.Channel)
+	assert.Equal(t, "home", appHomeEvent.Tab)
+	assert.Equal(t, "1747319568.267214", appHomeEvent.EventTimeStamp)
+	assert.NotNil(t, appHomeEvent.View)
+	assert.Equal(t, "V12345678", appHomeEvent.View.ID)
+	assert.Equal(t, "T12345678", appHomeEvent.View.TeamID)
+}
+
+func TestAppHomeOpenedEvent_FullEventParsing_WithoutView(t *testing.T) {
+	fullEventJSON := []byte(`{
+		"token": "verification-token",
+		"team_id": "T12345678",
+		"api_app_id": "A12345678",
+		"event": {
+			"type": "app_home_opened",
+			"user": "U12345678",
+			"channel": "D12345678",
+			"tab": "home",
+			"event_ts": "1747319568.267214"
+		},
+		"type": "event_callback",
+		"event_id": "Ev12345678",
+		"event_time": 1747319568,
+		"authorizations": [{
+			"enterprise_id": null,
+			"team_id": "T12345678",
+			"user_id": "U12345678",
+			"is_bot": true,
+			"is_enterprise_install": false
+		}],
+		"is_ext_shared_channel": false
+	}`)
+
+	parsedEvent, err := ParseEvent(fullEventJSON, OptionNoVerifyToken())
+
+	assert.NoError(t, err)
+	assert.Equal(t, "T12345678", parsedEvent.TeamID)
+	assert.Equal(t, "A12345678", parsedEvent.APIAppID)
+	assert.Equal(t, "app_home_opened", parsedEvent.InnerEvent.Type)
+	appHomeEvent, ok := parsedEvent.InnerEvent.Data.(*AppHomeOpenedEvent)
+	assert.True(t, ok)
+	assert.Equal(t, "app_home_opened", appHomeEvent.Type)
+	assert.Equal(t, "U12345678", appHomeEvent.User)
+	assert.Equal(t, "D12345678", appHomeEvent.Channel)
+	assert.Equal(t, "home", appHomeEvent.Tab)
+	assert.Equal(t, "1747319568.267214", appHomeEvent.EventTimeStamp)
+	assert.Nil(t, appHomeEvent.View)
+}
+
+func TestEntityDetailsRequestedEvent(t *testing.T) {
+	jsonStr := `{
+		"type": "entity_details_requested",
+		"user": "U123456789",
+		"trigger_id": "1234567890123.1234567890123.abcdef01234567890abcdef012345689",
+		"user_locale": "en-US",
+		"entity_url": "https://example.com/incidents/123",
+		"external_ref": {
+			"id": "123"
+		},
+		"link": {
+			"url": "https://example.com/incidents/123",
+			"domain": "example.com"
+		},
+		"app_unfurl_url": "https://example.com/incidents/123",
+		"channel": "C123456789",
+		"message_ts": "1234567890.123456",
+		"event_ts": "1234567890.123456"
+	}`
+
+	var event EntityDetailsRequestedEvent
+	if err := json.Unmarshal([]byte(jsonStr), &event); err != nil {
+		t.Errorf("Failed to unmarshal EntityDetailsRequestedEvent: %v", err)
+	}
+
+	if event.Type != "entity_details_requested" {
+		t.Errorf("Expected type to be 'entity_details_requested', got %s", event.Type)
+	}
+
+	if event.User != "U123456789" {
+		t.Errorf("Expected user to be 'U123456789', got %s", event.User)
+	}
+
+	if event.ExternalRef.ID != "123" {
+		t.Errorf("Expected external_ref.id to be '123', got %s", event.ExternalRef.ID)
+	}
+
+	if event.EntityURL != "https://example.com/incidents/123" {
+		t.Errorf("Expected entity_url to be 'https://example.com/incidents/123', got %s", event.EntityURL)
+	}
+
+	if event.Link.URL != "https://example.com/incidents/123" {
+		t.Errorf("Expected link.url to be 'https://example.com/incidents/123', got %s", event.Link.URL)
+	}
+
+	if event.Link.Domain != "example.com" {
+		t.Errorf("Expected link.domain to be 'example.com', got %s", event.Link.Domain)
+	}
+
+	if event.TriggerID != "1234567890123.1234567890123.abcdef01234567890abcdef012345689" {
+		t.Errorf("Expected trigger_id to be '1234567890123.1234567890123.abcdef01234567890abcdef012345689', got %s", event.TriggerID)
+	}
+
+	if event.EventTS != "1234567890.123456" {
+		t.Errorf("Expected event_ts to be '1234567890.123456', got %s", event.EventTS)
+	}
+
+	if event.Channel != "C123456789" {
+		t.Errorf("Expected channel to be 'C123456789', got %s", event.Channel)
+	}
+
+	if event.MessageTs != "1234567890.123456" {
+		t.Errorf("Expected message_ts to be '1234567890.123456', got %s", event.MessageTs)
+	}
+}
+
+func TestParseEventAPIEntityDetailsRequested(t *testing.T) {
+	rawE := []byte(`
+		{
+			"token": "test-token",
+			"team_id": "T123456789",
+			"api_app_id": "A123456789",
+			"event": {
+				"type": "entity_details_requested",
+				"user": "U123456789",
+				"trigger_id": "1234567890123.1234567890123.abcdef01234567890abcdef012345689",
+				"user_locale": "en-US",
+				"entity_url": "https://example.com/incidents/123",
+				"external_ref": {
+					"id": "123"
+				},
+				"link": {
+					"url": "https://example.com/incidents/123",
+					"domain": "example.com"
+				},
+				"app_unfurl_url": "https://example.com/incidents/123",
+				"channel": "C123456789",
+				"message_ts": "1234567890.123456",
+				"event_ts": "1234567890.123456"
+			},
+			"type": "event_callback",
+			"event_id": "Ev123456789",
+			"event_time": 1234567890
 		}
+	`)
+
+	parsedEvent, err := ParseEvent(rawE, OptionNoVerifyToken())
+	if err != nil {
+		t.Errorf("Failed to parse EntityDetailsRequestedEvent: %v", err)
+	}
+
+	if parsedEvent.Type != "event_callback" {
+		t.Errorf("Expected outer event type to be 'event_callback', got %s", parsedEvent.Type)
+	}
+
+	if parsedEvent.InnerEvent.Type != "entity_details_requested" {
+		t.Errorf("Expected inner event type to be 'entity_details_requested', got %s", parsedEvent.InnerEvent.Type)
+	}
+
+	innerEvent, ok := parsedEvent.InnerEvent.Data.(*EntityDetailsRequestedEvent)
+	if !ok {
+		t.Errorf("Expected inner event data to be *EntityDetailsRequestedEvent, got %T", parsedEvent.InnerEvent.Data)
+	}
+
+	if innerEvent.Type != "entity_details_requested" {
+		t.Errorf("Expected inner event type to be 'entity_details_requested', got %s", innerEvent.Type)
+	}
+
+	if innerEvent.User != "U123456789" {
+		t.Errorf("Expected user to be 'U123456789', got %s", innerEvent.User)
+	}
+
+	if innerEvent.ExternalRef.ID != "123" {
+		t.Errorf("Expected external_ref.id to be '123', got %s", innerEvent.ExternalRef.ID)
+	}
+
+	if innerEvent.TriggerID != "1234567890123.1234567890123.abcdef01234567890abcdef012345689" {
+		t.Errorf("Expected trigger_id to be '1234567890123.1234567890123.abcdef01234567890abcdef012345689', got %s", innerEvent.TriggerID)
+	}
+
+	if innerEvent.EventTS != "1234567890.123456" {
+		t.Errorf("Expected event_ts to be '1234567890.123456', got %s", innerEvent.EventTS)
+	}
+
+	if innerEvent.Link.Domain != "example.com" {
+		t.Errorf("Expected link.domain to be 'example.com', got %s", innerEvent.Link.Domain)
 	}
 }
diff --git a/slackevents/outer_events.go b/slackevents/outer_events.go
index 8d682cc32..5199da088 100644
--- a/slackevents/outer_events.go
+++ b/slackevents/outer_events.go
@@ -8,13 +8,14 @@ import (
 
 // EventsAPIEvent is the base EventsAPIEvent
 type EventsAPIEvent struct {
-	Token        string `json:"token"`
-	TeamID       string `json:"team_id"`
-	Type         string `json:"type"`
-	APIAppID     string `json:"api_app_id"`
-	EnterpriseID string `json:"enterprise_id"`
-	Data         interface{}
-	InnerEvent   EventsAPIInnerEvent
+	Token              string `json:"token"`
+	TeamID             string `json:"team_id"`
+	Type               string `json:"type"`
+	APIAppID           string `json:"api_app_id"`
+	EnterpriseID       string `json:"enterprise_id"`
+	IsExtSharedChannel bool   `json:"is_ext_shared_channel"`
+	Data               any
+	InnerEvent         EventsAPIInnerEvent
 }
 
 // EventsAPIURLVerificationEvent received when configuring a EventsAPI driven app
@@ -31,16 +32,18 @@ type ChallengeResponse struct {
 
 // EventsAPICallbackEvent is the main (outer) EventsAPI event.
 type EventsAPICallbackEvent struct {
-	Type         string           `json:"type"`
-	Token        string           `json:"token"`
-	TeamID       string           `json:"team_id"`
-	APIAppID     string           `json:"api_app_id"`
-	InnerEvent   *json.RawMessage `json:"event"`
-	AuthedUsers  []string         `json:"authed_users"`
-	AuthedTeams  []string         `json:"authed_teams"`
-	EventID      string           `json:"event_id"`
-	EventTime    int              `json:"event_time"`
-	EventContext string           `json:"event_context"`
+	Type               string           `json:"type"`
+	Token              string           `json:"token"`
+	TeamID             string           `json:"team_id"`
+	APIAppID           string           `json:"api_app_id"`
+	EnterpriseID       string           `json:"enterprise_id"`
+	InnerEvent         *json.RawMessage `json:"event"`
+	AuthedUsers        []string         `json:"authed_users"`
+	AuthedTeams        []string         `json:"authed_teams"`
+	EventID            string           `json:"event_id"`
+	EventTime          int              `json:"event_time"`
+	EventContext       string           `json:"event_context"`
+	IsExtSharedChannel bool             `json:"is_ext_shared_channel"`
 }
 
 // EventsAPIAppRateLimited indicates your app's event subscriptions are being rate limited
@@ -61,10 +64,10 @@ const (
 	AppRateLimited = "app_rate_limited"
 )
 
-// EventsAPIEventMap maps OUTTER Event API events to their corresponding struct
+// EventsAPIEventMap maps OUTER Event API events to their corresponding struct
 // implementations. The structs should be instances of the unmarshalling
 // target for the matching event type.
-var EventsAPIEventMap = map[string]interface{}{
+var EventsAPIEventMap = map[string]any{
 	CallbackEvent:   EventsAPICallbackEvent{},
 	URLVerification: EventsAPIURLVerificationEvent{},
 	AppRateLimited:  EventsAPIAppRateLimited{},
diff --git a/slackevents/outer_events_test.go b/slackevents/outer_events_test.go
index 942d6240d..62f861171 100644
--- a/slackevents/outer_events_test.go
+++ b/slackevents/outer_events_test.go
@@ -33,13 +33,18 @@ func TestCallBackEvent(t *testing.T) {
 				"type": "event_callback",
 				"authed_users": [ "UXXXXXXX1" ],
 				"event_id": "Ev08MFMKH6",
-				"event_time": 1234567890
+				"event_time": 1234567890,
+				"is_ext_shared_channel": true
 		}
 	`)
-	err := json.Unmarshal(rawE, &EventsAPICallbackEvent{})
+	var cb EventsAPICallbackEvent
+	err := json.Unmarshal(rawE, &cb)
 	if err != nil {
 		t.Error(err)
 	}
+	if !cb.IsExtSharedChannel {
+		t.Errorf("expected IsExtSharedChannel to be true, got false")
+	}
 }
 
 func TestAppRateLimitedEvent(t *testing.T) {
diff --git a/slackevents/parsers.go b/slackevents/parsers.go
index 0cc584c76..af8ff39dd 100644
--- a/slackevents/parsers.go
+++ b/slackevents/parsers.go
@@ -10,23 +10,16 @@ import (
 	"github.com/slack-go/slack"
 )
 
-// eventsMap checks both slack.EventsMapping and
-// and slackevents.EventsAPIInnerEventMapping. If the event
-// exists, returns the the unmarshalled struct instance of
-// target for the matching event type.
-// TODO: Consider moving all events into its own package?
-func eventsMap(t string) (interface{}, bool) {
-	// Must parse EventsAPI FIRST as both RTM and EventsAPI
-	// have a type: "Message" event.
-	// TODO: Handle these cases more explicitly.
-	v, exists := EventsAPIInnerEventMapping[t]
+// eventsMap checks both slackevents.EventsAPIInnerEventMapping and slack.EventMapping
+// (RTM). EventsAPI mapping is checked first because both define a "message" type, and
+// EventsAPI's MessageEvent is the correct choice for Events API payloads.
+func eventsMap(t string) (any, bool) {
+	// EventsAPI mapping takes precedence over RTM mapping.
+	v, exists := EventsAPIInnerEventMapping[EventsAPIType(t)]
 	if exists {
 		return v, exists
 	}
 	v, exists = slack.EventMapping[t]
-	if exists {
-		return v, exists
-	}
 	return v, exists
 }
 
@@ -40,6 +33,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) {
 			"unmarshalling_error",
 			"",
 			"",
+			false,
 			&slack.UnmarshallingErrorEvent{ErrorObj: err},
 			EventsAPIInnerEvent{},
 		}, err
@@ -54,6 +48,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) {
 				"unmarshalling_error",
 				"",
 				"",
+				false,
 				&slack.UnmarshallingErrorEvent{ErrorObj: err},
 				EventsAPIInnerEvent{},
 			}, err
@@ -64,6 +59,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) {
 			e.Type,
 			e.APIAppID,
 			e.EnterpriseID,
+			e.IsExtSharedChannel,
 			cbEvent,
 			EventsAPIInnerEvent{},
 		}, nil
@@ -77,6 +73,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) {
 			"unmarshalling_error",
 			"",
 			"",
+			false,
 			&slack.UnmarshallingErrorEvent{ErrorObj: err},
 			EventsAPIInnerEvent{},
 		}, err
@@ -87,6 +84,7 @@ func parseOuterEvent(rawE json.RawMessage) (EventsAPIEvent, error) {
 		e.Type,
 		e.APIAppID,
 		e.EnterpriseID,
+		e.IsExtSharedChannel,
 		urlVE,
 		EventsAPIInnerEvent{},
 	}, nil
@@ -102,7 +100,8 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) {
 			e.TeamID,
 			"unmarshalling_error",
 			e.APIAppID,
-			"",
+			e.EnterpriseID,
+			false,
 			&slack.UnmarshallingErrorEvent{ErrorObj: err},
 			EventsAPIInnerEvent{},
 		}, err
@@ -114,10 +113,11 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) {
 			e.TeamID,
 			iE.Type,
 			e.APIAppID,
-			"",
+			e.EnterpriseID,
+			false,
 			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()
@@ -128,7 +128,8 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) {
 			e.TeamID,
 			"unmarshalling_error",
 			e.APIAppID,
-			"",
+			e.EnterpriseID,
+			false,
 			&slack.UnmarshallingErrorEvent{ErrorObj: err},
 			EventsAPIInnerEvent{},
 		}, err
@@ -138,7 +139,8 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) {
 		e.TeamID,
 		e.Type,
 		e.APIAppID,
-		"",
+		e.EnterpriseID,
+		e.IsExtSharedChannel,
 		e,
 		EventsAPIInnerEvent{iE.Type, recvEvent},
 	}, nil
@@ -176,7 +178,7 @@ func (c TokenComparator) Verify(t string) bool {
 	return subtle.ConstantTimeCompare([]byte(c.VerificationToken), []byte(t)) == 1
 }
 
-// ParseEvent parses the outter and inner events (if applicable) of an events
+// ParseEvent parses the outer and inner events (if applicable) of an events
 // api event returning a EventsAPIEvent type. If the event is a url_verification event,
 // the inner event is empty.
 func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error) {
@@ -192,7 +194,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 {
@@ -206,12 +208,41 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error
 				"unmarshalling_error",
 				"",
 				"",
+				false,
 				&slack.UnmarshallingErrorEvent{ErrorObj: err},
 				EventsAPIInnerEvent{},
 			}, err
 		}
 		return innerEvent, nil
 	}
+
+	if e.Type == AppRateLimited {
+		appRateLimitedEvent := &EventsAPIAppRateLimited{}
+		err = json.Unmarshal(rawEvent, appRateLimitedEvent)
+		if err != nil {
+			return EventsAPIEvent{
+				"",
+				"",
+				"unmarshalling_error",
+				"",
+				"",
+				false,
+				&slack.UnmarshallingErrorEvent{ErrorObj: err},
+				EventsAPIInnerEvent{},
+			}, err
+		}
+		return EventsAPIEvent{
+			e.Token,
+			e.TeamID,
+			e.Type,
+			e.APIAppID,
+			e.EnterpriseID,
+			e.IsExtSharedChannel,
+			appRateLimitedEvent,
+			EventsAPIInnerEvent{},
+		}, nil
+	}
+
 	urlVerificationEvent := &EventsAPIURLVerificationEvent{}
 	err = json.Unmarshal(rawEvent, urlVerificationEvent)
 	if err != nil {
@@ -221,6 +252,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error
 			"unmarshalling_error",
 			"",
 			"",
+			false,
 			&slack.UnmarshallingErrorEvent{ErrorObj: err},
 			EventsAPIInnerEvent{},
 		}, err
@@ -231,11 +263,28 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error
 		e.Type,
 		e.APIAppID,
 		e.EnterpriseID,
+		e.IsExtSharedChannel,
 		urlVerificationEvent,
 		EventsAPIInnerEvent{},
 	}, nil
 }
 
+// Deprecated: ParseActionEvent cannot parse block_actions payloads and will return an
+// unmarshalling error for them. Use [slack.InteractionCallback] with [json.Unmarshal]
+// instead, or [slack.InteractionCallbackParse] to parse directly from an HTTP request.
+// InteractionCallback handles all interaction types (block_actions, interactive_message,
+// view_submission, etc.).
+//
+// Migration example:
+//
+//	// Before (broken for block_actions):
+//	action, err := slackevents.ParseActionEvent(payload, slackevents.OptionNoVerifyToken())
+//
+//	// After (handles all interaction types):
+//	var ic slack.InteractionCallback
+//	err := json.Unmarshal([]byte(payload), &ic)
+//	// Use ic.ActionCallback.BlockActions for block actions
+//	// Use ic.ActionCallback.AttachmentActions for legacy attachment actions
 func ParseActionEvent(payloadString string, opts ...Option) (MessageAction, error) {
 	byteString := []byte(payloadString)
 	action := MessageAction{}
diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go
index 8dcdedda0..1cdcc8b93 100644
--- a/slackevents/parsers_test.go
+++ b/slackevents/parsers_test.go
@@ -10,20 +10,20 @@ import (
 
 func TestParserOuterCallBackEvent(t *testing.T) {
 	eventsAPIRawCallbackEvent := `
-			{
-				"token": "XXYYZZ",
-				"team_id": "TXXXXXXXX",
-				"api_app_id": "AXXXXXXXXX",
-				"event": {
-								"type": "app_mention",
-								"event_ts": "1234567890.123456",
-								"user": "UXXXXXXX1"
-				},
-				"type": "event_callback",
-				"authed_users": [ "UXXXXXXX1" ],
-				"event_id": "Ev08MFMKH6",
-				"event_time": 1234567890
-		}
+    {
+        "token": "XXYYZZ",
+        "team_id": "TXXXXXXXX",
+        "api_app_id": "AXXXXXXXXX",
+        "event": {
+            "type": "app_mention",
+            "event_ts": "1234567890.123456",
+            "user": "UXXXXXXX1"
+        },
+        "type": "event_callback",
+        "authed_users": [ "UXXXXXXX1" ],
+        "event_id": "Ev08MFMKH6",
+        "event_time": 1234567890
+        }
 	`
 	msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
 	if e != nil {
@@ -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 := `
 			{
@@ -95,7 +122,7 @@ func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) {
 		fmt.Println(e)
 		t.Fail()
 	}
-	switch outterEvent := msg.Data.(type) {
+	switch outerEvent := msg.Data.(type) {
 	case *EventsAPICallbackEvent:
 		{
 			switch innerEvent := msg.InnerEvent.Data.(type) {
@@ -109,12 +136,41 @@ func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) {
 		}
 	default:
 		{
-			fmt.Println(outterEvent)
+			fmt.Println(outerEvent)
 			t.Fail()
 		}
 	}
 }
 
+func TestParseEventExposesIsExtSharedChannel(t *testing.T) {
+	// is_ext_shared_channel lives on the outer event_callback wrapper. Verify
+	// that it survives all the way through ParseEvent for a callback event that
+	// carries an inner event (the common case), not just on the outer parse.
+	eventsAPIRawCallbackEvent := `
+			{
+				"token": "XXYYZZ",
+				"team_id": "TXXXXXXXX",
+				"api_app_id": "AXXXXXXXXX",
+				"event": {
+								"type": "app_mention",
+								"event_ts": "1234567890.123456",
+								"user": "UXXXXXXX1"
+				},
+				"type": "event_callback",
+				"event_id": "Ev08MFMKH6",
+				"event_time": 1234567890,
+				"is_ext_shared_channel": true
+		}
+	`
+	msg, err := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !msg.IsExtSharedChannel {
+		t.Fatalf("expected IsExtSharedChannel to be true, got false")
+	}
+}
+
 func TestBadTokenVerification(t *testing.T) {
 	urlVerificationEvent := `
 		{
@@ -143,3 +199,365 @@ func TestNoTokenVerification(t *testing.T) {
 		t.Fail()
 	}
 }
+
+func TestParseEventAPIAppMentionWithAssistantThread(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "app_mention",
+				"event_ts": "1234567890.123456",
+				"user": "UXXXXXXX1",
+				"text": "<@U0LAN0Z89> help me with something",
+				"ts": "1515449522.000016",
+				"channel": "C0LAN2Q65",
+				"assistant_thread": {
+					"action_token": "1234567.abcdefg"
+				}
+			},
+			"type": "event_callback",
+			"authed_users": [ "UXXXXXXX1" ],
+			"event_id": "Ev08MFMKH6",
+			"event_time": 1234567890
+		}
+	`
+	msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if e != nil {
+		fmt.Println(e)
+		t.Fail()
+	}
+
+	switch outerEvent := msg.Data.(type) {
+	case *EventsAPICallbackEvent:
+		{
+			switch innerEvent := msg.InnerEvent.Data.(type) {
+			case *AppMentionEvent:
+				{
+					if innerEvent.AssistantThread == nil {
+						t.Error("Expected AssistantThread to be non-nil")
+					}
+					if innerEvent.AssistantThread.ActionToken != "1234567.abcdefg" {
+						t.Errorf("Expected ActionToken to be '1234567.abcdefg', got %s", innerEvent.AssistantThread.ActionToken)
+					}
+				}
+			default:
+				fmt.Println(innerEvent)
+				t.Fail()
+			}
+		}
+	default:
+		{
+			fmt.Println(outerEvent)
+			t.Fail()
+		}
+	}
+}
+
+func TestParseEventAPIAppMentionWithActionToken(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "app_mention",
+				"event_ts": "1234567890.123456",
+				"user": "UXXXXXXX1",
+				"text": "<@U0LAN0Z89> search slack",
+				"ts": "1515449522.000016",
+				"channel": "C0LAN2Q65",
+				"action_token": "1234567.top-level"
+			},
+			"type": "event_callback",
+			"authed_users": [ "UXXXXXXX1" ],
+			"event_id": "Ev08MFMKH6",
+			"event_time": 1234567890
+		}
+	`
+	msg, err := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	innerEvent, ok := msg.InnerEvent.Data.(*AppMentionEvent)
+	if !ok {
+		t.Fatalf("Expected *AppMentionEvent, got %T", msg.InnerEvent.Data)
+	}
+	if innerEvent.ActionToken != "1234567.top-level" {
+		t.Errorf("Expected ActionToken to be '1234567.top-level', got %s", innerEvent.ActionToken)
+	}
+}
+
+func TestParseEventAPIMessageIMWithAssistantThread(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "message",
+				"channel": "D024BE91L",
+				"user": "U2147483697",
+				"text": "Hello, I need help with something.",
+				"ts": "1355517523.000005",
+				"event_ts": "1355517523.000005",
+				"channel_type": "im",
+				"assistant_thread": {
+					"action_token": "9876543.hijklmnop"
+				}
+			},
+			"type": "event_callback",
+			"authed_users": [ "U2147483697" ],
+			"event_id": "Ev08MFMKH7",
+			"event_time": 1234567890
+		}
+	`
+	msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if e != nil {
+		fmt.Println(e)
+		t.Fail()
+	}
+
+	switch outerEvent := msg.Data.(type) {
+	case *EventsAPICallbackEvent:
+		{
+			switch innerEvent := msg.InnerEvent.Data.(type) {
+			case *MessageEvent:
+				{
+					if innerEvent.AssistantThread == nil {
+						t.Error("Expected AssistantThread to be non-nil")
+					}
+					if innerEvent.AssistantThread.ActionToken != "9876543.hijklmnop" {
+						t.Errorf("Expected ActionToken to be '9876543.hijklmnop', got %s", innerEvent.AssistantThread.ActionToken)
+					}
+					if innerEvent.ChannelType != "im" {
+						t.Errorf("Expected ChannelType to be 'im', got %s", innerEvent.ChannelType)
+					}
+				}
+			default:
+				fmt.Println(innerEvent)
+				t.Fail()
+			}
+		}
+	default:
+		{
+			fmt.Println(outerEvent)
+			t.Fail()
+		}
+	}
+}
+
+func TestParseEventAPIMessageIMWithActionToken(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "message",
+				"channel": "D024BE91L",
+				"user": "U2147483697",
+				"text": "Search slack",
+				"ts": "1355517523.000005",
+				"event_ts": "1355517523.000005",
+				"channel_type": "im",
+				"action_token": "9876543.top-level"
+			},
+			"type": "event_callback",
+			"authed_users": [ "U2147483697" ],
+			"event_id": "Ev08MFMKH7",
+			"event_time": 1234567890
+		}
+	`
+	msg, err := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	innerEvent, ok := msg.InnerEvent.Data.(*MessageEvent)
+	if !ok {
+		t.Fatalf("Expected *MessageEvent, got %T", msg.InnerEvent.Data)
+	}
+	if innerEvent.ActionToken != "9876543.top-level" {
+		t.Errorf("Expected ActionToken to be '9876543.top-level', got %s", innerEvent.ActionToken)
+	}
+}
+
+func TestParseEventAPIMessageChannelWithAssistantThread(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "message",
+				"channel": "C024BE91L",
+				"user": "U2147483697",
+				"text": "Hello everyone, I need help with something.",
+				"ts": "1355517523.000005",
+				"event_ts": "1355517523.000005",
+				"channel_type": "channel",
+				"assistant_thread": {
+					"action_token": "abcd1234.qwerty"
+				}
+			},
+			"type": "event_callback",
+			"authed_users": [ "U2147483697" ],
+			"event_id": "Ev08MFMKH8",
+			"event_time": 1234567890
+		}
+	`
+	msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if e != nil {
+		fmt.Println(e)
+		t.Fail()
+	}
+
+	switch outerEvent := msg.Data.(type) {
+	case *EventsAPICallbackEvent:
+		{
+			switch innerEvent := msg.InnerEvent.Data.(type) {
+			case *MessageEvent:
+				{
+					if innerEvent.AssistantThread == nil {
+						t.Error("Expected AssistantThread to be non-nil")
+					}
+					if innerEvent.AssistantThread.ActionToken != "abcd1234.qwerty" {
+						t.Errorf("Expected ActionToken to be 'abcd1234.qwerty', got %s", innerEvent.AssistantThread.ActionToken)
+					}
+					if innerEvent.ChannelType != "channel" {
+						t.Errorf("Expected ChannelType to be 'channel', got %s", innerEvent.ChannelType)
+					}
+				}
+			default:
+				fmt.Println(innerEvent)
+				t.Fail()
+			}
+		}
+	default:
+		{
+			fmt.Println(outerEvent)
+			t.Fail()
+		}
+	}
+}
+
+func TestParseEventAPIMessageMPIMWithAssistantThread(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "message",
+				"channel": "G024BE91L",
+				"user": "U2147483697",
+				"text": "Hey team, I need some assistance.",
+				"ts": "1355517523.000005",
+				"event_ts": "1355517523.000005",
+				"channel_type": "mpim",
+				"assistant_thread": {
+					"action_token": "xyz789.multiparty"
+				}
+			},
+			"type": "event_callback",
+			"authed_users": [ "U2147483697" ],
+			"event_id": "Ev08MFMKH9",
+			"event_time": 1234567890
+		}
+	`
+	msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if e != nil {
+		fmt.Println(e)
+		t.Fail()
+	}
+
+	switch outerEvent := msg.Data.(type) {
+	case *EventsAPICallbackEvent:
+		{
+			switch innerEvent := msg.InnerEvent.Data.(type) {
+			case *MessageEvent:
+				{
+					if innerEvent.AssistantThread == nil {
+						t.Error("Expected AssistantThread to be non-nil")
+					}
+					if innerEvent.AssistantThread.ActionToken != "xyz789.multiparty" {
+						t.Errorf("Expected ActionToken to be 'xyz789.multiparty', got %s", innerEvent.AssistantThread.ActionToken)
+					}
+					if !innerEvent.IsMpIM() {
+						t.Errorf("Expected ChannelType to be 'mpim', got %s", innerEvent.ChannelType)
+					}
+				}
+			default:
+				fmt.Println(innerEvent)
+				t.Fail()
+			}
+		}
+	default:
+		{
+			fmt.Println(outerEvent)
+			t.Fail()
+		}
+	}
+}
+
+func TestParseEventAPIMessageGroupWithAssistantThread(t *testing.T) {
+	eventsAPIRawCallbackEvent := `
+		{
+			"token": "XXYYZZ",
+			"team_id": "TXXXXXXXX",
+			"api_app_id": "AXXXXXXXXX",
+			"event": {
+				"type": "message",
+				"channel": "G124BE91L",
+				"user": "U2147483697",
+				"text": "Private group message with assistant request.",
+				"ts": "1355517523.000005",
+				"event_ts": "1355517523.000005",
+				"channel_type": "group",
+				"assistant_thread": {
+					"action_token": "group123.private"
+				}
+			},
+			"type": "event_callback",
+			"authed_users": [ "U2147483697" ],
+			"event_id": "Ev08MFMK10",
+			"event_time": 1234567890
+		}
+	`
+	msg, e := ParseEvent(json.RawMessage(eventsAPIRawCallbackEvent), OptionVerifyToken(&TokenComparator{"XXYYZZ"}))
+	if e != nil {
+		fmt.Println(e)
+		t.Fail()
+	}
+
+	switch outerEvent := msg.Data.(type) {
+	case *EventsAPICallbackEvent:
+		{
+			switch innerEvent := msg.InnerEvent.Data.(type) {
+			case *MessageEvent:
+				{
+					if innerEvent.AssistantThread == nil {
+						t.Error("Expected AssistantThread to be non-nil")
+					}
+					if innerEvent.AssistantThread.ActionToken != "group123.private" {
+						t.Errorf("Expected ActionToken to be 'group123.private', got %s", innerEvent.AssistantThread.ActionToken)
+					}
+					if innerEvent.ChannelType != "group" {
+						t.Errorf("Expected ChannelType to be 'group', got %s", innerEvent.ChannelType)
+					}
+				}
+			default:
+				fmt.Println(innerEvent)
+				t.Fail()
+			}
+		}
+	default:
+		{
+			fmt.Println(outerEvent)
+			t.Fail()
+		}
+	}
+}
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 37d413a0d..98697cf36 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,19 +35,11 @@ var okWebResponse = slack.SlackResponse{
 	Ok: true,
 }
 
-var defaultChannelsListJSON = fmt.Sprintf(`
+var defaultOkJSON = `
 	{
-		"ok": true,
-		"channels": [%s, %s]
+		"ok": true
 	}
-	`, defaultGeneralChannelJSON, defaultExtraChannelJSON)
-
-var defaultGroupsListJSON = fmt.Sprintf(`
-		{
-			"ok": true,
-			"groups": [%s]
-		}
-		`, defaultGroupJSON)
+	`
 
 var defaultAuthTestJSON = fmt.Sprintf(`
 	{
@@ -67,86 +59,6 @@ var defaultUsersInfoJSON = fmt.Sprintf(`
 	}
 	`, defaultNonBotUser)
 
-var defaultGeneralChannelJSON = fmt.Sprintf(`
-	{
-        "id": "C024BE91L",
-        "name": "general",
-        "is_channel": true,
-        "created": %d,
-        "creator": "%s",
-        "is_archived": false,
-        "is_general": true,
-
-        "members": [
-            "W012A3CDE"
-        ],
-
-        "topic": {
-            "value": "Fun times",
-            "creator": "%s",
-            "last_set": %d
-        },
-        "purpose": {
-            "value": "This channel is for fun",
-            "creator": "%s",
-            "last_set": %d
-        },
-
-        "is_member": true
-    }
-`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
-
-var defaultExtraChannelJSON = fmt.Sprintf(`
-	{
-        "id": "C024BE92L",
-        "name": "bot-playground",
-        "is_channel": true,
-        "created": %d,
-        "creator": "%s",
-        "is_archived": false,
-        "is_general": true,
-
-        "members": [
-            "W012A3CDE"
-        ],
-
-        "topic": {
-            "value": "Fun times",
-            "creator": "%s",
-            "last_set": %d
-        },
-        "purpose": {
-            "value": "This channel is for fun",
-            "creator": "%s",
-            "last_set": %d
-        },
-
-        "is_member": true
-    }
-`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
-
-var defaultGroupJSON = fmt.Sprintf(`{
-    "id": "G024BE91L",
-    "name": "secretplans",
-    "is_group": true,
-    "created": %d,
-    "creator": "%s",
-    "is_archived": false,
-    "members": [
-        "W012A3CDE"
-    ],
-    "topic": {
-        "value": "Secret plans on hold",
-        "creator": "%s",
-        "last_set": %d
-    },
-    "purpose": {
-        "value": "Discuss secret plans that no-one else should know",
-        "creator": "%s",
-        "last_set": %d
-    }
-}`, nowAsJSONTime(), defaultNonBotUserID, defaultNonBotUserID, nowAsJSONTime(), defaultNonBotUserID, nowAsJSONTime())
-
 var defaultNonBotUser = fmt.Sprintf(`
 		"user": {
 			"id": "%s",
@@ -226,7 +138,7 @@ var templateConversationJSON = `
 			"creator": "%s",
 			"last_set": %d
 		},
-        "num_members": %d,
+		"num_members": %d,
 		"previous_names": [],
 		"priority": 0
 	}
@@ -244,3 +156,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/funcs.go b/slacktest/funcs.go
index a5b6470eb..19c0bba67 100644
--- a/slacktest/funcs.go
+++ b/slacktest/funcs.go
@@ -6,19 +6,18 @@ import (
 	"log"
 	"time"
 
-	websocket "github.com/gorilla/websocket"
-	slack "github.com/slack-go/slack"
+	"github.com/gorilla/websocket"
+
+	"github.com/slack-go/slack"
 )
 
 func (sts *Server) queueForWebsocket(s, hubname string) {
 	channel, err := getHubForServer(hubname)
 	if err != nil {
 		log.Printf("Unable to get server's channels: %s", err.Error())
+	} else {
+		channel.sent <- s
 	}
-	sts.seenOutboundMessages.Lock()
-	sts.seenOutboundMessages.messages = append(sts.seenOutboundMessages.messages, s)
-	sts.seenOutboundMessages.Unlock()
-	channel.sent <- s
 }
 
 func handlePendingMessages(c *websocket.Conn, hubname string) {
@@ -42,9 +41,7 @@ func (sts *Server) postProcessMessage(m, hubname string) {
 		log.Printf("Unable to get server's channels: %s", err.Error())
 		return
 	}
-	sts.seenInboundMessages.Lock()
-	sts.seenInboundMessages.messages = append(sts.seenInboundMessages.messages, m)
-	sts.seenInboundMessages.Unlock()
+	sts.seenInboundMessages.observe(m)
 	// send to firehose
 	channel.seen <- m
 }
@@ -97,6 +94,15 @@ func BotIDFromContext(ctx context.Context) string {
 	return botname
 }
 
+// ServerWSFromContext returns the server websocket endpoint from a provided context
+func ServerWSFromContext(ctx context.Context) string {
+	url, ok := ctx.Value(ServerWSContextKey).(string)
+	if !ok {
+		return "ws://wtf?!"
+	}
+	return url
+}
+
 // generate a full rtminfo response for initial rtm connections
 func generateRTMInfo(ctx context.Context, wsurl string) *fullInfoSlackResponse {
 	rtmInfo := slack.Info{
@@ -136,3 +142,13 @@ func defaultBotInfoJSON(ctx context.Context) string {
 		}
 		`, botid, botname)
 }
+
+func defaultAppsConnectionsJSON(ctx context.Context) string {
+	url := ServerWSFromContext(ctx)
+	return fmt.Sprintf(`
+			   {
+					   "ok":true,
+					   "url": "%s"
+			   }
+			   `, url)
+}
diff --git a/slacktest/handlers.go b/slacktest/handlers.go
index b3f13700c..a3a604aa1 100644
--- a/slacktest/handlers.go
+++ b/slacktest/handlers.go
@@ -4,14 +4,15 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"net/http"
 	"net/url"
 	"time"
 
-	websocket "github.com/gorilla/websocket"
-	slack "github.com/slack-go/slack"
+	"github.com/gorilla/websocket"
+
+	"github.com/slack-go/slack"
 )
 
 func contextHandler(server *Server, next http.HandlerFunc) http.Handler {
@@ -45,17 +46,17 @@ 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)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
 	values, vErr := url.ParseQuery(string(data))
 	if vErr != nil {
 		msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error())
-		log.Printf(msg)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
@@ -74,7 +75,7 @@ func (sts *Server) conversationsInfoHandler(w http.ResponseWriter, r *http.Reque
 	encoded, err := json.Marshal(&response)
 	if err != nil {
 		msg := fmt.Sprintf("Unable to encode response: %s", err.Error())
-		log.Printf(msg)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
@@ -107,20 +108,35 @@ 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 reaction.Add
+func reactionAddHandler(w http.ResponseWriter, _ *http.Request) {
+	_, _ = w.Write([]byte(defaultOkJSON))
+}
+
+// handle apps.connections.open
+func appsConnectionsOpenHandler(w http.ResponseWriter, r *http.Request) {
+	_, _ = w.Write([]byte(defaultAppsConnectionsJSON(r.Context())))
+}
+
 // 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)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
 	values, vErr := url.ParseQuery(string(data))
 	if vErr != nil {
 		msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error())
-		log.Printf(msg)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
@@ -156,7 +172,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) {
 		decoded, err := url.QueryUnescape(attachments)
 		if err != nil {
 			msg := fmt.Sprintf("Unable to decode attachments: %s", err.Error())
-			log.Printf(msg)
+			log.Printf("%s", msg)
 			http.Error(w, msg, http.StatusInternalServerError)
 			return
 		}
@@ -164,7 +180,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) {
 		aJErr := json.Unmarshal([]byte(decoded), &attaches)
 		if aJErr != nil {
 			msg := fmt.Sprintf("Unable to decode attachments string to json: %s", aJErr.Error())
-			log.Printf(msg)
+			log.Printf("%s", msg)
 			http.Error(w, msg, http.StatusInternalServerError)
 			return
 		}
@@ -175,7 +191,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) {
 		decoded, err := url.QueryUnescape(blocks)
 		if err != nil {
 			msg := fmt.Sprintf("Unable to decode blocks: %s", err.Error())
-			log.Printf(msg)
+			log.Printf("%s", msg)
 			http.Error(w, msg, http.StatusInternalServerError)
 			return
 		}
@@ -183,7 +199,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) {
 		dbJErr := json.Unmarshal([]byte(decoded), &decodedBlocks)
 		if dbJErr != nil {
 			msg := fmt.Sprintf("Unable to decode blocks string to json: %s", dbJErr.Error())
-			log.Printf(msg)
+			log.Printf("%s", msg)
 			http.Error(w, msg, http.StatusInternalServerError)
 			return
 		}
@@ -192,7 +208,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) {
 	jsonMessage, jsonErr := json.Marshal(m)
 	if jsonErr != nil {
 		msg := fmt.Sprintf("Unable to marshal message: %s", jsonErr.Error())
-		log.Printf(msg)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
@@ -200,19 +216,110 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) {
 	_ = json.NewEncoder(w).Encode(resp)
 }
 
+// handle chat.postEphemeral
+func (sts *Server) postEphemeralHandler(w http.ResponseWriter, r *http.Request) {
+	data, err := io.ReadAll(r.Body)
+	if err != nil {
+		msg := fmt.Sprintf("error reading body: %s", err.Error())
+		log.Printf("%s", msg)
+		http.Error(w, msg, http.StatusInternalServerError)
+		return
+	}
+	values, vErr := url.ParseQuery(string(data))
+	if vErr != nil {
+		msg := fmt.Sprintf("Unable to decode query params: %s", vErr.Error())
+		log.Printf("%s", msg)
+		http.Error(w, msg, http.StatusInternalServerError)
+		return
+	}
+
+	ts := time.Now().Unix()
+	resp := &struct {
+		Ok      bool   `json:"ok"`
+		Channel string `json:"channel"`
+		Ts      string `json:"ts"`
+		Text    string `json:"text"`
+	}{
+		Ok:      true,
+		Channel: values.Get("channel"),
+		Ts:      fmt.Sprintf("%d", ts),
+		Text:    values.Get("text"),
+	}
+
+	m := slack.Message{}
+	m.Type = "message"
+	m.Channel = values.Get("channel")
+	m.Timestamp = fmt.Sprintf("%d", ts)
+	m.Text = values.Get("text")
+	m.ThreadTimestamp = values.Get("thread_ts")
+	m.User = values.Get("user")
+	if values.Get("as_user") != "true" {
+		m.Username = defaultNonBotUserName
+	} else {
+		m.Username = BotNameFromContext(r.Context())
+	}
+	attachments := values.Get("attachments")
+	if attachments != "" {
+		decoded, err := url.QueryUnescape(attachments)
+		if err != nil {
+			msg := fmt.Sprintf("Unable to decode attachments: %s", err.Error())
+			log.Printf("%s", msg)
+			http.Error(w, msg, http.StatusInternalServerError)
+			return
+		}
+		var attaches []slack.Attachment
+		aJErr := json.Unmarshal([]byte(decoded), &attaches)
+		if aJErr != nil {
+			msg := fmt.Sprintf("Unable to decode attachments string to json: %s", aJErr.Error())
+			log.Printf("%s", msg)
+			http.Error(w, msg, http.StatusInternalServerError)
+			return
+		}
+		m.Attachments = attaches
+	}
+	blocks := values.Get("blocks")
+	if blocks != "" {
+		decoded, err := url.QueryUnescape(blocks)
+		if err != nil {
+			msg := fmt.Sprintf("Unable to decode blocks: %s", err.Error())
+			log.Printf("%s", msg)
+			http.Error(w, msg, http.StatusInternalServerError)
+			return
+		}
+		var decodedBlocks slack.Blocks
+		dbJErr := json.Unmarshal([]byte(decoded), &decodedBlocks)
+		if dbJErr != nil {
+			msg := fmt.Sprintf("Unable to decode blocks string to json: %s", dbJErr.Error())
+			log.Printf("%s", msg)
+			http.Error(w, msg, http.StatusInternalServerError)
+			return
+		}
+		m.Blocks = decodedBlocks
+	}
+	jsonMessage, jsonErr := json.Marshal(m)
+	if jsonErr != nil {
+		msg := fmt.Sprintf("Unable to marshal message: %s", jsonErr.Error())
+		log.Printf("%s", msg)
+		http.Error(w, msg, http.StatusInternalServerError)
+		return
+	}
+	sts.SendToWebsocket(string(jsonMessage))
+	_ = json.NewEncoder(w).Encode(resp)
+}
+
 // 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)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
 	wsurl := r.Context().Value(ServerWSContextKey).(string)
 	if wsurl == "" {
 		msg := "missing webservice url from context"
-		log.Printf(msg)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
@@ -232,17 +339,17 @@ 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)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
 	wsurl := r.Context().Value(ServerWSContextKey).(string)
 	if wsurl == "" {
 		msg := "missing webservice url from context"
-		log.Printf(msg)
+		log.Printf("%s", msg)
 		http.Error(w, msg, http.StatusInternalServerError)
 		return
 	}
diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go
index 9ad35f754..eefbe03a6 100644
--- a/slacktest/handlers_test.go
+++ b/slacktest/handlers_test.go
@@ -3,8 +3,9 @@ package slacktest
 import (
 	"testing"
 
-	slack "github.com/slack-go/slack"
 	"github.com/stretchr/testify/assert"
+
+	"github.com/slack-go/slack"
 )
 
 func TestAuthTestHandler(t *testing.T) {
@@ -31,12 +32,24 @@ func TestPostMessageHandler(t *testing.T) {
 	assert.NotEmpty(t, tstamp, "timestamp should not be empty")
 }
 
+func TestPostEphemeralHandler(t *testing.T) {
+	s := NewTestServer()
+	go s.Start()
+
+	client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL()))
+	tstamp, err := client.PostEphemeral("fake_channel", "fake_user", slack.MsgOptionText("some ephemeral text", false), slack.MsgOptionPostMessageParameters(slack.PostMessageParameters{}))
+	assert.NoError(t, err, "should not error out")
+	assert.NotEmpty(t, tstamp, "timestamp should not be empty")
+
+	assert.True(t, s.SawOutgoingMessage("some ephemeral text"))
+}
+
 func TestServerCreateConversationHandler(t *testing.T) {
 	s := NewTestServer()
 	go s.Start()
 
 	client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL()))
-	conversation, err := client.CreateConversation("test", false)
+	conversation, err := client.CreateConversation(slack.CreateConversationParams{ChannelName: "test"})
 	assert.NoError(t, err)
 	assert.Equal(t, "C0EAQDV4Z", conversation.ID)
 	assert.Equal(t, "U023BECGF", conversation.Creator)
@@ -116,7 +129,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/rtm_test.go b/slacktest/rtm_test.go
index 00a59f5fc..2183f3f8b 100644
--- a/slacktest/rtm_test.go
+++ b/slacktest/rtm_test.go
@@ -4,12 +4,13 @@ import (
 	"testing"
 	"time"
 
-	"github.com/slack-go/slack"
 	"github.com/stretchr/testify/assert"
+
+	"github.com/slack-go/slack"
 )
 
 func TestRTMInfo(t *testing.T) {
-	maxWait := 10 * time.Millisecond
+	maxWait := 5 * time.Second
 	s := NewTestServer()
 	go s.Start()
 
@@ -19,8 +20,7 @@ func TestRTMInfo(t *testing.T) {
 	messageChan := make(chan (*slack.ConnectedEvent), 1)
 	go func() {
 		for msg := range rtm.IncomingEvents {
-			switch ev := msg.Data.(type) {
-			case *slack.ConnectedEvent:
+			if ev, ok := msg.Data.(*slack.ConnectedEvent); ok {
 				messageChan <- ev
 			}
 		}
@@ -50,8 +50,7 @@ func TestRTMPing(t *testing.T) {
 	messageChan := make(chan (*slack.LatencyReport), 1)
 	go func() {
 		for msg := range rtm.IncomingEvents {
-			switch ev := msg.Data.(type) {
-			case *slack.LatencyReport:
+			if ev, ok := msg.Data.(*slack.LatencyReport); ok {
 				messageChan <- ev
 			}
 		}
@@ -78,8 +77,7 @@ func TestRTMDirectMessage(t *testing.T) {
 	messageChan := make(chan (*slack.MessageEvent), 1)
 	go func() {
 		for msg := range rtm.IncomingEvents {
-			switch ev := msg.Data.(type) {
-			case *slack.MessageEvent:
+			if ev, ok := msg.Data.(*slack.MessageEvent); ok {
 				messageChan <- ev
 			}
 		}
@@ -107,8 +105,7 @@ func TestRTMChannelMessage(t *testing.T) {
 	messageChan := make(chan (*slack.MessageEvent), 1)
 	go func() {
 		for msg := range rtm.IncomingEvents {
-			switch ev := msg.Data.(type) {
-			case *slack.MessageEvent:
+			if ev, ok := msg.Data.(*slack.MessageEvent); ok {
 				messageChan <- ev
 			}
 		}
diff --git a/slacktest/server.go b/slacktest/server.go
index aa6c0a180..338290b86 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{}
@@ -50,15 +50,19 @@ func NewTestServer(custom ...binder) *Server {
 	s.Handle("/rtm.start", rtmStartHandler)
 	s.Handle("/rtm.connect", RTMConnectHandler)
 	s.Handle("/chat.postMessage", s.postMessageHandler)
+	s.Handle("/chat.postEphemeral", s.postEphemeralHandler)
 	s.Handle("/conversations.create", createConversationHandler)
 	s.Handle("/conversations.setTopic", setConversationTopicHandler)
 	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)
 	s.Handle("/auth.test", authTestHandler)
+	s.Handle("/reactions.add", reactionAddHandler)
+	s.Handle("/apps.connections.open", appsConnectionsOpenHandler)
 
 	httpserver := httptest.NewUnstartedServer(s.mux)
 	addr := httpserver.Listener.Addr().String()
@@ -104,28 +108,20 @@ func (sts *Server) GetGroups() []slack.Group {
 
 // GetSeenInboundMessages returns all messages seen via websocket excluding pings
 func (sts *Server) GetSeenInboundMessages() []string {
-	sts.seenInboundMessages.RLock()
-	m := sts.seenInboundMessages.messages
-	sts.seenInboundMessages.RUnlock()
-	return m
+	return sts.seenInboundMessages.get()
 }
 
 // GetSeenOutboundMessages returns all messages seen via websocket excluding pings
 func (sts *Server) GetSeenOutboundMessages() []string {
-	sts.seenOutboundMessages.RLock()
-	m := sts.seenOutboundMessages.messages
-	sts.seenOutboundMessages.RUnlock()
-	return m
+	return sts.seenOutboundMessages.get()
 }
 
 // SawOutgoingMessage checks if a message was sent to connected websocket clients
 func (sts *Server) SawOutgoingMessage(msg string) bool {
-	sts.seenOutboundMessages.RLock()
-	defer sts.seenOutboundMessages.RUnlock()
-	for _, m := range sts.seenOutboundMessages.messages {
+	for _, m := range sts.seenOutboundMessages.get() {
 		evt := &slack.MessageEvent{}
-		jErr := json.Unmarshal([]byte(m), evt)
-		if jErr != nil {
+		err := json.Unmarshal([]byte(m), evt)
+		if err != nil {
 			continue
 		}
 
@@ -133,17 +129,16 @@ func (sts *Server) SawOutgoingMessage(msg string) bool {
 			return true
 		}
 	}
+
 	return false
 }
 
 // SawMessage checks if an incoming message was seen
 func (sts *Server) SawMessage(msg string) bool {
-	sts.seenInboundMessages.RLock()
-	defer sts.seenInboundMessages.RUnlock()
-	for _, m := range sts.seenInboundMessages.messages {
+	for _, m := range sts.seenInboundMessages.get() {
 		evt := &slack.MessageEvent{}
-		jErr := json.Unmarshal([]byte(m), evt)
-		if jErr != nil {
+		err := json.Unmarshal([]byte(m), evt)
+		if err != nil {
 			// This event isn't a message event so we'll skip it
 			continue
 		}
@@ -151,6 +146,7 @@ func (sts *Server) SawMessage(msg string) bool {
 			return true
 		}
 	}
+
 	return false
 }
 
@@ -182,11 +178,14 @@ func (sts *Server) SendMessageToBot(channel, msg string) {
 	m.User = defaultNonBotUserID
 	m.Text = fmt.Sprintf("<@%s> %s", sts.BotID, msg)
 	m.Timestamp = fmt.Sprintf("%d", time.Now().Unix())
-	j, jErr := json.Marshal(m)
-	if jErr != nil {
-		log.Printf("Unable to marshal message for bot: %s", jErr.Error())
+
+	j, err := json.Marshal(m)
+	if err != nil {
+		log.Printf("Unable to marshal message for bot: %s", err.Error())
 		return
 	}
+
+	sts.seenOutboundMessages.observe(string(j))
 	go sts.queueForWebsocket(string(j), sts.ServerAddr)
 }
 
@@ -198,11 +197,14 @@ func (sts *Server) SendDirectMessageToBot(msg string) {
 	m.User = defaultNonBotUserID
 	m.Text = msg
 	m.Timestamp = fmt.Sprintf("%d", time.Now().Unix())
-	j, jErr := json.Marshal(m)
-	if jErr != nil {
-		log.Printf("Unable to marshal private message for bot: %s", jErr.Error())
+
+	j, err := json.Marshal(m)
+	if err != nil {
+		log.Printf("Unable to marshal private message for bot: %s", err.Error())
 		return
 	}
+
+	sts.seenOutboundMessages.observe(string(j))
 	go sts.queueForWebsocket(string(j), sts.ServerAddr)
 }
 
@@ -214,18 +216,21 @@ func (sts *Server) SendMessageToChannel(channel, msg string) {
 	m.Text = msg
 	m.User = defaultNonBotUserID
 	m.Timestamp = fmt.Sprintf("%d", time.Now().Unix())
+
 	j, jErr := json.Marshal(m)
 	if jErr != nil {
 		log.Printf("Unable to marshal message for channel: %s", jErr.Error())
 		return
 	}
-	stringMsg := string(j)
-	go sts.queueForWebsocket(stringMsg, sts.ServerAddr)
+
+	sts.seenOutboundMessages.observe(string(j))
+	go sts.queueForWebsocket(string(j), sts.ServerAddr)
 }
 
 // SendToWebsocket send `s` as is to connected clients.
 // This is useful for sending your own custom json to the websocket
 func (sts *Server) SendToWebsocket(s string) {
+	sts.seenOutboundMessages.observe(s)
 	go sts.queueForWebsocket(s, sts.ServerAddr)
 }
 
diff --git a/slacktest/server_test.go b/slacktest/server_test.go
index 5902282c1..24582a6fb 100644
--- a/slacktest/server_test.go
+++ b/slacktest/server_test.go
@@ -6,8 +6,9 @@ import (
 	"testing"
 	"time"
 
-	"github.com/slack-go/slack"
 	"github.com/stretchr/testify/assert"
+
+	"github.com/slack-go/slack"
 )
 
 func TestDefaultNewServer(t *testing.T) {
@@ -26,7 +27,7 @@ func TestCustomNewServer(t *testing.T) {
 
 func TestServerSendMessageToChannel(t *testing.T) {
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 	s.SendMessageToChannel("C123456789", "some text")
 	time.Sleep(2 * time.Second)
 	assert.True(t, s.SawOutgoingMessage("some text"))
@@ -35,7 +36,7 @@ func TestServerSendMessageToChannel(t *testing.T) {
 
 func TestServerSendMessageToBot(t *testing.T) {
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 	s.SendMessageToBot("C123456789", "some text")
 	expectedMsg := fmt.Sprintf("<@%s> %s", s.BotID, "some text")
 	time.Sleep(2 * time.Second)
@@ -45,23 +46,23 @@ func TestServerSendMessageToBot(t *testing.T) {
 
 func TestBotDirectMessageBotHandler(t *testing.T) {
 	s := NewTestServer()
-	go s.Start()
+	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()
 }
 
 func TestGetSeenOutboundMessages(t *testing.T) {
-	maxWait := 5 * time.Second
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 
 	s.SendMessageToChannel("foo", "should see this message")
-	time.Sleep(maxWait)
+
 	seenOutbound := s.GetSeenOutboundMessages()
-	assert.True(t, len(seenOutbound) > 0)
+	assert.Len(t, seenOutbound, 1)
+
 	hadMessage := false
 	for _, msg := range seenOutbound {
 		var m = slack.Message{}
@@ -78,7 +79,7 @@ func TestGetSeenOutboundMessages(t *testing.T) {
 func TestGetSeenInboundMessages(t *testing.T) {
 	maxWait := 5 * time.Second
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 
 	api := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL()))
 	rtm := api.NewRTM()
@@ -107,14 +108,13 @@ func TestGetSeenInboundMessages(t *testing.T) {
 func TestSendChannelInvite(t *testing.T) {
 	maxWait := 5 * time.Second
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 	rtm := s.GetTestRTMInstance()
 	go rtm.ManageConnection()
 	evChan := make(chan (slack.Channel), 1)
 	go func() {
 		for msg := range rtm.IncomingEvents {
-			switch ev := msg.Data.(type) {
-			case *slack.ChannelJoinedEvent:
+			if ev, ok := msg.Data.(*slack.ChannelJoinedEvent); ok {
 				evChan <- ev.Channel
 			}
 		}
@@ -136,14 +136,13 @@ func TestSendChannelInvite(t *testing.T) {
 func TestSendGroupInvite(t *testing.T) {
 	maxWait := 5 * time.Second
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 	rtm := s.GetTestRTMInstance()
 	go rtm.ManageConnection()
 	evChan := make(chan (slack.Channel), 1)
 	go func() {
 		for msg := range rtm.IncomingEvents {
-			switch ev := msg.Data.(type) {
-			case *slack.GroupJoinedEvent:
+			if ev, ok := msg.Data.(*slack.GroupJoinedEvent); ok {
 				evChan <- ev.Channel
 			}
 		}
@@ -164,12 +163,12 @@ func TestSendGroupInvite(t *testing.T) {
 
 func TestServerSawMessage(t *testing.T) {
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 	assert.False(t, s.SawMessage("foo"), "should not have seen any message")
 }
 
 func TestServerSawOutgoingMessage(t *testing.T) {
 	s := NewTestServer()
-	go s.Start()
+	s.Start()
 	assert.False(t, s.SawOutgoingMessage("foo"), "should not have seen any message")
 }
diff --git a/slacktest/types.go b/slacktest/types.go
index 4c90ed9ba..c6e776f72 100644
--- a/slacktest/types.go
+++ b/slacktest/types.go
@@ -40,15 +40,29 @@ type hub struct {
 }
 
 type messageChannels struct {
-	seen   chan (string)
-	sent   chan (string)
-	posted chan (slack.Message)
+	seen   chan string
+	sent   chan string
+	posted chan slack.Message
 }
 type messageCollection struct {
 	sync.RWMutex
 	messages []string
 }
 
+func (mc *messageCollection) observe(msg string) {
+	mc.Lock()
+	defer mc.Unlock()
+	mc.messages = append(mc.messages, msg)
+}
+
+func (mc *messageCollection) get() []string {
+	mc.RLock()
+	defer mc.RUnlock()
+
+	m := mc.messages
+	return m
+}
+
 type serverChannels struct {
 	sync.RWMutex
 	channels []slack.Channel
@@ -68,7 +82,7 @@ type Server struct {
 	BotName              string
 	BotID                string
 	ServerAddr           string
-	SeenFeed             chan (string)
+	SeenFeed             chan string
 	channels             *serverChannels
 	groups               *serverGroups
 	seenInboundMessages  *messageCollection
diff --git a/slash.go b/slash.go
index b2c509476..d13b1a670 100644
--- a/slash.go
+++ b/slash.go
@@ -1,25 +1,30 @@
 package slack
 
 import (
+	"encoding/json"
+	"fmt"
 	"net/http"
+	"slices"
+	"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 +37,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")
@@ -46,10 +52,36 @@ func SlashCommandParse(r *http.Request) (s SlashCommand, err error) {
 
 // ValidateToken validates verificationTokens
 func (s SlashCommand) ValidateToken(verificationTokens ...string) bool {
-	for _, token := range verificationTokens {
-		if s.Token == token {
-			return true
+	return slices.Contains(verificationTokens, s.Token)
+}
+
+// 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 any `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 false
+
+	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/socket_mode.go b/socket_mode.go
index 69e40d99d..26276ac16 100644
--- a/socket_mode.go
+++ b/socket_mode.go
@@ -2,13 +2,14 @@ package slack
 
 import (
 	"context"
+	"net/url"
 )
 
 // SocketModeConnection contains various details about the SocketMode connection.
 // It is returned by an "apps.connections.open" API call.
 type SocketModeConnection struct {
-	URL  string                 `json:"url,omitempty"`
-	Data map[string]interface{} `json:"-"`
+	URL  string         `json:"url,omitempty"`
+	Data map[string]any `json:"-"`
 }
 
 type openResponseFull struct {
@@ -21,7 +22,7 @@ type openResponseFull struct {
 // To have a fully managed Socket Mode connection, use `socketmode.New()`, and call `Run()` on it.
 func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketModeConnection, websocketURL string, err error) {
 	response := &openResponseFull{}
-	err = postJSON(ctx, api.httpclient, api.endpoint+"apps.connections.open", api.appLevelToken, nil, response, api)
+	err = api.postJSONMethod(ctx, "apps.connections.open", api.appLevelToken, nil, response)
 	if err != nil {
 		return nil, "", err
 	}
@@ -30,5 +31,15 @@ func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketMode
 		api.Debugln("Using URL:", response.SocketModeConnection.URL)
 	}
 
+	// According to the API documentation at https://api.slack.com/apis/socket-mode, we
+	// can add a query parameter `debug_reconnects=true` to the URL to make the connection
+	// time significantly shorter (360 seconds).
+	if api.debug {
+		u, _ := url.Parse(response.SocketModeConnection.URL)
+		q := u.Query()
+		q.Set("debug_reconnects", "true")
+		u.RawQuery = q.Encode()
+		response.SocketModeConnection.URL = u.String()
+	}
 	return &response.SocketModeConnection, response.SocketModeConnection.URL, response.Err()
 }
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/event.go b/socketmode/event.go
index 5ae434a70..e9d75ed4a 100644
--- a/socketmode/event.go
+++ b/socketmode/event.go
@@ -5,7 +5,7 @@ import "encoding/json"
 // Event is the event sent to the consumer of Client
 type Event struct {
 	Type EventType
-	Data interface{}
+	Data any
 
 	// Request is the json-decoded raw WebSocket message that is received via the Slack Socket Mode
 	// WebSocket connection.
diff --git a/socketmode/log.go b/socketmode/log.go
index 9f3b7f690..6ac134e9b 100644
--- a/socketmode/log.go
+++ b/socketmode/log.go
@@ -13,9 +13,9 @@ type logger interface {
 // ilogger represents the internal logging api we use.
 type ilogger interface {
 	logger
-	Print(...interface{})
-	Printf(string, ...interface{})
-	Println(...interface{})
+	Print(...any)
+	Printf(string, ...any)
+	Println(...any)
 }
 
 // internalLog implements the additional methods used by our internal logging.
@@ -24,27 +24,27 @@ type internalLog struct {
 }
 
 // Println replicates the behaviour of the standard logger.
-func (t internalLog) Println(v ...interface{}) {
+func (t internalLog) Println(v ...any) {
 	t.Output(2, fmt.Sprintln(v...))
 }
 
 // Printf replicates the behaviour of the standard logger.
-func (t internalLog) Printf(format string, v ...interface{}) {
+func (t internalLog) Printf(format string, v ...any) {
 	t.Output(2, fmt.Sprintf(format, v...))
 }
 
 // Print replicates the behaviour of the standard logger.
-func (t internalLog) Print(v ...interface{}) {
+func (t internalLog) Print(v ...any) {
 	t.Output(2, fmt.Sprint(v...))
 }
 
-func (smc *Client) Debugf(format string, v ...interface{}) {
+func (smc *Client) Debugf(format string, v ...any) {
 	if smc.debug {
 		smc.log.Output(2, fmt.Sprintf(format, v...))
 	}
 }
 
-func (smc *Client) Debugln(v ...interface{}) {
+func (smc *Client) Debugln(v ...any) {
 	if smc.debug {
 		smc.log.Output(2, fmt.Sprintln(v...))
 	}
diff --git a/socketmode/request.go b/socketmode/request.go
index 078003a0a..1baef5748 100644
--- a/socketmode/request.go
+++ b/socketmode/request.go
@@ -6,7 +6,7 @@ import "encoding/json"
 //
 // We call this a "request" rather than e.g. a WebSocket message or an Socket Mode "event" following python-slack-sdk:
 //
-//   https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L6
+// https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L6
 //
 // We know that node-slack-sdk calls it an "event", that makes it hard for us to distinguish our client's own event
 // that wraps both internal events and Socket Mode "events", vs node-slack-sdk's is for the latter only.
@@ -29,7 +29,8 @@ type Request struct {
 
 	// `events_api` type only
 	EnvelopeID string `json:"envelope_id"`
-	// TODO Can it really be a non-object type?
+	// Payload is typed as json.RawMessage because the Slack API sends different
+	// shapes depending on the envelope type (object, array, or string).
 	// See https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L26-L31
 	Payload                json.RawMessage `json:"payload"`
 	AcceptsResponsePayload bool            `json:"accepts_response_payload"`
diff --git a/socketmode/response.go b/socketmode/response.go
index 5c7bfabcf..fa7c7924b 100644
--- a/socketmode/response.go
+++ b/socketmode/response.go
@@ -1,6 +1,11 @@
 package socketmode
 
 type Response struct {
-	EnvelopeID string      `json:"envelope_id"`
-	Payload    interface{} `json:"payload,omitempty"`
+	EnvelopeID string `json:"envelope_id"`
+	Payload    any    `json:"payload,omitempty"`
+
+	// rawJSON holds the pre-marshaled JSON bytes when set by SendCtx.
+	// This avoids double-marshaling: once for the size check and once for
+	// the WebSocket write.
+	rawJSON []byte `json:"-"`
 }
diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go
index 9373a6be0..1dc815875 100644
--- a/socketmode/socket_mode_managed_conn.go
+++ b/socketmode/socket_mode_managed_conn.go
@@ -11,13 +11,12 @@ import (
 	"sync"
 	"time"
 
+	"github.com/gorilla/websocket"
+
 	"github.com/slack-go/slack"
 	"github.com/slack-go/slack/internal/backoff"
-	"github.com/slack-go/slack/internal/misc"
-	"github.com/slack-go/slack/slackevents"
-
-	"github.com/gorilla/websocket"
 	"github.com/slack-go/slack/internal/timex"
+	"github.com/slack-go/slack/slackevents"
 )
 
 // Run is a blocking function that connects the Slack Socket Mode API and handles all incoming
@@ -55,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
 	}
@@ -83,89 +83,112 @@ 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() {
-		defer wg.Done()
+	wg.Go(func() {
 		defer cancel()
 
 		// 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)
 		}
-	}()
+	})
 
-	wg.Add(1)
-	go func() {
-		defer wg.Done()
+	wg.Go(func() {
 		defer cancel()
 
 		// 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)
 		}
-	}()
+	})
 
-	wg.Add(1)
 	go func() {
-		defer wg.Done()
 		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
-			})
-		}
+		// the request handler. It only ever returns on error.
+		sendErr(smc.runMessageReceiver(ctx, conn, messages))
 	}()
 
-	wg.Add(1)
-	go func() {
-		defer wg.Done()
+	// OptionPingInterval writes maxPingInterval, so snapshot it here instead of
+	// reading the field from the goroutine below.
+	pingInterval := smc.maxPingInterval
 
-		select {
-		case <-ctx.Done():
+	wg.Go(func() {
+		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
+				}
+			}
 		}
-	}()
+	})
 
 	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.
-	// Also, we can expect firstErr to be not nil, as goroutines can finish only on error.
-	smc.Debugf("Reconnecting due to %v", firstErr)
+	// 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 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 +216,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 +235,27 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP
 		default:
 		}
 
-		switch actual := err.(type) {
-		case misc.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:
+		if codeErr, ok := errors.AsType[slack.StatusCodeError](err); ok && codeErr.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 rlError, ok := errors.AsType[*slack.RateLimitedError](err); ok {
+			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 +263,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()
 		}
 	}
@@ -267,21 +293,33 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s
 	// Only use HTTPS for connections to prevent MITM attacks on the connection.
 	upgradeHeader := http.Header{}
 	upgradeHeader.Add("Origin", "https://api.slack.com")
-	dialer := websocket.DefaultDialer
+	dialer := &websocket.Dialer{
+		Proxy:            http.ProxyFromEnvironment,
+		HandshakeTimeout: defaultHandshakeTimeout,
+		WriteBufferSize:  defaultWriteBufferSize,
+	}
 	if smc.dialer != nil {
+		smc.Debugf("Using custom websocket dialer")
 		dialer = smc.dialer
 	}
-	conn, _, err := dialer.DialContext(ctx, url, upgradeHeader)
+	conn, resp, err := dialer.DialContext(ctx, url, upgradeHeader)
 	if err != nil {
 		smc.Debugf("Failed to dial to the websocket: %s", err)
+		if resp != nil {
+			smc.Debugf("WebSocket dial response status: %s", resp.Status)
+		}
 		return nil, nil, err
 	}
+	if resp != nil && resp.Body != nil {
+		resp.Body.Close()
+	}
+	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)
@@ -292,7 +330,8 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s
 	// We don't need to conn.SetCloseHandler because the default handler is effective enough that
 	// it sends back the CLOSE message to the server and let conn.ReadJSON() fail with CloseError.
 	// The CloseError must be handled normally in our receiveMessagesInto function.
-	//conn.SetCloseHandler(func(code int, text string) error {
+	//
+	// conn.SetCloseHandler(func(code int, text string) error {
 	//  ...
 	// })
 
@@ -307,15 +346,16 @@ func (smc *Client) runResponseSender(ctx context.Context, conn *websocket.Conn)
 		select {
 		case <-ctx.Done():
 			return ctx.Err()
-		// 3. listen for messages that need to be sent
+		// listen for messages that need to be sent
 		case res := <-smc.socketModeResponses:
 			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.Debugf("failed to write Socket Mode response for envelope ID %q: %v", res.EnvelopeID, err)
+				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 +372,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 +395,7 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra
 					return errorRequestedDisconnect{}
 				}
 
-				smc.Events <- *evt
+				smc.sendEvent(ctx, *evt)
 			}
 		}
 	}
@@ -357,7 +403,7 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra
 
 // runMessageReceiver monitors the Socket Mode opened WebSocket connection for any incoming
 // messages. It pushes the raw events into the channel.
-// The receiver runs until the context is closed.
+// The receiver runs until a read fails, so it always returns a non-nil error.
 func (smc *Client) runMessageReceiver(ctx context.Context, conn *websocket.Conn, sink chan json.RawMessage) error {
 	for {
 		if err := smc.receiveMessagesInto(ctx, conn, sink); err != nil {
@@ -382,17 +428,19 @@ func unsafeWriteSocketModeResponse(conn *websocket.Conn, res *Response) error {
 		return err
 	}
 
-	// Remove write deadline regardless of WriteJSON succeeds or not
+	// Remove write deadline regardless of write succeeding or not
 	defer conn.SetWriteDeadline(time.Time{})
 
-	if err := conn.WriteJSON(res); err != nil {
-		return err
+	// Use pre-marshaled bytes from SendCtx when available to avoid
+	// marshaling twice. Fall back to WriteJSON for responses that
+	// bypassed SendCtx.
+	if res.rawJSON != nil {
+		return conn.WriteMessage(websocket.TextMessage, res.rawJSON)
 	}
-
-	return nil
+	return conn.WriteJSON(res)
 }
 
-func newEvent(tpe EventType, data interface{}, req ...*Request) Event {
+func newEvent(tpe EventType, data any, req ...*Request) Event {
 	evt := Event{Type: tpe, Data: data}
 
 	if len(req) > 0 {
@@ -404,32 +452,79 @@ func newEvent(tpe EventType, data interface{}, req ...*Request) Event {
 
 // Ack acknowledges the Socket Mode request with the payload.
 //
-// This tells Slack that the we have received the request denoted by the envelope ID,
+// This tells Slack that 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,
-	}
-
+//
+// Returns an error if the serialized response is 20KB or larger, as Slack
+// silently drops oversized Socket Mode responses. Use Web API methods (e.g.
+// chat.PostMessage, views.Push) for large payloads.
+func (smc *Client) Ack(req Request, payload ...any) error {
+	var pld any
 	if len(payload) > 0 {
-		res.Payload = payload[0]
+		pld = payload[0]
 	}
 
-	smc.Send(res)
+	return smc.AckCtx(context.TODO(), req.EnvelopeID, pld)
+}
+
+// AckCtx acknowledges the Socket Mode request envelope ID with the payload.
+//
+// This tells Slack that we have received the request denoted by the request (envelope) ID,
+// by sending back the ID over the WebSocket connection.
+//
+// Returns an error if the serialized response is 20KB or larger, as Slack
+// silently drops oversized Socket Mode responses.
+func (smc *Client) AckCtx(ctx context.Context, reqID string, payload any) 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) {
+//
+// Returns an error if the serialized response is 20KB or larger, as Slack
+// silently drops oversized Socket Mode responses.
+func (smc *Client) Send(res Response) error {
+	return 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.
+//
+// Slack's Socket Mode silently drops WebSocket responses that are 20KB or
+// larger (the write succeeds but Slack ignores the payload). SendCtx returns an
+// error if the serialized response reaches this limit. For large payloads, use
+// Web API methods instead (e.g. chat.PostMessage, views.Push).
+func (smc *Client) SendCtx(ctx context.Context, res Response) error {
 	js, err := json.Marshal(res)
 	if err != nil {
-		panic(err)
+		return fmt.Errorf("marshalling socket mode response: %w", err)
+	}
+
+	if len(js) >= maxResponseSize {
+		return fmt.Errorf("socket mode response (%d bytes) meets or exceeds Slack's %d-byte WebSocket limit and would be silently dropped; use the Web API for large payloads",
+			len(js), maxResponseSize)
+	}
+
+	if smc.debug {
+		smc.Debugf("Scheduling Socket Mode response for envelope ID %s: %s", res.EnvelopeID, js)
 	}
 
-	smc.Debugf("Scheduling Socket Mode response for envelope ID %s: %s", res.EnvelopeID, js)
+	// Store pre-marshaled bytes so unsafeWriteSocketModeResponse can use
+	// WriteMessage instead of WriteJSON, avoiding a second marshal.
+	res.rawJSON = js
 
-	smc.socketModeResponses <- &res
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case smc.socketModeResponses <- &res:
+	}
+
+	return nil
 }
 
 // receiveMessagesInto attempts to receive an event from the WebSocket connection for Socket Mode.
@@ -441,53 +536,62 @@ 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
+		if wsErr, ok := errors.AsType[*websocket.CloseError](err); ok && 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{
+		smc.sendEvent(ctx, newEvent(EventTypeIncomingError, &slack.IncomingEventError{
 			ErrorObj: err,
-		})
+		}))
+
+		// JSON unmarshal errors indicate a malformed message, not a broken
+		// connection — keep the connection alive.
+		_, isSyntaxErr := errors.AsType[*json.SyntaxError](err)
+		_, isTypeErr := errors.AsType[*json.UnmarshalTypeError](err)
+		if isSyntaxErr || isTypeErr {
+			return nil
+		}
 
+		// All other errors from ReadJSON come from NextReader, and should
+		// kill the read loop and force a reconnect.
 		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 +604,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 +620,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 +633,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 +647,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/socket_mode_managed_conn_test.go b/socketmode/socket_mode_managed_conn_test.go
index 23bba7f50..a6a0cd3d6 100644
--- a/socketmode/socket_mode_managed_conn_test.go
+++ b/socketmode/socket_mode_managed_conn_test.go
@@ -1,13 +1,14 @@
-// +build go1.13
-
 package socketmode
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
+	"strings"
 	"testing"
 	"time"
 
+	"github.com/gorilla/websocket"
 	"github.com/slack-go/slack"
 	"github.com/slack-go/slack/slacktest"
 
@@ -25,21 +26,232 @@ func Test_passContext(t *testing.T) {
 	defer cancel()
 
 	t.Run("RunWithContext", func(t *testing.T) {
-		// should fail imidiatly.
+		// should fail immediately.
 		assert.EqualError(t, cli.RunContext(ctx), context.DeadlineExceeded.Error())
 	})
 
 	t.Run("openAndDial", func(t *testing.T) {
 		_, _, err := cli.openAndDial(ctx, func(_ string) error { return nil })
 
-		// should fail imidiatly.
+		// should fail immediately.
 		assert.EqualError(t, errors.Unwrap(err), context.DeadlineExceeded.Error())
 	})
 
 	t.Run("OpenWithContext", func(t *testing.T) {
 		_, _, err := cli.OpenContext(ctx)
 
-		// should fail imidiatly.
+		// should fail immediately.
 		assert.EqualError(t, errors.Unwrap(err), context.DeadlineExceeded.Error())
 	})
 }
+
+func TestSendCtx_PayloadQueued(t *testing.T) {
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+
+	err := cli.SendCtx(context.Background(), Response{
+		EnvelopeID: "test-envelope",
+		Payload:    "small payload",
+	})
+
+	assert.NoError(t, err)
+
+	select {
+	case res := <-cli.socketModeResponses:
+		assert.Equal(t, "test-envelope", res.EnvelopeID)
+	default:
+		t.Fatal("expected response to be queued on socketModeResponses channel")
+	}
+}
+
+func TestSendCtx_PayloadAtLimit(t *testing.T) {
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+
+	// Build a response that serializes to exactly maxResponseSize bytes.
+	// The envelope and JSON overhead consume some bytes, so we pad the payload.
+	envelope := "test-envelope"
+	overhead, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: ""})
+	padding := strings.Repeat("x", maxResponseSize-len(overhead))
+
+	// Verify we hit exactly the limit.
+	exact, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: padding})
+	assert.Equal(t, maxResponseSize, len(exact), "test setup: payload should serialize to exactly %d bytes", maxResponseSize)
+
+	err := cli.SendCtx(context.Background(), Response{
+		EnvelopeID: envelope,
+		Payload:    padding,
+	})
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "silently dropped")
+}
+
+func TestSendCtx_PayloadJustUnderLimit(t *testing.T) {
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+
+	envelope := "test-envelope"
+	overhead, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: ""})
+	padding := strings.Repeat("x", maxResponseSize-len(overhead)-1)
+
+	// Verify we're one byte under.
+	under, _ := json.Marshal(Response{EnvelopeID: envelope, Payload: padding})
+	assert.Equal(t, maxResponseSize-1, len(under), "test setup: payload should serialize to exactly %d bytes", maxResponseSize-1)
+
+	err := cli.SendCtx(context.Background(), Response{
+		EnvelopeID: envelope,
+		Payload:    padding,
+	})
+	assert.NoError(t, err)
+
+	select {
+	case res := <-cli.socketModeResponses:
+		assert.Equal(t, envelope, res.EnvelopeID)
+	default:
+		t.Fatal("expected response to be queued on socketModeResponses channel")
+	}
+}
+
+func TestSendCtx_MarshalError(t *testing.T) {
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+
+	err := cli.SendCtx(context.Background(), Response{
+		EnvelopeID: "test-envelope",
+		Payload:    func() {},
+	})
+
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "marshalling socket mode response")
+}
+
+func TestAck_ReturnsNoError(t *testing.T) {
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+
+	err := cli.Ack(Request{EnvelopeID: "test-envelope"}, "payload")
+	assert.NoError(t, err)
+
+	select {
+	case res := <-cli.socketModeResponses:
+		assert.Equal(t, "test-envelope", res.EnvelopeID)
+	default:
+		t.Fatal("expected response to be queued on socketModeResponses channel")
+	}
+}
+
+func TestAck_ReturnsErrorWhenPayloadTooLarge(t *testing.T) {
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+
+	largePayload := strings.Repeat("x", maxResponseSize)
+	err := cli.Ack(Request{EnvelopeID: "test-envelope"}, largePayload)
+
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "silently dropped")
+}
+
+// dialTestWebSocket starts a slacktest.Server with a custom /ws handler,
+// then returns a client-side *websocket.Conn connected to it.
+func dialTestWebSocket(t *testing.T, serverFunc func(conn *websocket.Conn)) *websocket.Conn {
+	t.Helper()
+	srv := slacktest.NewTestServer(func(c slacktest.Customize) {
+		c.Handle("/ws", slacktest.Websocket(serverFunc))
+	})
+	srv.Start()
+	t.Cleanup(srv.Stop)
+
+	conn, _, err := websocket.DefaultDialer.Dial(srv.GetWSURL(), nil)
+	if err != nil {
+		t.Fatalf("dial: %v", err)
+	}
+	t.Cleanup(func() { conn.Close() })
+	return conn
+}
+
+func TestReceiveMessagesInto_WebSocketCloseError(t *testing.T) {
+	conn := dialTestWebSocket(t, func(srvConn *websocket.Conn) {
+		// Cleanly close the server side to trigger a CloseError on the client.
+		srvConn.WriteMessage(
+			websocket.CloseMessage,
+			websocket.FormatCloseMessage(websocket.CloseGoingAway, "bye"),
+		)
+	})
+
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+	sink := make(chan json.RawMessage, 1)
+
+	err := cli.receiveMessagesInto(context.Background(), conn, sink)
+
+	// WebSocket close errors SHOULD force a reconnect (err != nil).
+	assert.Error(t, err)
+}
+
+// TestRunMessageReceiver_SurvivesMalformedJSON demonstrates the full message
+// receiver loop: valid messages are forwarded, malformed JSON produces an error
+// event without dropping the connection, and a WebSocket close terminates the
+// loop so the caller can reconnect.
+func TestRunMessageReceiver_SurvivesMalformedJSON(t *testing.T) {
+	ready := make(chan struct{})
+	conn := dialTestWebSocket(t, func(srvConn *websocket.Conn) {
+		// Wait for the receiver loop to start before sending.
+		<-ready
+
+		// 1. Valid JSON message
+		srvConn.WriteMessage(websocket.TextMessage, []byte(`{"type":"hello"}`))
+		// 2. Malformed JSON — should NOT kill the connection
+		srvConn.WriteMessage(websocket.TextMessage, []byte(`{not json`))
+		// 3. Another valid message — proves the connection survived
+		srvConn.WriteMessage(websocket.TextMessage, []byte(`{"type":"disconnect"}`))
+		// 4. Close the WebSocket — should terminate the loop
+		srvConn.WriteMessage(
+			websocket.CloseMessage,
+			websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"),
+		)
+	})
+
+	api := slack.New("ABCDEFG")
+	cli := New(api)
+	sink := make(chan json.RawMessage, 10)
+
+	// Run the receiver loop in a goroutine.
+	loopErr := make(chan error, 1)
+	go func() {
+		close(ready)
+		loopErr <- cli.runMessageReceiver(context.Background(), conn, sink)
+	}()
+
+	// Wait for the loop to finish.
+	var err error
+	select {
+	case err = <-loopErr:
+	case <-time.After(5 * time.Second):
+		t.Fatal("timed out waiting for receiver loop to finish")
+	}
+
+	// Drain all messages that made it through to the sink.
+	var messages []string
+	for len(sink) > 0 {
+		messages = append(messages, string(<-sink))
+	}
+
+	// The loop should exit with an error (WebSocket close triggers reconnect).
+	assert.Error(t, err)
+
+	// Both valid messages should have been forwarded.
+	assert.Equal(t, []string{`{"type":"hello"}`, `{"type":"disconnect"}`}, messages)
+
+	// The malformed JSON should have produced an IncomingError event.
+	select {
+	case evt := <-cli.Events:
+		assert.Equal(t, EventTypeIncomingError, evt.Type)
+		incomingErr, ok := evt.Data.(*slack.IncomingEventError)
+		if assert.True(t, ok) {
+			var syntaxErr *json.SyntaxError
+			assert.ErrorAs(t, incomingErr.ErrorObj, &syntaxErr)
+		}
+	default:
+		t.Fatal("expected an IncomingError event for the malformed JSON")
+	}
+}
diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go
index d91d422b1..86445cb06 100644
--- a/socketmode/socketmode.go
+++ b/socketmode/socketmode.go
@@ -6,9 +6,9 @@ import (
 	"os"
 	"time"
 
-	"github.com/slack-go/slack"
-
 	"github.com/gorilla/websocket"
+
+	"github.com/slack-go/slack"
 )
 
 // EventType is the type of events that are emitted by scoketmode.Client.
@@ -49,9 +49,32 @@ const (
 
 	websocketDefaultTimeout = 10 * time.Second
 	defaultMaxPingInterval  = 30 * time.Second
+	defaultHandshakeTimeout = 45 * time.Second
+
+	// maxResponseSize is Slack's server-side limit for Socket Mode WebSocket response
+	// messages (20KB). Responses at or above this size on the wire are silently dropped
+	// by Slack — the WebSocket write succeeds but Slack ignores the payload. Empirically
+	// verified using both this library and Slack's official Node SDK
+	// (@slack/socket-mode).
+	//
+	// Note: gorilla/websocket's WriteJSON adds a trailing newline (via
+	// json.Encoder.Encode), so the on-wire size is json.Marshal(response) + 1. The
+	// pre-flight check in SendCtx uses >= maxResponseSize on the json.Marshal output,
+	// which is equivalent to > maxResponseSize on the wire minus the newline —
+	// conservative by exactly 1 byte.
+	maxResponseSize = 20 * 1024 // 20480 bytes
+
+	// defaultWriteBufferSize is the WebSocket write buffer size used when dialing Slack's
+	// Socket Mode endpoint. gorilla/websocket's default is 4096 bytes; messages exceeding
+	// the buffer are split into WebSocket continuation frames (I think). Slack's server
+	// does not reassemble continuation frames, causing silent message drops (this is my
+	// hypothesis from testing). We set this above Slack's 20KB response limit to ensure
+	// all valid messages are sent as single frames.
+	defaultWriteBufferSize = 32 * 1024
 )
 
-// Open calls the "apps.connections.open" endpoint and returns the provided URL and the full Info block.
+// Open calls the "apps.connections.open" endpoint and returns the provided URL and the
+// full Info block.
 //
 // To have a fully managed Websocket connection, use `New`, and call `Run()` on it.
 func (smc *Client) Open() (info *slack.SocketModeConnection, websocketURL string, err error) {
@@ -61,7 +84,8 @@ func (smc *Client) Open() (info *slack.SocketModeConnection, websocketURL string
 	return smc.StartSocketModeContext(ctx)
 }
 
-// OpenContext calls the "apps.connections.open" endpoint and returns the provided URL and the full Info block.
+// OpenContext calls the "apps.connections.open" endpoint and returns the provided URL and
+// the full Info block.
 //
 // To have a fully managed Websocket connection, use `New`, and call `Run()` on it.
 func (smc *Client) OpenContext(ctx context.Context) (info *slack.SocketModeConnection, websocketURL string, err error) {
@@ -119,3 +143,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
new file mode 100644
index 000000000..330a644ce
--- /dev/null
+++ b/socketmode/socketmode_handler.go
@@ -0,0 +1,343 @@
+package socketmode
+
+import (
+	"context"
+
+	"github.com/slack-go/slack"
+	"github.com/slack-go/slack/slackevents"
+)
+
+type SocketmodeHandler struct {
+	Client *Client
+
+	// level 1 - the most generic type of event
+	EventMap map[EventType][]SocketmodeHandlerFunc
+
+	// level 2 - Manage event by inner type
+	InteractionEventMap map[slack.InteractionType][]SocketmodeHandlerFunc
+	EventApiMap         map[slackevents.EventsAPIType][]SocketmodeHandlerFunc
+
+	// level 3 - the most user friendly way of managing event
+	InteractionBlockActionEventMap    map[string]SocketmodeHandlerFunc
+	InteractionShortcutEventMap       map[string]SocketmodeHandlerFunc
+	InteractionViewSubmissionEventMap map[string]SocketmodeHandlerFunc
+	InteractionViewClosedEventMap     map[string]SocketmodeHandlerFunc
+	SlashCommandMap                   map[string]SocketmodeHandlerFunc
+
+	Default SocketmodeHandlerFunc
+}
+
+// Handler have access to the event and socketmode client
+type SocketmodeHandlerFunc func(*Event, *Client)
+
+// Middleware accept SocketmodeHandlerFunc, and return SocketmodeHandlerFunc
+type SocketmodeMiddlewareFunc func(SocketmodeHandlerFunc) SocketmodeHandlerFunc
+
+// Initialization constructor for SocketmodeHandler
+func NewSocketmodeHandler(client *Client) *SocketmodeHandler {
+	eventMap := make(map[EventType][]SocketmodeHandlerFunc)
+	interactionEventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc)
+	eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc)
+
+	interactionBlockActionEventMap := make(map[string]SocketmodeHandlerFunc)
+	shortcutMap := make(map[string]SocketmodeHandlerFunc)
+	viewSubmissionMap := make(map[string]SocketmodeHandlerFunc)
+	viewClosedMap := make(map[string]SocketmodeHandlerFunc)
+	slackCommandMap := make(map[string]SocketmodeHandlerFunc)
+
+	return &SocketmodeHandler{
+		Client:                            client,
+		EventMap:                          eventMap,
+		EventApiMap:                       eventApiMap,
+		InteractionEventMap:               interactionEventMap,
+		InteractionBlockActionEventMap:    interactionBlockActionEventMap,
+		InteractionShortcutEventMap:       shortcutMap,
+		InteractionViewSubmissionEventMap: viewSubmissionMap,
+		InteractionViewClosedEventMap:     viewClosedMap,
+		SlashCommandMap:                   slackCommandMap,
+		Default: func(e *Event, c *Client) {
+			c.log.Printf("Unexpected event type received: %v\n", e.Type)
+		},
+	}
+}
+
+// Register a middleware or handler for an Event from socketmode
+// This most general entrypoint
+func (r *SocketmodeHandler) Handle(et EventType, f SocketmodeHandlerFunc) {
+	r.EventMap[et] = append(r.EventMap[et], f)
+}
+
+// Register a middleware or handler for an Interaction
+// There is several types of interactions, dedicated functions lets you better handle them
+// See
+// * HandleInteractionBlockAction
+// * HandleShortcut
+// * HandleViewSubmission
+// * HandleViewClosed
+func (r *SocketmodeHandler) HandleInteraction(et slack.InteractionType, f SocketmodeHandlerFunc) {
+	r.InteractionEventMap[et] = append(r.InteractionEventMap[et], f)
+}
+
+// Register a middleware or handler for a Block Action referenced by its ActionID
+func (r *SocketmodeHandler) HandleInteractionBlockAction(actionID string, f SocketmodeHandlerFunc) {
+	if actionID == "" {
+		panic("invalid command cannot be empty")
+	}
+	if f == nil {
+		panic("invalid handler cannot be nil")
+	}
+	if _, exist := r.InteractionBlockActionEventMap[actionID]; exist {
+		panic("multiple registrations for actionID" + actionID)
+	}
+	r.InteractionBlockActionEventMap[actionID] = f
+}
+
+// Register a middleware or handler for a Shortcut (global or message) referenced by its CallbackID
+func (r *SocketmodeHandler) HandleShortcut(callbackID string, f SocketmodeHandlerFunc) {
+	if callbackID == "" {
+		panic("invalid callbackID cannot be empty")
+	}
+	if f == nil {
+		panic("invalid handler cannot be nil")
+	}
+	if _, exist := r.InteractionShortcutEventMap[callbackID]; exist {
+		panic("multiple registrations for callbackID " + callbackID)
+	}
+	r.InteractionShortcutEventMap[callbackID] = f
+}
+
+// Register a middleware or handler for a View Submission referenced by its CallbackID
+func (r *SocketmodeHandler) HandleViewSubmission(callbackID string, f SocketmodeHandlerFunc) {
+	if callbackID == "" {
+		panic("invalid callbackID cannot be empty")
+	}
+	if f == nil {
+		panic("invalid handler cannot be nil")
+	}
+	if _, exist := r.InteractionViewSubmissionEventMap[callbackID]; exist {
+		panic("multiple registrations for callbackID " + callbackID)
+	}
+	r.InteractionViewSubmissionEventMap[callbackID] = f
+}
+
+// Register a middleware or handler for a View Closed event referenced by its CallbackID
+func (r *SocketmodeHandler) HandleViewClosed(callbackID string, f SocketmodeHandlerFunc) {
+	if callbackID == "" {
+		panic("invalid callbackID cannot be empty")
+	}
+	if f == nil {
+		panic("invalid handler cannot be nil")
+	}
+	if _, exist := r.InteractionViewClosedEventMap[callbackID]; exist {
+		panic("multiple registrations for callbackID " + callbackID)
+	}
+	r.InteractionViewClosedEventMap[callbackID] = f
+}
+
+// Register a middleware or handler for an Event (from slackevents)
+func (r *SocketmodeHandler) HandleEvents(et slackevents.EventsAPIType, f SocketmodeHandlerFunc) {
+	r.EventApiMap[et] = append(r.EventApiMap[et], f)
+}
+
+// Register a middleware or handler for a Slash Command
+func (r *SocketmodeHandler) HandleSlashCommand(command string, f SocketmodeHandlerFunc) {
+	if command == "" {
+		panic("invalid command cannot be empty")
+	}
+	if f == nil {
+		panic("invalid handler cannot be nil")
+	}
+	if _, exist := r.SlashCommandMap[command]; exist {
+		panic("multiple registrations for command" + command)
+	}
+	r.SlashCommandMap[command] = f
+}
+
+// Register a middleware or handler to use as a last resort
+func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) {
+	r.Default = f
+}
+
+// RunSlackEventLoop receives the event via the socket
+func (r *SocketmodeHandler) RunEventLoop() error {
+
+	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 incoming event
+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
+		}
+	}
+}
+
+// DispatchEvent routes an event to the appropriate registered handlers. Handlers are
+// invoked asynchronously in goroutines, matching the behavior of RunEventLoop. This
+// method is useful for integration testing handler registrations without a WebSocket
+// connection.
+//
+// NOTE: This method does not implement the same dispatching logic as RunEventLoop. It
+// should only be used for testing purposes, and not as a general-purpose event
+// dispatcher.
+func (r *SocketmodeHandler) DispatchEvent(evt Event) {
+	var ishandled bool
+
+	// Some eventType can be further decomposed
+	switch evt.Type {
+	case EventTypeInteractive:
+		ishandled = r.interactionDispatcher(&evt)
+	case EventTypeEventsAPI:
+		ishandled = r.eventAPIDispatcher(&evt)
+	case EventTypeSlashCommand:
+		ishandled = r.slashCommandDispatcher(&evt)
+	default:
+		ishandled = r.socketmodeDispatcher(&evt)
+	}
+
+	if !ishandled {
+		go r.Default(&evt, r.Client)
+	}
+}
+
+func (r *SocketmodeHandler) dispatcher(evt Event) {
+	r.DispatchEvent(evt)
+}
+
+// Dispatch socketmode events to the registered middleware
+func (r *SocketmodeHandler) socketmodeDispatcher(evt *Event) bool {
+	if handlers, ok := r.EventMap[evt.Type]; ok {
+		// If we registered an event
+		for _, f := range handlers {
+			go f(evt, r.Client)
+		}
+
+		return true
+	}
+
+	return false
+}
+
+// Dispatch interactions to the registered middleware
+func (r *SocketmodeHandler) interactionDispatcher(evt *Event) bool {
+	var ishandled bool = false
+
+	interaction, ok := evt.Data.(slack.InteractionCallback)
+	if !ok {
+		r.Client.log.Printf("Ignored %+v\n", evt)
+		return false
+	}
+
+	// Level 1 - socketmode EventType
+	ishandled = r.socketmodeDispatcher(evt)
+
+	// Level 2 - interaction EventType
+	if handlers, ok := r.InteractionEventMap[interaction.Type]; ok {
+		// If we registered an event
+		for _, f := range handlers {
+			go f(evt, r.Client)
+		}
+
+		ishandled = true
+	}
+
+	// Level 3 - interaction with actionID or callbackID
+	switch interaction.Type {
+	case slack.InteractionTypeBlockActions:
+		blockActions := interaction.ActionCallback.BlockActions
+		// outmoded approach won`t be implemented
+		// attachments_actions := interaction.ActionCallback.AttachmentActions
+
+		for _, action := range blockActions {
+			if handler, ok := r.InteractionBlockActionEventMap[action.ActionID]; ok {
+				go handler(evt, r.Client)
+				ishandled = true
+			}
+		}
+	case slack.InteractionTypeShortcut, slack.InteractionTypeMessageAction:
+		if handler, ok := r.InteractionShortcutEventMap[interaction.CallbackID]; ok {
+			go handler(evt, r.Client)
+			ishandled = true
+		}
+	case slack.InteractionTypeViewSubmission:
+		if handler, ok := r.InteractionViewSubmissionEventMap[interaction.View.CallbackID]; ok {
+			go handler(evt, r.Client)
+			ishandled = true
+		}
+	case slack.InteractionTypeViewClosed:
+		if handler, ok := r.InteractionViewClosedEventMap[interaction.View.CallbackID]; ok {
+			go handler(evt, r.Client)
+			ishandled = true
+		}
+	}
+
+	return ishandled
+}
+
+// Dispatch eventAPI events to the registered middleware
+func (r *SocketmodeHandler) eventAPIDispatcher(evt *Event) bool {
+	var ishandled bool = false
+	eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
+	if !ok {
+		r.Client.log.Printf("Ignored %+v\n", evt)
+		return false
+	}
+
+	innerEventType := slackevents.EventsAPIType(eventsAPIEvent.InnerEvent.Type)
+
+	// Level 1 - socketmode EventType
+	ishandled = r.socketmodeDispatcher(evt)
+
+	// Level 2 - EventAPI EventType
+	if handlers, ok := r.EventApiMap[innerEventType]; ok {
+		// If we registered an event
+		for _, f := range handlers {
+			go f(evt, r.Client)
+		}
+
+		ishandled = true
+	}
+
+	return ishandled
+}
+
+// Dispatch SlashCommands events to the registered middleware
+func (r *SocketmodeHandler) slashCommandDispatcher(evt *Event) bool {
+	var ishandled bool = false
+	slashCommandEvent, ok := evt.Data.(slack.SlashCommand)
+	if !ok {
+		r.Client.log.Printf("Ignored %+v\n", evt)
+		return false
+	}
+
+	// Level 1 - socketmode EventType
+	ishandled = r.socketmodeDispatcher(evt)
+
+	// Level 2 - SlackCommand by name
+	if handler, ok := r.SlashCommandMap[slashCommandEvent.Command]; ok {
+
+		go handler(evt, r.Client)
+
+		ishandled = true
+	}
+
+	return ishandled
+
+}
diff --git a/socketmode/socketmode_handler_test.go b/socketmode/socketmode_handler_test.go
new file mode 100644
index 000000000..9aaf4c08c
--- /dev/null
+++ b/socketmode/socketmode_handler_test.go
@@ -0,0 +1,808 @@
+package socketmode
+
+import (
+	"log"
+	"os"
+	"reflect"
+	"runtime"
+	"testing"
+
+	"github.com/slack-go/slack"
+	"github.com/slack-go/slack/slackevents"
+)
+
+func init_SocketmodeHandler() *SocketmodeHandler {
+	eventMap := make(map[EventType][]SocketmodeHandlerFunc)
+	interactioneventMap := make(map[slack.InteractionType][]SocketmodeHandlerFunc)
+	eventApiMap := make(map[slackevents.EventsAPIType][]SocketmodeHandlerFunc)
+	interactionBlockActionEventMap := make(map[string]SocketmodeHandlerFunc)
+	shortcutMap := make(map[string]SocketmodeHandlerFunc)
+	viewSubmissionMap := make(map[string]SocketmodeHandlerFunc)
+	viewClosedMap := make(map[string]SocketmodeHandlerFunc)
+	slashCommandMap := make(map[string]SocketmodeHandlerFunc)
+
+	return &SocketmodeHandler{
+		Client: &Client{
+			log: log.New(os.Stderr, "slack-go/slack/socketmode", log.LstdFlags|log.Lshortfile),
+		},
+		EventMap:                          eventMap,
+		EventApiMap:                       eventApiMap,
+		InteractionEventMap:               interactioneventMap,
+		InteractionBlockActionEventMap:    interactionBlockActionEventMap,
+		InteractionShortcutEventMap:       shortcutMap,
+		InteractionViewSubmissionEventMap: viewSubmissionMap,
+		InteractionViewClosedEventMap:     viewClosedMap,
+		SlashCommandMap:                   slashCommandMap,
+	}
+}
+
+// The goal of this function is to catch the name of the function that is behing called
+// This let us validate that the dispatcher did its job correctly
+func testing_wrapper(ch chan<- string, f SocketmodeHandlerFunc) SocketmodeHandlerFunc {
+	return SocketmodeHandlerFunc(func(e *Event, c *Client) {
+		f(e, c)
+
+		var name_f string
+
+		// test with the name of the function we called
+		v := reflect.ValueOf(f)
+		if v.Kind() == reflect.Func {
+			if rf := runtime.FuncForPC(v.Pointer()); rf != nil {
+				name_f = rf.Name()
+			}
+		} else {
+			name_f = v.String()
+		}
+
+		ch <- name_f
+	})
+}
+
+func middleware_interaction(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware_interaction_block_action(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware_eventapi(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware(evt *Event, client *Client) {
+	// do nothing
+}
+
+func defaultmiddleware(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware_slach_command(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware_shortcut(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware_view_submission(evt *Event, client *Client) {
+	// do nothing
+}
+
+func middleware_view_closed(evt *Event, client *Client) {
+	// do nothing
+}
+
+func TestSocketmodeHandler_Handle(t *testing.T) {
+	type args struct {
+		evt      Event
+		evt_type EventType
+	}
+	tests := []struct {
+		name string
+		args args
+		want string // the name of the function we want to be called
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeConnecting,
+				},
+				evt_type: EventTypeConnecting,
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware",
+		}, {
+			name: "Event do not registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeConnected,
+				},
+				evt_type: EventTypeConnecting,
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			r.Handle(tt.args.evt_type, testing_wrapper(c, middleware))
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("middleware was not called for EventTy(\"%v\"), got %v", tt.args.evt_type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleInteraction(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string // the name of the function we want to be called
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeBlockActions,
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteraction(slack.InteractionTypeBlockActions, testing_wrapper(c, middleware_interaction))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_interaction",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeBlockActions,
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteraction(slack.InteractionTypeBlockSuggestion, testing_wrapper(c, middleware_interaction))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		}, {
+			name: "Event with invalid data is handled by default middleware",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: map[string]string{
+						"brokendata": "test",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteraction(slack.InteractionTypeBlockActions, testing_wrapper(c, middleware_interaction))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		}, {
+			name: "Event is handled as EventTypeInteractive",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeBlockActions,
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.Handle(EventTypeInteractive, testing_wrapper(c, middleware))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleEvents(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string // the name of the function we want to be called
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeEventsAPI,
+					Data: slackevents.EventsAPIEvent{
+						Type: "event_callback",
+						InnerEvent: slackevents.EventsAPIInnerEvent{
+							Type: string(slackevents.AppMention),
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleEvents(slackevents.AppMention, testing_wrapper(c, middleware_eventapi))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_eventapi",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeEventsAPI,
+					Data: slackevents.EventsAPIEvent{
+						Type: "event_callback",
+						InnerEvent: slackevents.EventsAPIInnerEvent{
+							Type: string(slackevents.MemberJoinedChannel),
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleEvents(slackevents.AppMention, testing_wrapper(c, middleware_eventapi))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		}, {
+			name: "Event with invalid data is handled by default middleware",
+			args: args{
+				evt: Event{
+					Type: EventTypeEventsAPI,
+					Data: map[string]string{
+						"brokendata": "test",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleEvents(slackevents.AppMention, testing_wrapper(c, middleware_eventapi))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		}, {
+			name: "Event is handled as EventTypeInteractive",
+			args: args{
+				evt: Event{
+					Type: EventTypeEventsAPI,
+					Data: slackevents.EventsAPIEvent{
+						Type: "event_callback",
+						InnerEvent: slackevents.EventsAPIInnerEvent{
+							Type: string(slackevents.MemberJoinedChannel),
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.Handle(EventTypeEventsAPI, testing_wrapper(c, middleware))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleInteractionBlockAction(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string // the name of the function we want to be called
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeBlockActions,
+						ActionCallback: slack.ActionCallbacks{
+							BlockActions: []*slack.BlockAction{
+								{
+									ActionID: "add_note",
+									Text: slack.TextBlockObject{
+										Type: "plain_text",
+										Text: "Add a Stickie",
+									},
+								},
+							},
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteractionBlockAction("add_note", testing_wrapper(c, middleware_interaction_block_action))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_interaction_block_action",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeBlockActions,
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteractionBlockAction("add_note", testing_wrapper(c, middleware_interaction_block_action))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleSlashCommand(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string // the name of the function we want to be called
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeSlashCommand,
+					Data: slack.SlashCommand{
+						Command: "/rocket",
+						Text:    "key=value",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleSlashCommand("/rocket", testing_wrapper(c, middleware_slach_command))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_slach_command",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeSlashCommand,
+					Data: slack.SlashCommand{
+						Command: "/broken_rocket",
+						Text:    "key=value",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleSlashCommand("/rocket", testing_wrapper(c, middleware_slach_command))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleShortcut(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{
+		{
+			name: "Global shortcut matches registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type:       slack.InteractionTypeShortcut,
+						CallbackID: "open_ticket",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleShortcut("open_ticket", testing_wrapper(c, middleware_shortcut))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_shortcut",
+		}, {
+			name: "Message action matches registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type:       slack.InteractionTypeMessageAction,
+						CallbackID: "save_message",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleShortcut("save_message", testing_wrapper(c, middleware_shortcut))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_shortcut",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type:       slack.InteractionTypeShortcut,
+						CallbackID: "other_shortcut",
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleShortcut("open_ticket", testing_wrapper(c, middleware_shortcut))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleViewSubmission(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeViewSubmission,
+						View: slack.View{
+							CallbackID: "create_ticket",
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewSubmission("create_ticket", testing_wrapper(c, middleware_view_submission))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_view_submission",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeViewSubmission,
+						View: slack.View{
+							CallbackID: "other_modal",
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewSubmission("create_ticket", testing_wrapper(c, middleware_view_submission))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_HandleViewClosed(t *testing.T) {
+	type args struct {
+		evt      Event
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{
+		{
+			name: "Event Match registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeViewClosed,
+						View: slack.View{
+							CallbackID: "create_ticket",
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewClosed("create_ticket", testing_wrapper(c, middleware_view_closed))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.middleware_view_closed",
+		}, {
+			name: "Event do not Match any registered function",
+			args: args{
+				evt: Event{
+					Type: EventTypeInteractive,
+					Data: slack.InteractionCallback{
+						Type: slack.InteractionTypeViewClosed,
+						View: slack.View{
+							CallbackID: "other_modal",
+						},
+					},
+				},
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewClosed("create_ticket", testing_wrapper(c, middleware_view_closed))
+				},
+			},
+			want: "github.com/slack-go/slack/socketmode.defaultmiddleware",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			tt.args.register(r, c)
+			r.HandleDefault(testing_wrapper(c, defaultmiddleware))
+
+			r.dispatcher(tt.args.evt)
+
+			got := <-c
+
+			if got != tt.want {
+				t.Fatalf("%s was not called for EventTy(\"%v\"), got %v", tt.want, tt.args.evt.Type, got)
+			}
+		})
+	}
+}
+
+func TestSocketmodeHandler_Handle_errors(t *testing.T) {
+	type args struct {
+		register func(*SocketmodeHandler, chan<- string)
+	}
+	tests := []struct {
+		name string
+		args args
+	}{
+		{
+			name: "Attempt to register empty command",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleSlashCommand("", testing_wrapper(c, middleware_slach_command))
+				},
+			},
+		}, {
+			name: "Attempt to register nil handler",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleSlashCommand("/command", nil)
+				},
+			},
+		}, {
+			name: "Attempt to register duplicate command",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleSlashCommand("/command", testing_wrapper(c, middleware_slach_command))
+					r.HandleSlashCommand("/command", testing_wrapper(c, middleware_slach_command))
+				},
+			},
+		}, {
+			name: "Attempt to register empty Block ActionID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteractionBlockAction("", testing_wrapper(c, middleware_interaction_block_action))
+				},
+			},
+		}, {
+			name: "Attempt to register nil handler",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteractionBlockAction("action_id", nil)
+				},
+			},
+		}, {
+			name: "Attempt to register duplicate Block ActionID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleInteractionBlockAction("action_id", testing_wrapper(c, middleware_interaction_block_action))
+					r.HandleInteractionBlockAction("action_id", testing_wrapper(c, middleware_interaction_block_action))
+				},
+			},
+		}, {
+			name: "Attempt to register empty Shortcut callbackID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleShortcut("", testing_wrapper(c, middleware_shortcut))
+				},
+			},
+		}, {
+			name: "Attempt to register nil Shortcut handler",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleShortcut("callback_id", nil)
+				},
+			},
+		}, {
+			name: "Attempt to register duplicate Shortcut callbackID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleShortcut("callback_id", testing_wrapper(c, middleware_shortcut))
+					r.HandleShortcut("callback_id", testing_wrapper(c, middleware_shortcut))
+				},
+			},
+		}, {
+			name: "Attempt to register empty ViewSubmission callbackID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewSubmission("", testing_wrapper(c, middleware_view_submission))
+				},
+			},
+		}, {
+			name: "Attempt to register nil ViewSubmission handler",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewSubmission("callback_id", nil)
+				},
+			},
+		}, {
+			name: "Attempt to register duplicate ViewSubmission callbackID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewSubmission("callback_id", testing_wrapper(c, middleware_view_submission))
+					r.HandleViewSubmission("callback_id", testing_wrapper(c, middleware_view_submission))
+				},
+			},
+		}, {
+			name: "Attempt to register empty ViewClosed callbackID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewClosed("", testing_wrapper(c, middleware_view_closed))
+				},
+			},
+		}, {
+			name: "Attempt to register nil ViewClosed handler",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewClosed("callback_id", nil)
+				},
+			},
+		}, {
+			name: "Attempt to register duplicate ViewClosed callbackID",
+			args: args{
+				register: func(r *SocketmodeHandler, c chan<- string) {
+					r.HandleViewClosed("callback_id", testing_wrapper(c, middleware_view_closed))
+					r.HandleViewClosed("callback_id", testing_wrapper(c, middleware_view_closed))
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := init_SocketmodeHandler()
+
+			c := make(chan string)
+
+			defer func() { recover() }()
+
+			tt.args.register(r, c)
+
+			t.Errorf("should have panicked")
+
+		})
+	}
+}
diff --git a/socketmode/socketmode_test.go b/socketmode/socketmode_test.go
index 015a34604..80c984c53 100644
--- a/socketmode/socketmode_test.go
+++ b/socketmode/socketmode_test.go
@@ -7,6 +7,7 @@ import (
 	"reflect"
 	"testing"
 
+	"github.com/slack-go/slack"
 	"github.com/slack-go/slack/slackevents"
 )
 
@@ -228,15 +229,38 @@ func TestEventParsing(t *testing.T) {
 					EventContext: "1-app_mention-redacted-redacted",
 				},
 				InnerEvent: slackevents.EventsAPIInnerEvent{
-					Type: slackevents.AppMention,
+					Type: string(slackevents.AppMention),
 					Data: &slackevents.AppMentionEvent{
-						Type:            slackevents.AppMention,
+						Type:            string(slackevents.AppMention),
 						User:            "redacted",
 						Text:            "<@U01JKSB8T7Y> test",
 						TimeStamp:       "1610927831.000200",
 						ThreadTimeStamp: "",
 						Channel:         "redacted",
-						EventTimeStamp:  json.Number("1610927831.000200"),
+						EventTimeStamp:  "1610927831.000200",
+						Blocks: slack.Blocks{
+							BlockSet: []slack.Block{
+								&slack.RichTextBlock{
+									Type:    slack.MBTRichText,
+									BlockID: "2Le",
+									Elements: []slack.RichTextElement{
+										&slack.RichTextSection{
+											Type: slack.RTESection,
+											Elements: []slack.RichTextSectionElement{
+												&slack.RichTextSectionUserElement{
+													Type:   slack.RTSEUser,
+													UserID: "redacted",
+												},
+												&slack.RichTextSectionTextElement{
+													Type: slack.RTSEText,
+													Text: " test39",
+												},
+											},
+										},
+									},
+								},
+							},
+						},
 					},
 				},
 			},
@@ -251,7 +275,7 @@ func TestEventParsing(t *testing.T) {
 		})
 }
 
-func testParsing(t *testing.T, raw string, want interface{}) {
+func testParsing(t *testing.T, raw string, want any) {
 	t.Helper()
 
 	got, err := parse(raw)
@@ -264,7 +288,7 @@ func testParsing(t *testing.T, raw string, want interface{}) {
 	}
 }
 
-func dump(t *testing.T, data interface{}) string {
+func dump(t *testing.T, data any) string {
 	t.Helper()
 
 	var buf bytes.Buffer
diff --git a/stars.go b/stars.go
index 529676048..0adb28c53 100644
--- a/stars.go
+++ b/stars.go
@@ -8,40 +8,39 @@ import (
 )
 
 const (
-	DEFAULT_STARS_USER  = ""
-	DEFAULT_STARS_COUNT = 100
-	DEFAULT_STARS_PAGE  = 1
+	DEFAULT_STARS_USER = ""
 )
 
 type StarsParameters struct {
-	User  string
-	Count int
-	Page  int
+	User   string
+	Cursor string
+	Limit  int
+	TeamID string
 }
 
 type StarredItem Item
 
 type listResponseFull struct {
-	Items  []Item `json:"items"`
-	Paging `json:"paging"`
+	Items []Item `json:"items"`
 	SlackResponse
+	ResponseMetadata `json:"response_metadata"`
 }
 
 // NewStarsParameters initialises StarsParameters with default values
 func NewStarsParameters() StarsParameters {
 	return StarsParameters{
-		User:  DEFAULT_STARS_USER,
-		Count: DEFAULT_STARS_COUNT,
-		Page:  DEFAULT_STARS_PAGE,
+		User: DEFAULT_STARS_USER,
 	}
 }
 
-// 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 +64,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,70 +95,75 @@ func (api *Client) RemoveStarContext(ctx context.Context, channel string, item I
 	return response.Err()
 }
 
-// ListStars returns information about the stars a user added
-func (api *Client) ListStars(params StarsParameters) ([]Item, *Paging, error) {
+// ListStars returns information about the stars a user added.
+// For more information see the ListStarsContext documentation.
+func (api *Client) ListStars(params StarsParameters) ([]Item, string, error) {
 	return api.ListStarsContext(context.Background(), params)
 }
 
-// ListStarsContext returns information about the stars a user added with a custom context
-func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) ([]Item, *Paging, error) {
+// 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, string, error) {
 	values := url.Values{
 		"token": {api.token},
 	}
 	if params.User != DEFAULT_STARS_USER {
 		values.Add("user", params.User)
 	}
-	if params.Count != DEFAULT_STARS_COUNT {
-		values.Add("count", strconv.Itoa(params.Count))
+	if params.Cursor != "" {
+		values.Add("cursor", params.Cursor)
+	}
+	if params.Limit != 0 {
+		values.Add("limit", strconv.Itoa(params.Limit))
 	}
-	if params.Page != DEFAULT_STARS_PAGE {
-		values.Add("page", strconv.Itoa(params.Page))
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
 	}
 
 	response := &listResponseFull{}
 	err := api.postMethod(ctx, "stars.list", values, response)
 	if err != nil {
-		return nil, nil, err
+		return nil, "", err
 	}
 
 	if err := response.Err(); err != nil {
-		return nil, nil, err
+		return nil, "", err
 	}
 
-	return response.Items, &response.Paging, nil
+	return response.Items, response.ResponseMetadata.Cursor, nil
 }
 
 // GetStarred returns a list of StarredItem items.
 //
 // The user then has to iterate over them and figure out what they should
-// be looking at according to what is in the Type.
-//    for _, item := range items {
-//        switch c.Type {
-//        case "file_comment":
-//            log.Println(c.Comment)
-//        case "file":
-//             ...
+// be looking at according to what is in the Type:
+//
+//	for _, item := range items {
+//		switch c.Type {
+//		case "file_comment":
+//			log.Println(c.Comment)
+//		case "file":
+//			...
+//	}
 //
-//    }
 // This function still exists to maintain backwards compatibility.
-// I exposed it as returning []StarredItem, so it shall stay as StarredItem
-func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, error) {
+// I exposed it as returning []StarredItem, so it shall stay as StarredItem.
+func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, string, error) {
 	return api.GetStarredContext(context.Background(), params)
 }
 
 // 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)
+func (api *Client) GetStarredContext(ctx context.Context, params StarsParameters) ([]StarredItem, string, error) {
+	items, nextCursor, err := api.ListStarsContext(ctx, params)
 	if err != nil {
-		return nil, nil, err
+		return nil, "", err
 	}
 	starredItems := make([]StarredItem, len(items))
 	for i, item := range items {
 		starredItems[i] = StarredItem(item)
 	}
-	return starredItems, paging, nil
+	return starredItems, nextCursor, nil
 }
 
 type listResponsePaginated struct {
diff --git a/stars_test.go b/stars_test.go
index 53f0e28bc..1b53a7a26 100644
--- a/stars_test.go
+++ b/stars_test.go
@@ -28,10 +28,13 @@ func (sh *starsHandler) accumulateFormValue(k string, r *http.Request) {
 func (sh *starsHandler) handler(w http.ResponseWriter, r *http.Request) {
 	sh.accumulateFormValue("user", r)
 	sh.accumulateFormValue("count", r)
+	sh.accumulateFormValue("cursor", r)
 	sh.accumulateFormValue("channel", r)
 	sh.accumulateFormValue("file", r)
 	sh.accumulateFormValue("file_comment", r)
+	sh.accumulateFormValue("limit", r)
 	sh.accumulateFormValue("page", r)
+	sh.accumulateFormValue("team_id", r)
 	sh.accumulateFormValue("timestamp", r)
 	w.Header().Set("Content-Type", "application/json")
 	w.Write([]byte(sh.response))
@@ -187,11 +190,8 @@ func TestSlack_ListStars(t *testing.T) {
             }
         }
     ],
-    "paging": {
-        "count": 100,
-        "total": 4,
-        "page": 1,
-        "pages": 1
+    "response_metadata": {
+        "next_cursor": "dXNlcjpVMDYxTkZUVDI="
     }}`
 	want := []Item{
 		NewMessageItem("C1", &Message{Msg: Msg{
@@ -209,13 +209,14 @@ func TestSlack_ListStars(t *testing.T) {
 		wantStarred[i] = StarredItem(item)
 	}
 	wantParams := map[string]string{
-		"count": "200",
-		"page":  "2",
+		"cursor": "somecursor",
+		"limit":  "200",
 	}
+	wantCursor := "dXNlcjpVMDYxTkZUVDI="
 	params := NewStarsParameters()
-	params.Count = 200
-	params.Page = 2
-	got, paging, err := api.ListStars(params)
+	params.Cursor = "somecursor"
+	params.Limit = 200
+	got, nextCursor, err := api.ListStars(params)
 	if err != nil {
 		t.Fatalf("Unexpected error: %s", err)
 	}
@@ -231,11 +232,12 @@ func TestSlack_ListStars(t *testing.T) {
 	if !reflect.DeepEqual(rh.gotParams, wantParams) {
 		t.Errorf("Got params %#v, want %#v", rh.gotParams, wantParams)
 	}
-	if reflect.DeepEqual(paging, Paging{}) {
-		t.Errorf("Want paging data, got empty struct")
+	if nextCursor != wantCursor {
+		t.Errorf("Got cursor %q, want %q", nextCursor, wantCursor)
 	}
 	// Test GetStarred
-	gotStarred, paging, err := api.GetStarred(params)
+	rh.gotParams = make(map[string]string) // reset
+	gotStarred, nextCursor, err := api.GetStarred(params)
 	if err != nil {
 		t.Fatalf("Unexpected error: %s", err)
 	}
@@ -248,10 +250,7 @@ func TestSlack_ListStars(t *testing.T) {
 			fmt.Printf("Comment  %#v\n", item.Comment)
 		}
 	}
-	if !reflect.DeepEqual(rh.gotParams, wantParams) {
-		t.Errorf("Got params %#v, want %#v", rh.gotParams, wantParams)
-	}
-	if reflect.DeepEqual(paging, Paging{}) {
-		t.Errorf("Want paging data, got empty struct")
+	if nextCursor != wantCursor {
+		t.Errorf("Got cursor %q, want %q", nextCursor, wantCursor)
 	}
 }
diff --git a/internal/misc/misc.go b/status_code_error.go
similarity index 97%
rename from internal/misc/misc.go
rename to status_code_error.go
index eab8cdd8c..7347137aa 100644
--- a/internal/misc/misc.go
+++ b/status_code_error.go
@@ -1,4 +1,4 @@
-package misc
+package slack
 
 import (
 	"fmt"
diff --git a/team.go b/team.go
index 029e2b5bc..610de311b 100644
--- a/team.go
+++ b/team.go
@@ -6,28 +6,43 @@ import (
 	"strconv"
 )
 
-const (
-	DEFAULT_LOGINS_COUNT = 100
-	DEFAULT_LOGINS_PAGE  = 1
-)
-
 type TeamResponse struct {
 	Team TeamInfo `json:"team"`
 	SlackResponse
 }
 
 type TeamInfo struct {
-	ID          string                 `json:"id"`
-	Name        string                 `json:"name"`
-	Domain      string                 `json:"domain"`
-	EmailDomain string                 `json:"email_domain"`
-	Icon        map[string]interface{} `json:"icon"`
+	ID          string         `json:"id"`
+	Name        string         `json:"name"`
+	Domain      string         `json:"domain"`
+	EmailDomain string         `json:"email_domain"`
+	Icon        map[string]any `json:"icon"`
+}
+
+type TeamProfileResponse struct {
+	Profile TeamProfile `json:"profile"`
+	SlackResponse
+}
+
+type TeamProfile struct {
+	Fields []TeamProfileField `json:"fields"`
+}
+
+type TeamProfileField struct {
+	ID             string          `json:"id"`
+	Ordering       int             `json:"ordering"`
+	Label          string          `json:"label"`
+	Hint           string          `json:"hint"`
+	Type           string          `json:"type"`
+	PossibleValues []string        `json:"possible_values"`
+	IsHidden       bool            `json:"is_hidden"`
+	Options        map[string]bool `json:"options"`
 }
 
 type LoginResponse struct {
 	Logins []Login `json:"logins"`
-	Paging `json:"paging"`
 	SlackResponse
+	ResponseMetadata `json:"response_metadata"`
 }
 
 type Login struct {
@@ -54,16 +69,15 @@ 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
+	Cursor string
+	Limit  int
+	Before int
 }
 
 // NewAccessLogParameters provides an instance of AccessLogParameters with all the sane default values set
 func NewAccessLogParameters() AccessLogParameters {
-	return AccessLogParameters{
-		Count: DEFAULT_LOGINS_COUNT,
-		Page:  DEFAULT_LOGINS_PAGE,
-	}
+	return AccessLogParameters{}
 }
 
 func (api *Client) teamRequest(ctx context.Context, path string, values url.Values) (*TeamResponse, error) {
@@ -95,12 +109,46 @@ func (api *Client) accessLogsRequest(ctx context.Context, path string, values ur
 	return response, response.Err()
 }
 
-// GetTeamInfo gets the Team Information of the user
+func (api *Client) teamProfileRequest(ctx context.Context, path string, values url.Values) (*TeamProfileResponse, error) {
+	response := &TeamProfileResponse{}
+	err := api.postMethod(ctx, path, values, response)
+	if err != nil {
+		return nil, err
+	}
+	return response, response.Err()
+}
+
+// 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())
 }
 
-// GetTeamInfoContext gets the Team Information of the user 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)
+	}
+	values := url.Values{
+		"token": {api.token},
+	}
+	values.Add("team", team)
+	response, err := api.teamRequest(ctx, "team.info", values)
+	if err != nil {
+		return nil, err
+	}
+	return &response.Team, nil
+}
+
+// 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.
+// 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},
@@ -113,55 +161,86 @@ func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) {
 	return &response.Team, nil
 }
 
-// GetAccessLogs retrieves a page of logins according to the parameters given
-func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, *Paging, error) {
-	return api.GetAccessLogsContext(context.Background(), params)
+// 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...)
 }
 
-// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context
-func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogParameters) ([]Login, *Paging, 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 params.Count != DEFAULT_LOGINS_COUNT {
-		values.Add("count", strconv.Itoa(params.Count))
-	}
-	if params.Page != DEFAULT_LOGINS_PAGE {
-		values.Add("page", strconv.Itoa(params.Page))
+	if len(teamID) > 0 {
+		values["team_id"] = teamID
 	}
 
-	response, err := api.accessLogsRequest(ctx, "team.accessLogs", values)
+	response, err := api.teamProfileRequest(ctx, "team.profile.get", values)
 	if err != nil {
-		return nil, nil, err
+		return nil, err
 	}
-	return response.Logins, &response.Paging, nil
+	return &response.Profile, nil
 }
 
-// GetBillableInfo ...
-func (api *Client) GetBillableInfo(user string) (map[string]BillingActive, error) {
-	return api.GetBillableInfoContext(context.Background(), user)
+// 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, string, error) {
+	return api.GetAccessLogsContext(context.Background(), params)
 }
 
-// GetBillableInfoContext ...
-func (api *Client) GetBillableInfoContext(ctx context.Context, user string) (map[string]BillingActive, error) {
+// 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, string, error) {
 	values := url.Values{
 		"token": {api.token},
-		"user":  {user},
+	}
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+	if params.Cursor != "" {
+		values.Add("cursor", params.Cursor)
+	}
+	if params.Limit != 0 {
+		values.Add("limit", strconv.Itoa(params.Limit))
+	}
+	if params.Before != 0 {
+		values.Add("before", strconv.Itoa(params.Before))
 	}
 
-	return api.billableInfoRequest(ctx, "team.billableInfo", values)
+	response, err := api.accessLogsRequest(ctx, "team.accessLogs", values)
+	if err != nil {
+		return nil, "", err
+	}
+	return response.Logins, response.ResponseMetadata.Cursor, nil
 }
 
-// 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())
+type GetBillableInfoParams struct {
+	User   string
+	TeamID string
 }
 
-// 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) {
+// 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},
 	}
 
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+
+	if params.User != "" {
+		values.Add("user", params.User)
+	}
+
 	return api.billableInfoRequest(ctx, "team.billableInfo", values)
 }
diff --git a/team_test.go b/team_test.go
index ecc3769e2..925e44464 100644
--- a/team_test.go
+++ b/team_test.go
@@ -8,7 +8,7 @@ import (
 )
 
 var (
-	ErrIncorrectResponse = errors.New("Response is incorrect")
+	ErrIncorrectResponse = errors.New("response is incorrect")
 )
 
 func getTeamInfo(rw http.ResponseWriter, r *http.Request) {
@@ -18,11 +18,11 @@ func getTeamInfo(rw http.ResponseWriter, r *http.Request) {
 			"name": "notalar",
 			"domain": "notalar",
 			"icon": {
-              "image_34": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-34.png",
-              "image_44": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-44.png",
-              "image_55": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-55.png",
-              "image_default": true
-          }
+			  "image_34": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-34.png",
+			  "image_44": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-44.png",
+			  "image_55": "https://slack.global.ssl.fastly.net/66f9/img/avatars-teams/ava_0002-55.png",
+			  "image_default": true
+		  }
 		}}`)
 	rw.Write(response)
 }
@@ -54,6 +54,76 @@ func TestGetTeamInfo(t *testing.T) {
 	}
 }
 
+func getTeamProfile(rw http.ResponseWriter, r *http.Request) {
+	rw.Header().Set("Content-Type", "application/json")
+	response := []byte(`{
+		"ok":true,
+		"profile":{
+			 "fields":[
+					{
+						"id":"XXXD7KN555",
+						"ordering":2,
+						"field_name":"",
+						"label":"Skype",
+						"hint":"This will be displayed on your profile.",
+						"type":"text",
+						"possible_values":null,
+						"options":null,
+						"is_hidden":true
+				},
+				{
+					"id":"XXXGGE5AAN7",
+					"ordering":4,
+					"field_name":"title",
+					"label":"Title",
+					"hint":"",
+					"type":"text",
+					"possible_values":null,
+					"options":{
+						"is_protected":true,
+						"is_scim":true
+					},
+					"is_hidden":false
+					}
+				]
+			}
+		}`)
+
+	rw.Write(response)
+}
+
+func TestGetTeamProfile(t *testing.T) {
+	http.HandleFunc("/team.profile.get", getTeamProfile)
+
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+
+	teamProfile, err := api.GetTeamProfile()
+	if err != nil {
+		t.Errorf("Unexpected error: %s", err)
+		return
+	}
+
+	// t.Fatal refers to -> t.Errorf & return
+	if teamProfile.Fields[0].ID != "XXXD7KN555" {
+		t.Fatal(ErrIncorrectResponse)
+	}
+	if teamProfile.Fields[0].Label != "Skype" {
+		t.Fatal(ErrIncorrectResponse)
+	}
+
+	if teamProfile.Fields[1].ID != "XXXGGE5AAN7" {
+		t.Fatal(ErrIncorrectResponse)
+	}
+	if teamProfile.Fields[1].Label != "Title" {
+		t.Fatal(ErrIncorrectResponse)
+	}
+	if !teamProfile.Fields[1].Options["is_protected"] {
+		t.Fatal(ErrIncorrectResponse)
+	}
+
+}
+
 func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) {
 	rw.Header().Set("Content-Type", "application/json")
 	response := []byte(`{"ok": true, "logins": [{
@@ -65,11 +135,11 @@ func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) {
 			"ip": "127.0.0.1",
 			"user_agent": "SlackWeb/3abb0ae2380d48a9ae20c58cc624ebcd Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Slack/1.2.6 Chrome/45.0.2454.85 AtomShell/0.34.3 Safari/537.36 Slack_SSB/1.2.6",
 			"isp": "AT&T U-verse",
-                        "country": "US",
-                        "region": "IN"
-                        },
-                        {
-                        "user_id": "XUHWU0F",
+						"country": "US",
+						"region": "IN"
+						},
+						{
+						"user_id": "XUHWU0F",
 			"username": "ralaton",
 			"date_first": 1447395893,
 			"date_last": 1447395965,
@@ -77,15 +147,12 @@ func getTeamAccessLogs(rw http.ResponseWriter, r *http.Request) {
 			"ip": "192.168.0.1",
 			"user_agent": "com.tinyspeck.chatlyio/2.60 (iPhone; iOS 9.1; Scale/3.00)",
 			"isp": null,
-                        "country": null,
-                        "region": null
-                        }],
-                        "paging": {
-    			"count": 2,
-    			"total": 2,
-    			"page": 1,
-    			"pages": 1
-    			}
+						"country": null,
+						"region": null
+						}],
+						"response_metadata": {
+				"next_cursor": "dGVhbV9pZDo5MDAwMTcw"
+				}
   }`)
 	rw.Write(response)
 }
@@ -96,7 +163,10 @@ func TestGetAccessLogs(t *testing.T) {
 	once.Do(startServer)
 	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
 
-	logins, paging, err := api.GetAccessLogs(NewAccessLogParameters())
+	params := NewAccessLogParameters()
+	params.Limit = 2
+	params.TeamID = "T12345"
+	logins, nextCursor, err := api.GetAccessLogs(params)
 	if err != nil {
 		t.Errorf("Unexpected error: %s", err)
 		return
@@ -152,17 +222,8 @@ func TestGetAccessLogs(t *testing.T) {
 		t.Fatal(ErrIncorrectResponse)
 	}
 
-	// test the paging
-	if paging.Count != 2 {
-		t.Fatal(ErrIncorrectResponse)
-	}
-	if paging.Total != 2 {
-		t.Fatal(ErrIncorrectResponse)
-	}
-	if paging.Page != 1 {
-		t.Fatal(ErrIncorrectResponse)
-	}
-	if paging.Pages != 1 {
-		t.Fatal(ErrIncorrectResponse)
+	// test the cursor
+	if nextCursor != "dGVhbV9pZDo5MDAwMTcw" {
+		t.Fatalf("Expected cursor %q, got %q", "dGVhbV9pZDo5MDAwMTcw", nextCursor)
 	}
 }
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 9417f8177..b5c545457 100644
--- a/usergroups.go
+++ b/usergroups.go
@@ -3,6 +3,7 @@ package slack
 import (
 	"context"
 	"net/url"
+	"strconv"
 	"strings"
 )
 
@@ -50,18 +51,61 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url
 	return response, response.Err()
 }
 
-// CreateUserGroup creates a new user group
-func (api *Client) CreateUserGroup(userGroup UserGroup) (UserGroup, error) {
-	return api.CreateUserGroupContext(context.Background(), userGroup)
+// createUserGroupParams contains arguments for CreateUserGroup method call
+type createUserGroupParams struct {
+	enableSection bool
+	includeCount  bool
 }
 
-// CreateUserGroupContext creates a new user group with a custom context
-func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) {
+// CreateUserGroupOption options for the CreateUserGroup method call.
+type CreateUserGroupOption func(*createUserGroupParams)
+
+// CreateUserGroupOptionEnableSection enable the section for the user group (default: false)
+func CreateUserGroupOptionEnableSection(enableSection bool) CreateUserGroupOption {
+	return func(params *createUserGroupParams) {
+		params.enableSection = enableSection
+	}
+}
+
+// CreateUserGroupOptionIncludeCount include the number of users in each User Group
+func CreateUserGroupOptionIncludeCount(includeCount bool) CreateUserGroupOption {
+	return func(params *createUserGroupParams) {
+		params.includeCount = includeCount
+	}
+}
+
+// CreateUserGroup creates a new user group.
+// For more information see the CreateUserGroupContext documentation.
+func (api *Client) CreateUserGroup(userGroup UserGroup, options ...CreateUserGroupOption) (UserGroup, error) {
+	return api.CreateUserGroupContext(context.Background(), userGroup, options...)
+}
+
+// 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, options ...CreateUserGroupOption) (UserGroup, error) {
+	params := createUserGroupParams{}
+
+	for _, opt := range options {
+		opt(¶ms)
+	}
+
 	values := url.Values{
 		"token": {api.token},
 		"name":  {userGroup.Name},
 	}
 
+	if params.enableSection {
+		values["enable_section"] = []string{strconv.FormatBool(params.enableSection)}
+	}
+
+	if params.includeCount {
+		values["include_count"] = []string{strconv.FormatBool(params.includeCount)}
+	}
+
+	if userGroup.TeamID != "" {
+		values["team_id"] = []string{userGroup.TeamID}
+	}
+
 	if userGroup.Handle != "" {
 		values["handle"] = []string{userGroup.Handle}
 	}
@@ -81,18 +125,57 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro
 	return response.UserGroup, nil
 }
 
-// DisableUserGroup disables an existing user group
-func (api *Client) DisableUserGroup(userGroup string) (UserGroup, error) {
-	return api.DisableUserGroupContext(context.Background(), userGroup)
+// DisableUserGroupParams contains arguments for DisableUserGroup method calls.
+type DisableUserGroupParams struct {
+	IncludeCount bool
+	TeamID       string
 }
 
-// DisableUserGroupContext disables an existing user group with a custom context
-func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) {
+// DisableUserGroupOption options for the DisableUserGroup method calls.
+type DisableUserGroupOption func(*DisableUserGroupParams)
+
+// DisableUserGroupOptionIncludeCount include the count of User Groups (default: false)
+func DisableUserGroupOptionIncludeCount(b bool) DisableUserGroupOption {
+	return func(params *DisableUserGroupParams) {
+		params.IncludeCount = b
+	}
+}
+
+// DisableUserGroupOptionTeamID include team Id
+func DisableUserGroupOptionTeamID(teamID string) DisableUserGroupOption {
+	return func(params *DisableUserGroupParams) {
+		params.TeamID = teamID
+	}
+}
+
+// DisableUserGroup disables an existing user group.
+// For more information see the DisableUserGroupContext documentation.
+func (api *Client) DisableUserGroup(userGroup string, options ...DisableUserGroupOption) (UserGroup, error) {
+	return api.DisableUserGroupContext(context.Background(), userGroup, options...)
+}
+
+// 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, options ...DisableUserGroupOption) (UserGroup, error) {
+	params := DisableUserGroupParams{}
+
+	for _, opt := range options {
+		opt(¶ms)
+	}
+
 	values := url.Values{
 		"token":     {api.token},
 		"usergroup": {userGroup},
 	}
 
+	if params.IncludeCount {
+		values.Add("include_count", "true")
+	}
+
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+
 	response, err := api.userGroupRequest(ctx, "usergroups.disable", values)
 	if err != nil {
 		return UserGroup{}, err
@@ -100,18 +183,57 @@ func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string
 	return response.UserGroup, nil
 }
 
-// EnableUserGroup enables an existing user group
-func (api *Client) EnableUserGroup(userGroup string) (UserGroup, error) {
-	return api.EnableUserGroupContext(context.Background(), userGroup)
+// EnableUserGroupParams contains arguments for EnableUserGroup method calls.
+type EnableUserGroupParams struct {
+	IncludeCount bool
+	TeamID       string
 }
 
-// EnableUserGroupContext enables an existing user group with a custom context
-func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) {
+// EnableUserGroupOption options for the EnableUserGroup method calls.
+type EnableUserGroupOption func(*EnableUserGroupParams)
+
+// EnableUserGroupOptionIncludeCount include the count of User Groups (default: false)
+func EnableUserGroupOptionIncludeCount(b bool) EnableUserGroupOption {
+	return func(params *EnableUserGroupParams) {
+		params.IncludeCount = b
+	}
+}
+
+// EnableUserGroupOptionTeamID include team Id
+func EnableUserGroupOptionTeamID(teamID string) EnableUserGroupOption {
+	return func(params *EnableUserGroupParams) {
+		params.TeamID = teamID
+	}
+}
+
+// EnableUserGroup enables an existing user group.
+// For more information see the EnableUserGroupContext documentation.
+func (api *Client) EnableUserGroup(userGroup string, options ...EnableUserGroupOption) (UserGroup, error) {
+	return api.EnableUserGroupContext(context.Background(), userGroup, options...)
+}
+
+// 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, options ...EnableUserGroupOption) (UserGroup, error) {
+	params := EnableUserGroupParams{}
+
+	for _, opt := range options {
+		opt(¶ms)
+	}
+
 	values := url.Values{
 		"token":     {api.token},
 		"usergroup": {userGroup},
 	}
 
+	if params.IncludeCount {
+		values.Add("include_count", "true")
+	}
+
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+
 	response, err := api.userGroupRequest(ctx, "usergroups.enable", values)
 	if err != nil {
 		return UserGroup{}, err
@@ -122,6 +244,17 @@ func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string)
 // GetUserGroupsOption options for the GetUserGroups method call.
 type GetUserGroupsOption func(*GetUserGroupsParams)
 
+// Deprecated: GetUserGroupsOptionWithTeamID is deprecated, use GetUserGroupsOptionTeamID instead
+func GetUserGroupsOptionWithTeamID(teamID string) GetUserGroupsOption {
+	return GetUserGroupsOptionTeamID(teamID)
+}
+
+func GetUserGroupsOptionTeamID(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) {
@@ -145,17 +278,20 @@ func GetUserGroupsOptionIncludeUsers(b bool) GetUserGroupsOption {
 
 // GetUserGroupsParams contains arguments for GetUserGroups method call
 type GetUserGroupsParams struct {
+	TeamID          string
 	IncludeCount    bool
 	IncludeDisabled bool
 	IncludeUsers    bool
 }
 
-// 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{}
 
@@ -166,6 +302,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")
 	}
@@ -183,32 +322,103 @@ func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserG
 	return response.UserGroups, nil
 }
 
-// UpdateUserGroup will update an existing user group
-func (api *Client) UpdateUserGroup(userGroup UserGroup) (UserGroup, error) {
-	return api.UpdateUserGroupContext(context.Background(), userGroup)
+// UpdateUserGroupsOption options for the UpdateUserGroup method call.
+type UpdateUserGroupsOption func(*UpdateUserGroupsParams)
+
+// UpdateUserGroupsOptionName change the name of the User Group (default: empty, so it's no-op)
+func UpdateUserGroupsOptionName(name string) UpdateUserGroupsOption {
+	return func(params *UpdateUserGroupsParams) {
+		params.Name = name
+	}
+}
+
+// UpdateUserGroupsOptionHandle change the handle of the User Group (default: empty, so it's no-op)
+func UpdateUserGroupsOptionHandle(handle string) UpdateUserGroupsOption {
+	return func(params *UpdateUserGroupsParams) {
+		params.Handle = handle
+	}
+}
+
+// UpdateUserGroupsOptionDescription change the description of the User Group. (default: nil, so it's no-op)
+func UpdateUserGroupsOptionDescription(description *string) UpdateUserGroupsOption {
+	return func(params *UpdateUserGroupsParams) {
+		params.Description = description
+	}
+}
+
+// UpdateUserGroupsOptionChannels change the default channels of the User Group. (default: unspecified, so it's no-op)
+func UpdateUserGroupsOptionChannels(channels []string) UpdateUserGroupsOption {
+	return func(params *UpdateUserGroupsParams) {
+		params.Channels = &channels
+	}
+}
+
+// UpdateUserGroupsOptionEnableSection enable the section for the user group (default: false)
+func UpdateUserGroupsOptionEnableSection(enableSection bool) UpdateUserGroupsOption {
+	return func(params *UpdateUserGroupsParams) {
+		params.EnableSection = enableSection
+	}
+}
+
+// UpdateUserGroupsOptionTeamID specify the team id for the User Group. (default: nil, so it's no-op)
+func UpdateUserGroupsOptionTeamID(teamID string) UpdateUserGroupsOption {
+	return func(params *UpdateUserGroupsParams) {
+		params.TeamID = teamID
+	}
 }
 
-// UpdateUserGroupContext will update an existing user group with a custom context
-func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) {
+// UpdateUserGroupsParams contains arguments for UpdateUserGroup method call
+type UpdateUserGroupsParams struct {
+	Name          string
+	Handle        string
+	Description   *string
+	Channels      *[]string
+	EnableSection bool
+	TeamID        string
+}
+
+// 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.
+// 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{}
+
+	for _, opt := range options {
+		opt(¶ms)
+	}
+
 	values := url.Values{
 		"token":     {api.token},
-		"usergroup": {userGroup.ID},
+		"usergroup": {userGroupID},
 	}
 
-	if userGroup.Name != "" {
-		values["name"] = []string{userGroup.Name}
+	if params.Name != "" {
+		values["name"] = []string{params.Name}
 	}
 
-	if userGroup.Handle != "" {
-		values["handle"] = []string{userGroup.Handle}
+	if params.Handle != "" {
+		values["handle"] = []string{params.Handle}
 	}
 
-	if userGroup.Description != "" {
-		values["description"] = []string{userGroup.Description}
+	if params.Description != nil {
+		values["description"] = []string{*params.Description}
 	}
 
-	if len(userGroup.Prefs.Channels) > 0 {
-		values["channels"] = []string{strings.Join(userGroup.Prefs.Channels, ",")}
+	if params.Channels != nil {
+		values["channels"] = []string{strings.Join(*params.Channels, ",")}
+	}
+
+	if params.EnableSection {
+		values["enable_section"] = []string{strconv.FormatBool(params.EnableSection)}
+	}
+
+	if params.TeamID != "" {
+		values["team_id"] = []string{params.TeamID}
 	}
 
 	response, err := api.userGroupRequest(ctx, "usergroups.update", values)
@@ -218,18 +428,57 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroup UserGro
 	return response.UserGroup, nil
 }
 
-// GetUserGroupMembers will retrieve the current list of users in a group
-func (api *Client) GetUserGroupMembers(userGroup string) ([]string, error) {
-	return api.GetUserGroupMembersContext(context.Background(), userGroup)
+// GetUserGroupMembersOption options for the GetUserGroupMembers method call.
+type GetUserGroupMembersOption func(*GetUserGroupMembersParams)
+
+// GetUserGroupMembersParams contains arguments for GetUserGroupMembers method call
+type GetUserGroupMembersParams struct {
+	IncludeDisabled bool
+	TeamID          string
+}
+
+// GetUserGroupMembersOptionIncludeDisabled include disabled User Groups (default: false)
+func GetUserGroupMembersOptionIncludeDisabled(b bool) GetUserGroupMembersOption {
+	return func(params *GetUserGroupMembersParams) {
+		params.IncludeDisabled = b
+	}
+}
+
+// GetUserGroupMembersOptionTeamID include team Id
+func GetUserGroupMembersOptionTeamID(teamID string) GetUserGroupMembersOption {
+	return func(params *GetUserGroupMembersParams) {
+		params.TeamID = teamID
+	}
 }
 
-// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context
-func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string) ([]string, error) {
+// GetUserGroupMembers will retrieve the current list of users in a group.
+// For more information see the GetUserGroupMembersContext documentation.
+func (api *Client) GetUserGroupMembers(userGroup string, options ...GetUserGroupMembersOption) ([]string, error) {
+	return api.GetUserGroupMembersContext(context.Background(), userGroup, options...)
+}
+
+// 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, options ...GetUserGroupMembersOption) ([]string, error) {
+	params := GetUserGroupMembersParams{}
+
+	for _, opt := range options {
+		opt(¶ms)
+	}
+
 	values := url.Values{
 		"token":     {api.token},
 		"usergroup": {userGroup},
 	}
 
+	if params.IncludeDisabled {
+		values.Add("include_disabled", "true")
+	}
+
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+
 	response, err := api.userGroupRequest(ctx, "usergroups.users.list", values)
 	if err != nil {
 		return []string{}, err
@@ -237,22 +486,99 @@ func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup str
 	return response.Users, nil
 }
 
-// UpdateUserGroupMembers will update the members of an existing user group
-func (api *Client) UpdateUserGroupMembers(userGroup string, members string) (UserGroup, error) {
-	return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members)
+// UpdateUserGroupMembersOption options for the UpdateUserGroupMembers method call.
+type UpdateUserGroupMembersOption func(*UpdateUserGroupMembersParams)
+
+// UpdateUserGroupMembersParams contains arguments for UpdateUserGroupMembers method call
+type UpdateUserGroupMembersParams struct {
+	AdditionalChannels []string
+	IncludeCount       bool
+	IsShared           bool
+	TeamID             string
+}
+
+// UpdateUserGroupMembersOptionAdditionalChannels include additional channels
+func UpdateUserGroupMembersOptionAdditionalChannels(channels []string) UpdateUserGroupMembersOption {
+	return func(params *UpdateUserGroupMembersParams) {
+		params.AdditionalChannels = channels
+	}
+}
+
+// UpdateUserGroupMembersOptionIsShared include the count of User Groups (default: false)
+func UpdateUserGroupMembersOptionIsShared(b bool) UpdateUserGroupMembersOption {
+	return func(params *UpdateUserGroupMembersParams) {
+		params.IsShared = b
+	}
+}
+
+// UpdateUserGroupMembersOptionIncludeCount include the count of User Groups (default: false)
+func UpdateUserGroupMembersOptionIncludeCount(b bool) UpdateUserGroupMembersOption {
+	return func(params *UpdateUserGroupMembersParams) {
+		params.IncludeCount = b
+	}
+}
+
+// UpdateUserGroupMembersOptionTeamID include team Id
+func UpdateUserGroupMembersOptionTeamID(teamID string) UpdateUserGroupMembersOption {
+	return func(params *UpdateUserGroupMembersParams) {
+		params.TeamID = teamID
+	}
+}
+
+// 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, options ...UpdateUserGroupMembersOption) (UserGroup, error) {
+	return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members, options...)
 }
 
-// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context
-func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string) (UserGroup, error) {
+// 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, options ...UpdateUserGroupMembersOption) (UserGroup, error) {
+	params := UpdateUserGroupMembersParams{}
+
+	for _, opt := range options {
+		opt(¶ms)
+	}
+
 	values := url.Values{
 		"token":     {api.token},
 		"usergroup": {userGroup},
 		"users":     {members},
 	}
 
+	if params.IncludeCount {
+		values.Add("include_count", "true")
+	}
+
+	if params.IsShared {
+		values.Add("is_shared", "true")
+	}
+
+	if params.TeamID != "" {
+		values.Add("team_id", params.TeamID)
+	}
+
+	if len(params.AdditionalChannels) > 0 {
+		values["additional_channels"] = []string{strings.Join(params.AdditionalChannels, ",")}
+	}
+
 	response, err := api.userGroupRequest(ctx, "usergroups.users.update", values)
 	if err != nil {
 		return UserGroup{}, err
 	}
 	return response.UserGroup, nil
 }
+
+// UpdateUserGroupMembersList updates the members of an existing user group,
+// accepting a slice of user IDs. This is a convenience wrapper around
+// UpdateUserGroupMembers for use with APIs that return []string (e.g.
+// GetUserGroupMembers).
+func (api *Client) UpdateUserGroupMembersList(userGroup string, members []string, options ...UpdateUserGroupMembersOption) (UserGroup, error) {
+	return api.UpdateUserGroupMembersContext(context.Background(), userGroup, strings.Join(members, ","), options...)
+}
+
+// UpdateUserGroupMembersListContext updates the members of an existing user
+// group with a custom context, accepting a slice of user IDs.
+func (api *Client) UpdateUserGroupMembersListContext(ctx context.Context, userGroup string, members []string, options ...UpdateUserGroupMembersOption) (UserGroup, error) {
+	return api.UpdateUserGroupMembersContext(ctx, userGroup, strings.Join(members, ","), options...)
+}
diff --git a/usergroups_test.go b/usergroups_test.go
index 5b92dcdf1..9586cf167 100644
--- a/usergroups_test.go
+++ b/usergroups_test.go
@@ -45,16 +45,11 @@ func newUserGroupsHandler() *userGroupsHandler {
 	}
 }
 
-func (ugh *userGroupsHandler) accumulateFormValue(k string, r *http.Request) {
-	if v := r.FormValue(k); v != "" {
-		ugh.gotParams[k] = v
-	}
-}
-
 func (ugh *userGroupsHandler) handler(w http.ResponseWriter, r *http.Request) {
-	ugh.accumulateFormValue("name", r)
-	ugh.accumulateFormValue("description", r)
-	ugh.accumulateFormValue("handle", r)
+	r.ParseForm()
+	for k, v := range r.Form {
+		ugh.gotParams[k] = v[0]
+	}
 	w.Header().Set("Content-Type", "application/json")
 	w.Write([]byte(ugh.response))
 }
@@ -73,6 +68,7 @@ func TestCreateUserGroup(t *testing.T) {
 				Description: "Marketing gurus, PR experts and product advocates.",
 				Handle:      "marketing-team"},
 			map[string]string{
+				"token":       "testing-token",
 				"name":        "Marketing Team",
 				"description": "Marketing gurus, PR experts and product advocates.",
 				"handle":      "marketing-team",
@@ -184,3 +180,101 @@ func TestGetUserGroups(t *testing.T) {
 		t.Errorf("Got %#v, want %#v", userGroups[0], S0614TZR7)
 	}
 }
+
+func updateUserGroupsHandler() *userGroupsHandler {
+	return &userGroupsHandler{
+		gotParams: make(map[string]string),
+		response: `{
+    "ok": true,
+    "usergroup": {
+        "id": "S0615G0KT",
+        "team_id": "T060RNRCH",
+        "is_usergroup": true,
+        "name": "Marketing Team",
+        "description": "Marketing gurus, PR experts and product advocates.",
+        "handle": "marketing-team",
+        "is_external": false,
+        "date_create": 1446746793,
+        "date_update": 1446746793,
+        "date_delete": 0,
+        "auto_type": null,
+        "created_by": "U060RNRCZ",
+        "updated_by": "U060RNRCZ",
+        "deleted_by": null,
+        "prefs": {
+            "channels": [
+				"channel1",
+				"channel2"
+            ],
+            "groups": [
+
+            ]
+        },
+        "user_count": 0
+    }
+}`,
+	}
+}
+func TestUpdateUserGroup(t *testing.T) {
+	once.Do(startServer)
+	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
+
+	emptyDescription := ""
+	presenceDescription := "Marketing gurus, PR experts and product advocates."
+
+	tests := []struct {
+		options    []UpdateUserGroupsOption
+		wantParams map[string]string
+	}{
+		{
+			[]UpdateUserGroupsOption{
+				UpdateUserGroupsOptionName("Marketing Team"),
+				UpdateUserGroupsOptionHandle("marketing-team"),
+			},
+			map[string]string{
+				"token":     "testing-token",
+				"usergroup": "S0615G0KT",
+				"name":      "Marketing Team",
+				"handle":    "marketing-team",
+			},
+		},
+		{
+			[]UpdateUserGroupsOption{
+				UpdateUserGroupsOptionDescription(&presenceDescription),
+				UpdateUserGroupsOptionChannels([]string{"channel1", "channel2"}),
+			},
+			map[string]string{
+				"token":       "testing-token",
+				"usergroup":   "S0615G0KT",
+				"description": "Marketing gurus, PR experts and product advocates.",
+				"channels":    "channel1,channel2",
+			},
+		},
+		{
+			[]UpdateUserGroupsOption{
+				UpdateUserGroupsOptionDescription(&emptyDescription),
+				UpdateUserGroupsOptionChannels([]string{}),
+			},
+			map[string]string{
+				"token":       "testing-token",
+				"usergroup":   "S0615G0KT",
+				"description": "",
+				"channels":    "",
+			},
+		},
+	}
+
+	var rh *userGroupsHandler
+	http.HandleFunc("/usergroups.update", func(w http.ResponseWriter, r *http.Request) { rh.handler(w, r) })
+
+	for i, test := range tests {
+		rh = updateUserGroupsHandler()
+		_, err := api.UpdateUserGroup("S0615G0KT", test.options...)
+		if err != nil {
+			t.Fatalf("%d: Unexpected error: %s", i, err)
+		}
+		if !reflect.DeepEqual(rh.gotParams, test.wantParams) {
+			t.Errorf("%d: Got params %#v, want %#v", i, rh.gotParams, test.wantParams)
+		}
+	}
+}
diff --git a/users.go b/users.go
index 873115690..541baae78 100644
--- a/users.go
+++ b/users.go
@@ -17,30 +17,48 @@ const (
 
 // UserProfile contains all the information details of a given user
 type UserProfile struct {
-	FirstName             string                  `json:"first_name"`
-	LastName              string                  `json:"last_name"`
-	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"`
-	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"`
-	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"`
-	StatusExpiration      int                     `json:"status_expiration"`
-	Team                  string                  `json:"team"`
-	Fields                UserProfileCustomFields `json:"fields"`
+	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"`
+	Pronouns                string                              `json:"pronouns,omitempty"`
+	AvatarHash              string                              `json:"avatar_hash"`
+	Email                   string                              `json:"email,omitempty"`
+	Skype                   string                              `json:"skype,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"`
+	Image1024               string                              `json:"image_1024,omitempty"`
+	ImageOriginal           string                              `json:"image_original,omitempty"`
+	IsCustomImage           bool                                `json:"is_custom_image,omitempty"`
+	Title                   string                              `json:"title,omitempty"`
+	BotID                   string                              `json:"bot_id,omitempty"`
+	ApiAppID                string                              `json:"api_app_id,omitempty"`
+	AlwaysActive            bool                                `json:"always_active,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,omitempty"`
+	StatusTextCanonical     string                              `json:"status_text_canonical,omitempty"`
+	HuddleState             string                              `json:"huddle_state,omitempty"`
+	HuddleStateExpirationTS int                                 `json:"huddle_state_expiration_ts,omitempty"`
+	StartDate               string                              `json:"start_date,omitempty"`
+	GuestInvitedBy          string                              `json:"guest_invited_by,omitempty"`
+	Team                    string                              `json:"team"`
+	Fields                  UserProfileCustomFields             `json:"fields,omitempty"`
+}
+
+type UserProfileStatusEmojiDisplayInfo struct {
+	EmojiName    string `json:"emoji_name"`
+	DisplayAlias string `json:"display_alias,omitempty"`
+	DisplayURL   string `json:"display_url,omitempty"`
+	Unicode      string `json:"unicode,omitempty"`
 }
 
 // UserProfileCustomFields represents user profile's custom fields.
@@ -63,7 +81,7 @@ func (fields *UserProfileCustomFields) UnmarshalJSON(b []byte) error {
 // MarshalJSON is the implementation of the json.Marshaler interface.
 func (fields UserProfileCustomFields) MarshalJSON() ([]byte, error) {
 	if len(fields.fields) == 0 {
-		return []byte("[]"), nil
+		return []byte("{}"), nil
 	}
 	return json.Marshal(fields.fields)
 }
@@ -102,31 +120,37 @@ type UserProfileCustomField struct {
 
 // User contains all the information of a user
 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,omitempty"`
-	TZLabel           string         `json:"tz_label"`
-	TZOffset          int            `json:"tz_offset"`
-	Profile           UserProfile    `json:"profile"`
-	IsBot             bool           `json:"is_bot"`
-	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"`
-	IsStranger        bool           `json:"is_stranger"`
-	IsAppUser         bool           `json:"is_app_user"`
-	IsInvitedUser     bool           `json:"is_invited_user"`
-	Has2FA            bool           `json:"has_2fa"`
-	HasFiles          bool           `json:"has_files"`
-	Presence          string         `json:"presence"`
-	Locale            string         `json:"locale"`
-	Updated           JSONTime       `json:"updated"`
-	Enterprise        EnterpriseUser `json:"enterprise_user,omitempty"`
+	ID                     string         `json:"id"`
+	TeamID                 string         `json:"team_id"`
+	Name                   string         `json:"name"`
+	Username               string         `json:"username,omitempty"`
+	Deleted                bool           `json:"deleted"`
+	Color                  string         `json:"color"`
+	RealName               string         `json:"real_name"`
+	TZ                     string         `json:"tz,omitempty"`
+	TZLabel                string         `json:"tz_label"`
+	TZOffset               int            `json:"tz_offset"`
+	Profile                UserProfile    `json:"profile"`
+	IsBot                  bool           `json:"is_bot"`
+	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"`
+	IsStranger             bool           `json:"is_stranger"`
+	IsAppUser              bool           `json:"is_app_user"`
+	IsConnectorBot         bool           `json:"is_connector_bot"`
+	IsWorkflowBot          bool           `json:"is_workflow_bot"`
+	IsInvitedUser          bool           `json:"is_invited_user"`
+	IsEmailConfirmed       bool           `json:"is_email_confirmed"`
+	Has2FA                 *bool          `json:"has_2fa,omitempty"`
+	TwoFactorType          *string        `json:"two_factor_type"`
+	HasFiles               bool           `json:"has_files"`
+	Presence               string         `json:"presence"`
+	Locale                 string         `json:"locale"`
+	Updated                JSONTime       `json:"updated"`
+	WhoCanShareContactCard string         `json:"who_can_share_contact_card,omitempty"`
+	Enterprise             EnterpriseUser `json:"enterprise_user,omitempty"`
 }
 
 // UserPresence contains details about a user online status
@@ -158,13 +182,14 @@ type UserIdentity struct {
 }
 
 // EnterpriseUser is present when a user is part of Slack Enterprise Grid
-// https://api.slack.com/types/user#enterprise_grid_user_objects
+// https://docs.slack.dev/reference/objects/user-object/#fields
 type EnterpriseUser struct {
 	ID             string   `json:"id"`
 	EnterpriseID   string   `json:"enterprise_id"`
 	EnterpriseName string   `json:"enterprise_name"`
 	IsAdmin        bool     `json:"is_admin"`
 	IsOwner        bool     `json:"is_owner"`
+	IsPrimaryOwner bool     `json:"is_primary_owner"`
 	Teams          []string `json:"teams"`
 }
 
@@ -217,11 +242,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},
@@ -235,12 +262,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},
@@ -255,12 +284,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},
@@ -292,6 +323,20 @@ func GetUsersOptionPresence(n bool) GetUsersOption {
 	}
 }
 
+// GetUsersOptionTeamID include team Id
+func GetUsersOptionTeamID(teamId string) GetUsersOption {
+	return func(p *UserPagination) {
+		p.teamId = teamId
+	}
+}
+
+// GetUsersOptionCursor set the cursor to the next page of results
+func GetUsersOptionCursor(cursor string) GetUsersOption {
+	return func(p *UserPagination) {
+		p.Cursor = cursor
+	}
+}
+
 func newUserPagination(c *Client, options ...GetUsersOption) (up UserPagination) {
 	up = UserPagination{
 		c:     c,
@@ -307,11 +352,13 @@ func newUserPagination(c *Client, options ...GetUsersOption) (up UserPagination)
 
 // UserPagination allows for paginating over the users
 type UserPagination struct {
-	Users        []User
-	limit        int
-	presence     bool
-	previousResp *ResponseMetadata
-	c            *Client
+	Users    []User
+	Cursor   string
+	limit    int
+	presence bool
+	teamId   string
+	complete bool
+	c        *Client
 }
 
 // Done checks if the pagination has completed
@@ -333,17 +380,16 @@ func (t UserPagination) Next(ctx context.Context) (_ UserPagination, err error)
 		resp *userResponseFull
 	)
 
-	if t.c == nil || (t.previousResp != nil && t.previousResp.Cursor == "") {
+	if t.c == nil || t.complete {
 		return t, errPaginationComplete
 	}
 
-	t.previousResp = t.previousResp.initialize()
-
 	values := url.Values{
 		"limit":          {strconv.Itoa(t.limit)},
 		"presence":       {strconv.FormatBool(t.presence)},
 		"token":          {t.c.token},
-		"cursor":         {t.previousResp.Cursor},
+		"cursor":         {t.Cursor},
+		"team_id":        {t.teamId},
 		"include_locale": {strconv.FormatBool(true)},
 	}
 
@@ -353,7 +399,8 @@ func (t UserPagination) Next(ctx context.Context) (_ UserPagination, err error)
 
 	t.c.Debugf("GetUsersContext: got %d users; metadata %v", len(resp.Members), resp.Metadata)
 	t.Users = resp.Members
-	t.previousResp = &resp.Metadata
+	t.Cursor = resp.Metadata.Cursor
+	t.complete = t.Cursor == ""
 
 	return t, nil
 }
@@ -364,13 +411,13 @@ func (api *Client) GetUsersPaginated(options ...GetUsersOption) UserPagination {
 }
 
 // GetUsers returns the list of users (with their detailed information)
-func (api *Client) GetUsers() ([]User, error) {
-	return api.GetUsersContext(context.Background())
+func (api *Client) GetUsers(options ...GetUsersOption) ([]User, error) {
+	return api.GetUsersContext(context.Background(), options...)
 }
 
 // GetUsersContext returns the list of users (with their detailed information) with a custom context
-func (api *Client) GetUsersContext(ctx context.Context) (results []User, err error) {
-	p := api.GetUsersPaginated()
+func (api *Client) GetUsersContext(ctx context.Context, options ...GetUsersOption) (results []User, err error) {
+	p := api.GetUsersPaginated(options...)
 	for err == nil {
 		p, err = p.Next(ctx)
 		if err == nil {
@@ -388,12 +435,14 @@ func (api *Client) GetUsersContext(ctx context.Context) (results []User, err err
 	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},
@@ -406,12 +455,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},
@@ -421,12 +472,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},
@@ -437,12 +490,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},
@@ -461,12 +516,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{}
@@ -488,12 +545,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{
@@ -509,13 +568,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 {
@@ -547,43 +606,140 @@ func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, rea
 	return response.Err()
 }
 
-// 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.
+// SetUserProfile sets the profile for the provided user.
+// For more information see the SetUserProfileContext documentation.
+func (api *Client) SetUserProfile(user string, profile *UserProfile) error {
+	return api.SetUserProfileContext(context.Background(), user, profile)
+}
+
+// SetUserProfileContext sets the profile for the provided user with a custom context.
+//
+// The profile parameter is serialized as-is. Fields present in the JSON (including
+// zero-value fields without an omitempty tag, such as RealName and DisplayName) will
+// be updated by Slack. To avoid unintended changes, retrieve the current profile with
+// GetUserProfile, modify the desired fields, and pass the result.
+//
+// For setting individual fields, prefer the targeted methods: SetUserRealName,
+// SetUserCustomStatus, SetUserCustomFields.
+//
+// If a workspace admin has mapped custom profile fields to standard fields (e.g.
+// title), the custom field takes precedence. Update the custom field via
+// SetUserCustomFields instead.
+//
+// The user parameter is required when setting another user's profile (admin only,
+// paid plans). Pass an empty string to modify the authenticated user's own profile.
+//
+// Slack API docs: https://docs.slack.dev/reference/methods/users.profile.set/
+func (api *Client) SetUserProfileContext(ctx context.Context, user string, profile *UserProfile) error {
+	profileJSON, err := json.Marshal(profile)
+	if err != nil {
+		return err
+	}
+
+	values := url.Values{
+		"token":   {api.token},
+		"profile": {string(profileJSON)},
+	}
+
+	// optional field. It should not be set if empty
+	if user != "" {
+		values["user"] = []string{user}
+	}
+
+	response := &userResponseFull{}
+	if err = api.postMethod(ctx, "users.profile.set", values, response); err != nil {
+		return err
+	}
+
+	return response.Err()
+}
+
+// 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 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.
+//
+// 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
+	// For more information, see UserProfileCustomFields definition.
+	updateFields := UserProfileCustomFields{}
+	updateFields.SetMap(customFields)
+
+	// This anonymous struct is needed to set the fields level of the request data.  The base struct for
+	// UserProfileCustomFields has an unexported variable named fields that does not contain a struct tag,
+	// which has resulted in this configuration.
+	profile, err := json.Marshal(&struct {
+		Fields UserProfileCustomFields `json:"fields"`
+	}{
+		Fields: updateFields,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	values := url.Values{
+		"token":   {api.token},
+		"user":    {userID},
+		"profile": {string(profile)},
+	}
+
+	response := &userResponseFull{}
+	if _, err := postForm(ctx, api.httpclient, APIURL+"users.profile.set", values, response, api); err != nil {
+		return err
+	}
+
+	return response.Err()
+
+}
+
+// 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
-	// these values we must provide a JSON document as the profile POST field.
+	// This anonymous struct is for making requests to the Slack API for setting and
+	// unsetting a User's Custom Status/Emoji. To change these values we must provide a
+	// JSON document as the profile POST field.
 	//
-	// We use an anonymous struct over UserProfile because to unset the values
-	// on the User's profile we cannot use the `json:"omitempty"` tag. This is
-	// because an empty string ("") is what's used to unset the values. Check
-	// out the API docs for more details:
+	// We use an anonymous struct over UserProfile because to unset the values on the
+	// User's profile we cannot use the `json:"omitempty"` tag. This is because an empty
+	// string ("") is what's used to unset the values. Check out the API docs for more
+	// details:
 	//
-	// - https://api.slack.com/docs/presence-and-status#custom_status
+	// - https://docs.slack.dev/apis/web-api/user-presence-and-status/#custom-status
 	profile, err := json.Marshal(
 		&struct {
 			StatusText       string `json:"status_text"`
@@ -637,6 +793,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)
 }
@@ -647,6 +804,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 58b3788b5..25b1b1968 100644
--- a/users_test.go
+++ b/users_test.go
@@ -2,13 +2,13 @@ package slack
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"image"
 	"image/draw"
 	"image/png"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"os"
 	"reflect"
@@ -33,22 +33,31 @@ func getTestUserProfileCustomFields() UserProfileCustomFields {
 		}}
 }
 
+func getTestUserProfileStatusEmojiDisplayInfo() []UserProfileStatusEmojiDisplayInfo {
+	return []UserProfileStatusEmojiDisplayInfo{{
+		EmojiName:  "construction",
+		Unicode:    "1f6a7",
+		DisplayURL: "https://a.slack-edge.com/production-standard-emoji-assets/14.0/apple-large/1f6a7.png",
+	}}
+}
+
 func getTestUserProfile() UserProfile {
 	return UserProfile{
-		StatusText:            "testStatus",
-		StatusEmoji:           ":construction:",
-		RealName:              "Test Real Name",
-		RealNameNormalized:    "Test Real Name Normalized",
-		DisplayName:           "Test Display Name",
-		DisplayNameNormalized: "Test Display Name Normalized",
-		Email:                 "test@test.com",
-		Image24:               "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_24.jpg",
-		Image32:               "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_32.jpg",
-		Image48:               "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_48.jpg",
-		Image72:               "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_72.jpg",
-		Image192:              "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_192.jpg",
-		Image512:              "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_512.jpg",
-		Fields:                getTestUserProfileCustomFields(),
+		StatusText:             "testStatus",
+		StatusEmoji:            ":construction:",
+		StatusEmojiDisplayInfo: getTestUserProfileStatusEmojiDisplayInfo(),
+		RealName:               "Test Real Name",
+		RealNameNormalized:     "Test Real Name Normalized",
+		DisplayName:            "Test Display Name",
+		DisplayNameNormalized:  "Test Display Name Normalized",
+		Email:                  "test@test.com",
+		Image24:                "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_24.jpg",
+		Image32:                "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_32.jpg",
+		Image48:                "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_48.jpg",
+		Image72:                "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_72.jpg",
+		Image192:               "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_192.jpg",
+		Image512:               "https://s3-us-west-2.amazonaws.com/slack-files2/avatars/2016-10-18/92962080834_ef14c1469fc0741caea1_512.jpg",
+		Fields:                 getTestUserProfileCustomFields(),
 	}
 }
 
@@ -70,7 +79,7 @@ func getTestUserWithId(id string) User {
 		IsRestricted:      false,
 		IsUltraRestricted: false,
 		Updated:           1555425715,
-		Has2FA:            false,
+		Has2FA:            new(false),
 	}
 }
 
@@ -90,28 +99,28 @@ func getUserIdentity(rw http.ResponseWriter, r *http.Request) {
 	response := []byte(`{
   "ok": true,
   "user": {
-    "id": "UXXXXXXXX",
-    "name": "Test User",
-    "email": "test@test.com",
-    "image_24": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_24.jpg",
-    "image_32": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_32.jpg",
-    "image_48": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_48.jpg",
-    "image_72": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_72.jpg",
-    "image_192": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_192.jpg",
-    "image_512": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_512.jpg"
+	"id": "UXXXXXXXX",
+	"name": "Test User",
+	"email": "test@test.com",
+	"image_24": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_24.jpg",
+	"image_32": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_32.jpg",
+	"image_48": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_48.jpg",
+	"image_72": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_72.jpg",
+	"image_192": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_192.jpg",
+	"image_512": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_512.jpg"
   },
   "team": {
-    "id": "TXXXXXXXX",
-    "name": "team-name",
-    "domain": "team-domain",
-    "image_34": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_34.jpg",
-    "image_44": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_44.jpg",
-    "image_68": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_68.jpg",
-    "image_88": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_88.jpg",
-    "image_102": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_102.jpg",
-    "image_132": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_132.jpg",
-    "image_230": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_230.jpg",
-    "image_original": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_original.jpg"
+	"id": "TXXXXXXXX",
+	"name": "team-name",
+	"domain": "team-domain",
+	"image_34": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_34.jpg",
+	"image_44": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_44.jpg",
+	"image_68": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_68.jpg",
+	"image_88": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_88.jpg",
+	"image_102": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_102.jpg",
+	"image_132": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_132.jpg",
+	"image_230": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_230.jpg",
+	"image_original": "https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2016-10-18\/92962080834_ef14c1469fc0741caea1_original.jpg"
   }
 }`)
 	rw.Write(response)
@@ -206,7 +215,8 @@ func newProfileHandler(up *UserProfile) (setter func(http.ResponseWriter, *http.
 
 		*up = userProfile
 
-		// TODO(theckman): enhance this to return a full User object
+		// Response only needs {"ok":true} — this handler tests request parsing, not
+		// response unmarshalling
 		fmt.Fprint(w, `{"ok":true}`)
 	}
 }
@@ -320,6 +330,8 @@ func TestUserProfileSet(t *testing.T) {
 
 	up.RealName = "Real Name Test"
 	testSetUserRealName(api, up, t)
+
+	testSetUserProfile(api, up, t)
 }
 
 func testSetUserRealName(api *Client, up *UserProfile, t *testing.T) {
@@ -342,7 +354,7 @@ func testSetUserCustomStatus(api *Client, up *UserProfile, t *testing.T) {
 		statusExpiration = 1551619082
 	)
 	if err := api.SetUserCustomStatus(statusText, statusEmoji, statusExpiration); err != nil {
-		t.Fatalf(`SetUserCustomStatus(%q, %q, %q) = %#v, want `, statusText, statusEmoji, statusExpiration, err)
+		t.Fatalf(`SetUserCustomStatus(%q, %q, %d) = %#v, want `, statusText, statusEmoji, statusExpiration, err)
 	}
 
 	if up.StatusText != statusText {
@@ -353,7 +365,7 @@ func testSetUserCustomStatus(api *Client, up *UserProfile, t *testing.T) {
 		t.Fatalf(`UserProfile.StatusEmoji = %q, want %q`, up.StatusEmoji, statusEmoji)
 	}
 	if up.StatusExpiration != statusExpiration {
-		t.Fatalf(`UserProfile.StatusExpiration = %q, want %q`, up.StatusExpiration, statusExpiration)
+		t.Fatalf(`UserProfile.StatusExpiration = %d, want %d`, up.StatusExpiration, statusExpiration)
 	}
 }
 
@@ -364,7 +376,7 @@ func testSetUserCustomStatusWithUser(api *Client, user string, up *UserProfile,
 		statusExpiration = 1551619082
 	)
 	if err := api.SetUserCustomStatusWithUser(user, statusText, statusEmoji, statusExpiration); err != nil {
-		t.Fatalf(`SetUserCustomStatusWithUser(%q, %q, %q, %q) = %#v, want `, user, statusText, statusEmoji, statusExpiration, err)
+		t.Fatalf(`SetUserCustomStatusWithUser(%q, %q, %q, %d) = %#v, want `, user, statusText, statusEmoji, statusExpiration, err)
 	}
 
 	if up.StatusText != statusText {
@@ -375,7 +387,29 @@ func testSetUserCustomStatusWithUser(api *Client, user string, up *UserProfile,
 		t.Fatalf(`UserProfile.StatusEmoji = %q, want %q`, up.StatusEmoji, statusEmoji)
 	}
 	if up.StatusExpiration != statusExpiration {
-		t.Fatalf(`UserProfile.StatusExpiration = %q, want %q`, up.StatusExpiration, statusExpiration)
+		t.Fatalf(`UserProfile.StatusExpiration = %d, want %d`, up.StatusExpiration, statusExpiration)
+	}
+}
+
+func testSetUserProfile(api *Client, up *UserProfile, t *testing.T) {
+	profile := &UserProfile{
+		RealName:    "Set Profile Test",
+		DisplayName: "setprofile",
+		Title:       "Engineer",
+	}
+
+	if err := api.SetUserProfile("U1234567", profile); err != nil {
+		t.Fatalf("SetUserProfile() = %#v, want ", err)
+	}
+
+	if up.RealName != profile.RealName {
+		t.Fatalf("UserProfile.RealName = %q, want %q", up.RealName, profile.RealName)
+	}
+	if up.DisplayName != profile.DisplayName {
+		t.Fatalf("UserProfile.DisplayName = %q, want %q", up.DisplayName, profile.DisplayName)
+	}
+	if up.Title != profile.Title {
+		t.Fatalf("UserProfile.Title = %q, want %q", up.Title, profile.Title)
 	}
 }
 
@@ -400,27 +434,61 @@ func TestGetUsers(t *testing.T) {
 	once.Do(startServer)
 	api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/"))
 
-	users, err := api.GetUsers()
-	if err != nil {
-		t.Errorf("Unexpected error: %s", err)
-		return
-	}
+	t.Run("Get all users", func(t *testing.T) {
 
-	if !reflect.DeepEqual([]User{
-		getTestUserWithId("U000"),
-		getTestUserWithId("U001"),
-		getTestUserWithId("U002"),
-		getTestUserWithId("U003"),
-	}, users) {
-		t.Fatal(ErrIncorrectResponse)
-	}
+		users, err := api.GetUsers()
+		if err != nil {
+			t.Errorf("Unexpected error: %s", err)
+			return
+		}
+
+		if !reflect.DeepEqual([]User{
+			getTestUserWithId("U000"),
+			getTestUserWithId("U001"),
+			getTestUserWithId("U002"),
+			getTestUserWithId("U003"),
+		}, users) {
+			t.Fatal(ErrIncorrectResponse)
+		}
+	})
+
+	t.Run("Get users with cursor", func(t *testing.T) {
+		page := api.GetUsersPaginated(GetUsersOptionCursor("2"), GetUsersOptionLimit(1))
+		nextPage, err := page.Next(context.TODO())
+		if err != nil {
+			t.Errorf("Unexpected error: %s", err)
+			return
+		}
+
+		if !reflect.DeepEqual([]User{
+			getTestUserWithId("U002"),
+		}, nextPage.Users) {
+			t.Fatal(ErrIncorrectResponse)
+		}
+
+		if nextPage.Cursor != "3" {
+			t.Fatal(ErrIncorrectResponse)
+		}
+	})
 }
 
 // returns n pages users.
 func getUserPage(max int64) func(rw http.ResponseWriter, r *http.Request) {
-	var n int64
 	return func(rw http.ResponseWriter, r *http.Request) {
-		var cpage int64
+		var (
+			n   int64
+			err error
+		)
+
+		_ = r.ParseForm()
+		if cursor := r.FormValue("cursor"); cursor != "" {
+			n, err = strconv.ParseInt(cursor, 10, 64)
+		}
+		if err != nil || n >= max { // invalid cursor
+			rw.WriteHeader(http.StatusBadRequest)
+			return
+		}
+
 		sresp := SlackResponse{
 			Ok: true,
 		}
@@ -428,7 +496,9 @@ func getUserPage(max int64) func(rw http.ResponseWriter, r *http.Request) {
 			getTestUserWithId(fmt.Sprintf("U%03d", n)),
 		}
 		rw.Header().Set("Content-Type", "application/json")
-		if cpage = atomic.AddInt64(&n, 1); cpage == max {
+
+		nextPage := n + 1
+		if nextPage == max {
 			response, _ := json.Marshal(userResponseFull{
 				SlackResponse: sresp,
 				Members:       members,
@@ -436,10 +506,11 @@ func getUserPage(max int64) func(rw http.ResponseWriter, r *http.Request) {
 			rw.Write(response)
 			return
 		}
+
 		response, _ := json.Marshal(userResponseFull{
 			SlackResponse: sresp,
 			Members:       members,
-			Metadata:      ResponseMetadata{Cursor: strconv.Itoa(int(cpage))},
+			Metadata:      ResponseMetadata{Cursor: strconv.Itoa(int(nextPage))},
 		})
 		rw.Write(response)
 	}
@@ -547,7 +618,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
@@ -566,9 +637,9 @@ func setUserPhotoHandler(wantBytes []byte, wantParams UserSetPhotoParams) http.H
 // contents, and a function that can be called to remove the file.
 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)
+	draw.Draw(photo, photo.Bounds(), image.Black, image.Point{}, 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)
 	}
@@ -608,6 +679,9 @@ func TestGetUserProfile(t *testing.T) {
 	if profile.DisplayName != exp.DisplayName {
 		t.Fatalf(`profile.DisplayName = "%s", wanted "%s"`, profile.DisplayName, exp.DisplayName)
 	}
+	if len(profile.StatusEmojiDisplayInfo) != 1 {
+		t.Fatalf(`expected 1 emoji, got %d`, len(profile.StatusEmojiDisplayInfo))
+	}
 }
 
 func TestSetFieldsMap(t *testing.T) {
@@ -629,8 +703,8 @@ func TestUserProfileCustomFieldsUnmarshalJSON(t *testing.T) {
 	}
 	if err := json.Unmarshal([]byte(`{
 	  "Xxxxxx": {
-	    "value": "test value",
-	    "alt": ""
+		"value": "test value",
+		"alt": ""
 	  }
 	}`), fields); err != nil {
 		t.Fatal(err)
@@ -648,8 +722,8 @@ func TestUserProfileCustomFieldsMarshalJSON(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	if string(b) != "[]" {
-		t.Fatalf(`string(b) = "%s", wanted "[]"`, string(b))
+	if string(b) != "{}" {
+		t.Fatalf(`string(b) = "%s", wanted "{}"`, string(b))
 	}
 	fields = getTestUserProfileCustomFields()
 	if _, err := json.Marshal(fields); err != nil {
@@ -712,6 +786,166 @@ func TestGetUsersHandlesRateLimit(t *testing.T) {
 	}
 }
 
+func TestUserUnmarshalJSON(t *testing.T) {
+	userJSON := `{
+		"id": "U12345678",
+		"team_id": "T12345678",
+		"name": "testuser",
+		"deleted": false,
+		"color": "4bbe2e",
+		"real_name": "Test User",
+		"tz": "America/Los_Angeles",
+		"tz_label": "Pacific Daylight Time",
+		"tz_offset": -25200,
+		"profile": {
+			"first_name": "Test",
+			"last_name": "User",
+			"real_name": "Test User",
+			"real_name_normalized": "Test User",
+			"display_name": "testuser",
+			"display_name_normalized": "testuser",
+			"pronouns": "they/them",
+			"avatar_hash": "abc123",
+			"email": "test@example.com",
+			"skype": "",
+			"phone": "+1234567890",
+			"image_24": "https://example.com/24.png",
+			"image_32": "https://example.com/32.png",
+			"image_48": "https://example.com/48.png",
+			"image_72": "https://example.com/72.png",
+			"image_192": "https://example.com/192.png",
+			"image_512": "https://example.com/512.png",
+			"image_1024": "https://example.com/1024.png",
+			"image_original": "https://example.com/original.png",
+			"is_custom_image": true,
+			"always_active": true,
+			"status_text": "Working",
+			"status_emoji": ":computer:",
+			"status_expiration": 0,
+			"status_text_canonical": "",
+			"huddle_state": "in_a_huddle",
+			"huddle_state_expiration_ts": 1648596421,
+			"start_date": "2022-01-01",
+			"team": "T12345678",
+			"fields": {}
+		},
+		"is_bot": false,
+		"is_admin": true,
+		"is_owner": false,
+		"is_primary_owner": false,
+		"is_restricted": false,
+		"is_ultra_restricted": false,
+		"is_stranger": false,
+		"is_app_user": false,
+		"is_invited_user": false,
+		"is_email_confirmed": true,
+		"has_2fa": false,
+		"has_files": true,
+		"presence": "active",
+		"locale": "en-US",
+		"updated": 1648596421,
+		"who_can_share_contact_card": "EVERYONE",
+		"enterprise_user": {
+			"id": "E12345678",
+			"enterprise_id": "E99999999",
+			"enterprise_name": "Test Enterprise",
+			"is_admin": false,
+			"is_owner": false,
+			"is_primary_owner": false,
+			"teams": ["T12345678", "T87654321"]
+		}
+	}`
+
+	var user User
+	if err := json.Unmarshal([]byte(userJSON), &user); err != nil {
+		t.Fatalf("Failed to unmarshal User: %s", err)
+	}
+
+	// Verify User fields
+	if user.WhoCanShareContactCard != "EVERYONE" {
+		t.Fatalf(`user.WhoCanShareContactCard = %q, want "EVERYONE"`, user.WhoCanShareContactCard)
+	}
+
+	// Verify UserProfile fields
+	if user.Profile.AlwaysActive != true {
+		t.Fatalf(`user.Profile.AlwaysActive = %v, want true`, user.Profile.AlwaysActive)
+	}
+	if user.Profile.Pronouns != "they/them" {
+		t.Fatalf(`user.Profile.Pronouns = %q, want "they/them"`, user.Profile.Pronouns)
+	}
+	if user.Profile.Image1024 != "https://example.com/1024.png" {
+		t.Fatalf(`user.Profile.Image1024 = %q, want "https://example.com/1024.png"`, user.Profile.Image1024)
+	}
+	if user.Profile.IsCustomImage != true {
+		t.Fatalf(`user.Profile.IsCustomImage = %v, want true`, user.Profile.IsCustomImage)
+	}
+	if user.Profile.HuddleState != "in_a_huddle" {
+		t.Fatalf(`user.Profile.HuddleState = %q, want "in_a_huddle"`, user.Profile.HuddleState)
+	}
+	if user.Profile.HuddleStateExpirationTS != 1648596421 {
+		t.Fatalf(`user.Profile.HuddleStateExpirationTS = %d, want 1648596421`, user.Profile.HuddleStateExpirationTS)
+	}
+	if user.Profile.StartDate != "2022-01-01" {
+		t.Fatalf(`user.Profile.StartDate = %q, want "2022-01-01"`, user.Profile.StartDate)
+	}
+	if user.Profile.StatusTextCanonical != "" {
+		t.Fatalf(`user.Profile.StatusTextCanonical = %q, want ""`, user.Profile.StatusTextCanonical)
+	}
+
+	// Verify EnterpriseUser fields
+	if user.Enterprise.IsPrimaryOwner != false {
+		t.Fatalf(`user.Enterprise.IsPrimaryOwner = %v, want false`, user.Enterprise.IsPrimaryOwner)
+	}
+	if user.Enterprise.EnterpriseID != "E99999999" {
+		t.Fatalf(`user.Enterprise.EnterpriseID = %q, want "E99999999"`, user.Enterprise.EnterpriseID)
+	}
+
+	// Verify round-trip: marshal and unmarshal should produce the same result
+	marshaled, err := json.Marshal(user)
+	if err != nil {
+		t.Fatalf("Failed to marshal User: %s", err)
+	}
+	var roundTripped User
+	if err := json.Unmarshal(marshaled, &roundTripped); err != nil {
+		t.Fatalf("Failed to unmarshal round-tripped User: %s", err)
+	}
+	if !reflect.DeepEqual(user, roundTripped) {
+		t.Fatal("Round-trip marshal/unmarshal produced different result")
+	}
+}
+
+func TestUserHas2FA_ThreeStates(t *testing.T) {
+	// When has_2fa is present and true
+	withTrue := []byte(`{"id":"U1","has_2fa":true}`)
+	var u1 User
+	if err := json.Unmarshal(withTrue, &u1); err != nil {
+		t.Fatal(err)
+	}
+	if u1.Has2FA == nil || *u1.Has2FA != true {
+		t.Fatalf("expected Has2FA=true, got %v", u1.Has2FA)
+	}
+
+	// When has_2fa is present and false
+	withFalse := []byte(`{"id":"U2","has_2fa":false}`)
+	var u2 User
+	if err := json.Unmarshal(withFalse, &u2); err != nil {
+		t.Fatal(err)
+	}
+	if u2.Has2FA == nil || *u2.Has2FA != false {
+		t.Fatalf("expected Has2FA=false, got %v", u2.Has2FA)
+	}
+
+	// When has_2fa is absent (bot token response)
+	withoutField := []byte(`{"id":"U3"}`)
+	var u3 User
+	if err := json.Unmarshal(withoutField, &u3); err != nil {
+		t.Fatal(err)
+	}
+	if u3.Has2FA != nil {
+		t.Fatalf("expected Has2FA=nil, got %v", *u3.Has2FA)
+	}
+}
+
 func TestGetUsersReturnsServerError(t *testing.T) {
 	http.DefaultServeMux = new(http.ServeMux)
 	http.HandleFunc("/users.list", func(w http.ResponseWriter, r *http.Request) {
diff --git a/vendor/github.com/davecgh/go-spew/LICENSE b/vendor/github.com/davecgh/go-spew/LICENSE
deleted file mode 100644
index bc52e96f2..000000000
--- a/vendor/github.com/davecgh/go-spew/LICENSE
+++ /dev/null
@@ -1,15 +0,0 @@
-ISC License
-
-Copyright (c) 2012-2016 Dave Collins 
-
-Permission to use, copy, modify, and/or distribute this software for any
-purpose with or without fee is hereby granted, provided that the above
-copyright notice and this permission notice appear in all copies.
-
-THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/vendor/github.com/davecgh/go-spew/spew/bypass.go b/vendor/github.com/davecgh/go-spew/spew/bypass.go
deleted file mode 100644
index 792994785..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/bypass.go
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (c) 2015-2016 Dave Collins 
-//
-// Permission to use, copy, modify, and distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-
-// NOTE: Due to the following build constraints, this file will only be compiled
-// when the code is not running on Google App Engine, compiled by GopherJS, and
-// "-tags safe" is not added to the go build command line.  The "disableunsafe"
-// tag is deprecated and thus should not be used.
-// Go versions prior to 1.4 are disabled because they use a different layout
-// for interfaces which make the implementation of unsafeReflectValue more complex.
-// +build !js,!appengine,!safe,!disableunsafe,go1.4
-
-package spew
-
-import (
-	"reflect"
-	"unsafe"
-)
-
-const (
-	// UnsafeDisabled is a build-time constant which specifies whether or
-	// not access to the unsafe package is available.
-	UnsafeDisabled = false
-
-	// ptrSize is the size of a pointer on the current arch.
-	ptrSize = unsafe.Sizeof((*byte)(nil))
-)
-
-type flag uintptr
-
-var (
-	// flagRO indicates whether the value field of a reflect.Value
-	// is read-only.
-	flagRO flag
-
-	// flagAddr indicates whether the address of the reflect.Value's
-	// value may be taken.
-	flagAddr flag
-)
-
-// flagKindMask holds the bits that make up the kind
-// part of the flags field. In all the supported versions,
-// it is in the lower 5 bits.
-const flagKindMask = flag(0x1f)
-
-// Different versions of Go have used different
-// bit layouts for the flags type. This table
-// records the known combinations.
-var okFlags = []struct {
-	ro, addr flag
-}{{
-	// From Go 1.4 to 1.5
-	ro:   1 << 5,
-	addr: 1 << 7,
-}, {
-	// Up to Go tip.
-	ro:   1<<5 | 1<<6,
-	addr: 1 << 8,
-}}
-
-var flagValOffset = func() uintptr {
-	field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
-	if !ok {
-		panic("reflect.Value has no flag field")
-	}
-	return field.Offset
-}()
-
-// flagField returns a pointer to the flag field of a reflect.Value.
-func flagField(v *reflect.Value) *flag {
-	return (*flag)(unsafe.Pointer(uintptr(unsafe.Pointer(v)) + flagValOffset))
-}
-
-// unsafeReflectValue converts the passed reflect.Value into a one that bypasses
-// the typical safety restrictions preventing access to unaddressable and
-// unexported data.  It works by digging the raw pointer to the underlying
-// value out of the protected value and generating a new unprotected (unsafe)
-// reflect.Value to it.
-//
-// This allows us to check for implementations of the Stringer and error
-// interfaces to be used for pretty printing ordinarily unaddressable and
-// inaccessible values such as unexported struct fields.
-func unsafeReflectValue(v reflect.Value) reflect.Value {
-	if !v.IsValid() || (v.CanInterface() && v.CanAddr()) {
-		return v
-	}
-	flagFieldPtr := flagField(&v)
-	*flagFieldPtr &^= flagRO
-	*flagFieldPtr |= flagAddr
-	return v
-}
-
-// Sanity checks against future reflect package changes
-// to the type or semantics of the Value.flag field.
-func init() {
-	field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
-	if !ok {
-		panic("reflect.Value has no flag field")
-	}
-	if field.Type.Kind() != reflect.TypeOf(flag(0)).Kind() {
-		panic("reflect.Value flag field has changed kind")
-	}
-	type t0 int
-	var t struct {
-		A t0
-		// t0 will have flagEmbedRO set.
-		t0
-		// a will have flagStickyRO set
-		a t0
-	}
-	vA := reflect.ValueOf(t).FieldByName("A")
-	va := reflect.ValueOf(t).FieldByName("a")
-	vt0 := reflect.ValueOf(t).FieldByName("t0")
-
-	// Infer flagRO from the difference between the flags
-	// for the (otherwise identical) fields in t.
-	flagPublic := *flagField(&vA)
-	flagWithRO := *flagField(&va) | *flagField(&vt0)
-	flagRO = flagPublic ^ flagWithRO
-
-	// Infer flagAddr from the difference between a value
-	// taken from a pointer and not.
-	vPtrA := reflect.ValueOf(&t).Elem().FieldByName("A")
-	flagNoPtr := *flagField(&vA)
-	flagPtr := *flagField(&vPtrA)
-	flagAddr = flagNoPtr ^ flagPtr
-
-	// Check that the inferred flags tally with one of the known versions.
-	for _, f := range okFlags {
-		if flagRO == f.ro && flagAddr == f.addr {
-			return
-		}
-	}
-	panic("reflect.Value read-only flag has changed semantics")
-}
diff --git a/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go b/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go
deleted file mode 100644
index 205c28d68..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) 2015-2016 Dave Collins 
-//
-// Permission to use, copy, modify, and distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-
-// NOTE: Due to the following build constraints, this file will only be compiled
-// when the code is running on Google App Engine, compiled by GopherJS, or
-// "-tags safe" is added to the go build command line.  The "disableunsafe"
-// tag is deprecated and thus should not be used.
-// +build js appengine safe disableunsafe !go1.4
-
-package spew
-
-import "reflect"
-
-const (
-	// UnsafeDisabled is a build-time constant which specifies whether or
-	// not access to the unsafe package is available.
-	UnsafeDisabled = true
-)
-
-// unsafeReflectValue typically converts the passed reflect.Value into a one
-// that bypasses the typical safety restrictions preventing access to
-// unaddressable and unexported data.  However, doing this relies on access to
-// the unsafe package.  This is a stub version which simply returns the passed
-// reflect.Value when the unsafe package is not available.
-func unsafeReflectValue(v reflect.Value) reflect.Value {
-	return v
-}
diff --git a/vendor/github.com/davecgh/go-spew/spew/common.go b/vendor/github.com/davecgh/go-spew/spew/common.go
deleted file mode 100644
index 1be8ce945..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/common.go
+++ /dev/null
@@ -1,341 +0,0 @@
-/*
- * Copyright (c) 2013-2016 Dave Collins 
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-package spew
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"reflect"
-	"sort"
-	"strconv"
-)
-
-// Some constants in the form of bytes to avoid string overhead.  This mirrors
-// the technique used in the fmt package.
-var (
-	panicBytes            = []byte("(PANIC=")
-	plusBytes             = []byte("+")
-	iBytes                = []byte("i")
-	trueBytes             = []byte("true")
-	falseBytes            = []byte("false")
-	interfaceBytes        = []byte("(interface {})")
-	commaNewlineBytes     = []byte(",\n")
-	newlineBytes          = []byte("\n")
-	openBraceBytes        = []byte("{")
-	openBraceNewlineBytes = []byte("{\n")
-	closeBraceBytes       = []byte("}")
-	asteriskBytes         = []byte("*")
-	colonBytes            = []byte(":")
-	colonSpaceBytes       = []byte(": ")
-	openParenBytes        = []byte("(")
-	closeParenBytes       = []byte(")")
-	spaceBytes            = []byte(" ")
-	pointerChainBytes     = []byte("->")
-	nilAngleBytes         = []byte("")
-	maxNewlineBytes       = []byte("\n")
-	maxShortBytes         = []byte("")
-	circularBytes         = []byte("")
-	circularShortBytes    = []byte("")
-	invalidAngleBytes     = []byte("")
-	openBracketBytes      = []byte("[")
-	closeBracketBytes     = []byte("]")
-	percentBytes          = []byte("%")
-	precisionBytes        = []byte(".")
-	openAngleBytes        = []byte("<")
-	closeAngleBytes       = []byte(">")
-	openMapBytes          = []byte("map[")
-	closeMapBytes         = []byte("]")
-	lenEqualsBytes        = []byte("len=")
-	capEqualsBytes        = []byte("cap=")
-)
-
-// hexDigits is used to map a decimal value to a hex digit.
-var hexDigits = "0123456789abcdef"
-
-// catchPanic handles any panics that might occur during the handleMethods
-// calls.
-func catchPanic(w io.Writer, v reflect.Value) {
-	if err := recover(); err != nil {
-		w.Write(panicBytes)
-		fmt.Fprintf(w, "%v", err)
-		w.Write(closeParenBytes)
-	}
-}
-
-// handleMethods attempts to call the Error and String methods on the underlying
-// type the passed reflect.Value represents and outputes the result to Writer w.
-//
-// It handles panics in any called methods by catching and displaying the error
-// as the formatted value.
-func handleMethods(cs *ConfigState, w io.Writer, v reflect.Value) (handled bool) {
-	// We need an interface to check if the type implements the error or
-	// Stringer interface.  However, the reflect package won't give us an
-	// interface on certain things like unexported struct fields in order
-	// to enforce visibility rules.  We use unsafe, when it's available,
-	// to bypass these restrictions since this package does not mutate the
-	// values.
-	if !v.CanInterface() {
-		if UnsafeDisabled {
-			return false
-		}
-
-		v = unsafeReflectValue(v)
-	}
-
-	// Choose whether or not to do error and Stringer interface lookups against
-	// the base type or a pointer to the base type depending on settings.
-	// Technically calling one of these methods with a pointer receiver can
-	// mutate the value, however, types which choose to satisify an error or
-	// Stringer interface with a pointer receiver should not be mutating their
-	// state inside these interface methods.
-	if !cs.DisablePointerMethods && !UnsafeDisabled && !v.CanAddr() {
-		v = unsafeReflectValue(v)
-	}
-	if v.CanAddr() {
-		v = v.Addr()
-	}
-
-	// Is it an error or Stringer?
-	switch iface := v.Interface().(type) {
-	case error:
-		defer catchPanic(w, v)
-		if cs.ContinueOnMethod {
-			w.Write(openParenBytes)
-			w.Write([]byte(iface.Error()))
-			w.Write(closeParenBytes)
-			w.Write(spaceBytes)
-			return false
-		}
-
-		w.Write([]byte(iface.Error()))
-		return true
-
-	case fmt.Stringer:
-		defer catchPanic(w, v)
-		if cs.ContinueOnMethod {
-			w.Write(openParenBytes)
-			w.Write([]byte(iface.String()))
-			w.Write(closeParenBytes)
-			w.Write(spaceBytes)
-			return false
-		}
-		w.Write([]byte(iface.String()))
-		return true
-	}
-	return false
-}
-
-// printBool outputs a boolean value as true or false to Writer w.
-func printBool(w io.Writer, val bool) {
-	if val {
-		w.Write(trueBytes)
-	} else {
-		w.Write(falseBytes)
-	}
-}
-
-// printInt outputs a signed integer value to Writer w.
-func printInt(w io.Writer, val int64, base int) {
-	w.Write([]byte(strconv.FormatInt(val, base)))
-}
-
-// printUint outputs an unsigned integer value to Writer w.
-func printUint(w io.Writer, val uint64, base int) {
-	w.Write([]byte(strconv.FormatUint(val, base)))
-}
-
-// printFloat outputs a floating point value using the specified precision,
-// which is expected to be 32 or 64bit, to Writer w.
-func printFloat(w io.Writer, val float64, precision int) {
-	w.Write([]byte(strconv.FormatFloat(val, 'g', -1, precision)))
-}
-
-// printComplex outputs a complex value using the specified float precision
-// for the real and imaginary parts to Writer w.
-func printComplex(w io.Writer, c complex128, floatPrecision int) {
-	r := real(c)
-	w.Write(openParenBytes)
-	w.Write([]byte(strconv.FormatFloat(r, 'g', -1, floatPrecision)))
-	i := imag(c)
-	if i >= 0 {
-		w.Write(plusBytes)
-	}
-	w.Write([]byte(strconv.FormatFloat(i, 'g', -1, floatPrecision)))
-	w.Write(iBytes)
-	w.Write(closeParenBytes)
-}
-
-// printHexPtr outputs a uintptr formatted as hexadecimal with a leading '0x'
-// prefix to Writer w.
-func printHexPtr(w io.Writer, p uintptr) {
-	// Null pointer.
-	num := uint64(p)
-	if num == 0 {
-		w.Write(nilAngleBytes)
-		return
-	}
-
-	// Max uint64 is 16 bytes in hex + 2 bytes for '0x' prefix
-	buf := make([]byte, 18)
-
-	// It's simpler to construct the hex string right to left.
-	base := uint64(16)
-	i := len(buf) - 1
-	for num >= base {
-		buf[i] = hexDigits[num%base]
-		num /= base
-		i--
-	}
-	buf[i] = hexDigits[num]
-
-	// Add '0x' prefix.
-	i--
-	buf[i] = 'x'
-	i--
-	buf[i] = '0'
-
-	// Strip unused leading bytes.
-	buf = buf[i:]
-	w.Write(buf)
-}
-
-// valuesSorter implements sort.Interface to allow a slice of reflect.Value
-// elements to be sorted.
-type valuesSorter struct {
-	values  []reflect.Value
-	strings []string // either nil or same len and values
-	cs      *ConfigState
-}
-
-// newValuesSorter initializes a valuesSorter instance, which holds a set of
-// surrogate keys on which the data should be sorted.  It uses flags in
-// ConfigState to decide if and how to populate those surrogate keys.
-func newValuesSorter(values []reflect.Value, cs *ConfigState) sort.Interface {
-	vs := &valuesSorter{values: values, cs: cs}
-	if canSortSimply(vs.values[0].Kind()) {
-		return vs
-	}
-	if !cs.DisableMethods {
-		vs.strings = make([]string, len(values))
-		for i := range vs.values {
-			b := bytes.Buffer{}
-			if !handleMethods(cs, &b, vs.values[i]) {
-				vs.strings = nil
-				break
-			}
-			vs.strings[i] = b.String()
-		}
-	}
-	if vs.strings == nil && cs.SpewKeys {
-		vs.strings = make([]string, len(values))
-		for i := range vs.values {
-			vs.strings[i] = Sprintf("%#v", vs.values[i].Interface())
-		}
-	}
-	return vs
-}
-
-// canSortSimply tests whether a reflect.Kind is a primitive that can be sorted
-// directly, or whether it should be considered for sorting by surrogate keys
-// (if the ConfigState allows it).
-func canSortSimply(kind reflect.Kind) bool {
-	// This switch parallels valueSortLess, except for the default case.
-	switch kind {
-	case reflect.Bool:
-		return true
-	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
-		return true
-	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
-		return true
-	case reflect.Float32, reflect.Float64:
-		return true
-	case reflect.String:
-		return true
-	case reflect.Uintptr:
-		return true
-	case reflect.Array:
-		return true
-	}
-	return false
-}
-
-// Len returns the number of values in the slice.  It is part of the
-// sort.Interface implementation.
-func (s *valuesSorter) Len() int {
-	return len(s.values)
-}
-
-// Swap swaps the values at the passed indices.  It is part of the
-// sort.Interface implementation.
-func (s *valuesSorter) Swap(i, j int) {
-	s.values[i], s.values[j] = s.values[j], s.values[i]
-	if s.strings != nil {
-		s.strings[i], s.strings[j] = s.strings[j], s.strings[i]
-	}
-}
-
-// valueSortLess returns whether the first value should sort before the second
-// value.  It is used by valueSorter.Less as part of the sort.Interface
-// implementation.
-func valueSortLess(a, b reflect.Value) bool {
-	switch a.Kind() {
-	case reflect.Bool:
-		return !a.Bool() && b.Bool()
-	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
-		return a.Int() < b.Int()
-	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
-		return a.Uint() < b.Uint()
-	case reflect.Float32, reflect.Float64:
-		return a.Float() < b.Float()
-	case reflect.String:
-		return a.String() < b.String()
-	case reflect.Uintptr:
-		return a.Uint() < b.Uint()
-	case reflect.Array:
-		// Compare the contents of both arrays.
-		l := a.Len()
-		for i := 0; i < l; i++ {
-			av := a.Index(i)
-			bv := b.Index(i)
-			if av.Interface() == bv.Interface() {
-				continue
-			}
-			return valueSortLess(av, bv)
-		}
-	}
-	return a.String() < b.String()
-}
-
-// Less returns whether the value at index i should sort before the
-// value at index j.  It is part of the sort.Interface implementation.
-func (s *valuesSorter) Less(i, j int) bool {
-	if s.strings == nil {
-		return valueSortLess(s.values[i], s.values[j])
-	}
-	return s.strings[i] < s.strings[j]
-}
-
-// sortValues is a sort function that handles both native types and any type that
-// can be converted to error or Stringer.  Other inputs are sorted according to
-// their Value.String() value to ensure display stability.
-func sortValues(values []reflect.Value, cs *ConfigState) {
-	if len(values) == 0 {
-		return
-	}
-	sort.Sort(newValuesSorter(values, cs))
-}
diff --git a/vendor/github.com/davecgh/go-spew/spew/config.go b/vendor/github.com/davecgh/go-spew/spew/config.go
deleted file mode 100644
index 2e3d22f31..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/config.go
+++ /dev/null
@@ -1,306 +0,0 @@
-/*
- * Copyright (c) 2013-2016 Dave Collins 
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-package spew
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"os"
-)
-
-// ConfigState houses the configuration options used by spew to format and
-// display values.  There is a global instance, Config, that is used to control
-// all top-level Formatter and Dump functionality.  Each ConfigState instance
-// provides methods equivalent to the top-level functions.
-//
-// The zero value for ConfigState provides no indentation.  You would typically
-// want to set it to a space or a tab.
-//
-// Alternatively, you can use NewDefaultConfig to get a ConfigState instance
-// with default settings.  See the documentation of NewDefaultConfig for default
-// values.
-type ConfigState struct {
-	// Indent specifies the string to use for each indentation level.  The
-	// global config instance that all top-level functions use set this to a
-	// single space by default.  If you would like more indentation, you might
-	// set this to a tab with "\t" or perhaps two spaces with "  ".
-	Indent string
-
-	// MaxDepth controls the maximum number of levels to descend into nested
-	// data structures.  The default, 0, means there is no limit.
-	//
-	// NOTE: Circular data structures are properly detected, so it is not
-	// necessary to set this value unless you specifically want to limit deeply
-	// nested data structures.
-	MaxDepth int
-
-	// DisableMethods specifies whether or not error and Stringer interfaces are
-	// invoked for types that implement them.
-	DisableMethods bool
-
-	// DisablePointerMethods specifies whether or not to check for and invoke
-	// error and Stringer interfaces on types which only accept a pointer
-	// receiver when the current type is not a pointer.
-	//
-	// NOTE: This might be an unsafe action since calling one of these methods
-	// with a pointer receiver could technically mutate the value, however,
-	// in practice, types which choose to satisify an error or Stringer
-	// interface with a pointer receiver should not be mutating their state
-	// inside these interface methods.  As a result, this option relies on
-	// access to the unsafe package, so it will not have any effect when
-	// running in environments without access to the unsafe package such as
-	// Google App Engine or with the "safe" build tag specified.
-	DisablePointerMethods bool
-
-	// DisablePointerAddresses specifies whether to disable the printing of
-	// pointer addresses. This is useful when diffing data structures in tests.
-	DisablePointerAddresses bool
-
-	// DisableCapacities specifies whether to disable the printing of capacities
-	// for arrays, slices, maps and channels. This is useful when diffing
-	// data structures in tests.
-	DisableCapacities bool
-
-	// ContinueOnMethod specifies whether or not recursion should continue once
-	// a custom error or Stringer interface is invoked.  The default, false,
-	// means it will print the results of invoking the custom error or Stringer
-	// interface and return immediately instead of continuing to recurse into
-	// the internals of the data type.
-	//
-	// NOTE: This flag does not have any effect if method invocation is disabled
-	// via the DisableMethods or DisablePointerMethods options.
-	ContinueOnMethod bool
-
-	// SortKeys specifies map keys should be sorted before being printed. Use
-	// this to have a more deterministic, diffable output.  Note that only
-	// native types (bool, int, uint, floats, uintptr and string) and types
-	// that support the error or Stringer interfaces (if methods are
-	// enabled) are supported, with other types sorted according to the
-	// reflect.Value.String() output which guarantees display stability.
-	SortKeys bool
-
-	// SpewKeys specifies that, as a last resort attempt, map keys should
-	// be spewed to strings and sorted by those strings.  This is only
-	// considered if SortKeys is true.
-	SpewKeys bool
-}
-
-// Config is the active configuration of the top-level functions.
-// The configuration can be changed by modifying the contents of spew.Config.
-var Config = ConfigState{Indent: " "}
-
-// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the formatted string as a value that satisfies error.  See NewFormatter
-// for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Errorf(format, c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Errorf(format string, a ...interface{}) (err error) {
-	return fmt.Errorf(format, c.convertArgs(a)...)
-}
-
-// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Fprint(w, c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Fprint(w io.Writer, a ...interface{}) (n int, err error) {
-	return fmt.Fprint(w, c.convertArgs(a)...)
-}
-
-// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Fprintf(w, format, c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
-	return fmt.Fprintf(w, format, c.convertArgs(a)...)
-}
-
-// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
-// passed with a Formatter interface returned by c.NewFormatter.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Fprintln(w, c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
-	return fmt.Fprintln(w, c.convertArgs(a)...)
-}
-
-// Print is a wrapper for fmt.Print that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Print(c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Print(a ...interface{}) (n int, err error) {
-	return fmt.Print(c.convertArgs(a)...)
-}
-
-// Printf is a wrapper for fmt.Printf that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Printf(format, c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Printf(format string, a ...interface{}) (n int, err error) {
-	return fmt.Printf(format, c.convertArgs(a)...)
-}
-
-// Println is a wrapper for fmt.Println that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Println(c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Println(a ...interface{}) (n int, err error) {
-	return fmt.Println(c.convertArgs(a)...)
-}
-
-// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the resulting string.  See NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Sprint(c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Sprint(a ...interface{}) string {
-	return fmt.Sprint(c.convertArgs(a)...)
-}
-
-// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
-// passed with a Formatter interface returned by c.NewFormatter.  It returns
-// the resulting string.  See NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Sprintf(format, c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Sprintf(format string, a ...interface{}) string {
-	return fmt.Sprintf(format, c.convertArgs(a)...)
-}
-
-// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
-// were passed with a Formatter interface returned by c.NewFormatter.  It
-// returns the resulting string.  See NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Sprintln(c.NewFormatter(a), c.NewFormatter(b))
-func (c *ConfigState) Sprintln(a ...interface{}) string {
-	return fmt.Sprintln(c.convertArgs(a)...)
-}
-
-/*
-NewFormatter returns a custom formatter that satisfies the fmt.Formatter
-interface.  As a result, it integrates cleanly with standard fmt package
-printing functions.  The formatter is useful for inline printing of smaller data
-types similar to the standard %v format specifier.
-
-The custom formatter only responds to the %v (most compact), %+v (adds pointer
-addresses), %#v (adds types), and %#+v (adds types and pointer addresses) verb
-combinations.  Any other verbs such as %x and %q will be sent to the the
-standard fmt package for formatting.  In addition, the custom formatter ignores
-the width and precision arguments (however they will still work on the format
-specifiers not handled by the custom formatter).
-
-Typically this function shouldn't be called directly.  It is much easier to make
-use of the custom formatter by calling one of the convenience functions such as
-c.Printf, c.Println, or c.Printf.
-*/
-func (c *ConfigState) NewFormatter(v interface{}) fmt.Formatter {
-	return newFormatter(c, v)
-}
-
-// Fdump formats and displays the passed arguments to io.Writer w.  It formats
-// exactly the same as Dump.
-func (c *ConfigState) Fdump(w io.Writer, a ...interface{}) {
-	fdump(c, w, a...)
-}
-
-/*
-Dump displays the passed parameters to standard out with newlines, customizable
-indentation, and additional debug information such as complete types and all
-pointer addresses used to indirect to the final value.  It provides the
-following features over the built-in printing facilities provided by the fmt
-package:
-
-	* Pointers are dereferenced and followed
-	* Circular data structures are detected and handled properly
-	* Custom Stringer/error interfaces are optionally invoked, including
-	  on unexported types
-	* Custom types which only implement the Stringer/error interfaces via
-	  a pointer receiver are optionally invoked when passing non-pointer
-	  variables
-	* Byte arrays and slices are dumped like the hexdump -C command which
-	  includes offsets, byte values in hex, and ASCII output
-
-The configuration options are controlled by modifying the public members
-of c.  See ConfigState for options documentation.
-
-See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
-get the formatted result as a string.
-*/
-func (c *ConfigState) Dump(a ...interface{}) {
-	fdump(c, os.Stdout, a...)
-}
-
-// Sdump returns a string with the passed arguments formatted exactly the same
-// as Dump.
-func (c *ConfigState) Sdump(a ...interface{}) string {
-	var buf bytes.Buffer
-	fdump(c, &buf, a...)
-	return buf.String()
-}
-
-// convertArgs accepts a slice of arguments and returns a slice of the same
-// length with each argument converted to a spew Formatter interface using
-// the ConfigState associated with s.
-func (c *ConfigState) convertArgs(args []interface{}) (formatters []interface{}) {
-	formatters = make([]interface{}, len(args))
-	for index, arg := range args {
-		formatters[index] = newFormatter(c, arg)
-	}
-	return formatters
-}
-
-// NewDefaultConfig returns a ConfigState with the following default settings.
-//
-// 	Indent: " "
-// 	MaxDepth: 0
-// 	DisableMethods: false
-// 	DisablePointerMethods: false
-// 	ContinueOnMethod: false
-// 	SortKeys: false
-func NewDefaultConfig() *ConfigState {
-	return &ConfigState{Indent: " "}
-}
diff --git a/vendor/github.com/davecgh/go-spew/spew/doc.go b/vendor/github.com/davecgh/go-spew/spew/doc.go
deleted file mode 100644
index aacaac6f1..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/doc.go
+++ /dev/null
@@ -1,211 +0,0 @@
-/*
- * Copyright (c) 2013-2016 Dave Collins 
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-/*
-Package spew implements a deep pretty printer for Go data structures to aid in
-debugging.
-
-A quick overview of the additional features spew provides over the built-in
-printing facilities for Go data types are as follows:
-
-	* Pointers are dereferenced and followed
-	* Circular data structures are detected and handled properly
-	* Custom Stringer/error interfaces are optionally invoked, including
-	  on unexported types
-	* Custom types which only implement the Stringer/error interfaces via
-	  a pointer receiver are optionally invoked when passing non-pointer
-	  variables
-	* Byte arrays and slices are dumped like the hexdump -C command which
-	  includes offsets, byte values in hex, and ASCII output (only when using
-	  Dump style)
-
-There are two different approaches spew allows for dumping Go data structures:
-
-	* Dump style which prints with newlines, customizable indentation,
-	  and additional debug information such as types and all pointer addresses
-	  used to indirect to the final value
-	* A custom Formatter interface that integrates cleanly with the standard fmt
-	  package and replaces %v, %+v, %#v, and %#+v to provide inline printing
-	  similar to the default %v while providing the additional functionality
-	  outlined above and passing unsupported format verbs such as %x and %q
-	  along to fmt
-
-Quick Start
-
-This section demonstrates how to quickly get started with spew.  See the
-sections below for further details on formatting and configuration options.
-
-To dump a variable with full newlines, indentation, type, and pointer
-information use Dump, Fdump, or Sdump:
-	spew.Dump(myVar1, myVar2, ...)
-	spew.Fdump(someWriter, myVar1, myVar2, ...)
-	str := spew.Sdump(myVar1, myVar2, ...)
-
-Alternatively, if you would prefer to use format strings with a compacted inline
-printing style, use the convenience wrappers Printf, Fprintf, etc with
-%v (most compact), %+v (adds pointer addresses), %#v (adds types), or
-%#+v (adds types and pointer addresses):
-	spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
-	spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
-	spew.Fprintf(someWriter, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
-	spew.Fprintf(someWriter, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
-
-Configuration Options
-
-Configuration of spew is handled by fields in the ConfigState type.  For
-convenience, all of the top-level functions use a global state available
-via the spew.Config global.
-
-It is also possible to create a ConfigState instance that provides methods
-equivalent to the top-level functions.  This allows concurrent configuration
-options.  See the ConfigState documentation for more details.
-
-The following configuration options are available:
-	* Indent
-		String to use for each indentation level for Dump functions.
-		It is a single space by default.  A popular alternative is "\t".
-
-	* MaxDepth
-		Maximum number of levels to descend into nested data structures.
-		There is no limit by default.
-
-	* DisableMethods
-		Disables invocation of error and Stringer interface methods.
-		Method invocation is enabled by default.
-
-	* DisablePointerMethods
-		Disables invocation of error and Stringer interface methods on types
-		which only accept pointer receivers from non-pointer variables.
-		Pointer method invocation is enabled by default.
-
-	* DisablePointerAddresses
-		DisablePointerAddresses specifies whether to disable the printing of
-		pointer addresses. This is useful when diffing data structures in tests.
-
-	* DisableCapacities
-		DisableCapacities specifies whether to disable the printing of
-		capacities for arrays, slices, maps and channels. This is useful when
-		diffing data structures in tests.
-
-	* ContinueOnMethod
-		Enables recursion into types after invoking error and Stringer interface
-		methods. Recursion after method invocation is disabled by default.
-
-	* SortKeys
-		Specifies map keys should be sorted before being printed. Use
-		this to have a more deterministic, diffable output.  Note that
-		only native types (bool, int, uint, floats, uintptr and string)
-		and types which implement error or Stringer interfaces are
-		supported with other types sorted according to the
-		reflect.Value.String() output which guarantees display
-		stability.  Natural map order is used by default.
-
-	* SpewKeys
-		Specifies that, as a last resort attempt, map keys should be
-		spewed to strings and sorted by those strings.  This is only
-		considered if SortKeys is true.
-
-Dump Usage
-
-Simply call spew.Dump with a list of variables you want to dump:
-
-	spew.Dump(myVar1, myVar2, ...)
-
-You may also call spew.Fdump if you would prefer to output to an arbitrary
-io.Writer.  For example, to dump to standard error:
-
-	spew.Fdump(os.Stderr, myVar1, myVar2, ...)
-
-A third option is to call spew.Sdump to get the formatted output as a string:
-
-	str := spew.Sdump(myVar1, myVar2, ...)
-
-Sample Dump Output
-
-See the Dump example for details on the setup of the types and variables being
-shown here.
-
-	(main.Foo) {
-	 unexportedField: (*main.Bar)(0xf84002e210)({
-	  flag: (main.Flag) flagTwo,
-	  data: (uintptr) 
-	 }),
-	 ExportedField: (map[interface {}]interface {}) (len=1) {
-	  (string) (len=3) "one": (bool) true
-	 }
-	}
-
-Byte (and uint8) arrays and slices are displayed uniquely like the hexdump -C
-command as shown.
-	([]uint8) (len=32 cap=32) {
-	 00000000  11 12 13 14 15 16 17 18  19 1a 1b 1c 1d 1e 1f 20  |............... |
-	 00000010  21 22 23 24 25 26 27 28  29 2a 2b 2c 2d 2e 2f 30  |!"#$%&'()*+,-./0|
-	 00000020  31 32                                             |12|
-	}
-
-Custom Formatter
-
-Spew provides a custom formatter that implements the fmt.Formatter interface
-so that it integrates cleanly with standard fmt package printing functions. The
-formatter is useful for inline printing of smaller data types similar to the
-standard %v format specifier.
-
-The custom formatter only responds to the %v (most compact), %+v (adds pointer
-addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
-combinations.  Any other verbs such as %x and %q will be sent to the the
-standard fmt package for formatting.  In addition, the custom formatter ignores
-the width and precision arguments (however they will still work on the format
-specifiers not handled by the custom formatter).
-
-Custom Formatter Usage
-
-The simplest way to make use of the spew custom formatter is to call one of the
-convenience functions such as spew.Printf, spew.Println, or spew.Printf.  The
-functions have syntax you are most likely already familiar with:
-
-	spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
-	spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
-	spew.Println(myVar, myVar2)
-	spew.Fprintf(os.Stderr, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
-	spew.Fprintf(os.Stderr, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
-
-See the Index for the full list convenience functions.
-
-Sample Formatter Output
-
-Double pointer to a uint8:
-	  %v: <**>5
-	 %+v: <**>(0xf8400420d0->0xf8400420c8)5
-	 %#v: (**uint8)5
-	%#+v: (**uint8)(0xf8400420d0->0xf8400420c8)5
-
-Pointer to circular struct with a uint8 field and a pointer to itself:
-	  %v: <*>{1 <*>}
-	 %+v: <*>(0xf84003e260){ui8:1 c:<*>(0xf84003e260)}
-	 %#v: (*main.circular){ui8:(uint8)1 c:(*main.circular)}
-	%#+v: (*main.circular)(0xf84003e260){ui8:(uint8)1 c:(*main.circular)(0xf84003e260)}
-
-See the Printf example for details on the setup of variables being shown
-here.
-
-Errors
-
-Since it is possible for custom Stringer/error interfaces to panic, spew
-detects them and handles them internally by printing the panic information
-inline with the output.  Since spew is intended to provide deep pretty printing
-capabilities on structures, it intentionally does not return any errors.
-*/
-package spew
diff --git a/vendor/github.com/davecgh/go-spew/spew/dump.go b/vendor/github.com/davecgh/go-spew/spew/dump.go
deleted file mode 100644
index f78d89fc1..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/dump.go
+++ /dev/null
@@ -1,509 +0,0 @@
-/*
- * Copyright (c) 2013-2016 Dave Collins 
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-package spew
-
-import (
-	"bytes"
-	"encoding/hex"
-	"fmt"
-	"io"
-	"os"
-	"reflect"
-	"regexp"
-	"strconv"
-	"strings"
-)
-
-var (
-	// uint8Type is a reflect.Type representing a uint8.  It is used to
-	// convert cgo types to uint8 slices for hexdumping.
-	uint8Type = reflect.TypeOf(uint8(0))
-
-	// cCharRE is a regular expression that matches a cgo char.
-	// It is used to detect character arrays to hexdump them.
-	cCharRE = regexp.MustCompile(`^.*\._Ctype_char$`)
-
-	// cUnsignedCharRE is a regular expression that matches a cgo unsigned
-	// char.  It is used to detect unsigned character arrays to hexdump
-	// them.
-	cUnsignedCharRE = regexp.MustCompile(`^.*\._Ctype_unsignedchar$`)
-
-	// cUint8tCharRE is a regular expression that matches a cgo uint8_t.
-	// It is used to detect uint8_t arrays to hexdump them.
-	cUint8tCharRE = regexp.MustCompile(`^.*\._Ctype_uint8_t$`)
-)
-
-// dumpState contains information about the state of a dump operation.
-type dumpState struct {
-	w                io.Writer
-	depth            int
-	pointers         map[uintptr]int
-	ignoreNextType   bool
-	ignoreNextIndent bool
-	cs               *ConfigState
-}
-
-// indent performs indentation according to the depth level and cs.Indent
-// option.
-func (d *dumpState) indent() {
-	if d.ignoreNextIndent {
-		d.ignoreNextIndent = false
-		return
-	}
-	d.w.Write(bytes.Repeat([]byte(d.cs.Indent), d.depth))
-}
-
-// unpackValue returns values inside of non-nil interfaces when possible.
-// This is useful for data types like structs, arrays, slices, and maps which
-// can contain varying types packed inside an interface.
-func (d *dumpState) unpackValue(v reflect.Value) reflect.Value {
-	if v.Kind() == reflect.Interface && !v.IsNil() {
-		v = v.Elem()
-	}
-	return v
-}
-
-// dumpPtr handles formatting of pointers by indirecting them as necessary.
-func (d *dumpState) dumpPtr(v reflect.Value) {
-	// Remove pointers at or below the current depth from map used to detect
-	// circular refs.
-	for k, depth := range d.pointers {
-		if depth >= d.depth {
-			delete(d.pointers, k)
-		}
-	}
-
-	// Keep list of all dereferenced pointers to show later.
-	pointerChain := make([]uintptr, 0)
-
-	// Figure out how many levels of indirection there are by dereferencing
-	// pointers and unpacking interfaces down the chain while detecting circular
-	// references.
-	nilFound := false
-	cycleFound := false
-	indirects := 0
-	ve := v
-	for ve.Kind() == reflect.Ptr {
-		if ve.IsNil() {
-			nilFound = true
-			break
-		}
-		indirects++
-		addr := ve.Pointer()
-		pointerChain = append(pointerChain, addr)
-		if pd, ok := d.pointers[addr]; ok && pd < d.depth {
-			cycleFound = true
-			indirects--
-			break
-		}
-		d.pointers[addr] = d.depth
-
-		ve = ve.Elem()
-		if ve.Kind() == reflect.Interface {
-			if ve.IsNil() {
-				nilFound = true
-				break
-			}
-			ve = ve.Elem()
-		}
-	}
-
-	// Display type information.
-	d.w.Write(openParenBytes)
-	d.w.Write(bytes.Repeat(asteriskBytes, indirects))
-	d.w.Write([]byte(ve.Type().String()))
-	d.w.Write(closeParenBytes)
-
-	// Display pointer information.
-	if !d.cs.DisablePointerAddresses && len(pointerChain) > 0 {
-		d.w.Write(openParenBytes)
-		for i, addr := range pointerChain {
-			if i > 0 {
-				d.w.Write(pointerChainBytes)
-			}
-			printHexPtr(d.w, addr)
-		}
-		d.w.Write(closeParenBytes)
-	}
-
-	// Display dereferenced value.
-	d.w.Write(openParenBytes)
-	switch {
-	case nilFound:
-		d.w.Write(nilAngleBytes)
-
-	case cycleFound:
-		d.w.Write(circularBytes)
-
-	default:
-		d.ignoreNextType = true
-		d.dump(ve)
-	}
-	d.w.Write(closeParenBytes)
-}
-
-// dumpSlice handles formatting of arrays and slices.  Byte (uint8 under
-// reflection) arrays and slices are dumped in hexdump -C fashion.
-func (d *dumpState) dumpSlice(v reflect.Value) {
-	// Determine whether this type should be hex dumped or not.  Also,
-	// for types which should be hexdumped, try to use the underlying data
-	// first, then fall back to trying to convert them to a uint8 slice.
-	var buf []uint8
-	doConvert := false
-	doHexDump := false
-	numEntries := v.Len()
-	if numEntries > 0 {
-		vt := v.Index(0).Type()
-		vts := vt.String()
-		switch {
-		// C types that need to be converted.
-		case cCharRE.MatchString(vts):
-			fallthrough
-		case cUnsignedCharRE.MatchString(vts):
-			fallthrough
-		case cUint8tCharRE.MatchString(vts):
-			doConvert = true
-
-		// Try to use existing uint8 slices and fall back to converting
-		// and copying if that fails.
-		case vt.Kind() == reflect.Uint8:
-			// We need an addressable interface to convert the type
-			// to a byte slice.  However, the reflect package won't
-			// give us an interface on certain things like
-			// unexported struct fields in order to enforce
-			// visibility rules.  We use unsafe, when available, to
-			// bypass these restrictions since this package does not
-			// mutate the values.
-			vs := v
-			if !vs.CanInterface() || !vs.CanAddr() {
-				vs = unsafeReflectValue(vs)
-			}
-			if !UnsafeDisabled {
-				vs = vs.Slice(0, numEntries)
-
-				// Use the existing uint8 slice if it can be
-				// type asserted.
-				iface := vs.Interface()
-				if slice, ok := iface.([]uint8); ok {
-					buf = slice
-					doHexDump = true
-					break
-				}
-			}
-
-			// The underlying data needs to be converted if it can't
-			// be type asserted to a uint8 slice.
-			doConvert = true
-		}
-
-		// Copy and convert the underlying type if needed.
-		if doConvert && vt.ConvertibleTo(uint8Type) {
-			// Convert and copy each element into a uint8 byte
-			// slice.
-			buf = make([]uint8, numEntries)
-			for i := 0; i < numEntries; i++ {
-				vv := v.Index(i)
-				buf[i] = uint8(vv.Convert(uint8Type).Uint())
-			}
-			doHexDump = true
-		}
-	}
-
-	// Hexdump the entire slice as needed.
-	if doHexDump {
-		indent := strings.Repeat(d.cs.Indent, d.depth)
-		str := indent + hex.Dump(buf)
-		str = strings.Replace(str, "\n", "\n"+indent, -1)
-		str = strings.TrimRight(str, d.cs.Indent)
-		d.w.Write([]byte(str))
-		return
-	}
-
-	// Recursively call dump for each item.
-	for i := 0; i < numEntries; i++ {
-		d.dump(d.unpackValue(v.Index(i)))
-		if i < (numEntries - 1) {
-			d.w.Write(commaNewlineBytes)
-		} else {
-			d.w.Write(newlineBytes)
-		}
-	}
-}
-
-// dump is the main workhorse for dumping a value.  It uses the passed reflect
-// value to figure out what kind of object we are dealing with and formats it
-// appropriately.  It is a recursive function, however circular data structures
-// are detected and handled properly.
-func (d *dumpState) dump(v reflect.Value) {
-	// Handle invalid reflect values immediately.
-	kind := v.Kind()
-	if kind == reflect.Invalid {
-		d.w.Write(invalidAngleBytes)
-		return
-	}
-
-	// Handle pointers specially.
-	if kind == reflect.Ptr {
-		d.indent()
-		d.dumpPtr(v)
-		return
-	}
-
-	// Print type information unless already handled elsewhere.
-	if !d.ignoreNextType {
-		d.indent()
-		d.w.Write(openParenBytes)
-		d.w.Write([]byte(v.Type().String()))
-		d.w.Write(closeParenBytes)
-		d.w.Write(spaceBytes)
-	}
-	d.ignoreNextType = false
-
-	// Display length and capacity if the built-in len and cap functions
-	// work with the value's kind and the len/cap itself is non-zero.
-	valueLen, valueCap := 0, 0
-	switch v.Kind() {
-	case reflect.Array, reflect.Slice, reflect.Chan:
-		valueLen, valueCap = v.Len(), v.Cap()
-	case reflect.Map, reflect.String:
-		valueLen = v.Len()
-	}
-	if valueLen != 0 || !d.cs.DisableCapacities && valueCap != 0 {
-		d.w.Write(openParenBytes)
-		if valueLen != 0 {
-			d.w.Write(lenEqualsBytes)
-			printInt(d.w, int64(valueLen), 10)
-		}
-		if !d.cs.DisableCapacities && valueCap != 0 {
-			if valueLen != 0 {
-				d.w.Write(spaceBytes)
-			}
-			d.w.Write(capEqualsBytes)
-			printInt(d.w, int64(valueCap), 10)
-		}
-		d.w.Write(closeParenBytes)
-		d.w.Write(spaceBytes)
-	}
-
-	// Call Stringer/error interfaces if they exist and the handle methods flag
-	// is enabled
-	if !d.cs.DisableMethods {
-		if (kind != reflect.Invalid) && (kind != reflect.Interface) {
-			if handled := handleMethods(d.cs, d.w, v); handled {
-				return
-			}
-		}
-	}
-
-	switch kind {
-	case reflect.Invalid:
-		// Do nothing.  We should never get here since invalid has already
-		// been handled above.
-
-	case reflect.Bool:
-		printBool(d.w, v.Bool())
-
-	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
-		printInt(d.w, v.Int(), 10)
-
-	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
-		printUint(d.w, v.Uint(), 10)
-
-	case reflect.Float32:
-		printFloat(d.w, v.Float(), 32)
-
-	case reflect.Float64:
-		printFloat(d.w, v.Float(), 64)
-
-	case reflect.Complex64:
-		printComplex(d.w, v.Complex(), 32)
-
-	case reflect.Complex128:
-		printComplex(d.w, v.Complex(), 64)
-
-	case reflect.Slice:
-		if v.IsNil() {
-			d.w.Write(nilAngleBytes)
-			break
-		}
-		fallthrough
-
-	case reflect.Array:
-		d.w.Write(openBraceNewlineBytes)
-		d.depth++
-		if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
-			d.indent()
-			d.w.Write(maxNewlineBytes)
-		} else {
-			d.dumpSlice(v)
-		}
-		d.depth--
-		d.indent()
-		d.w.Write(closeBraceBytes)
-
-	case reflect.String:
-		d.w.Write([]byte(strconv.Quote(v.String())))
-
-	case reflect.Interface:
-		// The only time we should get here is for nil interfaces due to
-		// unpackValue calls.
-		if v.IsNil() {
-			d.w.Write(nilAngleBytes)
-		}
-
-	case reflect.Ptr:
-		// Do nothing.  We should never get here since pointers have already
-		// been handled above.
-
-	case reflect.Map:
-		// nil maps should be indicated as different than empty maps
-		if v.IsNil() {
-			d.w.Write(nilAngleBytes)
-			break
-		}
-
-		d.w.Write(openBraceNewlineBytes)
-		d.depth++
-		if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
-			d.indent()
-			d.w.Write(maxNewlineBytes)
-		} else {
-			numEntries := v.Len()
-			keys := v.MapKeys()
-			if d.cs.SortKeys {
-				sortValues(keys, d.cs)
-			}
-			for i, key := range keys {
-				d.dump(d.unpackValue(key))
-				d.w.Write(colonSpaceBytes)
-				d.ignoreNextIndent = true
-				d.dump(d.unpackValue(v.MapIndex(key)))
-				if i < (numEntries - 1) {
-					d.w.Write(commaNewlineBytes)
-				} else {
-					d.w.Write(newlineBytes)
-				}
-			}
-		}
-		d.depth--
-		d.indent()
-		d.w.Write(closeBraceBytes)
-
-	case reflect.Struct:
-		d.w.Write(openBraceNewlineBytes)
-		d.depth++
-		if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
-			d.indent()
-			d.w.Write(maxNewlineBytes)
-		} else {
-			vt := v.Type()
-			numFields := v.NumField()
-			for i := 0; i < numFields; i++ {
-				d.indent()
-				vtf := vt.Field(i)
-				d.w.Write([]byte(vtf.Name))
-				d.w.Write(colonSpaceBytes)
-				d.ignoreNextIndent = true
-				d.dump(d.unpackValue(v.Field(i)))
-				if i < (numFields - 1) {
-					d.w.Write(commaNewlineBytes)
-				} else {
-					d.w.Write(newlineBytes)
-				}
-			}
-		}
-		d.depth--
-		d.indent()
-		d.w.Write(closeBraceBytes)
-
-	case reflect.Uintptr:
-		printHexPtr(d.w, uintptr(v.Uint()))
-
-	case reflect.UnsafePointer, reflect.Chan, reflect.Func:
-		printHexPtr(d.w, v.Pointer())
-
-	// There were not any other types at the time this code was written, but
-	// fall back to letting the default fmt package handle it in case any new
-	// types are added.
-	default:
-		if v.CanInterface() {
-			fmt.Fprintf(d.w, "%v", v.Interface())
-		} else {
-			fmt.Fprintf(d.w, "%v", v.String())
-		}
-	}
-}
-
-// fdump is a helper function to consolidate the logic from the various public
-// methods which take varying writers and config states.
-func fdump(cs *ConfigState, w io.Writer, a ...interface{}) {
-	for _, arg := range a {
-		if arg == nil {
-			w.Write(interfaceBytes)
-			w.Write(spaceBytes)
-			w.Write(nilAngleBytes)
-			w.Write(newlineBytes)
-			continue
-		}
-
-		d := dumpState{w: w, cs: cs}
-		d.pointers = make(map[uintptr]int)
-		d.dump(reflect.ValueOf(arg))
-		d.w.Write(newlineBytes)
-	}
-}
-
-// Fdump formats and displays the passed arguments to io.Writer w.  It formats
-// exactly the same as Dump.
-func Fdump(w io.Writer, a ...interface{}) {
-	fdump(&Config, w, a...)
-}
-
-// Sdump returns a string with the passed arguments formatted exactly the same
-// as Dump.
-func Sdump(a ...interface{}) string {
-	var buf bytes.Buffer
-	fdump(&Config, &buf, a...)
-	return buf.String()
-}
-
-/*
-Dump displays the passed parameters to standard out with newlines, customizable
-indentation, and additional debug information such as complete types and all
-pointer addresses used to indirect to the final value.  It provides the
-following features over the built-in printing facilities provided by the fmt
-package:
-
-	* Pointers are dereferenced and followed
-	* Circular data structures are detected and handled properly
-	* Custom Stringer/error interfaces are optionally invoked, including
-	  on unexported types
-	* Custom types which only implement the Stringer/error interfaces via
-	  a pointer receiver are optionally invoked when passing non-pointer
-	  variables
-	* Byte arrays and slices are dumped like the hexdump -C command which
-	  includes offsets, byte values in hex, and ASCII output
-
-The configuration options are controlled by an exported package global,
-spew.Config.  See ConfigState for options documentation.
-
-See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
-get the formatted result as a string.
-*/
-func Dump(a ...interface{}) {
-	fdump(&Config, os.Stdout, a...)
-}
diff --git a/vendor/github.com/davecgh/go-spew/spew/format.go b/vendor/github.com/davecgh/go-spew/spew/format.go
deleted file mode 100644
index b04edb7d7..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/format.go
+++ /dev/null
@@ -1,419 +0,0 @@
-/*
- * Copyright (c) 2013-2016 Dave Collins 
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-package spew
-
-import (
-	"bytes"
-	"fmt"
-	"reflect"
-	"strconv"
-	"strings"
-)
-
-// supportedFlags is a list of all the character flags supported by fmt package.
-const supportedFlags = "0-+# "
-
-// formatState implements the fmt.Formatter interface and contains information
-// about the state of a formatting operation.  The NewFormatter function can
-// be used to get a new Formatter which can be used directly as arguments
-// in standard fmt package printing calls.
-type formatState struct {
-	value          interface{}
-	fs             fmt.State
-	depth          int
-	pointers       map[uintptr]int
-	ignoreNextType bool
-	cs             *ConfigState
-}
-
-// buildDefaultFormat recreates the original format string without precision
-// and width information to pass in to fmt.Sprintf in the case of an
-// unrecognized type.  Unless new types are added to the language, this
-// function won't ever be called.
-func (f *formatState) buildDefaultFormat() (format string) {
-	buf := bytes.NewBuffer(percentBytes)
-
-	for _, flag := range supportedFlags {
-		if f.fs.Flag(int(flag)) {
-			buf.WriteRune(flag)
-		}
-	}
-
-	buf.WriteRune('v')
-
-	format = buf.String()
-	return format
-}
-
-// constructOrigFormat recreates the original format string including precision
-// and width information to pass along to the standard fmt package.  This allows
-// automatic deferral of all format strings this package doesn't support.
-func (f *formatState) constructOrigFormat(verb rune) (format string) {
-	buf := bytes.NewBuffer(percentBytes)
-
-	for _, flag := range supportedFlags {
-		if f.fs.Flag(int(flag)) {
-			buf.WriteRune(flag)
-		}
-	}
-
-	if width, ok := f.fs.Width(); ok {
-		buf.WriteString(strconv.Itoa(width))
-	}
-
-	if precision, ok := f.fs.Precision(); ok {
-		buf.Write(precisionBytes)
-		buf.WriteString(strconv.Itoa(precision))
-	}
-
-	buf.WriteRune(verb)
-
-	format = buf.String()
-	return format
-}
-
-// unpackValue returns values inside of non-nil interfaces when possible and
-// ensures that types for values which have been unpacked from an interface
-// are displayed when the show types flag is also set.
-// This is useful for data types like structs, arrays, slices, and maps which
-// can contain varying types packed inside an interface.
-func (f *formatState) unpackValue(v reflect.Value) reflect.Value {
-	if v.Kind() == reflect.Interface {
-		f.ignoreNextType = false
-		if !v.IsNil() {
-			v = v.Elem()
-		}
-	}
-	return v
-}
-
-// formatPtr handles formatting of pointers by indirecting them as necessary.
-func (f *formatState) formatPtr(v reflect.Value) {
-	// Display nil if top level pointer is nil.
-	showTypes := f.fs.Flag('#')
-	if v.IsNil() && (!showTypes || f.ignoreNextType) {
-		f.fs.Write(nilAngleBytes)
-		return
-	}
-
-	// Remove pointers at or below the current depth from map used to detect
-	// circular refs.
-	for k, depth := range f.pointers {
-		if depth >= f.depth {
-			delete(f.pointers, k)
-		}
-	}
-
-	// Keep list of all dereferenced pointers to possibly show later.
-	pointerChain := make([]uintptr, 0)
-
-	// Figure out how many levels of indirection there are by derferencing
-	// pointers and unpacking interfaces down the chain while detecting circular
-	// references.
-	nilFound := false
-	cycleFound := false
-	indirects := 0
-	ve := v
-	for ve.Kind() == reflect.Ptr {
-		if ve.IsNil() {
-			nilFound = true
-			break
-		}
-		indirects++
-		addr := ve.Pointer()
-		pointerChain = append(pointerChain, addr)
-		if pd, ok := f.pointers[addr]; ok && pd < f.depth {
-			cycleFound = true
-			indirects--
-			break
-		}
-		f.pointers[addr] = f.depth
-
-		ve = ve.Elem()
-		if ve.Kind() == reflect.Interface {
-			if ve.IsNil() {
-				nilFound = true
-				break
-			}
-			ve = ve.Elem()
-		}
-	}
-
-	// Display type or indirection level depending on flags.
-	if showTypes && !f.ignoreNextType {
-		f.fs.Write(openParenBytes)
-		f.fs.Write(bytes.Repeat(asteriskBytes, indirects))
-		f.fs.Write([]byte(ve.Type().String()))
-		f.fs.Write(closeParenBytes)
-	} else {
-		if nilFound || cycleFound {
-			indirects += strings.Count(ve.Type().String(), "*")
-		}
-		f.fs.Write(openAngleBytes)
-		f.fs.Write([]byte(strings.Repeat("*", indirects)))
-		f.fs.Write(closeAngleBytes)
-	}
-
-	// Display pointer information depending on flags.
-	if f.fs.Flag('+') && (len(pointerChain) > 0) {
-		f.fs.Write(openParenBytes)
-		for i, addr := range pointerChain {
-			if i > 0 {
-				f.fs.Write(pointerChainBytes)
-			}
-			printHexPtr(f.fs, addr)
-		}
-		f.fs.Write(closeParenBytes)
-	}
-
-	// Display dereferenced value.
-	switch {
-	case nilFound:
-		f.fs.Write(nilAngleBytes)
-
-	case cycleFound:
-		f.fs.Write(circularShortBytes)
-
-	default:
-		f.ignoreNextType = true
-		f.format(ve)
-	}
-}
-
-// format is the main workhorse for providing the Formatter interface.  It
-// uses the passed reflect value to figure out what kind of object we are
-// dealing with and formats it appropriately.  It is a recursive function,
-// however circular data structures are detected and handled properly.
-func (f *formatState) format(v reflect.Value) {
-	// Handle invalid reflect values immediately.
-	kind := v.Kind()
-	if kind == reflect.Invalid {
-		f.fs.Write(invalidAngleBytes)
-		return
-	}
-
-	// Handle pointers specially.
-	if kind == reflect.Ptr {
-		f.formatPtr(v)
-		return
-	}
-
-	// Print type information unless already handled elsewhere.
-	if !f.ignoreNextType && f.fs.Flag('#') {
-		f.fs.Write(openParenBytes)
-		f.fs.Write([]byte(v.Type().String()))
-		f.fs.Write(closeParenBytes)
-	}
-	f.ignoreNextType = false
-
-	// Call Stringer/error interfaces if they exist and the handle methods
-	// flag is enabled.
-	if !f.cs.DisableMethods {
-		if (kind != reflect.Invalid) && (kind != reflect.Interface) {
-			if handled := handleMethods(f.cs, f.fs, v); handled {
-				return
-			}
-		}
-	}
-
-	switch kind {
-	case reflect.Invalid:
-		// Do nothing.  We should never get here since invalid has already
-		// been handled above.
-
-	case reflect.Bool:
-		printBool(f.fs, v.Bool())
-
-	case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
-		printInt(f.fs, v.Int(), 10)
-
-	case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
-		printUint(f.fs, v.Uint(), 10)
-
-	case reflect.Float32:
-		printFloat(f.fs, v.Float(), 32)
-
-	case reflect.Float64:
-		printFloat(f.fs, v.Float(), 64)
-
-	case reflect.Complex64:
-		printComplex(f.fs, v.Complex(), 32)
-
-	case reflect.Complex128:
-		printComplex(f.fs, v.Complex(), 64)
-
-	case reflect.Slice:
-		if v.IsNil() {
-			f.fs.Write(nilAngleBytes)
-			break
-		}
-		fallthrough
-
-	case reflect.Array:
-		f.fs.Write(openBracketBytes)
-		f.depth++
-		if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
-			f.fs.Write(maxShortBytes)
-		} else {
-			numEntries := v.Len()
-			for i := 0; i < numEntries; i++ {
-				if i > 0 {
-					f.fs.Write(spaceBytes)
-				}
-				f.ignoreNextType = true
-				f.format(f.unpackValue(v.Index(i)))
-			}
-		}
-		f.depth--
-		f.fs.Write(closeBracketBytes)
-
-	case reflect.String:
-		f.fs.Write([]byte(v.String()))
-
-	case reflect.Interface:
-		// The only time we should get here is for nil interfaces due to
-		// unpackValue calls.
-		if v.IsNil() {
-			f.fs.Write(nilAngleBytes)
-		}
-
-	case reflect.Ptr:
-		// Do nothing.  We should never get here since pointers have already
-		// been handled above.
-
-	case reflect.Map:
-		// nil maps should be indicated as different than empty maps
-		if v.IsNil() {
-			f.fs.Write(nilAngleBytes)
-			break
-		}
-
-		f.fs.Write(openMapBytes)
-		f.depth++
-		if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
-			f.fs.Write(maxShortBytes)
-		} else {
-			keys := v.MapKeys()
-			if f.cs.SortKeys {
-				sortValues(keys, f.cs)
-			}
-			for i, key := range keys {
-				if i > 0 {
-					f.fs.Write(spaceBytes)
-				}
-				f.ignoreNextType = true
-				f.format(f.unpackValue(key))
-				f.fs.Write(colonBytes)
-				f.ignoreNextType = true
-				f.format(f.unpackValue(v.MapIndex(key)))
-			}
-		}
-		f.depth--
-		f.fs.Write(closeMapBytes)
-
-	case reflect.Struct:
-		numFields := v.NumField()
-		f.fs.Write(openBraceBytes)
-		f.depth++
-		if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
-			f.fs.Write(maxShortBytes)
-		} else {
-			vt := v.Type()
-			for i := 0; i < numFields; i++ {
-				if i > 0 {
-					f.fs.Write(spaceBytes)
-				}
-				vtf := vt.Field(i)
-				if f.fs.Flag('+') || f.fs.Flag('#') {
-					f.fs.Write([]byte(vtf.Name))
-					f.fs.Write(colonBytes)
-				}
-				f.format(f.unpackValue(v.Field(i)))
-			}
-		}
-		f.depth--
-		f.fs.Write(closeBraceBytes)
-
-	case reflect.Uintptr:
-		printHexPtr(f.fs, uintptr(v.Uint()))
-
-	case reflect.UnsafePointer, reflect.Chan, reflect.Func:
-		printHexPtr(f.fs, v.Pointer())
-
-	// There were not any other types at the time this code was written, but
-	// fall back to letting the default fmt package handle it if any get added.
-	default:
-		format := f.buildDefaultFormat()
-		if v.CanInterface() {
-			fmt.Fprintf(f.fs, format, v.Interface())
-		} else {
-			fmt.Fprintf(f.fs, format, v.String())
-		}
-	}
-}
-
-// Format satisfies the fmt.Formatter interface. See NewFormatter for usage
-// details.
-func (f *formatState) Format(fs fmt.State, verb rune) {
-	f.fs = fs
-
-	// Use standard formatting for verbs that are not v.
-	if verb != 'v' {
-		format := f.constructOrigFormat(verb)
-		fmt.Fprintf(fs, format, f.value)
-		return
-	}
-
-	if f.value == nil {
-		if fs.Flag('#') {
-			fs.Write(interfaceBytes)
-		}
-		fs.Write(nilAngleBytes)
-		return
-	}
-
-	f.format(reflect.ValueOf(f.value))
-}
-
-// newFormatter is a helper function to consolidate the logic from the various
-// public methods which take varying config states.
-func newFormatter(cs *ConfigState, v interface{}) fmt.Formatter {
-	fs := &formatState{value: v, cs: cs}
-	fs.pointers = make(map[uintptr]int)
-	return fs
-}
-
-/*
-NewFormatter returns a custom formatter that satisfies the fmt.Formatter
-interface.  As a result, it integrates cleanly with standard fmt package
-printing functions.  The formatter is useful for inline printing of smaller data
-types similar to the standard %v format specifier.
-
-The custom formatter only responds to the %v (most compact), %+v (adds pointer
-addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
-combinations.  Any other verbs such as %x and %q will be sent to the the
-standard fmt package for formatting.  In addition, the custom formatter ignores
-the width and precision arguments (however they will still work on the format
-specifiers not handled by the custom formatter).
-
-Typically this function shouldn't be called directly.  It is much easier to make
-use of the custom formatter by calling one of the convenience functions such as
-Printf, Println, or Fprintf.
-*/
-func NewFormatter(v interface{}) fmt.Formatter {
-	return newFormatter(&Config, v)
-}
diff --git a/vendor/github.com/davecgh/go-spew/spew/spew.go b/vendor/github.com/davecgh/go-spew/spew/spew.go
deleted file mode 100644
index 32c0e3388..000000000
--- a/vendor/github.com/davecgh/go-spew/spew/spew.go
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- * Copyright (c) 2013-2016 Dave Collins 
- *
- * Permission to use, copy, modify, and distribute this software for any
- * purpose with or without fee is hereby granted, provided that the above
- * copyright notice and this permission notice appear in all copies.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
- * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
- * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
- * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- */
-
-package spew
-
-import (
-	"fmt"
-	"io"
-)
-
-// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the formatted string as a value that satisfies error.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Errorf(format, spew.NewFormatter(a), spew.NewFormatter(b))
-func Errorf(format string, a ...interface{}) (err error) {
-	return fmt.Errorf(format, convertArgs(a)...)
-}
-
-// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Fprint(w, spew.NewFormatter(a), spew.NewFormatter(b))
-func Fprint(w io.Writer, a ...interface{}) (n int, err error) {
-	return fmt.Fprint(w, convertArgs(a)...)
-}
-
-// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Fprintf(w, format, spew.NewFormatter(a), spew.NewFormatter(b))
-func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
-	return fmt.Fprintf(w, format, convertArgs(a)...)
-}
-
-// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
-// passed with a default Formatter interface returned by NewFormatter.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Fprintln(w, spew.NewFormatter(a), spew.NewFormatter(b))
-func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
-	return fmt.Fprintln(w, convertArgs(a)...)
-}
-
-// Print is a wrapper for fmt.Print that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Print(spew.NewFormatter(a), spew.NewFormatter(b))
-func Print(a ...interface{}) (n int, err error) {
-	return fmt.Print(convertArgs(a)...)
-}
-
-// Printf is a wrapper for fmt.Printf that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Printf(format, spew.NewFormatter(a), spew.NewFormatter(b))
-func Printf(format string, a ...interface{}) (n int, err error) {
-	return fmt.Printf(format, convertArgs(a)...)
-}
-
-// Println is a wrapper for fmt.Println that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the number of bytes written and any write error encountered.  See
-// NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Println(spew.NewFormatter(a), spew.NewFormatter(b))
-func Println(a ...interface{}) (n int, err error) {
-	return fmt.Println(convertArgs(a)...)
-}
-
-// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the resulting string.  See NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Sprint(spew.NewFormatter(a), spew.NewFormatter(b))
-func Sprint(a ...interface{}) string {
-	return fmt.Sprint(convertArgs(a)...)
-}
-
-// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
-// passed with a default Formatter interface returned by NewFormatter.  It
-// returns the resulting string.  See NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Sprintf(format, spew.NewFormatter(a), spew.NewFormatter(b))
-func Sprintf(format string, a ...interface{}) string {
-	return fmt.Sprintf(format, convertArgs(a)...)
-}
-
-// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
-// were passed with a default Formatter interface returned by NewFormatter.  It
-// returns the resulting string.  See NewFormatter for formatting details.
-//
-// This function is shorthand for the following syntax:
-//
-//	fmt.Sprintln(spew.NewFormatter(a), spew.NewFormatter(b))
-func Sprintln(a ...interface{}) string {
-	return fmt.Sprintln(convertArgs(a)...)
-}
-
-// convertArgs accepts a slice of arguments and returns a slice of the same
-// length with each argument converted to a default spew Formatter interface.
-func convertArgs(args []interface{}) (formatters []interface{}) {
-	formatters = make([]interface{}, len(args))
-	for index, arg := range args {
-		formatters[index] = NewFormatter(arg)
-	}
-	return formatters
-}
diff --git a/vendor/github.com/go-test/deep/.gitignore b/vendor/github.com/go-test/deep/.gitignore
deleted file mode 100644
index 53f12f0f0..000000000
--- a/vendor/github.com/go-test/deep/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*.swp
-*.out
diff --git a/vendor/github.com/go-test/deep/.travis.yml b/vendor/github.com/go-test/deep/.travis.yml
deleted file mode 100644
index df3972fc9..000000000
--- a/vendor/github.com/go-test/deep/.travis.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-language: go
-
-go:
-  - "1.10"
-  - "1.11"
-  - "1.12"
-
-before_install:
-  - go get github.com/mattn/goveralls
-  - go get golang.org/x/tools/cover
-
-script:
-  - $HOME/gopath/bin/goveralls -service=travis-ci
diff --git a/vendor/github.com/go-test/deep/CHANGES.md b/vendor/github.com/go-test/deep/CHANGES.md
deleted file mode 100644
index 00f072b18..000000000
--- a/vendor/github.com/go-test/deep/CHANGES.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# go-test/deep Changelog
-
-## v1.0.4 released 2019-09-15
-
-* Added \`deep:"-"\` structure field tag to ignore field (PR #38) (@flga)
-
-## v1.0.3 released 2019-08-18
-
-* Fixed issue #31: panic on typed primitives that implement error interface
-
-## v1.0.2 released 2019-07-14
-
-* Enabled Go module (@radeksimko)
-* Changed supported and tested Go versions: 1.10, 1.11, and 1.12 (dropped 1.9)
-* Changed Error equality: additional struct fields are compared too (PR #29) (@andrewmostello)
-* Fixed typos and ineffassign issues (PR #25) (@tariq1890)
-* Fixed diff order for nil comparison (PR #16) (@gmarik)
-* Fixed slice equality when slices are extracted from the same array (PR #11) (@risteli)
-* Fixed test spelling and messages (PR #19) (@sofuture)
-* Fixed issue #15: panic on comparing struct with anonymous time.Time
-* Fixed issue #18: Panic when comparing structs with time.Time value and CompareUnexportedFields is true
-* Fixed issue #21: Set default MaxDepth = 0 (disabled) (PR #23)
-
-## v1.0.1 released 2018-01-28
-
-* Fixed issue #12: Arrays are not properly compared (@samlitowitz)
-
-## v1.0.0 releaesd 2017-10-27 
-
-* First release
diff --git a/vendor/github.com/go-test/deep/LICENSE b/vendor/github.com/go-test/deep/LICENSE
deleted file mode 100644
index 228ef16f7..000000000
--- a/vendor/github.com/go-test/deep/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright 2015-2017 Daniel Nichter
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/vendor/github.com/go-test/deep/README.md b/vendor/github.com/go-test/deep/README.md
deleted file mode 100644
index 3b78eac7c..000000000
--- a/vendor/github.com/go-test/deep/README.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Deep Variable Equality for Humans
-
-[![Go Report Card](https://goreportcard.com/badge/github.com/go-test/deep)](https://goreportcard.com/report/github.com/go-test/deep) [![Build Status](https://travis-ci.org/go-test/deep.svg?branch=master)](https://travis-ci.org/go-test/deep) [![Coverage Status](https://coveralls.io/repos/github/go-test/deep/badge.svg?branch=master)](https://coveralls.io/github/go-test/deep?branch=master) [![GoDoc](https://godoc.org/github.com/go-test/deep?status.svg)](https://godoc.org/github.com/go-test/deep)
-
-This package provides a single function: `deep.Equal`. It's like [reflect.DeepEqual](http://golang.org/pkg/reflect/#DeepEqual) but much friendlier to humans (or any sentient being) for two reason:
-
-* `deep.Equal` returns a list of differences
-* `deep.Equal` does not compare unexported fields (by default)
-
-`reflect.DeepEqual` is good (like all things Golang!), but it's a game of [Hunt the Wumpus](https://en.wikipedia.org/wiki/Hunt_the_Wumpus). For large maps, slices, and structs, finding the difference is difficult.
-
-`deep.Equal` doesn't play games with you, it lists the differences:
-
-```go
-package main_test
-
-import (
-	"testing"
-	"github.com/go-test/deep"
-)
-
-type T struct {
-	Name    string
-	Numbers []float64
-}
-
-func TestDeepEqual(t *testing.T) {
-	// Can you spot the difference?
-	t1 := T{
-		Name:    "Isabella",
-		Numbers: []float64{1.13459, 2.29343, 3.010100010},
-	}
-	t2 := T{
-		Name:    "Isabella",
-		Numbers: []float64{1.13459, 2.29843, 3.010100010},
-	}
-
-	if diff := deep.Equal(t1, t2); diff != nil {
-		t.Error(diff)
-	}
-}
-```
-
-
-```
-$ go test
---- FAIL: TestDeepEqual (0.00s)
-        main_test.go:25: [Numbers.slice[1]: 2.29343 != 2.29843]
-```
-
-The difference is in `Numbers.slice[1]`: the two values aren't equal using Go `==`.
diff --git a/vendor/github.com/go-test/deep/deep.go b/vendor/github.com/go-test/deep/deep.go
deleted file mode 100644
index 7f8789512..000000000
--- a/vendor/github.com/go-test/deep/deep.go
+++ /dev/null
@@ -1,376 +0,0 @@
-// Package deep provides function deep.Equal which is like reflect.DeepEqual but
-// returns a list of differences. This is helpful when comparing complex types
-// like structures and maps.
-package deep
-
-import (
-	"errors"
-	"fmt"
-	"log"
-	"reflect"
-	"strings"
-)
-
-var (
-	// FloatPrecision is the number of decimal places to round float values
-	// to when comparing.
-	FloatPrecision = 10
-
-	// MaxDiff specifies the maximum number of differences to return.
-	MaxDiff = 10
-
-	// MaxDepth specifies the maximum levels of a struct to recurse into,
-	// if greater than zero. If zero, there is no limit.
-	MaxDepth = 0
-
-	// LogErrors causes errors to be logged to STDERR when true.
-	LogErrors = false
-
-	// CompareUnexportedFields causes unexported struct fields, like s in
-	// T{s int}, to be compared when true.
-	CompareUnexportedFields = false
-)
-
-var (
-	// ErrMaxRecursion is logged when MaxDepth is reached.
-	ErrMaxRecursion = errors.New("recursed to MaxDepth")
-
-	// ErrTypeMismatch is logged when Equal passed two different types of values.
-	ErrTypeMismatch = errors.New("variables are different reflect.Type")
-
-	// ErrNotHandled is logged when a primitive Go kind is not handled.
-	ErrNotHandled = errors.New("cannot compare the reflect.Kind")
-)
-
-type cmp struct {
-	diff        []string
-	buff        []string
-	floatFormat string
-}
-
-var errorType = reflect.TypeOf((*error)(nil)).Elem()
-
-// Equal compares variables a and b, recursing into their structure up to
-// MaxDepth levels deep (if greater than zero), and returns a list of differences,
-// or nil if there are none. Some differences may not be found if an error is
-// also returned.
-//
-// If a type has an Equal method, like time.Equal, it is called to check for
-// equality.
-//
-// When comparing a struct, if a field has the tag `deep:"-"` then it will be
-// ignored.
-func Equal(a, b interface{}) []string {
-	aVal := reflect.ValueOf(a)
-	bVal := reflect.ValueOf(b)
-	c := &cmp{
-		diff:        []string{},
-		buff:        []string{},
-		floatFormat: fmt.Sprintf("%%.%df", FloatPrecision),
-	}
-	if a == nil && b == nil {
-		return nil
-	} else if a == nil && b != nil {
-		c.saveDiff("", b)
-	} else if a != nil && b == nil {
-		c.saveDiff(a, "")
-	}
-	if len(c.diff) > 0 {
-		return c.diff
-	}
-
-	c.equals(aVal, bVal, 0)
-	if len(c.diff) > 0 {
-		return c.diff // diffs
-	}
-	return nil // no diffs
-}
-
-func (c *cmp) equals(a, b reflect.Value, level int) {
-	if MaxDepth > 0 && level > MaxDepth {
-		logError(ErrMaxRecursion)
-		return
-	}
-
-	// Check if one value is nil, e.g. T{x: *X} and T.x is nil
-	if !a.IsValid() || !b.IsValid() {
-		if a.IsValid() && !b.IsValid() {
-			c.saveDiff(a.Type(), "")
-		} else if !a.IsValid() && b.IsValid() {
-			c.saveDiff("", b.Type())
-		}
-		return
-	}
-
-	// If different types, they can't be equal
-	aType := a.Type()
-	bType := b.Type()
-	if aType != bType {
-		c.saveDiff(aType, bType)
-		logError(ErrTypeMismatch)
-		return
-	}
-
-	// Primitive https://golang.org/pkg/reflect/#Kind
-	aKind := a.Kind()
-	bKind := b.Kind()
-
-	// Do a and b have underlying elements? Yes if they're ptr or interface.
-	aElem := aKind == reflect.Ptr || aKind == reflect.Interface
-	bElem := bKind == reflect.Ptr || bKind == reflect.Interface
-
-	// If both types implement the error interface, compare the error strings.
-	// This must be done before dereferencing because the interface is on a
-	// pointer receiver. Re https://github.com/go-test/deep/issues/31, a/b might
-	// be primitive kinds; see TestErrorPrimitiveKind.
-	if aType.Implements(errorType) && bType.Implements(errorType) {
-		if (!aElem || !a.IsNil()) && (!bElem || !b.IsNil()) {
-			aString := a.MethodByName("Error").Call(nil)[0].String()
-			bString := b.MethodByName("Error").Call(nil)[0].String()
-			if aString != bString {
-				c.saveDiff(aString, bString)
-				return
-			}
-		}
-	}
-
-	// Dereference pointers and interface{}
-	if aElem || bElem {
-		if aElem {
-			a = a.Elem()
-		}
-		if bElem {
-			b = b.Elem()
-		}
-		c.equals(a, b, level+1)
-		return
-	}
-
-	switch aKind {
-
-	/////////////////////////////////////////////////////////////////////
-	// Iterable kinds
-	/////////////////////////////////////////////////////////////////////
-
-	case reflect.Struct:
-		/*
-			The variables are structs like:
-				type T struct {
-					FirstName string
-					LastName  string
-				}
-			Type = .T, Kind = reflect.Struct
-
-			Iterate through the fields (FirstName, LastName), recurse into their values.
-		*/
-
-		// Types with an Equal() method, like time.Time, only if struct field
-		// is exported (CanInterface)
-		if eqFunc := a.MethodByName("Equal"); eqFunc.IsValid() && eqFunc.CanInterface() {
-			// Handle https://github.com/go-test/deep/issues/15:
-			// Don't call T.Equal if the method is from an embedded struct, like:
-			//   type Foo struct { time.Time }
-			// First, we'll encounter Equal(Ttime, time.Time) but if we pass b
-			// as the 2nd arg we'll panic: "Call using pkg.Foo as type time.Time"
-			// As far as I can tell, there's no way to see that the method is from
-			// time.Time not Foo. So we check the type of the 1st (0) arg and skip
-			// unless it's b type. Later, we'll encounter the time.Time anonymous/
-			// embedded field and then we'll have Equal(time.Time, time.Time).
-			funcType := eqFunc.Type()
-			if funcType.NumIn() == 1 && funcType.In(0) == bType {
-				retVals := eqFunc.Call([]reflect.Value{b})
-				if !retVals[0].Bool() {
-					c.saveDiff(a, b)
-				}
-				return
-			}
-		}
-
-		for i := 0; i < a.NumField(); i++ {
-			if aType.Field(i).PkgPath != "" && !CompareUnexportedFields {
-				continue // skip unexported field, e.g. s in type T struct {s string}
-			}
-
-			if aType.Field(i).Tag.Get("deep") == "-" {
-				continue // field wants to be ignored
-			}
-
-			c.push(aType.Field(i).Name) // push field name to buff
-
-			// Get the Value for each field, e.g. FirstName has Type = string,
-			// Kind = reflect.String.
-			af := a.Field(i)
-			bf := b.Field(i)
-
-			// Recurse to compare the field values
-			c.equals(af, bf, level+1)
-
-			c.pop() // pop field name from buff
-
-			if len(c.diff) >= MaxDiff {
-				break
-			}
-		}
-	case reflect.Map:
-		/*
-			The variables are maps like:
-				map[string]int{
-					"foo": 1,
-					"bar": 2,
-				}
-			Type = map[string]int, Kind = reflect.Map
-
-			Or:
-				type T map[string]int{}
-			Type = .T, Kind = reflect.Map
-
-			Iterate through the map keys (foo, bar), recurse into their values.
-		*/
-
-		if a.IsNil() || b.IsNil() {
-			if a.IsNil() && !b.IsNil() {
-				c.saveDiff("", b)
-			} else if !a.IsNil() && b.IsNil() {
-				c.saveDiff(a, "")
-			}
-			return
-		}
-
-		if a.Pointer() == b.Pointer() {
-			return
-		}
-
-		for _, key := range a.MapKeys() {
-			c.push(fmt.Sprintf("map[%s]", key))
-
-			aVal := a.MapIndex(key)
-			bVal := b.MapIndex(key)
-			if bVal.IsValid() {
-				c.equals(aVal, bVal, level+1)
-			} else {
-				c.saveDiff(aVal, "")
-			}
-
-			c.pop()
-
-			if len(c.diff) >= MaxDiff {
-				return
-			}
-		}
-
-		for _, key := range b.MapKeys() {
-			if aVal := a.MapIndex(key); aVal.IsValid() {
-				continue
-			}
-
-			c.push(fmt.Sprintf("map[%s]", key))
-			c.saveDiff("", b.MapIndex(key))
-			c.pop()
-			if len(c.diff) >= MaxDiff {
-				return
-			}
-		}
-	case reflect.Array:
-		n := a.Len()
-		for i := 0; i < n; i++ {
-			c.push(fmt.Sprintf("array[%d]", i))
-			c.equals(a.Index(i), b.Index(i), level+1)
-			c.pop()
-			if len(c.diff) >= MaxDiff {
-				break
-			}
-		}
-	case reflect.Slice:
-		if a.IsNil() || b.IsNil() {
-			if a.IsNil() && !b.IsNil() {
-				c.saveDiff("", b)
-			} else if !a.IsNil() && b.IsNil() {
-				c.saveDiff(a, "")
-			}
-			return
-		}
-
-		aLen := a.Len()
-		bLen := b.Len()
-
-		if a.Pointer() == b.Pointer() && aLen == bLen {
-			return
-		}
-
-		n := aLen
-		if bLen > aLen {
-			n = bLen
-		}
-		for i := 0; i < n; i++ {
-			c.push(fmt.Sprintf("slice[%d]", i))
-			if i < aLen && i < bLen {
-				c.equals(a.Index(i), b.Index(i), level+1)
-			} else if i < aLen {
-				c.saveDiff(a.Index(i), "")
-			} else {
-				c.saveDiff("", b.Index(i))
-			}
-			c.pop()
-			if len(c.diff) >= MaxDiff {
-				break
-			}
-		}
-
-	/////////////////////////////////////////////////////////////////////
-	// Primitive kinds
-	/////////////////////////////////////////////////////////////////////
-
-	case reflect.Float32, reflect.Float64:
-		// Avoid 0.04147685731961082 != 0.041476857319611
-		// 6 decimal places is close enough
-		aval := fmt.Sprintf(c.floatFormat, a.Float())
-		bval := fmt.Sprintf(c.floatFormat, b.Float())
-		if aval != bval {
-			c.saveDiff(a.Float(), b.Float())
-		}
-	case reflect.Bool:
-		if a.Bool() != b.Bool() {
-			c.saveDiff(a.Bool(), b.Bool())
-		}
-	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-		if a.Int() != b.Int() {
-			c.saveDiff(a.Int(), b.Int())
-		}
-	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
-		if a.Uint() != b.Uint() {
-			c.saveDiff(a.Uint(), b.Uint())
-		}
-	case reflect.String:
-		if a.String() != b.String() {
-			c.saveDiff(a.String(), b.String())
-		}
-
-	default:
-		logError(ErrNotHandled)
-	}
-}
-
-func (c *cmp) push(name string) {
-	c.buff = append(c.buff, name)
-}
-
-func (c *cmp) pop() {
-	if len(c.buff) > 0 {
-		c.buff = c.buff[0 : len(c.buff)-1]
-	}
-}
-
-func (c *cmp) saveDiff(aval, bval interface{}) {
-	if len(c.buff) > 0 {
-		varName := strings.Join(c.buff, ".")
-		c.diff = append(c.diff, fmt.Sprintf("%s: %v != %v", varName, aval, bval))
-	} else {
-		c.diff = append(c.diff, fmt.Sprintf("%v != %v", aval, bval))
-	}
-}
-
-func logError(err error) {
-	if LogErrors {
-		log.Println(err)
-	}
-}
diff --git a/vendor/github.com/go-test/deep/go.mod b/vendor/github.com/go-test/deep/go.mod
deleted file mode 100644
index 6e8ca1d2b..000000000
--- a/vendor/github.com/go-test/deep/go.mod
+++ /dev/null
@@ -1 +0,0 @@
-module github.com/go-test/deep
diff --git a/vendor/github.com/google/go-cmp/LICENSE b/vendor/github.com/google/go-cmp/LICENSE
deleted file mode 100644
index 32017f8fa..000000000
--- a/vendor/github.com/google/go-cmp/LICENSE
+++ /dev/null
@@ -1,27 +0,0 @@
-Copyright (c) 2017 The Go Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/vendor/github.com/google/go-cmp/cmp/compare.go b/vendor/github.com/google/go-cmp/cmp/compare.go
deleted file mode 100644
index 2a5446762..000000000
--- a/vendor/github.com/google/go-cmp/cmp/compare.go
+++ /dev/null
@@ -1,665 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Package cmp determines equality of values.
-//
-// This package is intended to be a more powerful and safer alternative to
-// reflect.DeepEqual for comparing whether two values are semantically equal.
-// It is intended to only be used in tests, as performance is not a goal and
-// it may panic if it cannot compare the values. Its propensity towards
-// panicking means that its unsuitable for production environments where a
-// spurious panic may be fatal.
-//
-// The primary features of cmp are:
-//
-// • When the default behavior of equality does not suit the needs of the test,
-// custom equality functions can override the equality operation.
-// For example, an equality function may report floats as equal so long as they
-// are within some tolerance of each other.
-//
-// • Types that have an Equal method may use that method to determine equality.
-// This allows package authors to determine the equality operation for the types
-// that they define.
-//
-// • If no custom equality functions are used and no Equal method is defined,
-// equality is determined by recursively comparing the primitive kinds on both
-// values, much like reflect.DeepEqual. Unlike reflect.DeepEqual, unexported
-// fields are not compared by default; they result in panics unless suppressed
-// by using an Ignore option (see cmpopts.IgnoreUnexported) or explicitly
-// compared using the Exporter option.
-package cmp
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-
-	"github.com/google/go-cmp/cmp/internal/diff"
-	"github.com/google/go-cmp/cmp/internal/function"
-	"github.com/google/go-cmp/cmp/internal/value"
-)
-
-// Equal reports whether x and y are equal by recursively applying the
-// following rules in the given order to x and y and all of their sub-values:
-//
-// • Let S be the set of all Ignore, Transformer, and Comparer options that
-// remain after applying all path filters, value filters, and type filters.
-// If at least one Ignore exists in S, then the comparison is ignored.
-// If the number of Transformer and Comparer options in S is greater than one,
-// then Equal panics because it is ambiguous which option to use.
-// If S contains a single Transformer, then use that to transform the current
-// values and recursively call Equal on the output values.
-// If S contains a single Comparer, then use that to compare the current values.
-// Otherwise, evaluation proceeds to the next rule.
-//
-// • If the values have an Equal method of the form "(T) Equal(T) bool" or
-// "(T) Equal(I) bool" where T is assignable to I, then use the result of
-// x.Equal(y) even if x or y is nil. Otherwise, no such method exists and
-// evaluation proceeds to the next rule.
-//
-// • Lastly, try to compare x and y based on their basic kinds.
-// Simple kinds like booleans, integers, floats, complex numbers, strings, and
-// channels are compared using the equivalent of the == operator in Go.
-// Functions are only equal if they are both nil, otherwise they are unequal.
-//
-// Structs are equal if recursively calling Equal on all fields report equal.
-// If a struct contains unexported fields, Equal panics unless an Ignore option
-// (e.g., cmpopts.IgnoreUnexported) ignores that field or the Exporter option
-// explicitly permits comparing the unexported field.
-//
-// Slices are equal if they are both nil or both non-nil, where recursively
-// calling Equal on all non-ignored slice or array elements report equal.
-// Empty non-nil slices and nil slices are not equal; to equate empty slices,
-// consider using cmpopts.EquateEmpty.
-//
-// Maps are equal if they are both nil or both non-nil, where recursively
-// calling Equal on all non-ignored map entries report equal.
-// Map keys are equal according to the == operator.
-// To use custom comparisons for map keys, consider using cmpopts.SortMaps.
-// Empty non-nil maps and nil maps are not equal; to equate empty maps,
-// consider using cmpopts.EquateEmpty.
-//
-// Pointers and interfaces are equal if they are both nil or both non-nil,
-// where they have the same underlying concrete type and recursively
-// calling Equal on the underlying values reports equal.
-//
-// Before recursing into a pointer, slice element, or map, the current path
-// is checked to detect whether the address has already been visited.
-// If there is a cycle, then the pointed at values are considered equal
-// only if both addresses were previously visited in the same path step.
-func Equal(x, y interface{}, opts ...Option) bool {
-	s := newState(opts)
-	s.compareAny(rootStep(x, y))
-	return s.result.Equal()
-}
-
-// Diff returns a human-readable report of the differences between two values:
-// y - x. It returns an empty string if and only if Equal returns true for the
-// same input values and options.
-//
-// The output is displayed as a literal in pseudo-Go syntax.
-// At the start of each line, a "-" prefix indicates an element removed from x,
-// a "+" prefix to indicates an element added from y, and the lack of a prefix
-// indicates an element common to both x and y. If possible, the output
-// uses fmt.Stringer.String or error.Error methods to produce more humanly
-// readable outputs. In such cases, the string is prefixed with either an
-// 's' or 'e' character, respectively, to indicate that the method was called.
-//
-// Do not depend on this output being stable. If you need the ability to
-// programmatically interpret the difference, consider using a custom Reporter.
-func Diff(x, y interface{}, opts ...Option) string {
-	s := newState(opts)
-
-	// Optimization: If there are no other reporters, we can optimize for the
-	// common case where the result is equal (and thus no reported difference).
-	// This avoids the expensive construction of a difference tree.
-	if len(s.reporters) == 0 {
-		s.compareAny(rootStep(x, y))
-		if s.result.Equal() {
-			return ""
-		}
-		s.result = diff.Result{} // Reset results
-	}
-
-	r := new(defaultReporter)
-	s.reporters = append(s.reporters, reporter{r})
-	s.compareAny(rootStep(x, y))
-	d := r.String()
-	if (d == "") != s.result.Equal() {
-		panic("inconsistent difference and equality results")
-	}
-	return d
-}
-
-// rootStep constructs the first path step. If x and y have differing types,
-// then they are stored within an empty interface type.
-func rootStep(x, y interface{}) PathStep {
-	vx := reflect.ValueOf(x)
-	vy := reflect.ValueOf(y)
-
-	// If the inputs are different types, auto-wrap them in an empty interface
-	// so that they have the same parent type.
-	var t reflect.Type
-	if !vx.IsValid() || !vy.IsValid() || vx.Type() != vy.Type() {
-		t = reflect.TypeOf((*interface{})(nil)).Elem()
-		if vx.IsValid() {
-			vvx := reflect.New(t).Elem()
-			vvx.Set(vx)
-			vx = vvx
-		}
-		if vy.IsValid() {
-			vvy := reflect.New(t).Elem()
-			vvy.Set(vy)
-			vy = vvy
-		}
-	} else {
-		t = vx.Type()
-	}
-
-	return &pathStep{t, vx, vy}
-}
-
-type state struct {
-	// These fields represent the "comparison state".
-	// Calling statelessCompare must not result in observable changes to these.
-	result    diff.Result // The current result of comparison
-	curPath   Path        // The current path in the value tree
-	curPtrs   pointerPath // The current set of visited pointers
-	reporters []reporter  // Optional reporters
-
-	// recChecker checks for infinite cycles applying the same set of
-	// transformers upon the output of itself.
-	recChecker recChecker
-
-	// dynChecker triggers pseudo-random checks for option correctness.
-	// It is safe for statelessCompare to mutate this value.
-	dynChecker dynChecker
-
-	// These fields, once set by processOption, will not change.
-	exporters []exporter // List of exporters for structs with unexported fields
-	opts      Options    // List of all fundamental and filter options
-}
-
-func newState(opts []Option) *state {
-	// Always ensure a validator option exists to validate the inputs.
-	s := &state{opts: Options{validator{}}}
-	s.curPtrs.Init()
-	s.processOption(Options(opts))
-	return s
-}
-
-func (s *state) processOption(opt Option) {
-	switch opt := opt.(type) {
-	case nil:
-	case Options:
-		for _, o := range opt {
-			s.processOption(o)
-		}
-	case coreOption:
-		type filtered interface {
-			isFiltered() bool
-		}
-		if fopt, ok := opt.(filtered); ok && !fopt.isFiltered() {
-			panic(fmt.Sprintf("cannot use an unfiltered option: %v", opt))
-		}
-		s.opts = append(s.opts, opt)
-	case exporter:
-		s.exporters = append(s.exporters, opt)
-	case reporter:
-		s.reporters = append(s.reporters, opt)
-	default:
-		panic(fmt.Sprintf("unknown option %T", opt))
-	}
-}
-
-// statelessCompare compares two values and returns the result.
-// This function is stateless in that it does not alter the current result,
-// or output to any registered reporters.
-func (s *state) statelessCompare(step PathStep) diff.Result {
-	// We do not save and restore curPath and curPtrs because all of the
-	// compareX methods should properly push and pop from them.
-	// It is an implementation bug if the contents of the paths differ from
-	// when calling this function to when returning from it.
-
-	oldResult, oldReporters := s.result, s.reporters
-	s.result = diff.Result{} // Reset result
-	s.reporters = nil        // Remove reporters to avoid spurious printouts
-	s.compareAny(step)
-	res := s.result
-	s.result, s.reporters = oldResult, oldReporters
-	return res
-}
-
-func (s *state) compareAny(step PathStep) {
-	// Update the path stack.
-	s.curPath.push(step)
-	defer s.curPath.pop()
-	for _, r := range s.reporters {
-		r.PushStep(step)
-		defer r.PopStep()
-	}
-	s.recChecker.Check(s.curPath)
-
-	// Cycle-detection for slice elements (see NOTE in compareSlice).
-	t := step.Type()
-	vx, vy := step.Values()
-	if si, ok := step.(SliceIndex); ok && si.isSlice && vx.IsValid() && vy.IsValid() {
-		px, py := vx.Addr(), vy.Addr()
-		if eq, visited := s.curPtrs.Push(px, py); visited {
-			s.report(eq, reportByCycle)
-			return
-		}
-		defer s.curPtrs.Pop(px, py)
-	}
-
-	// Rule 1: Check whether an option applies on this node in the value tree.
-	if s.tryOptions(t, vx, vy) {
-		return
-	}
-
-	// Rule 2: Check whether the type has a valid Equal method.
-	if s.tryMethod(t, vx, vy) {
-		return
-	}
-
-	// Rule 3: Compare based on the underlying kind.
-	switch t.Kind() {
-	case reflect.Bool:
-		s.report(vx.Bool() == vy.Bool(), 0)
-	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-		s.report(vx.Int() == vy.Int(), 0)
-	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
-		s.report(vx.Uint() == vy.Uint(), 0)
-	case reflect.Float32, reflect.Float64:
-		s.report(vx.Float() == vy.Float(), 0)
-	case reflect.Complex64, reflect.Complex128:
-		s.report(vx.Complex() == vy.Complex(), 0)
-	case reflect.String:
-		s.report(vx.String() == vy.String(), 0)
-	case reflect.Chan, reflect.UnsafePointer:
-		s.report(vx.Pointer() == vy.Pointer(), 0)
-	case reflect.Func:
-		s.report(vx.IsNil() && vy.IsNil(), 0)
-	case reflect.Struct:
-		s.compareStruct(t, vx, vy)
-	case reflect.Slice, reflect.Array:
-		s.compareSlice(t, vx, vy)
-	case reflect.Map:
-		s.compareMap(t, vx, vy)
-	case reflect.Ptr:
-		s.comparePtr(t, vx, vy)
-	case reflect.Interface:
-		s.compareInterface(t, vx, vy)
-	default:
-		panic(fmt.Sprintf("%v kind not handled", t.Kind()))
-	}
-}
-
-func (s *state) tryOptions(t reflect.Type, vx, vy reflect.Value) bool {
-	// Evaluate all filters and apply the remaining options.
-	if opt := s.opts.filter(s, t, vx, vy); opt != nil {
-		opt.apply(s, vx, vy)
-		return true
-	}
-	return false
-}
-
-func (s *state) tryMethod(t reflect.Type, vx, vy reflect.Value) bool {
-	// Check if this type even has an Equal method.
-	m, ok := t.MethodByName("Equal")
-	if !ok || !function.IsType(m.Type, function.EqualAssignable) {
-		return false
-	}
-
-	eq := s.callTTBFunc(m.Func, vx, vy)
-	s.report(eq, reportByMethod)
-	return true
-}
-
-func (s *state) callTRFunc(f, v reflect.Value, step Transform) reflect.Value {
-	if !s.dynChecker.Next() {
-		return f.Call([]reflect.Value{v})[0]
-	}
-
-	// Run the function twice and ensure that we get the same results back.
-	// We run in goroutines so that the race detector (if enabled) can detect
-	// unsafe mutations to the input.
-	c := make(chan reflect.Value)
-	go detectRaces(c, f, v)
-	got := <-c
-	want := f.Call([]reflect.Value{v})[0]
-	if step.vx, step.vy = got, want; !s.statelessCompare(step).Equal() {
-		// To avoid false-positives with non-reflexive equality operations,
-		// we sanity check whether a value is equal to itself.
-		if step.vx, step.vy = want, want; !s.statelessCompare(step).Equal() {
-			return want
-		}
-		panic(fmt.Sprintf("non-deterministic function detected: %s", function.NameOf(f)))
-	}
-	return want
-}
-
-func (s *state) callTTBFunc(f, x, y reflect.Value) bool {
-	if !s.dynChecker.Next() {
-		return f.Call([]reflect.Value{x, y})[0].Bool()
-	}
-
-	// Swapping the input arguments is sufficient to check that
-	// f is symmetric and deterministic.
-	// We run in goroutines so that the race detector (if enabled) can detect
-	// unsafe mutations to the input.
-	c := make(chan reflect.Value)
-	go detectRaces(c, f, y, x)
-	got := <-c
-	want := f.Call([]reflect.Value{x, y})[0].Bool()
-	if !got.IsValid() || got.Bool() != want {
-		panic(fmt.Sprintf("non-deterministic or non-symmetric function detected: %s", function.NameOf(f)))
-	}
-	return want
-}
-
-func detectRaces(c chan<- reflect.Value, f reflect.Value, vs ...reflect.Value) {
-	var ret reflect.Value
-	defer func() {
-		recover() // Ignore panics, let the other call to f panic instead
-		c <- ret
-	}()
-	ret = f.Call(vs)[0]
-}
-
-func (s *state) compareStruct(t reflect.Type, vx, vy reflect.Value) {
-	var addr bool
-	var vax, vay reflect.Value // Addressable versions of vx and vy
-
-	var mayForce, mayForceInit bool
-	step := StructField{&structField{}}
-	for i := 0; i < t.NumField(); i++ {
-		step.typ = t.Field(i).Type
-		step.vx = vx.Field(i)
-		step.vy = vy.Field(i)
-		step.name = t.Field(i).Name
-		step.idx = i
-		step.unexported = !isExported(step.name)
-		if step.unexported {
-			if step.name == "_" {
-				continue
-			}
-			// Defer checking of unexported fields until later to give an
-			// Ignore a chance to ignore the field.
-			if !vax.IsValid() || !vay.IsValid() {
-				// For retrieveUnexportedField to work, the parent struct must
-				// be addressable. Create a new copy of the values if
-				// necessary to make them addressable.
-				addr = vx.CanAddr() || vy.CanAddr()
-				vax = makeAddressable(vx)
-				vay = makeAddressable(vy)
-			}
-			if !mayForceInit {
-				for _, xf := range s.exporters {
-					mayForce = mayForce || xf(t)
-				}
-				mayForceInit = true
-			}
-			step.mayForce = mayForce
-			step.paddr = addr
-			step.pvx = vax
-			step.pvy = vay
-			step.field = t.Field(i)
-		}
-		s.compareAny(step)
-	}
-}
-
-func (s *state) compareSlice(t reflect.Type, vx, vy reflect.Value) {
-	isSlice := t.Kind() == reflect.Slice
-	if isSlice && (vx.IsNil() || vy.IsNil()) {
-		s.report(vx.IsNil() && vy.IsNil(), 0)
-		return
-	}
-
-	// NOTE: It is incorrect to call curPtrs.Push on the slice header pointer
-	// since slices represents a list of pointers, rather than a single pointer.
-	// The pointer checking logic must be handled on a per-element basis
-	// in compareAny.
-	//
-	// A slice header (see reflect.SliceHeader) in Go is a tuple of a starting
-	// pointer P, a length N, and a capacity C. Supposing each slice element has
-	// a memory size of M, then the slice is equivalent to the list of pointers:
-	//	[P+i*M for i in range(N)]
-	//
-	// For example, v[:0] and v[:1] are slices with the same starting pointer,
-	// but they are clearly different values. Using the slice pointer alone
-	// violates the assumption that equal pointers implies equal values.
-
-	step := SliceIndex{&sliceIndex{pathStep: pathStep{typ: t.Elem()}, isSlice: isSlice}}
-	withIndexes := func(ix, iy int) SliceIndex {
-		if ix >= 0 {
-			step.vx, step.xkey = vx.Index(ix), ix
-		} else {
-			step.vx, step.xkey = reflect.Value{}, -1
-		}
-		if iy >= 0 {
-			step.vy, step.ykey = vy.Index(iy), iy
-		} else {
-			step.vy, step.ykey = reflect.Value{}, -1
-		}
-		return step
-	}
-
-	// Ignore options are able to ignore missing elements in a slice.
-	// However, detecting these reliably requires an optimal differencing
-	// algorithm, for which diff.Difference is not.
-	//
-	// Instead, we first iterate through both slices to detect which elements
-	// would be ignored if standing alone. The index of non-discarded elements
-	// are stored in a separate slice, which diffing is then performed on.
-	var indexesX, indexesY []int
-	var ignoredX, ignoredY []bool
-	for ix := 0; ix < vx.Len(); ix++ {
-		ignored := s.statelessCompare(withIndexes(ix, -1)).NumDiff == 0
-		if !ignored {
-			indexesX = append(indexesX, ix)
-		}
-		ignoredX = append(ignoredX, ignored)
-	}
-	for iy := 0; iy < vy.Len(); iy++ {
-		ignored := s.statelessCompare(withIndexes(-1, iy)).NumDiff == 0
-		if !ignored {
-			indexesY = append(indexesY, iy)
-		}
-		ignoredY = append(ignoredY, ignored)
-	}
-
-	// Compute an edit-script for slices vx and vy (excluding ignored elements).
-	edits := diff.Difference(len(indexesX), len(indexesY), func(ix, iy int) diff.Result {
-		return s.statelessCompare(withIndexes(indexesX[ix], indexesY[iy]))
-	})
-
-	// Replay the ignore-scripts and the edit-script.
-	var ix, iy int
-	for ix < vx.Len() || iy < vy.Len() {
-		var e diff.EditType
-		switch {
-		case ix < len(ignoredX) && ignoredX[ix]:
-			e = diff.UniqueX
-		case iy < len(ignoredY) && ignoredY[iy]:
-			e = diff.UniqueY
-		default:
-			e, edits = edits[0], edits[1:]
-		}
-		switch e {
-		case diff.UniqueX:
-			s.compareAny(withIndexes(ix, -1))
-			ix++
-		case diff.UniqueY:
-			s.compareAny(withIndexes(-1, iy))
-			iy++
-		default:
-			s.compareAny(withIndexes(ix, iy))
-			ix++
-			iy++
-		}
-	}
-}
-
-func (s *state) compareMap(t reflect.Type, vx, vy reflect.Value) {
-	if vx.IsNil() || vy.IsNil() {
-		s.report(vx.IsNil() && vy.IsNil(), 0)
-		return
-	}
-
-	// Cycle-detection for maps.
-	if eq, visited := s.curPtrs.Push(vx, vy); visited {
-		s.report(eq, reportByCycle)
-		return
-	}
-	defer s.curPtrs.Pop(vx, vy)
-
-	// We combine and sort the two map keys so that we can perform the
-	// comparisons in a deterministic order.
-	step := MapIndex{&mapIndex{pathStep: pathStep{typ: t.Elem()}}}
-	for _, k := range value.SortKeys(append(vx.MapKeys(), vy.MapKeys()...)) {
-		step.vx = vx.MapIndex(k)
-		step.vy = vy.MapIndex(k)
-		step.key = k
-		if !step.vx.IsValid() && !step.vy.IsValid() {
-			// It is possible for both vx and vy to be invalid if the
-			// key contained a NaN value in it.
-			//
-			// Even with the ability to retrieve NaN keys in Go 1.12,
-			// there still isn't a sensible way to compare the values since
-			// a NaN key may map to multiple unordered values.
-			// The most reasonable way to compare NaNs would be to compare the
-			// set of values. However, this is impossible to do efficiently
-			// since set equality is provably an O(n^2) operation given only
-			// an Equal function. If we had a Less function or Hash function,
-			// this could be done in O(n*log(n)) or O(n), respectively.
-			//
-			// Rather than adding complex logic to deal with NaNs, make it
-			// the user's responsibility to compare such obscure maps.
-			const help = "consider providing a Comparer to compare the map"
-			panic(fmt.Sprintf("%#v has map key with NaNs\n%s", s.curPath, help))
-		}
-		s.compareAny(step)
-	}
-}
-
-func (s *state) comparePtr(t reflect.Type, vx, vy reflect.Value) {
-	if vx.IsNil() || vy.IsNil() {
-		s.report(vx.IsNil() && vy.IsNil(), 0)
-		return
-	}
-
-	// Cycle-detection for pointers.
-	if eq, visited := s.curPtrs.Push(vx, vy); visited {
-		s.report(eq, reportByCycle)
-		return
-	}
-	defer s.curPtrs.Pop(vx, vy)
-
-	vx, vy = vx.Elem(), vy.Elem()
-	s.compareAny(Indirect{&indirect{pathStep{t.Elem(), vx, vy}}})
-}
-
-func (s *state) compareInterface(t reflect.Type, vx, vy reflect.Value) {
-	if vx.IsNil() || vy.IsNil() {
-		s.report(vx.IsNil() && vy.IsNil(), 0)
-		return
-	}
-	vx, vy = vx.Elem(), vy.Elem()
-	if vx.Type() != vy.Type() {
-		s.report(false, 0)
-		return
-	}
-	s.compareAny(TypeAssertion{&typeAssertion{pathStep{vx.Type(), vx, vy}}})
-}
-
-func (s *state) report(eq bool, rf resultFlags) {
-	if rf&reportByIgnore == 0 {
-		if eq {
-			s.result.NumSame++
-			rf |= reportEqual
-		} else {
-			s.result.NumDiff++
-			rf |= reportUnequal
-		}
-	}
-	for _, r := range s.reporters {
-		r.Report(Result{flags: rf})
-	}
-}
-
-// recChecker tracks the state needed to periodically perform checks that
-// user provided transformers are not stuck in an infinitely recursive cycle.
-type recChecker struct{ next int }
-
-// Check scans the Path for any recursive transformers and panics when any
-// recursive transformers are detected. Note that the presence of a
-// recursive Transformer does not necessarily imply an infinite cycle.
-// As such, this check only activates after some minimal number of path steps.
-func (rc *recChecker) Check(p Path) {
-	const minLen = 1 << 16
-	if rc.next == 0 {
-		rc.next = minLen
-	}
-	if len(p) < rc.next {
-		return
-	}
-	rc.next <<= 1
-
-	// Check whether the same transformer has appeared at least twice.
-	var ss []string
-	m := map[Option]int{}
-	for _, ps := range p {
-		if t, ok := ps.(Transform); ok {
-			t := t.Option()
-			if m[t] == 1 { // Transformer was used exactly once before
-				tf := t.(*transformer).fnc.Type()
-				ss = append(ss, fmt.Sprintf("%v: %v => %v", t, tf.In(0), tf.Out(0)))
-			}
-			m[t]++
-		}
-	}
-	if len(ss) > 0 {
-		const warning = "recursive set of Transformers detected"
-		const help = "consider using cmpopts.AcyclicTransformer"
-		set := strings.Join(ss, "\n\t")
-		panic(fmt.Sprintf("%s:\n\t%s\n%s", warning, set, help))
-	}
-}
-
-// dynChecker tracks the state needed to periodically perform checks that
-// user provided functions are symmetric and deterministic.
-// The zero value is safe for immediate use.
-type dynChecker struct{ curr, next int }
-
-// Next increments the state and reports whether a check should be performed.
-//
-// Checks occur every Nth function call, where N is a triangular number:
-//	0 1 3 6 10 15 21 28 36 45 55 66 78 91 105 120 136 153 171 190 ...
-// See https://en.wikipedia.org/wiki/Triangular_number
-//
-// This sequence ensures that the cost of checks drops significantly as
-// the number of functions calls grows larger.
-func (dc *dynChecker) Next() bool {
-	ok := dc.curr == dc.next
-	if ok {
-		dc.curr = 0
-		dc.next++
-	}
-	dc.curr++
-	return ok
-}
-
-// makeAddressable returns a value that is always addressable.
-// It returns the input verbatim if it is already addressable,
-// otherwise it creates a new value and returns an addressable copy.
-func makeAddressable(v reflect.Value) reflect.Value {
-	if v.CanAddr() {
-		return v
-	}
-	vc := reflect.New(v.Type()).Elem()
-	vc.Set(v)
-	return vc
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/export_panic.go b/vendor/github.com/google/go-cmp/cmp/export_panic.go
deleted file mode 100644
index ae851fe53..000000000
--- a/vendor/github.com/google/go-cmp/cmp/export_panic.go
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build purego
-// +build purego
-
-package cmp
-
-import "reflect"
-
-const supportExporters = false
-
-func retrieveUnexportedField(reflect.Value, reflect.StructField, bool) reflect.Value {
-	panic("no support for forcibly accessing unexported fields")
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/export_unsafe.go b/vendor/github.com/google/go-cmp/cmp/export_unsafe.go
deleted file mode 100644
index e2c0f74e8..000000000
--- a/vendor/github.com/google/go-cmp/cmp/export_unsafe.go
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build !purego
-// +build !purego
-
-package cmp
-
-import (
-	"reflect"
-	"unsafe"
-)
-
-const supportExporters = true
-
-// retrieveUnexportedField uses unsafe to forcibly retrieve any field from
-// a struct such that the value has read-write permissions.
-//
-// The parent struct, v, must be addressable, while f must be a StructField
-// describing the field to retrieve. If addr is false,
-// then the returned value will be shallowed copied to be non-addressable.
-func retrieveUnexportedField(v reflect.Value, f reflect.StructField, addr bool) reflect.Value {
-	ve := reflect.NewAt(f.Type, unsafe.Pointer(uintptr(unsafe.Pointer(v.UnsafeAddr()))+f.Offset)).Elem()
-	if !addr {
-		// A field is addressable if and only if the struct is addressable.
-		// If the original parent value was not addressable, shallow copy the
-		// value to make it non-addressable to avoid leaking an implementation
-		// detail of how forcibly exporting a field works.
-		if ve.Kind() == reflect.Interface && ve.IsNil() {
-			return reflect.Zero(f.Type)
-		}
-		return reflect.ValueOf(ve.Interface()).Convert(f.Type)
-	}
-	return ve
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go
deleted file mode 100644
index 36062a604..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build !cmp_debug
-// +build !cmp_debug
-
-package diff
-
-var debug debugger
-
-type debugger struct{}
-
-func (debugger) Begin(_, _ int, f EqualFunc, _, _ *EditScript) EqualFunc {
-	return f
-}
-func (debugger) Update() {}
-func (debugger) Finish() {}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go
deleted file mode 100644
index a3b97a1ad..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build cmp_debug
-// +build cmp_debug
-
-package diff
-
-import (
-	"fmt"
-	"strings"
-	"sync"
-	"time"
-)
-
-// The algorithm can be seen running in real-time by enabling debugging:
-//	go test -tags=cmp_debug -v
-//
-// Example output:
-//	=== RUN   TestDifference/#34
-//	┌───────────────────────────────┐
-//	│ \ · · · · · · · · · · · · · · │
-//	│ · # · · · · · · · · · · · · · │
-//	│ · \ · · · · · · · · · · · · · │
-//	│ · · \ · · · · · · · · · · · · │
-//	│ · · · X # · · · · · · · · · · │
-//	│ · · · # \ · · · · · · · · · · │
-//	│ · · · · · # # · · · · · · · · │
-//	│ · · · · · # \ · · · · · · · · │
-//	│ · · · · · · · \ · · · · · · · │
-//	│ · · · · · · · · \ · · · · · · │
-//	│ · · · · · · · · · \ · · · · · │
-//	│ · · · · · · · · · · \ · · # · │
-//	│ · · · · · · · · · · · \ # # · │
-//	│ · · · · · · · · · · · # # # · │
-//	│ · · · · · · · · · · # # # # · │
-//	│ · · · · · · · · · # # # # # · │
-//	│ · · · · · · · · · · · · · · \ │
-//	└───────────────────────────────┘
-//	[.Y..M.XY......YXYXY.|]
-//
-// The grid represents the edit-graph where the horizontal axis represents
-// list X and the vertical axis represents list Y. The start of the two lists
-// is the top-left, while the ends are the bottom-right. The '·' represents
-// an unexplored node in the graph. The '\' indicates that the two symbols
-// from list X and Y are equal. The 'X' indicates that two symbols are similar
-// (but not exactly equal) to each other. The '#' indicates that the two symbols
-// are different (and not similar). The algorithm traverses this graph trying to
-// make the paths starting in the top-left and the bottom-right connect.
-//
-// The series of '.', 'X', 'Y', and 'M' characters at the bottom represents
-// the currently established path from the forward and reverse searches,
-// separated by a '|' character.
-
-const (
-	updateDelay  = 100 * time.Millisecond
-	finishDelay  = 500 * time.Millisecond
-	ansiTerminal = true // ANSI escape codes used to move terminal cursor
-)
-
-var debug debugger
-
-type debugger struct {
-	sync.Mutex
-	p1, p2           EditScript
-	fwdPath, revPath *EditScript
-	grid             []byte
-	lines            int
-}
-
-func (dbg *debugger) Begin(nx, ny int, f EqualFunc, p1, p2 *EditScript) EqualFunc {
-	dbg.Lock()
-	dbg.fwdPath, dbg.revPath = p1, p2
-	top := "┌─" + strings.Repeat("──", nx) + "┐\n"
-	row := "│ " + strings.Repeat("· ", nx) + "│\n"
-	btm := "└─" + strings.Repeat("──", nx) + "┘\n"
-	dbg.grid = []byte(top + strings.Repeat(row, ny) + btm)
-	dbg.lines = strings.Count(dbg.String(), "\n")
-	fmt.Print(dbg)
-
-	// Wrap the EqualFunc so that we can intercept each result.
-	return func(ix, iy int) (r Result) {
-		cell := dbg.grid[len(top)+iy*len(row):][len("│ ")+len("· ")*ix:][:len("·")]
-		for i := range cell {
-			cell[i] = 0 // Zero out the multiple bytes of UTF-8 middle-dot
-		}
-		switch r = f(ix, iy); {
-		case r.Equal():
-			cell[0] = '\\'
-		case r.Similar():
-			cell[0] = 'X'
-		default:
-			cell[0] = '#'
-		}
-		return
-	}
-}
-
-func (dbg *debugger) Update() {
-	dbg.print(updateDelay)
-}
-
-func (dbg *debugger) Finish() {
-	dbg.print(finishDelay)
-	dbg.Unlock()
-}
-
-func (dbg *debugger) String() string {
-	dbg.p1, dbg.p2 = *dbg.fwdPath, dbg.p2[:0]
-	for i := len(*dbg.revPath) - 1; i >= 0; i-- {
-		dbg.p2 = append(dbg.p2, (*dbg.revPath)[i])
-	}
-	return fmt.Sprintf("%s[%v|%v]\n\n", dbg.grid, dbg.p1, dbg.p2)
-}
-
-func (dbg *debugger) print(d time.Duration) {
-	if ansiTerminal {
-		fmt.Printf("\x1b[%dA", dbg.lines) // Reset terminal cursor
-	}
-	fmt.Print(dbg)
-	time.Sleep(d)
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go
deleted file mode 100644
index bc196b16c..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go
+++ /dev/null
@@ -1,398 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Package diff implements an algorithm for producing edit-scripts.
-// The edit-script is a sequence of operations needed to transform one list
-// of symbols into another (or vice-versa). The edits allowed are insertions,
-// deletions, and modifications. The summation of all edits is called the
-// Levenshtein distance as this problem is well-known in computer science.
-//
-// This package prioritizes performance over accuracy. That is, the run time
-// is more important than obtaining a minimal Levenshtein distance.
-package diff
-
-import (
-	"math/rand"
-	"time"
-
-	"github.com/google/go-cmp/cmp/internal/flags"
-)
-
-// EditType represents a single operation within an edit-script.
-type EditType uint8
-
-const (
-	// Identity indicates that a symbol pair is identical in both list X and Y.
-	Identity EditType = iota
-	// UniqueX indicates that a symbol only exists in X and not Y.
-	UniqueX
-	// UniqueY indicates that a symbol only exists in Y and not X.
-	UniqueY
-	// Modified indicates that a symbol pair is a modification of each other.
-	Modified
-)
-
-// EditScript represents the series of differences between two lists.
-type EditScript []EditType
-
-// String returns a human-readable string representing the edit-script where
-// Identity, UniqueX, UniqueY, and Modified are represented by the
-// '.', 'X', 'Y', and 'M' characters, respectively.
-func (es EditScript) String() string {
-	b := make([]byte, len(es))
-	for i, e := range es {
-		switch e {
-		case Identity:
-			b[i] = '.'
-		case UniqueX:
-			b[i] = 'X'
-		case UniqueY:
-			b[i] = 'Y'
-		case Modified:
-			b[i] = 'M'
-		default:
-			panic("invalid edit-type")
-		}
-	}
-	return string(b)
-}
-
-// stats returns a histogram of the number of each type of edit operation.
-func (es EditScript) stats() (s struct{ NI, NX, NY, NM int }) {
-	for _, e := range es {
-		switch e {
-		case Identity:
-			s.NI++
-		case UniqueX:
-			s.NX++
-		case UniqueY:
-			s.NY++
-		case Modified:
-			s.NM++
-		default:
-			panic("invalid edit-type")
-		}
-	}
-	return
-}
-
-// Dist is the Levenshtein distance and is guaranteed to be 0 if and only if
-// lists X and Y are equal.
-func (es EditScript) Dist() int { return len(es) - es.stats().NI }
-
-// LenX is the length of the X list.
-func (es EditScript) LenX() int { return len(es) - es.stats().NY }
-
-// LenY is the length of the Y list.
-func (es EditScript) LenY() int { return len(es) - es.stats().NX }
-
-// EqualFunc reports whether the symbols at indexes ix and iy are equal.
-// When called by Difference, the index is guaranteed to be within nx and ny.
-type EqualFunc func(ix int, iy int) Result
-
-// Result is the result of comparison.
-// NumSame is the number of sub-elements that are equal.
-// NumDiff is the number of sub-elements that are not equal.
-type Result struct{ NumSame, NumDiff int }
-
-// BoolResult returns a Result that is either Equal or not Equal.
-func BoolResult(b bool) Result {
-	if b {
-		return Result{NumSame: 1} // Equal, Similar
-	} else {
-		return Result{NumDiff: 2} // Not Equal, not Similar
-	}
-}
-
-// Equal indicates whether the symbols are equal. Two symbols are equal
-// if and only if NumDiff == 0. If Equal, then they are also Similar.
-func (r Result) Equal() bool { return r.NumDiff == 0 }
-
-// Similar indicates whether two symbols are similar and may be represented
-// by using the Modified type. As a special case, we consider binary comparisons
-// (i.e., those that return Result{1, 0} or Result{0, 1}) to be similar.
-//
-// The exact ratio of NumSame to NumDiff to determine similarity may change.
-func (r Result) Similar() bool {
-	// Use NumSame+1 to offset NumSame so that binary comparisons are similar.
-	return r.NumSame+1 >= r.NumDiff
-}
-
-var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0
-
-// Difference reports whether two lists of lengths nx and ny are equal
-// given the definition of equality provided as f.
-//
-// This function returns an edit-script, which is a sequence of operations
-// needed to convert one list into the other. The following invariants for
-// the edit-script are maintained:
-//	• eq == (es.Dist()==0)
-//	• nx == es.LenX()
-//	• ny == es.LenY()
-//
-// This algorithm is not guaranteed to be an optimal solution (i.e., one that
-// produces an edit-script with a minimal Levenshtein distance). This algorithm
-// favors performance over optimality. The exact output is not guaranteed to
-// be stable and may change over time.
-func Difference(nx, ny int, f EqualFunc) (es EditScript) {
-	// This algorithm is based on traversing what is known as an "edit-graph".
-	// See Figure 1 from "An O(ND) Difference Algorithm and Its Variations"
-	// by Eugene W. Myers. Since D can be as large as N itself, this is
-	// effectively O(N^2). Unlike the algorithm from that paper, we are not
-	// interested in the optimal path, but at least some "decent" path.
-	//
-	// For example, let X and Y be lists of symbols:
-	//	X = [A B C A B B A]
-	//	Y = [C B A B A C]
-	//
-	// The edit-graph can be drawn as the following:
-	//	   A B C A B B A
-	//	  ┌─────────────┐
-	//	C │_|_|\|_|_|_|_│ 0
-	//	B │_|\|_|_|\|\|_│ 1
-	//	A │\|_|_|\|_|_|\│ 2
-	//	B │_|\|_|_|\|\|_│ 3
-	//	A │\|_|_|\|_|_|\│ 4
-	//	C │ | |\| | | | │ 5
-	//	  └─────────────┘ 6
-	//	   0 1 2 3 4 5 6 7
-	//
-	// List X is written along the horizontal axis, while list Y is written
-	// along the vertical axis. At any point on this grid, if the symbol in
-	// list X matches the corresponding symbol in list Y, then a '\' is drawn.
-	// The goal of any minimal edit-script algorithm is to find a path from the
-	// top-left corner to the bottom-right corner, while traveling through the
-	// fewest horizontal or vertical edges.
-	// A horizontal edge is equivalent to inserting a symbol from list X.
-	// A vertical edge is equivalent to inserting a symbol from list Y.
-	// A diagonal edge is equivalent to a matching symbol between both X and Y.
-
-	// Invariants:
-	//	• 0 ≤ fwdPath.X ≤ (fwdFrontier.X, revFrontier.X) ≤ revPath.X ≤ nx
-	//	• 0 ≤ fwdPath.Y ≤ (fwdFrontier.Y, revFrontier.Y) ≤ revPath.Y ≤ ny
-	//
-	// In general:
-	//	• fwdFrontier.X < revFrontier.X
-	//	• fwdFrontier.Y < revFrontier.Y
-	// Unless, it is time for the algorithm to terminate.
-	fwdPath := path{+1, point{0, 0}, make(EditScript, 0, (nx+ny)/2)}
-	revPath := path{-1, point{nx, ny}, make(EditScript, 0)}
-	fwdFrontier := fwdPath.point // Forward search frontier
-	revFrontier := revPath.point // Reverse search frontier
-
-	// Search budget bounds the cost of searching for better paths.
-	// The longest sequence of non-matching symbols that can be tolerated is
-	// approximately the square-root of the search budget.
-	searchBudget := 4 * (nx + ny) // O(n)
-
-	// Running the tests with the "cmp_debug" build tag prints a visualization
-	// of the algorithm running in real-time. This is educational for
-	// understanding how the algorithm works. See debug_enable.go.
-	f = debug.Begin(nx, ny, f, &fwdPath.es, &revPath.es)
-
-	// The algorithm below is a greedy, meet-in-the-middle algorithm for
-	// computing sub-optimal edit-scripts between two lists.
-	//
-	// The algorithm is approximately as follows:
-	//	• Searching for differences switches back-and-forth between
-	//	a search that starts at the beginning (the top-left corner), and
-	//	a search that starts at the end (the bottom-right corner). The goal of
-	//	the search is connect with the search from the opposite corner.
-	//	• As we search, we build a path in a greedy manner, where the first
-	//	match seen is added to the path (this is sub-optimal, but provides a
-	//	decent result in practice). When matches are found, we try the next pair
-	//	of symbols in the lists and follow all matches as far as possible.
-	//	• When searching for matches, we search along a diagonal going through
-	//	through the "frontier" point. If no matches are found, we advance the
-	//	frontier towards the opposite corner.
-	//	• This algorithm terminates when either the X coordinates or the
-	//	Y coordinates of the forward and reverse frontier points ever intersect.
-
-	// This algorithm is correct even if searching only in the forward direction
-	// or in the reverse direction. We do both because it is commonly observed
-	// that two lists commonly differ because elements were added to the front
-	// or end of the other list.
-	//
-	// Non-deterministically start with either the forward or reverse direction
-	// to introduce some deliberate instability so that we have the flexibility
-	// to change this algorithm in the future.
-	if flags.Deterministic || randBool {
-		goto forwardSearch
-	} else {
-		goto reverseSearch
-	}
-
-forwardSearch:
-	{
-		// Forward search from the beginning.
-		if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 {
-			goto finishSearch
-		}
-		for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ {
-			// Search in a diagonal pattern for a match.
-			z := zigzag(i)
-			p := point{fwdFrontier.X + z, fwdFrontier.Y - z}
-			switch {
-			case p.X >= revPath.X || p.Y < fwdPath.Y:
-				stop1 = true // Hit top-right corner
-			case p.Y >= revPath.Y || p.X < fwdPath.X:
-				stop2 = true // Hit bottom-left corner
-			case f(p.X, p.Y).Equal():
-				// Match found, so connect the path to this point.
-				fwdPath.connect(p, f)
-				fwdPath.append(Identity)
-				// Follow sequence of matches as far as possible.
-				for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y {
-					if !f(fwdPath.X, fwdPath.Y).Equal() {
-						break
-					}
-					fwdPath.append(Identity)
-				}
-				fwdFrontier = fwdPath.point
-				stop1, stop2 = true, true
-			default:
-				searchBudget-- // Match not found
-			}
-			debug.Update()
-		}
-		// Advance the frontier towards reverse point.
-		if revPath.X-fwdFrontier.X >= revPath.Y-fwdFrontier.Y {
-			fwdFrontier.X++
-		} else {
-			fwdFrontier.Y++
-		}
-		goto reverseSearch
-	}
-
-reverseSearch:
-	{
-		// Reverse search from the end.
-		if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 {
-			goto finishSearch
-		}
-		for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ {
-			// Search in a diagonal pattern for a match.
-			z := zigzag(i)
-			p := point{revFrontier.X - z, revFrontier.Y + z}
-			switch {
-			case fwdPath.X >= p.X || revPath.Y < p.Y:
-				stop1 = true // Hit bottom-left corner
-			case fwdPath.Y >= p.Y || revPath.X < p.X:
-				stop2 = true // Hit top-right corner
-			case f(p.X-1, p.Y-1).Equal():
-				// Match found, so connect the path to this point.
-				revPath.connect(p, f)
-				revPath.append(Identity)
-				// Follow sequence of matches as far as possible.
-				for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y {
-					if !f(revPath.X-1, revPath.Y-1).Equal() {
-						break
-					}
-					revPath.append(Identity)
-				}
-				revFrontier = revPath.point
-				stop1, stop2 = true, true
-			default:
-				searchBudget-- // Match not found
-			}
-			debug.Update()
-		}
-		// Advance the frontier towards forward point.
-		if revFrontier.X-fwdPath.X >= revFrontier.Y-fwdPath.Y {
-			revFrontier.X--
-		} else {
-			revFrontier.Y--
-		}
-		goto forwardSearch
-	}
-
-finishSearch:
-	// Join the forward and reverse paths and then append the reverse path.
-	fwdPath.connect(revPath.point, f)
-	for i := len(revPath.es) - 1; i >= 0; i-- {
-		t := revPath.es[i]
-		revPath.es = revPath.es[:i]
-		fwdPath.append(t)
-	}
-	debug.Finish()
-	return fwdPath.es
-}
-
-type path struct {
-	dir   int // +1 if forward, -1 if reverse
-	point     // Leading point of the EditScript path
-	es    EditScript
-}
-
-// connect appends any necessary Identity, Modified, UniqueX, or UniqueY types
-// to the edit-script to connect p.point to dst.
-func (p *path) connect(dst point, f EqualFunc) {
-	if p.dir > 0 {
-		// Connect in forward direction.
-		for dst.X > p.X && dst.Y > p.Y {
-			switch r := f(p.X, p.Y); {
-			case r.Equal():
-				p.append(Identity)
-			case r.Similar():
-				p.append(Modified)
-			case dst.X-p.X >= dst.Y-p.Y:
-				p.append(UniqueX)
-			default:
-				p.append(UniqueY)
-			}
-		}
-		for dst.X > p.X {
-			p.append(UniqueX)
-		}
-		for dst.Y > p.Y {
-			p.append(UniqueY)
-		}
-	} else {
-		// Connect in reverse direction.
-		for p.X > dst.X && p.Y > dst.Y {
-			switch r := f(p.X-1, p.Y-1); {
-			case r.Equal():
-				p.append(Identity)
-			case r.Similar():
-				p.append(Modified)
-			case p.Y-dst.Y >= p.X-dst.X:
-				p.append(UniqueY)
-			default:
-				p.append(UniqueX)
-			}
-		}
-		for p.X > dst.X {
-			p.append(UniqueX)
-		}
-		for p.Y > dst.Y {
-			p.append(UniqueY)
-		}
-	}
-}
-
-func (p *path) append(t EditType) {
-	p.es = append(p.es, t)
-	switch t {
-	case Identity, Modified:
-		p.add(p.dir, p.dir)
-	case UniqueX:
-		p.add(p.dir, 0)
-	case UniqueY:
-		p.add(0, p.dir)
-	}
-	debug.Update()
-}
-
-type point struct{ X, Y int }
-
-func (p *point) add(dx, dy int) { p.X += dx; p.Y += dy }
-
-// zigzag maps a consecutive sequence of integers to a zig-zag sequence.
-//	[0 1 2 3 4 5 ...] => [0 -1 +1 -2 +2 ...]
-func zigzag(x int) int {
-	if x&1 != 0 {
-		x = ^x
-	}
-	return x >> 1
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go b/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go
deleted file mode 100644
index d8e459c9b..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package flags
-
-// Deterministic controls whether the output of Diff should be deterministic.
-// This is only used for testing.
-var Deterministic bool
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/function/func.go b/vendor/github.com/google/go-cmp/cmp/internal/function/func.go
deleted file mode 100644
index d127d4362..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/function/func.go
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Package function provides functionality for identifying function types.
-package function
-
-import (
-	"reflect"
-	"regexp"
-	"runtime"
-	"strings"
-)
-
-type funcType int
-
-const (
-	_ funcType = iota
-
-	tbFunc  // func(T) bool
-	ttbFunc // func(T, T) bool
-	trbFunc // func(T, R) bool
-	tibFunc // func(T, I) bool
-	trFunc  // func(T) R
-
-	Equal             = ttbFunc // func(T, T) bool
-	EqualAssignable   = tibFunc // func(T, I) bool; encapsulates func(T, T) bool
-	Transformer       = trFunc  // func(T) R
-	ValueFilter       = ttbFunc // func(T, T) bool
-	Less              = ttbFunc // func(T, T) bool
-	ValuePredicate    = tbFunc  // func(T) bool
-	KeyValuePredicate = trbFunc // func(T, R) bool
-)
-
-var boolType = reflect.TypeOf(true)
-
-// IsType reports whether the reflect.Type is of the specified function type.
-func IsType(t reflect.Type, ft funcType) bool {
-	if t == nil || t.Kind() != reflect.Func || t.IsVariadic() {
-		return false
-	}
-	ni, no := t.NumIn(), t.NumOut()
-	switch ft {
-	case tbFunc: // func(T) bool
-		if ni == 1 && no == 1 && t.Out(0) == boolType {
-			return true
-		}
-	case ttbFunc: // func(T, T) bool
-		if ni == 2 && no == 1 && t.In(0) == t.In(1) && t.Out(0) == boolType {
-			return true
-		}
-	case trbFunc: // func(T, R) bool
-		if ni == 2 && no == 1 && t.Out(0) == boolType {
-			return true
-		}
-	case tibFunc: // func(T, I) bool
-		if ni == 2 && no == 1 && t.In(0).AssignableTo(t.In(1)) && t.Out(0) == boolType {
-			return true
-		}
-	case trFunc: // func(T) R
-		if ni == 1 && no == 1 {
-			return true
-		}
-	}
-	return false
-}
-
-var lastIdentRx = regexp.MustCompile(`[_\p{L}][_\p{L}\p{N}]*$`)
-
-// NameOf returns the name of the function value.
-func NameOf(v reflect.Value) string {
-	fnc := runtime.FuncForPC(v.Pointer())
-	if fnc == nil {
-		return ""
-	}
-	fullName := fnc.Name() // e.g., "long/path/name/mypkg.(*MyType).(long/path/name/mypkg.myMethod)-fm"
-
-	// Method closures have a "-fm" suffix.
-	fullName = strings.TrimSuffix(fullName, "-fm")
-
-	var name string
-	for len(fullName) > 0 {
-		inParen := strings.HasSuffix(fullName, ")")
-		fullName = strings.TrimSuffix(fullName, ")")
-
-		s := lastIdentRx.FindString(fullName)
-		if s == "" {
-			break
-		}
-		name = s + "." + name
-		fullName = strings.TrimSuffix(fullName, s)
-
-		if i := strings.LastIndexByte(fullName, '('); inParen && i >= 0 {
-			fullName = fullName[:i]
-		}
-		fullName = strings.TrimSuffix(fullName, ".")
-	}
-	return strings.TrimSuffix(name, ".")
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go b/vendor/github.com/google/go-cmp/cmp/internal/value/name.go
deleted file mode 100644
index 7b498bb2c..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go
+++ /dev/null
@@ -1,164 +0,0 @@
-// Copyright 2020, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package value
-
-import (
-	"reflect"
-	"strconv"
-)
-
-var anyType = reflect.TypeOf((*interface{})(nil)).Elem()
-
-// TypeString is nearly identical to reflect.Type.String,
-// but has an additional option to specify that full type names be used.
-func TypeString(t reflect.Type, qualified bool) string {
-	return string(appendTypeName(nil, t, qualified, false))
-}
-
-func appendTypeName(b []byte, t reflect.Type, qualified, elideFunc bool) []byte {
-	// BUG: Go reflection provides no way to disambiguate two named types
-	// of the same name and within the same package,
-	// but declared within the namespace of different functions.
-
-	// Use the "any" alias instead of "interface{}" for better readability.
-	if t == anyType {
-		return append(b, "any"...)
-	}
-
-	// Named type.
-	if t.Name() != "" {
-		if qualified && t.PkgPath() != "" {
-			b = append(b, '"')
-			b = append(b, t.PkgPath()...)
-			b = append(b, '"')
-			b = append(b, '.')
-			b = append(b, t.Name()...)
-		} else {
-			b = append(b, t.String()...)
-		}
-		return b
-	}
-
-	// Unnamed type.
-	switch k := t.Kind(); k {
-	case reflect.Bool, reflect.String, reflect.UnsafePointer,
-		reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
-		reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
-		reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128:
-		b = append(b, k.String()...)
-	case reflect.Chan:
-		if t.ChanDir() == reflect.RecvDir {
-			b = append(b, "<-"...)
-		}
-		b = append(b, "chan"...)
-		if t.ChanDir() == reflect.SendDir {
-			b = append(b, "<-"...)
-		}
-		b = append(b, ' ')
-		b = appendTypeName(b, t.Elem(), qualified, false)
-	case reflect.Func:
-		if !elideFunc {
-			b = append(b, "func"...)
-		}
-		b = append(b, '(')
-		for i := 0; i < t.NumIn(); i++ {
-			if i > 0 {
-				b = append(b, ", "...)
-			}
-			if i == t.NumIn()-1 && t.IsVariadic() {
-				b = append(b, "..."...)
-				b = appendTypeName(b, t.In(i).Elem(), qualified, false)
-			} else {
-				b = appendTypeName(b, t.In(i), qualified, false)
-			}
-		}
-		b = append(b, ')')
-		switch t.NumOut() {
-		case 0:
-			// Do nothing
-		case 1:
-			b = append(b, ' ')
-			b = appendTypeName(b, t.Out(0), qualified, false)
-		default:
-			b = append(b, " ("...)
-			for i := 0; i < t.NumOut(); i++ {
-				if i > 0 {
-					b = append(b, ", "...)
-				}
-				b = appendTypeName(b, t.Out(i), qualified, false)
-			}
-			b = append(b, ')')
-		}
-	case reflect.Struct:
-		b = append(b, "struct{ "...)
-		for i := 0; i < t.NumField(); i++ {
-			if i > 0 {
-				b = append(b, "; "...)
-			}
-			sf := t.Field(i)
-			if !sf.Anonymous {
-				if qualified && sf.PkgPath != "" {
-					b = append(b, '"')
-					b = append(b, sf.PkgPath...)
-					b = append(b, '"')
-					b = append(b, '.')
-				}
-				b = append(b, sf.Name...)
-				b = append(b, ' ')
-			}
-			b = appendTypeName(b, sf.Type, qualified, false)
-			if sf.Tag != "" {
-				b = append(b, ' ')
-				b = strconv.AppendQuote(b, string(sf.Tag))
-			}
-		}
-		if b[len(b)-1] == ' ' {
-			b = b[:len(b)-1]
-		} else {
-			b = append(b, ' ')
-		}
-		b = append(b, '}')
-	case reflect.Slice, reflect.Array:
-		b = append(b, '[')
-		if k == reflect.Array {
-			b = strconv.AppendUint(b, uint64(t.Len()), 10)
-		}
-		b = append(b, ']')
-		b = appendTypeName(b, t.Elem(), qualified, false)
-	case reflect.Map:
-		b = append(b, "map["...)
-		b = appendTypeName(b, t.Key(), qualified, false)
-		b = append(b, ']')
-		b = appendTypeName(b, t.Elem(), qualified, false)
-	case reflect.Ptr:
-		b = append(b, '*')
-		b = appendTypeName(b, t.Elem(), qualified, false)
-	case reflect.Interface:
-		b = append(b, "interface{ "...)
-		for i := 0; i < t.NumMethod(); i++ {
-			if i > 0 {
-				b = append(b, "; "...)
-			}
-			m := t.Method(i)
-			if qualified && m.PkgPath != "" {
-				b = append(b, '"')
-				b = append(b, m.PkgPath...)
-				b = append(b, '"')
-				b = append(b, '.')
-			}
-			b = append(b, m.Name...)
-			b = appendTypeName(b, m.Type, qualified, true)
-		}
-		if b[len(b)-1] == ' ' {
-			b = b[:len(b)-1]
-		} else {
-			b = append(b, ' ')
-		}
-		b = append(b, '}')
-	default:
-		panic("invalid kind: " + k.String())
-	}
-	return b
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go
deleted file mode 100644
index 1a71bfcbd..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2018, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build purego
-// +build purego
-
-package value
-
-import "reflect"
-
-// Pointer is an opaque typed pointer and is guaranteed to be comparable.
-type Pointer struct {
-	p uintptr
-	t reflect.Type
-}
-
-// PointerOf returns a Pointer from v, which must be a
-// reflect.Ptr, reflect.Slice, or reflect.Map.
-func PointerOf(v reflect.Value) Pointer {
-	// NOTE: Storing a pointer as an uintptr is technically incorrect as it
-	// assumes that the GC implementation does not use a moving collector.
-	return Pointer{v.Pointer(), v.Type()}
-}
-
-// IsNil reports whether the pointer is nil.
-func (p Pointer) IsNil() bool {
-	return p.p == 0
-}
-
-// Uintptr returns the pointer as a uintptr.
-func (p Pointer) Uintptr() uintptr {
-	return p.p
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go
deleted file mode 100644
index 16e6860af..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright 2018, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-//go:build !purego
-// +build !purego
-
-package value
-
-import (
-	"reflect"
-	"unsafe"
-)
-
-// Pointer is an opaque typed pointer and is guaranteed to be comparable.
-type Pointer struct {
-	p unsafe.Pointer
-	t reflect.Type
-}
-
-// PointerOf returns a Pointer from v, which must be a
-// reflect.Ptr, reflect.Slice, or reflect.Map.
-func PointerOf(v reflect.Value) Pointer {
-	// The proper representation of a pointer is unsafe.Pointer,
-	// which is necessary if the GC ever uses a moving collector.
-	return Pointer{unsafe.Pointer(v.Pointer()), v.Type()}
-}
-
-// IsNil reports whether the pointer is nil.
-func (p Pointer) IsNil() bool {
-	return p.p == nil
-}
-
-// Uintptr returns the pointer as a uintptr.
-func (p Pointer) Uintptr() uintptr {
-	return uintptr(p.p)
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go b/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go
deleted file mode 100644
index 98533b036..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go
+++ /dev/null
@@ -1,106 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package value
-
-import (
-	"fmt"
-	"math"
-	"reflect"
-	"sort"
-)
-
-// SortKeys sorts a list of map keys, deduplicating keys if necessary.
-// The type of each value must be comparable.
-func SortKeys(vs []reflect.Value) []reflect.Value {
-	if len(vs) == 0 {
-		return vs
-	}
-
-	// Sort the map keys.
-	sort.SliceStable(vs, func(i, j int) bool { return isLess(vs[i], vs[j]) })
-
-	// Deduplicate keys (fails for NaNs).
-	vs2 := vs[:1]
-	for _, v := range vs[1:] {
-		if isLess(vs2[len(vs2)-1], v) {
-			vs2 = append(vs2, v)
-		}
-	}
-	return vs2
-}
-
-// isLess is a generic function for sorting arbitrary map keys.
-// The inputs must be of the same type and must be comparable.
-func isLess(x, y reflect.Value) bool {
-	switch x.Type().Kind() {
-	case reflect.Bool:
-		return !x.Bool() && y.Bool()
-	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-		return x.Int() < y.Int()
-	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
-		return x.Uint() < y.Uint()
-	case reflect.Float32, reflect.Float64:
-		// NOTE: This does not sort -0 as less than +0
-		// since Go maps treat -0 and +0 as equal keys.
-		fx, fy := x.Float(), y.Float()
-		return fx < fy || math.IsNaN(fx) && !math.IsNaN(fy)
-	case reflect.Complex64, reflect.Complex128:
-		cx, cy := x.Complex(), y.Complex()
-		rx, ix, ry, iy := real(cx), imag(cx), real(cy), imag(cy)
-		if rx == ry || (math.IsNaN(rx) && math.IsNaN(ry)) {
-			return ix < iy || math.IsNaN(ix) && !math.IsNaN(iy)
-		}
-		return rx < ry || math.IsNaN(rx) && !math.IsNaN(ry)
-	case reflect.Ptr, reflect.UnsafePointer, reflect.Chan:
-		return x.Pointer() < y.Pointer()
-	case reflect.String:
-		return x.String() < y.String()
-	case reflect.Array:
-		for i := 0; i < x.Len(); i++ {
-			if isLess(x.Index(i), y.Index(i)) {
-				return true
-			}
-			if isLess(y.Index(i), x.Index(i)) {
-				return false
-			}
-		}
-		return false
-	case reflect.Struct:
-		for i := 0; i < x.NumField(); i++ {
-			if isLess(x.Field(i), y.Field(i)) {
-				return true
-			}
-			if isLess(y.Field(i), x.Field(i)) {
-				return false
-			}
-		}
-		return false
-	case reflect.Interface:
-		vx, vy := x.Elem(), y.Elem()
-		if !vx.IsValid() || !vy.IsValid() {
-			return !vx.IsValid() && vy.IsValid()
-		}
-		tx, ty := vx.Type(), vy.Type()
-		if tx == ty {
-			return isLess(x.Elem(), y.Elem())
-		}
-		if tx.Kind() != ty.Kind() {
-			return vx.Kind() < vy.Kind()
-		}
-		if tx.String() != ty.String() {
-			return tx.String() < ty.String()
-		}
-		if tx.PkgPath() != ty.PkgPath() {
-			return tx.PkgPath() < ty.PkgPath()
-		}
-		// This can happen in rare situations, so we fallback to just comparing
-		// the unique pointer for a reflect.Type. This guarantees deterministic
-		// ordering within a program, but it is obviously not stable.
-		return reflect.ValueOf(vx.Type()).Pointer() < reflect.ValueOf(vy.Type()).Pointer()
-	default:
-		// Must be Func, Map, or Slice; which are not comparable.
-		panic(fmt.Sprintf("%T is not comparable", x.Type()))
-	}
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/zero.go b/vendor/github.com/google/go-cmp/cmp/internal/value/zero.go
deleted file mode 100644
index 9147a2997..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/zero.go
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package value
-
-import (
-	"math"
-	"reflect"
-)
-
-// IsZero reports whether v is the zero value.
-// This does not rely on Interface and so can be used on unexported fields.
-func IsZero(v reflect.Value) bool {
-	switch v.Kind() {
-	case reflect.Bool:
-		return v.Bool() == false
-	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-		return v.Int() == 0
-	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
-		return v.Uint() == 0
-	case reflect.Float32, reflect.Float64:
-		return math.Float64bits(v.Float()) == 0
-	case reflect.Complex64, reflect.Complex128:
-		return math.Float64bits(real(v.Complex())) == 0 && math.Float64bits(imag(v.Complex())) == 0
-	case reflect.String:
-		return v.String() == ""
-	case reflect.UnsafePointer:
-		return v.Pointer() == 0
-	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Ptr, reflect.Map, reflect.Slice:
-		return v.IsNil()
-	case reflect.Array:
-		for i := 0; i < v.Len(); i++ {
-			if !IsZero(v.Index(i)) {
-				return false
-			}
-		}
-		return true
-	case reflect.Struct:
-		for i := 0; i < v.NumField(); i++ {
-			if !IsZero(v.Field(i)) {
-				return false
-			}
-		}
-		return true
-	}
-	return false
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/options.go b/vendor/github.com/google/go-cmp/cmp/options.go
deleted file mode 100644
index e57b9eb53..000000000
--- a/vendor/github.com/google/go-cmp/cmp/options.go
+++ /dev/null
@@ -1,552 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"fmt"
-	"reflect"
-	"regexp"
-	"strings"
-
-	"github.com/google/go-cmp/cmp/internal/function"
-)
-
-// Option configures for specific behavior of Equal and Diff. In particular,
-// the fundamental Option functions (Ignore, Transformer, and Comparer),
-// configure how equality is determined.
-//
-// The fundamental options may be composed with filters (FilterPath and
-// FilterValues) to control the scope over which they are applied.
-//
-// The cmp/cmpopts package provides helper functions for creating options that
-// may be used with Equal and Diff.
-type Option interface {
-	// filter applies all filters and returns the option that remains.
-	// Each option may only read s.curPath and call s.callTTBFunc.
-	//
-	// An Options is returned only if multiple comparers or transformers
-	// can apply simultaneously and will only contain values of those types
-	// or sub-Options containing values of those types.
-	filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption
-}
-
-// applicableOption represents the following types:
-//	Fundamental: ignore | validator | *comparer | *transformer
-//	Grouping:    Options
-type applicableOption interface {
-	Option
-
-	// apply executes the option, which may mutate s or panic.
-	apply(s *state, vx, vy reflect.Value)
-}
-
-// coreOption represents the following types:
-//	Fundamental: ignore | validator | *comparer | *transformer
-//	Filters:     *pathFilter | *valuesFilter
-type coreOption interface {
-	Option
-	isCore()
-}
-
-type core struct{}
-
-func (core) isCore() {}
-
-// Options is a list of Option values that also satisfies the Option interface.
-// Helper comparison packages may return an Options value when packing multiple
-// Option values into a single Option. When this package processes an Options,
-// it will be implicitly expanded into a flat list.
-//
-// Applying a filter on an Options is equivalent to applying that same filter
-// on all individual options held within.
-type Options []Option
-
-func (opts Options) filter(s *state, t reflect.Type, vx, vy reflect.Value) (out applicableOption) {
-	for _, opt := range opts {
-		switch opt := opt.filter(s, t, vx, vy); opt.(type) {
-		case ignore:
-			return ignore{} // Only ignore can short-circuit evaluation
-		case validator:
-			out = validator{} // Takes precedence over comparer or transformer
-		case *comparer, *transformer, Options:
-			switch out.(type) {
-			case nil:
-				out = opt
-			case validator:
-				// Keep validator
-			case *comparer, *transformer, Options:
-				out = Options{out, opt} // Conflicting comparers or transformers
-			}
-		}
-	}
-	return out
-}
-
-func (opts Options) apply(s *state, _, _ reflect.Value) {
-	const warning = "ambiguous set of applicable options"
-	const help = "consider using filters to ensure at most one Comparer or Transformer may apply"
-	var ss []string
-	for _, opt := range flattenOptions(nil, opts) {
-		ss = append(ss, fmt.Sprint(opt))
-	}
-	set := strings.Join(ss, "\n\t")
-	panic(fmt.Sprintf("%s at %#v:\n\t%s\n%s", warning, s.curPath, set, help))
-}
-
-func (opts Options) String() string {
-	var ss []string
-	for _, opt := range opts {
-		ss = append(ss, fmt.Sprint(opt))
-	}
-	return fmt.Sprintf("Options{%s}", strings.Join(ss, ", "))
-}
-
-// FilterPath returns a new Option where opt is only evaluated if filter f
-// returns true for the current Path in the value tree.
-//
-// This filter is called even if a slice element or map entry is missing and
-// provides an opportunity to ignore such cases. The filter function must be
-// symmetric such that the filter result is identical regardless of whether the
-// missing value is from x or y.
-//
-// The option passed in may be an Ignore, Transformer, Comparer, Options, or
-// a previously filtered Option.
-func FilterPath(f func(Path) bool, opt Option) Option {
-	if f == nil {
-		panic("invalid path filter function")
-	}
-	if opt := normalizeOption(opt); opt != nil {
-		return &pathFilter{fnc: f, opt: opt}
-	}
-	return nil
-}
-
-type pathFilter struct {
-	core
-	fnc func(Path) bool
-	opt Option
-}
-
-func (f pathFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption {
-	if f.fnc(s.curPath) {
-		return f.opt.filter(s, t, vx, vy)
-	}
-	return nil
-}
-
-func (f pathFilter) String() string {
-	return fmt.Sprintf("FilterPath(%s, %v)", function.NameOf(reflect.ValueOf(f.fnc)), f.opt)
-}
-
-// FilterValues returns a new Option where opt is only evaluated if filter f,
-// which is a function of the form "func(T, T) bool", returns true for the
-// current pair of values being compared. If either value is invalid or
-// the type of the values is not assignable to T, then this filter implicitly
-// returns false.
-//
-// The filter function must be
-// symmetric (i.e., agnostic to the order of the inputs) and
-// deterministic (i.e., produces the same result when given the same inputs).
-// If T is an interface, it is possible that f is called with two values with
-// different concrete types that both implement T.
-//
-// The option passed in may be an Ignore, Transformer, Comparer, Options, or
-// a previously filtered Option.
-func FilterValues(f interface{}, opt Option) Option {
-	v := reflect.ValueOf(f)
-	if !function.IsType(v.Type(), function.ValueFilter) || v.IsNil() {
-		panic(fmt.Sprintf("invalid values filter function: %T", f))
-	}
-	if opt := normalizeOption(opt); opt != nil {
-		vf := &valuesFilter{fnc: v, opt: opt}
-		if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 {
-			vf.typ = ti
-		}
-		return vf
-	}
-	return nil
-}
-
-type valuesFilter struct {
-	core
-	typ reflect.Type  // T
-	fnc reflect.Value // func(T, T) bool
-	opt Option
-}
-
-func (f valuesFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption {
-	if !vx.IsValid() || !vx.CanInterface() || !vy.IsValid() || !vy.CanInterface() {
-		return nil
-	}
-	if (f.typ == nil || t.AssignableTo(f.typ)) && s.callTTBFunc(f.fnc, vx, vy) {
-		return f.opt.filter(s, t, vx, vy)
-	}
-	return nil
-}
-
-func (f valuesFilter) String() string {
-	return fmt.Sprintf("FilterValues(%s, %v)", function.NameOf(f.fnc), f.opt)
-}
-
-// Ignore is an Option that causes all comparisons to be ignored.
-// This value is intended to be combined with FilterPath or FilterValues.
-// It is an error to pass an unfiltered Ignore option to Equal.
-func Ignore() Option { return ignore{} }
-
-type ignore struct{ core }
-
-func (ignore) isFiltered() bool                                                     { return false }
-func (ignore) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { return ignore{} }
-func (ignore) apply(s *state, _, _ reflect.Value)                                   { s.report(true, reportByIgnore) }
-func (ignore) String() string                                                       { return "Ignore()" }
-
-// validator is a sentinel Option type to indicate that some options could not
-// be evaluated due to unexported fields, missing slice elements, or
-// missing map entries. Both values are validator only for unexported fields.
-type validator struct{ core }
-
-func (validator) filter(_ *state, _ reflect.Type, vx, vy reflect.Value) applicableOption {
-	if !vx.IsValid() || !vy.IsValid() {
-		return validator{}
-	}
-	if !vx.CanInterface() || !vy.CanInterface() {
-		return validator{}
-	}
-	return nil
-}
-func (validator) apply(s *state, vx, vy reflect.Value) {
-	// Implies missing slice element or map entry.
-	if !vx.IsValid() || !vy.IsValid() {
-		s.report(vx.IsValid() == vy.IsValid(), 0)
-		return
-	}
-
-	// Unable to Interface implies unexported field without visibility access.
-	if !vx.CanInterface() || !vy.CanInterface() {
-		help := "consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported"
-		var name string
-		if t := s.curPath.Index(-2).Type(); t.Name() != "" {
-			// Named type with unexported fields.
-			name = fmt.Sprintf("%q.%v", t.PkgPath(), t.Name()) // e.g., "path/to/package".MyType
-			if _, ok := reflect.New(t).Interface().(error); ok {
-				help = "consider using cmpopts.EquateErrors to compare error values"
-			}
-		} else {
-			// Unnamed type with unexported fields. Derive PkgPath from field.
-			var pkgPath string
-			for i := 0; i < t.NumField() && pkgPath == ""; i++ {
-				pkgPath = t.Field(i).PkgPath
-			}
-			name = fmt.Sprintf("%q.(%v)", pkgPath, t.String()) // e.g., "path/to/package".(struct { a int })
-		}
-		panic(fmt.Sprintf("cannot handle unexported field at %#v:\n\t%v\n%s", s.curPath, name, help))
-	}
-
-	panic("not reachable")
-}
-
-// identRx represents a valid identifier according to the Go specification.
-const identRx = `[_\p{L}][_\p{L}\p{N}]*`
-
-var identsRx = regexp.MustCompile(`^` + identRx + `(\.` + identRx + `)*$`)
-
-// Transformer returns an Option that applies a transformation function that
-// converts values of a certain type into that of another.
-//
-// The transformer f must be a function "func(T) R" that converts values of
-// type T to those of type R and is implicitly filtered to input values
-// assignable to T. The transformer must not mutate T in any way.
-//
-// To help prevent some cases of infinite recursive cycles applying the
-// same transform to the output of itself (e.g., in the case where the
-// input and output types are the same), an implicit filter is added such that
-// a transformer is applicable only if that exact transformer is not already
-// in the tail of the Path since the last non-Transform step.
-// For situations where the implicit filter is still insufficient,
-// consider using cmpopts.AcyclicTransformer, which adds a filter
-// to prevent the transformer from being recursively applied upon itself.
-//
-// The name is a user provided label that is used as the Transform.Name in the
-// transformation PathStep (and eventually shown in the Diff output).
-// The name must be a valid identifier or qualified identifier in Go syntax.
-// If empty, an arbitrary name is used.
-func Transformer(name string, f interface{}) Option {
-	v := reflect.ValueOf(f)
-	if !function.IsType(v.Type(), function.Transformer) || v.IsNil() {
-		panic(fmt.Sprintf("invalid transformer function: %T", f))
-	}
-	if name == "" {
-		name = function.NameOf(v)
-		if !identsRx.MatchString(name) {
-			name = "λ" // Lambda-symbol as placeholder name
-		}
-	} else if !identsRx.MatchString(name) {
-		panic(fmt.Sprintf("invalid name: %q", name))
-	}
-	tr := &transformer{name: name, fnc: reflect.ValueOf(f)}
-	if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 {
-		tr.typ = ti
-	}
-	return tr
-}
-
-type transformer struct {
-	core
-	name string
-	typ  reflect.Type  // T
-	fnc  reflect.Value // func(T) R
-}
-
-func (tr *transformer) isFiltered() bool { return tr.typ != nil }
-
-func (tr *transformer) filter(s *state, t reflect.Type, _, _ reflect.Value) applicableOption {
-	for i := len(s.curPath) - 1; i >= 0; i-- {
-		if t, ok := s.curPath[i].(Transform); !ok {
-			break // Hit most recent non-Transform step
-		} else if tr == t.trans {
-			return nil // Cannot directly use same Transform
-		}
-	}
-	if tr.typ == nil || t.AssignableTo(tr.typ) {
-		return tr
-	}
-	return nil
-}
-
-func (tr *transformer) apply(s *state, vx, vy reflect.Value) {
-	step := Transform{&transform{pathStep{typ: tr.fnc.Type().Out(0)}, tr}}
-	vvx := s.callTRFunc(tr.fnc, vx, step)
-	vvy := s.callTRFunc(tr.fnc, vy, step)
-	step.vx, step.vy = vvx, vvy
-	s.compareAny(step)
-}
-
-func (tr transformer) String() string {
-	return fmt.Sprintf("Transformer(%s, %s)", tr.name, function.NameOf(tr.fnc))
-}
-
-// Comparer returns an Option that determines whether two values are equal
-// to each other.
-//
-// The comparer f must be a function "func(T, T) bool" and is implicitly
-// filtered to input values assignable to T. If T is an interface, it is
-// possible that f is called with two values of different concrete types that
-// both implement T.
-//
-// The equality function must be:
-//	• Symmetric: equal(x, y) == equal(y, x)
-//	• Deterministic: equal(x, y) == equal(x, y)
-//	• Pure: equal(x, y) does not modify x or y
-func Comparer(f interface{}) Option {
-	v := reflect.ValueOf(f)
-	if !function.IsType(v.Type(), function.Equal) || v.IsNil() {
-		panic(fmt.Sprintf("invalid comparer function: %T", f))
-	}
-	cm := &comparer{fnc: v}
-	if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 {
-		cm.typ = ti
-	}
-	return cm
-}
-
-type comparer struct {
-	core
-	typ reflect.Type  // T
-	fnc reflect.Value // func(T, T) bool
-}
-
-func (cm *comparer) isFiltered() bool { return cm.typ != nil }
-
-func (cm *comparer) filter(_ *state, t reflect.Type, _, _ reflect.Value) applicableOption {
-	if cm.typ == nil || t.AssignableTo(cm.typ) {
-		return cm
-	}
-	return nil
-}
-
-func (cm *comparer) apply(s *state, vx, vy reflect.Value) {
-	eq := s.callTTBFunc(cm.fnc, vx, vy)
-	s.report(eq, reportByFunc)
-}
-
-func (cm comparer) String() string {
-	return fmt.Sprintf("Comparer(%s)", function.NameOf(cm.fnc))
-}
-
-// Exporter returns an Option that specifies whether Equal is allowed to
-// introspect into the unexported fields of certain struct types.
-//
-// Users of this option must understand that comparing on unexported fields
-// from external packages is not safe since changes in the internal
-// implementation of some external package may cause the result of Equal
-// to unexpectedly change. However, it may be valid to use this option on types
-// defined in an internal package where the semantic meaning of an unexported
-// field is in the control of the user.
-//
-// In many cases, a custom Comparer should be used instead that defines
-// equality as a function of the public API of a type rather than the underlying
-// unexported implementation.
-//
-// For example, the reflect.Type documentation defines equality to be determined
-// by the == operator on the interface (essentially performing a shallow pointer
-// comparison) and most attempts to compare *regexp.Regexp types are interested
-// in only checking that the regular expression strings are equal.
-// Both of these are accomplished using Comparers:
-//
-//	Comparer(func(x, y reflect.Type) bool { return x == y })
-//	Comparer(func(x, y *regexp.Regexp) bool { return x.String() == y.String() })
-//
-// In other cases, the cmpopts.IgnoreUnexported option can be used to ignore
-// all unexported fields on specified struct types.
-func Exporter(f func(reflect.Type) bool) Option {
-	if !supportExporters {
-		panic("Exporter is not supported on purego builds")
-	}
-	return exporter(f)
-}
-
-type exporter func(reflect.Type) bool
-
-func (exporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption {
-	panic("not implemented")
-}
-
-// AllowUnexported returns an Options that allows Equal to forcibly introspect
-// unexported fields of the specified struct types.
-//
-// See Exporter for the proper use of this option.
-func AllowUnexported(types ...interface{}) Option {
-	m := make(map[reflect.Type]bool)
-	for _, typ := range types {
-		t := reflect.TypeOf(typ)
-		if t.Kind() != reflect.Struct {
-			panic(fmt.Sprintf("invalid struct type: %T", typ))
-		}
-		m[t] = true
-	}
-	return exporter(func(t reflect.Type) bool { return m[t] })
-}
-
-// Result represents the comparison result for a single node and
-// is provided by cmp when calling Result (see Reporter).
-type Result struct {
-	_     [0]func() // Make Result incomparable
-	flags resultFlags
-}
-
-// Equal reports whether the node was determined to be equal or not.
-// As a special case, ignored nodes are considered equal.
-func (r Result) Equal() bool {
-	return r.flags&(reportEqual|reportByIgnore) != 0
-}
-
-// ByIgnore reports whether the node is equal because it was ignored.
-// This never reports true if Equal reports false.
-func (r Result) ByIgnore() bool {
-	return r.flags&reportByIgnore != 0
-}
-
-// ByMethod reports whether the Equal method determined equality.
-func (r Result) ByMethod() bool {
-	return r.flags&reportByMethod != 0
-}
-
-// ByFunc reports whether a Comparer function determined equality.
-func (r Result) ByFunc() bool {
-	return r.flags&reportByFunc != 0
-}
-
-// ByCycle reports whether a reference cycle was detected.
-func (r Result) ByCycle() bool {
-	return r.flags&reportByCycle != 0
-}
-
-type resultFlags uint
-
-const (
-	_ resultFlags = (1 << iota) / 2
-
-	reportEqual
-	reportUnequal
-	reportByIgnore
-	reportByMethod
-	reportByFunc
-	reportByCycle
-)
-
-// Reporter is an Option that can be passed to Equal. When Equal traverses
-// the value trees, it calls PushStep as it descends into each node in the
-// tree and PopStep as it ascend out of the node. The leaves of the tree are
-// either compared (determined to be equal or not equal) or ignored and reported
-// as such by calling the Report method.
-func Reporter(r interface {
-	// PushStep is called when a tree-traversal operation is performed.
-	// The PathStep itself is only valid until the step is popped.
-	// The PathStep.Values are valid for the duration of the entire traversal
-	// and must not be mutated.
-	//
-	// Equal always calls PushStep at the start to provide an operation-less
-	// PathStep used to report the root values.
-	//
-	// Within a slice, the exact set of inserted, removed, or modified elements
-	// is unspecified and may change in future implementations.
-	// The entries of a map are iterated through in an unspecified order.
-	PushStep(PathStep)
-
-	// Report is called exactly once on leaf nodes to report whether the
-	// comparison identified the node as equal, unequal, or ignored.
-	// A leaf node is one that is immediately preceded by and followed by
-	// a pair of PushStep and PopStep calls.
-	Report(Result)
-
-	// PopStep ascends back up the value tree.
-	// There is always a matching pop call for every push call.
-	PopStep()
-}) Option {
-	return reporter{r}
-}
-
-type reporter struct{ reporterIface }
-type reporterIface interface {
-	PushStep(PathStep)
-	Report(Result)
-	PopStep()
-}
-
-func (reporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption {
-	panic("not implemented")
-}
-
-// normalizeOption normalizes the input options such that all Options groups
-// are flattened and groups with a single element are reduced to that element.
-// Only coreOptions and Options containing coreOptions are allowed.
-func normalizeOption(src Option) Option {
-	switch opts := flattenOptions(nil, Options{src}); len(opts) {
-	case 0:
-		return nil
-	case 1:
-		return opts[0]
-	default:
-		return opts
-	}
-}
-
-// flattenOptions copies all options in src to dst as a flat list.
-// Only coreOptions and Options containing coreOptions are allowed.
-func flattenOptions(dst, src Options) Options {
-	for _, opt := range src {
-		switch opt := opt.(type) {
-		case nil:
-			continue
-		case Options:
-			dst = flattenOptions(dst, opt)
-		case coreOption:
-			dst = append(dst, opt)
-		default:
-			panic(fmt.Sprintf("invalid option type: %T", opt))
-		}
-	}
-	return dst
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/path.go b/vendor/github.com/google/go-cmp/cmp/path.go
deleted file mode 100644
index c71003463..000000000
--- a/vendor/github.com/google/go-cmp/cmp/path.go
+++ /dev/null
@@ -1,378 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-	"unicode"
-	"unicode/utf8"
-
-	"github.com/google/go-cmp/cmp/internal/value"
-)
-
-// Path is a list of PathSteps describing the sequence of operations to get
-// from some root type to the current position in the value tree.
-// The first Path element is always an operation-less PathStep that exists
-// simply to identify the initial type.
-//
-// When traversing structs with embedded structs, the embedded struct will
-// always be accessed as a field before traversing the fields of the
-// embedded struct themselves. That is, an exported field from the
-// embedded struct will never be accessed directly from the parent struct.
-type Path []PathStep
-
-// PathStep is a union-type for specific operations to traverse
-// a value's tree structure. Users of this package never need to implement
-// these types as values of this type will be returned by this package.
-//
-// Implementations of this interface are
-// StructField, SliceIndex, MapIndex, Indirect, TypeAssertion, and Transform.
-type PathStep interface {
-	String() string
-
-	// Type is the resulting type after performing the path step.
-	Type() reflect.Type
-
-	// Values is the resulting values after performing the path step.
-	// The type of each valid value is guaranteed to be identical to Type.
-	//
-	// In some cases, one or both may be invalid or have restrictions:
-	//	• For StructField, both are not interface-able if the current field
-	//	is unexported and the struct type is not explicitly permitted by
-	//	an Exporter to traverse unexported fields.
-	//	• For SliceIndex, one may be invalid if an element is missing from
-	//	either the x or y slice.
-	//	• For MapIndex, one may be invalid if an entry is missing from
-	//	either the x or y map.
-	//
-	// The provided values must not be mutated.
-	Values() (vx, vy reflect.Value)
-}
-
-var (
-	_ PathStep = StructField{}
-	_ PathStep = SliceIndex{}
-	_ PathStep = MapIndex{}
-	_ PathStep = Indirect{}
-	_ PathStep = TypeAssertion{}
-	_ PathStep = Transform{}
-)
-
-func (pa *Path) push(s PathStep) {
-	*pa = append(*pa, s)
-}
-
-func (pa *Path) pop() {
-	*pa = (*pa)[:len(*pa)-1]
-}
-
-// Last returns the last PathStep in the Path.
-// If the path is empty, this returns a non-nil PathStep that reports a nil Type.
-func (pa Path) Last() PathStep {
-	return pa.Index(-1)
-}
-
-// Index returns the ith step in the Path and supports negative indexing.
-// A negative index starts counting from the tail of the Path such that -1
-// refers to the last step, -2 refers to the second-to-last step, and so on.
-// If index is invalid, this returns a non-nil PathStep that reports a nil Type.
-func (pa Path) Index(i int) PathStep {
-	if i < 0 {
-		i = len(pa) + i
-	}
-	if i < 0 || i >= len(pa) {
-		return pathStep{}
-	}
-	return pa[i]
-}
-
-// String returns the simplified path to a node.
-// The simplified path only contains struct field accesses.
-//
-// For example:
-//	MyMap.MySlices.MyField
-func (pa Path) String() string {
-	var ss []string
-	for _, s := range pa {
-		if _, ok := s.(StructField); ok {
-			ss = append(ss, s.String())
-		}
-	}
-	return strings.TrimPrefix(strings.Join(ss, ""), ".")
-}
-
-// GoString returns the path to a specific node using Go syntax.
-//
-// For example:
-//	(*root.MyMap["key"].(*mypkg.MyStruct).MySlices)[2][3].MyField
-func (pa Path) GoString() string {
-	var ssPre, ssPost []string
-	var numIndirect int
-	for i, s := range pa {
-		var nextStep PathStep
-		if i+1 < len(pa) {
-			nextStep = pa[i+1]
-		}
-		switch s := s.(type) {
-		case Indirect:
-			numIndirect++
-			pPre, pPost := "(", ")"
-			switch nextStep.(type) {
-			case Indirect:
-				continue // Next step is indirection, so let them batch up
-			case StructField:
-				numIndirect-- // Automatic indirection on struct fields
-			case nil:
-				pPre, pPost = "", "" // Last step; no need for parenthesis
-			}
-			if numIndirect > 0 {
-				ssPre = append(ssPre, pPre+strings.Repeat("*", numIndirect))
-				ssPost = append(ssPost, pPost)
-			}
-			numIndirect = 0
-			continue
-		case Transform:
-			ssPre = append(ssPre, s.trans.name+"(")
-			ssPost = append(ssPost, ")")
-			continue
-		}
-		ssPost = append(ssPost, s.String())
-	}
-	for i, j := 0, len(ssPre)-1; i < j; i, j = i+1, j-1 {
-		ssPre[i], ssPre[j] = ssPre[j], ssPre[i]
-	}
-	return strings.Join(ssPre, "") + strings.Join(ssPost, "")
-}
-
-type pathStep struct {
-	typ    reflect.Type
-	vx, vy reflect.Value
-}
-
-func (ps pathStep) Type() reflect.Type             { return ps.typ }
-func (ps pathStep) Values() (vx, vy reflect.Value) { return ps.vx, ps.vy }
-func (ps pathStep) String() string {
-	if ps.typ == nil {
-		return ""
-	}
-	s := ps.typ.String()
-	if s == "" || strings.ContainsAny(s, "{}\n") {
-		return "root" // Type too simple or complex to print
-	}
-	return fmt.Sprintf("{%s}", s)
-}
-
-// StructField represents a struct field access on a field called Name.
-type StructField struct{ *structField }
-type structField struct {
-	pathStep
-	name string
-	idx  int
-
-	// These fields are used for forcibly accessing an unexported field.
-	// pvx, pvy, and field are only valid if unexported is true.
-	unexported bool
-	mayForce   bool                // Forcibly allow visibility
-	paddr      bool                // Was parent addressable?
-	pvx, pvy   reflect.Value       // Parent values (always addressable)
-	field      reflect.StructField // Field information
-}
-
-func (sf StructField) Type() reflect.Type { return sf.typ }
-func (sf StructField) Values() (vx, vy reflect.Value) {
-	if !sf.unexported {
-		return sf.vx, sf.vy // CanInterface reports true
-	}
-
-	// Forcibly obtain read-write access to an unexported struct field.
-	if sf.mayForce {
-		vx = retrieveUnexportedField(sf.pvx, sf.field, sf.paddr)
-		vy = retrieveUnexportedField(sf.pvy, sf.field, sf.paddr)
-		return vx, vy // CanInterface reports true
-	}
-	return sf.vx, sf.vy // CanInterface reports false
-}
-func (sf StructField) String() string { return fmt.Sprintf(".%s", sf.name) }
-
-// Name is the field name.
-func (sf StructField) Name() string { return sf.name }
-
-// Index is the index of the field in the parent struct type.
-// See reflect.Type.Field.
-func (sf StructField) Index() int { return sf.idx }
-
-// SliceIndex is an index operation on a slice or array at some index Key.
-type SliceIndex struct{ *sliceIndex }
-type sliceIndex struct {
-	pathStep
-	xkey, ykey int
-	isSlice    bool // False for reflect.Array
-}
-
-func (si SliceIndex) Type() reflect.Type             { return si.typ }
-func (si SliceIndex) Values() (vx, vy reflect.Value) { return si.vx, si.vy }
-func (si SliceIndex) String() string {
-	switch {
-	case si.xkey == si.ykey:
-		return fmt.Sprintf("[%d]", si.xkey)
-	case si.ykey == -1:
-		// [5->?] means "I don't know where X[5] went"
-		return fmt.Sprintf("[%d->?]", si.xkey)
-	case si.xkey == -1:
-		// [?->3] means "I don't know where Y[3] came from"
-		return fmt.Sprintf("[?->%d]", si.ykey)
-	default:
-		// [5->3] means "X[5] moved to Y[3]"
-		return fmt.Sprintf("[%d->%d]", si.xkey, si.ykey)
-	}
-}
-
-// Key is the index key; it may return -1 if in a split state
-func (si SliceIndex) Key() int {
-	if si.xkey != si.ykey {
-		return -1
-	}
-	return si.xkey
-}
-
-// SplitKeys are the indexes for indexing into slices in the
-// x and y values, respectively. These indexes may differ due to the
-// insertion or removal of an element in one of the slices, causing
-// all of the indexes to be shifted. If an index is -1, then that
-// indicates that the element does not exist in the associated slice.
-//
-// Key is guaranteed to return -1 if and only if the indexes returned
-// by SplitKeys are not the same. SplitKeys will never return -1 for
-// both indexes.
-func (si SliceIndex) SplitKeys() (ix, iy int) { return si.xkey, si.ykey }
-
-// MapIndex is an index operation on a map at some index Key.
-type MapIndex struct{ *mapIndex }
-type mapIndex struct {
-	pathStep
-	key reflect.Value
-}
-
-func (mi MapIndex) Type() reflect.Type             { return mi.typ }
-func (mi MapIndex) Values() (vx, vy reflect.Value) { return mi.vx, mi.vy }
-func (mi MapIndex) String() string                 { return fmt.Sprintf("[%#v]", mi.key) }
-
-// Key is the value of the map key.
-func (mi MapIndex) Key() reflect.Value { return mi.key }
-
-// Indirect represents pointer indirection on the parent type.
-type Indirect struct{ *indirect }
-type indirect struct {
-	pathStep
-}
-
-func (in Indirect) Type() reflect.Type             { return in.typ }
-func (in Indirect) Values() (vx, vy reflect.Value) { return in.vx, in.vy }
-func (in Indirect) String() string                 { return "*" }
-
-// TypeAssertion represents a type assertion on an interface.
-type TypeAssertion struct{ *typeAssertion }
-type typeAssertion struct {
-	pathStep
-}
-
-func (ta TypeAssertion) Type() reflect.Type             { return ta.typ }
-func (ta TypeAssertion) Values() (vx, vy reflect.Value) { return ta.vx, ta.vy }
-func (ta TypeAssertion) String() string                 { return fmt.Sprintf(".(%v)", ta.typ) }
-
-// Transform is a transformation from the parent type to the current type.
-type Transform struct{ *transform }
-type transform struct {
-	pathStep
-	trans *transformer
-}
-
-func (tf Transform) Type() reflect.Type             { return tf.typ }
-func (tf Transform) Values() (vx, vy reflect.Value) { return tf.vx, tf.vy }
-func (tf Transform) String() string                 { return fmt.Sprintf("%s()", tf.trans.name) }
-
-// Name is the name of the Transformer.
-func (tf Transform) Name() string { return tf.trans.name }
-
-// Func is the function pointer to the transformer function.
-func (tf Transform) Func() reflect.Value { return tf.trans.fnc }
-
-// Option returns the originally constructed Transformer option.
-// The == operator can be used to detect the exact option used.
-func (tf Transform) Option() Option { return tf.trans }
-
-// pointerPath represents a dual-stack of pointers encountered when
-// recursively traversing the x and y values. This data structure supports
-// detection of cycles and determining whether the cycles are equal.
-// In Go, cycles can occur via pointers, slices, and maps.
-//
-// The pointerPath uses a map to represent a stack; where descension into a
-// pointer pushes the address onto the stack, and ascension from a pointer
-// pops the address from the stack. Thus, when traversing into a pointer from
-// reflect.Ptr, reflect.Slice element, or reflect.Map, we can detect cycles
-// by checking whether the pointer has already been visited. The cycle detection
-// uses a separate stack for the x and y values.
-//
-// If a cycle is detected we need to determine whether the two pointers
-// should be considered equal. The definition of equality chosen by Equal
-// requires two graphs to have the same structure. To determine this, both the
-// x and y values must have a cycle where the previous pointers were also
-// encountered together as a pair.
-//
-// Semantically, this is equivalent to augmenting Indirect, SliceIndex, and
-// MapIndex with pointer information for the x and y values.
-// Suppose px and py are two pointers to compare, we then search the
-// Path for whether px was ever encountered in the Path history of x, and
-// similarly so with py. If either side has a cycle, the comparison is only
-// equal if both px and py have a cycle resulting from the same PathStep.
-//
-// Using a map as a stack is more performant as we can perform cycle detection
-// in O(1) instead of O(N) where N is len(Path).
-type pointerPath struct {
-	// mx is keyed by x pointers, where the value is the associated y pointer.
-	mx map[value.Pointer]value.Pointer
-	// my is keyed by y pointers, where the value is the associated x pointer.
-	my map[value.Pointer]value.Pointer
-}
-
-func (p *pointerPath) Init() {
-	p.mx = make(map[value.Pointer]value.Pointer)
-	p.my = make(map[value.Pointer]value.Pointer)
-}
-
-// Push indicates intent to descend into pointers vx and vy where
-// visited reports whether either has been seen before. If visited before,
-// equal reports whether both pointers were encountered together.
-// Pop must be called if and only if the pointers were never visited.
-//
-// The pointers vx and vy must be a reflect.Ptr, reflect.Slice, or reflect.Map
-// and be non-nil.
-func (p pointerPath) Push(vx, vy reflect.Value) (equal, visited bool) {
-	px := value.PointerOf(vx)
-	py := value.PointerOf(vy)
-	_, ok1 := p.mx[px]
-	_, ok2 := p.my[py]
-	if ok1 || ok2 {
-		equal = p.mx[px] == py && p.my[py] == px // Pointers paired together
-		return equal, true
-	}
-	p.mx[px] = py
-	p.my[py] = px
-	return false, false
-}
-
-// Pop ascends from pointers vx and vy.
-func (p pointerPath) Pop(vx, vy reflect.Value) {
-	delete(p.mx, value.PointerOf(vx))
-	delete(p.my, value.PointerOf(vy))
-}
-
-// isExported reports whether the identifier is exported.
-func isExported(id string) bool {
-	r, _ := utf8.DecodeRuneInString(id)
-	return unicode.IsUpper(r)
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/report.go b/vendor/github.com/google/go-cmp/cmp/report.go
deleted file mode 100644
index f43cd12eb..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright 2017, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-// defaultReporter implements the reporter interface.
-//
-// As Equal serially calls the PushStep, Report, and PopStep methods, the
-// defaultReporter constructs a tree-based representation of the compared value
-// and the result of each comparison (see valueNode).
-//
-// When the String method is called, the FormatDiff method transforms the
-// valueNode tree into a textNode tree, which is a tree-based representation
-// of the textual output (see textNode).
-//
-// Lastly, the textNode.String method produces the final report as a string.
-type defaultReporter struct {
-	root *valueNode
-	curr *valueNode
-}
-
-func (r *defaultReporter) PushStep(ps PathStep) {
-	r.curr = r.curr.PushStep(ps)
-	if r.root == nil {
-		r.root = r.curr
-	}
-}
-func (r *defaultReporter) Report(rs Result) {
-	r.curr.Report(rs)
-}
-func (r *defaultReporter) PopStep() {
-	r.curr = r.curr.PopStep()
-}
-
-// String provides a full report of the differences detected as a structured
-// literal in pseudo-Go syntax. String may only be called after the entire tree
-// has been traversed.
-func (r *defaultReporter) String() string {
-	assert(r.root != nil && r.curr == nil)
-	if r.root.NumDiff == 0 {
-		return ""
-	}
-	ptrs := new(pointerReferences)
-	text := formatOptions{}.FormatDiff(r.root, ptrs)
-	resolveReferences(text)
-	return text.String()
-}
-
-func assert(ok bool) {
-	if !ok {
-		panic("assertion failure")
-	}
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/report_compare.go b/vendor/github.com/google/go-cmp/cmp/report_compare.go
deleted file mode 100644
index 104bb3053..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report_compare.go
+++ /dev/null
@@ -1,432 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"fmt"
-	"reflect"
-
-	"github.com/google/go-cmp/cmp/internal/value"
-)
-
-// numContextRecords is the number of surrounding equal records to print.
-const numContextRecords = 2
-
-type diffMode byte
-
-const (
-	diffUnknown   diffMode = 0
-	diffIdentical diffMode = ' '
-	diffRemoved   diffMode = '-'
-	diffInserted  diffMode = '+'
-)
-
-type typeMode int
-
-const (
-	// emitType always prints the type.
-	emitType typeMode = iota
-	// elideType never prints the type.
-	elideType
-	// autoType prints the type only for composite kinds
-	// (i.e., structs, slices, arrays, and maps).
-	autoType
-)
-
-type formatOptions struct {
-	// DiffMode controls the output mode of FormatDiff.
-	//
-	// If diffUnknown,   then produce a diff of the x and y values.
-	// If diffIdentical, then emit values as if they were equal.
-	// If diffRemoved,   then only emit x values (ignoring y values).
-	// If diffInserted,  then only emit y values (ignoring x values).
-	DiffMode diffMode
-
-	// TypeMode controls whether to print the type for the current node.
-	//
-	// As a general rule of thumb, we always print the type of the next node
-	// after an interface, and always elide the type of the next node after
-	// a slice or map node.
-	TypeMode typeMode
-
-	// formatValueOptions are options specific to printing reflect.Values.
-	formatValueOptions
-}
-
-func (opts formatOptions) WithDiffMode(d diffMode) formatOptions {
-	opts.DiffMode = d
-	return opts
-}
-func (opts formatOptions) WithTypeMode(t typeMode) formatOptions {
-	opts.TypeMode = t
-	return opts
-}
-func (opts formatOptions) WithVerbosity(level int) formatOptions {
-	opts.VerbosityLevel = level
-	opts.LimitVerbosity = true
-	return opts
-}
-func (opts formatOptions) verbosity() uint {
-	switch {
-	case opts.VerbosityLevel < 0:
-		return 0
-	case opts.VerbosityLevel > 16:
-		return 16 // some reasonable maximum to avoid shift overflow
-	default:
-		return uint(opts.VerbosityLevel)
-	}
-}
-
-const maxVerbosityPreset = 6
-
-// verbosityPreset modifies the verbosity settings given an index
-// between 0 and maxVerbosityPreset, inclusive.
-func verbosityPreset(opts formatOptions, i int) formatOptions {
-	opts.VerbosityLevel = int(opts.verbosity()) + 2*i
-	if i > 0 {
-		opts.AvoidStringer = true
-	}
-	if i >= maxVerbosityPreset {
-		opts.PrintAddresses = true
-		opts.QualifiedNames = true
-	}
-	return opts
-}
-
-// FormatDiff converts a valueNode tree into a textNode tree, where the later
-// is a textual representation of the differences detected in the former.
-func (opts formatOptions) FormatDiff(v *valueNode, ptrs *pointerReferences) (out textNode) {
-	if opts.DiffMode == diffIdentical {
-		opts = opts.WithVerbosity(1)
-	} else if opts.verbosity() < 3 {
-		opts = opts.WithVerbosity(3)
-	}
-
-	// Check whether we have specialized formatting for this node.
-	// This is not necessary, but helpful for producing more readable outputs.
-	if opts.CanFormatDiffSlice(v) {
-		return opts.FormatDiffSlice(v)
-	}
-
-	var parentKind reflect.Kind
-	if v.parent != nil && v.parent.TransformerName == "" {
-		parentKind = v.parent.Type.Kind()
-	}
-
-	// For leaf nodes, format the value based on the reflect.Values alone.
-	if v.MaxDepth == 0 {
-		switch opts.DiffMode {
-		case diffUnknown, diffIdentical:
-			// Format Equal.
-			if v.NumDiff == 0 {
-				outx := opts.FormatValue(v.ValueX, parentKind, ptrs)
-				outy := opts.FormatValue(v.ValueY, parentKind, ptrs)
-				if v.NumIgnored > 0 && v.NumSame == 0 {
-					return textEllipsis
-				} else if outx.Len() < outy.Len() {
-					return outx
-				} else {
-					return outy
-				}
-			}
-
-			// Format unequal.
-			assert(opts.DiffMode == diffUnknown)
-			var list textList
-			outx := opts.WithTypeMode(elideType).FormatValue(v.ValueX, parentKind, ptrs)
-			outy := opts.WithTypeMode(elideType).FormatValue(v.ValueY, parentKind, ptrs)
-			for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ {
-				opts2 := verbosityPreset(opts, i).WithTypeMode(elideType)
-				outx = opts2.FormatValue(v.ValueX, parentKind, ptrs)
-				outy = opts2.FormatValue(v.ValueY, parentKind, ptrs)
-			}
-			if outx != nil {
-				list = append(list, textRecord{Diff: '-', Value: outx})
-			}
-			if outy != nil {
-				list = append(list, textRecord{Diff: '+', Value: outy})
-			}
-			return opts.WithTypeMode(emitType).FormatType(v.Type, list)
-		case diffRemoved:
-			return opts.FormatValue(v.ValueX, parentKind, ptrs)
-		case diffInserted:
-			return opts.FormatValue(v.ValueY, parentKind, ptrs)
-		default:
-			panic("invalid diff mode")
-		}
-	}
-
-	// Register slice element to support cycle detection.
-	if parentKind == reflect.Slice {
-		ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, true)
-		defer ptrs.Pop()
-		defer func() { out = wrapTrunkReferences(ptrRefs, out) }()
-	}
-
-	// Descend into the child value node.
-	if v.TransformerName != "" {
-		out := opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs)
-		out = &textWrap{Prefix: "Inverse(" + v.TransformerName + ", ", Value: out, Suffix: ")"}
-		return opts.FormatType(v.Type, out)
-	} else {
-		switch k := v.Type.Kind(); k {
-		case reflect.Struct, reflect.Array, reflect.Slice:
-			out = opts.formatDiffList(v.Records, k, ptrs)
-			out = opts.FormatType(v.Type, out)
-		case reflect.Map:
-			// Register map to support cycle detection.
-			ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false)
-			defer ptrs.Pop()
-
-			out = opts.formatDiffList(v.Records, k, ptrs)
-			out = wrapTrunkReferences(ptrRefs, out)
-			out = opts.FormatType(v.Type, out)
-		case reflect.Ptr:
-			// Register pointer to support cycle detection.
-			ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false)
-			defer ptrs.Pop()
-
-			out = opts.FormatDiff(v.Value, ptrs)
-			out = wrapTrunkReferences(ptrRefs, out)
-			out = &textWrap{Prefix: "&", Value: out}
-		case reflect.Interface:
-			out = opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs)
-		default:
-			panic(fmt.Sprintf("%v cannot have children", k))
-		}
-		return out
-	}
-}
-
-func (opts formatOptions) formatDiffList(recs []reportRecord, k reflect.Kind, ptrs *pointerReferences) textNode {
-	// Derive record name based on the data structure kind.
-	var name string
-	var formatKey func(reflect.Value) string
-	switch k {
-	case reflect.Struct:
-		name = "field"
-		opts = opts.WithTypeMode(autoType)
-		formatKey = func(v reflect.Value) string { return v.String() }
-	case reflect.Slice, reflect.Array:
-		name = "element"
-		opts = opts.WithTypeMode(elideType)
-		formatKey = func(reflect.Value) string { return "" }
-	case reflect.Map:
-		name = "entry"
-		opts = opts.WithTypeMode(elideType)
-		formatKey = func(v reflect.Value) string { return formatMapKey(v, false, ptrs) }
-	}
-
-	maxLen := -1
-	if opts.LimitVerbosity {
-		if opts.DiffMode == diffIdentical {
-			maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc...
-		} else {
-			maxLen = (1 << opts.verbosity()) << 1 // 2, 4, 8, 16, 32, 64, etc...
-		}
-		opts.VerbosityLevel--
-	}
-
-	// Handle unification.
-	switch opts.DiffMode {
-	case diffIdentical, diffRemoved, diffInserted:
-		var list textList
-		var deferredEllipsis bool // Add final "..." to indicate records were dropped
-		for _, r := range recs {
-			if len(list) == maxLen {
-				deferredEllipsis = true
-				break
-			}
-
-			// Elide struct fields that are zero value.
-			if k == reflect.Struct {
-				var isZero bool
-				switch opts.DiffMode {
-				case diffIdentical:
-					isZero = value.IsZero(r.Value.ValueX) || value.IsZero(r.Value.ValueY)
-				case diffRemoved:
-					isZero = value.IsZero(r.Value.ValueX)
-				case diffInserted:
-					isZero = value.IsZero(r.Value.ValueY)
-				}
-				if isZero {
-					continue
-				}
-			}
-			// Elide ignored nodes.
-			if r.Value.NumIgnored > 0 && r.Value.NumSame+r.Value.NumDiff == 0 {
-				deferredEllipsis = !(k == reflect.Slice || k == reflect.Array)
-				if !deferredEllipsis {
-					list.AppendEllipsis(diffStats{})
-				}
-				continue
-			}
-			if out := opts.FormatDiff(r.Value, ptrs); out != nil {
-				list = append(list, textRecord{Key: formatKey(r.Key), Value: out})
-			}
-		}
-		if deferredEllipsis {
-			list.AppendEllipsis(diffStats{})
-		}
-		return &textWrap{Prefix: "{", Value: list, Suffix: "}"}
-	case diffUnknown:
-	default:
-		panic("invalid diff mode")
-	}
-
-	// Handle differencing.
-	var numDiffs int
-	var list textList
-	var keys []reflect.Value // invariant: len(list) == len(keys)
-	groups := coalesceAdjacentRecords(name, recs)
-	maxGroup := diffStats{Name: name}
-	for i, ds := range groups {
-		if maxLen >= 0 && numDiffs >= maxLen {
-			maxGroup = maxGroup.Append(ds)
-			continue
-		}
-
-		// Handle equal records.
-		if ds.NumDiff() == 0 {
-			// Compute the number of leading and trailing records to print.
-			var numLo, numHi int
-			numEqual := ds.NumIgnored + ds.NumIdentical
-			for numLo < numContextRecords && numLo+numHi < numEqual && i != 0 {
-				if r := recs[numLo].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 {
-					break
-				}
-				numLo++
-			}
-			for numHi < numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 {
-				if r := recs[numEqual-numHi-1].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 {
-					break
-				}
-				numHi++
-			}
-			if numEqual-(numLo+numHi) == 1 && ds.NumIgnored == 0 {
-				numHi++ // Avoid pointless coalescing of a single equal record
-			}
-
-			// Format the equal values.
-			for _, r := range recs[:numLo] {
-				out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs)
-				list = append(list, textRecord{Key: formatKey(r.Key), Value: out})
-				keys = append(keys, r.Key)
-			}
-			if numEqual > numLo+numHi {
-				ds.NumIdentical -= numLo + numHi
-				list.AppendEllipsis(ds)
-				for len(keys) < len(list) {
-					keys = append(keys, reflect.Value{})
-				}
-			}
-			for _, r := range recs[numEqual-numHi : numEqual] {
-				out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs)
-				list = append(list, textRecord{Key: formatKey(r.Key), Value: out})
-				keys = append(keys, r.Key)
-			}
-			recs = recs[numEqual:]
-			continue
-		}
-
-		// Handle unequal records.
-		for _, r := range recs[:ds.NumDiff()] {
-			switch {
-			case opts.CanFormatDiffSlice(r.Value):
-				out := opts.FormatDiffSlice(r.Value)
-				list = append(list, textRecord{Key: formatKey(r.Key), Value: out})
-				keys = append(keys, r.Key)
-			case r.Value.NumChildren == r.Value.MaxDepth:
-				outx := opts.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs)
-				outy := opts.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs)
-				for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ {
-					opts2 := verbosityPreset(opts, i)
-					outx = opts2.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs)
-					outy = opts2.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs)
-				}
-				if outx != nil {
-					list = append(list, textRecord{Diff: diffRemoved, Key: formatKey(r.Key), Value: outx})
-					keys = append(keys, r.Key)
-				}
-				if outy != nil {
-					list = append(list, textRecord{Diff: diffInserted, Key: formatKey(r.Key), Value: outy})
-					keys = append(keys, r.Key)
-				}
-			default:
-				out := opts.FormatDiff(r.Value, ptrs)
-				list = append(list, textRecord{Key: formatKey(r.Key), Value: out})
-				keys = append(keys, r.Key)
-			}
-		}
-		recs = recs[ds.NumDiff():]
-		numDiffs += ds.NumDiff()
-	}
-	if maxGroup.IsZero() {
-		assert(len(recs) == 0)
-	} else {
-		list.AppendEllipsis(maxGroup)
-		for len(keys) < len(list) {
-			keys = append(keys, reflect.Value{})
-		}
-	}
-	assert(len(list) == len(keys))
-
-	// For maps, the default formatting logic uses fmt.Stringer which may
-	// produce ambiguous output. Avoid calling String to disambiguate.
-	if k == reflect.Map {
-		var ambiguous bool
-		seenKeys := map[string]reflect.Value{}
-		for i, currKey := range keys {
-			if currKey.IsValid() {
-				strKey := list[i].Key
-				prevKey, seen := seenKeys[strKey]
-				if seen && prevKey.CanInterface() && currKey.CanInterface() {
-					ambiguous = prevKey.Interface() != currKey.Interface()
-					if ambiguous {
-						break
-					}
-				}
-				seenKeys[strKey] = currKey
-			}
-		}
-		if ambiguous {
-			for i, k := range keys {
-				if k.IsValid() {
-					list[i].Key = formatMapKey(k, true, ptrs)
-				}
-			}
-		}
-	}
-
-	return &textWrap{Prefix: "{", Value: list, Suffix: "}"}
-}
-
-// coalesceAdjacentRecords coalesces the list of records into groups of
-// adjacent equal, or unequal counts.
-func coalesceAdjacentRecords(name string, recs []reportRecord) (groups []diffStats) {
-	var prevCase int // Arbitrary index into which case last occurred
-	lastStats := func(i int) *diffStats {
-		if prevCase != i {
-			groups = append(groups, diffStats{Name: name})
-			prevCase = i
-		}
-		return &groups[len(groups)-1]
-	}
-	for _, r := range recs {
-		switch rv := r.Value; {
-		case rv.NumIgnored > 0 && rv.NumSame+rv.NumDiff == 0:
-			lastStats(1).NumIgnored++
-		case rv.NumDiff == 0:
-			lastStats(1).NumIdentical++
-		case rv.NumDiff > 0 && !rv.ValueY.IsValid():
-			lastStats(2).NumRemoved++
-		case rv.NumDiff > 0 && !rv.ValueX.IsValid():
-			lastStats(2).NumInserted++
-		default:
-			lastStats(2).NumModified++
-		}
-	}
-	return groups
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/report_references.go b/vendor/github.com/google/go-cmp/cmp/report_references.go
deleted file mode 100644
index be31b33a9..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report_references.go
+++ /dev/null
@@ -1,264 +0,0 @@
-// Copyright 2020, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-
-	"github.com/google/go-cmp/cmp/internal/flags"
-	"github.com/google/go-cmp/cmp/internal/value"
-)
-
-const (
-	pointerDelimPrefix = "⟪"
-	pointerDelimSuffix = "⟫"
-)
-
-// formatPointer prints the address of the pointer.
-func formatPointer(p value.Pointer, withDelims bool) string {
-	v := p.Uintptr()
-	if flags.Deterministic {
-		v = 0xdeadf00f // Only used for stable testing purposes
-	}
-	if withDelims {
-		return pointerDelimPrefix + formatHex(uint64(v)) + pointerDelimSuffix
-	}
-	return formatHex(uint64(v))
-}
-
-// pointerReferences is a stack of pointers visited so far.
-type pointerReferences [][2]value.Pointer
-
-func (ps *pointerReferences) PushPair(vx, vy reflect.Value, d diffMode, deref bool) (pp [2]value.Pointer) {
-	if deref && vx.IsValid() {
-		vx = vx.Addr()
-	}
-	if deref && vy.IsValid() {
-		vy = vy.Addr()
-	}
-	switch d {
-	case diffUnknown, diffIdentical:
-		pp = [2]value.Pointer{value.PointerOf(vx), value.PointerOf(vy)}
-	case diffRemoved:
-		pp = [2]value.Pointer{value.PointerOf(vx), value.Pointer{}}
-	case diffInserted:
-		pp = [2]value.Pointer{value.Pointer{}, value.PointerOf(vy)}
-	}
-	*ps = append(*ps, pp)
-	return pp
-}
-
-func (ps *pointerReferences) Push(v reflect.Value) (p value.Pointer, seen bool) {
-	p = value.PointerOf(v)
-	for _, pp := range *ps {
-		if p == pp[0] || p == pp[1] {
-			return p, true
-		}
-	}
-	*ps = append(*ps, [2]value.Pointer{p, p})
-	return p, false
-}
-
-func (ps *pointerReferences) Pop() {
-	*ps = (*ps)[:len(*ps)-1]
-}
-
-// trunkReferences is metadata for a textNode indicating that the sub-tree
-// represents the value for either pointer in a pair of references.
-type trunkReferences struct{ pp [2]value.Pointer }
-
-// trunkReference is metadata for a textNode indicating that the sub-tree
-// represents the value for the given pointer reference.
-type trunkReference struct{ p value.Pointer }
-
-// leafReference is metadata for a textNode indicating that the value is
-// truncated as it refers to another part of the tree (i.e., a trunk).
-type leafReference struct{ p value.Pointer }
-
-func wrapTrunkReferences(pp [2]value.Pointer, s textNode) textNode {
-	switch {
-	case pp[0].IsNil():
-		return &textWrap{Value: s, Metadata: trunkReference{pp[1]}}
-	case pp[1].IsNil():
-		return &textWrap{Value: s, Metadata: trunkReference{pp[0]}}
-	case pp[0] == pp[1]:
-		return &textWrap{Value: s, Metadata: trunkReference{pp[0]}}
-	default:
-		return &textWrap{Value: s, Metadata: trunkReferences{pp}}
-	}
-}
-func wrapTrunkReference(p value.Pointer, printAddress bool, s textNode) textNode {
-	var prefix string
-	if printAddress {
-		prefix = formatPointer(p, true)
-	}
-	return &textWrap{Prefix: prefix, Value: s, Metadata: trunkReference{p}}
-}
-func makeLeafReference(p value.Pointer, printAddress bool) textNode {
-	out := &textWrap{Prefix: "(", Value: textEllipsis, Suffix: ")"}
-	var prefix string
-	if printAddress {
-		prefix = formatPointer(p, true)
-	}
-	return &textWrap{Prefix: prefix, Value: out, Metadata: leafReference{p}}
-}
-
-// resolveReferences walks the textNode tree searching for any leaf reference
-// metadata and resolves each against the corresponding trunk references.
-// Since pointer addresses in memory are not particularly readable to the user,
-// it replaces each pointer value with an arbitrary and unique reference ID.
-func resolveReferences(s textNode) {
-	var walkNodes func(textNode, func(textNode))
-	walkNodes = func(s textNode, f func(textNode)) {
-		f(s)
-		switch s := s.(type) {
-		case *textWrap:
-			walkNodes(s.Value, f)
-		case textList:
-			for _, r := range s {
-				walkNodes(r.Value, f)
-			}
-		}
-	}
-
-	// Collect all trunks and leaves with reference metadata.
-	var trunks, leaves []*textWrap
-	walkNodes(s, func(s textNode) {
-		if s, ok := s.(*textWrap); ok {
-			switch s.Metadata.(type) {
-			case leafReference:
-				leaves = append(leaves, s)
-			case trunkReference, trunkReferences:
-				trunks = append(trunks, s)
-			}
-		}
-	})
-
-	// No leaf references to resolve.
-	if len(leaves) == 0 {
-		return
-	}
-
-	// Collect the set of all leaf references to resolve.
-	leafPtrs := make(map[value.Pointer]bool)
-	for _, leaf := range leaves {
-		leafPtrs[leaf.Metadata.(leafReference).p] = true
-	}
-
-	// Collect the set of trunk pointers that are always paired together.
-	// This allows us to assign a single ID to both pointers for brevity.
-	// If a pointer in a pair ever occurs by itself or as a different pair,
-	// then the pair is broken.
-	pairedTrunkPtrs := make(map[value.Pointer]value.Pointer)
-	unpair := func(p value.Pointer) {
-		if !pairedTrunkPtrs[p].IsNil() {
-			pairedTrunkPtrs[pairedTrunkPtrs[p]] = value.Pointer{} // invalidate other half
-		}
-		pairedTrunkPtrs[p] = value.Pointer{} // invalidate this half
-	}
-	for _, trunk := range trunks {
-		switch p := trunk.Metadata.(type) {
-		case trunkReference:
-			unpair(p.p) // standalone pointer cannot be part of a pair
-		case trunkReferences:
-			p0, ok0 := pairedTrunkPtrs[p.pp[0]]
-			p1, ok1 := pairedTrunkPtrs[p.pp[1]]
-			switch {
-			case !ok0 && !ok1:
-				// Register the newly seen pair.
-				pairedTrunkPtrs[p.pp[0]] = p.pp[1]
-				pairedTrunkPtrs[p.pp[1]] = p.pp[0]
-			case ok0 && ok1 && p0 == p.pp[1] && p1 == p.pp[0]:
-				// Exact pair already seen; do nothing.
-			default:
-				// Pair conflicts with some other pair; break all pairs.
-				unpair(p.pp[0])
-				unpair(p.pp[1])
-			}
-		}
-	}
-
-	// Correlate each pointer referenced by leaves to a unique identifier,
-	// and print the IDs for each trunk that matches those pointers.
-	var nextID uint
-	ptrIDs := make(map[value.Pointer]uint)
-	newID := func() uint {
-		id := nextID
-		nextID++
-		return id
-	}
-	for _, trunk := range trunks {
-		switch p := trunk.Metadata.(type) {
-		case trunkReference:
-			if print := leafPtrs[p.p]; print {
-				id, ok := ptrIDs[p.p]
-				if !ok {
-					id = newID()
-					ptrIDs[p.p] = id
-				}
-				trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id))
-			}
-		case trunkReferences:
-			print0 := leafPtrs[p.pp[0]]
-			print1 := leafPtrs[p.pp[1]]
-			if print0 || print1 {
-				id0, ok0 := ptrIDs[p.pp[0]]
-				id1, ok1 := ptrIDs[p.pp[1]]
-				isPair := pairedTrunkPtrs[p.pp[0]] == p.pp[1] && pairedTrunkPtrs[p.pp[1]] == p.pp[0]
-				if isPair {
-					var id uint
-					assert(ok0 == ok1) // must be seen together or not at all
-					if ok0 {
-						assert(id0 == id1) // must have the same ID
-						id = id0
-					} else {
-						id = newID()
-						ptrIDs[p.pp[0]] = id
-						ptrIDs[p.pp[1]] = id
-					}
-					trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id))
-				} else {
-					if print0 && !ok0 {
-						id0 = newID()
-						ptrIDs[p.pp[0]] = id0
-					}
-					if print1 && !ok1 {
-						id1 = newID()
-						ptrIDs[p.pp[1]] = id1
-					}
-					switch {
-					case print0 && print1:
-						trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)+","+formatReference(id1))
-					case print0:
-						trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0))
-					case print1:
-						trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id1))
-					}
-				}
-			}
-		}
-	}
-
-	// Update all leaf references with the unique identifier.
-	for _, leaf := range leaves {
-		if id, ok := ptrIDs[leaf.Metadata.(leafReference).p]; ok {
-			leaf.Prefix = updateReferencePrefix(leaf.Prefix, formatReference(id))
-		}
-	}
-}
-
-func formatReference(id uint) string {
-	return fmt.Sprintf("ref#%d", id)
-}
-
-func updateReferencePrefix(prefix, ref string) string {
-	if prefix == "" {
-		return pointerDelimPrefix + ref + pointerDelimSuffix
-	}
-	suffix := strings.TrimPrefix(prefix, pointerDelimPrefix)
-	return pointerDelimPrefix + ref + ": " + suffix
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/report_reflect.go b/vendor/github.com/google/go-cmp/cmp/report_reflect.go
deleted file mode 100644
index 76c04fdbd..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report_reflect.go
+++ /dev/null
@@ -1,403 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"bytes"
-	"fmt"
-	"reflect"
-	"strconv"
-	"strings"
-	"unicode"
-	"unicode/utf8"
-
-	"github.com/google/go-cmp/cmp/internal/value"
-)
-
-type formatValueOptions struct {
-	// AvoidStringer controls whether to avoid calling custom stringer
-	// methods like error.Error or fmt.Stringer.String.
-	AvoidStringer bool
-
-	// PrintAddresses controls whether to print the address of all pointers,
-	// slice elements, and maps.
-	PrintAddresses bool
-
-	// QualifiedNames controls whether FormatType uses the fully qualified name
-	// (including the full package path as opposed to just the package name).
-	QualifiedNames bool
-
-	// VerbosityLevel controls the amount of output to produce.
-	// A higher value produces more output. A value of zero or lower produces
-	// no output (represented using an ellipsis).
-	// If LimitVerbosity is false, then the level is treated as infinite.
-	VerbosityLevel int
-
-	// LimitVerbosity specifies that formatting should respect VerbosityLevel.
-	LimitVerbosity bool
-}
-
-// FormatType prints the type as if it were wrapping s.
-// This may return s as-is depending on the current type and TypeMode mode.
-func (opts formatOptions) FormatType(t reflect.Type, s textNode) textNode {
-	// Check whether to emit the type or not.
-	switch opts.TypeMode {
-	case autoType:
-		switch t.Kind() {
-		case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map:
-			if s.Equal(textNil) {
-				return s
-			}
-		default:
-			return s
-		}
-		if opts.DiffMode == diffIdentical {
-			return s // elide type for identical nodes
-		}
-	case elideType:
-		return s
-	}
-
-	// Determine the type label, applying special handling for unnamed types.
-	typeName := value.TypeString(t, opts.QualifiedNames)
-	if t.Name() == "" {
-		// According to Go grammar, certain type literals contain symbols that
-		// do not strongly bind to the next lexicographical token (e.g., *T).
-		switch t.Kind() {
-		case reflect.Chan, reflect.Func, reflect.Ptr:
-			typeName = "(" + typeName + ")"
-		}
-	}
-	return &textWrap{Prefix: typeName, Value: wrapParens(s)}
-}
-
-// wrapParens wraps s with a set of parenthesis, but avoids it if the
-// wrapped node itself is already surrounded by a pair of parenthesis or braces.
-// It handles unwrapping one level of pointer-reference nodes.
-func wrapParens(s textNode) textNode {
-	var refNode *textWrap
-	if s2, ok := s.(*textWrap); ok {
-		// Unwrap a single pointer reference node.
-		switch s2.Metadata.(type) {
-		case leafReference, trunkReference, trunkReferences:
-			refNode = s2
-			if s3, ok := refNode.Value.(*textWrap); ok {
-				s2 = s3
-			}
-		}
-
-		// Already has delimiters that make parenthesis unnecessary.
-		hasParens := strings.HasPrefix(s2.Prefix, "(") && strings.HasSuffix(s2.Suffix, ")")
-		hasBraces := strings.HasPrefix(s2.Prefix, "{") && strings.HasSuffix(s2.Suffix, "}")
-		if hasParens || hasBraces {
-			return s
-		}
-	}
-	if refNode != nil {
-		refNode.Value = &textWrap{Prefix: "(", Value: refNode.Value, Suffix: ")"}
-		return s
-	}
-	return &textWrap{Prefix: "(", Value: s, Suffix: ")"}
-}
-
-// FormatValue prints the reflect.Value, taking extra care to avoid descending
-// into pointers already in ptrs. As pointers are visited, ptrs is also updated.
-func (opts formatOptions) FormatValue(v reflect.Value, parentKind reflect.Kind, ptrs *pointerReferences) (out textNode) {
-	if !v.IsValid() {
-		return nil
-	}
-	t := v.Type()
-
-	// Check slice element for cycles.
-	if parentKind == reflect.Slice {
-		ptrRef, visited := ptrs.Push(v.Addr())
-		if visited {
-			return makeLeafReference(ptrRef, false)
-		}
-		defer ptrs.Pop()
-		defer func() { out = wrapTrunkReference(ptrRef, false, out) }()
-	}
-
-	// Check whether there is an Error or String method to call.
-	if !opts.AvoidStringer && v.CanInterface() {
-		// Avoid calling Error or String methods on nil receivers since many
-		// implementations crash when doing so.
-		if (t.Kind() != reflect.Ptr && t.Kind() != reflect.Interface) || !v.IsNil() {
-			var prefix, strVal string
-			func() {
-				// Swallow and ignore any panics from String or Error.
-				defer func() { recover() }()
-				switch v := v.Interface().(type) {
-				case error:
-					strVal = v.Error()
-					prefix = "e"
-				case fmt.Stringer:
-					strVal = v.String()
-					prefix = "s"
-				}
-			}()
-			if prefix != "" {
-				return opts.formatString(prefix, strVal)
-			}
-		}
-	}
-
-	// Check whether to explicitly wrap the result with the type.
-	var skipType bool
-	defer func() {
-		if !skipType {
-			out = opts.FormatType(t, out)
-		}
-	}()
-
-	switch t.Kind() {
-	case reflect.Bool:
-		return textLine(fmt.Sprint(v.Bool()))
-	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-		return textLine(fmt.Sprint(v.Int()))
-	case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64:
-		return textLine(fmt.Sprint(v.Uint()))
-	case reflect.Uint8:
-		if parentKind == reflect.Slice || parentKind == reflect.Array {
-			return textLine(formatHex(v.Uint()))
-		}
-		return textLine(fmt.Sprint(v.Uint()))
-	case reflect.Uintptr:
-		return textLine(formatHex(v.Uint()))
-	case reflect.Float32, reflect.Float64:
-		return textLine(fmt.Sprint(v.Float()))
-	case reflect.Complex64, reflect.Complex128:
-		return textLine(fmt.Sprint(v.Complex()))
-	case reflect.String:
-		return opts.formatString("", v.String())
-	case reflect.UnsafePointer, reflect.Chan, reflect.Func:
-		return textLine(formatPointer(value.PointerOf(v), true))
-	case reflect.Struct:
-		var list textList
-		v := makeAddressable(v) // needed for retrieveUnexportedField
-		maxLen := v.NumField()
-		if opts.LimitVerbosity {
-			maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc...
-			opts.VerbosityLevel--
-		}
-		for i := 0; i < v.NumField(); i++ {
-			vv := v.Field(i)
-			if value.IsZero(vv) {
-				continue // Elide fields with zero values
-			}
-			if len(list) == maxLen {
-				list.AppendEllipsis(diffStats{})
-				break
-			}
-			sf := t.Field(i)
-			if supportExporters && !isExported(sf.Name) {
-				vv = retrieveUnexportedField(v, sf, true)
-			}
-			s := opts.WithTypeMode(autoType).FormatValue(vv, t.Kind(), ptrs)
-			list = append(list, textRecord{Key: sf.Name, Value: s})
-		}
-		return &textWrap{Prefix: "{", Value: list, Suffix: "}"}
-	case reflect.Slice:
-		if v.IsNil() {
-			return textNil
-		}
-
-		// Check whether this is a []byte of text data.
-		if t.Elem() == reflect.TypeOf(byte(0)) {
-			b := v.Bytes()
-			isPrintSpace := func(r rune) bool { return unicode.IsPrint(r) || unicode.IsSpace(r) }
-			if len(b) > 0 && utf8.Valid(b) && len(bytes.TrimFunc(b, isPrintSpace)) == 0 {
-				out = opts.formatString("", string(b))
-				skipType = true
-				return opts.WithTypeMode(emitType).FormatType(t, out)
-			}
-		}
-
-		fallthrough
-	case reflect.Array:
-		maxLen := v.Len()
-		if opts.LimitVerbosity {
-			maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc...
-			opts.VerbosityLevel--
-		}
-		var list textList
-		for i := 0; i < v.Len(); i++ {
-			if len(list) == maxLen {
-				list.AppendEllipsis(diffStats{})
-				break
-			}
-			s := opts.WithTypeMode(elideType).FormatValue(v.Index(i), t.Kind(), ptrs)
-			list = append(list, textRecord{Value: s})
-		}
-
-		out = &textWrap{Prefix: "{", Value: list, Suffix: "}"}
-		if t.Kind() == reflect.Slice && opts.PrintAddresses {
-			header := fmt.Sprintf("ptr:%v, len:%d, cap:%d", formatPointer(value.PointerOf(v), false), v.Len(), v.Cap())
-			out = &textWrap{Prefix: pointerDelimPrefix + header + pointerDelimSuffix, Value: out}
-		}
-		return out
-	case reflect.Map:
-		if v.IsNil() {
-			return textNil
-		}
-
-		// Check pointer for cycles.
-		ptrRef, visited := ptrs.Push(v)
-		if visited {
-			return makeLeafReference(ptrRef, opts.PrintAddresses)
-		}
-		defer ptrs.Pop()
-
-		maxLen := v.Len()
-		if opts.LimitVerbosity {
-			maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc...
-			opts.VerbosityLevel--
-		}
-		var list textList
-		for _, k := range value.SortKeys(v.MapKeys()) {
-			if len(list) == maxLen {
-				list.AppendEllipsis(diffStats{})
-				break
-			}
-			sk := formatMapKey(k, false, ptrs)
-			sv := opts.WithTypeMode(elideType).FormatValue(v.MapIndex(k), t.Kind(), ptrs)
-			list = append(list, textRecord{Key: sk, Value: sv})
-		}
-
-		out = &textWrap{Prefix: "{", Value: list, Suffix: "}"}
-		out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out)
-		return out
-	case reflect.Ptr:
-		if v.IsNil() {
-			return textNil
-		}
-
-		// Check pointer for cycles.
-		ptrRef, visited := ptrs.Push(v)
-		if visited {
-			out = makeLeafReference(ptrRef, opts.PrintAddresses)
-			return &textWrap{Prefix: "&", Value: out}
-		}
-		defer ptrs.Pop()
-
-		skipType = true // Let the underlying value print the type instead
-		out = opts.FormatValue(v.Elem(), t.Kind(), ptrs)
-		out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out)
-		out = &textWrap{Prefix: "&", Value: out}
-		return out
-	case reflect.Interface:
-		if v.IsNil() {
-			return textNil
-		}
-		// Interfaces accept different concrete types,
-		// so configure the underlying value to explicitly print the type.
-		skipType = true // Print the concrete type instead
-		return opts.WithTypeMode(emitType).FormatValue(v.Elem(), t.Kind(), ptrs)
-	default:
-		panic(fmt.Sprintf("%v kind not handled", v.Kind()))
-	}
-}
-
-func (opts formatOptions) formatString(prefix, s string) textNode {
-	maxLen := len(s)
-	maxLines := strings.Count(s, "\n") + 1
-	if opts.LimitVerbosity {
-		maxLen = (1 << opts.verbosity()) << 5   // 32, 64, 128, 256, etc...
-		maxLines = (1 << opts.verbosity()) << 2 //  4, 8, 16, 32, 64, etc...
-	}
-
-	// For multiline strings, use the triple-quote syntax,
-	// but only use it when printing removed or inserted nodes since
-	// we only want the extra verbosity for those cases.
-	lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n")
-	isTripleQuoted := len(lines) >= 4 && (opts.DiffMode == '-' || opts.DiffMode == '+')
-	for i := 0; i < len(lines) && isTripleQuoted; i++ {
-		lines[i] = strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support
-		isPrintable := func(r rune) bool {
-			return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable
-		}
-		line := lines[i]
-		isTripleQuoted = !strings.HasPrefix(strings.TrimPrefix(line, prefix), `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" && len(line) <= maxLen
-	}
-	if isTripleQuoted {
-		var list textList
-		list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true})
-		for i, line := range lines {
-			if numElided := len(lines) - i; i == maxLines-1 && numElided > 1 {
-				comment := commentString(fmt.Sprintf("%d elided lines", numElided))
-				list = append(list, textRecord{Diff: opts.DiffMode, Value: textEllipsis, ElideComma: true, Comment: comment})
-				break
-			}
-			list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(line), ElideComma: true})
-		}
-		list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true})
-		return &textWrap{Prefix: "(", Value: list, Suffix: ")"}
-	}
-
-	// Format the string as a single-line quoted string.
-	if len(s) > maxLen+len(textEllipsis) {
-		return textLine(prefix + formatString(s[:maxLen]) + string(textEllipsis))
-	}
-	return textLine(prefix + formatString(s))
-}
-
-// formatMapKey formats v as if it were a map key.
-// The result is guaranteed to be a single line.
-func formatMapKey(v reflect.Value, disambiguate bool, ptrs *pointerReferences) string {
-	var opts formatOptions
-	opts.DiffMode = diffIdentical
-	opts.TypeMode = elideType
-	opts.PrintAddresses = disambiguate
-	opts.AvoidStringer = disambiguate
-	opts.QualifiedNames = disambiguate
-	opts.VerbosityLevel = maxVerbosityPreset
-	opts.LimitVerbosity = true
-	s := opts.FormatValue(v, reflect.Map, ptrs).String()
-	return strings.TrimSpace(s)
-}
-
-// formatString prints s as a double-quoted or backtick-quoted string.
-func formatString(s string) string {
-	// Use quoted string if it the same length as a raw string literal.
-	// Otherwise, attempt to use the raw string form.
-	qs := strconv.Quote(s)
-	if len(qs) == 1+len(s)+1 {
-		return qs
-	}
-
-	// Disallow newlines to ensure output is a single line.
-	// Only allow printable runes for readability purposes.
-	rawInvalid := func(r rune) bool {
-		return r == '`' || r == '\n' || !(unicode.IsPrint(r) || r == '\t')
-	}
-	if utf8.ValidString(s) && strings.IndexFunc(s, rawInvalid) < 0 {
-		return "`" + s + "`"
-	}
-	return qs
-}
-
-// formatHex prints u as a hexadecimal integer in Go notation.
-func formatHex(u uint64) string {
-	var f string
-	switch {
-	case u <= 0xff:
-		f = "0x%02x"
-	case u <= 0xffff:
-		f = "0x%04x"
-	case u <= 0xffffff:
-		f = "0x%06x"
-	case u <= 0xffffffff:
-		f = "0x%08x"
-	case u <= 0xffffffffff:
-		f = "0x%010x"
-	case u <= 0xffffffffffff:
-		f = "0x%012x"
-	case u <= 0xffffffffffffff:
-		f = "0x%014x"
-	case u <= 0xffffffffffffffff:
-		f = "0x%016x"
-	}
-	return fmt.Sprintf(f, u)
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/report_slices.go b/vendor/github.com/google/go-cmp/cmp/report_slices.go
deleted file mode 100644
index 68b5c1ae1..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report_slices.go
+++ /dev/null
@@ -1,613 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"bytes"
-	"fmt"
-	"math"
-	"reflect"
-	"strconv"
-	"strings"
-	"unicode"
-	"unicode/utf8"
-
-	"github.com/google/go-cmp/cmp/internal/diff"
-)
-
-// CanFormatDiffSlice reports whether we support custom formatting for nodes
-// that are slices of primitive kinds or strings.
-func (opts formatOptions) CanFormatDiffSlice(v *valueNode) bool {
-	switch {
-	case opts.DiffMode != diffUnknown:
-		return false // Must be formatting in diff mode
-	case v.NumDiff == 0:
-		return false // No differences detected
-	case !v.ValueX.IsValid() || !v.ValueY.IsValid():
-		return false // Both values must be valid
-	case v.NumIgnored > 0:
-		return false // Some ignore option was used
-	case v.NumTransformed > 0:
-		return false // Some transform option was used
-	case v.NumCompared > 1:
-		return false // More than one comparison was used
-	case v.NumCompared == 1 && v.Type.Name() != "":
-		// The need for cmp to check applicability of options on every element
-		// in a slice is a significant performance detriment for large []byte.
-		// The workaround is to specify Comparer(bytes.Equal),
-		// which enables cmp to compare []byte more efficiently.
-		// If they differ, we still want to provide batched diffing.
-		// The logic disallows named types since they tend to have their own
-		// String method, with nicer formatting than what this provides.
-		return false
-	}
-
-	// Check whether this is an interface with the same concrete types.
-	t := v.Type
-	vx, vy := v.ValueX, v.ValueY
-	if t.Kind() == reflect.Interface && !vx.IsNil() && !vy.IsNil() && vx.Elem().Type() == vy.Elem().Type() {
-		vx, vy = vx.Elem(), vy.Elem()
-		t = vx.Type()
-	}
-
-	// Check whether we provide specialized diffing for this type.
-	switch t.Kind() {
-	case reflect.String:
-	case reflect.Array, reflect.Slice:
-		// Only slices of primitive types have specialized handling.
-		switch t.Elem().Kind() {
-		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
-			reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr,
-			reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128:
-		default:
-			return false
-		}
-
-		// Both slice values have to be non-empty.
-		if t.Kind() == reflect.Slice && (vx.Len() == 0 || vy.Len() == 0) {
-			return false
-		}
-
-		// If a sufficient number of elements already differ,
-		// use specialized formatting even if length requirement is not met.
-		if v.NumDiff > v.NumSame {
-			return true
-		}
-	default:
-		return false
-	}
-
-	// Use specialized string diffing for longer slices or strings.
-	const minLength = 32
-	return vx.Len() >= minLength && vy.Len() >= minLength
-}
-
-// FormatDiffSlice prints a diff for the slices (or strings) represented by v.
-// This provides custom-tailored logic to make printing of differences in
-// textual strings and slices of primitive kinds more readable.
-func (opts formatOptions) FormatDiffSlice(v *valueNode) textNode {
-	assert(opts.DiffMode == diffUnknown)
-	t, vx, vy := v.Type, v.ValueX, v.ValueY
-	if t.Kind() == reflect.Interface {
-		vx, vy = vx.Elem(), vy.Elem()
-		t = vx.Type()
-		opts = opts.WithTypeMode(emitType)
-	}
-
-	// Auto-detect the type of the data.
-	var sx, sy string
-	var ssx, ssy []string
-	var isString, isMostlyText, isPureLinedText, isBinary bool
-	switch {
-	case t.Kind() == reflect.String:
-		sx, sy = vx.String(), vy.String()
-		isString = true
-	case t.Kind() == reflect.Slice && t.Elem() == reflect.TypeOf(byte(0)):
-		sx, sy = string(vx.Bytes()), string(vy.Bytes())
-		isString = true
-	case t.Kind() == reflect.Array:
-		// Arrays need to be addressable for slice operations to work.
-		vx2, vy2 := reflect.New(t).Elem(), reflect.New(t).Elem()
-		vx2.Set(vx)
-		vy2.Set(vy)
-		vx, vy = vx2, vy2
-	}
-	if isString {
-		var numTotalRunes, numValidRunes, numLines, lastLineIdx, maxLineLen int
-		for i, r := range sx + sy {
-			numTotalRunes++
-			if (unicode.IsPrint(r) || unicode.IsSpace(r)) && r != utf8.RuneError {
-				numValidRunes++
-			}
-			if r == '\n' {
-				if maxLineLen < i-lastLineIdx {
-					maxLineLen = i - lastLineIdx
-				}
-				lastLineIdx = i + 1
-				numLines++
-			}
-		}
-		isPureText := numValidRunes == numTotalRunes
-		isMostlyText = float64(numValidRunes) > math.Floor(0.90*float64(numTotalRunes))
-		isPureLinedText = isPureText && numLines >= 4 && maxLineLen <= 1024
-		isBinary = !isMostlyText
-
-		// Avoid diffing by lines if it produces a significantly more complex
-		// edit script than diffing by bytes.
-		if isPureLinedText {
-			ssx = strings.Split(sx, "\n")
-			ssy = strings.Split(sy, "\n")
-			esLines := diff.Difference(len(ssx), len(ssy), func(ix, iy int) diff.Result {
-				return diff.BoolResult(ssx[ix] == ssy[iy])
-			})
-			esBytes := diff.Difference(len(sx), len(sy), func(ix, iy int) diff.Result {
-				return diff.BoolResult(sx[ix] == sy[iy])
-			})
-			efficiencyLines := float64(esLines.Dist()) / float64(len(esLines))
-			efficiencyBytes := float64(esBytes.Dist()) / float64(len(esBytes))
-			isPureLinedText = efficiencyLines < 4*efficiencyBytes
-		}
-	}
-
-	// Format the string into printable records.
-	var list textList
-	var delim string
-	switch {
-	// If the text appears to be multi-lined text,
-	// then perform differencing across individual lines.
-	case isPureLinedText:
-		list = opts.formatDiffSlice(
-			reflect.ValueOf(ssx), reflect.ValueOf(ssy), 1, "line",
-			func(v reflect.Value, d diffMode) textRecord {
-				s := formatString(v.Index(0).String())
-				return textRecord{Diff: d, Value: textLine(s)}
-			},
-		)
-		delim = "\n"
-
-		// If possible, use a custom triple-quote (""") syntax for printing
-		// differences in a string literal. This format is more readable,
-		// but has edge-cases where differences are visually indistinguishable.
-		// This format is avoided under the following conditions:
-		//	• A line starts with `"""`
-		//	• A line starts with "..."
-		//	• A line contains non-printable characters
-		//	• Adjacent different lines differ only by whitespace
-		//
-		// For example:
-		//		"""
-		//		... // 3 identical lines
-		//		foo
-		//		bar
-		//	-	baz
-		//	+	BAZ
-		//		"""
-		isTripleQuoted := true
-		prevRemoveLines := map[string]bool{}
-		prevInsertLines := map[string]bool{}
-		var list2 textList
-		list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true})
-		for _, r := range list {
-			if !r.Value.Equal(textEllipsis) {
-				line, _ := strconv.Unquote(string(r.Value.(textLine)))
-				line = strings.TrimPrefix(strings.TrimSuffix(line, "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support
-				normLine := strings.Map(func(r rune) rune {
-					if unicode.IsSpace(r) {
-						return -1 // drop whitespace to avoid visually indistinguishable output
-					}
-					return r
-				}, line)
-				isPrintable := func(r rune) bool {
-					return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable
-				}
-				isTripleQuoted = !strings.HasPrefix(line, `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == ""
-				switch r.Diff {
-				case diffRemoved:
-					isTripleQuoted = isTripleQuoted && !prevInsertLines[normLine]
-					prevRemoveLines[normLine] = true
-				case diffInserted:
-					isTripleQuoted = isTripleQuoted && !prevRemoveLines[normLine]
-					prevInsertLines[normLine] = true
-				}
-				if !isTripleQuoted {
-					break
-				}
-				r.Value = textLine(line)
-				r.ElideComma = true
-			}
-			if !(r.Diff == diffRemoved || r.Diff == diffInserted) { // start a new non-adjacent difference group
-				prevRemoveLines = map[string]bool{}
-				prevInsertLines = map[string]bool{}
-			}
-			list2 = append(list2, r)
-		}
-		if r := list2[len(list2)-1]; r.Diff == diffIdentical && len(r.Value.(textLine)) == 0 {
-			list2 = list2[:len(list2)-1] // elide single empty line at the end
-		}
-		list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true})
-		if isTripleQuoted {
-			var out textNode = &textWrap{Prefix: "(", Value: list2, Suffix: ")"}
-			switch t.Kind() {
-			case reflect.String:
-				if t != reflect.TypeOf(string("")) {
-					out = opts.FormatType(t, out)
-				}
-			case reflect.Slice:
-				// Always emit type for slices since the triple-quote syntax
-				// looks like a string (not a slice).
-				opts = opts.WithTypeMode(emitType)
-				out = opts.FormatType(t, out)
-			}
-			return out
-		}
-
-	// If the text appears to be single-lined text,
-	// then perform differencing in approximately fixed-sized chunks.
-	// The output is printed as quoted strings.
-	case isMostlyText:
-		list = opts.formatDiffSlice(
-			reflect.ValueOf(sx), reflect.ValueOf(sy), 64, "byte",
-			func(v reflect.Value, d diffMode) textRecord {
-				s := formatString(v.String())
-				return textRecord{Diff: d, Value: textLine(s)}
-			},
-		)
-
-	// If the text appears to be binary data,
-	// then perform differencing in approximately fixed-sized chunks.
-	// The output is inspired by hexdump.
-	case isBinary:
-		list = opts.formatDiffSlice(
-			reflect.ValueOf(sx), reflect.ValueOf(sy), 16, "byte",
-			func(v reflect.Value, d diffMode) textRecord {
-				var ss []string
-				for i := 0; i < v.Len(); i++ {
-					ss = append(ss, formatHex(v.Index(i).Uint()))
-				}
-				s := strings.Join(ss, ", ")
-				comment := commentString(fmt.Sprintf("%c|%v|", d, formatASCII(v.String())))
-				return textRecord{Diff: d, Value: textLine(s), Comment: comment}
-			},
-		)
-
-	// For all other slices of primitive types,
-	// then perform differencing in approximately fixed-sized chunks.
-	// The size of each chunk depends on the width of the element kind.
-	default:
-		var chunkSize int
-		if t.Elem().Kind() == reflect.Bool {
-			chunkSize = 16
-		} else {
-			switch t.Elem().Bits() {
-			case 8:
-				chunkSize = 16
-			case 16:
-				chunkSize = 12
-			case 32:
-				chunkSize = 8
-			default:
-				chunkSize = 8
-			}
-		}
-		list = opts.formatDiffSlice(
-			vx, vy, chunkSize, t.Elem().Kind().String(),
-			func(v reflect.Value, d diffMode) textRecord {
-				var ss []string
-				for i := 0; i < v.Len(); i++ {
-					switch t.Elem().Kind() {
-					case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
-						ss = append(ss, fmt.Sprint(v.Index(i).Int()))
-					case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64:
-						ss = append(ss, fmt.Sprint(v.Index(i).Uint()))
-					case reflect.Uint8, reflect.Uintptr:
-						ss = append(ss, formatHex(v.Index(i).Uint()))
-					case reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128:
-						ss = append(ss, fmt.Sprint(v.Index(i).Interface()))
-					}
-				}
-				s := strings.Join(ss, ", ")
-				return textRecord{Diff: d, Value: textLine(s)}
-			},
-		)
-	}
-
-	// Wrap the output with appropriate type information.
-	var out textNode = &textWrap{Prefix: "{", Value: list, Suffix: "}"}
-	if !isMostlyText {
-		// The "{...}" byte-sequence literal is not valid Go syntax for strings.
-		// Emit the type for extra clarity (e.g. "string{...}").
-		if t.Kind() == reflect.String {
-			opts = opts.WithTypeMode(emitType)
-		}
-		return opts.FormatType(t, out)
-	}
-	switch t.Kind() {
-	case reflect.String:
-		out = &textWrap{Prefix: "strings.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)}
-		if t != reflect.TypeOf(string("")) {
-			out = opts.FormatType(t, out)
-		}
-	case reflect.Slice:
-		out = &textWrap{Prefix: "bytes.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)}
-		if t != reflect.TypeOf([]byte(nil)) {
-			out = opts.FormatType(t, out)
-		}
-	}
-	return out
-}
-
-// formatASCII formats s as an ASCII string.
-// This is useful for printing binary strings in a semi-legible way.
-func formatASCII(s string) string {
-	b := bytes.Repeat([]byte{'.'}, len(s))
-	for i := 0; i < len(s); i++ {
-		if ' ' <= s[i] && s[i] <= '~' {
-			b[i] = s[i]
-		}
-	}
-	return string(b)
-}
-
-func (opts formatOptions) formatDiffSlice(
-	vx, vy reflect.Value, chunkSize int, name string,
-	makeRec func(reflect.Value, diffMode) textRecord,
-) (list textList) {
-	eq := func(ix, iy int) bool {
-		return vx.Index(ix).Interface() == vy.Index(iy).Interface()
-	}
-	es := diff.Difference(vx.Len(), vy.Len(), func(ix, iy int) diff.Result {
-		return diff.BoolResult(eq(ix, iy))
-	})
-
-	appendChunks := func(v reflect.Value, d diffMode) int {
-		n0 := v.Len()
-		for v.Len() > 0 {
-			n := chunkSize
-			if n > v.Len() {
-				n = v.Len()
-			}
-			list = append(list, makeRec(v.Slice(0, n), d))
-			v = v.Slice(n, v.Len())
-		}
-		return n0 - v.Len()
-	}
-
-	var numDiffs int
-	maxLen := -1
-	if opts.LimitVerbosity {
-		maxLen = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc...
-		opts.VerbosityLevel--
-	}
-
-	groups := coalesceAdjacentEdits(name, es)
-	groups = coalesceInterveningIdentical(groups, chunkSize/4)
-	groups = cleanupSurroundingIdentical(groups, eq)
-	maxGroup := diffStats{Name: name}
-	for i, ds := range groups {
-		if maxLen >= 0 && numDiffs >= maxLen {
-			maxGroup = maxGroup.Append(ds)
-			continue
-		}
-
-		// Print equal.
-		if ds.NumDiff() == 0 {
-			// Compute the number of leading and trailing equal bytes to print.
-			var numLo, numHi int
-			numEqual := ds.NumIgnored + ds.NumIdentical
-			for numLo < chunkSize*numContextRecords && numLo+numHi < numEqual && i != 0 {
-				numLo++
-			}
-			for numHi < chunkSize*numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 {
-				numHi++
-			}
-			if numEqual-(numLo+numHi) <= chunkSize && ds.NumIgnored == 0 {
-				numHi = numEqual - numLo // Avoid pointless coalescing of single equal row
-			}
-
-			// Print the equal bytes.
-			appendChunks(vx.Slice(0, numLo), diffIdentical)
-			if numEqual > numLo+numHi {
-				ds.NumIdentical -= numLo + numHi
-				list.AppendEllipsis(ds)
-			}
-			appendChunks(vx.Slice(numEqual-numHi, numEqual), diffIdentical)
-			vx = vx.Slice(numEqual, vx.Len())
-			vy = vy.Slice(numEqual, vy.Len())
-			continue
-		}
-
-		// Print unequal.
-		len0 := len(list)
-		nx := appendChunks(vx.Slice(0, ds.NumIdentical+ds.NumRemoved+ds.NumModified), diffRemoved)
-		vx = vx.Slice(nx, vx.Len())
-		ny := appendChunks(vy.Slice(0, ds.NumIdentical+ds.NumInserted+ds.NumModified), diffInserted)
-		vy = vy.Slice(ny, vy.Len())
-		numDiffs += len(list) - len0
-	}
-	if maxGroup.IsZero() {
-		assert(vx.Len() == 0 && vy.Len() == 0)
-	} else {
-		list.AppendEllipsis(maxGroup)
-	}
-	return list
-}
-
-// coalesceAdjacentEdits coalesces the list of edits into groups of adjacent
-// equal or unequal counts.
-//
-// Example:
-//
-//	Input:  "..XXY...Y"
-//	Output: [
-//		{NumIdentical: 2},
-//		{NumRemoved: 2, NumInserted 1},
-//		{NumIdentical: 3},
-//		{NumInserted: 1},
-//	]
-//
-func coalesceAdjacentEdits(name string, es diff.EditScript) (groups []diffStats) {
-	var prevMode byte
-	lastStats := func(mode byte) *diffStats {
-		if prevMode != mode {
-			groups = append(groups, diffStats{Name: name})
-			prevMode = mode
-		}
-		return &groups[len(groups)-1]
-	}
-	for _, e := range es {
-		switch e {
-		case diff.Identity:
-			lastStats('=').NumIdentical++
-		case diff.UniqueX:
-			lastStats('!').NumRemoved++
-		case diff.UniqueY:
-			lastStats('!').NumInserted++
-		case diff.Modified:
-			lastStats('!').NumModified++
-		}
-	}
-	return groups
-}
-
-// coalesceInterveningIdentical coalesces sufficiently short (<= windowSize)
-// equal groups into adjacent unequal groups that currently result in a
-// dual inserted/removed printout. This acts as a high-pass filter to smooth
-// out high-frequency changes within the windowSize.
-//
-// Example:
-//
-//	WindowSize: 16,
-//	Input: [
-//		{NumIdentical: 61},              // group 0
-//		{NumRemoved: 3, NumInserted: 1}, // group 1
-//		{NumIdentical: 6},               // ├── coalesce
-//		{NumInserted: 2},                // ├── coalesce
-//		{NumIdentical: 1},               // ├── coalesce
-//		{NumRemoved: 9},                 // └── coalesce
-//		{NumIdentical: 64},              // group 2
-//		{NumRemoved: 3, NumInserted: 1}, // group 3
-//		{NumIdentical: 6},               // ├── coalesce
-//		{NumInserted: 2},                // ├── coalesce
-//		{NumIdentical: 1},               // ├── coalesce
-//		{NumRemoved: 7},                 // ├── coalesce
-//		{NumIdentical: 1},               // ├── coalesce
-//		{NumRemoved: 2},                 // └── coalesce
-//		{NumIdentical: 63},              // group 4
-//	]
-//	Output: [
-//		{NumIdentical: 61},
-//		{NumIdentical: 7, NumRemoved: 12, NumInserted: 3},
-//		{NumIdentical: 64},
-//		{NumIdentical: 8, NumRemoved: 12, NumInserted: 3},
-//		{NumIdentical: 63},
-//	]
-//
-func coalesceInterveningIdentical(groups []diffStats, windowSize int) []diffStats {
-	groups, groupsOrig := groups[:0], groups
-	for i, ds := range groupsOrig {
-		if len(groups) >= 2 && ds.NumDiff() > 0 {
-			prev := &groups[len(groups)-2] // Unequal group
-			curr := &groups[len(groups)-1] // Equal group
-			next := &groupsOrig[i]         // Unequal group
-			hadX, hadY := prev.NumRemoved > 0, prev.NumInserted > 0
-			hasX, hasY := next.NumRemoved > 0, next.NumInserted > 0
-			if ((hadX || hasX) && (hadY || hasY)) && curr.NumIdentical <= windowSize {
-				*prev = prev.Append(*curr).Append(*next)
-				groups = groups[:len(groups)-1] // Truncate off equal group
-				continue
-			}
-		}
-		groups = append(groups, ds)
-	}
-	return groups
-}
-
-// cleanupSurroundingIdentical scans through all unequal groups, and
-// moves any leading sequence of equal elements to the preceding equal group and
-// moves and trailing sequence of equal elements to the succeeding equal group.
-//
-// This is necessary since coalesceInterveningIdentical may coalesce edit groups
-// together such that leading/trailing spans of equal elements becomes possible.
-// Note that this can occur even with an optimal diffing algorithm.
-//
-// Example:
-//
-//	Input: [
-//		{NumIdentical: 61},
-//		{NumIdentical: 1 , NumRemoved: 11, NumInserted: 2}, // assume 3 leading identical elements
-//		{NumIdentical: 67},
-//		{NumIdentical: 7, NumRemoved: 12, NumInserted: 3},  // assume 10 trailing identical elements
-//		{NumIdentical: 54},
-//	]
-//	Output: [
-//		{NumIdentical: 64}, // incremented by 3
-//		{NumRemoved: 9},
-//		{NumIdentical: 67},
-//		{NumRemoved: 9},
-//		{NumIdentical: 64}, // incremented by 10
-//	]
-//
-func cleanupSurroundingIdentical(groups []diffStats, eq func(i, j int) bool) []diffStats {
-	var ix, iy int // indexes into sequence x and y
-	for i, ds := range groups {
-		// Handle equal group.
-		if ds.NumDiff() == 0 {
-			ix += ds.NumIdentical
-			iy += ds.NumIdentical
-			continue
-		}
-
-		// Handle unequal group.
-		nx := ds.NumIdentical + ds.NumRemoved + ds.NumModified
-		ny := ds.NumIdentical + ds.NumInserted + ds.NumModified
-		var numLeadingIdentical, numTrailingIdentical int
-		for j := 0; j < nx && j < ny && eq(ix+j, iy+j); j++ {
-			numLeadingIdentical++
-		}
-		for j := 0; j < nx && j < ny && eq(ix+nx-1-j, iy+ny-1-j); j++ {
-			numTrailingIdentical++
-		}
-		if numIdentical := numLeadingIdentical + numTrailingIdentical; numIdentical > 0 {
-			if numLeadingIdentical > 0 {
-				// Remove leading identical span from this group and
-				// insert it into the preceding group.
-				if i-1 >= 0 {
-					groups[i-1].NumIdentical += numLeadingIdentical
-				} else {
-					// No preceding group exists, so prepend a new group,
-					// but do so after we finish iterating over all groups.
-					defer func() {
-						groups = append([]diffStats{{Name: groups[0].Name, NumIdentical: numLeadingIdentical}}, groups...)
-					}()
-				}
-				// Increment indexes since the preceding group would have handled this.
-				ix += numLeadingIdentical
-				iy += numLeadingIdentical
-			}
-			if numTrailingIdentical > 0 {
-				// Remove trailing identical span from this group and
-				// insert it into the succeeding group.
-				if i+1 < len(groups) {
-					groups[i+1].NumIdentical += numTrailingIdentical
-				} else {
-					// No succeeding group exists, so append a new group,
-					// but do so after we finish iterating over all groups.
-					defer func() {
-						groups = append(groups, diffStats{Name: groups[len(groups)-1].Name, NumIdentical: numTrailingIdentical})
-					}()
-				}
-				// Do not increment indexes since the succeeding group will handle this.
-			}
-
-			// Update this group since some identical elements were removed.
-			nx -= numIdentical
-			ny -= numIdentical
-			groups[i] = diffStats{Name: ds.Name, NumRemoved: nx, NumInserted: ny}
-		}
-		ix += nx
-		iy += ny
-	}
-	return groups
-}
diff --git a/vendor/github.com/google/go-cmp/cmp/report_text.go b/vendor/github.com/google/go-cmp/cmp/report_text.go
deleted file mode 100644
index 0fd46d7ff..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report_text.go
+++ /dev/null
@@ -1,431 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import (
-	"bytes"
-	"fmt"
-	"math/rand"
-	"strings"
-	"time"
-	"unicode/utf8"
-
-	"github.com/google/go-cmp/cmp/internal/flags"
-)
-
-var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0
-
-const maxColumnLength = 80
-
-type indentMode int
-
-func (n indentMode) appendIndent(b []byte, d diffMode) []byte {
-	// The output of Diff is documented as being unstable to provide future
-	// flexibility in changing the output for more humanly readable reports.
-	// This logic intentionally introduces instability to the exact output
-	// so that users can detect accidental reliance on stability early on,
-	// rather than much later when an actual change to the format occurs.
-	if flags.Deterministic || randBool {
-		// Use regular spaces (U+0020).
-		switch d {
-		case diffUnknown, diffIdentical:
-			b = append(b, "  "...)
-		case diffRemoved:
-			b = append(b, "- "...)
-		case diffInserted:
-			b = append(b, "+ "...)
-		}
-	} else {
-		// Use non-breaking spaces (U+00a0).
-		switch d {
-		case diffUnknown, diffIdentical:
-			b = append(b, "  "...)
-		case diffRemoved:
-			b = append(b, "- "...)
-		case diffInserted:
-			b = append(b, "+ "...)
-		}
-	}
-	return repeatCount(n).appendChar(b, '\t')
-}
-
-type repeatCount int
-
-func (n repeatCount) appendChar(b []byte, c byte) []byte {
-	for ; n > 0; n-- {
-		b = append(b, c)
-	}
-	return b
-}
-
-// textNode is a simplified tree-based representation of structured text.
-// Possible node types are textWrap, textList, or textLine.
-type textNode interface {
-	// Len reports the length in bytes of a single-line version of the tree.
-	// Nested textRecord.Diff and textRecord.Comment fields are ignored.
-	Len() int
-	// Equal reports whether the two trees are structurally identical.
-	// Nested textRecord.Diff and textRecord.Comment fields are compared.
-	Equal(textNode) bool
-	// String returns the string representation of the text tree.
-	// It is not guaranteed that len(x.String()) == x.Len(),
-	// nor that x.String() == y.String() implies that x.Equal(y).
-	String() string
-
-	// formatCompactTo formats the contents of the tree as a single-line string
-	// to the provided buffer. Any nested textRecord.Diff and textRecord.Comment
-	// fields are ignored.
-	//
-	// However, not all nodes in the tree should be collapsed as a single-line.
-	// If a node can be collapsed as a single-line, it is replaced by a textLine
-	// node. Since the top-level node cannot replace itself, this also returns
-	// the current node itself.
-	//
-	// This does not mutate the receiver.
-	formatCompactTo([]byte, diffMode) ([]byte, textNode)
-	// formatExpandedTo formats the contents of the tree as a multi-line string
-	// to the provided buffer. In order for column alignment to operate well,
-	// formatCompactTo must be called before calling formatExpandedTo.
-	formatExpandedTo([]byte, diffMode, indentMode) []byte
-}
-
-// textWrap is a wrapper that concatenates a prefix and/or a suffix
-// to the underlying node.
-type textWrap struct {
-	Prefix   string      // e.g., "bytes.Buffer{"
-	Value    textNode    // textWrap | textList | textLine
-	Suffix   string      // e.g., "}"
-	Metadata interface{} // arbitrary metadata; has no effect on formatting
-}
-
-func (s *textWrap) Len() int {
-	return len(s.Prefix) + s.Value.Len() + len(s.Suffix)
-}
-func (s1 *textWrap) Equal(s2 textNode) bool {
-	if s2, ok := s2.(*textWrap); ok {
-		return s1.Prefix == s2.Prefix && s1.Value.Equal(s2.Value) && s1.Suffix == s2.Suffix
-	}
-	return false
-}
-func (s *textWrap) String() string {
-	var d diffMode
-	var n indentMode
-	_, s2 := s.formatCompactTo(nil, d)
-	b := n.appendIndent(nil, d)      // Leading indent
-	b = s2.formatExpandedTo(b, d, n) // Main body
-	b = append(b, '\n')              // Trailing newline
-	return string(b)
-}
-func (s *textWrap) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) {
-	n0 := len(b) // Original buffer length
-	b = append(b, s.Prefix...)
-	b, s.Value = s.Value.formatCompactTo(b, d)
-	b = append(b, s.Suffix...)
-	if _, ok := s.Value.(textLine); ok {
-		return b, textLine(b[n0:])
-	}
-	return b, s
-}
-func (s *textWrap) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte {
-	b = append(b, s.Prefix...)
-	b = s.Value.formatExpandedTo(b, d, n)
-	b = append(b, s.Suffix...)
-	return b
-}
-
-// textList is a comma-separated list of textWrap or textLine nodes.
-// The list may be formatted as multi-lines or single-line at the discretion
-// of the textList.formatCompactTo method.
-type textList []textRecord
-type textRecord struct {
-	Diff       diffMode     // e.g., 0 or '-' or '+'
-	Key        string       // e.g., "MyField"
-	Value      textNode     // textWrap | textLine
-	ElideComma bool         // avoid trailing comma
-	Comment    fmt.Stringer // e.g., "6 identical fields"
-}
-
-// AppendEllipsis appends a new ellipsis node to the list if none already
-// exists at the end. If cs is non-zero it coalesces the statistics with the
-// previous diffStats.
-func (s *textList) AppendEllipsis(ds diffStats) {
-	hasStats := !ds.IsZero()
-	if len(*s) == 0 || !(*s)[len(*s)-1].Value.Equal(textEllipsis) {
-		if hasStats {
-			*s = append(*s, textRecord{Value: textEllipsis, ElideComma: true, Comment: ds})
-		} else {
-			*s = append(*s, textRecord{Value: textEllipsis, ElideComma: true})
-		}
-		return
-	}
-	if hasStats {
-		(*s)[len(*s)-1].Comment = (*s)[len(*s)-1].Comment.(diffStats).Append(ds)
-	}
-}
-
-func (s textList) Len() (n int) {
-	for i, r := range s {
-		n += len(r.Key)
-		if r.Key != "" {
-			n += len(": ")
-		}
-		n += r.Value.Len()
-		if i < len(s)-1 {
-			n += len(", ")
-		}
-	}
-	return n
-}
-
-func (s1 textList) Equal(s2 textNode) bool {
-	if s2, ok := s2.(textList); ok {
-		if len(s1) != len(s2) {
-			return false
-		}
-		for i := range s1 {
-			r1, r2 := s1[i], s2[i]
-			if !(r1.Diff == r2.Diff && r1.Key == r2.Key && r1.Value.Equal(r2.Value) && r1.Comment == r2.Comment) {
-				return false
-			}
-		}
-		return true
-	}
-	return false
-}
-
-func (s textList) String() string {
-	return (&textWrap{Prefix: "{", Value: s, Suffix: "}"}).String()
-}
-
-func (s textList) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) {
-	s = append(textList(nil), s...) // Avoid mutating original
-
-	// Determine whether we can collapse this list as a single line.
-	n0 := len(b) // Original buffer length
-	var multiLine bool
-	for i, r := range s {
-		if r.Diff == diffInserted || r.Diff == diffRemoved {
-			multiLine = true
-		}
-		b = append(b, r.Key...)
-		if r.Key != "" {
-			b = append(b, ": "...)
-		}
-		b, s[i].Value = r.Value.formatCompactTo(b, d|r.Diff)
-		if _, ok := s[i].Value.(textLine); !ok {
-			multiLine = true
-		}
-		if r.Comment != nil {
-			multiLine = true
-		}
-		if i < len(s)-1 {
-			b = append(b, ", "...)
-		}
-	}
-	// Force multi-lined output when printing a removed/inserted node that
-	// is sufficiently long.
-	if (d == diffInserted || d == diffRemoved) && len(b[n0:]) > maxColumnLength {
-		multiLine = true
-	}
-	if !multiLine {
-		return b, textLine(b[n0:])
-	}
-	return b, s
-}
-
-func (s textList) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte {
-	alignKeyLens := s.alignLens(
-		func(r textRecord) bool {
-			_, isLine := r.Value.(textLine)
-			return r.Key == "" || !isLine
-		},
-		func(r textRecord) int { return utf8.RuneCountInString(r.Key) },
-	)
-	alignValueLens := s.alignLens(
-		func(r textRecord) bool {
-			_, isLine := r.Value.(textLine)
-			return !isLine || r.Value.Equal(textEllipsis) || r.Comment == nil
-		},
-		func(r textRecord) int { return utf8.RuneCount(r.Value.(textLine)) },
-	)
-
-	// Format lists of simple lists in a batched form.
-	// If the list is sequence of only textLine values,
-	// then batch multiple values on a single line.
-	var isSimple bool
-	for _, r := range s {
-		_, isLine := r.Value.(textLine)
-		isSimple = r.Diff == 0 && r.Key == "" && isLine && r.Comment == nil
-		if !isSimple {
-			break
-		}
-	}
-	if isSimple {
-		n++
-		var batch []byte
-		emitBatch := func() {
-			if len(batch) > 0 {
-				b = n.appendIndent(append(b, '\n'), d)
-				b = append(b, bytes.TrimRight(batch, " ")...)
-				batch = batch[:0]
-			}
-		}
-		for _, r := range s {
-			line := r.Value.(textLine)
-			if len(batch)+len(line)+len(", ") > maxColumnLength {
-				emitBatch()
-			}
-			batch = append(batch, line...)
-			batch = append(batch, ", "...)
-		}
-		emitBatch()
-		n--
-		return n.appendIndent(append(b, '\n'), d)
-	}
-
-	// Format the list as a multi-lined output.
-	n++
-	for i, r := range s {
-		b = n.appendIndent(append(b, '\n'), d|r.Diff)
-		if r.Key != "" {
-			b = append(b, r.Key+": "...)
-		}
-		b = alignKeyLens[i].appendChar(b, ' ')
-
-		b = r.Value.formatExpandedTo(b, d|r.Diff, n)
-		if !r.ElideComma {
-			b = append(b, ',')
-		}
-		b = alignValueLens[i].appendChar(b, ' ')
-
-		if r.Comment != nil {
-			b = append(b, " // "+r.Comment.String()...)
-		}
-	}
-	n--
-
-	return n.appendIndent(append(b, '\n'), d)
-}
-
-func (s textList) alignLens(
-	skipFunc func(textRecord) bool,
-	lenFunc func(textRecord) int,
-) []repeatCount {
-	var startIdx, endIdx, maxLen int
-	lens := make([]repeatCount, len(s))
-	for i, r := range s {
-		if skipFunc(r) {
-			for j := startIdx; j < endIdx && j < len(s); j++ {
-				lens[j] = repeatCount(maxLen - lenFunc(s[j]))
-			}
-			startIdx, endIdx, maxLen = i+1, i+1, 0
-		} else {
-			if maxLen < lenFunc(r) {
-				maxLen = lenFunc(r)
-			}
-			endIdx = i + 1
-		}
-	}
-	for j := startIdx; j < endIdx && j < len(s); j++ {
-		lens[j] = repeatCount(maxLen - lenFunc(s[j]))
-	}
-	return lens
-}
-
-// textLine is a single-line segment of text and is always a leaf node
-// in the textNode tree.
-type textLine []byte
-
-var (
-	textNil      = textLine("nil")
-	textEllipsis = textLine("...")
-)
-
-func (s textLine) Len() int {
-	return len(s)
-}
-func (s1 textLine) Equal(s2 textNode) bool {
-	if s2, ok := s2.(textLine); ok {
-		return bytes.Equal([]byte(s1), []byte(s2))
-	}
-	return false
-}
-func (s textLine) String() string {
-	return string(s)
-}
-func (s textLine) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) {
-	return append(b, s...), s
-}
-func (s textLine) formatExpandedTo(b []byte, _ diffMode, _ indentMode) []byte {
-	return append(b, s...)
-}
-
-type diffStats struct {
-	Name         string
-	NumIgnored   int
-	NumIdentical int
-	NumRemoved   int
-	NumInserted  int
-	NumModified  int
-}
-
-func (s diffStats) IsZero() bool {
-	s.Name = ""
-	return s == diffStats{}
-}
-
-func (s diffStats) NumDiff() int {
-	return s.NumRemoved + s.NumInserted + s.NumModified
-}
-
-func (s diffStats) Append(ds diffStats) diffStats {
-	assert(s.Name == ds.Name)
-	s.NumIgnored += ds.NumIgnored
-	s.NumIdentical += ds.NumIdentical
-	s.NumRemoved += ds.NumRemoved
-	s.NumInserted += ds.NumInserted
-	s.NumModified += ds.NumModified
-	return s
-}
-
-// String prints a humanly-readable summary of coalesced records.
-//
-// Example:
-//	diffStats{Name: "Field", NumIgnored: 5}.String() => "5 ignored fields"
-func (s diffStats) String() string {
-	var ss []string
-	var sum int
-	labels := [...]string{"ignored", "identical", "removed", "inserted", "modified"}
-	counts := [...]int{s.NumIgnored, s.NumIdentical, s.NumRemoved, s.NumInserted, s.NumModified}
-	for i, n := range counts {
-		if n > 0 {
-			ss = append(ss, fmt.Sprintf("%d %v", n, labels[i]))
-		}
-		sum += n
-	}
-
-	// Pluralize the name (adjusting for some obscure English grammar rules).
-	name := s.Name
-	if sum > 1 {
-		name += "s"
-		if strings.HasSuffix(name, "ys") {
-			name = name[:len(name)-2] + "ies" // e.g., "entrys" => "entries"
-		}
-	}
-
-	// Format the list according to English grammar (with Oxford comma).
-	switch n := len(ss); n {
-	case 0:
-		return ""
-	case 1, 2:
-		return strings.Join(ss, " and ") + " " + name
-	default:
-		return strings.Join(ss[:n-1], ", ") + ", and " + ss[n-1] + " " + name
-	}
-}
-
-type commentString string
-
-func (s commentString) String() string { return string(s) }
diff --git a/vendor/github.com/google/go-cmp/cmp/report_value.go b/vendor/github.com/google/go-cmp/cmp/report_value.go
deleted file mode 100644
index 668d470fd..000000000
--- a/vendor/github.com/google/go-cmp/cmp/report_value.go
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package cmp
-
-import "reflect"
-
-// valueNode represents a single node within a report, which is a
-// structured representation of the value tree, containing information
-// regarding which nodes are equal or not.
-type valueNode struct {
-	parent *valueNode
-
-	Type   reflect.Type
-	ValueX reflect.Value
-	ValueY reflect.Value
-
-	// NumSame is the number of leaf nodes that are equal.
-	// All descendants are equal only if NumDiff is 0.
-	NumSame int
-	// NumDiff is the number of leaf nodes that are not equal.
-	NumDiff int
-	// NumIgnored is the number of leaf nodes that are ignored.
-	NumIgnored int
-	// NumCompared is the number of leaf nodes that were compared
-	// using an Equal method or Comparer function.
-	NumCompared int
-	// NumTransformed is the number of non-leaf nodes that were transformed.
-	NumTransformed int
-	// NumChildren is the number of transitive descendants of this node.
-	// This counts from zero; thus, leaf nodes have no descendants.
-	NumChildren int
-	// MaxDepth is the maximum depth of the tree. This counts from zero;
-	// thus, leaf nodes have a depth of zero.
-	MaxDepth int
-
-	// Records is a list of struct fields, slice elements, or map entries.
-	Records []reportRecord // If populated, implies Value is not populated
-
-	// Value is the result of a transformation, pointer indirect, of
-	// type assertion.
-	Value *valueNode // If populated, implies Records is not populated
-
-	// TransformerName is the name of the transformer.
-	TransformerName string // If non-empty, implies Value is populated
-}
-type reportRecord struct {
-	Key   reflect.Value // Invalid for slice element
-	Value *valueNode
-}
-
-func (parent *valueNode) PushStep(ps PathStep) (child *valueNode) {
-	vx, vy := ps.Values()
-	child = &valueNode{parent: parent, Type: ps.Type(), ValueX: vx, ValueY: vy}
-	switch s := ps.(type) {
-	case StructField:
-		assert(parent.Value == nil)
-		parent.Records = append(parent.Records, reportRecord{Key: reflect.ValueOf(s.Name()), Value: child})
-	case SliceIndex:
-		assert(parent.Value == nil)
-		parent.Records = append(parent.Records, reportRecord{Value: child})
-	case MapIndex:
-		assert(parent.Value == nil)
-		parent.Records = append(parent.Records, reportRecord{Key: s.Key(), Value: child})
-	case Indirect:
-		assert(parent.Value == nil && parent.Records == nil)
-		parent.Value = child
-	case TypeAssertion:
-		assert(parent.Value == nil && parent.Records == nil)
-		parent.Value = child
-	case Transform:
-		assert(parent.Value == nil && parent.Records == nil)
-		parent.Value = child
-		parent.TransformerName = s.Name()
-		parent.NumTransformed++
-	default:
-		assert(parent == nil) // Must be the root step
-	}
-	return child
-}
-
-func (r *valueNode) Report(rs Result) {
-	assert(r.MaxDepth == 0) // May only be called on leaf nodes
-
-	if rs.ByIgnore() {
-		r.NumIgnored++
-	} else {
-		if rs.Equal() {
-			r.NumSame++
-		} else {
-			r.NumDiff++
-		}
-	}
-	assert(r.NumSame+r.NumDiff+r.NumIgnored == 1)
-
-	if rs.ByMethod() {
-		r.NumCompared++
-	}
-	if rs.ByFunc() {
-		r.NumCompared++
-	}
-	assert(r.NumCompared <= 1)
-}
-
-func (child *valueNode) PopStep() (parent *valueNode) {
-	if child.parent == nil {
-		return nil
-	}
-	parent = child.parent
-	parent.NumSame += child.NumSame
-	parent.NumDiff += child.NumDiff
-	parent.NumIgnored += child.NumIgnored
-	parent.NumCompared += child.NumCompared
-	parent.NumTransformed += child.NumTransformed
-	parent.NumChildren += child.NumChildren + 1
-	if parent.MaxDepth < child.MaxDepth+1 {
-		parent.MaxDepth = child.MaxDepth + 1
-	}
-	return parent
-}
diff --git a/vendor/github.com/gorilla/websocket/.gitignore b/vendor/github.com/gorilla/websocket/.gitignore
deleted file mode 100644
index cd3fcd1ef..000000000
--- a/vendor/github.com/gorilla/websocket/.gitignore
+++ /dev/null
@@ -1,25 +0,0 @@
-# Compiled Object files, Static and Dynamic libs (Shared Objects)
-*.o
-*.a
-*.so
-
-# Folders
-_obj
-_test
-
-# Architecture specific extensions/prefixes
-*.[568vq]
-[568vq].out
-
-*.cgo1.go
-*.cgo2.c
-_cgo_defun.c
-_cgo_gotypes.go
-_cgo_export.*
-
-_testmain.go
-
-*.exe
-
-.idea/
-*.iml
diff --git a/vendor/github.com/gorilla/websocket/AUTHORS b/vendor/github.com/gorilla/websocket/AUTHORS
deleted file mode 100644
index 1931f4006..000000000
--- a/vendor/github.com/gorilla/websocket/AUTHORS
+++ /dev/null
@@ -1,9 +0,0 @@
-# This is the official list of Gorilla WebSocket authors for copyright
-# purposes.
-#
-# Please keep the list sorted.
-
-Gary Burd 
-Google LLC (https://opensource.google.com/)
-Joachim Bauch 
-
diff --git a/vendor/github.com/gorilla/websocket/LICENSE b/vendor/github.com/gorilla/websocket/LICENSE
deleted file mode 100644
index 9171c9722..000000000
--- a/vendor/github.com/gorilla/websocket/LICENSE
+++ /dev/null
@@ -1,22 +0,0 @@
-Copyright (c) 2013 The Gorilla WebSocket Authors. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-  Redistributions of source code must retain the above copyright notice, this
-  list of conditions and the following disclaimer.
-
-  Redistributions in binary form must reproduce the above copyright notice,
-  this list of conditions and the following disclaimer in the documentation
-  and/or other materials provided with the distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
-ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/vendor/github.com/gorilla/websocket/README.md b/vendor/github.com/gorilla/websocket/README.md
deleted file mode 100644
index 19aa2e75c..000000000
--- a/vendor/github.com/gorilla/websocket/README.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Gorilla WebSocket
-
-[![GoDoc](https://godoc.org/github.com/gorilla/websocket?status.svg)](https://godoc.org/github.com/gorilla/websocket)
-[![CircleCI](https://circleci.com/gh/gorilla/websocket.svg?style=svg)](https://circleci.com/gh/gorilla/websocket)
-
-Gorilla WebSocket is a [Go](http://golang.org/) implementation of the
-[WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol.
-
-### Documentation
-
-* [API Reference](https://pkg.go.dev/github.com/gorilla/websocket?tab=doc)
-* [Chat example](https://github.com/gorilla/websocket/tree/master/examples/chat)
-* [Command example](https://github.com/gorilla/websocket/tree/master/examples/command)
-* [Client and server example](https://github.com/gorilla/websocket/tree/master/examples/echo)
-* [File watch example](https://github.com/gorilla/websocket/tree/master/examples/filewatch)
-
-### Status
-
-The Gorilla WebSocket package provides a complete and tested implementation of
-the [WebSocket](http://www.rfc-editor.org/rfc/rfc6455.txt) protocol. The
-package API is stable.
-
-### Installation
-
-    go get github.com/gorilla/websocket
-
-### Protocol Compliance
-
-The Gorilla WebSocket package passes the server tests in the [Autobahn Test
-Suite](https://github.com/crossbario/autobahn-testsuite) using the application in the [examples/autobahn
-subdirectory](https://github.com/gorilla/websocket/tree/master/examples/autobahn).
-
-### Gorilla WebSocket compared with other packages
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
github.com/gorillagolang.org/x/net
RFC 6455 Features
Passes Autobahn Test SuiteYesNo
Receive fragmented messageYesNo, see note 1
Send close messageYesNo
Send pings and receive pongsYesNo
Get the type of a received data messageYesYes, see note 2
Other Features
Compression ExtensionsExperimentalNo
Read message using io.ReaderYesNo, see note 3
Write message using io.WriteCloserYesNo, see note 3
- -Notes: - -1. Large messages are fragmented in [Chrome's new WebSocket implementation](http://www.ietf.org/mail-archive/web/hybi/current/msg10503.html). -2. The application can get the type of a received data message by implementing - a [Codec marshal](http://godoc.org/golang.org/x/net/websocket#Codec.Marshal) - function. -3. The go.net io.Reader and io.Writer operate across WebSocket frame boundaries. - Read returns when the input buffer is full or a frame boundary is - encountered. Each call to Write sends a single frame message. The Gorilla - io.Reader and io.WriteCloser operate on a single WebSocket message. - diff --git a/vendor/github.com/gorilla/websocket/client.go b/vendor/github.com/gorilla/websocket/client.go deleted file mode 100644 index 962c06a39..000000000 --- a/vendor/github.com/gorilla/websocket/client.go +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bytes" - "context" - "crypto/tls" - "errors" - "io" - "io/ioutil" - "net" - "net/http" - "net/http/httptrace" - "net/url" - "strings" - "time" -) - -// ErrBadHandshake is returned when the server response to opening handshake is -// invalid. -var ErrBadHandshake = errors.New("websocket: bad handshake") - -var errInvalidCompression = errors.New("websocket: invalid compression negotiation") - -// NewClient creates a new client connection using the given net connection. -// The URL u specifies the host and request URI. Use requestHeader to specify -// the origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies -// (Cookie). Use the response.Header to get the selected subprotocol -// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). -// -// If the WebSocket handshake fails, ErrBadHandshake is returned along with a -// non-nil *http.Response so that callers can handle redirects, authentication, -// etc. -// -// Deprecated: Use Dialer instead. -func NewClient(netConn net.Conn, u *url.URL, requestHeader http.Header, readBufSize, writeBufSize int) (c *Conn, response *http.Response, err error) { - d := Dialer{ - ReadBufferSize: readBufSize, - WriteBufferSize: writeBufSize, - NetDial: func(net, addr string) (net.Conn, error) { - return netConn, nil - }, - } - return d.Dial(u.String(), requestHeader) -} - -// A Dialer contains options for connecting to WebSocket server. -type Dialer struct { - // NetDial specifies the dial function for creating TCP connections. If - // NetDial is nil, net.Dial is used. - NetDial func(network, addr string) (net.Conn, error) - - // NetDialContext specifies the dial function for creating TCP connections. If - // NetDialContext is nil, net.DialContext is used. - NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error) - - // Proxy specifies a function to return a proxy for a given - // Request. If the function returns a non-nil error, the - // request is aborted with the provided error. - // If Proxy is nil or returns a nil *URL, no proxy is used. - Proxy func(*http.Request) (*url.URL, error) - - // TLSClientConfig specifies the TLS configuration to use with tls.Client. - // If nil, the default configuration is used. - TLSClientConfig *tls.Config - - // HandshakeTimeout specifies the duration for the handshake to complete. - HandshakeTimeout time.Duration - - // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer - // size is zero, then a useful default size is used. The I/O buffer sizes - // do not limit the size of the messages that can be sent or received. - ReadBufferSize, WriteBufferSize int - - // WriteBufferPool is a pool of buffers for write operations. If the value - // is not set, then write buffers are allocated to the connection for the - // lifetime of the connection. - // - // A pool is most useful when the application has a modest volume of writes - // across a large number of connections. - // - // Applications should use a single pool for each unique value of - // WriteBufferSize. - WriteBufferPool BufferPool - - // Subprotocols specifies the client's requested subprotocols. - Subprotocols []string - - // EnableCompression specifies if the client should attempt to negotiate - // per message compression (RFC 7692). Setting this value to true does not - // guarantee that compression will be supported. Currently only "no context - // takeover" modes are supported. - EnableCompression bool - - // Jar specifies the cookie jar. - // If Jar is nil, cookies are not sent in requests and ignored - // in responses. - Jar http.CookieJar -} - -// Dial creates a new client connection by calling DialContext with a background context. -func (d *Dialer) Dial(urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { - return d.DialContext(context.Background(), urlStr, requestHeader) -} - -var errMalformedURL = errors.New("malformed ws or wss URL") - -func hostPortNoPort(u *url.URL) (hostPort, hostNoPort string) { - hostPort = u.Host - hostNoPort = u.Host - if i := strings.LastIndex(u.Host, ":"); i > strings.LastIndex(u.Host, "]") { - hostNoPort = hostNoPort[:i] - } else { - switch u.Scheme { - case "wss": - hostPort += ":443" - case "https": - hostPort += ":443" - default: - hostPort += ":80" - } - } - return hostPort, hostNoPort -} - -// DefaultDialer is a dialer with all fields set to the default values. -var DefaultDialer = &Dialer{ - Proxy: http.ProxyFromEnvironment, - HandshakeTimeout: 45 * time.Second, -} - -// nilDialer is dialer to use when receiver is nil. -var nilDialer = *DefaultDialer - -// DialContext creates a new client connection. Use requestHeader to specify the -// origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies (Cookie). -// Use the response.Header to get the selected subprotocol -// (Sec-WebSocket-Protocol) and cookies (Set-Cookie). -// -// The context will be used in the request and in the Dialer. -// -// If the WebSocket handshake fails, ErrBadHandshake is returned along with a -// non-nil *http.Response so that callers can handle redirects, authentication, -// etcetera. The response body may not contain the entire response and does not -// need to be closed by the application. -func (d *Dialer) DialContext(ctx context.Context, urlStr string, requestHeader http.Header) (*Conn, *http.Response, error) { - if d == nil { - d = &nilDialer - } - - challengeKey, err := generateChallengeKey() - if err != nil { - return nil, nil, err - } - - u, err := url.Parse(urlStr) - if err != nil { - return nil, nil, err - } - - switch u.Scheme { - case "ws": - u.Scheme = "http" - case "wss": - u.Scheme = "https" - default: - return nil, nil, errMalformedURL - } - - if u.User != nil { - // User name and password are not allowed in websocket URIs. - return nil, nil, errMalformedURL - } - - req := &http.Request{ - Method: "GET", - URL: u, - Proto: "HTTP/1.1", - ProtoMajor: 1, - ProtoMinor: 1, - Header: make(http.Header), - Host: u.Host, - } - req = req.WithContext(ctx) - - // Set the cookies present in the cookie jar of the dialer - if d.Jar != nil { - for _, cookie := range d.Jar.Cookies(u) { - req.AddCookie(cookie) - } - } - - // Set the request headers using the capitalization for names and values in - // RFC examples. Although the capitalization shouldn't matter, there are - // servers that depend on it. The Header.Set method is not used because the - // method canonicalizes the header names. - req.Header["Upgrade"] = []string{"websocket"} - req.Header["Connection"] = []string{"Upgrade"} - req.Header["Sec-WebSocket-Key"] = []string{challengeKey} - req.Header["Sec-WebSocket-Version"] = []string{"13"} - if len(d.Subprotocols) > 0 { - req.Header["Sec-WebSocket-Protocol"] = []string{strings.Join(d.Subprotocols, ", ")} - } - for k, vs := range requestHeader { - switch { - case k == "Host": - if len(vs) > 0 { - req.Host = vs[0] - } - case k == "Upgrade" || - k == "Connection" || - k == "Sec-Websocket-Key" || - k == "Sec-Websocket-Version" || - k == "Sec-Websocket-Extensions" || - (k == "Sec-Websocket-Protocol" && len(d.Subprotocols) > 0): - return nil, nil, errors.New("websocket: duplicate header not allowed: " + k) - case k == "Sec-Websocket-Protocol": - req.Header["Sec-WebSocket-Protocol"] = vs - default: - req.Header[k] = vs - } - } - - if d.EnableCompression { - req.Header["Sec-WebSocket-Extensions"] = []string{"permessage-deflate; server_no_context_takeover; client_no_context_takeover"} - } - - if d.HandshakeTimeout != 0 { - var cancel func() - ctx, cancel = context.WithTimeout(ctx, d.HandshakeTimeout) - defer cancel() - } - - // Get network dial function. - var netDial func(network, add string) (net.Conn, error) - - if d.NetDialContext != nil { - netDial = func(network, addr string) (net.Conn, error) { - return d.NetDialContext(ctx, network, addr) - } - } else if d.NetDial != nil { - netDial = d.NetDial - } else { - netDialer := &net.Dialer{} - netDial = func(network, addr string) (net.Conn, error) { - return netDialer.DialContext(ctx, network, addr) - } - } - - // If needed, wrap the dial function to set the connection deadline. - if deadline, ok := ctx.Deadline(); ok { - forwardDial := netDial - netDial = func(network, addr string) (net.Conn, error) { - c, err := forwardDial(network, addr) - if err != nil { - return nil, err - } - err = c.SetDeadline(deadline) - if err != nil { - c.Close() - return nil, err - } - return c, nil - } - } - - // If needed, wrap the dial function to connect through a proxy. - if d.Proxy != nil { - proxyURL, err := d.Proxy(req) - if err != nil { - return nil, nil, err - } - if proxyURL != nil { - dialer, err := proxy_FromURL(proxyURL, netDialerFunc(netDial)) - if err != nil { - return nil, nil, err - } - netDial = dialer.Dial - } - } - - hostPort, hostNoPort := hostPortNoPort(u) - trace := httptrace.ContextClientTrace(ctx) - if trace != nil && trace.GetConn != nil { - trace.GetConn(hostPort) - } - - netConn, err := netDial("tcp", hostPort) - if trace != nil && trace.GotConn != nil { - trace.GotConn(httptrace.GotConnInfo{ - Conn: netConn, - }) - } - if err != nil { - return nil, nil, err - } - - defer func() { - if netConn != nil { - netConn.Close() - } - }() - - if u.Scheme == "https" { - cfg := cloneTLSConfig(d.TLSClientConfig) - if cfg.ServerName == "" { - cfg.ServerName = hostNoPort - } - tlsConn := tls.Client(netConn, cfg) - netConn = tlsConn - - var err error - if trace != nil { - err = doHandshakeWithTrace(trace, tlsConn, cfg) - } else { - err = doHandshake(tlsConn, cfg) - } - - if err != nil { - return nil, nil, err - } - } - - conn := newConn(netConn, false, d.ReadBufferSize, d.WriteBufferSize, d.WriteBufferPool, nil, nil) - - if err := req.Write(netConn); err != nil { - return nil, nil, err - } - - if trace != nil && trace.GotFirstResponseByte != nil { - if peek, err := conn.br.Peek(1); err == nil && len(peek) == 1 { - trace.GotFirstResponseByte() - } - } - - resp, err := http.ReadResponse(conn.br, req) - if err != nil { - return nil, nil, err - } - - if d.Jar != nil { - if rc := resp.Cookies(); len(rc) > 0 { - d.Jar.SetCookies(u, rc) - } - } - - if resp.StatusCode != 101 || - !strings.EqualFold(resp.Header.Get("Upgrade"), "websocket") || - !strings.EqualFold(resp.Header.Get("Connection"), "upgrade") || - resp.Header.Get("Sec-Websocket-Accept") != computeAcceptKey(challengeKey) { - // Before closing the network connection on return from this - // function, slurp up some of the response to aid application - // debugging. - buf := make([]byte, 1024) - n, _ := io.ReadFull(resp.Body, buf) - resp.Body = ioutil.NopCloser(bytes.NewReader(buf[:n])) - return nil, resp, ErrBadHandshake - } - - for _, ext := range parseExtensions(resp.Header) { - if ext[""] != "permessage-deflate" { - continue - } - _, snct := ext["server_no_context_takeover"] - _, cnct := ext["client_no_context_takeover"] - if !snct || !cnct { - return nil, resp, errInvalidCompression - } - conn.newCompressionWriter = compressNoContextTakeover - conn.newDecompressionReader = decompressNoContextTakeover - break - } - - resp.Body = ioutil.NopCloser(bytes.NewReader([]byte{})) - conn.subprotocol = resp.Header.Get("Sec-Websocket-Protocol") - - netConn.SetDeadline(time.Time{}) - netConn = nil // to avoid close in defer. - return conn, resp, nil -} - -func doHandshake(tlsConn *tls.Conn, cfg *tls.Config) error { - if err := tlsConn.Handshake(); err != nil { - return err - } - if !cfg.InsecureSkipVerify { - if err := tlsConn.VerifyHostname(cfg.ServerName); err != nil { - return err - } - } - return nil -} diff --git a/vendor/github.com/gorilla/websocket/client_clone.go b/vendor/github.com/gorilla/websocket/client_clone.go deleted file mode 100644 index 4f0d94372..000000000 --- a/vendor/github.com/gorilla/websocket/client_clone.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build go1.8 - -package websocket - -import "crypto/tls" - -func cloneTLSConfig(cfg *tls.Config) *tls.Config { - if cfg == nil { - return &tls.Config{} - } - return cfg.Clone() -} diff --git a/vendor/github.com/gorilla/websocket/client_clone_legacy.go b/vendor/github.com/gorilla/websocket/client_clone_legacy.go deleted file mode 100644 index babb007fb..000000000 --- a/vendor/github.com/gorilla/websocket/client_clone_legacy.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build !go1.8 - -package websocket - -import "crypto/tls" - -// cloneTLSConfig clones all public fields except the fields -// SessionTicketsDisabled and SessionTicketKey. This avoids copying the -// sync.Mutex in the sync.Once and makes it safe to call cloneTLSConfig on a -// config in active use. -func cloneTLSConfig(cfg *tls.Config) *tls.Config { - if cfg == nil { - return &tls.Config{} - } - return &tls.Config{ - Rand: cfg.Rand, - Time: cfg.Time, - Certificates: cfg.Certificates, - NameToCertificate: cfg.NameToCertificate, - GetCertificate: cfg.GetCertificate, - RootCAs: cfg.RootCAs, - NextProtos: cfg.NextProtos, - ServerName: cfg.ServerName, - ClientAuth: cfg.ClientAuth, - ClientCAs: cfg.ClientCAs, - InsecureSkipVerify: cfg.InsecureSkipVerify, - CipherSuites: cfg.CipherSuites, - PreferServerCipherSuites: cfg.PreferServerCipherSuites, - ClientSessionCache: cfg.ClientSessionCache, - MinVersion: cfg.MinVersion, - MaxVersion: cfg.MaxVersion, - CurvePreferences: cfg.CurvePreferences, - } -} diff --git a/vendor/github.com/gorilla/websocket/compression.go b/vendor/github.com/gorilla/websocket/compression.go deleted file mode 100644 index 813ffb1e8..000000000 --- a/vendor/github.com/gorilla/websocket/compression.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "compress/flate" - "errors" - "io" - "strings" - "sync" -) - -const ( - minCompressionLevel = -2 // flate.HuffmanOnly not defined in Go < 1.6 - maxCompressionLevel = flate.BestCompression - defaultCompressionLevel = 1 -) - -var ( - flateWriterPools [maxCompressionLevel - minCompressionLevel + 1]sync.Pool - flateReaderPool = sync.Pool{New: func() interface{} { - return flate.NewReader(nil) - }} -) - -func decompressNoContextTakeover(r io.Reader) io.ReadCloser { - const tail = - // Add four bytes as specified in RFC - "\x00\x00\xff\xff" + - // Add final block to squelch unexpected EOF error from flate reader. - "\x01\x00\x00\xff\xff" - - fr, _ := flateReaderPool.Get().(io.ReadCloser) - fr.(flate.Resetter).Reset(io.MultiReader(r, strings.NewReader(tail)), nil) - return &flateReadWrapper{fr} -} - -func isValidCompressionLevel(level int) bool { - return minCompressionLevel <= level && level <= maxCompressionLevel -} - -func compressNoContextTakeover(w io.WriteCloser, level int) io.WriteCloser { - p := &flateWriterPools[level-minCompressionLevel] - tw := &truncWriter{w: w} - fw, _ := p.Get().(*flate.Writer) - if fw == nil { - fw, _ = flate.NewWriter(tw, level) - } else { - fw.Reset(tw) - } - return &flateWriteWrapper{fw: fw, tw: tw, p: p} -} - -// truncWriter is an io.Writer that writes all but the last four bytes of the -// stream to another io.Writer. -type truncWriter struct { - w io.WriteCloser - n int - p [4]byte -} - -func (w *truncWriter) Write(p []byte) (int, error) { - n := 0 - - // fill buffer first for simplicity. - if w.n < len(w.p) { - n = copy(w.p[w.n:], p) - p = p[n:] - w.n += n - if len(p) == 0 { - return n, nil - } - } - - m := len(p) - if m > len(w.p) { - m = len(w.p) - } - - if nn, err := w.w.Write(w.p[:m]); err != nil { - return n + nn, err - } - - copy(w.p[:], w.p[m:]) - copy(w.p[len(w.p)-m:], p[len(p)-m:]) - nn, err := w.w.Write(p[:len(p)-m]) - return n + nn, err -} - -type flateWriteWrapper struct { - fw *flate.Writer - tw *truncWriter - p *sync.Pool -} - -func (w *flateWriteWrapper) Write(p []byte) (int, error) { - if w.fw == nil { - return 0, errWriteClosed - } - return w.fw.Write(p) -} - -func (w *flateWriteWrapper) Close() error { - if w.fw == nil { - return errWriteClosed - } - err1 := w.fw.Flush() - w.p.Put(w.fw) - w.fw = nil - if w.tw.p != [4]byte{0, 0, 0xff, 0xff} { - return errors.New("websocket: internal error, unexpected bytes at end of flate stream") - } - err2 := w.tw.w.Close() - if err1 != nil { - return err1 - } - return err2 -} - -type flateReadWrapper struct { - fr io.ReadCloser -} - -func (r *flateReadWrapper) Read(p []byte) (int, error) { - if r.fr == nil { - return 0, io.ErrClosedPipe - } - n, err := r.fr.Read(p) - if err == io.EOF { - // Preemptively place the reader back in the pool. This helps with - // scenarios where the application does not call NextReader() soon after - // this final read. - r.Close() - } - return n, err -} - -func (r *flateReadWrapper) Close() error { - if r.fr == nil { - return io.ErrClosedPipe - } - err := r.fr.Close() - flateReaderPool.Put(r.fr) - r.fr = nil - return err -} diff --git a/vendor/github.com/gorilla/websocket/conn.go b/vendor/github.com/gorilla/websocket/conn.go deleted file mode 100644 index ca46d2f79..000000000 --- a/vendor/github.com/gorilla/websocket/conn.go +++ /dev/null @@ -1,1201 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bufio" - "encoding/binary" - "errors" - "io" - "io/ioutil" - "math/rand" - "net" - "strconv" - "sync" - "time" - "unicode/utf8" -) - -const ( - // Frame header byte 0 bits from Section 5.2 of RFC 6455 - finalBit = 1 << 7 - rsv1Bit = 1 << 6 - rsv2Bit = 1 << 5 - rsv3Bit = 1 << 4 - - // Frame header byte 1 bits from Section 5.2 of RFC 6455 - maskBit = 1 << 7 - - maxFrameHeaderSize = 2 + 8 + 4 // Fixed header + length + mask - maxControlFramePayloadSize = 125 - - writeWait = time.Second - - defaultReadBufferSize = 4096 - defaultWriteBufferSize = 4096 - - continuationFrame = 0 - noFrame = -1 -) - -// Close codes defined in RFC 6455, section 11.7. -const ( - CloseNormalClosure = 1000 - CloseGoingAway = 1001 - CloseProtocolError = 1002 - CloseUnsupportedData = 1003 - CloseNoStatusReceived = 1005 - CloseAbnormalClosure = 1006 - CloseInvalidFramePayloadData = 1007 - ClosePolicyViolation = 1008 - CloseMessageTooBig = 1009 - CloseMandatoryExtension = 1010 - CloseInternalServerErr = 1011 - CloseServiceRestart = 1012 - CloseTryAgainLater = 1013 - CloseTLSHandshake = 1015 -) - -// The message types are defined in RFC 6455, section 11.8. -const ( - // TextMessage denotes a text data message. The text message payload is - // interpreted as UTF-8 encoded text data. - TextMessage = 1 - - // BinaryMessage denotes a binary data message. - BinaryMessage = 2 - - // CloseMessage denotes a close control message. The optional message - // payload contains a numeric code and text. Use the FormatCloseMessage - // function to format a close message payload. - CloseMessage = 8 - - // PingMessage denotes a ping control message. The optional message payload - // is UTF-8 encoded text. - PingMessage = 9 - - // PongMessage denotes a pong control message. The optional message payload - // is UTF-8 encoded text. - PongMessage = 10 -) - -// ErrCloseSent is returned when the application writes a message to the -// connection after sending a close message. -var ErrCloseSent = errors.New("websocket: close sent") - -// ErrReadLimit is returned when reading a message that is larger than the -// read limit set for the connection. -var ErrReadLimit = errors.New("websocket: read limit exceeded") - -// netError satisfies the net Error interface. -type netError struct { - msg string - temporary bool - timeout bool -} - -func (e *netError) Error() string { return e.msg } -func (e *netError) Temporary() bool { return e.temporary } -func (e *netError) Timeout() bool { return e.timeout } - -// CloseError represents a close message. -type CloseError struct { - // Code is defined in RFC 6455, section 11.7. - Code int - - // Text is the optional text payload. - Text string -} - -func (e *CloseError) Error() string { - s := []byte("websocket: close ") - s = strconv.AppendInt(s, int64(e.Code), 10) - switch e.Code { - case CloseNormalClosure: - s = append(s, " (normal)"...) - case CloseGoingAway: - s = append(s, " (going away)"...) - case CloseProtocolError: - s = append(s, " (protocol error)"...) - case CloseUnsupportedData: - s = append(s, " (unsupported data)"...) - case CloseNoStatusReceived: - s = append(s, " (no status)"...) - case CloseAbnormalClosure: - s = append(s, " (abnormal closure)"...) - case CloseInvalidFramePayloadData: - s = append(s, " (invalid payload data)"...) - case ClosePolicyViolation: - s = append(s, " (policy violation)"...) - case CloseMessageTooBig: - s = append(s, " (message too big)"...) - case CloseMandatoryExtension: - s = append(s, " (mandatory extension missing)"...) - case CloseInternalServerErr: - s = append(s, " (internal server error)"...) - case CloseTLSHandshake: - s = append(s, " (TLS handshake error)"...) - } - if e.Text != "" { - s = append(s, ": "...) - s = append(s, e.Text...) - } - return string(s) -} - -// IsCloseError returns boolean indicating whether the error is a *CloseError -// with one of the specified codes. -func IsCloseError(err error, codes ...int) bool { - if e, ok := err.(*CloseError); ok { - for _, code := range codes { - if e.Code == code { - return true - } - } - } - return false -} - -// IsUnexpectedCloseError returns boolean indicating whether the error is a -// *CloseError with a code not in the list of expected codes. -func IsUnexpectedCloseError(err error, expectedCodes ...int) bool { - if e, ok := err.(*CloseError); ok { - for _, code := range expectedCodes { - if e.Code == code { - return false - } - } - return true - } - return false -} - -var ( - errWriteTimeout = &netError{msg: "websocket: write timeout", timeout: true, temporary: true} - errUnexpectedEOF = &CloseError{Code: CloseAbnormalClosure, Text: io.ErrUnexpectedEOF.Error()} - errBadWriteOpCode = errors.New("websocket: bad write message type") - errWriteClosed = errors.New("websocket: write closed") - errInvalidControlFrame = errors.New("websocket: invalid control frame") -) - -func newMaskKey() [4]byte { - n := rand.Uint32() - return [4]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)} -} - -func hideTempErr(err error) error { - if e, ok := err.(net.Error); ok && e.Temporary() { - err = &netError{msg: e.Error(), timeout: e.Timeout()} - } - return err -} - -func isControl(frameType int) bool { - return frameType == CloseMessage || frameType == PingMessage || frameType == PongMessage -} - -func isData(frameType int) bool { - return frameType == TextMessage || frameType == BinaryMessage -} - -var validReceivedCloseCodes = map[int]bool{ - // see http://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number - - CloseNormalClosure: true, - CloseGoingAway: true, - CloseProtocolError: true, - CloseUnsupportedData: true, - CloseNoStatusReceived: false, - CloseAbnormalClosure: false, - CloseInvalidFramePayloadData: true, - ClosePolicyViolation: true, - CloseMessageTooBig: true, - CloseMandatoryExtension: true, - CloseInternalServerErr: true, - CloseServiceRestart: true, - CloseTryAgainLater: true, - CloseTLSHandshake: false, -} - -func isValidReceivedCloseCode(code int) bool { - return validReceivedCloseCodes[code] || (code >= 3000 && code <= 4999) -} - -// BufferPool represents a pool of buffers. The *sync.Pool type satisfies this -// interface. The type of the value stored in a pool is not specified. -type BufferPool interface { - // Get gets a value from the pool or returns nil if the pool is empty. - Get() interface{} - // Put adds a value to the pool. - Put(interface{}) -} - -// writePoolData is the type added to the write buffer pool. This wrapper is -// used to prevent applications from peeking at and depending on the values -// added to the pool. -type writePoolData struct{ buf []byte } - -// The Conn type represents a WebSocket connection. -type Conn struct { - conn net.Conn - isServer bool - subprotocol string - - // Write fields - mu chan struct{} // used as mutex to protect write to conn - writeBuf []byte // frame is constructed in this buffer. - writePool BufferPool - writeBufSize int - writeDeadline time.Time - writer io.WriteCloser // the current writer returned to the application - isWriting bool // for best-effort concurrent write detection - - writeErrMu sync.Mutex - writeErr error - - enableWriteCompression bool - compressionLevel int - newCompressionWriter func(io.WriteCloser, int) io.WriteCloser - - // Read fields - reader io.ReadCloser // the current reader returned to the application - readErr error - br *bufio.Reader - // bytes remaining in current frame. - // set setReadRemaining to safely update this value and prevent overflow - readRemaining int64 - readFinal bool // true the current message has more frames. - readLength int64 // Message size. - readLimit int64 // Maximum message size. - readMaskPos int - readMaskKey [4]byte - handlePong func(string) error - handlePing func(string) error - handleClose func(int, string) error - readErrCount int - messageReader *messageReader // the current low-level reader - - readDecompress bool // whether last read frame had RSV1 set - newDecompressionReader func(io.Reader) io.ReadCloser -} - -func newConn(conn net.Conn, isServer bool, readBufferSize, writeBufferSize int, writeBufferPool BufferPool, br *bufio.Reader, writeBuf []byte) *Conn { - - if br == nil { - if readBufferSize == 0 { - readBufferSize = defaultReadBufferSize - } else if readBufferSize < maxControlFramePayloadSize { - // must be large enough for control frame - readBufferSize = maxControlFramePayloadSize - } - br = bufio.NewReaderSize(conn, readBufferSize) - } - - if writeBufferSize <= 0 { - writeBufferSize = defaultWriteBufferSize - } - writeBufferSize += maxFrameHeaderSize - - if writeBuf == nil && writeBufferPool == nil { - writeBuf = make([]byte, writeBufferSize) - } - - mu := make(chan struct{}, 1) - mu <- struct{}{} - c := &Conn{ - isServer: isServer, - br: br, - conn: conn, - mu: mu, - readFinal: true, - writeBuf: writeBuf, - writePool: writeBufferPool, - writeBufSize: writeBufferSize, - enableWriteCompression: true, - compressionLevel: defaultCompressionLevel, - } - c.SetCloseHandler(nil) - c.SetPingHandler(nil) - c.SetPongHandler(nil) - return c -} - -// setReadRemaining tracks the number of bytes remaining on the connection. If n -// overflows, an ErrReadLimit is returned. -func (c *Conn) setReadRemaining(n int64) error { - if n < 0 { - return ErrReadLimit - } - - c.readRemaining = n - return nil -} - -// Subprotocol returns the negotiated protocol for the connection. -func (c *Conn) Subprotocol() string { - return c.subprotocol -} - -// Close closes the underlying network connection without sending or waiting -// for a close message. -func (c *Conn) Close() error { - return c.conn.Close() -} - -// LocalAddr returns the local network address. -func (c *Conn) LocalAddr() net.Addr { - return c.conn.LocalAddr() -} - -// RemoteAddr returns the remote network address. -func (c *Conn) RemoteAddr() net.Addr { - return c.conn.RemoteAddr() -} - -// Write methods - -func (c *Conn) writeFatal(err error) error { - err = hideTempErr(err) - c.writeErrMu.Lock() - if c.writeErr == nil { - c.writeErr = err - } - c.writeErrMu.Unlock() - return err -} - -func (c *Conn) read(n int) ([]byte, error) { - p, err := c.br.Peek(n) - if err == io.EOF { - err = errUnexpectedEOF - } - c.br.Discard(len(p)) - return p, err -} - -func (c *Conn) write(frameType int, deadline time.Time, buf0, buf1 []byte) error { - <-c.mu - defer func() { c.mu <- struct{}{} }() - - c.writeErrMu.Lock() - err := c.writeErr - c.writeErrMu.Unlock() - if err != nil { - return err - } - - c.conn.SetWriteDeadline(deadline) - if len(buf1) == 0 { - _, err = c.conn.Write(buf0) - } else { - err = c.writeBufs(buf0, buf1) - } - if err != nil { - return c.writeFatal(err) - } - if frameType == CloseMessage { - c.writeFatal(ErrCloseSent) - } - return nil -} - -// WriteControl writes a control message with the given deadline. The allowed -// message types are CloseMessage, PingMessage and PongMessage. -func (c *Conn) WriteControl(messageType int, data []byte, deadline time.Time) error { - if !isControl(messageType) { - return errBadWriteOpCode - } - if len(data) > maxControlFramePayloadSize { - return errInvalidControlFrame - } - - b0 := byte(messageType) | finalBit - b1 := byte(len(data)) - if !c.isServer { - b1 |= maskBit - } - - buf := make([]byte, 0, maxFrameHeaderSize+maxControlFramePayloadSize) - buf = append(buf, b0, b1) - - if c.isServer { - buf = append(buf, data...) - } else { - key := newMaskKey() - buf = append(buf, key[:]...) - buf = append(buf, data...) - maskBytes(key, 0, buf[6:]) - } - - d := 1000 * time.Hour - if !deadline.IsZero() { - d = deadline.Sub(time.Now()) - if d < 0 { - return errWriteTimeout - } - } - - timer := time.NewTimer(d) - select { - case <-c.mu: - timer.Stop() - case <-timer.C: - return errWriteTimeout - } - defer func() { c.mu <- struct{}{} }() - - c.writeErrMu.Lock() - err := c.writeErr - c.writeErrMu.Unlock() - if err != nil { - return err - } - - c.conn.SetWriteDeadline(deadline) - _, err = c.conn.Write(buf) - if err != nil { - return c.writeFatal(err) - } - if messageType == CloseMessage { - c.writeFatal(ErrCloseSent) - } - return err -} - -// beginMessage prepares a connection and message writer for a new message. -func (c *Conn) beginMessage(mw *messageWriter, messageType int) error { - // Close previous writer if not already closed by the application. It's - // probably better to return an error in this situation, but we cannot - // change this without breaking existing applications. - if c.writer != nil { - c.writer.Close() - c.writer = nil - } - - if !isControl(messageType) && !isData(messageType) { - return errBadWriteOpCode - } - - c.writeErrMu.Lock() - err := c.writeErr - c.writeErrMu.Unlock() - if err != nil { - return err - } - - mw.c = c - mw.frameType = messageType - mw.pos = maxFrameHeaderSize - - if c.writeBuf == nil { - wpd, ok := c.writePool.Get().(writePoolData) - if ok { - c.writeBuf = wpd.buf - } else { - c.writeBuf = make([]byte, c.writeBufSize) - } - } - return nil -} - -// NextWriter returns a writer for the next message to send. The writer's Close -// method flushes the complete message to the network. -// -// There can be at most one open writer on a connection. NextWriter closes the -// previous writer if the application has not already done so. -// -// All message types (TextMessage, BinaryMessage, CloseMessage, PingMessage and -// PongMessage) are supported. -func (c *Conn) NextWriter(messageType int) (io.WriteCloser, error) { - var mw messageWriter - if err := c.beginMessage(&mw, messageType); err != nil { - return nil, err - } - c.writer = &mw - if c.newCompressionWriter != nil && c.enableWriteCompression && isData(messageType) { - w := c.newCompressionWriter(c.writer, c.compressionLevel) - mw.compress = true - c.writer = w - } - return c.writer, nil -} - -type messageWriter struct { - c *Conn - compress bool // whether next call to flushFrame should set RSV1 - pos int // end of data in writeBuf. - frameType int // type of the current frame. - err error -} - -func (w *messageWriter) endMessage(err error) error { - if w.err != nil { - return err - } - c := w.c - w.err = err - c.writer = nil - if c.writePool != nil { - c.writePool.Put(writePoolData{buf: c.writeBuf}) - c.writeBuf = nil - } - return err -} - -// flushFrame writes buffered data and extra as a frame to the network. The -// final argument indicates that this is the last frame in the message. -func (w *messageWriter) flushFrame(final bool, extra []byte) error { - c := w.c - length := w.pos - maxFrameHeaderSize + len(extra) - - // Check for invalid control frames. - if isControl(w.frameType) && - (!final || length > maxControlFramePayloadSize) { - return w.endMessage(errInvalidControlFrame) - } - - b0 := byte(w.frameType) - if final { - b0 |= finalBit - } - if w.compress { - b0 |= rsv1Bit - } - w.compress = false - - b1 := byte(0) - if !c.isServer { - b1 |= maskBit - } - - // Assume that the frame starts at beginning of c.writeBuf. - framePos := 0 - if c.isServer { - // Adjust up if mask not included in the header. - framePos = 4 - } - - switch { - case length >= 65536: - c.writeBuf[framePos] = b0 - c.writeBuf[framePos+1] = b1 | 127 - binary.BigEndian.PutUint64(c.writeBuf[framePos+2:], uint64(length)) - case length > 125: - framePos += 6 - c.writeBuf[framePos] = b0 - c.writeBuf[framePos+1] = b1 | 126 - binary.BigEndian.PutUint16(c.writeBuf[framePos+2:], uint16(length)) - default: - framePos += 8 - c.writeBuf[framePos] = b0 - c.writeBuf[framePos+1] = b1 | byte(length) - } - - if !c.isServer { - key := newMaskKey() - copy(c.writeBuf[maxFrameHeaderSize-4:], key[:]) - maskBytes(key, 0, c.writeBuf[maxFrameHeaderSize:w.pos]) - if len(extra) > 0 { - return w.endMessage(c.writeFatal(errors.New("websocket: internal error, extra used in client mode"))) - } - } - - // Write the buffers to the connection with best-effort detection of - // concurrent writes. See the concurrency section in the package - // documentation for more info. - - if c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = true - - err := c.write(w.frameType, c.writeDeadline, c.writeBuf[framePos:w.pos], extra) - - if !c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = false - - if err != nil { - return w.endMessage(err) - } - - if final { - w.endMessage(errWriteClosed) - return nil - } - - // Setup for next frame. - w.pos = maxFrameHeaderSize - w.frameType = continuationFrame - return nil -} - -func (w *messageWriter) ncopy(max int) (int, error) { - n := len(w.c.writeBuf) - w.pos - if n <= 0 { - if err := w.flushFrame(false, nil); err != nil { - return 0, err - } - n = len(w.c.writeBuf) - w.pos - } - if n > max { - n = max - } - return n, nil -} - -func (w *messageWriter) Write(p []byte) (int, error) { - if w.err != nil { - return 0, w.err - } - - if len(p) > 2*len(w.c.writeBuf) && w.c.isServer { - // Don't buffer large messages. - err := w.flushFrame(false, p) - if err != nil { - return 0, err - } - return len(p), nil - } - - nn := len(p) - for len(p) > 0 { - n, err := w.ncopy(len(p)) - if err != nil { - return 0, err - } - copy(w.c.writeBuf[w.pos:], p[:n]) - w.pos += n - p = p[n:] - } - return nn, nil -} - -func (w *messageWriter) WriteString(p string) (int, error) { - if w.err != nil { - return 0, w.err - } - - nn := len(p) - for len(p) > 0 { - n, err := w.ncopy(len(p)) - if err != nil { - return 0, err - } - copy(w.c.writeBuf[w.pos:], p[:n]) - w.pos += n - p = p[n:] - } - return nn, nil -} - -func (w *messageWriter) ReadFrom(r io.Reader) (nn int64, err error) { - if w.err != nil { - return 0, w.err - } - for { - if w.pos == len(w.c.writeBuf) { - err = w.flushFrame(false, nil) - if err != nil { - break - } - } - var n int - n, err = r.Read(w.c.writeBuf[w.pos:]) - w.pos += n - nn += int64(n) - if err != nil { - if err == io.EOF { - err = nil - } - break - } - } - return nn, err -} - -func (w *messageWriter) Close() error { - if w.err != nil { - return w.err - } - return w.flushFrame(true, nil) -} - -// WritePreparedMessage writes prepared message into connection. -func (c *Conn) WritePreparedMessage(pm *PreparedMessage) error { - frameType, frameData, err := pm.frame(prepareKey{ - isServer: c.isServer, - compress: c.newCompressionWriter != nil && c.enableWriteCompression && isData(pm.messageType), - compressionLevel: c.compressionLevel, - }) - if err != nil { - return err - } - if c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = true - err = c.write(frameType, c.writeDeadline, frameData, nil) - if !c.isWriting { - panic("concurrent write to websocket connection") - } - c.isWriting = false - return err -} - -// WriteMessage is a helper method for getting a writer using NextWriter, -// writing the message and closing the writer. -func (c *Conn) WriteMessage(messageType int, data []byte) error { - - if c.isServer && (c.newCompressionWriter == nil || !c.enableWriteCompression) { - // Fast path with no allocations and single frame. - - var mw messageWriter - if err := c.beginMessage(&mw, messageType); err != nil { - return err - } - n := copy(c.writeBuf[mw.pos:], data) - mw.pos += n - data = data[n:] - return mw.flushFrame(true, data) - } - - w, err := c.NextWriter(messageType) - if err != nil { - return err - } - if _, err = w.Write(data); err != nil { - return err - } - return w.Close() -} - -// SetWriteDeadline sets the write deadline on the underlying network -// connection. After a write has timed out, the websocket state is corrupt and -// all future writes will return an error. A zero value for t means writes will -// not time out. -func (c *Conn) SetWriteDeadline(t time.Time) error { - c.writeDeadline = t - return nil -} - -// Read methods - -func (c *Conn) advanceFrame() (int, error) { - // 1. Skip remainder of previous frame. - - if c.readRemaining > 0 { - if _, err := io.CopyN(ioutil.Discard, c.br, c.readRemaining); err != nil { - return noFrame, err - } - } - - // 2. Read and parse first two bytes of frame header. - - p, err := c.read(2) - if err != nil { - return noFrame, err - } - - final := p[0]&finalBit != 0 - frameType := int(p[0] & 0xf) - mask := p[1]&maskBit != 0 - c.setReadRemaining(int64(p[1] & 0x7f)) - - c.readDecompress = false - if c.newDecompressionReader != nil && (p[0]&rsv1Bit) != 0 { - c.readDecompress = true - p[0] &^= rsv1Bit - } - - if rsv := p[0] & (rsv1Bit | rsv2Bit | rsv3Bit); rsv != 0 { - return noFrame, c.handleProtocolError("unexpected reserved bits 0x" + strconv.FormatInt(int64(rsv), 16)) - } - - switch frameType { - case CloseMessage, PingMessage, PongMessage: - if c.readRemaining > maxControlFramePayloadSize { - return noFrame, c.handleProtocolError("control frame length > 125") - } - if !final { - return noFrame, c.handleProtocolError("control frame not final") - } - case TextMessage, BinaryMessage: - if !c.readFinal { - return noFrame, c.handleProtocolError("message start before final message frame") - } - c.readFinal = final - case continuationFrame: - if c.readFinal { - return noFrame, c.handleProtocolError("continuation after final message frame") - } - c.readFinal = final - default: - return noFrame, c.handleProtocolError("unknown opcode " + strconv.Itoa(frameType)) - } - - // 3. Read and parse frame length as per - // https://tools.ietf.org/html/rfc6455#section-5.2 - // - // The length of the "Payload data", in bytes: if 0-125, that is the payload - // length. - // - If 126, the following 2 bytes interpreted as a 16-bit unsigned - // integer are the payload length. - // - If 127, the following 8 bytes interpreted as - // a 64-bit unsigned integer (the most significant bit MUST be 0) are the - // payload length. Multibyte length quantities are expressed in network byte - // order. - - switch c.readRemaining { - case 126: - p, err := c.read(2) - if err != nil { - return noFrame, err - } - - if err := c.setReadRemaining(int64(binary.BigEndian.Uint16(p))); err != nil { - return noFrame, err - } - case 127: - p, err := c.read(8) - if err != nil { - return noFrame, err - } - - if err := c.setReadRemaining(int64(binary.BigEndian.Uint64(p))); err != nil { - return noFrame, err - } - } - - // 4. Handle frame masking. - - if mask != c.isServer { - return noFrame, c.handleProtocolError("incorrect mask flag") - } - - if mask { - c.readMaskPos = 0 - p, err := c.read(len(c.readMaskKey)) - if err != nil { - return noFrame, err - } - copy(c.readMaskKey[:], p) - } - - // 5. For text and binary messages, enforce read limit and return. - - if frameType == continuationFrame || frameType == TextMessage || frameType == BinaryMessage { - - c.readLength += c.readRemaining - // Don't allow readLength to overflow in the presence of a large readRemaining - // counter. - if c.readLength < 0 { - return noFrame, ErrReadLimit - } - - if c.readLimit > 0 && c.readLength > c.readLimit { - c.WriteControl(CloseMessage, FormatCloseMessage(CloseMessageTooBig, ""), time.Now().Add(writeWait)) - return noFrame, ErrReadLimit - } - - return frameType, nil - } - - // 6. Read control frame payload. - - var payload []byte - if c.readRemaining > 0 { - payload, err = c.read(int(c.readRemaining)) - c.setReadRemaining(0) - if err != nil { - return noFrame, err - } - if c.isServer { - maskBytes(c.readMaskKey, 0, payload) - } - } - - // 7. Process control frame payload. - - switch frameType { - case PongMessage: - if err := c.handlePong(string(payload)); err != nil { - return noFrame, err - } - case PingMessage: - if err := c.handlePing(string(payload)); err != nil { - return noFrame, err - } - case CloseMessage: - closeCode := CloseNoStatusReceived - closeText := "" - if len(payload) >= 2 { - closeCode = int(binary.BigEndian.Uint16(payload)) - if !isValidReceivedCloseCode(closeCode) { - return noFrame, c.handleProtocolError("invalid close code") - } - closeText = string(payload[2:]) - if !utf8.ValidString(closeText) { - return noFrame, c.handleProtocolError("invalid utf8 payload in close frame") - } - } - if err := c.handleClose(closeCode, closeText); err != nil { - return noFrame, err - } - return noFrame, &CloseError{Code: closeCode, Text: closeText} - } - - return frameType, nil -} - -func (c *Conn) handleProtocolError(message string) error { - c.WriteControl(CloseMessage, FormatCloseMessage(CloseProtocolError, message), time.Now().Add(writeWait)) - return errors.New("websocket: " + message) -} - -// NextReader returns the next data message received from the peer. The -// returned messageType is either TextMessage or BinaryMessage. -// -// There can be at most one open reader on a connection. NextReader discards -// the previous message if the application has not already consumed it. -// -// Applications must break out of the application's read loop when this method -// returns a non-nil error value. Errors returned from this method are -// permanent. Once this method returns a non-nil error, all subsequent calls to -// this method return the same error. -func (c *Conn) NextReader() (messageType int, r io.Reader, err error) { - // Close previous reader, only relevant for decompression. - if c.reader != nil { - c.reader.Close() - c.reader = nil - } - - c.messageReader = nil - c.readLength = 0 - - for c.readErr == nil { - frameType, err := c.advanceFrame() - if err != nil { - c.readErr = hideTempErr(err) - break - } - - if frameType == TextMessage || frameType == BinaryMessage { - c.messageReader = &messageReader{c} - c.reader = c.messageReader - if c.readDecompress { - c.reader = c.newDecompressionReader(c.reader) - } - return frameType, c.reader, nil - } - } - - // Applications that do handle the error returned from this method spin in - // tight loop on connection failure. To help application developers detect - // this error, panic on repeated reads to the failed connection. - c.readErrCount++ - if c.readErrCount >= 1000 { - panic("repeated read on failed websocket connection") - } - - return noFrame, nil, c.readErr -} - -type messageReader struct{ c *Conn } - -func (r *messageReader) Read(b []byte) (int, error) { - c := r.c - if c.messageReader != r { - return 0, io.EOF - } - - for c.readErr == nil { - - if c.readRemaining > 0 { - if int64(len(b)) > c.readRemaining { - b = b[:c.readRemaining] - } - n, err := c.br.Read(b) - c.readErr = hideTempErr(err) - if c.isServer { - c.readMaskPos = maskBytes(c.readMaskKey, c.readMaskPos, b[:n]) - } - rem := c.readRemaining - rem -= int64(n) - c.setReadRemaining(rem) - if c.readRemaining > 0 && c.readErr == io.EOF { - c.readErr = errUnexpectedEOF - } - return n, c.readErr - } - - if c.readFinal { - c.messageReader = nil - return 0, io.EOF - } - - frameType, err := c.advanceFrame() - switch { - case err != nil: - c.readErr = hideTempErr(err) - case frameType == TextMessage || frameType == BinaryMessage: - c.readErr = errors.New("websocket: internal error, unexpected text or binary in Reader") - } - } - - err := c.readErr - if err == io.EOF && c.messageReader == r { - err = errUnexpectedEOF - } - return 0, err -} - -func (r *messageReader) Close() error { - return nil -} - -// ReadMessage is a helper method for getting a reader using NextReader and -// reading from that reader to a buffer. -func (c *Conn) ReadMessage() (messageType int, p []byte, err error) { - var r io.Reader - messageType, r, err = c.NextReader() - if err != nil { - return messageType, nil, err - } - p, err = ioutil.ReadAll(r) - return messageType, p, err -} - -// SetReadDeadline sets the read deadline on the underlying network connection. -// After a read has timed out, the websocket connection state is corrupt and -// all future reads will return an error. A zero value for t means reads will -// not time out. -func (c *Conn) SetReadDeadline(t time.Time) error { - return c.conn.SetReadDeadline(t) -} - -// SetReadLimit sets the maximum size in bytes for a message read from the peer. If a -// message exceeds the limit, the connection sends a close message to the peer -// and returns ErrReadLimit to the application. -func (c *Conn) SetReadLimit(limit int64) { - c.readLimit = limit -} - -// CloseHandler returns the current close handler -func (c *Conn) CloseHandler() func(code int, text string) error { - return c.handleClose -} - -// SetCloseHandler sets the handler for close messages received from the peer. -// The code argument to h is the received close code or CloseNoStatusReceived -// if the close message is empty. The default close handler sends a close -// message back to the peer. -// -// The handler function is called from the NextReader, ReadMessage and message -// reader Read methods. The application must read the connection to process -// close messages as described in the section on Control Messages above. -// -// The connection read methods return a CloseError when a close message is -// received. Most applications should handle close messages as part of their -// normal error handling. Applications should only set a close handler when the -// application must perform some action before sending a close message back to -// the peer. -func (c *Conn) SetCloseHandler(h func(code int, text string) error) { - if h == nil { - h = func(code int, text string) error { - message := FormatCloseMessage(code, "") - c.WriteControl(CloseMessage, message, time.Now().Add(writeWait)) - return nil - } - } - c.handleClose = h -} - -// PingHandler returns the current ping handler -func (c *Conn) PingHandler() func(appData string) error { - return c.handlePing -} - -// SetPingHandler sets the handler for ping messages received from the peer. -// The appData argument to h is the PING message application data. The default -// ping handler sends a pong to the peer. -// -// The handler function is called from the NextReader, ReadMessage and message -// reader Read methods. The application must read the connection to process -// ping messages as described in the section on Control Messages above. -func (c *Conn) SetPingHandler(h func(appData string) error) { - if h == nil { - h = func(message string) error { - err := c.WriteControl(PongMessage, []byte(message), time.Now().Add(writeWait)) - if err == ErrCloseSent { - return nil - } else if e, ok := err.(net.Error); ok && e.Temporary() { - return nil - } - return err - } - } - c.handlePing = h -} - -// PongHandler returns the current pong handler -func (c *Conn) PongHandler() func(appData string) error { - return c.handlePong -} - -// SetPongHandler sets the handler for pong messages received from the peer. -// The appData argument to h is the PONG message application data. The default -// pong handler does nothing. -// -// The handler function is called from the NextReader, ReadMessage and message -// reader Read methods. The application must read the connection to process -// pong messages as described in the section on Control Messages above. -func (c *Conn) SetPongHandler(h func(appData string) error) { - if h == nil { - h = func(string) error { return nil } - } - c.handlePong = h -} - -// UnderlyingConn returns the internal net.Conn. This can be used to further -// modifications to connection specific flags. -func (c *Conn) UnderlyingConn() net.Conn { - return c.conn -} - -// EnableWriteCompression enables and disables write compression of -// subsequent text and binary messages. This function is a noop if -// compression was not negotiated with the peer. -func (c *Conn) EnableWriteCompression(enable bool) { - c.enableWriteCompression = enable -} - -// SetCompressionLevel sets the flate compression level for subsequent text and -// binary messages. This function is a noop if compression was not negotiated -// with the peer. See the compress/flate package for a description of -// compression levels. -func (c *Conn) SetCompressionLevel(level int) error { - if !isValidCompressionLevel(level) { - return errors.New("websocket: invalid compression level") - } - c.compressionLevel = level - return nil -} - -// FormatCloseMessage formats closeCode and text as a WebSocket close message. -// An empty message is returned for code CloseNoStatusReceived. -func FormatCloseMessage(closeCode int, text string) []byte { - if closeCode == CloseNoStatusReceived { - // Return empty message because it's illegal to send - // CloseNoStatusReceived. Return non-nil value in case application - // checks for nil. - return []byte{} - } - buf := make([]byte, 2+len(text)) - binary.BigEndian.PutUint16(buf, uint16(closeCode)) - copy(buf[2:], text) - return buf -} diff --git a/vendor/github.com/gorilla/websocket/conn_write.go b/vendor/github.com/gorilla/websocket/conn_write.go deleted file mode 100644 index a509a21f8..000000000 --- a/vendor/github.com/gorilla/websocket/conn_write.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build go1.8 - -package websocket - -import "net" - -func (c *Conn) writeBufs(bufs ...[]byte) error { - b := net.Buffers(bufs) - _, err := b.WriteTo(c.conn) - return err -} diff --git a/vendor/github.com/gorilla/websocket/conn_write_legacy.go b/vendor/github.com/gorilla/websocket/conn_write_legacy.go deleted file mode 100644 index 37edaff5a..000000000 --- a/vendor/github.com/gorilla/websocket/conn_write_legacy.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// +build !go1.8 - -package websocket - -func (c *Conn) writeBufs(bufs ...[]byte) error { - for _, buf := range bufs { - if len(buf) > 0 { - if _, err := c.conn.Write(buf); err != nil { - return err - } - } - } - return nil -} diff --git a/vendor/github.com/gorilla/websocket/doc.go b/vendor/github.com/gorilla/websocket/doc.go deleted file mode 100644 index 8db0cef95..000000000 --- a/vendor/github.com/gorilla/websocket/doc.go +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package websocket implements the WebSocket protocol defined in RFC 6455. -// -// Overview -// -// The Conn type represents a WebSocket connection. A server application calls -// the Upgrader.Upgrade method from an HTTP request handler to get a *Conn: -// -// var upgrader = websocket.Upgrader{ -// ReadBufferSize: 1024, -// WriteBufferSize: 1024, -// } -// -// func handler(w http.ResponseWriter, r *http.Request) { -// conn, err := upgrader.Upgrade(w, r, nil) -// if err != nil { -// log.Println(err) -// return -// } -// ... Use conn to send and receive messages. -// } -// -// Call the connection's WriteMessage and ReadMessage methods to send and -// receive messages as a slice of bytes. This snippet of code shows how to echo -// messages using these methods: -// -// for { -// messageType, p, err := conn.ReadMessage() -// if err != nil { -// log.Println(err) -// return -// } -// if err := conn.WriteMessage(messageType, p); err != nil { -// log.Println(err) -// return -// } -// } -// -// In above snippet of code, p is a []byte and messageType is an int with value -// websocket.BinaryMessage or websocket.TextMessage. -// -// An application can also send and receive messages using the io.WriteCloser -// and io.Reader interfaces. To send a message, call the connection NextWriter -// method to get an io.WriteCloser, write the message to the writer and close -// the writer when done. To receive a message, call the connection NextReader -// method to get an io.Reader and read until io.EOF is returned. This snippet -// shows how to echo messages using the NextWriter and NextReader methods: -// -// for { -// messageType, r, err := conn.NextReader() -// if err != nil { -// return -// } -// w, err := conn.NextWriter(messageType) -// if err != nil { -// return err -// } -// if _, err := io.Copy(w, r); err != nil { -// return err -// } -// if err := w.Close(); err != nil { -// return err -// } -// } -// -// Data Messages -// -// The WebSocket protocol distinguishes between text and binary data messages. -// Text messages are interpreted as UTF-8 encoded text. The interpretation of -// binary messages is left to the application. -// -// This package uses the TextMessage and BinaryMessage integer constants to -// identify the two data message types. The ReadMessage and NextReader methods -// return the type of the received message. The messageType argument to the -// WriteMessage and NextWriter methods specifies the type of a sent message. -// -// It is the application's responsibility to ensure that text messages are -// valid UTF-8 encoded text. -// -// Control Messages -// -// The WebSocket protocol defines three types of control messages: close, ping -// and pong. Call the connection WriteControl, WriteMessage or NextWriter -// methods to send a control message to the peer. -// -// Connections handle received close messages by calling the handler function -// set with the SetCloseHandler method and by returning a *CloseError from the -// NextReader, ReadMessage or the message Read method. The default close -// handler sends a close message to the peer. -// -// Connections handle received ping messages by calling the handler function -// set with the SetPingHandler method. The default ping handler sends a pong -// message to the peer. -// -// Connections handle received pong messages by calling the handler function -// set with the SetPongHandler method. The default pong handler does nothing. -// If an application sends ping messages, then the application should set a -// pong handler to receive the corresponding pong. -// -// The control message handler functions are called from the NextReader, -// ReadMessage and message reader Read methods. The default close and ping -// handlers can block these methods for a short time when the handler writes to -// the connection. -// -// The application must read the connection to process close, ping and pong -// messages sent from the peer. If the application is not otherwise interested -// in messages from the peer, then the application should start a goroutine to -// read and discard messages from the peer. A simple example is: -// -// func readLoop(c *websocket.Conn) { -// for { -// if _, _, err := c.NextReader(); err != nil { -// c.Close() -// break -// } -// } -// } -// -// Concurrency -// -// Connections support one concurrent reader and one concurrent writer. -// -// Applications are responsible for ensuring that no more than one goroutine -// calls the write methods (NextWriter, SetWriteDeadline, WriteMessage, -// WriteJSON, EnableWriteCompression, SetCompressionLevel) concurrently and -// that no more than one goroutine calls the read methods (NextReader, -// SetReadDeadline, ReadMessage, ReadJSON, SetPongHandler, SetPingHandler) -// concurrently. -// -// The Close and WriteControl methods can be called concurrently with all other -// methods. -// -// Origin Considerations -// -// Web browsers allow Javascript applications to open a WebSocket connection to -// any host. It's up to the server to enforce an origin policy using the Origin -// request header sent by the browser. -// -// The Upgrader calls the function specified in the CheckOrigin field to check -// the origin. If the CheckOrigin function returns false, then the Upgrade -// method fails the WebSocket handshake with HTTP status 403. -// -// If the CheckOrigin field is nil, then the Upgrader uses a safe default: fail -// the handshake if the Origin request header is present and the Origin host is -// not equal to the Host request header. -// -// The deprecated package-level Upgrade function does not perform origin -// checking. The application is responsible for checking the Origin header -// before calling the Upgrade function. -// -// Buffers -// -// Connections buffer network input and output to reduce the number -// of system calls when reading or writing messages. -// -// Write buffers are also used for constructing WebSocket frames. See RFC 6455, -// Section 5 for a discussion of message framing. A WebSocket frame header is -// written to the network each time a write buffer is flushed to the network. -// Decreasing the size of the write buffer can increase the amount of framing -// overhead on the connection. -// -// The buffer sizes in bytes are specified by the ReadBufferSize and -// WriteBufferSize fields in the Dialer and Upgrader. The Dialer uses a default -// size of 4096 when a buffer size field is set to zero. The Upgrader reuses -// buffers created by the HTTP server when a buffer size field is set to zero. -// The HTTP server buffers have a size of 4096 at the time of this writing. -// -// The buffer sizes do not limit the size of a message that can be read or -// written by a connection. -// -// Buffers are held for the lifetime of the connection by default. If the -// Dialer or Upgrader WriteBufferPool field is set, then a connection holds the -// write buffer only when writing a message. -// -// Applications should tune the buffer sizes to balance memory use and -// performance. Increasing the buffer size uses more memory, but can reduce the -// number of system calls to read or write the network. In the case of writing, -// increasing the buffer size can reduce the number of frame headers written to -// the network. -// -// Some guidelines for setting buffer parameters are: -// -// Limit the buffer sizes to the maximum expected message size. Buffers larger -// than the largest message do not provide any benefit. -// -// Depending on the distribution of message sizes, setting the buffer size to -// a value less than the maximum expected message size can greatly reduce memory -// use with a small impact on performance. Here's an example: If 99% of the -// messages are smaller than 256 bytes and the maximum message size is 512 -// bytes, then a buffer size of 256 bytes will result in 1.01 more system calls -// than a buffer size of 512 bytes. The memory savings is 50%. -// -// A write buffer pool is useful when the application has a modest number -// writes over a large number of connections. when buffers are pooled, a larger -// buffer size has a reduced impact on total memory use and has the benefit of -// reducing system calls and frame overhead. -// -// Compression EXPERIMENTAL -// -// Per message compression extensions (RFC 7692) are experimentally supported -// by this package in a limited capacity. Setting the EnableCompression option -// to true in Dialer or Upgrader will attempt to negotiate per message deflate -// support. -// -// var upgrader = websocket.Upgrader{ -// EnableCompression: true, -// } -// -// If compression was successfully negotiated with the connection's peer, any -// message received in compressed form will be automatically decompressed. -// All Read methods will return uncompressed bytes. -// -// Per message compression of messages written to a connection can be enabled -// or disabled by calling the corresponding Conn method: -// -// conn.EnableWriteCompression(false) -// -// Currently this package does not support compression with "context takeover". -// This means that messages must be compressed and decompressed in isolation, -// without retaining sliding window or dictionary state across messages. For -// more details refer to RFC 7692. -// -// Use of compression is experimental and may result in decreased performance. -package websocket diff --git a/vendor/github.com/gorilla/websocket/go.mod b/vendor/github.com/gorilla/websocket/go.mod deleted file mode 100644 index 1a7afd502..000000000 --- a/vendor/github.com/gorilla/websocket/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/gorilla/websocket - -go 1.12 diff --git a/vendor/github.com/gorilla/websocket/go.sum b/vendor/github.com/gorilla/websocket/go.sum deleted file mode 100644 index e69de29bb..000000000 diff --git a/vendor/github.com/gorilla/websocket/join.go b/vendor/github.com/gorilla/websocket/join.go deleted file mode 100644 index c64f8c829..000000000 --- a/vendor/github.com/gorilla/websocket/join.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2019 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "io" - "strings" -) - -// JoinMessages concatenates received messages to create a single io.Reader. -// The string term is appended to each message. The returned reader does not -// support concurrent calls to the Read method. -func JoinMessages(c *Conn, term string) io.Reader { - return &joinReader{c: c, term: term} -} - -type joinReader struct { - c *Conn - term string - r io.Reader -} - -func (r *joinReader) Read(p []byte) (int, error) { - if r.r == nil { - var err error - _, r.r, err = r.c.NextReader() - if err != nil { - return 0, err - } - if r.term != "" { - r.r = io.MultiReader(r.r, strings.NewReader(r.term)) - } - } - n, err := r.r.Read(p) - if err == io.EOF { - err = nil - r.r = nil - } - return n, err -} diff --git a/vendor/github.com/gorilla/websocket/json.go b/vendor/github.com/gorilla/websocket/json.go deleted file mode 100644 index dc2c1f641..000000000 --- a/vendor/github.com/gorilla/websocket/json.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "encoding/json" - "io" -) - -// WriteJSON writes the JSON encoding of v as a message. -// -// Deprecated: Use c.WriteJSON instead. -func WriteJSON(c *Conn, v interface{}) error { - return c.WriteJSON(v) -} - -// WriteJSON writes the JSON encoding of v as a message. -// -// See the documentation for encoding/json Marshal for details about the -// conversion of Go values to JSON. -func (c *Conn) WriteJSON(v interface{}) error { - w, err := c.NextWriter(TextMessage) - if err != nil { - return err - } - err1 := json.NewEncoder(w).Encode(v) - err2 := w.Close() - if err1 != nil { - return err1 - } - return err2 -} - -// ReadJSON reads the next JSON-encoded message from the connection and stores -// it in the value pointed to by v. -// -// Deprecated: Use c.ReadJSON instead. -func ReadJSON(c *Conn, v interface{}) error { - return c.ReadJSON(v) -} - -// ReadJSON reads the next JSON-encoded message from the connection and stores -// it in the value pointed to by v. -// -// See the documentation for the encoding/json Unmarshal function for details -// about the conversion of JSON to a Go value. -func (c *Conn) ReadJSON(v interface{}) error { - _, r, err := c.NextReader() - if err != nil { - return err - } - err = json.NewDecoder(r).Decode(v) - if err == io.EOF { - // One value is expected in the message. - err = io.ErrUnexpectedEOF - } - return err -} diff --git a/vendor/github.com/gorilla/websocket/mask.go b/vendor/github.com/gorilla/websocket/mask.go deleted file mode 100644 index 577fce9ef..000000000 --- a/vendor/github.com/gorilla/websocket/mask.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of -// this source code is governed by a BSD-style license that can be found in the -// LICENSE file. - -// +build !appengine - -package websocket - -import "unsafe" - -const wordSize = int(unsafe.Sizeof(uintptr(0))) - -func maskBytes(key [4]byte, pos int, b []byte) int { - // Mask one byte at a time for small buffers. - if len(b) < 2*wordSize { - for i := range b { - b[i] ^= key[pos&3] - pos++ - } - return pos & 3 - } - - // Mask one byte at a time to word boundary. - if n := int(uintptr(unsafe.Pointer(&b[0]))) % wordSize; n != 0 { - n = wordSize - n - for i := range b[:n] { - b[i] ^= key[pos&3] - pos++ - } - b = b[n:] - } - - // Create aligned word size key. - var k [wordSize]byte - for i := range k { - k[i] = key[(pos+i)&3] - } - kw := *(*uintptr)(unsafe.Pointer(&k)) - - // Mask one word at a time. - n := (len(b) / wordSize) * wordSize - for i := 0; i < n; i += wordSize { - *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(i))) ^= kw - } - - // Mask one byte at a time for remaining bytes. - b = b[n:] - for i := range b { - b[i] ^= key[pos&3] - pos++ - } - - return pos & 3 -} diff --git a/vendor/github.com/gorilla/websocket/mask_safe.go b/vendor/github.com/gorilla/websocket/mask_safe.go deleted file mode 100644 index 2aac060e5..000000000 --- a/vendor/github.com/gorilla/websocket/mask_safe.go +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2016 The Gorilla WebSocket Authors. All rights reserved. Use of -// this source code is governed by a BSD-style license that can be found in the -// LICENSE file. - -// +build appengine - -package websocket - -func maskBytes(key [4]byte, pos int, b []byte) int { - for i := range b { - b[i] ^= key[pos&3] - pos++ - } - return pos & 3 -} diff --git a/vendor/github.com/gorilla/websocket/prepared.go b/vendor/github.com/gorilla/websocket/prepared.go deleted file mode 100644 index c854225e9..000000000 --- a/vendor/github.com/gorilla/websocket/prepared.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bytes" - "net" - "sync" - "time" -) - -// PreparedMessage caches on the wire representations of a message payload. -// Use PreparedMessage to efficiently send a message payload to multiple -// connections. PreparedMessage is especially useful when compression is used -// because the CPU and memory expensive compression operation can be executed -// once for a given set of compression options. -type PreparedMessage struct { - messageType int - data []byte - mu sync.Mutex - frames map[prepareKey]*preparedFrame -} - -// prepareKey defines a unique set of options to cache prepared frames in PreparedMessage. -type prepareKey struct { - isServer bool - compress bool - compressionLevel int -} - -// preparedFrame contains data in wire representation. -type preparedFrame struct { - once sync.Once - data []byte -} - -// NewPreparedMessage returns an initialized PreparedMessage. You can then send -// it to connection using WritePreparedMessage method. Valid wire -// representation will be calculated lazily only once for a set of current -// connection options. -func NewPreparedMessage(messageType int, data []byte) (*PreparedMessage, error) { - pm := &PreparedMessage{ - messageType: messageType, - frames: make(map[prepareKey]*preparedFrame), - data: data, - } - - // Prepare a plain server frame. - _, frameData, err := pm.frame(prepareKey{isServer: true, compress: false}) - if err != nil { - return nil, err - } - - // To protect against caller modifying the data argument, remember the data - // copied to the plain server frame. - pm.data = frameData[len(frameData)-len(data):] - return pm, nil -} - -func (pm *PreparedMessage) frame(key prepareKey) (int, []byte, error) { - pm.mu.Lock() - frame, ok := pm.frames[key] - if !ok { - frame = &preparedFrame{} - pm.frames[key] = frame - } - pm.mu.Unlock() - - var err error - frame.once.Do(func() { - // Prepare a frame using a 'fake' connection. - // TODO: Refactor code in conn.go to allow more direct construction of - // the frame. - mu := make(chan struct{}, 1) - mu <- struct{}{} - var nc prepareConn - c := &Conn{ - conn: &nc, - mu: mu, - isServer: key.isServer, - compressionLevel: key.compressionLevel, - enableWriteCompression: true, - writeBuf: make([]byte, defaultWriteBufferSize+maxFrameHeaderSize), - } - if key.compress { - c.newCompressionWriter = compressNoContextTakeover - } - err = c.WriteMessage(pm.messageType, pm.data) - frame.data = nc.buf.Bytes() - }) - return pm.messageType, frame.data, err -} - -type prepareConn struct { - buf bytes.Buffer - net.Conn -} - -func (pc *prepareConn) Write(p []byte) (int, error) { return pc.buf.Write(p) } -func (pc *prepareConn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/vendor/github.com/gorilla/websocket/proxy.go b/vendor/github.com/gorilla/websocket/proxy.go deleted file mode 100644 index e87a8c9f0..000000000 --- a/vendor/github.com/gorilla/websocket/proxy.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2017 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bufio" - "encoding/base64" - "errors" - "net" - "net/http" - "net/url" - "strings" -) - -type netDialerFunc func(network, addr string) (net.Conn, error) - -func (fn netDialerFunc) Dial(network, addr string) (net.Conn, error) { - return fn(network, addr) -} - -func init() { - proxy_RegisterDialerType("http", func(proxyURL *url.URL, forwardDialer proxy_Dialer) (proxy_Dialer, error) { - return &httpProxyDialer{proxyURL: proxyURL, forwardDial: forwardDialer.Dial}, nil - }) -} - -type httpProxyDialer struct { - proxyURL *url.URL - forwardDial func(network, addr string) (net.Conn, error) -} - -func (hpd *httpProxyDialer) Dial(network string, addr string) (net.Conn, error) { - hostPort, _ := hostPortNoPort(hpd.proxyURL) - conn, err := hpd.forwardDial(network, hostPort) - if err != nil { - return nil, err - } - - connectHeader := make(http.Header) - if user := hpd.proxyURL.User; user != nil { - proxyUser := user.Username() - if proxyPassword, passwordSet := user.Password(); passwordSet { - credential := base64.StdEncoding.EncodeToString([]byte(proxyUser + ":" + proxyPassword)) - connectHeader.Set("Proxy-Authorization", "Basic "+credential) - } - } - - connectReq := &http.Request{ - Method: "CONNECT", - URL: &url.URL{Opaque: addr}, - Host: addr, - Header: connectHeader, - } - - if err := connectReq.Write(conn); err != nil { - conn.Close() - return nil, err - } - - // Read response. It's OK to use and discard buffered reader here becaue - // the remote server does not speak until spoken to. - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, connectReq) - if err != nil { - conn.Close() - return nil, err - } - - if resp.StatusCode != 200 { - conn.Close() - f := strings.SplitN(resp.Status, " ", 2) - return nil, errors.New(f[1]) - } - return conn, nil -} diff --git a/vendor/github.com/gorilla/websocket/server.go b/vendor/github.com/gorilla/websocket/server.go deleted file mode 100644 index 887d55891..000000000 --- a/vendor/github.com/gorilla/websocket/server.go +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "bufio" - "errors" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -// HandshakeError describes an error with the handshake from the peer. -type HandshakeError struct { - message string -} - -func (e HandshakeError) Error() string { return e.message } - -// Upgrader specifies parameters for upgrading an HTTP connection to a -// WebSocket connection. -type Upgrader struct { - // HandshakeTimeout specifies the duration for the handshake to complete. - HandshakeTimeout time.Duration - - // ReadBufferSize and WriteBufferSize specify I/O buffer sizes in bytes. If a buffer - // size is zero, then buffers allocated by the HTTP server are used. The - // I/O buffer sizes do not limit the size of the messages that can be sent - // or received. - ReadBufferSize, WriteBufferSize int - - // WriteBufferPool is a pool of buffers for write operations. If the value - // is not set, then write buffers are allocated to the connection for the - // lifetime of the connection. - // - // A pool is most useful when the application has a modest volume of writes - // across a large number of connections. - // - // Applications should use a single pool for each unique value of - // WriteBufferSize. - WriteBufferPool BufferPool - - // Subprotocols specifies the server's supported protocols in order of - // preference. If this field is not nil, then the Upgrade method negotiates a - // subprotocol by selecting the first match in this list with a protocol - // requested by the client. If there's no match, then no protocol is - // negotiated (the Sec-Websocket-Protocol header is not included in the - // handshake response). - Subprotocols []string - - // Error specifies the function for generating HTTP error responses. If Error - // is nil, then http.Error is used to generate the HTTP response. - Error func(w http.ResponseWriter, r *http.Request, status int, reason error) - - // CheckOrigin returns true if the request Origin header is acceptable. If - // CheckOrigin is nil, then a safe default is used: return false if the - // Origin request header is present and the origin host is not equal to - // request Host header. - // - // A CheckOrigin function should carefully validate the request origin to - // prevent cross-site request forgery. - CheckOrigin func(r *http.Request) bool - - // EnableCompression specify if the server should attempt to negotiate per - // message compression (RFC 7692). Setting this value to true does not - // guarantee that compression will be supported. Currently only "no context - // takeover" modes are supported. - EnableCompression bool -} - -func (u *Upgrader) returnError(w http.ResponseWriter, r *http.Request, status int, reason string) (*Conn, error) { - err := HandshakeError{reason} - if u.Error != nil { - u.Error(w, r, status, err) - } else { - w.Header().Set("Sec-Websocket-Version", "13") - http.Error(w, http.StatusText(status), status) - } - return nil, err -} - -// checkSameOrigin returns true if the origin is not set or is equal to the request host. -func checkSameOrigin(r *http.Request) bool { - origin := r.Header["Origin"] - if len(origin) == 0 { - return true - } - u, err := url.Parse(origin[0]) - if err != nil { - return false - } - return equalASCIIFold(u.Host, r.Host) -} - -func (u *Upgrader) selectSubprotocol(r *http.Request, responseHeader http.Header) string { - if u.Subprotocols != nil { - clientProtocols := Subprotocols(r) - for _, serverProtocol := range u.Subprotocols { - for _, clientProtocol := range clientProtocols { - if clientProtocol == serverProtocol { - return clientProtocol - } - } - } - } else if responseHeader != nil { - return responseHeader.Get("Sec-Websocket-Protocol") - } - return "" -} - -// Upgrade upgrades the HTTP server connection to the WebSocket protocol. -// -// The responseHeader is included in the response to the client's upgrade -// request. Use the responseHeader to specify cookies (Set-Cookie) and the -// application negotiated subprotocol (Sec-WebSocket-Protocol). -// -// If the upgrade fails, then Upgrade replies to the client with an HTTP error -// response. -func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) { - const badHandshake = "websocket: the client is not using the websocket protocol: " - - if !tokenListContainsValue(r.Header, "Connection", "upgrade") { - return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'upgrade' token not found in 'Connection' header") - } - - if !tokenListContainsValue(r.Header, "Upgrade", "websocket") { - return u.returnError(w, r, http.StatusBadRequest, badHandshake+"'websocket' token not found in 'Upgrade' header") - } - - if r.Method != "GET" { - return u.returnError(w, r, http.StatusMethodNotAllowed, badHandshake+"request method is not GET") - } - - if !tokenListContainsValue(r.Header, "Sec-Websocket-Version", "13") { - return u.returnError(w, r, http.StatusBadRequest, "websocket: unsupported version: 13 not found in 'Sec-Websocket-Version' header") - } - - if _, ok := responseHeader["Sec-Websocket-Extensions"]; ok { - return u.returnError(w, r, http.StatusInternalServerError, "websocket: application specific 'Sec-WebSocket-Extensions' headers are unsupported") - } - - checkOrigin := u.CheckOrigin - if checkOrigin == nil { - checkOrigin = checkSameOrigin - } - if !checkOrigin(r) { - return u.returnError(w, r, http.StatusForbidden, "websocket: request origin not allowed by Upgrader.CheckOrigin") - } - - challengeKey := r.Header.Get("Sec-Websocket-Key") - if challengeKey == "" { - return u.returnError(w, r, http.StatusBadRequest, "websocket: not a websocket handshake: 'Sec-WebSocket-Key' header is missing or blank") - } - - subprotocol := u.selectSubprotocol(r, responseHeader) - - // Negotiate PMCE - var compress bool - if u.EnableCompression { - for _, ext := range parseExtensions(r.Header) { - if ext[""] != "permessage-deflate" { - continue - } - compress = true - break - } - } - - h, ok := w.(http.Hijacker) - if !ok { - return u.returnError(w, r, http.StatusInternalServerError, "websocket: response does not implement http.Hijacker") - } - var brw *bufio.ReadWriter - netConn, brw, err := h.Hijack() - if err != nil { - return u.returnError(w, r, http.StatusInternalServerError, err.Error()) - } - - if brw.Reader.Buffered() > 0 { - netConn.Close() - return nil, errors.New("websocket: client sent data before handshake is complete") - } - - var br *bufio.Reader - if u.ReadBufferSize == 0 && bufioReaderSize(netConn, brw.Reader) > 256 { - // Reuse hijacked buffered reader as connection reader. - br = brw.Reader - } - - buf := bufioWriterBuffer(netConn, brw.Writer) - - var writeBuf []byte - if u.WriteBufferPool == nil && u.WriteBufferSize == 0 && len(buf) >= maxFrameHeaderSize+256 { - // Reuse hijacked write buffer as connection buffer. - writeBuf = buf - } - - c := newConn(netConn, true, u.ReadBufferSize, u.WriteBufferSize, u.WriteBufferPool, br, writeBuf) - c.subprotocol = subprotocol - - if compress { - c.newCompressionWriter = compressNoContextTakeover - c.newDecompressionReader = decompressNoContextTakeover - } - - // Use larger of hijacked buffer and connection write buffer for header. - p := buf - if len(c.writeBuf) > len(p) { - p = c.writeBuf - } - p = p[:0] - - p = append(p, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "...) - p = append(p, computeAcceptKey(challengeKey)...) - p = append(p, "\r\n"...) - if c.subprotocol != "" { - p = append(p, "Sec-WebSocket-Protocol: "...) - p = append(p, c.subprotocol...) - p = append(p, "\r\n"...) - } - if compress { - p = append(p, "Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n"...) - } - for k, vs := range responseHeader { - if k == "Sec-Websocket-Protocol" { - continue - } - for _, v := range vs { - p = append(p, k...) - p = append(p, ": "...) - for i := 0; i < len(v); i++ { - b := v[i] - if b <= 31 { - // prevent response splitting. - b = ' ' - } - p = append(p, b) - } - p = append(p, "\r\n"...) - } - } - p = append(p, "\r\n"...) - - // Clear deadlines set by HTTP server. - netConn.SetDeadline(time.Time{}) - - if u.HandshakeTimeout > 0 { - netConn.SetWriteDeadline(time.Now().Add(u.HandshakeTimeout)) - } - if _, err = netConn.Write(p); err != nil { - netConn.Close() - return nil, err - } - if u.HandshakeTimeout > 0 { - netConn.SetWriteDeadline(time.Time{}) - } - - return c, nil -} - -// Upgrade upgrades the HTTP server connection to the WebSocket protocol. -// -// Deprecated: Use websocket.Upgrader instead. -// -// Upgrade does not perform origin checking. The application is responsible for -// checking the Origin header before calling Upgrade. An example implementation -// of the same origin policy check is: -// -// if req.Header.Get("Origin") != "http://"+req.Host { -// http.Error(w, "Origin not allowed", http.StatusForbidden) -// return -// } -// -// If the endpoint supports subprotocols, then the application is responsible -// for negotiating the protocol used on the connection. Use the Subprotocols() -// function to get the subprotocols requested by the client. Use the -// Sec-Websocket-Protocol response header to specify the subprotocol selected -// by the application. -// -// The responseHeader is included in the response to the client's upgrade -// request. Use the responseHeader to specify cookies (Set-Cookie) and the -// negotiated subprotocol (Sec-Websocket-Protocol). -// -// The connection buffers IO to the underlying network connection. The -// readBufSize and writeBufSize parameters specify the size of the buffers to -// use. Messages can be larger than the buffers. -// -// If the request is not a valid WebSocket handshake, then Upgrade returns an -// error of type HandshakeError. Applications should handle this error by -// replying to the client with an HTTP error response. -func Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header, readBufSize, writeBufSize int) (*Conn, error) { - u := Upgrader{ReadBufferSize: readBufSize, WriteBufferSize: writeBufSize} - u.Error = func(w http.ResponseWriter, r *http.Request, status int, reason error) { - // don't return errors to maintain backwards compatibility - } - u.CheckOrigin = func(r *http.Request) bool { - // allow all connections by default - return true - } - return u.Upgrade(w, r, responseHeader) -} - -// Subprotocols returns the subprotocols requested by the client in the -// Sec-Websocket-Protocol header. -func Subprotocols(r *http.Request) []string { - h := strings.TrimSpace(r.Header.Get("Sec-Websocket-Protocol")) - if h == "" { - return nil - } - protocols := strings.Split(h, ",") - for i := range protocols { - protocols[i] = strings.TrimSpace(protocols[i]) - } - return protocols -} - -// IsWebSocketUpgrade returns true if the client requested upgrade to the -// WebSocket protocol. -func IsWebSocketUpgrade(r *http.Request) bool { - return tokenListContainsValue(r.Header, "Connection", "upgrade") && - tokenListContainsValue(r.Header, "Upgrade", "websocket") -} - -// bufioReaderSize size returns the size of a bufio.Reader. -func bufioReaderSize(originalReader io.Reader, br *bufio.Reader) int { - // This code assumes that peek on a reset reader returns - // bufio.Reader.buf[:0]. - // TODO: Use bufio.Reader.Size() after Go 1.10 - br.Reset(originalReader) - if p, err := br.Peek(0); err == nil { - return cap(p) - } - return 0 -} - -// writeHook is an io.Writer that records the last slice passed to it vio -// io.Writer.Write. -type writeHook struct { - p []byte -} - -func (wh *writeHook) Write(p []byte) (int, error) { - wh.p = p - return len(p), nil -} - -// bufioWriterBuffer grabs the buffer from a bufio.Writer. -func bufioWriterBuffer(originalWriter io.Writer, bw *bufio.Writer) []byte { - // This code assumes that bufio.Writer.buf[:1] is passed to the - // bufio.Writer's underlying writer. - var wh writeHook - bw.Reset(&wh) - bw.WriteByte(0) - bw.Flush() - - bw.Reset(originalWriter) - - return wh.p[:cap(wh.p)] -} diff --git a/vendor/github.com/gorilla/websocket/trace.go b/vendor/github.com/gorilla/websocket/trace.go deleted file mode 100644 index 834f122a0..000000000 --- a/vendor/github.com/gorilla/websocket/trace.go +++ /dev/null @@ -1,19 +0,0 @@ -// +build go1.8 - -package websocket - -import ( - "crypto/tls" - "net/http/httptrace" -) - -func doHandshakeWithTrace(trace *httptrace.ClientTrace, tlsConn *tls.Conn, cfg *tls.Config) error { - if trace.TLSHandshakeStart != nil { - trace.TLSHandshakeStart() - } - err := doHandshake(tlsConn, cfg) - if trace.TLSHandshakeDone != nil { - trace.TLSHandshakeDone(tlsConn.ConnectionState(), err) - } - return err -} diff --git a/vendor/github.com/gorilla/websocket/trace_17.go b/vendor/github.com/gorilla/websocket/trace_17.go deleted file mode 100644 index 77d05a0b5..000000000 --- a/vendor/github.com/gorilla/websocket/trace_17.go +++ /dev/null @@ -1,12 +0,0 @@ -// +build !go1.8 - -package websocket - -import ( - "crypto/tls" - "net/http/httptrace" -) - -func doHandshakeWithTrace(trace *httptrace.ClientTrace, tlsConn *tls.Conn, cfg *tls.Config) error { - return doHandshake(tlsConn, cfg) -} diff --git a/vendor/github.com/gorilla/websocket/util.go b/vendor/github.com/gorilla/websocket/util.go deleted file mode 100644 index 7bf2f66c6..000000000 --- a/vendor/github.com/gorilla/websocket/util.go +++ /dev/null @@ -1,283 +0,0 @@ -// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package websocket - -import ( - "crypto/rand" - "crypto/sha1" - "encoding/base64" - "io" - "net/http" - "strings" - "unicode/utf8" -) - -var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11") - -func computeAcceptKey(challengeKey string) string { - h := sha1.New() - h.Write([]byte(challengeKey)) - h.Write(keyGUID) - return base64.StdEncoding.EncodeToString(h.Sum(nil)) -} - -func generateChallengeKey() (string, error) { - p := make([]byte, 16) - if _, err := io.ReadFull(rand.Reader, p); err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(p), nil -} - -// Token octets per RFC 2616. -var isTokenOctet = [256]bool{ - '!': true, - '#': true, - '$': true, - '%': true, - '&': true, - '\'': true, - '*': true, - '+': true, - '-': true, - '.': true, - '0': true, - '1': true, - '2': true, - '3': true, - '4': true, - '5': true, - '6': true, - '7': true, - '8': true, - '9': true, - 'A': true, - 'B': true, - 'C': true, - 'D': true, - 'E': true, - 'F': true, - 'G': true, - 'H': true, - 'I': true, - 'J': true, - 'K': true, - 'L': true, - 'M': true, - 'N': true, - 'O': true, - 'P': true, - 'Q': true, - 'R': true, - 'S': true, - 'T': true, - 'U': true, - 'W': true, - 'V': true, - 'X': true, - 'Y': true, - 'Z': true, - '^': true, - '_': true, - '`': true, - 'a': true, - 'b': true, - 'c': true, - 'd': true, - 'e': true, - 'f': true, - 'g': true, - 'h': true, - 'i': true, - 'j': true, - 'k': true, - 'l': true, - 'm': true, - 'n': true, - 'o': true, - 'p': true, - 'q': true, - 'r': true, - 's': true, - 't': true, - 'u': true, - 'v': true, - 'w': true, - 'x': true, - 'y': true, - 'z': true, - '|': true, - '~': true, -} - -// skipSpace returns a slice of the string s with all leading RFC 2616 linear -// whitespace removed. -func skipSpace(s string) (rest string) { - i := 0 - for ; i < len(s); i++ { - if b := s[i]; b != ' ' && b != '\t' { - break - } - } - return s[i:] -} - -// nextToken returns the leading RFC 2616 token of s and the string following -// the token. -func nextToken(s string) (token, rest string) { - i := 0 - for ; i < len(s); i++ { - if !isTokenOctet[s[i]] { - break - } - } - return s[:i], s[i:] -} - -// nextTokenOrQuoted returns the leading token or quoted string per RFC 2616 -// and the string following the token or quoted string. -func nextTokenOrQuoted(s string) (value string, rest string) { - if !strings.HasPrefix(s, "\"") { - return nextToken(s) - } - s = s[1:] - for i := 0; i < len(s); i++ { - switch s[i] { - case '"': - return s[:i], s[i+1:] - case '\\': - p := make([]byte, len(s)-1) - j := copy(p, s[:i]) - escape := true - for i = i + 1; i < len(s); i++ { - b := s[i] - switch { - case escape: - escape = false - p[j] = b - j++ - case b == '\\': - escape = true - case b == '"': - return string(p[:j]), s[i+1:] - default: - p[j] = b - j++ - } - } - return "", "" - } - } - return "", "" -} - -// equalASCIIFold returns true if s is equal to t with ASCII case folding as -// defined in RFC 4790. -func equalASCIIFold(s, t string) bool { - for s != "" && t != "" { - sr, size := utf8.DecodeRuneInString(s) - s = s[size:] - tr, size := utf8.DecodeRuneInString(t) - t = t[size:] - if sr == tr { - continue - } - if 'A' <= sr && sr <= 'Z' { - sr = sr + 'a' - 'A' - } - if 'A' <= tr && tr <= 'Z' { - tr = tr + 'a' - 'A' - } - if sr != tr { - return false - } - } - return s == t -} - -// tokenListContainsValue returns true if the 1#token header with the given -// name contains a token equal to value with ASCII case folding. -func tokenListContainsValue(header http.Header, name string, value string) bool { -headers: - for _, s := range header[name] { - for { - var t string - t, s = nextToken(skipSpace(s)) - if t == "" { - continue headers - } - s = skipSpace(s) - if s != "" && s[0] != ',' { - continue headers - } - if equalASCIIFold(t, value) { - return true - } - if s == "" { - continue headers - } - s = s[1:] - } - } - return false -} - -// parseExtensions parses WebSocket extensions from a header. -func parseExtensions(header http.Header) []map[string]string { - // From RFC 6455: - // - // Sec-WebSocket-Extensions = extension-list - // extension-list = 1#extension - // extension = extension-token *( ";" extension-param ) - // extension-token = registered-token - // registered-token = token - // extension-param = token [ "=" (token | quoted-string) ] - // ;When using the quoted-string syntax variant, the value - // ;after quoted-string unescaping MUST conform to the - // ;'token' ABNF. - - var result []map[string]string -headers: - for _, s := range header["Sec-Websocket-Extensions"] { - for { - var t string - t, s = nextToken(skipSpace(s)) - if t == "" { - continue headers - } - ext := map[string]string{"": t} - for { - s = skipSpace(s) - if !strings.HasPrefix(s, ";") { - break - } - var k string - k, s = nextToken(skipSpace(s[1:])) - if k == "" { - continue headers - } - s = skipSpace(s) - var v string - if strings.HasPrefix(s, "=") { - v, s = nextTokenOrQuoted(skipSpace(s[1:])) - s = skipSpace(s) - } - if s != "" && s[0] != ',' && s[0] != ';' { - continue headers - } - ext[k] = v - } - if s != "" && s[0] != ',' { - continue headers - } - result = append(result, ext) - if s == "" { - continue headers - } - s = s[1:] - } - } - return result -} diff --git a/vendor/github.com/gorilla/websocket/x_net_proxy.go b/vendor/github.com/gorilla/websocket/x_net_proxy.go deleted file mode 100644 index 2e668f6b8..000000000 --- a/vendor/github.com/gorilla/websocket/x_net_proxy.go +++ /dev/null @@ -1,473 +0,0 @@ -// Code generated by golang.org/x/tools/cmd/bundle. DO NOT EDIT. -//go:generate bundle -o x_net_proxy.go golang.org/x/net/proxy - -// Package proxy provides support for a variety of protocols to proxy network -// data. -// - -package websocket - -import ( - "errors" - "io" - "net" - "net/url" - "os" - "strconv" - "strings" - "sync" -) - -type proxy_direct struct{} - -// Direct is a direct proxy: one that makes network connections directly. -var proxy_Direct = proxy_direct{} - -func (proxy_direct) Dial(network, addr string) (net.Conn, error) { - return net.Dial(network, addr) -} - -// A PerHost directs connections to a default Dialer unless the host name -// requested matches one of a number of exceptions. -type proxy_PerHost struct { - def, bypass proxy_Dialer - - bypassNetworks []*net.IPNet - bypassIPs []net.IP - bypassZones []string - bypassHosts []string -} - -// NewPerHost returns a PerHost Dialer that directs connections to either -// defaultDialer or bypass, depending on whether the connection matches one of -// the configured rules. -func proxy_NewPerHost(defaultDialer, bypass proxy_Dialer) *proxy_PerHost { - return &proxy_PerHost{ - def: defaultDialer, - bypass: bypass, - } -} - -// Dial connects to the address addr on the given network through either -// defaultDialer or bypass. -func (p *proxy_PerHost) Dial(network, addr string) (c net.Conn, err error) { - host, _, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - - return p.dialerForRequest(host).Dial(network, addr) -} - -func (p *proxy_PerHost) dialerForRequest(host string) proxy_Dialer { - if ip := net.ParseIP(host); ip != nil { - for _, net := range p.bypassNetworks { - if net.Contains(ip) { - return p.bypass - } - } - for _, bypassIP := range p.bypassIPs { - if bypassIP.Equal(ip) { - return p.bypass - } - } - return p.def - } - - for _, zone := range p.bypassZones { - if strings.HasSuffix(host, zone) { - return p.bypass - } - if host == zone[1:] { - // For a zone ".example.com", we match "example.com" - // too. - return p.bypass - } - } - for _, bypassHost := range p.bypassHosts { - if bypassHost == host { - return p.bypass - } - } - return p.def -} - -// AddFromString parses a string that contains comma-separated values -// specifying hosts that should use the bypass proxy. Each value is either an -// IP address, a CIDR range, a zone (*.example.com) or a host name -// (localhost). A best effort is made to parse the string and errors are -// ignored. -func (p *proxy_PerHost) AddFromString(s string) { - hosts := strings.Split(s, ",") - for _, host := range hosts { - host = strings.TrimSpace(host) - if len(host) == 0 { - continue - } - if strings.Contains(host, "/") { - // We assume that it's a CIDR address like 127.0.0.0/8 - if _, net, err := net.ParseCIDR(host); err == nil { - p.AddNetwork(net) - } - continue - } - if ip := net.ParseIP(host); ip != nil { - p.AddIP(ip) - continue - } - if strings.HasPrefix(host, "*.") { - p.AddZone(host[1:]) - continue - } - p.AddHost(host) - } -} - -// AddIP specifies an IP address that will use the bypass proxy. Note that -// this will only take effect if a literal IP address is dialed. A connection -// to a named host will never match an IP. -func (p *proxy_PerHost) AddIP(ip net.IP) { - p.bypassIPs = append(p.bypassIPs, ip) -} - -// AddNetwork specifies an IP range that will use the bypass proxy. Note that -// this will only take effect if a literal IP address is dialed. A connection -// to a named host will never match. -func (p *proxy_PerHost) AddNetwork(net *net.IPNet) { - p.bypassNetworks = append(p.bypassNetworks, net) -} - -// AddZone specifies a DNS suffix that will use the bypass proxy. A zone of -// "example.com" matches "example.com" and all of its subdomains. -func (p *proxy_PerHost) AddZone(zone string) { - if strings.HasSuffix(zone, ".") { - zone = zone[:len(zone)-1] - } - if !strings.HasPrefix(zone, ".") { - zone = "." + zone - } - p.bypassZones = append(p.bypassZones, zone) -} - -// AddHost specifies a host name that will use the bypass proxy. -func (p *proxy_PerHost) AddHost(host string) { - if strings.HasSuffix(host, ".") { - host = host[:len(host)-1] - } - p.bypassHosts = append(p.bypassHosts, host) -} - -// A Dialer is a means to establish a connection. -type proxy_Dialer interface { - // Dial connects to the given address via the proxy. - Dial(network, addr string) (c net.Conn, err error) -} - -// Auth contains authentication parameters that specific Dialers may require. -type proxy_Auth struct { - User, Password string -} - -// FromEnvironment returns the dialer specified by the proxy related variables in -// the environment. -func proxy_FromEnvironment() proxy_Dialer { - allProxy := proxy_allProxyEnv.Get() - if len(allProxy) == 0 { - return proxy_Direct - } - - proxyURL, err := url.Parse(allProxy) - if err != nil { - return proxy_Direct - } - proxy, err := proxy_FromURL(proxyURL, proxy_Direct) - if err != nil { - return proxy_Direct - } - - noProxy := proxy_noProxyEnv.Get() - if len(noProxy) == 0 { - return proxy - } - - perHost := proxy_NewPerHost(proxy, proxy_Direct) - perHost.AddFromString(noProxy) - return perHost -} - -// proxySchemes is a map from URL schemes to a function that creates a Dialer -// from a URL with such a scheme. -var proxy_proxySchemes map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error) - -// RegisterDialerType takes a URL scheme and a function to generate Dialers from -// a URL with that scheme and a forwarding Dialer. Registered schemes are used -// by FromURL. -func proxy_RegisterDialerType(scheme string, f func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) { - if proxy_proxySchemes == nil { - proxy_proxySchemes = make(map[string]func(*url.URL, proxy_Dialer) (proxy_Dialer, error)) - } - proxy_proxySchemes[scheme] = f -} - -// FromURL returns a Dialer given a URL specification and an underlying -// Dialer for it to make network requests. -func proxy_FromURL(u *url.URL, forward proxy_Dialer) (proxy_Dialer, error) { - var auth *proxy_Auth - if u.User != nil { - auth = new(proxy_Auth) - auth.User = u.User.Username() - if p, ok := u.User.Password(); ok { - auth.Password = p - } - } - - switch u.Scheme { - case "socks5": - return proxy_SOCKS5("tcp", u.Host, auth, forward) - } - - // If the scheme doesn't match any of the built-in schemes, see if it - // was registered by another package. - if proxy_proxySchemes != nil { - if f, ok := proxy_proxySchemes[u.Scheme]; ok { - return f(u, forward) - } - } - - return nil, errors.New("proxy: unknown scheme: " + u.Scheme) -} - -var ( - proxy_allProxyEnv = &proxy_envOnce{ - names: []string{"ALL_PROXY", "all_proxy"}, - } - proxy_noProxyEnv = &proxy_envOnce{ - names: []string{"NO_PROXY", "no_proxy"}, - } -) - -// envOnce looks up an environment variable (optionally by multiple -// names) once. It mitigates expensive lookups on some platforms -// (e.g. Windows). -// (Borrowed from net/http/transport.go) -type proxy_envOnce struct { - names []string - once sync.Once - val string -} - -func (e *proxy_envOnce) Get() string { - e.once.Do(e.init) - return e.val -} - -func (e *proxy_envOnce) init() { - for _, n := range e.names { - e.val = os.Getenv(n) - if e.val != "" { - return - } - } -} - -// SOCKS5 returns a Dialer that makes SOCKSv5 connections to the given address -// with an optional username and password. See RFC 1928 and RFC 1929. -func proxy_SOCKS5(network, addr string, auth *proxy_Auth, forward proxy_Dialer) (proxy_Dialer, error) { - s := &proxy_socks5{ - network: network, - addr: addr, - forward: forward, - } - if auth != nil { - s.user = auth.User - s.password = auth.Password - } - - return s, nil -} - -type proxy_socks5 struct { - user, password string - network, addr string - forward proxy_Dialer -} - -const proxy_socks5Version = 5 - -const ( - proxy_socks5AuthNone = 0 - proxy_socks5AuthPassword = 2 -) - -const proxy_socks5Connect = 1 - -const ( - proxy_socks5IP4 = 1 - proxy_socks5Domain = 3 - proxy_socks5IP6 = 4 -) - -var proxy_socks5Errors = []string{ - "", - "general failure", - "connection forbidden", - "network unreachable", - "host unreachable", - "connection refused", - "TTL expired", - "command not supported", - "address type not supported", -} - -// Dial connects to the address addr on the given network via the SOCKS5 proxy. -func (s *proxy_socks5) Dial(network, addr string) (net.Conn, error) { - switch network { - case "tcp", "tcp6", "tcp4": - default: - return nil, errors.New("proxy: no support for SOCKS5 proxy connections of type " + network) - } - - conn, err := s.forward.Dial(s.network, s.addr) - if err != nil { - return nil, err - } - if err := s.connect(conn, addr); err != nil { - conn.Close() - return nil, err - } - return conn, nil -} - -// connect takes an existing connection to a socks5 proxy server, -// and commands the server to extend that connection to target, -// which must be a canonical address with a host and port. -func (s *proxy_socks5) connect(conn net.Conn, target string) error { - host, portStr, err := net.SplitHostPort(target) - if err != nil { - return err - } - - port, err := strconv.Atoi(portStr) - if err != nil { - return errors.New("proxy: failed to parse port number: " + portStr) - } - if port < 1 || port > 0xffff { - return errors.New("proxy: port number out of range: " + portStr) - } - - // the size here is just an estimate - buf := make([]byte, 0, 6+len(host)) - - buf = append(buf, proxy_socks5Version) - if len(s.user) > 0 && len(s.user) < 256 && len(s.password) < 256 { - buf = append(buf, 2 /* num auth methods */, proxy_socks5AuthNone, proxy_socks5AuthPassword) - } else { - buf = append(buf, 1 /* num auth methods */, proxy_socks5AuthNone) - } - - if _, err := conn.Write(buf); err != nil { - return errors.New("proxy: failed to write greeting to SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return errors.New("proxy: failed to read greeting from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - if buf[0] != 5 { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " has unexpected version " + strconv.Itoa(int(buf[0]))) - } - if buf[1] == 0xff { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " requires authentication") - } - - // See RFC 1929 - if buf[1] == proxy_socks5AuthPassword { - buf = buf[:0] - buf = append(buf, 1 /* password protocol version */) - buf = append(buf, uint8(len(s.user))) - buf = append(buf, s.user...) - buf = append(buf, uint8(len(s.password))) - buf = append(buf, s.password...) - - if _, err := conn.Write(buf); err != nil { - return errors.New("proxy: failed to write authentication request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return errors.New("proxy: failed to read authentication reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if buf[1] != 0 { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " rejected username/password") - } - } - - buf = buf[:0] - buf = append(buf, proxy_socks5Version, proxy_socks5Connect, 0 /* reserved */) - - if ip := net.ParseIP(host); ip != nil { - if ip4 := ip.To4(); ip4 != nil { - buf = append(buf, proxy_socks5IP4) - ip = ip4 - } else { - buf = append(buf, proxy_socks5IP6) - } - buf = append(buf, ip...) - } else { - if len(host) > 255 { - return errors.New("proxy: destination host name too long: " + host) - } - buf = append(buf, proxy_socks5Domain) - buf = append(buf, byte(len(host))) - buf = append(buf, host...) - } - buf = append(buf, byte(port>>8), byte(port)) - - if _, err := conn.Write(buf); err != nil { - return errors.New("proxy: failed to write connect request to SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - if _, err := io.ReadFull(conn, buf[:4]); err != nil { - return errors.New("proxy: failed to read connect reply from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - failure := "unknown error" - if int(buf[1]) < len(proxy_socks5Errors) { - failure = proxy_socks5Errors[buf[1]] - } - - if len(failure) > 0 { - return errors.New("proxy: SOCKS5 proxy at " + s.addr + " failed to connect: " + failure) - } - - bytesToDiscard := 0 - switch buf[3] { - case proxy_socks5IP4: - bytesToDiscard = net.IPv4len - case proxy_socks5IP6: - bytesToDiscard = net.IPv6len - case proxy_socks5Domain: - _, err := io.ReadFull(conn, buf[:1]) - if err != nil { - return errors.New("proxy: failed to read domain length from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - bytesToDiscard = int(buf[0]) - default: - return errors.New("proxy: got unknown address type " + strconv.Itoa(int(buf[3])) + " from SOCKS5 proxy at " + s.addr) - } - - if cap(buf) < bytesToDiscard { - buf = make([]byte, bytesToDiscard) - } else { - buf = buf[:bytesToDiscard] - } - if _, err := io.ReadFull(conn, buf); err != nil { - return errors.New("proxy: failed to read address from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - // Also need to discard the port number - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return errors.New("proxy: failed to read port from SOCKS5 proxy at " + s.addr + ": " + err.Error()) - } - - return nil -} diff --git a/vendor/github.com/pmezard/go-difflib/LICENSE b/vendor/github.com/pmezard/go-difflib/LICENSE deleted file mode 100644 index c67dad612..000000000 --- a/vendor/github.com/pmezard/go-difflib/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2013, Patrick Mezard -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright -notice, this list of conditions and the following disclaimer in the -documentation and/or other materials provided with the distribution. - The names of its contributors may not be used to endorse or promote -products derived from this software without specific prior written -permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pmezard/go-difflib/difflib/difflib.go b/vendor/github.com/pmezard/go-difflib/difflib/difflib.go deleted file mode 100644 index 003e99fad..000000000 --- a/vendor/github.com/pmezard/go-difflib/difflib/difflib.go +++ /dev/null @@ -1,772 +0,0 @@ -// Package difflib is a partial port of Python difflib module. -// -// It provides tools to compare sequences of strings and generate textual diffs. -// -// The following class and functions have been ported: -// -// - SequenceMatcher -// -// - unified_diff -// -// - context_diff -// -// Getting unified diffs was the main goal of the port. Keep in mind this code -// is mostly suitable to output text differences in a human friendly way, there -// are no guarantees generated diffs are consumable by patch(1). -package difflib - -import ( - "bufio" - "bytes" - "fmt" - "io" - "strings" -) - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func calculateRatio(matches, length int) float64 { - if length > 0 { - return 2.0 * float64(matches) / float64(length) - } - return 1.0 -} - -type Match struct { - A int - B int - Size int -} - -type OpCode struct { - Tag byte - I1 int - I2 int - J1 int - J2 int -} - -// SequenceMatcher compares sequence of strings. The basic -// algorithm predates, and is a little fancier than, an algorithm -// published in the late 1980's by Ratcliff and Obershelp under the -// hyperbolic name "gestalt pattern matching". The basic idea is to find -// the longest contiguous matching subsequence that contains no "junk" -// elements (R-O doesn't address junk). The same idea is then applied -// recursively to the pieces of the sequences to the left and to the right -// of the matching subsequence. This does not yield minimal edit -// sequences, but does tend to yield matches that "look right" to people. -// -// SequenceMatcher tries to compute a "human-friendly diff" between two -// sequences. Unlike e.g. UNIX(tm) diff, the fundamental notion is the -// longest *contiguous* & junk-free matching subsequence. That's what -// catches peoples' eyes. The Windows(tm) windiff has another interesting -// notion, pairing up elements that appear uniquely in each sequence. -// That, and the method here, appear to yield more intuitive difference -// reports than does diff. This method appears to be the least vulnerable -// to synching up on blocks of "junk lines", though (like blank lines in -// ordinary text files, or maybe "

" lines in HTML files). That may be -// because this is the only method of the 3 that has a *concept* of -// "junk" . -// -// Timing: Basic R-O is cubic time worst case and quadratic time expected -// case. SequenceMatcher is quadratic time for the worst case and has -// expected-case behavior dependent in a complicated way on how many -// elements the sequences have in common; best case time is linear. -type SequenceMatcher struct { - a []string - b []string - b2j map[string][]int - IsJunk func(string) bool - autoJunk bool - bJunk map[string]struct{} - matchingBlocks []Match - fullBCount map[string]int - bPopular map[string]struct{} - opCodes []OpCode -} - -func NewMatcher(a, b []string) *SequenceMatcher { - m := SequenceMatcher{autoJunk: true} - m.SetSeqs(a, b) - return &m -} - -func NewMatcherWithJunk(a, b []string, autoJunk bool, - isJunk func(string) bool) *SequenceMatcher { - - m := SequenceMatcher{IsJunk: isJunk, autoJunk: autoJunk} - m.SetSeqs(a, b) - return &m -} - -// Set two sequences to be compared. -func (m *SequenceMatcher) SetSeqs(a, b []string) { - m.SetSeq1(a) - m.SetSeq2(b) -} - -// Set the first sequence to be compared. The second sequence to be compared is -// not changed. -// -// SequenceMatcher computes and caches detailed information about the second -// sequence, so if you want to compare one sequence S against many sequences, -// use .SetSeq2(s) once and call .SetSeq1(x) repeatedly for each of the other -// sequences. -// -// See also SetSeqs() and SetSeq2(). -func (m *SequenceMatcher) SetSeq1(a []string) { - if &a == &m.a { - return - } - m.a = a - m.matchingBlocks = nil - m.opCodes = nil -} - -// Set the second sequence to be compared. The first sequence to be compared is -// not changed. -func (m *SequenceMatcher) SetSeq2(b []string) { - if &b == &m.b { - return - } - m.b = b - m.matchingBlocks = nil - m.opCodes = nil - m.fullBCount = nil - m.chainB() -} - -func (m *SequenceMatcher) chainB() { - // Populate line -> index mapping - b2j := map[string][]int{} - for i, s := range m.b { - indices := b2j[s] - indices = append(indices, i) - b2j[s] = indices - } - - // Purge junk elements - m.bJunk = map[string]struct{}{} - if m.IsJunk != nil { - junk := m.bJunk - for s, _ := range b2j { - if m.IsJunk(s) { - junk[s] = struct{}{} - } - } - for s, _ := range junk { - delete(b2j, s) - } - } - - // Purge remaining popular elements - popular := map[string]struct{}{} - n := len(m.b) - if m.autoJunk && n >= 200 { - ntest := n/100 + 1 - for s, indices := range b2j { - if len(indices) > ntest { - popular[s] = struct{}{} - } - } - for s, _ := range popular { - delete(b2j, s) - } - } - m.bPopular = popular - m.b2j = b2j -} - -func (m *SequenceMatcher) isBJunk(s string) bool { - _, ok := m.bJunk[s] - return ok -} - -// Find longest matching block in a[alo:ahi] and b[blo:bhi]. -// -// If IsJunk is not defined: -// -// Return (i,j,k) such that a[i:i+k] is equal to b[j:j+k], where -// alo <= i <= i+k <= ahi -// blo <= j <= j+k <= bhi -// and for all (i',j',k') meeting those conditions, -// k >= k' -// i <= i' -// and if i == i', j <= j' -// -// In other words, of all maximal matching blocks, return one that -// starts earliest in a, and of all those maximal matching blocks that -// start earliest in a, return the one that starts earliest in b. -// -// If IsJunk is defined, first the longest matching block is -// determined as above, but with the additional restriction that no -// junk element appears in the block. Then that block is extended as -// far as possible by matching (only) junk elements on both sides. So -// the resulting block never matches on junk except as identical junk -// happens to be adjacent to an "interesting" match. -// -// If no blocks match, return (alo, blo, 0). -func (m *SequenceMatcher) findLongestMatch(alo, ahi, blo, bhi int) Match { - // CAUTION: stripping common prefix or suffix would be incorrect. - // E.g., - // ab - // acab - // Longest matching block is "ab", but if common prefix is - // stripped, it's "a" (tied with "b"). UNIX(tm) diff does so - // strip, so ends up claiming that ab is changed to acab by - // inserting "ca" in the middle. That's minimal but unintuitive: - // "it's obvious" that someone inserted "ac" at the front. - // Windiff ends up at the same place as diff, but by pairing up - // the unique 'b's and then matching the first two 'a's. - besti, bestj, bestsize := alo, blo, 0 - - // find longest junk-free match - // during an iteration of the loop, j2len[j] = length of longest - // junk-free match ending with a[i-1] and b[j] - j2len := map[int]int{} - for i := alo; i != ahi; i++ { - // look at all instances of a[i] in b; note that because - // b2j has no junk keys, the loop is skipped if a[i] is junk - newj2len := map[int]int{} - for _, j := range m.b2j[m.a[i]] { - // a[i] matches b[j] - if j < blo { - continue - } - if j >= bhi { - break - } - k := j2len[j-1] + 1 - newj2len[j] = k - if k > bestsize { - besti, bestj, bestsize = i-k+1, j-k+1, k - } - } - j2len = newj2len - } - - // Extend the best by non-junk elements on each end. In particular, - // "popular" non-junk elements aren't in b2j, which greatly speeds - // the inner loop above, but also means "the best" match so far - // doesn't contain any junk *or* popular non-junk elements. - for besti > alo && bestj > blo && !m.isBJunk(m.b[bestj-1]) && - m.a[besti-1] == m.b[bestj-1] { - besti, bestj, bestsize = besti-1, bestj-1, bestsize+1 - } - for besti+bestsize < ahi && bestj+bestsize < bhi && - !m.isBJunk(m.b[bestj+bestsize]) && - m.a[besti+bestsize] == m.b[bestj+bestsize] { - bestsize += 1 - } - - // Now that we have a wholly interesting match (albeit possibly - // empty!), we may as well suck up the matching junk on each - // side of it too. Can't think of a good reason not to, and it - // saves post-processing the (possibly considerable) expense of - // figuring out what to do with it. In the case of an empty - // interesting match, this is clearly the right thing to do, - // because no other kind of match is possible in the regions. - for besti > alo && bestj > blo && m.isBJunk(m.b[bestj-1]) && - m.a[besti-1] == m.b[bestj-1] { - besti, bestj, bestsize = besti-1, bestj-1, bestsize+1 - } - for besti+bestsize < ahi && bestj+bestsize < bhi && - m.isBJunk(m.b[bestj+bestsize]) && - m.a[besti+bestsize] == m.b[bestj+bestsize] { - bestsize += 1 - } - - return Match{A: besti, B: bestj, Size: bestsize} -} - -// Return list of triples describing matching subsequences. -// -// Each triple is of the form (i, j, n), and means that -// a[i:i+n] == b[j:j+n]. The triples are monotonically increasing in -// i and in j. It's also guaranteed that if (i, j, n) and (i', j', n') are -// adjacent triples in the list, and the second is not the last triple in the -// list, then i+n != i' or j+n != j'. IOW, adjacent triples never describe -// adjacent equal blocks. -// -// The last triple is a dummy, (len(a), len(b), 0), and is the only -// triple with n==0. -func (m *SequenceMatcher) GetMatchingBlocks() []Match { - if m.matchingBlocks != nil { - return m.matchingBlocks - } - - var matchBlocks func(alo, ahi, blo, bhi int, matched []Match) []Match - matchBlocks = func(alo, ahi, blo, bhi int, matched []Match) []Match { - match := m.findLongestMatch(alo, ahi, blo, bhi) - i, j, k := match.A, match.B, match.Size - if match.Size > 0 { - if alo < i && blo < j { - matched = matchBlocks(alo, i, blo, j, matched) - } - matched = append(matched, match) - if i+k < ahi && j+k < bhi { - matched = matchBlocks(i+k, ahi, j+k, bhi, matched) - } - } - return matched - } - matched := matchBlocks(0, len(m.a), 0, len(m.b), nil) - - // It's possible that we have adjacent equal blocks in the - // matching_blocks list now. - nonAdjacent := []Match{} - i1, j1, k1 := 0, 0, 0 - for _, b := range matched { - // Is this block adjacent to i1, j1, k1? - i2, j2, k2 := b.A, b.B, b.Size - if i1+k1 == i2 && j1+k1 == j2 { - // Yes, so collapse them -- this just increases the length of - // the first block by the length of the second, and the first - // block so lengthened remains the block to compare against. - k1 += k2 - } else { - // Not adjacent. Remember the first block (k1==0 means it's - // the dummy we started with), and make the second block the - // new block to compare against. - if k1 > 0 { - nonAdjacent = append(nonAdjacent, Match{i1, j1, k1}) - } - i1, j1, k1 = i2, j2, k2 - } - } - if k1 > 0 { - nonAdjacent = append(nonAdjacent, Match{i1, j1, k1}) - } - - nonAdjacent = append(nonAdjacent, Match{len(m.a), len(m.b), 0}) - m.matchingBlocks = nonAdjacent - return m.matchingBlocks -} - -// Return list of 5-tuples describing how to turn a into b. -// -// Each tuple is of the form (tag, i1, i2, j1, j2). The first tuple -// has i1 == j1 == 0, and remaining tuples have i1 == the i2 from the -// tuple preceding it, and likewise for j1 == the previous j2. -// -// The tags are characters, with these meanings: -// -// 'r' (replace): a[i1:i2] should be replaced by b[j1:j2] -// -// 'd' (delete): a[i1:i2] should be deleted, j1==j2 in this case. -// -// 'i' (insert): b[j1:j2] should be inserted at a[i1:i1], i1==i2 in this case. -// -// 'e' (equal): a[i1:i2] == b[j1:j2] -func (m *SequenceMatcher) GetOpCodes() []OpCode { - if m.opCodes != nil { - return m.opCodes - } - i, j := 0, 0 - matching := m.GetMatchingBlocks() - opCodes := make([]OpCode, 0, len(matching)) - for _, m := range matching { - // invariant: we've pumped out correct diffs to change - // a[:i] into b[:j], and the next matching block is - // a[ai:ai+size] == b[bj:bj+size]. So we need to pump - // out a diff to change a[i:ai] into b[j:bj], pump out - // the matching block, and move (i,j) beyond the match - ai, bj, size := m.A, m.B, m.Size - tag := byte(0) - if i < ai && j < bj { - tag = 'r' - } else if i < ai { - tag = 'd' - } else if j < bj { - tag = 'i' - } - if tag > 0 { - opCodes = append(opCodes, OpCode{tag, i, ai, j, bj}) - } - i, j = ai+size, bj+size - // the list of matching blocks is terminated by a - // sentinel with size 0 - if size > 0 { - opCodes = append(opCodes, OpCode{'e', ai, i, bj, j}) - } - } - m.opCodes = opCodes - return m.opCodes -} - -// Isolate change clusters by eliminating ranges with no changes. -// -// Return a generator of groups with up to n lines of context. -// Each group is in the same format as returned by GetOpCodes(). -func (m *SequenceMatcher) GetGroupedOpCodes(n int) [][]OpCode { - if n < 0 { - n = 3 - } - codes := m.GetOpCodes() - if len(codes) == 0 { - codes = []OpCode{OpCode{'e', 0, 1, 0, 1}} - } - // Fixup leading and trailing groups if they show no changes. - if codes[0].Tag == 'e' { - c := codes[0] - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - codes[0] = OpCode{c.Tag, max(i1, i2-n), i2, max(j1, j2-n), j2} - } - if codes[len(codes)-1].Tag == 'e' { - c := codes[len(codes)-1] - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - codes[len(codes)-1] = OpCode{c.Tag, i1, min(i2, i1+n), j1, min(j2, j1+n)} - } - nn := n + n - groups := [][]OpCode{} - group := []OpCode{} - for _, c := range codes { - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - // End the current group and start a new one whenever - // there is a large range with no changes. - if c.Tag == 'e' && i2-i1 > nn { - group = append(group, OpCode{c.Tag, i1, min(i2, i1+n), - j1, min(j2, j1+n)}) - groups = append(groups, group) - group = []OpCode{} - i1, j1 = max(i1, i2-n), max(j1, j2-n) - } - group = append(group, OpCode{c.Tag, i1, i2, j1, j2}) - } - if len(group) > 0 && !(len(group) == 1 && group[0].Tag == 'e') { - groups = append(groups, group) - } - return groups -} - -// Return a measure of the sequences' similarity (float in [0,1]). -// -// Where T is the total number of elements in both sequences, and -// M is the number of matches, this is 2.0*M / T. -// Note that this is 1 if the sequences are identical, and 0 if -// they have nothing in common. -// -// .Ratio() is expensive to compute if you haven't already computed -// .GetMatchingBlocks() or .GetOpCodes(), in which case you may -// want to try .QuickRatio() or .RealQuickRation() first to get an -// upper bound. -func (m *SequenceMatcher) Ratio() float64 { - matches := 0 - for _, m := range m.GetMatchingBlocks() { - matches += m.Size - } - return calculateRatio(matches, len(m.a)+len(m.b)) -} - -// Return an upper bound on ratio() relatively quickly. -// -// This isn't defined beyond that it is an upper bound on .Ratio(), and -// is faster to compute. -func (m *SequenceMatcher) QuickRatio() float64 { - // viewing a and b as multisets, set matches to the cardinality - // of their intersection; this counts the number of matches - // without regard to order, so is clearly an upper bound - if m.fullBCount == nil { - m.fullBCount = map[string]int{} - for _, s := range m.b { - m.fullBCount[s] = m.fullBCount[s] + 1 - } - } - - // avail[x] is the number of times x appears in 'b' less the - // number of times we've seen it in 'a' so far ... kinda - avail := map[string]int{} - matches := 0 - for _, s := range m.a { - n, ok := avail[s] - if !ok { - n = m.fullBCount[s] - } - avail[s] = n - 1 - if n > 0 { - matches += 1 - } - } - return calculateRatio(matches, len(m.a)+len(m.b)) -} - -// Return an upper bound on ratio() very quickly. -// -// This isn't defined beyond that it is an upper bound on .Ratio(), and -// is faster to compute than either .Ratio() or .QuickRatio(). -func (m *SequenceMatcher) RealQuickRatio() float64 { - la, lb := len(m.a), len(m.b) - return calculateRatio(min(la, lb), la+lb) -} - -// Convert range to the "ed" format -func formatRangeUnified(start, stop int) string { - // Per the diff spec at http://www.unix.org/single_unix_specification/ - beginning := start + 1 // lines start numbering with one - length := stop - start - if length == 1 { - return fmt.Sprintf("%d", beginning) - } - if length == 0 { - beginning -= 1 // empty ranges begin at line just before the range - } - return fmt.Sprintf("%d,%d", beginning, length) -} - -// Unified diff parameters -type UnifiedDiff struct { - A []string // First sequence lines - FromFile string // First file name - FromDate string // First file time - B []string // Second sequence lines - ToFile string // Second file name - ToDate string // Second file time - Eol string // Headers end of line, defaults to LF - Context int // Number of context lines -} - -// Compare two sequences of lines; generate the delta as a unified diff. -// -// Unified diffs are a compact way of showing line changes and a few -// lines of context. The number of context lines is set by 'n' which -// defaults to three. -// -// By default, the diff control lines (those with ---, +++, or @@) are -// created with a trailing newline. This is helpful so that inputs -// created from file.readlines() result in diffs that are suitable for -// file.writelines() since both the inputs and outputs have trailing -// newlines. -// -// For inputs that do not have trailing newlines, set the lineterm -// argument to "" so that the output will be uniformly newline free. -// -// The unidiff format normally has a header for filenames and modification -// times. Any or all of these may be specified using strings for -// 'fromfile', 'tofile', 'fromfiledate', and 'tofiledate'. -// The modification times are normally expressed in the ISO 8601 format. -func WriteUnifiedDiff(writer io.Writer, diff UnifiedDiff) error { - buf := bufio.NewWriter(writer) - defer buf.Flush() - wf := func(format string, args ...interface{}) error { - _, err := buf.WriteString(fmt.Sprintf(format, args...)) - return err - } - ws := func(s string) error { - _, err := buf.WriteString(s) - return err - } - - if len(diff.Eol) == 0 { - diff.Eol = "\n" - } - - started := false - m := NewMatcher(diff.A, diff.B) - for _, g := range m.GetGroupedOpCodes(diff.Context) { - if !started { - started = true - fromDate := "" - if len(diff.FromDate) > 0 { - fromDate = "\t" + diff.FromDate - } - toDate := "" - if len(diff.ToDate) > 0 { - toDate = "\t" + diff.ToDate - } - if diff.FromFile != "" || diff.ToFile != "" { - err := wf("--- %s%s%s", diff.FromFile, fromDate, diff.Eol) - if err != nil { - return err - } - err = wf("+++ %s%s%s", diff.ToFile, toDate, diff.Eol) - if err != nil { - return err - } - } - } - first, last := g[0], g[len(g)-1] - range1 := formatRangeUnified(first.I1, last.I2) - range2 := formatRangeUnified(first.J1, last.J2) - if err := wf("@@ -%s +%s @@%s", range1, range2, diff.Eol); err != nil { - return err - } - for _, c := range g { - i1, i2, j1, j2 := c.I1, c.I2, c.J1, c.J2 - if c.Tag == 'e' { - for _, line := range diff.A[i1:i2] { - if err := ws(" " + line); err != nil { - return err - } - } - continue - } - if c.Tag == 'r' || c.Tag == 'd' { - for _, line := range diff.A[i1:i2] { - if err := ws("-" + line); err != nil { - return err - } - } - } - if c.Tag == 'r' || c.Tag == 'i' { - for _, line := range diff.B[j1:j2] { - if err := ws("+" + line); err != nil { - return err - } - } - } - } - } - return nil -} - -// Like WriteUnifiedDiff but returns the diff a string. -func GetUnifiedDiffString(diff UnifiedDiff) (string, error) { - w := &bytes.Buffer{} - err := WriteUnifiedDiff(w, diff) - return string(w.Bytes()), err -} - -// Convert range to the "ed" format. -func formatRangeContext(start, stop int) string { - // Per the diff spec at http://www.unix.org/single_unix_specification/ - beginning := start + 1 // lines start numbering with one - length := stop - start - if length == 0 { - beginning -= 1 // empty ranges begin at line just before the range - } - if length <= 1 { - return fmt.Sprintf("%d", beginning) - } - return fmt.Sprintf("%d,%d", beginning, beginning+length-1) -} - -type ContextDiff UnifiedDiff - -// Compare two sequences of lines; generate the delta as a context diff. -// -// Context diffs are a compact way of showing line changes and a few -// lines of context. The number of context lines is set by diff.Context -// which defaults to three. -// -// By default, the diff control lines (those with *** or ---) are -// created with a trailing newline. -// -// For inputs that do not have trailing newlines, set the diff.Eol -// argument to "" so that the output will be uniformly newline free. -// -// The context diff format normally has a header for filenames and -// modification times. Any or all of these may be specified using -// strings for diff.FromFile, diff.ToFile, diff.FromDate, diff.ToDate. -// The modification times are normally expressed in the ISO 8601 format. -// If not specified, the strings default to blanks. -func WriteContextDiff(writer io.Writer, diff ContextDiff) error { - buf := bufio.NewWriter(writer) - defer buf.Flush() - var diffErr error - wf := func(format string, args ...interface{}) { - _, err := buf.WriteString(fmt.Sprintf(format, args...)) - if diffErr == nil && err != nil { - diffErr = err - } - } - ws := func(s string) { - _, err := buf.WriteString(s) - if diffErr == nil && err != nil { - diffErr = err - } - } - - if len(diff.Eol) == 0 { - diff.Eol = "\n" - } - - prefix := map[byte]string{ - 'i': "+ ", - 'd': "- ", - 'r': "! ", - 'e': " ", - } - - started := false - m := NewMatcher(diff.A, diff.B) - for _, g := range m.GetGroupedOpCodes(diff.Context) { - if !started { - started = true - fromDate := "" - if len(diff.FromDate) > 0 { - fromDate = "\t" + diff.FromDate - } - toDate := "" - if len(diff.ToDate) > 0 { - toDate = "\t" + diff.ToDate - } - if diff.FromFile != "" || diff.ToFile != "" { - wf("*** %s%s%s", diff.FromFile, fromDate, diff.Eol) - wf("--- %s%s%s", diff.ToFile, toDate, diff.Eol) - } - } - - first, last := g[0], g[len(g)-1] - ws("***************" + diff.Eol) - - range1 := formatRangeContext(first.I1, last.I2) - wf("*** %s ****%s", range1, diff.Eol) - for _, c := range g { - if c.Tag == 'r' || c.Tag == 'd' { - for _, cc := range g { - if cc.Tag == 'i' { - continue - } - for _, line := range diff.A[cc.I1:cc.I2] { - ws(prefix[cc.Tag] + line) - } - } - break - } - } - - range2 := formatRangeContext(first.J1, last.J2) - wf("--- %s ----%s", range2, diff.Eol) - for _, c := range g { - if c.Tag == 'r' || c.Tag == 'i' { - for _, cc := range g { - if cc.Tag == 'd' { - continue - } - for _, line := range diff.B[cc.J1:cc.J2] { - ws(prefix[cc.Tag] + line) - } - } - break - } - } - } - return diffErr -} - -// Like WriteContextDiff but returns the diff a string. -func GetContextDiffString(diff ContextDiff) (string, error) { - w := &bytes.Buffer{} - err := WriteContextDiff(w, diff) - return string(w.Bytes()), err -} - -// Split a string on "\n" while preserving them. The output can be used -// as input for UnifiedDiff and ContextDiff structures. -func SplitLines(s string) []string { - lines := strings.SplitAfter(s, "\n") - lines[len(lines)-1] += "\n" - return lines -} diff --git a/vendor/github.com/stretchr/testify/LICENSE b/vendor/github.com/stretchr/testify/LICENSE deleted file mode 100644 index 473b670a7..000000000 --- a/vendor/github.com/stretchr/testify/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) 2012 - 2013 Mat Ryer and Tyler Bunnell - -Please consider promoting this project if you find it useful. - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without restriction, -including without limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of the Software, -and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT -OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/stretchr/testify/assert/assertion_format.go b/vendor/github.com/stretchr/testify/assert/assertion_format.go deleted file mode 100644 index aa1c2b95c..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_format.go +++ /dev/null @@ -1,484 +0,0 @@ -/* -* CODE GENERATED AUTOMATICALLY WITH github.com/stretchr/testify/_codegen -* THIS FILE MUST NOT BE EDITED BY HAND - */ - -package assert - -import ( - http "net/http" - url "net/url" - time "time" -) - -// Conditionf uses a Comparison to assert a complex condition. -func Conditionf(t TestingT, comp Comparison, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Condition(t, comp, append([]interface{}{msg}, args...)...) -} - -// Containsf asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// assert.Containsf(t, "Hello World", "World", "error message %s", "formatted") -// assert.Containsf(t, ["Hello", "World"], "World", "error message %s", "formatted") -// assert.Containsf(t, {"Hello": "World"}, "Hello", "error message %s", "formatted") -func Containsf(t TestingT, s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Contains(t, s, contains, append([]interface{}{msg}, args...)...) -} - -// DirExistsf checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func DirExistsf(t TestingT, path string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return DirExists(t, path, append([]interface{}{msg}, args...)...) -} - -// ElementsMatchf asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// assert.ElementsMatchf(t, [1, 3, 2, 3], [1, 3, 3, 2], "error message %s", "formatted") -func ElementsMatchf(t TestingT, listA interface{}, listB interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return ElementsMatch(t, listA, listB, append([]interface{}{msg}, args...)...) -} - -// Emptyf asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// assert.Emptyf(t, obj, "error message %s", "formatted") -func Emptyf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Empty(t, object, append([]interface{}{msg}, args...)...) -} - -// Equalf asserts that two objects are equal. -// -// assert.Equalf(t, 123, 123, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func Equalf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Equal(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// EqualErrorf asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// assert.EqualErrorf(t, err, expectedErrorString, "error message %s", "formatted") -func EqualErrorf(t TestingT, theError error, errString string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return EqualError(t, theError, errString, append([]interface{}{msg}, args...)...) -} - -// EqualValuesf asserts that two objects are equal or convertable to the same types -// and equal. -// -// assert.EqualValuesf(t, uint32(123, "error message %s", "formatted"), int32(123)) -func EqualValuesf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return EqualValues(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// Errorf asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if assert.Errorf(t, err, "error message %s", "formatted") { -// assert.Equal(t, expectedErrorf, err) -// } -func Errorf(t TestingT, err error, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Error(t, err, append([]interface{}{msg}, args...)...) -} - -// Exactlyf asserts that two objects are equal in value and type. -// -// assert.Exactlyf(t, int32(123, "error message %s", "formatted"), int64(123)) -func Exactlyf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Exactly(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// Failf reports a failure through -func Failf(t TestingT, failureMessage string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Fail(t, failureMessage, append([]interface{}{msg}, args...)...) -} - -// FailNowf fails test -func FailNowf(t TestingT, failureMessage string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return FailNow(t, failureMessage, append([]interface{}{msg}, args...)...) -} - -// Falsef asserts that the specified value is false. -// -// assert.Falsef(t, myBool, "error message %s", "formatted") -func Falsef(t TestingT, value bool, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return False(t, value, append([]interface{}{msg}, args...)...) -} - -// FileExistsf checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func FileExistsf(t TestingT, path string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return FileExists(t, path, append([]interface{}{msg}, args...)...) -} - -// HTTPBodyContainsf asserts that a specified handler returns a -// body that contains a string. -// -// assert.HTTPBodyContainsf(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyContainsf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPBodyContains(t, handler, method, url, values, str, append([]interface{}{msg}, args...)...) -} - -// HTTPBodyNotContainsf asserts that a specified handler returns a -// body that does not contain a string. -// -// assert.HTTPBodyNotContainsf(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyNotContainsf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPBodyNotContains(t, handler, method, url, values, str, append([]interface{}{msg}, args...)...) -} - -// HTTPErrorf asserts that a specified handler returns an error status code. -// -// assert.HTTPErrorf(t, myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func HTTPErrorf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPError(t, handler, method, url, values, append([]interface{}{msg}, args...)...) -} - -// HTTPRedirectf asserts that a specified handler returns a redirect status code. -// -// assert.HTTPRedirectf(t, myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func HTTPRedirectf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPRedirect(t, handler, method, url, values, append([]interface{}{msg}, args...)...) -} - -// HTTPSuccessf asserts that a specified handler returns a success status code. -// -// assert.HTTPSuccessf(t, myHandler, "POST", "http://www.google.com", nil, "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPSuccessf(t TestingT, handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return HTTPSuccess(t, handler, method, url, values, append([]interface{}{msg}, args...)...) -} - -// Implementsf asserts that an object is implemented by the specified interface. -// -// assert.Implementsf(t, (*MyInterface, "error message %s", "formatted")(nil), new(MyObject)) -func Implementsf(t TestingT, interfaceObject interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Implements(t, interfaceObject, object, append([]interface{}{msg}, args...)...) -} - -// InDeltaf asserts that the two numerals are within delta of each other. -// -// assert.InDeltaf(t, math.Pi, (22 / 7.0, "error message %s", "formatted"), 0.01) -func InDeltaf(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InDelta(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// InDeltaMapValuesf is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func InDeltaMapValuesf(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InDeltaMapValues(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// InDeltaSlicef is the same as InDelta, except it compares two slices. -func InDeltaSlicef(t TestingT, expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InDeltaSlice(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// InEpsilonf asserts that expected and actual have a relative error less than epsilon -func InEpsilonf(t TestingT, expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InEpsilon(t, expected, actual, epsilon, append([]interface{}{msg}, args...)...) -} - -// InEpsilonSlicef is the same as InEpsilon, except it compares each value from two slices. -func InEpsilonSlicef(t TestingT, expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return InEpsilonSlice(t, expected, actual, epsilon, append([]interface{}{msg}, args...)...) -} - -// IsTypef asserts that the specified objects are of the same type. -func IsTypef(t TestingT, expectedType interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return IsType(t, expectedType, object, append([]interface{}{msg}, args...)...) -} - -// JSONEqf asserts that two JSON strings are equivalent. -// -// assert.JSONEqf(t, `{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`, "error message %s", "formatted") -func JSONEqf(t TestingT, expected string, actual string, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return JSONEq(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// Lenf asserts that the specified object has specific length. -// Lenf also fails if the object has a type that len() not accept. -// -// assert.Lenf(t, mySlice, 3, "error message %s", "formatted") -func Lenf(t TestingT, object interface{}, length int, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Len(t, object, length, append([]interface{}{msg}, args...)...) -} - -// Nilf asserts that the specified object is nil. -// -// assert.Nilf(t, err, "error message %s", "formatted") -func Nilf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Nil(t, object, append([]interface{}{msg}, args...)...) -} - -// NoErrorf asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if assert.NoErrorf(t, err, "error message %s", "formatted") { -// assert.Equal(t, expectedObj, actualObj) -// } -func NoErrorf(t TestingT, err error, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NoError(t, err, append([]interface{}{msg}, args...)...) -} - -// NotContainsf asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// assert.NotContainsf(t, "Hello World", "Earth", "error message %s", "formatted") -// assert.NotContainsf(t, ["Hello", "World"], "Earth", "error message %s", "formatted") -// assert.NotContainsf(t, {"Hello": "World"}, "Earth", "error message %s", "formatted") -func NotContainsf(t TestingT, s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotContains(t, s, contains, append([]interface{}{msg}, args...)...) -} - -// NotEmptyf asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if assert.NotEmptyf(t, obj, "error message %s", "formatted") { -// assert.Equal(t, "two", obj[1]) -// } -func NotEmptyf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotEmpty(t, object, append([]interface{}{msg}, args...)...) -} - -// NotEqualf asserts that the specified values are NOT equal. -// -// assert.NotEqualf(t, obj1, obj2, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func NotEqualf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotEqual(t, expected, actual, append([]interface{}{msg}, args...)...) -} - -// NotNilf asserts that the specified object is not nil. -// -// assert.NotNilf(t, err, "error message %s", "formatted") -func NotNilf(t TestingT, object interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotNil(t, object, append([]interface{}{msg}, args...)...) -} - -// NotPanicsf asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// assert.NotPanicsf(t, func(){ RemainCalm() }, "error message %s", "formatted") -func NotPanicsf(t TestingT, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotPanics(t, f, append([]interface{}{msg}, args...)...) -} - -// NotRegexpf asserts that a specified regexp does not match a string. -// -// assert.NotRegexpf(t, regexp.MustCompile("starts", "error message %s", "formatted"), "it's starting") -// assert.NotRegexpf(t, "^start", "it's not starting", "error message %s", "formatted") -func NotRegexpf(t TestingT, rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotRegexp(t, rx, str, append([]interface{}{msg}, args...)...) -} - -// NotSubsetf asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// assert.NotSubsetf(t, [1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]", "error message %s", "formatted") -func NotSubsetf(t TestingT, list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotSubset(t, list, subset, append([]interface{}{msg}, args...)...) -} - -// NotZerof asserts that i is not the zero value for its type. -func NotZerof(t TestingT, i interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return NotZero(t, i, append([]interface{}{msg}, args...)...) -} - -// Panicsf asserts that the code inside the specified PanicTestFunc panics. -// -// assert.Panicsf(t, func(){ GoCrazy() }, "error message %s", "formatted") -func Panicsf(t TestingT, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Panics(t, f, append([]interface{}{msg}, args...)...) -} - -// PanicsWithValuef asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// assert.PanicsWithValuef(t, "crazy error", func(){ GoCrazy() }, "error message %s", "formatted") -func PanicsWithValuef(t TestingT, expected interface{}, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return PanicsWithValue(t, expected, f, append([]interface{}{msg}, args...)...) -} - -// Regexpf asserts that a specified regexp matches a string. -// -// assert.Regexpf(t, regexp.MustCompile("start", "error message %s", "formatted"), "it's starting") -// assert.Regexpf(t, "start...$", "it's not starting", "error message %s", "formatted") -func Regexpf(t TestingT, rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Regexp(t, rx, str, append([]interface{}{msg}, args...)...) -} - -// Subsetf asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// assert.Subsetf(t, [1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]", "error message %s", "formatted") -func Subsetf(t TestingT, list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Subset(t, list, subset, append([]interface{}{msg}, args...)...) -} - -// Truef asserts that the specified value is true. -// -// assert.Truef(t, myBool, "error message %s", "formatted") -func Truef(t TestingT, value bool, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return True(t, value, append([]interface{}{msg}, args...)...) -} - -// WithinDurationf asserts that the two times are within duration delta of each other. -// -// assert.WithinDurationf(t, time.Now(), time.Now(), 10*time.Second, "error message %s", "formatted") -func WithinDurationf(t TestingT, expected time.Time, actual time.Time, delta time.Duration, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return WithinDuration(t, expected, actual, delta, append([]interface{}{msg}, args...)...) -} - -// Zerof asserts that i is the zero value for its type. -func Zerof(t TestingT, i interface{}, msg string, args ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - return Zero(t, i, append([]interface{}{msg}, args...)...) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl b/vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl deleted file mode 100644 index d2bb0b817..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_format.go.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{.CommentFormat}} -func {{.DocInfo.Name}}f(t TestingT, {{.ParamsFormat}}) bool { - if h, ok := t.(tHelper); ok { h.Helper() } - return {{.DocInfo.Name}}(t, {{.ForwardedParamsFormat}}) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertion_forward.go b/vendor/github.com/stretchr/testify/assert/assertion_forward.go deleted file mode 100644 index de39f794e..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_forward.go +++ /dev/null @@ -1,956 +0,0 @@ -/* -* CODE GENERATED AUTOMATICALLY WITH github.com/stretchr/testify/_codegen -* THIS FILE MUST NOT BE EDITED BY HAND - */ - -package assert - -import ( - http "net/http" - url "net/url" - time "time" -) - -// Condition uses a Comparison to assert a complex condition. -func (a *Assertions) Condition(comp Comparison, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Condition(a.t, comp, msgAndArgs...) -} - -// Conditionf uses a Comparison to assert a complex condition. -func (a *Assertions) Conditionf(comp Comparison, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Conditionf(a.t, comp, msg, args...) -} - -// Contains asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// a.Contains("Hello World", "World") -// a.Contains(["Hello", "World"], "World") -// a.Contains({"Hello": "World"}, "Hello") -func (a *Assertions) Contains(s interface{}, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Contains(a.t, s, contains, msgAndArgs...) -} - -// Containsf asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// a.Containsf("Hello World", "World", "error message %s", "formatted") -// a.Containsf(["Hello", "World"], "World", "error message %s", "formatted") -// a.Containsf({"Hello": "World"}, "Hello", "error message %s", "formatted") -func (a *Assertions) Containsf(s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Containsf(a.t, s, contains, msg, args...) -} - -// DirExists checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func (a *Assertions) DirExists(path string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return DirExists(a.t, path, msgAndArgs...) -} - -// DirExistsf checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func (a *Assertions) DirExistsf(path string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return DirExistsf(a.t, path, msg, args...) -} - -// ElementsMatch asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// a.ElementsMatch([1, 3, 2, 3], [1, 3, 3, 2]) -func (a *Assertions) ElementsMatch(listA interface{}, listB interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return ElementsMatch(a.t, listA, listB, msgAndArgs...) -} - -// ElementsMatchf asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// a.ElementsMatchf([1, 3, 2, 3], [1, 3, 3, 2], "error message %s", "formatted") -func (a *Assertions) ElementsMatchf(listA interface{}, listB interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return ElementsMatchf(a.t, listA, listB, msg, args...) -} - -// Empty asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// a.Empty(obj) -func (a *Assertions) Empty(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Empty(a.t, object, msgAndArgs...) -} - -// Emptyf asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// a.Emptyf(obj, "error message %s", "formatted") -func (a *Assertions) Emptyf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Emptyf(a.t, object, msg, args...) -} - -// Equal asserts that two objects are equal. -// -// a.Equal(123, 123) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func (a *Assertions) Equal(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Equal(a.t, expected, actual, msgAndArgs...) -} - -// EqualError asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// a.EqualError(err, expectedErrorString) -func (a *Assertions) EqualError(theError error, errString string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualError(a.t, theError, errString, msgAndArgs...) -} - -// EqualErrorf asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// a.EqualErrorf(err, expectedErrorString, "error message %s", "formatted") -func (a *Assertions) EqualErrorf(theError error, errString string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualErrorf(a.t, theError, errString, msg, args...) -} - -// EqualValues asserts that two objects are equal or convertable to the same types -// and equal. -// -// a.EqualValues(uint32(123), int32(123)) -func (a *Assertions) EqualValues(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualValues(a.t, expected, actual, msgAndArgs...) -} - -// EqualValuesf asserts that two objects are equal or convertable to the same types -// and equal. -// -// a.EqualValuesf(uint32(123, "error message %s", "formatted"), int32(123)) -func (a *Assertions) EqualValuesf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return EqualValuesf(a.t, expected, actual, msg, args...) -} - -// Equalf asserts that two objects are equal. -// -// a.Equalf(123, 123, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func (a *Assertions) Equalf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Equalf(a.t, expected, actual, msg, args...) -} - -// Error asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if a.Error(err) { -// assert.Equal(t, expectedError, err) -// } -func (a *Assertions) Error(err error, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Error(a.t, err, msgAndArgs...) -} - -// Errorf asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if a.Errorf(err, "error message %s", "formatted") { -// assert.Equal(t, expectedErrorf, err) -// } -func (a *Assertions) Errorf(err error, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Errorf(a.t, err, msg, args...) -} - -// Exactly asserts that two objects are equal in value and type. -// -// a.Exactly(int32(123), int64(123)) -func (a *Assertions) Exactly(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Exactly(a.t, expected, actual, msgAndArgs...) -} - -// Exactlyf asserts that two objects are equal in value and type. -// -// a.Exactlyf(int32(123, "error message %s", "formatted"), int64(123)) -func (a *Assertions) Exactlyf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Exactlyf(a.t, expected, actual, msg, args...) -} - -// Fail reports a failure through -func (a *Assertions) Fail(failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Fail(a.t, failureMessage, msgAndArgs...) -} - -// FailNow fails test -func (a *Assertions) FailNow(failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FailNow(a.t, failureMessage, msgAndArgs...) -} - -// FailNowf fails test -func (a *Assertions) FailNowf(failureMessage string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FailNowf(a.t, failureMessage, msg, args...) -} - -// Failf reports a failure through -func (a *Assertions) Failf(failureMessage string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Failf(a.t, failureMessage, msg, args...) -} - -// False asserts that the specified value is false. -// -// a.False(myBool) -func (a *Assertions) False(value bool, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return False(a.t, value, msgAndArgs...) -} - -// Falsef asserts that the specified value is false. -// -// a.Falsef(myBool, "error message %s", "formatted") -func (a *Assertions) Falsef(value bool, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Falsef(a.t, value, msg, args...) -} - -// FileExists checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func (a *Assertions) FileExists(path string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FileExists(a.t, path, msgAndArgs...) -} - -// FileExistsf checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func (a *Assertions) FileExistsf(path string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return FileExistsf(a.t, path, msg, args...) -} - -// HTTPBodyContains asserts that a specified handler returns a -// body that contains a string. -// -// a.HTTPBodyContains(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyContains(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyContains(a.t, handler, method, url, values, str, msgAndArgs...) -} - -// HTTPBodyContainsf asserts that a specified handler returns a -// body that contains a string. -// -// a.HTTPBodyContainsf(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyContainsf(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyContainsf(a.t, handler, method, url, values, str, msg, args...) -} - -// HTTPBodyNotContains asserts that a specified handler returns a -// body that does not contain a string. -// -// a.HTTPBodyNotContains(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyNotContains(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyNotContains(a.t, handler, method, url, values, str, msgAndArgs...) -} - -// HTTPBodyNotContainsf asserts that a specified handler returns a -// body that does not contain a string. -// -// a.HTTPBodyNotContainsf(myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky", "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPBodyNotContainsf(handler http.HandlerFunc, method string, url string, values url.Values, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPBodyNotContainsf(a.t, handler, method, url, values, str, msg, args...) -} - -// HTTPError asserts that a specified handler returns an error status code. -// -// a.HTTPError(myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPError(handler http.HandlerFunc, method string, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPError(a.t, handler, method, url, values, msgAndArgs...) -} - -// HTTPErrorf asserts that a specified handler returns an error status code. -// -// a.HTTPErrorf(myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func (a *Assertions) HTTPErrorf(handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPErrorf(a.t, handler, method, url, values, msg, args...) -} - -// HTTPRedirect asserts that a specified handler returns a redirect status code. -// -// a.HTTPRedirect(myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPRedirect(handler http.HandlerFunc, method string, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPRedirect(a.t, handler, method, url, values, msgAndArgs...) -} - -// HTTPRedirectf asserts that a specified handler returns a redirect status code. -// -// a.HTTPRedirectf(myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true, "error message %s", "formatted") or not (false). -func (a *Assertions) HTTPRedirectf(handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPRedirectf(a.t, handler, method, url, values, msg, args...) -} - -// HTTPSuccess asserts that a specified handler returns a success status code. -// -// a.HTTPSuccess(myHandler, "POST", "http://www.google.com", nil) -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPSuccess(handler http.HandlerFunc, method string, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPSuccess(a.t, handler, method, url, values, msgAndArgs...) -} - -// HTTPSuccessf asserts that a specified handler returns a success status code. -// -// a.HTTPSuccessf(myHandler, "POST", "http://www.google.com", nil, "error message %s", "formatted") -// -// Returns whether the assertion was successful (true) or not (false). -func (a *Assertions) HTTPSuccessf(handler http.HandlerFunc, method string, url string, values url.Values, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return HTTPSuccessf(a.t, handler, method, url, values, msg, args...) -} - -// Implements asserts that an object is implemented by the specified interface. -// -// a.Implements((*MyInterface)(nil), new(MyObject)) -func (a *Assertions) Implements(interfaceObject interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Implements(a.t, interfaceObject, object, msgAndArgs...) -} - -// Implementsf asserts that an object is implemented by the specified interface. -// -// a.Implementsf((*MyInterface, "error message %s", "formatted")(nil), new(MyObject)) -func (a *Assertions) Implementsf(interfaceObject interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Implementsf(a.t, interfaceObject, object, msg, args...) -} - -// InDelta asserts that the two numerals are within delta of each other. -// -// a.InDelta(math.Pi, (22 / 7.0), 0.01) -func (a *Assertions) InDelta(expected interface{}, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDelta(a.t, expected, actual, delta, msgAndArgs...) -} - -// InDeltaMapValues is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func (a *Assertions) InDeltaMapValues(expected interface{}, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaMapValues(a.t, expected, actual, delta, msgAndArgs...) -} - -// InDeltaMapValuesf is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func (a *Assertions) InDeltaMapValuesf(expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaMapValuesf(a.t, expected, actual, delta, msg, args...) -} - -// InDeltaSlice is the same as InDelta, except it compares two slices. -func (a *Assertions) InDeltaSlice(expected interface{}, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaSlice(a.t, expected, actual, delta, msgAndArgs...) -} - -// InDeltaSlicef is the same as InDelta, except it compares two slices. -func (a *Assertions) InDeltaSlicef(expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaSlicef(a.t, expected, actual, delta, msg, args...) -} - -// InDeltaf asserts that the two numerals are within delta of each other. -// -// a.InDeltaf(math.Pi, (22 / 7.0, "error message %s", "formatted"), 0.01) -func (a *Assertions) InDeltaf(expected interface{}, actual interface{}, delta float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InDeltaf(a.t, expected, actual, delta, msg, args...) -} - -// InEpsilon asserts that expected and actual have a relative error less than epsilon -func (a *Assertions) InEpsilon(expected interface{}, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilon(a.t, expected, actual, epsilon, msgAndArgs...) -} - -// InEpsilonSlice is the same as InEpsilon, except it compares each value from two slices. -func (a *Assertions) InEpsilonSlice(expected interface{}, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilonSlice(a.t, expected, actual, epsilon, msgAndArgs...) -} - -// InEpsilonSlicef is the same as InEpsilon, except it compares each value from two slices. -func (a *Assertions) InEpsilonSlicef(expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilonSlicef(a.t, expected, actual, epsilon, msg, args...) -} - -// InEpsilonf asserts that expected and actual have a relative error less than epsilon -func (a *Assertions) InEpsilonf(expected interface{}, actual interface{}, epsilon float64, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return InEpsilonf(a.t, expected, actual, epsilon, msg, args...) -} - -// IsType asserts that the specified objects are of the same type. -func (a *Assertions) IsType(expectedType interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return IsType(a.t, expectedType, object, msgAndArgs...) -} - -// IsTypef asserts that the specified objects are of the same type. -func (a *Assertions) IsTypef(expectedType interface{}, object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return IsTypef(a.t, expectedType, object, msg, args...) -} - -// JSONEq asserts that two JSON strings are equivalent. -// -// a.JSONEq(`{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`) -func (a *Assertions) JSONEq(expected string, actual string, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return JSONEq(a.t, expected, actual, msgAndArgs...) -} - -// JSONEqf asserts that two JSON strings are equivalent. -// -// a.JSONEqf(`{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`, "error message %s", "formatted") -func (a *Assertions) JSONEqf(expected string, actual string, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return JSONEqf(a.t, expected, actual, msg, args...) -} - -// Len asserts that the specified object has specific length. -// Len also fails if the object has a type that len() not accept. -// -// a.Len(mySlice, 3) -func (a *Assertions) Len(object interface{}, length int, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Len(a.t, object, length, msgAndArgs...) -} - -// Lenf asserts that the specified object has specific length. -// Lenf also fails if the object has a type that len() not accept. -// -// a.Lenf(mySlice, 3, "error message %s", "formatted") -func (a *Assertions) Lenf(object interface{}, length int, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Lenf(a.t, object, length, msg, args...) -} - -// Nil asserts that the specified object is nil. -// -// a.Nil(err) -func (a *Assertions) Nil(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Nil(a.t, object, msgAndArgs...) -} - -// Nilf asserts that the specified object is nil. -// -// a.Nilf(err, "error message %s", "formatted") -func (a *Assertions) Nilf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Nilf(a.t, object, msg, args...) -} - -// NoError asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if a.NoError(err) { -// assert.Equal(t, expectedObj, actualObj) -// } -func (a *Assertions) NoError(err error, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NoError(a.t, err, msgAndArgs...) -} - -// NoErrorf asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if a.NoErrorf(err, "error message %s", "formatted") { -// assert.Equal(t, expectedObj, actualObj) -// } -func (a *Assertions) NoErrorf(err error, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NoErrorf(a.t, err, msg, args...) -} - -// NotContains asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// a.NotContains("Hello World", "Earth") -// a.NotContains(["Hello", "World"], "Earth") -// a.NotContains({"Hello": "World"}, "Earth") -func (a *Assertions) NotContains(s interface{}, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotContains(a.t, s, contains, msgAndArgs...) -} - -// NotContainsf asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// a.NotContainsf("Hello World", "Earth", "error message %s", "formatted") -// a.NotContainsf(["Hello", "World"], "Earth", "error message %s", "formatted") -// a.NotContainsf({"Hello": "World"}, "Earth", "error message %s", "formatted") -func (a *Assertions) NotContainsf(s interface{}, contains interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotContainsf(a.t, s, contains, msg, args...) -} - -// NotEmpty asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if a.NotEmpty(obj) { -// assert.Equal(t, "two", obj[1]) -// } -func (a *Assertions) NotEmpty(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEmpty(a.t, object, msgAndArgs...) -} - -// NotEmptyf asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if a.NotEmptyf(obj, "error message %s", "formatted") { -// assert.Equal(t, "two", obj[1]) -// } -func (a *Assertions) NotEmptyf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEmptyf(a.t, object, msg, args...) -} - -// NotEqual asserts that the specified values are NOT equal. -// -// a.NotEqual(obj1, obj2) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func (a *Assertions) NotEqual(expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEqual(a.t, expected, actual, msgAndArgs...) -} - -// NotEqualf asserts that the specified values are NOT equal. -// -// a.NotEqualf(obj1, obj2, "error message %s", "formatted") -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func (a *Assertions) NotEqualf(expected interface{}, actual interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotEqualf(a.t, expected, actual, msg, args...) -} - -// NotNil asserts that the specified object is not nil. -// -// a.NotNil(err) -func (a *Assertions) NotNil(object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotNil(a.t, object, msgAndArgs...) -} - -// NotNilf asserts that the specified object is not nil. -// -// a.NotNilf(err, "error message %s", "formatted") -func (a *Assertions) NotNilf(object interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotNilf(a.t, object, msg, args...) -} - -// NotPanics asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// a.NotPanics(func(){ RemainCalm() }) -func (a *Assertions) NotPanics(f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotPanics(a.t, f, msgAndArgs...) -} - -// NotPanicsf asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// a.NotPanicsf(func(){ RemainCalm() }, "error message %s", "formatted") -func (a *Assertions) NotPanicsf(f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotPanicsf(a.t, f, msg, args...) -} - -// NotRegexp asserts that a specified regexp does not match a string. -// -// a.NotRegexp(regexp.MustCompile("starts"), "it's starting") -// a.NotRegexp("^start", "it's not starting") -func (a *Assertions) NotRegexp(rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotRegexp(a.t, rx, str, msgAndArgs...) -} - -// NotRegexpf asserts that a specified regexp does not match a string. -// -// a.NotRegexpf(regexp.MustCompile("starts", "error message %s", "formatted"), "it's starting") -// a.NotRegexpf("^start", "it's not starting", "error message %s", "formatted") -func (a *Assertions) NotRegexpf(rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotRegexpf(a.t, rx, str, msg, args...) -} - -// NotSubset asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// a.NotSubset([1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]") -func (a *Assertions) NotSubset(list interface{}, subset interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotSubset(a.t, list, subset, msgAndArgs...) -} - -// NotSubsetf asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// a.NotSubsetf([1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]", "error message %s", "formatted") -func (a *Assertions) NotSubsetf(list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotSubsetf(a.t, list, subset, msg, args...) -} - -// NotZero asserts that i is not the zero value for its type. -func (a *Assertions) NotZero(i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotZero(a.t, i, msgAndArgs...) -} - -// NotZerof asserts that i is not the zero value for its type. -func (a *Assertions) NotZerof(i interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return NotZerof(a.t, i, msg, args...) -} - -// Panics asserts that the code inside the specified PanicTestFunc panics. -// -// a.Panics(func(){ GoCrazy() }) -func (a *Assertions) Panics(f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Panics(a.t, f, msgAndArgs...) -} - -// PanicsWithValue asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// a.PanicsWithValue("crazy error", func(){ GoCrazy() }) -func (a *Assertions) PanicsWithValue(expected interface{}, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return PanicsWithValue(a.t, expected, f, msgAndArgs...) -} - -// PanicsWithValuef asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// a.PanicsWithValuef("crazy error", func(){ GoCrazy() }, "error message %s", "formatted") -func (a *Assertions) PanicsWithValuef(expected interface{}, f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return PanicsWithValuef(a.t, expected, f, msg, args...) -} - -// Panicsf asserts that the code inside the specified PanicTestFunc panics. -// -// a.Panicsf(func(){ GoCrazy() }, "error message %s", "formatted") -func (a *Assertions) Panicsf(f PanicTestFunc, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Panicsf(a.t, f, msg, args...) -} - -// Regexp asserts that a specified regexp matches a string. -// -// a.Regexp(regexp.MustCompile("start"), "it's starting") -// a.Regexp("start...$", "it's not starting") -func (a *Assertions) Regexp(rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Regexp(a.t, rx, str, msgAndArgs...) -} - -// Regexpf asserts that a specified regexp matches a string. -// -// a.Regexpf(regexp.MustCompile("start", "error message %s", "formatted"), "it's starting") -// a.Regexpf("start...$", "it's not starting", "error message %s", "formatted") -func (a *Assertions) Regexpf(rx interface{}, str interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Regexpf(a.t, rx, str, msg, args...) -} - -// Subset asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// a.Subset([1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]") -func (a *Assertions) Subset(list interface{}, subset interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Subset(a.t, list, subset, msgAndArgs...) -} - -// Subsetf asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// a.Subsetf([1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]", "error message %s", "formatted") -func (a *Assertions) Subsetf(list interface{}, subset interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Subsetf(a.t, list, subset, msg, args...) -} - -// True asserts that the specified value is true. -// -// a.True(myBool) -func (a *Assertions) True(value bool, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return True(a.t, value, msgAndArgs...) -} - -// Truef asserts that the specified value is true. -// -// a.Truef(myBool, "error message %s", "formatted") -func (a *Assertions) Truef(value bool, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Truef(a.t, value, msg, args...) -} - -// WithinDuration asserts that the two times are within duration delta of each other. -// -// a.WithinDuration(time.Now(), time.Now(), 10*time.Second) -func (a *Assertions) WithinDuration(expected time.Time, actual time.Time, delta time.Duration, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return WithinDuration(a.t, expected, actual, delta, msgAndArgs...) -} - -// WithinDurationf asserts that the two times are within duration delta of each other. -// -// a.WithinDurationf(time.Now(), time.Now(), 10*time.Second, "error message %s", "formatted") -func (a *Assertions) WithinDurationf(expected time.Time, actual time.Time, delta time.Duration, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return WithinDurationf(a.t, expected, actual, delta, msg, args...) -} - -// Zero asserts that i is the zero value for its type. -func (a *Assertions) Zero(i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Zero(a.t, i, msgAndArgs...) -} - -// Zerof asserts that i is the zero value for its type. -func (a *Assertions) Zerof(i interface{}, msg string, args ...interface{}) bool { - if h, ok := a.t.(tHelper); ok { - h.Helper() - } - return Zerof(a.t, i, msg, args...) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl b/vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl deleted file mode 100644 index 188bb9e17..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertion_forward.go.tmpl +++ /dev/null @@ -1,5 +0,0 @@ -{{.CommentWithoutT "a"}} -func (a *Assertions) {{.DocInfo.Name}}({{.Params}}) bool { - if h, ok := a.t.(tHelper); ok { h.Helper() } - return {{.DocInfo.Name}}(a.t, {{.ForwardedParams}}) -} diff --git a/vendor/github.com/stretchr/testify/assert/assertions.go b/vendor/github.com/stretchr/testify/assert/assertions.go deleted file mode 100644 index 5bdec56cd..000000000 --- a/vendor/github.com/stretchr/testify/assert/assertions.go +++ /dev/null @@ -1,1394 +0,0 @@ -package assert - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "math" - "os" - "reflect" - "regexp" - "runtime" - "strings" - "time" - "unicode" - "unicode/utf8" - - "github.com/davecgh/go-spew/spew" - "github.com/pmezard/go-difflib/difflib" -) - -//go:generate go run ../_codegen/main.go -output-package=assert -template=assertion_format.go.tmpl - -// TestingT is an interface wrapper around *testing.T -type TestingT interface { - Errorf(format string, args ...interface{}) -} - -// ComparisonAssertionFunc is a common function prototype when comparing two values. Can be useful -// for table driven tests. -type ComparisonAssertionFunc func(TestingT, interface{}, interface{}, ...interface{}) bool - -// ValueAssertionFunc is a common function prototype when validating a single value. Can be useful -// for table driven tests. -type ValueAssertionFunc func(TestingT, interface{}, ...interface{}) bool - -// BoolAssertionFunc is a common function prototype when validating a bool value. Can be useful -// for table driven tests. -type BoolAssertionFunc func(TestingT, bool, ...interface{}) bool - -// ValuesAssertionFunc is a common function prototype when validating an error value. Can be useful -// for table driven tests. -type ErrorAssertionFunc func(TestingT, error, ...interface{}) bool - -// Comparison a custom function that returns true on success and false on failure -type Comparison func() (success bool) - -/* - Helper functions -*/ - -// ObjectsAreEqual determines if two objects are considered equal. -// -// This function does no assertion of any kind. -func ObjectsAreEqual(expected, actual interface{}) bool { - if expected == nil || actual == nil { - return expected == actual - } - - exp, ok := expected.([]byte) - if !ok { - return reflect.DeepEqual(expected, actual) - } - - act, ok := actual.([]byte) - if !ok { - return false - } - if exp == nil || act == nil { - return exp == nil && act == nil - } - return bytes.Equal(exp, act) -} - -// ObjectsAreEqualValues gets whether two objects are equal, or if their -// values are equal. -func ObjectsAreEqualValues(expected, actual interface{}) bool { - if ObjectsAreEqual(expected, actual) { - return true - } - - actualType := reflect.TypeOf(actual) - if actualType == nil { - return false - } - expectedValue := reflect.ValueOf(expected) - if expectedValue.IsValid() && expectedValue.Type().ConvertibleTo(actualType) { - // Attempt comparison after type conversion - return reflect.DeepEqual(expectedValue.Convert(actualType).Interface(), actual) - } - - return false -} - -/* CallerInfo is necessary because the assert functions use the testing object -internally, causing it to print the file:line of the assert method, rather than where -the problem actually occurred in calling code.*/ - -// CallerInfo returns an array of strings containing the file and line number -// of each stack frame leading from the current test to the assert call that -// failed. -func CallerInfo() []string { - - pc := uintptr(0) - file := "" - line := 0 - ok := false - name := "" - - callers := []string{} - for i := 0; ; i++ { - pc, file, line, ok = runtime.Caller(i) - if !ok { - // The breaks below failed to terminate the loop, and we ran off the - // end of the call stack. - break - } - - // This is a huge edge case, but it will panic if this is the case, see #180 - if file == "" { - break - } - - f := runtime.FuncForPC(pc) - if f == nil { - break - } - name = f.Name() - - // testing.tRunner is the standard library function that calls - // tests. Subtests are called directly by tRunner, without going through - // the Test/Benchmark/Example function that contains the t.Run calls, so - // with subtests we should break when we hit tRunner, without adding it - // to the list of callers. - if name == "testing.tRunner" { - break - } - - parts := strings.Split(file, "/") - file = parts[len(parts)-1] - if len(parts) > 1 { - dir := parts[len(parts)-2] - if (dir != "assert" && dir != "mock" && dir != "require") || file == "mock_test.go" { - callers = append(callers, fmt.Sprintf("%s:%d", file, line)) - } - } - - // Drop the package - segments := strings.Split(name, ".") - name = segments[len(segments)-1] - if isTest(name, "Test") || - isTest(name, "Benchmark") || - isTest(name, "Example") { - break - } - } - - return callers -} - -// Stolen from the `go test` tool. -// isTest tells whether name looks like a test (or benchmark, according to prefix). -// It is a Test (say) if there is a character after Test that is not a lower-case letter. -// We don't want TesticularCancer. -func isTest(name, prefix string) bool { - if !strings.HasPrefix(name, prefix) { - return false - } - if len(name) == len(prefix) { // "Test" is ok - return true - } - rune, _ := utf8.DecodeRuneInString(name[len(prefix):]) - return !unicode.IsLower(rune) -} - -func messageFromMsgAndArgs(msgAndArgs ...interface{}) string { - if len(msgAndArgs) == 0 || msgAndArgs == nil { - return "" - } - if len(msgAndArgs) == 1 { - return msgAndArgs[0].(string) - } - if len(msgAndArgs) > 1 { - return fmt.Sprintf(msgAndArgs[0].(string), msgAndArgs[1:]...) - } - return "" -} - -// Aligns the provided message so that all lines after the first line start at the same location as the first line. -// Assumes that the first line starts at the correct location (after carriage return, tab, label, spacer and tab). -// The longestLabelLen parameter specifies the length of the longest label in the output (required becaues this is the -// basis on which the alignment occurs). -func indentMessageLines(message string, longestLabelLen int) string { - outBuf := new(bytes.Buffer) - - for i, scanner := 0, bufio.NewScanner(strings.NewReader(message)); scanner.Scan(); i++ { - // no need to align first line because it starts at the correct location (after the label) - if i != 0 { - // append alignLen+1 spaces to align with "{{longestLabel}}:" before adding tab - outBuf.WriteString("\n\t" + strings.Repeat(" ", longestLabelLen+1) + "\t") - } - outBuf.WriteString(scanner.Text()) - } - - return outBuf.String() -} - -type failNower interface { - FailNow() -} - -// FailNow fails test -func FailNow(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - Fail(t, failureMessage, msgAndArgs...) - - // We cannot extend TestingT with FailNow() and - // maintain backwards compatibility, so we fallback - // to panicking when FailNow is not available in - // TestingT. - // See issue #263 - - if t, ok := t.(failNower); ok { - t.FailNow() - } else { - panic("test failed and t is missing `FailNow()`") - } - return false -} - -// Fail reports a failure through -func Fail(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - content := []labeledContent{ - {"Error Trace", strings.Join(CallerInfo(), "\n\t\t\t")}, - {"Error", failureMessage}, - } - - // Add test name if the Go version supports it - if n, ok := t.(interface { - Name() string - }); ok { - content = append(content, labeledContent{"Test", n.Name()}) - } - - message := messageFromMsgAndArgs(msgAndArgs...) - if len(message) > 0 { - content = append(content, labeledContent{"Messages", message}) - } - - t.Errorf("\n%s", ""+labeledOutput(content...)) - - return false -} - -type labeledContent struct { - label string - content string -} - -// labeledOutput returns a string consisting of the provided labeledContent. Each labeled output is appended in the following manner: -// -// \t{{label}}:{{align_spaces}}\t{{content}}\n -// -// The initial carriage return is required to undo/erase any padding added by testing.T.Errorf. The "\t{{label}}:" is for the label. -// If a label is shorter than the longest label provided, padding spaces are added to make all the labels match in length. Once this -// alignment is achieved, "\t{{content}}\n" is added for the output. -// -// If the content of the labeledOutput contains line breaks, the subsequent lines are aligned so that they start at the same location as the first line. -func labeledOutput(content ...labeledContent) string { - longestLabel := 0 - for _, v := range content { - if len(v.label) > longestLabel { - longestLabel = len(v.label) - } - } - var output string - for _, v := range content { - output += "\t" + v.label + ":" + strings.Repeat(" ", longestLabel-len(v.label)) + "\t" + indentMessageLines(v.content, longestLabel) + "\n" - } - return output -} - -// Implements asserts that an object is implemented by the specified interface. -// -// assert.Implements(t, (*MyInterface)(nil), new(MyObject)) -func Implements(t TestingT, interfaceObject interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - interfaceType := reflect.TypeOf(interfaceObject).Elem() - - if object == nil { - return Fail(t, fmt.Sprintf("Cannot check if nil implements %v", interfaceType), msgAndArgs...) - } - if !reflect.TypeOf(object).Implements(interfaceType) { - return Fail(t, fmt.Sprintf("%T must implement %v", object, interfaceType), msgAndArgs...) - } - - return true -} - -// IsType asserts that the specified objects are of the same type. -func IsType(t TestingT, expectedType interface{}, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if !ObjectsAreEqual(reflect.TypeOf(object), reflect.TypeOf(expectedType)) { - return Fail(t, fmt.Sprintf("Object expected to be of type %v, but was %v", reflect.TypeOf(expectedType), reflect.TypeOf(object)), msgAndArgs...) - } - - return true -} - -// Equal asserts that two objects are equal. -// -// assert.Equal(t, 123, 123) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). Function equality -// cannot be determined and will always fail. -func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if err := validateEqualArgs(expected, actual); err != nil { - return Fail(t, fmt.Sprintf("Invalid operation: %#v == %#v (%s)", - expected, actual, err), msgAndArgs...) - } - - if !ObjectsAreEqual(expected, actual) { - diff := diff(expected, actual) - expected, actual = formatUnequalValues(expected, actual) - return Fail(t, fmt.Sprintf("Not equal: \n"+ - "expected: %s\n"+ - "actual : %s%s", expected, actual, diff), msgAndArgs...) - } - - return true - -} - -// formatUnequalValues takes two values of arbitrary types and returns string -// representations appropriate to be presented to the user. -// -// If the values are not of like type, the returned strings will be prefixed -// with the type name, and the value will be enclosed in parenthesis similar -// to a type conversion in the Go grammar. -func formatUnequalValues(expected, actual interface{}) (e string, a string) { - if reflect.TypeOf(expected) != reflect.TypeOf(actual) { - return fmt.Sprintf("%T(%#v)", expected, expected), - fmt.Sprintf("%T(%#v)", actual, actual) - } - - return fmt.Sprintf("%#v", expected), - fmt.Sprintf("%#v", actual) -} - -// EqualValues asserts that two objects are equal or convertable to the same types -// and equal. -// -// assert.EqualValues(t, uint32(123), int32(123)) -func EqualValues(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if !ObjectsAreEqualValues(expected, actual) { - diff := diff(expected, actual) - expected, actual = formatUnequalValues(expected, actual) - return Fail(t, fmt.Sprintf("Not equal: \n"+ - "expected: %s\n"+ - "actual : %s%s", expected, actual, diff), msgAndArgs...) - } - - return true - -} - -// Exactly asserts that two objects are equal in value and type. -// -// assert.Exactly(t, int32(123), int64(123)) -func Exactly(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - aType := reflect.TypeOf(expected) - bType := reflect.TypeOf(actual) - - if aType != bType { - return Fail(t, fmt.Sprintf("Types expected to match exactly\n\t%v != %v", aType, bType), msgAndArgs...) - } - - return Equal(t, expected, actual, msgAndArgs...) - -} - -// NotNil asserts that the specified object is not nil. -// -// assert.NotNil(t, err) -func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if !isNil(object) { - return true - } - return Fail(t, "Expected value not to be nil.", msgAndArgs...) -} - -// isNil checks if a specified object is nil or not, without Failing. -func isNil(object interface{}) bool { - if object == nil { - return true - } - - value := reflect.ValueOf(object) - kind := value.Kind() - if kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil() { - return true - } - - return false -} - -// Nil asserts that the specified object is nil. -// -// assert.Nil(t, err) -func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if isNil(object) { - return true - } - return Fail(t, fmt.Sprintf("Expected nil, but got: %#v", object), msgAndArgs...) -} - -// isEmpty gets whether the specified object is considered empty or not. -func isEmpty(object interface{}) bool { - - // get nil case out of the way - if object == nil { - return true - } - - objValue := reflect.ValueOf(object) - - switch objValue.Kind() { - // collection types are empty when they have no element - case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice: - return objValue.Len() == 0 - // pointers are empty if nil or if the value they point to is empty - case reflect.Ptr: - if objValue.IsNil() { - return true - } - deref := objValue.Elem().Interface() - return isEmpty(deref) - // for all other types, compare against the zero value - default: - zero := reflect.Zero(objValue.Type()) - return reflect.DeepEqual(object, zero.Interface()) - } -} - -// Empty asserts that the specified object is empty. I.e. nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// assert.Empty(t, obj) -func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - pass := isEmpty(object) - if !pass { - Fail(t, fmt.Sprintf("Should be empty, but was %v", object), msgAndArgs...) - } - - return pass - -} - -// NotEmpty asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either -// a slice or a channel with len == 0. -// -// if assert.NotEmpty(t, obj) { -// assert.Equal(t, "two", obj[1]) -// } -func NotEmpty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - pass := !isEmpty(object) - if !pass { - Fail(t, fmt.Sprintf("Should NOT be empty, but was %v", object), msgAndArgs...) - } - - return pass - -} - -// getLen try to get length of object. -// return (false, 0) if impossible. -func getLen(x interface{}) (ok bool, length int) { - v := reflect.ValueOf(x) - defer func() { - if e := recover(); e != nil { - ok = false - } - }() - return true, v.Len() -} - -// Len asserts that the specified object has specific length. -// Len also fails if the object has a type that len() not accept. -// -// assert.Len(t, mySlice, 3) -func Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - ok, l := getLen(object) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", object), msgAndArgs...) - } - - if l != length { - return Fail(t, fmt.Sprintf("\"%s\" should have %d item(s), but has %d", object, length, l), msgAndArgs...) - } - return true -} - -// True asserts that the specified value is true. -// -// assert.True(t, myBool) -func True(t TestingT, value bool, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if h, ok := t.(interface { - Helper() - }); ok { - h.Helper() - } - - if value != true { - return Fail(t, "Should be true", msgAndArgs...) - } - - return true - -} - -// False asserts that the specified value is false. -// -// assert.False(t, myBool) -func False(t TestingT, value bool, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if value != false { - return Fail(t, "Should be false", msgAndArgs...) - } - - return true - -} - -// NotEqual asserts that the specified values are NOT equal. -// -// assert.NotEqual(t, obj1, obj2) -// -// Pointer variable equality is determined based on the equality of the -// referenced values (as opposed to the memory addresses). -func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if err := validateEqualArgs(expected, actual); err != nil { - return Fail(t, fmt.Sprintf("Invalid operation: %#v != %#v (%s)", - expected, actual, err), msgAndArgs...) - } - - if ObjectsAreEqual(expected, actual) { - return Fail(t, fmt.Sprintf("Should not be: %#v\n", actual), msgAndArgs...) - } - - return true - -} - -// containsElement try loop over the list check if the list includes the element. -// return (false, false) if impossible. -// return (true, false) if element was not found. -// return (true, true) if element was found. -func includeElement(list interface{}, element interface{}) (ok, found bool) { - - listValue := reflect.ValueOf(list) - elementValue := reflect.ValueOf(element) - defer func() { - if e := recover(); e != nil { - ok = false - found = false - } - }() - - if reflect.TypeOf(list).Kind() == reflect.String { - return true, strings.Contains(listValue.String(), elementValue.String()) - } - - if reflect.TypeOf(list).Kind() == reflect.Map { - mapKeys := listValue.MapKeys() - for i := 0; i < len(mapKeys); i++ { - if ObjectsAreEqual(mapKeys[i].Interface(), element) { - return true, true - } - } - return true, false - } - - for i := 0; i < listValue.Len(); i++ { - if ObjectsAreEqual(listValue.Index(i).Interface(), element) { - return true, true - } - } - return true, false - -} - -// Contains asserts that the specified string, list(array, slice...) or map contains the -// specified substring or element. -// -// assert.Contains(t, "Hello World", "World") -// assert.Contains(t, ["Hello", "World"], "World") -// assert.Contains(t, {"Hello": "World"}, "Hello") -func Contains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - ok, found := includeElement(s, contains) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", s), msgAndArgs...) - } - if !found { - return Fail(t, fmt.Sprintf("\"%s\" does not contain \"%s\"", s, contains), msgAndArgs...) - } - - return true - -} - -// NotContains asserts that the specified string, list(array, slice...) or map does NOT contain the -// specified substring or element. -// -// assert.NotContains(t, "Hello World", "Earth") -// assert.NotContains(t, ["Hello", "World"], "Earth") -// assert.NotContains(t, {"Hello": "World"}, "Earth") -func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - ok, found := includeElement(s, contains) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", s), msgAndArgs...) - } - if found { - return Fail(t, fmt.Sprintf("\"%s\" should not contain \"%s\"", s, contains), msgAndArgs...) - } - - return true - -} - -// Subset asserts that the specified list(array, slice...) contains all -// elements given in the specified subset(array, slice...). -// -// assert.Subset(t, [1, 2, 3], [1, 2], "But [1, 2, 3] does contain [1, 2]") -func Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool) { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if subset == nil { - return true // we consider nil to be equal to the nil set - } - - subsetValue := reflect.ValueOf(subset) - defer func() { - if e := recover(); e != nil { - ok = false - } - }() - - listKind := reflect.TypeOf(list).Kind() - subsetKind := reflect.TypeOf(subset).Kind() - - if listKind != reflect.Array && listKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", list, listKind), msgAndArgs...) - } - - if subsetKind != reflect.Array && subsetKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", subset, subsetKind), msgAndArgs...) - } - - for i := 0; i < subsetValue.Len(); i++ { - element := subsetValue.Index(i).Interface() - ok, found := includeElement(list, element) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", list), msgAndArgs...) - } - if !found { - return Fail(t, fmt.Sprintf("\"%s\" does not contain \"%s\"", list, element), msgAndArgs...) - } - } - - return true -} - -// NotSubset asserts that the specified list(array, slice...) contains not all -// elements given in the specified subset(array, slice...). -// -// assert.NotSubset(t, [1, 3, 4], [1, 2], "But [1, 3, 4] does not contain [1, 2]") -func NotSubset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool) { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if subset == nil { - return Fail(t, fmt.Sprintf("nil is the empty set which is a subset of every set"), msgAndArgs...) - } - - subsetValue := reflect.ValueOf(subset) - defer func() { - if e := recover(); e != nil { - ok = false - } - }() - - listKind := reflect.TypeOf(list).Kind() - subsetKind := reflect.TypeOf(subset).Kind() - - if listKind != reflect.Array && listKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", list, listKind), msgAndArgs...) - } - - if subsetKind != reflect.Array && subsetKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", subset, subsetKind), msgAndArgs...) - } - - for i := 0; i < subsetValue.Len(); i++ { - element := subsetValue.Index(i).Interface() - ok, found := includeElement(list, element) - if !ok { - return Fail(t, fmt.Sprintf("\"%s\" could not be applied builtin len()", list), msgAndArgs...) - } - if !found { - return true - } - } - - return Fail(t, fmt.Sprintf("%q is a subset of %q", subset, list), msgAndArgs...) -} - -// ElementsMatch asserts that the specified listA(array, slice...) is equal to specified -// listB(array, slice...) ignoring the order of the elements. If there are duplicate elements, -// the number of appearances of each of them in both lists should match. -// -// assert.ElementsMatch(t, [1, 3, 2, 3], [1, 3, 3, 2]) -func ElementsMatch(t TestingT, listA, listB interface{}, msgAndArgs ...interface{}) (ok bool) { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if isEmpty(listA) && isEmpty(listB) { - return true - } - - aKind := reflect.TypeOf(listA).Kind() - bKind := reflect.TypeOf(listB).Kind() - - if aKind != reflect.Array && aKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", listA, aKind), msgAndArgs...) - } - - if bKind != reflect.Array && bKind != reflect.Slice { - return Fail(t, fmt.Sprintf("%q has an unsupported type %s", listB, bKind), msgAndArgs...) - } - - aValue := reflect.ValueOf(listA) - bValue := reflect.ValueOf(listB) - - aLen := aValue.Len() - bLen := bValue.Len() - - if aLen != bLen { - return Fail(t, fmt.Sprintf("lengths don't match: %d != %d", aLen, bLen), msgAndArgs...) - } - - // Mark indexes in bValue that we already used - visited := make([]bool, bLen) - for i := 0; i < aLen; i++ { - element := aValue.Index(i).Interface() - found := false - for j := 0; j < bLen; j++ { - if visited[j] { - continue - } - if ObjectsAreEqual(bValue.Index(j).Interface(), element) { - visited[j] = true - found = true - break - } - } - if !found { - return Fail(t, fmt.Sprintf("element %s appears more times in %s than in %s", element, aValue, bValue), msgAndArgs...) - } - } - - return true -} - -// Condition uses a Comparison to assert a complex condition. -func Condition(t TestingT, comp Comparison, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - result := comp() - if !result { - Fail(t, "Condition failed!", msgAndArgs...) - } - return result -} - -// PanicTestFunc defines a func that should be passed to the assert.Panics and assert.NotPanics -// methods, and represents a simple func that takes no arguments, and returns nothing. -type PanicTestFunc func() - -// didPanic returns true if the function passed to it panics. Otherwise, it returns false. -func didPanic(f PanicTestFunc) (bool, interface{}) { - - didPanic := false - var message interface{} - func() { - - defer func() { - if message = recover(); message != nil { - didPanic = true - } - }() - - // call the target function - f() - - }() - - return didPanic, message - -} - -// Panics asserts that the code inside the specified PanicTestFunc panics. -// -// assert.Panics(t, func(){ GoCrazy() }) -func Panics(t TestingT, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if funcDidPanic, panicValue := didPanic(f); !funcDidPanic { - return Fail(t, fmt.Sprintf("func %#v should panic\n\tPanic value:\t%#v", f, panicValue), msgAndArgs...) - } - - return true -} - -// PanicsWithValue asserts that the code inside the specified PanicTestFunc panics, and that -// the recovered panic value equals the expected panic value. -// -// assert.PanicsWithValue(t, "crazy error", func(){ GoCrazy() }) -func PanicsWithValue(t TestingT, expected interface{}, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - funcDidPanic, panicValue := didPanic(f) - if !funcDidPanic { - return Fail(t, fmt.Sprintf("func %#v should panic\n\tPanic value:\t%#v", f, panicValue), msgAndArgs...) - } - if panicValue != expected { - return Fail(t, fmt.Sprintf("func %#v should panic with value:\t%#v\n\tPanic value:\t%#v", f, expected, panicValue), msgAndArgs...) - } - - return true -} - -// NotPanics asserts that the code inside the specified PanicTestFunc does NOT panic. -// -// assert.NotPanics(t, func(){ RemainCalm() }) -func NotPanics(t TestingT, f PanicTestFunc, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if funcDidPanic, panicValue := didPanic(f); funcDidPanic { - return Fail(t, fmt.Sprintf("func %#v should not panic\n\tPanic value:\t%v", f, panicValue), msgAndArgs...) - } - - return true -} - -// WithinDuration asserts that the two times are within duration delta of each other. -// -// assert.WithinDuration(t, time.Now(), time.Now(), 10*time.Second) -func WithinDuration(t TestingT, expected, actual time.Time, delta time.Duration, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - dt := expected.Sub(actual) - if dt < -delta || dt > delta { - return Fail(t, fmt.Sprintf("Max difference between %v and %v allowed is %v, but difference was %v", expected, actual, delta, dt), msgAndArgs...) - } - - return true -} - -func toFloat(x interface{}) (float64, bool) { - var xf float64 - xok := true - - switch xn := x.(type) { - case uint8: - xf = float64(xn) - case uint16: - xf = float64(xn) - case uint32: - xf = float64(xn) - case uint64: - xf = float64(xn) - case int: - xf = float64(xn) - case int8: - xf = float64(xn) - case int16: - xf = float64(xn) - case int32: - xf = float64(xn) - case int64: - xf = float64(xn) - case float32: - xf = float64(xn) - case float64: - xf = float64(xn) - case time.Duration: - xf = float64(xn) - default: - xok = false - } - - return xf, xok -} - -// InDelta asserts that the two numerals are within delta of each other. -// -// assert.InDelta(t, math.Pi, (22 / 7.0), 0.01) -func InDelta(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - af, aok := toFloat(expected) - bf, bok := toFloat(actual) - - if !aok || !bok { - return Fail(t, fmt.Sprintf("Parameters must be numerical"), msgAndArgs...) - } - - if math.IsNaN(af) { - return Fail(t, fmt.Sprintf("Expected must not be NaN"), msgAndArgs...) - } - - if math.IsNaN(bf) { - return Fail(t, fmt.Sprintf("Expected %v with delta %v, but was NaN", expected, delta), msgAndArgs...) - } - - dt := af - bf - if dt < -delta || dt > delta { - return Fail(t, fmt.Sprintf("Max difference between %v and %v allowed is %v, but difference was %v", expected, actual, delta, dt), msgAndArgs...) - } - - return true -} - -// InDeltaSlice is the same as InDelta, except it compares two slices. -func InDeltaSlice(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if expected == nil || actual == nil || - reflect.TypeOf(actual).Kind() != reflect.Slice || - reflect.TypeOf(expected).Kind() != reflect.Slice { - return Fail(t, fmt.Sprintf("Parameters must be slice"), msgAndArgs...) - } - - actualSlice := reflect.ValueOf(actual) - expectedSlice := reflect.ValueOf(expected) - - for i := 0; i < actualSlice.Len(); i++ { - result := InDelta(t, actualSlice.Index(i).Interface(), expectedSlice.Index(i).Interface(), delta, msgAndArgs...) - if !result { - return result - } - } - - return true -} - -// InDeltaMapValues is the same as InDelta, but it compares all values between two maps. Both maps must have exactly the same keys. -func InDeltaMapValues(t TestingT, expected, actual interface{}, delta float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if expected == nil || actual == nil || - reflect.TypeOf(actual).Kind() != reflect.Map || - reflect.TypeOf(expected).Kind() != reflect.Map { - return Fail(t, "Arguments must be maps", msgAndArgs...) - } - - expectedMap := reflect.ValueOf(expected) - actualMap := reflect.ValueOf(actual) - - if expectedMap.Len() != actualMap.Len() { - return Fail(t, "Arguments must have the same number of keys", msgAndArgs...) - } - - for _, k := range expectedMap.MapKeys() { - ev := expectedMap.MapIndex(k) - av := actualMap.MapIndex(k) - - if !ev.IsValid() { - return Fail(t, fmt.Sprintf("missing key %q in expected map", k), msgAndArgs...) - } - - if !av.IsValid() { - return Fail(t, fmt.Sprintf("missing key %q in actual map", k), msgAndArgs...) - } - - if !InDelta( - t, - ev.Interface(), - av.Interface(), - delta, - msgAndArgs..., - ) { - return false - } - } - - return true -} - -func calcRelativeError(expected, actual interface{}) (float64, error) { - af, aok := toFloat(expected) - if !aok { - return 0, fmt.Errorf("expected value %q cannot be converted to float", expected) - } - if af == 0 { - return 0, fmt.Errorf("expected value must have a value other than zero to calculate the relative error") - } - bf, bok := toFloat(actual) - if !bok { - return 0, fmt.Errorf("actual value %q cannot be converted to float", actual) - } - - return math.Abs(af-bf) / math.Abs(af), nil -} - -// InEpsilon asserts that expected and actual have a relative error less than epsilon -func InEpsilon(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - actualEpsilon, err := calcRelativeError(expected, actual) - if err != nil { - return Fail(t, err.Error(), msgAndArgs...) - } - if actualEpsilon > epsilon { - return Fail(t, fmt.Sprintf("Relative error is too high: %#v (expected)\n"+ - " < %#v (actual)", epsilon, actualEpsilon), msgAndArgs...) - } - - return true -} - -// InEpsilonSlice is the same as InEpsilon, except it compares each value from two slices. -func InEpsilonSlice(t TestingT, expected, actual interface{}, epsilon float64, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if expected == nil || actual == nil || - reflect.TypeOf(actual).Kind() != reflect.Slice || - reflect.TypeOf(expected).Kind() != reflect.Slice { - return Fail(t, fmt.Sprintf("Parameters must be slice"), msgAndArgs...) - } - - actualSlice := reflect.ValueOf(actual) - expectedSlice := reflect.ValueOf(expected) - - for i := 0; i < actualSlice.Len(); i++ { - result := InEpsilon(t, actualSlice.Index(i).Interface(), expectedSlice.Index(i).Interface(), epsilon) - if !result { - return result - } - } - - return true -} - -/* - Errors -*/ - -// NoError asserts that a function returned no error (i.e. `nil`). -// -// actualObj, err := SomeFunction() -// if assert.NoError(t, err) { -// assert.Equal(t, expectedObj, actualObj) -// } -func NoError(t TestingT, err error, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if err != nil { - return Fail(t, fmt.Sprintf("Received unexpected error:\n%+v", err), msgAndArgs...) - } - - return true -} - -// Error asserts that a function returned an error (i.e. not `nil`). -// -// actualObj, err := SomeFunction() -// if assert.Error(t, err) { -// assert.Equal(t, expectedError, err) -// } -func Error(t TestingT, err error, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - if err == nil { - return Fail(t, "An error is expected but got nil.", msgAndArgs...) - } - - return true -} - -// EqualError asserts that a function returned an error (i.e. not `nil`) -// and that it is equal to the provided error. -// -// actualObj, err := SomeFunction() -// assert.EqualError(t, err, expectedErrorString) -func EqualError(t TestingT, theError error, errString string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if !Error(t, theError, msgAndArgs...) { - return false - } - expected := errString - actual := theError.Error() - // don't need to use deep equals here, we know they are both strings - if expected != actual { - return Fail(t, fmt.Sprintf("Error message not equal:\n"+ - "expected: %q\n"+ - "actual : %q", expected, actual), msgAndArgs...) - } - return true -} - -// matchRegexp return true if a specified regexp matches a string. -func matchRegexp(rx interface{}, str interface{}) bool { - - var r *regexp.Regexp - if rr, ok := rx.(*regexp.Regexp); ok { - r = rr - } else { - r = regexp.MustCompile(fmt.Sprint(rx)) - } - - return (r.FindStringIndex(fmt.Sprint(str)) != nil) - -} - -// Regexp asserts that a specified regexp matches a string. -// -// assert.Regexp(t, regexp.MustCompile("start"), "it's starting") -// assert.Regexp(t, "start...$", "it's not starting") -func Regexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - - match := matchRegexp(rx, str) - - if !match { - Fail(t, fmt.Sprintf("Expect \"%v\" to match \"%v\"", str, rx), msgAndArgs...) - } - - return match -} - -// NotRegexp asserts that a specified regexp does not match a string. -// -// assert.NotRegexp(t, regexp.MustCompile("starts"), "it's starting") -// assert.NotRegexp(t, "^start", "it's not starting") -func NotRegexp(t TestingT, rx interface{}, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - match := matchRegexp(rx, str) - - if match { - Fail(t, fmt.Sprintf("Expect \"%v\" to NOT match \"%v\"", str, rx), msgAndArgs...) - } - - return !match - -} - -// Zero asserts that i is the zero value for its type. -func Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if i != nil && !reflect.DeepEqual(i, reflect.Zero(reflect.TypeOf(i)).Interface()) { - return Fail(t, fmt.Sprintf("Should be zero, but was %v", i), msgAndArgs...) - } - return true -} - -// NotZero asserts that i is not the zero value for its type. -func NotZero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - if i == nil || reflect.DeepEqual(i, reflect.Zero(reflect.TypeOf(i)).Interface()) { - return Fail(t, fmt.Sprintf("Should not be zero, but was %v", i), msgAndArgs...) - } - return true -} - -// FileExists checks whether a file exists in the given path. It also fails if the path points to a directory or there is an error when trying to check the file. -func FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - info, err := os.Lstat(path) - if err != nil { - if os.IsNotExist(err) { - return Fail(t, fmt.Sprintf("unable to find file %q", path), msgAndArgs...) - } - return Fail(t, fmt.Sprintf("error when running os.Lstat(%q): %s", path, err), msgAndArgs...) - } - if info.IsDir() { - return Fail(t, fmt.Sprintf("%q is a directory", path), msgAndArgs...) - } - return true -} - -// DirExists checks whether a directory exists in the given path. It also fails if the path is a file rather a directory or there is an error checking whether it exists. -func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - info, err := os.Lstat(path) - if err != nil { - if os.IsNotExist(err) { - return Fail(t, fmt.Sprintf("unable to find file %q", path), msgAndArgs...) - } - return Fail(t, fmt.Sprintf("error when running os.Lstat(%q): %s", path, err), msgAndArgs...) - } - if !info.IsDir() { - return Fail(t, fmt.Sprintf("%q is a file", path), msgAndArgs...) - } - return true -} - -// JSONEq asserts that two JSON strings are equivalent. -// -// assert.JSONEq(t, `{"hello": "world", "foo": "bar"}`, `{"foo": "bar", "hello": "world"}`) -func JSONEq(t TestingT, expected string, actual string, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - var expectedJSONAsInterface, actualJSONAsInterface interface{} - - if err := json.Unmarshal([]byte(expected), &expectedJSONAsInterface); err != nil { - return Fail(t, fmt.Sprintf("Expected value ('%s') is not valid json.\nJSON parsing error: '%s'", expected, err.Error()), msgAndArgs...) - } - - if err := json.Unmarshal([]byte(actual), &actualJSONAsInterface); err != nil { - return Fail(t, fmt.Sprintf("Input ('%s') needs to be valid json.\nJSON parsing error: '%s'", actual, err.Error()), msgAndArgs...) - } - - return Equal(t, expectedJSONAsInterface, actualJSONAsInterface, msgAndArgs...) -} - -func typeAndKind(v interface{}) (reflect.Type, reflect.Kind) { - t := reflect.TypeOf(v) - k := t.Kind() - - if k == reflect.Ptr { - t = t.Elem() - k = t.Kind() - } - return t, k -} - -// diff returns a diff of both values as long as both are of the same type and -// are a struct, map, slice or array. Otherwise it returns an empty string. -func diff(expected interface{}, actual interface{}) string { - if expected == nil || actual == nil { - return "" - } - - et, ek := typeAndKind(expected) - at, _ := typeAndKind(actual) - - if et != at { - return "" - } - - if ek != reflect.Struct && ek != reflect.Map && ek != reflect.Slice && ek != reflect.Array && ek != reflect.String { - return "" - } - - var e, a string - if ek != reflect.String { - e = spewConfig.Sdump(expected) - a = spewConfig.Sdump(actual) - } else { - e = expected.(string) - a = actual.(string) - } - - diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ - A: difflib.SplitLines(e), - B: difflib.SplitLines(a), - FromFile: "Expected", - FromDate: "", - ToFile: "Actual", - ToDate: "", - Context: 1, - }) - - return "\n\nDiff:\n" + diff -} - -// validateEqualArgs checks whether provided arguments can be safely used in the -// Equal/NotEqual functions. -func validateEqualArgs(expected, actual interface{}) error { - if isFunction(expected) || isFunction(actual) { - return errors.New("cannot take func type as argument") - } - return nil -} - -func isFunction(arg interface{}) bool { - if arg == nil { - return false - } - return reflect.TypeOf(arg).Kind() == reflect.Func -} - -var spewConfig = spew.ConfigState{ - Indent: " ", - DisablePointerAddresses: true, - DisableCapacities: true, - SortKeys: true, -} - -type tHelper interface { - Helper() -} diff --git a/vendor/github.com/stretchr/testify/assert/doc.go b/vendor/github.com/stretchr/testify/assert/doc.go deleted file mode 100644 index c9dccc4d6..000000000 --- a/vendor/github.com/stretchr/testify/assert/doc.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package assert provides a set of comprehensive testing tools for use with the normal Go testing system. -// -// Example Usage -// -// The following is a complete example using assert in a standard test function: -// import ( -// "testing" -// "github.com/stretchr/testify/assert" -// ) -// -// func TestSomething(t *testing.T) { -// -// var a string = "Hello" -// var b string = "Hello" -// -// assert.Equal(t, a, b, "The two words should be the same.") -// -// } -// -// if you assert many times, use the format below: -// -// import ( -// "testing" -// "github.com/stretchr/testify/assert" -// ) -// -// func TestSomething(t *testing.T) { -// assert := assert.New(t) -// -// var a string = "Hello" -// var b string = "Hello" -// -// assert.Equal(a, b, "The two words should be the same.") -// } -// -// Assertions -// -// Assertions allow you to easily write test code, and are global funcs in the `assert` package. -// All assertion functions take, as the first argument, the `*testing.T` object provided by the -// testing framework. This allows the assertion funcs to write the failings and other details to -// the correct place. -// -// Every assertion function also takes an optional string message as the final argument, -// allowing custom error messages to be appended to the message the assertion method outputs. -package assert diff --git a/vendor/github.com/stretchr/testify/assert/errors.go b/vendor/github.com/stretchr/testify/assert/errors.go deleted file mode 100644 index ac9dc9d1d..000000000 --- a/vendor/github.com/stretchr/testify/assert/errors.go +++ /dev/null @@ -1,10 +0,0 @@ -package assert - -import ( - "errors" -) - -// AnError is an error instance useful for testing. If the code does not care -// about error specifics, and only needs to return the error for example, this -// error should be used to make the test code more readable. -var AnError = errors.New("assert.AnError general error for testing") diff --git a/vendor/github.com/stretchr/testify/assert/forward_assertions.go b/vendor/github.com/stretchr/testify/assert/forward_assertions.go deleted file mode 100644 index 9ad56851d..000000000 --- a/vendor/github.com/stretchr/testify/assert/forward_assertions.go +++ /dev/null @@ -1,16 +0,0 @@ -package assert - -// Assertions provides assertion methods around the -// TestingT interface. -type Assertions struct { - t TestingT -} - -// New makes a new Assertions object for the specified TestingT. -func New(t TestingT) *Assertions { - return &Assertions{ - t: t, - } -} - -//go:generate go run ../_codegen/main.go -output-package=assert -template=assertion_forward.go.tmpl -include-format-funcs diff --git a/vendor/github.com/stretchr/testify/assert/http_assertions.go b/vendor/github.com/stretchr/testify/assert/http_assertions.go deleted file mode 100644 index df46fa777..000000000 --- a/vendor/github.com/stretchr/testify/assert/http_assertions.go +++ /dev/null @@ -1,143 +0,0 @@ -package assert - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" -) - -// httpCode is a helper that returns HTTP code of the response. It returns -1 and -// an error if building a new request fails. -func httpCode(handler http.HandlerFunc, method, url string, values url.Values) (int, error) { - w := httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - if err != nil { - return -1, err - } - req.URL.RawQuery = values.Encode() - handler(w, req) - return w.Code, nil -} - -// HTTPSuccess asserts that a specified handler returns a success status code. -// -// assert.HTTPSuccess(t, myHandler, "POST", "http://www.google.com", nil) -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPSuccess(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - code, err := httpCode(handler, method, url, values) - if err != nil { - Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err)) - return false - } - - isSuccessCode := code >= http.StatusOK && code <= http.StatusPartialContent - if !isSuccessCode { - Fail(t, fmt.Sprintf("Expected HTTP success status code for %q but received %d", url+"?"+values.Encode(), code)) - } - - return isSuccessCode -} - -// HTTPRedirect asserts that a specified handler returns a redirect status code. -// -// assert.HTTPRedirect(t, myHandler, "GET", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPRedirect(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - code, err := httpCode(handler, method, url, values) - if err != nil { - Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err)) - return false - } - - isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect - if !isRedirectCode { - Fail(t, fmt.Sprintf("Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code)) - } - - return isRedirectCode -} - -// HTTPError asserts that a specified handler returns an error status code. -// -// assert.HTTPError(t, myHandler, "POST", "/a/b/c", url.Values{"a": []string{"b", "c"}} -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPError(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - code, err := httpCode(handler, method, url, values) - if err != nil { - Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err)) - return false - } - - isErrorCode := code >= http.StatusBadRequest - if !isErrorCode { - Fail(t, fmt.Sprintf("Expected HTTP error status code for %q but received %d", url+"?"+values.Encode(), code)) - } - - return isErrorCode -} - -// HTTPBody is a helper that returns HTTP body of the response. It returns -// empty string if building a new request fails. -func HTTPBody(handler http.HandlerFunc, method, url string, values url.Values) string { - w := httptest.NewRecorder() - req, err := http.NewRequest(method, url+"?"+values.Encode(), nil) - if err != nil { - return "" - } - handler(w, req) - return w.Body.String() -} - -// HTTPBodyContains asserts that a specified handler returns a -// body that contains a string. -// -// assert.HTTPBodyContains(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyContains(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - body := HTTPBody(handler, method, url, values) - - contains := strings.Contains(body, fmt.Sprint(str)) - if !contains { - Fail(t, fmt.Sprintf("Expected response body for \"%s\" to contain \"%s\" but found \"%s\"", url+"?"+values.Encode(), str, body)) - } - - return contains -} - -// HTTPBodyNotContains asserts that a specified handler returns a -// body that does not contain a string. -// -// assert.HTTPBodyNotContains(t, myHandler, "GET", "www.google.com", nil, "I'm Feeling Lucky") -// -// Returns whether the assertion was successful (true) or not (false). -func HTTPBodyNotContains(t TestingT, handler http.HandlerFunc, method, url string, values url.Values, str interface{}, msgAndArgs ...interface{}) bool { - if h, ok := t.(tHelper); ok { - h.Helper() - } - body := HTTPBody(handler, method, url, values) - - contains := strings.Contains(body, fmt.Sprint(str)) - if contains { - Fail(t, fmt.Sprintf("Expected response body for \"%s\" to NOT contain \"%s\" but found \"%s\"", url+"?"+values.Encode(), str, body)) - } - - return !contains -} diff --git a/vendor/modules.txt b/vendor/modules.txt deleted file mode 100644 index cca25c04d..000000000 --- a/vendor/modules.txt +++ /dev/null @@ -1,22 +0,0 @@ -# github.com/davecgh/go-spew v1.1.1 -## explicit -github.com/davecgh/go-spew/spew -# github.com/go-test/deep v1.0.4 -## explicit -github.com/go-test/deep -# github.com/google/go-cmp v0.5.7 -## explicit -github.com/google/go-cmp/cmp -github.com/google/go-cmp/cmp/internal/diff -github.com/google/go-cmp/cmp/internal/flags -github.com/google/go-cmp/cmp/internal/function -github.com/google/go-cmp/cmp/internal/value -# github.com/gorilla/websocket v1.4.2 -## explicit -github.com/gorilla/websocket -# github.com/pmezard/go-difflib v1.0.0 -## explicit -github.com/pmezard/go-difflib/difflib -# github.com/stretchr/testify v1.2.2 -## explicit -github.com/stretchr/testify/assert diff --git a/views.go b/views.go index a3a1bd056..c16503c0e 100644 --- a/views.go +++ b/views.go @@ -18,24 +18,25 @@ type ViewState struct { type View struct { SlackResponse - ID string `json:"id"` - TeamID string `json:"team_id"` - Type ViewType `json:"type"` - Title *TextBlockObject `json:"title"` - Close *TextBlockObject `json:"close"` - Submit *TextBlockObject `json:"submit"` - Blocks Blocks `json:"blocks"` - PrivateMetadata string `json:"private_metadata"` - CallbackID string `json:"callback_id"` - State *ViewState `json:"state"` - Hash string `json:"hash"` - ClearOnClose bool `json:"clear_on_close"` - NotifyOnClose bool `json:"notify_on_close"` - RootViewID string `json:"root_view_id"` - PreviousViewID string `json:"previous_view_id"` - AppID string `json:"app_id"` - ExternalID string `json:"external_id"` - BotID string `json:"bot_id"` + ID string `json:"id"` + TeamID string `json:"team_id"` + Type ViewType `json:"type"` + Title *TextBlockObject `json:"title"` + Close *TextBlockObject `json:"close"` + Submit *TextBlockObject `json:"submit"` + Blocks Blocks `json:"blocks"` + PrivateMetadata string `json:"private_metadata"` + CallbackID string `json:"callback_id"` + State *ViewState `json:"state"` + Hash string `json:"hash"` + ClearOnClose bool `json:"clear_on_close"` + NotifyOnClose bool `json:"notify_on_close"` + RootViewID string `json:"root_view_id"` + PreviousViewID string `json:"previous_view_id"` + AppID string `json:"app_id"` + ExternalID string `json:"external_id"` + BotID string `json:"bot_id"` + AppInstalledTeamID string `json:"app_installed_team_id"` } type ViewSubmissionCallbackResponseURL struct { @@ -69,12 +70,30 @@ type ViewSubmissionResponse struct { Errors map[string]string `json:"errors,omitempty"` } +// NewClearViewSubmissionResponse closes all open modals in the current stack. +// +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response +// body. The response is not sent until the handler returns, so start any slow +// work in a goroutine and return promptly. +// +// For Socket Mode apps, pass this as the payload argument to Ack(). +// +// See https://docs.slack.dev/surfaces/modals#closing_views func NewClearViewSubmissionResponse() *ViewSubmissionResponse { return &ViewSubmissionResponse{ ResponseAction: RAClear, } } +// NewUpdateViewSubmissionResponse replaces the current modal with a new view. +// +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response +// body. The response is not sent until the handler returns, so start any slow +// work in a goroutine and return promptly. +// +// For Socket Mode apps, pass this as the payload argument to Ack(). +// +// See https://docs.slack.dev/surfaces/modals#updating_views func NewUpdateViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionResponse { return &ViewSubmissionResponse{ ResponseAction: RAUpdate, @@ -82,6 +101,15 @@ func NewUpdateViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionResp } } +// NewPushViewSubmissionResponse pushes a new view onto the modal stack. +// +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response +// body. The response is not sent until the handler returns, so start any slow +// work in a goroutine and return promptly. +// +// For Socket Mode apps, pass this as the payload argument to Ack(). +// +// See https://docs.slack.dev/surfaces/modals#pushing_views func NewPushViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionResponse { return &ViewSubmissionResponse{ ResponseAction: RAPush, @@ -89,6 +117,19 @@ func NewPushViewSubmissionResponse(view *ModalViewRequest) *ViewSubmissionRespon } } +// NewErrorsViewSubmissionResponse displays validation errors on form fields. +// +// The errors map keys must be the BlockID of an InputBlock in the view. Keys +// that reference other block types (e.g. SectionBlock) are silently ignored +// by Slack, which shows a generic "trouble connecting" error instead. +// +// For HTTP-based apps, marshal this to JSON and write it as the HTTP response +// body. The response is not sent until the handler returns, so start any slow +// work in a goroutine and return promptly. +// +// For Socket Mode apps, pass this as the payload argument to Ack(). +// +// See https://docs.slack.dev/surfaces/modals/#displaying_errors func NewErrorsViewSubmissionResponse(errors map[string]string) *ViewSubmissionResponse { return &ViewSubmissionResponse{ ResponseAction: RAErrors, @@ -109,6 +150,12 @@ type ModalViewRequest struct { ExternalID string `json:"external_id,omitempty"` } +type PublishViewContextRequest struct { + UserID string `json:"user_id"` + View HomeTabViewRequest `json:"view"` + Hash *string `json:"hash,omitempty"` +} + func (v *ModalViewRequest) ViewType() ViewType { return v.Type } @@ -130,12 +177,6 @@ type openViewRequest struct { View ModalViewRequest `json:"view"` } -type publishViewRequest struct { - UserID string `json:"user_id"` - View HomeTabViewRequest `json:"view"` - Hash string `json:"hash,omitempty"` -} - type pushViewRequest struct { TriggerID string `json:"trigger_id"` View ModalViewRequest `json:"view"` @@ -154,6 +195,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) } @@ -165,6 +207,9 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool { for _, b := range view.Blocks.BlockSet { if inputBlock, ok := b.(*InputBlock); ok { + if inputBlock.BlockID == "" { + continue + } if _, ok := uniqueBlockID[inputBlock.BlockID]; ok { return false } @@ -176,6 +221,7 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool { } // OpenViewContext opens a view for a user with a custom context. +// Slack API docs: https://docs.slack.dev/reference/methods/views.open func (api *Client) OpenViewContext( ctx context.Context, triggerID string, @@ -197,9 +243,8 @@ func (api *Client) OpenViewContext( if err != nil { return nil, err } - endpoint := api.endpoint + "views.open" resp := &ViewResponse{} - err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api) + err = api.postJSONMethod(ctx, "views.open", api.token, encoded, resp) if err != nil { return nil, err } @@ -207,32 +252,30 @@ 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) + var hashPtr *string + if hash != "" { + hashPtr = &hash + } + return api.PublishViewContext(context.Background(), PublishViewContextRequest{UserID: userID, View: view, Hash: hashPtr}) } // PublishViewContext publishes a static view for a user with a custom context. +// Slack API docs: https://docs.slack.dev/reference/methods/views.publish func (api *Client) PublishViewContext( ctx context.Context, - userID string, - view HomeTabViewRequest, - hash string, + req PublishViewContextRequest, ) (*ViewResponse, error) { - if userID == "" { + if req.UserID == "" { return nil, ErrParametersMissing } - req := publishViewRequest{ - UserID: userID, - View: view, - Hash: hash, - } encoded, err := json.Marshal(req) if err != nil { return nil, err } - endpoint := api.endpoint + "views.publish" resp := &ViewResponse{} - err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api) + err = api.postJSONMethod(ctx, "views.publish", api.token, encoded, resp) if err != nil { return nil, err } @@ -240,11 +283,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://docs.slack.dev/reference/methods/views.push func (api *Client) PushViewContext( ctx context.Context, triggerID string, @@ -261,9 +306,8 @@ func (api *Client) PushViewContext( if err != nil { return nil, err } - endpoint := api.endpoint + "views.push" resp := &ViewResponse{} - err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api) + err = api.postJSONMethod(ctx, "views.push", api.token, encoded, resp) if err != nil { return nil, err } @@ -271,11 +315,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://docs.slack.dev/reference/methods/views.update func (api *Client) UpdateViewContext( ctx context.Context, view ModalViewRequest, @@ -295,9 +341,8 @@ func (api *Client) UpdateViewContext( if err != nil { return nil, err } - endpoint := api.endpoint + "views.update" resp := &ViewResponse{} - err = postJSON(ctx, api.httpclient, endpoint, api.token, encoded, resp, api) + err = api.postJSONMethod(ctx, "views.update", api.token, encoded, resp) if err != nil { return nil, err } diff --git a/views_test.go b/views_test.go index 1dc1438ad..cfd715421 100644 --- a/views_test.go +++ b/views_test.go @@ -6,8 +6,9 @@ import ( "reflect" "testing" - "github.com/slack-go/slack/internal/errorsx" "github.com/stretchr/testify/assert" + + "github.com/slack-go/slack/internal/errorsx" ) var dummySlackErr = errorsx.String("dummy_error_from_slack") @@ -60,6 +61,21 @@ func TestSlack_OpenView(t *testing.T) { expectedResp: nil, expectedErr: ErrBlockIDNotUnique, }, + { + caseName: "allow multiple blocks with empty block IDs", + triggerID: "dummy_trigger_id", + modalViewRequest: ModalViewRequest{ + Blocks: Blocks{ + BlockSet: []Block{ + &InputBlock{BlockID: ""}, + &InputBlock{BlockID: ""}, + }, + }, + }, + rawResp: `{"ok": true, "view": {}}`, + expectedResp: &ViewResponse{SlackResponse{Ok: true}, View{}}, + expectedErr: nil, + }, { caseName: "raise an error from Slack API", triggerID: "dummy_trigger_id", @@ -172,6 +188,10 @@ func TestSlack_OpenView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -331,6 +351,10 @@ func TestSlack_View_PublishView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -503,6 +527,10 @@ func TestSlack_PushView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -679,6 +707,10 @@ func TestSlack_UpdateView(t *testing.T) { Type: PlainTextType, Text: "A simple label", }, + &TextBlockObject{ + Type: PlainTextType, + Text: "A simple hint", + }, NewPlainTextInputBlockElement( &TextBlockObject{ Type: PlainTextType, @@ -750,7 +782,8 @@ func TestSlack_UpdateViewSubmissionResponse(t *testing.T) { "type": "modal", "title": { "type": "plain_text", - "text": "Test update view submission response" + "text": "Test update view submission response", + "emoji": false }, "blocks": [ { @@ -786,7 +819,8 @@ func TestSlack_PushViewSubmissionResponse(t *testing.T) { "type": "modal", "title": { "type": "plain_text", - "text": "Test update view submission response" + "text": "Test update view submission response", + "emoji": false }, "blocks": [ { @@ -795,7 +829,8 @@ func TestSlack_PushViewSubmissionResponse(t *testing.T) { "elements": [ { "type": "plain_text", - "text": "Context text" + "text": "Context text", + "emoji": false }, { "type": "image", @@ -813,16 +848,61 @@ func TestSlack_PushViewSubmissionResponse(t *testing.T) { func TestSlack_ErrorsViewSubmissionResponse(t *testing.T) { resp := NewErrorsViewSubmissionResponse(map[string]string{ - "input_text_action_id": "Please input a name that's at least 6 characters long", - "file_action_id": "File exceeded size limit of 5 KB", + "name_input_block": "Please input a name that's at least 6 characters long", + "file_input_block": "File exceeded size limit of 5 KB", }) rawResp := `{ "response_action": "errors", "errors": { - "input_text_action_id": "Please input a name that's at least 6 characters long", - "file_action_id": "File exceeded size limit of 5 KB" + "name_input_block": "Please input a name that's at least 6 characters long", + "file_input_block": "File exceeded size limit of 5 KB" } }` assertViewSubmissionResponse(t, resp, rawResp) } + +func TestPublishViewContextRequest_HashOmittedWhenNil(t *testing.T) { + tests := []struct { + name string + hash *string + wantHashKey bool + wantHashVal string + }{ + { + name: "nil hash omits field", + hash: nil, + wantHashKey: false, + }, + { + name: "non-empty hash includes field", + hash: new("156772938.1827394"), + wantHashKey: true, + wantHashVal: "156772938.1827394", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := PublishViewContextRequest{ + UserID: "U12345", + View: HomeTabViewRequest{Type: VTHomeTab}, + Hash: tt.hash, + } + + data, err := json.Marshal(req) + assert.NoError(t, err) + + var decoded map[string]any + err = json.Unmarshal(data, &decoded) + assert.NoError(t, err) + + _, hasHash := decoded["hash"] + assert.Equal(t, tt.wantHashKey, hasHash, "hash key presence mismatch") + + if tt.wantHashKey { + assert.Equal(t, tt.wantHashVal, decoded["hash"]) + } + }) + } +} diff --git a/warnings_test.go b/warnings_test.go new file mode 100644 index 000000000..d57e88fef --- /dev/null +++ b/warnings_test.go @@ -0,0 +1,183 @@ +package slack + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestWarn(t *testing.T) { + t.Run("top-level only", func(t *testing.T) { + resp := SlackResponse{Warning: "missing_charset"} + got := resp.Warn() + if got == nil { + t.Fatal("expected warning, got nil") + } + if len(got.Codes) != 1 || got.Codes[0] != "missing_charset" { + t.Fatalf("expected codes [missing_charset], got %v", got.Codes) + } + }) + + t.Run("metadata only", func(t *testing.T) { + resp := SlackResponse{ + ResponseMetadata: ResponseMetadata{ + Warnings: []string{"superfluous_charset"}, + }, + } + got := resp.Warn() + if got == nil { + t.Fatal("expected warning, got nil") + } + if len(got.Warnings) != 1 || got.Warnings[0] != "superfluous_charset" { + t.Fatalf("expected warnings [superfluous_charset], got %v", got.Warnings) + } + }) + + t.Run("both sources", func(t *testing.T) { + resp := SlackResponse{ + Warning: "missing_charset,deprecated", + ResponseMetadata: ResponseMetadata{ + Warnings: []string{"missing_charset", "other"}, + }, + } + got := resp.Warn() + if got == nil { + t.Fatal("expected warning, got nil") + } + if len(got.Codes) != 2 { + t.Fatalf("expected 2 codes, got %v", got.Codes) + } + if len(got.Warnings) != 2 { + t.Fatalf("expected 2 warnings, got %v", got.Warnings) + } + }) + + t.Run("no warnings", func(t *testing.T) { + resp := SlackResponse{Ok: true} + got := resp.Warn() + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } + }) +} + +func TestOptionOnWarning(t *testing.T) { + t.Run("callback fires on warning", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "ok": true, + "warning": "missing_charset", + "response_metadata": { + "warnings": ["missing_charset"] + } + }`)) + })) + defer ts.Close() + + var gotWarning *Warning + var gotPath string + var gotRequest any + api := New("test-token", + OptionAPIURL(ts.URL+"/"), + OptionOnWarning(func(path string, request any, w *Warning) { + gotWarning = w + gotPath = path + gotRequest = request + }), + ) + + _, err := api.AuthTestContext(t.Context()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotWarning == nil { + t.Fatal("expected warning, got nil") + } + if len(gotWarning.Codes) != 1 || gotWarning.Codes[0] != "missing_charset" { + t.Fatalf("expected codes [missing_charset], got %v", gotWarning.Codes) + } + if gotPath != "auth.test" { + t.Fatalf("expected path auth.test, got %s", gotPath) + } + if _, ok := gotRequest.(url.Values); !ok { + t.Fatalf("expected url.Values request, got %T", gotRequest) + } + }) + + t.Run("callback not fired when no warnings", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok": true}`)) + })) + defer ts.Close() + + called := false + api := New("test-token", + OptionAPIURL(ts.URL+"/"), + OptionOnWarning(func(path string, request any, w *Warning) { + called = true + }), + ) + + _, err := api.AuthTestContext(t.Context()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if called { + t.Fatal("callback should not have been called") + } + }) + + t.Run("callback fires on error response with warnings", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "ok": false, + "error": "invalid_auth", + "warning": "missing_charset", + "response_metadata": { + "warnings": ["missing_charset"] + } + }`)) + })) + defer ts.Close() + + var gotWarning *Warning + api := New("test-token", + OptionAPIURL(ts.URL+"/"), + OptionOnWarning(func(path string, request any, w *Warning) { + gotWarning = w + }), + ) + + _, err := api.AuthTestContext(t.Context()) + if err == nil { + t.Fatal("expected error") + } + if gotWarning == nil { + t.Fatal("expected warning, got nil") + } + if len(gotWarning.Codes) != 1 || gotWarning.Codes[0] != "missing_charset" { + t.Fatalf("expected codes [missing_charset], got %v", gotWarning.Codes) + } + }) + + t.Run("no callback registered is safe", func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "ok": true, + "warning": "missing_charset" + }`)) + })) + defer ts.Close() + + api := New("test-token", OptionAPIURL(ts.URL+"/")) + _, err := api.AuthTestContext(t.Context()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/webhooks.go b/webhooks.go index 15097f03e..729bce401 100644 --- a/webhooks.go +++ b/webhooks.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" ) @@ -19,8 +20,11 @@ type WebhookMessage struct { Parse string `json:"parse,omitempty"` Blocks *Blocks `json:"blocks,omitempty"` ResponseType string `json:"response_type,omitempty"` - ReplaceOriginal bool `json:"replace_original,omitempty"` - DeleteOriginal bool `json:"delete_original,omitempty"` + 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 { @@ -51,7 +55,10 @@ func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *h if err != nil { return fmt.Errorf("failed to post webhook: %w", err) } - defer resp.Body.Close() + defer func() { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() return checkStatusCode(resp, discard{}) } diff --git a/webhooks_test.go b/webhooks_test.go index 6068802db..79d3ec21a 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "reflect" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -66,6 +67,78 @@ func TestPostWebhook_NotOK(t *testing.T) { } } +func TestPostWebhook_MessageLimitExceeded(t *testing.T) { + once.Do(startServer) + + http.HandleFunc("/message_limit_exceeded", func(rw http.ResponseWriter, r *http.Request) { + // When a workspace's message limit is exceeded we get a 429 without a Retry-After header + rw.WriteHeader(http.StatusTooManyRequests) + rw.Write([]byte("message_limit_exceeded")) + }) + + url := "http://" + serverAddr + "/message_limit_exceeded" + + err := PostWebhook(url, &WebhookMessage{}) + + if err == nil { + t.Errorf("Expected to receive error") + } + assert.IsType(t, StatusCodeError{}, err) +} + +func TestWebhookMessage_UnfurlFields(t *testing.T) { + t.Run("nil omits fields", func(t *testing.T) { + msg := WebhookMessage{Text: "hello"} + raw, err := json.Marshal(msg) + assert.NoError(t, err) + assert.False(t, strings.Contains(string(raw), "unfurl_links")) + assert.False(t, strings.Contains(string(raw), "unfurl_media")) + }) + + t.Run("false is preserved", func(t *testing.T) { + msg := WebhookMessage{ + Text: "hello", + UnfurlLinks: new(false), + UnfurlMedia: new(false), + } + raw, err := json.Marshal(msg) + assert.NoError(t, err) + assert.Contains(t, string(raw), `"unfurl_links":false`) + assert.Contains(t, string(raw), `"unfurl_media":false`) + }) + + t.Run("true is preserved", func(t *testing.T) { + msg := WebhookMessage{ + Text: "hello", + UnfurlLinks: new(true), + UnfurlMedia: new(true), + } + raw, err := json.Marshal(msg) + assert.NoError(t, err) + assert.Contains(t, string(raw), `"unfurl_links":true`) + assert.Contains(t, string(raw), `"unfurl_media":true`) + }) + + t.Run("round-trip preserves values", func(t *testing.T) { + original := WebhookMessage{ + Text: "hello", + UnfurlLinks: new(false), + UnfurlMedia: new(true), + } + raw, err := json.Marshal(original) + assert.NoError(t, err) + + var decoded WebhookMessage + err = json.Unmarshal(raw, &decoded) + assert.NoError(t, err) + assert.Equal(t, original.Text, decoded.Text) + assert.NotNil(t, decoded.UnfurlLinks) + assert.False(t, *decoded.UnfurlLinks) + assert.NotNil(t, decoded.UnfurlMedia) + assert.True(t, *decoded.UnfurlMedia) + }) +} + func TestWebhookMessage_WithBlocks(t *testing.T) { textBlockObject := NewTextBlockObject("plain_text", "text", false, false) sectionBlock := NewSectionBlock(textBlockObject, nil, nil) @@ -77,15 +150,15 @@ func TestWebhookMessage_WithBlocks(t *testing.T) { assert.Equal(t, 1, len(msgSingleBlock.Blocks.BlockSet)) msgJsonSingleBlock, _ := json.Marshal(msgSingleBlock) - assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}}]}`, string(msgJsonSingleBlock)) + assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text","emoji":false}}],"replace_original":false,"delete_original":false}`, string(msgJsonSingleBlock)) msgTwoBlocks := WebhookMessage{Blocks: twoBlocks} assert.Equal(t, 2, len(msgTwoBlocks.Blocks.BlockSet)) msgJsonTwoBlocks, _ := json.Marshal(msgTwoBlocks) - assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text"}},{"type":"section","text":{"type":"plain_text","text":"text"}}]}`, string(msgJsonTwoBlocks)) + assert.Equal(t, `{"blocks":[{"type":"section","text":{"type":"plain_text","text":"text","emoji":false}},{"type":"section","text":{"type":"plain_text","text":"text","emoji":false}}],"replace_original":false,"delete_original":false}`, string(msgJsonTwoBlocks)) msgNoBlocks := WebhookMessage{Text: "foo"} msgJsonNoBlocks, _ := json.Marshal(msgNoBlocks) - assert.Equal(t, `{"text":"foo"}`, string(msgJsonNoBlocks)) + assert.Equal(t, `{"text":"foo","replace_original":false,"delete_original":false}`, string(msgJsonNoBlocks)) } diff --git a/websocket_groups.go b/websocket_groups.go index eb88985c4..c35d5f357 100644 --- a/websocket_groups.go +++ b/websocket_groups.go @@ -7,9 +7,6 @@ type GroupCreatedEvent struct { Channel ChannelCreatedInfo `json:"channel"` } -// XXX: Should we really do this? event.Group is probably nicer than event.Channel -// even though the api returns "channel" - // GroupMarkedEvent represents the Group marked event type GroupMarkedEvent ChannelInfoEvent diff --git a/websocket_managed_conn.go b/websocket_managed_conn.go index 5555c3162..f58742b06 100644 --- a/websocket_managed_conn.go +++ b/websocket_managed_conn.go @@ -9,14 +9,38 @@ import ( "reflect" "time" - "github.com/slack-go/slack/internal/backoff" - "github.com/slack-go/slack/internal/misc" - "github.com/gorilla/websocket" + + "github.com/slack-go/slack/internal/backoff" "github.com/slack-go/slack/internal/errorsx" "github.com/slack-go/slack/internal/timex" ) +// UnmappedError represents error occurred when there is no mapping between given event name +// and corresponding Go struct. +type UnmappedError struct { + // EventType returns event type name. + EventType string + // RawEvent returns raw event body. + RawEvent json.RawMessage + + ctxMsg string +} + +// NewUnmappedError returns new UnmappedError instance. +func NewUnmappedError(ctxMsg, eventType string, raw json.RawMessage) *UnmappedError { + return &UnmappedError{ + ctxMsg: ctxMsg, + EventType: eventType, + RawEvent: raw, + } +} + +// Error returns human-readable error message. +func (u UnmappedError) Error() string { + return fmt.Sprintf("%s: Received unmapped event %q", u.ctxMsg, u.EventType) +} + // ManageConnection can be called on a Slack RTM instance returned by the // NewRTM method. It will connect to the slack RTM API and handle all incoming // and outgoing events. If a connection fails then it will attempt to reconnect @@ -127,7 +151,7 @@ func (rtm *RTM) connect(connectionCount int, useRTMStart bool) (*Info, *websocke } switch actual := err.(type) { - case misc.StatusCodeError: + case StatusCodeError: if actual.Code == http.StatusNotFound { rtm.Debugf("invalid auth when connecting with RTM: %s", err) rtm.IncomingEvents <- RTMEvent{"invalid_auth", &InvalidAuthEvent{}} @@ -297,7 +321,7 @@ func (rtm *RTM) handleIncomingEvents(events chan json.RawMessage) { } } -func (rtm *RTM) sendWithDeadline(msg interface{}) error { +func (rtm *RTM) sendWithDeadline(msg any) error { // set a write deadline on the connection if err := rtm.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { return err @@ -388,7 +412,7 @@ func (rtm *RTM) receiveIncomingEvent(events chan json.RawMessage) error { select { case events <- event: case <-rtm.disconnected: - rtm.Debugln("disonnected while attempting to send raw event") + rtm.Debugln("disconnected while attempting to send raw event") } } @@ -431,9 +455,10 @@ func (rtm *RTM) handleAck(event json.RawMessage) { return } - if ack.Ok { + switch { + case ack.Ok: rtm.IncomingEvents <- RTMEvent{"ack", ack} - } else if ack.RTMResponse.Error != nil { + case ack.RTMResponse.Error != nil: // As there is no documentation for RTM error-codes, this // identification of a rate-limit warning is very brittle. if ack.RTMResponse.Error.Code == -1 && ack.RTMResponse.Error.Msg == "slow down, too many messages..." { @@ -441,7 +466,7 @@ func (rtm *RTM) handleAck(event json.RawMessage) { } else { rtm.IncomingEvents <- RTMEvent{"ack_error", &AckErrorEvent{ack.Error, ack.ReplyTo}} } - } else { + default: rtm.IncomingEvents <- RTMEvent{"ack_error", &AckErrorEvent{ErrorObj: fmt.Errorf("ack decode failure")}} } } @@ -475,7 +500,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { v, exists := EventMapping[typeStr] if !exists { rtm.Debugf("RTM Error - received unmapped event %q: %s\n", typeStr, string(event)) - err := fmt.Errorf("RTM Error: Received unmapped event %q: %s", typeStr, string(event)) + err := NewUnmappedError("RTM Error", typeStr, event) rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} return } @@ -484,7 +509,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { err := json.Unmarshal(event, recvEvent) if err != nil { rtm.Debugf("RTM Error, could not unmarshall event %q: %s\n", typeStr, string(event)) - err := fmt.Errorf("RTM Error: Could not unmarshall event %q: %s", typeStr, string(event)) + err := fmt.Errorf("RTM Error: Could not unmarshall event %q", typeStr) rtm.IncomingEvents <- RTMEvent{"unmarshalling_error", &UnmarshallingErrorEvent{err}} return } @@ -494,7 +519,7 @@ func (rtm *RTM) handleEvent(typeStr string, event json.RawMessage) { // EventMapping holds a mapping of event names to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. -var EventMapping = map[string]interface{}{ +var EventMapping = map[string]any{ "message": MessageEvent{}, "presence_change": PresenceChangeEvent{}, "user_typing": UserTypingEvent{}, @@ -558,7 +583,10 @@ var EventMapping = map[string]interface{}{ "manual_presence_change": ManualPresenceChangeEvent{}, - "user_change": UserChangeEvent{}, + "user_change": UserChangeEvent{}, + "user_status_changed": UserStatusChangedEvent{}, + "user_huddle_changed": UserHuddleChangedEvent{}, + "user_profile_changed": UserProfileChangedEvent{}, "emoji_changed": EmojiChangedEvent{}, @@ -571,6 +599,10 @@ var EventMapping = map[string]interface{}{ "accounts_changed": AccountsChangedEvent{}, + "apps_uninstalled": AppsUninstalledEvent{}, + "activity": ActivityEvent{}, + "badge_counts_updated": BadgeCountsUpdatedEvent{}, + "reconnect_url": ReconnectUrlEvent{}, "member_joined_channel": MemberJoinedChannelEvent{}, @@ -584,4 +616,10 @@ var EventMapping = map[string]interface{}{ "desktop_notification": DesktopNotificationEvent{}, "mobile_in_app_notification": MobileInAppNotificationEvent{}, + + "channel_updated": ChannelUpdatedEvent{}, + + "sh_room_join": SHRoomJoinEvent{}, + "sh_room_leave": SHRoomLeaveEvent{}, + "sh_room_update": SHRoomUpdateEvent{}, } diff --git a/websocket_managed_conn_test.go b/websocket_managed_conn_test.go index bb4e3e4d5..983001605 100644 --- a/websocket_managed_conn_test.go +++ b/websocket_managed_conn_test.go @@ -1,16 +1,19 @@ package slack_test import ( + "encoding/json" "fmt" "log" "net/http" "testing" "time" - websocket "github.com/gorilla/websocket" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/slack-go/slack" "github.com/slack-go/slack/slacktest" - "github.com/stretchr/testify/assert" ) const ( @@ -312,3 +315,61 @@ func TestRTMSingleConnect(t *testing.T) { assert.True(t, connectedReceived, "Should have received a connected event from the RTM instance.") assert.True(t, testMessageReceived, "Should have received a test message from the server.") } + +func TestRTMUnmappedError(t *testing.T) { + const unmappedEventName = "some_unknown_event" + // Set up the test server. + testServer := slacktest.NewTestServer() + go testServer.Start() + + // Setup and start the RTM. + api := slack.New(testToken, slack.OptionAPIURL(testServer.GetAPIURL())) + rtm := api.NewRTM() + go rtm.ManageConnection() + + // Observe incoming messages. + done := make(chan struct{}) + var gotUnmarshallingError *slack.UnmarshallingErrorEvent + go func() { + for msg := range rtm.IncomingEvents { + switch ev := msg.Data.(type) { + case *slack.UnmarshallingErrorEvent: + gotUnmarshallingError = ev + rtm.Disconnect() + case *slack.DisconnectedEvent: + if ev.Intentional { + done <- struct{}{} + return + } + default: + t.Logf("Discarded event of type '%s' with content '%#v'", msg.Type, ev) + } + } + }() + + // Send a message and sleep for some time to make sure the message can be processed client-side. + testServer.SendToWebsocket(fixSlackMessage(t, unmappedEventName)) + <-done + testServer.Stop() + + // Verify that we got the expected error with details + unmappedErr, ok := gotUnmarshallingError.ErrorObj.(*slack.UnmappedError) + require.True(t, ok) + assert.Equal(t, unmappedEventName, unmappedErr.EventType) +} + +func fixSlackMessage(t *testing.T, eType string) string { + t.Helper() + + m := slack.Message{ + Msg: slack.Msg{ + Type: eType, + Text: "Fixture Slack message", + Timestamp: fmt.Sprintf("%d", time.Now().Unix()), + }, + } + msg, err := json.Marshal(m) + require.NoError(t, err) + + return string(msg) +} diff --git a/websocket_misc.go b/websocket_misc.go index 65a8bb65d..a12980f6b 100644 --- a/websocket_misc.go +++ b/websocket_misc.go @@ -35,7 +35,7 @@ type MessageEvent Message // RTMEvent is the main wrapper. You will find all the other messages attached type RTMEvent struct { Type string - Data interface{} + Data any } // HelloEvent represents the hello event @@ -71,8 +71,34 @@ type ManualPresenceChangeEvent struct { // UserChangeEvent represents the user change event type UserChangeEvent struct { - Type string `json:"type"` - User User `json:"user"` + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +// UserStatusChangedEvent represents the user status changed event +type UserStatusChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +// UserHuddleChangedEvent represents the user huddle changed event +type UserHuddleChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +// UserProfileChangedEvent represents the user profile changed event +type UserProfileChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` } // EmojiChangedEvent represents the emoji changed event @@ -139,3 +165,115 @@ type MemberLeftChannelEvent struct { ChannelType string `json:"channel_type"` Team string `json:"team"` } + +// ChannelUpdatedEvent is fired when a channel's properties are updated (tabs, meeting +// notes, etc.). +type ChannelUpdatedEvent struct { + Type string `json:"type"` + Updates map[string]any `json:"updates"` + Channel string `json:"channel"` + Channels []string `json:"channels"` + EventTS string `json:"event_ts"` + TS string `json:"ts"` +} + +// SHRoomRecording holds recording metadata for a Slack Call/Huddle room. +type SHRoomRecording struct { + CanRecordSummary string `json:"can_record_summary,omitempty"` +} + +// SHRoom represents a Slack Huddle/Call room. +type SHRoom struct { + ID string `json:"id"` + Name *string `json:"name"` // nullable in Slack's response + MediaServer string `json:"media_server"` + CreatedBy string `json:"created_by"` + DateStart int64 `json:"date_start"` + DateEnd int64 `json:"date_end"` + Participants []string `json:"participants"` + ParticipantHistory []string `json:"participant_history"` + ParticipantsEvents map[string]map[string]any `json:"participants_events,omitempty"` + ParticipantsCameraOn []string `json:"participants_camera_on"` + ParticipantsCameraOff []string `json:"participants_camera_off"` + ParticipantsScreenshareOn []string `json:"participants_screenshare_on"` + ParticipantsScreenshareOff []string `json:"participants_screenshare_off"` + CanvasThreadTS string `json:"canvas_thread_ts,omitempty"` + ThreadRootTS string `json:"thread_root_ts,omitempty"` + 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"` + BackgroundID string `json:"background_id,omitempty"` + CanvasBackground string `json:"canvas_background,omitempty"` + IsPrewarmed bool `json:"is_prewarmed,omitempty"` + IsScheduled bool `json:"is_scheduled,omitempty"` + Recording *SHRoomRecording `json:"recording,omitempty"` + Locale string `json:"locale,omitempty"` + AttachedFileIDs []string `json:"attached_file_ids,omitempty"` + MediaBackendType string `json:"media_backend_type"` + DisplayID string `json:"display_id,omitempty"` + ExternalUniqueID string `json:"external_unique_id"` + AppID string `json:"app_id"` + CallFamily string `json:"call_family,omitempty"` + HuddleLink string `json:"huddle_link,omitempty"` +} + +// SHRoomHuddle holds the huddle-specific metadata on sh_room events. +type SHRoomHuddle struct { + ChannelID string `json:"channel_id"` +} + +// SHRoomJoinEvent is fired when a user joins a Slack Call/Huddle room. +type SHRoomJoinEvent struct { + Type string `json:"type"` + Room SHRoom `json:"room"` + User string `json:"user"` + Huddle *SHRoomHuddle `json:"huddle,omitempty"` + EventTS string `json:"event_ts"` + TS string `json:"ts"` +} + +// SHRoomLeaveEvent is fired when a user leaves a Slack Call/Huddle room. +type SHRoomLeaveEvent struct { + Type string `json:"type"` + Room SHRoom `json:"room"` + User string `json:"user"` + Huddle *SHRoomHuddle `json:"huddle,omitempty"` + EventTS string `json:"event_ts"` + TS string `json:"ts"` +} + +// SHRoomUpdateEvent is fired when a Slack Call/Huddle room is updated. +type SHRoomUpdateEvent struct { + Type string `json:"type"` + Room SHRoom `json:"room"` + User string `json:"user"` + Huddle *SHRoomHuddle `json:"huddle,omitempty"` + EventTS string `json:"event_ts"` + TS string `json:"ts"` +} + +// AppsUninstalledEvent represents the apps_uninstalled event sent via RTM +// when one or more apps are uninstalled from the workspace. +type AppsUninstalledEvent struct { + Type string `json:"type"` +} + +// ActivityEvent represents the activity event sent via RTM. This is an +// internal Slack event that fires during normal workspace usage (e.g. new +// messages, bundle updates). +type ActivityEvent struct { + Type string `json:"type"` + SubType string `json:"subtype"` + Key string `json:"key"` + Entry json.RawMessage `json:"entry"` + EventTimestamp string `json:"event_ts"` +} + +// BadgeCountsUpdatedEvent represents the badge_counts_updated event sent via +// RTM when notification badge counts change. +type BadgeCountsUpdatedEvent struct { + Type string `json:"type"` +} diff --git a/websocket_misc_test.go b/websocket_misc_test.go new file mode 100644 index 000000000..b1a27956e --- /dev/null +++ b/websocket_misc_test.go @@ -0,0 +1,167 @@ +package slack + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSHRoomJoinEventUnmarshal(t *testing.T) { + raw := `{ + "type": "sh_room_join", + "room": { + "id": "R01XXXBW", + "name": null, + "media_server": "", + "created_by": "U12334", + "date_start": 1607089008, + "date_end": 0, + "participants": ["U12334", "U56789"], + "participant_history": ["U12334", "U56789"], + "participants_camera_on": [], + "participants_camera_off": [], + "participants_screenshare_on": [], + "participants_screenshare_off": [], + "channels": ["C12334"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false, + "media_backend_type": "free_willy", + "external_unique_id": "8c92471f-test", + "app_id": "A00" + }, + "user": "U12334", + "event_ts": "1607089059.080900", + "ts": "1607089059.080900" + }` + + var ev SHRoomJoinEvent + err := json.Unmarshal([]byte(raw), &ev) + require.NoError(t, err) + + assert.Equal(t, "sh_room_join", ev.Type) + assert.Equal(t, "U12334", ev.User) + assert.Equal(t, "R01XXXBW", ev.Room.ID) + assert.Nil(t, ev.Room.Name) + assert.Equal(t, "U12334", ev.Room.CreatedBy) + assert.Equal(t, int64(1607089008), ev.Room.DateStart) + assert.Equal(t, []string{"U12334", "U56789"}, ev.Room.Participants) + assert.Equal(t, []string{"C12334"}, ev.Room.Channels) + assert.False(t, ev.Room.IsDMCall) + assert.Equal(t, "free_willy", ev.Room.MediaBackendType) + assert.Equal(t, "1607089059.080900", ev.EventTS) +} + +func TestSHRoomLeaveEventUnmarshal(t *testing.T) { + raw := `{ + "type": "sh_room_leave", + "room": { + "id": "R01XXXBW", + "name": null, + "media_server": "", + "created_by": "U12334", + "date_start": 1607089008, + "date_end": 0, + "participants": ["U12334"], + "participant_history": ["U12334", "U56789"], + "participants_camera_on": [], + "participants_camera_off": [], + "participants_screenshare_on": [], + "participants_screenshare_off": [], + "channels": ["C12334"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false, + "media_backend_type": "free_willy", + "external_unique_id": "8c92471f-test", + "app_id": "A00" + }, + "user": "U56789", + "event_ts": "1607091086.081500", + "ts": "1607091086.081500" + }` + + var ev SHRoomLeaveEvent + err := json.Unmarshal([]byte(raw), &ev) + require.NoError(t, err) + + assert.Equal(t, "sh_room_leave", ev.Type) + assert.Equal(t, "U56789", ev.User) + assert.Equal(t, "R01XXXBW", ev.Room.ID) + assert.Equal(t, []string{"U12334"}, ev.Room.Participants) + assert.Equal(t, "1607091086.081500", ev.EventTS) +} + +func TestSHRoomUpdateEventUnmarshal(t *testing.T) { + raw := `{ + "type": "sh_room_update", + "room": { + "id": "R0AQSG0Q859", + "name": "A sort of topic", + "media_server": "", + "created_by": "U031L4VDD", + "date_start": 1775402709, + "date_end": 0, + "participants": ["U031L4VDD"], + "participant_history": ["U031L4VDD"], + "participants_events": {"U031L4VDD": {"joined": true, "camera_on": false}}, + "participants_camera_on": [], + "participants_camera_off": [], + "participants_screenshare_on": [], + "participants_screenshare_off": [], + "canvas_thread_ts": "1775402709.576349", + "thread_root_ts": "1775402709.576349", + "channels": ["C031L4VDP"], + "is_dm_call": false, + "was_rejected": false, + "was_missed": false, + "was_accepted": false, + "has_ended": false, + "background_id": "GRADIENT_02", + "canvas_background": "GRADIENT_02", + "is_prewarmed": true, + "is_scheduled": false, + "recording": {"can_record_summary": "unavailable"}, + "locale": "en-US", + "attached_file_ids": [], + "media_backend_type": "free_willy", + "display_id": "", + "external_unique_id": "755e016f-aae1-4d4f-abcc-952b1b872713", + "app_id": "A00", + "call_family": "huddle", + "huddle_link": "https://app.slack.com/huddle/T031L4VD9/C031L4VDP" + }, + "user": "U031L4VDD", + "huddle": {"channel_id": "C031L4VDP"}, + "event_ts": "1775402785.000200", + "ts": "1775402785.000200" + }` + + var ev SHRoomUpdateEvent + err := json.Unmarshal([]byte(raw), &ev) + require.NoError(t, err) + + assert.Equal(t, "sh_room_update", ev.Type) + assert.Equal(t, "U031L4VDD", ev.User) + assert.Equal(t, "R0AQSG0Q859", ev.Room.ID) + assert.NotNil(t, ev.Room.Name) + assert.Equal(t, "A sort of topic", *ev.Room.Name) + assert.Equal(t, "huddle", ev.Room.CallFamily) + assert.True(t, ev.Room.IsPrewarmed) + assert.Equal(t, "1775402709.576349", ev.Room.CanvasThreadTS) + assert.Equal(t, "GRADIENT_02", ev.Room.BackgroundID) + assert.Equal(t, "en-US", ev.Room.Locale) + assert.NotNil(t, ev.Room.Recording) + assert.Equal(t, "unavailable", ev.Room.Recording.CanRecordSummary) + assert.Equal(t, "https://app.slack.com/huddle/T031L4VD9/C031L4VDP", ev.Room.HuddleLink) + assert.NotNil(t, ev.Huddle) + assert.Equal(t, "C031L4VDP", ev.Huddle.ChannelID) + assert.NotNil(t, ev.Room.ParticipantsEvents) + assert.Contains(t, ev.Room.ParticipantsEvents, "U031L4VDD") +} diff --git a/websocket_reactions.go b/websocket_reactions.go index e49738783..6098a6cac 100644 --- a/websocket_reactions.go +++ b/websocket_reactions.go @@ -1,7 +1,7 @@ package slack -// reactionItem is a lighter-weight item than is returned by the reactions list. -type reactionItem struct { +// ReactionItem is a lighter-weight item than is returned by the reactions list. +type ReactionItem struct { Type string `json:"type"` Channel string `json:"channel,omitempty"` File string `json:"file,omitempty"` @@ -9,17 +9,17 @@ type reactionItem struct { Timestamp string `json:"ts,omitempty"` } -type reactionEvent struct { +type ReactionEvent struct { Type string `json:"type"` User string `json:"user"` ItemUser string `json:"item_user"` - Item reactionItem `json:"item"` + Item ReactionItem `json:"item"` Reaction string `json:"reaction"` EventTimestamp string `json:"event_ts"` } // ReactionAddedEvent represents the Reaction added event -type ReactionAddedEvent reactionEvent +type ReactionAddedEvent ReactionEvent // ReactionRemovedEvent represents the Reaction removed event -type ReactionRemovedEvent reactionEvent +type ReactionRemovedEvent ReactionEvent diff --git a/websocket_subteam.go b/websocket_subteam.go index a23b274cf..2f618733b 100644 --- a/websocket_subteam.go +++ b/websocket_subteam.go @@ -14,9 +14,9 @@ type SubteamMembersChangedEvent struct { DatePreviousUpdate JSONTime `json:"date_previous_update"` DateUpdate JSONTime `json:"date_update"` AddedUsers []string `json:"added_users"` - AddedUsersCount string `json:"added_users_count"` + AddedUsersCount int `json:"added_users_count"` RemovedUsers []string `json:"removed_users"` - RemovedUsersCount string `json:"removed_users_count"` + RemovedUsersCount int `json:"removed_users_count"` } // SubteamSelfAddedEvent represents an event of you have been added to a User Group diff --git a/workflow_step.go b/workflow_step.go deleted file mode 100644 index bcc892c5a..000000000 --- a/workflow_step.go +++ /dev/null @@ -1,98 +0,0 @@ -package slack - -import ( - "context" - "encoding/json" -) - -const VTWorkflowStep ViewType = "workflow_step" - -type ( - ConfigurationModalRequest struct { - ModalViewRequest - } - - WorkflowStepCompleteResponse struct { - WorkflowStepEditID string `json:"workflow_step_edit_id"` - Inputs *WorkflowStepInputs `json:"inputs,omitempty"` - Outputs *[]WorkflowStepOutput `json:"outputs,omitempty"` - } - - WorkflowStepInputElement struct { - Value string `json:"value"` - SkipVariableReplacement bool `json:"skip_variable_replacement"` - } - - WorkflowStepInputs map[string]WorkflowStepInputElement - - WorkflowStepOutput struct { - Name string `json:"name"` - Type string `json:"type"` - Label string `json:"label"` - } -) - -func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externalID string) *ConfigurationModalRequest { - return &ConfigurationModalRequest{ - ModalViewRequest{ - Type: VTWorkflowStep, - Title: nil, // slack configuration modal must not have a title! - Blocks: blocks, - PrivateMetadata: privateMetaData, - ExternalID: externalID, - }, - } -} - -func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs) -} - -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, - Outputs: outputs, - } - - endpoint := api.endpoint + "workflows.updateStep" - jsonData, err := json.Marshal(wscr) - 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 -} - -func GetInitialOptionFromWorkflowStepInput(selection *SelectBlockElement, inputs *WorkflowStepInputs, options []*OptionBlockObject) (*OptionBlockObject, bool) { - if len(*inputs) == 0 { - return &OptionBlockObject{}, false - } - if len(options) == 0 { - return &OptionBlockObject{}, false - } - - if val, ok := (*inputs)[selection.ActionID]; ok { - if val.SkipVariableReplacement { - return &OptionBlockObject{}, false - } - - for _, option := range options { - if option.Value == val.Value { - return option, true - } - } - } - - return &OptionBlockObject{}, false -} diff --git a/workflow_step_test.go b/workflow_step_test.go deleted file mode 100644 index daa3b6da2..000000000 --- a/workflow_step_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package slack - -import ( - "testing" - - "github.com/google/go-cmp/cmp" -) - -const ( - IDExampleSelectInput = "ae9642ae-a9ef-4394-904b-a5c7a83bf4a6" - IDSelectOptionBlock = "832bf7af-22ea-4acb-82e3-a0cc3722052b" -) - -func TestNewConfigurationModalRequest(t *testing.T) { - blocks := configModalBlocks() - privateMetaData := "An optional string that will be sent to your app in view_submission and block_actions events. Max length of 3000 characters." - externalID := "c4baf441-fbc1-4131-b349-7c8df0ae7df6" - - result := NewConfigurationModalRequest(blocks, privateMetaData, externalID) - - if result.ModalViewRequest.Title != nil { - t.Fail() - } - if result.PrivateMetadata != privateMetaData { - t.Fail() - } - if result.ExternalID != externalID { - t.Fail() - } -} - -func TestGetInitialOptionFromWorkflowStepInput(t *testing.T) { - options, testOption := createOptionBlockObjects() - selection := createSelection(options) - - scenarios := []struct { - options []*OptionBlockObject - inputs *WorkflowStepInputs - expectedResult *OptionBlockObject - expectedFlag bool - }{ - { - options: options, - inputs: createWorkflowStepInputs1(), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: []*OptionBlockObject{}, - inputs: createWorkflowStepInputs4(testOption.Value), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: options, - inputs: createWorkflowStepInputs2(), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: options, - inputs: createWorkflowStepInputs3(), - expectedResult: &OptionBlockObject{}, - expectedFlag: false, - }, - { - options: options, - inputs: createWorkflowStepInputs4(testOption.Value), - expectedResult: testOption, - expectedFlag: true, - }, - } - - for _, scenario := range scenarios { - result, ok := GetInitialOptionFromWorkflowStepInput(selection, scenario.inputs, scenario.options) - if ok != scenario.expectedFlag { - t.Fail() - } - - if !cmp.Equal(result, scenario.expectedResult) { - t.Fail() - } - } -} - -func createOptionBlockObjects() ([]*OptionBlockObject, *OptionBlockObject) { - var options []*OptionBlockObject - options = append( - options, - NewOptionBlockObject("one", NewTextBlockObject("plain_text", "One", false, false), nil), - ) - - option2 := NewOptionBlockObject("two", NewTextBlockObject("plain_text", "Two", false, false), nil) - options = append( - options, - option2, - ) - - options = append( - options, - NewOptionBlockObject("three", NewTextBlockObject("plain_text", "Three", false, false), nil), - ) - - return options, option2 -} - -func createSelection(options []*OptionBlockObject) *SelectBlockElement { - return NewOptionsSelectBlockElement( - "static_select", - NewTextBlockObject("plain_text", "your choice", false, false), - IDExampleSelectInput, - options..., - ) -} - -func configModalBlocks() Blocks { - headerText := NewTextBlockObject("mrkdwn", "Hello World!\nThis is your workflow step app configuration view", false, false) - headerSection := NewSectionBlock(headerText, nil, nil) - - options, _ := createOptionBlockObjects() - - selection := createSelection(options) - - inputBlock := NewInputBlock( - IDSelectOptionBlock, - NewTextBlockObject("plain_text", "Select an option", false, false), - selection, - ) - - blocks := Blocks{ - BlockSet: []Block{ - headerSection, - inputBlock, - }, - } - - return blocks -} - -func createWorkflowStepInputs1() *WorkflowStepInputs { - return &WorkflowStepInputs{} -} - -func createWorkflowStepInputs2() *WorkflowStepInputs { - return &WorkflowStepInputs{ - "test": WorkflowStepInputElement{ - Value: "random-string", - SkipVariableReplacement: false, - }, - "123-test": WorkflowStepInputElement{ - Value: "another-string", - SkipVariableReplacement: false, - }, - } -} - -func createWorkflowStepInputs3() *WorkflowStepInputs { - return &WorkflowStepInputs{ - "test": WorkflowStepInputElement{ - Value: "random-string", - SkipVariableReplacement: false, - }, - "123-test": WorkflowStepInputElement{ - Value: "another-string", - SkipVariableReplacement: false, - }, - IDExampleSelectInput: WorkflowStepInputElement{ - Value: "lorem-ipsum", - SkipVariableReplacement: true, - }, - } -} - -func createWorkflowStepInputs4(optionValue string) *WorkflowStepInputs { - return &WorkflowStepInputs{ - "test": WorkflowStepInputElement{ - Value: "random-string", - SkipVariableReplacement: false, - }, - "123-test": WorkflowStepInputElement{ - Value: "another-string", - SkipVariableReplacement: false, - }, - IDExampleSelectInput: WorkflowStepInputElement{ - Value: optionValue, - SkipVariableReplacement: false, - }, - } -} diff --git a/workflows_featured.go b/workflows_featured.go new file mode 100644 index 000000000..08b339868 --- /dev/null +++ b/workflows_featured.go @@ -0,0 +1,143 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" +) + +type ( + FeaturedWorkflowTrigger struct { + ID string `json:"id"` + Title string `json:"title"` + } + + FeaturedWorkflow struct { + ChannelID string `json:"channel_id"` + Triggers []FeaturedWorkflowTrigger `json:"triggers"` + } + + WorkflowsFeaturedAddInput struct { + ChannelID string `json:"channel_id"` + TriggerIDs []string `json:"trigger_ids"` + } + + WorkflowsFeaturedListInput struct { + ChannelIDs []string `json:"channel_ids"` + } + + WorkflowsFeaturedListOutput struct { + FeaturedWorkflows []FeaturedWorkflow `json:"featured_workflows"` + } + + WorkflowsFeaturedRemoveInput struct { + ChannelID string `json:"channel_id"` + TriggerIDs []string `json:"trigger_ids"` + } + + WorkflowsFeaturedSetInput struct { + ChannelID string `json:"channel_id"` + TriggerIDs []string `json:"trigger_ids"` + } +) + +// WorkflowsFeaturedAdd adds featured workflows to a channel. +// +// Slack API Docs:https://api.slack.com/methods/workflows.featured.add +func (api *Client) WorkflowsFeaturedAdd(ctx context.Context, input *WorkflowsFeaturedAddInput) error { + response := struct { + SlackResponse + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal WorkflowsFeaturedAddInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.featured.add", api.token, jsonPayload, &response) + if err != nil { + return err + } + + if err := response.Err(); err != nil { + return err + } + + return nil +} + +// WorkflowsFeaturedList lists featured workflows for the given channels. +// +// Slack API Docs:https://api.slack.com/methods/workflows.featured.list +func (api *Client) WorkflowsFeaturedList(ctx context.Context, input *WorkflowsFeaturedListInput) (*WorkflowsFeaturedListOutput, error) { + response := struct { + SlackResponse + *WorkflowsFeaturedListOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsFeaturedListInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.featured.list", api.token, jsonPayload, &response) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsFeaturedListOutput, nil +} + +// WorkflowsFeaturedRemove removes featured workflows from a channel. +// +// Slack API Docs:https://api.slack.com/methods/workflows.featured.remove +func (api *Client) WorkflowsFeaturedRemove(ctx context.Context, input *WorkflowsFeaturedRemoveInput) error { + response := struct { + SlackResponse + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal WorkflowsFeaturedRemoveInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.featured.remove", api.token, jsonPayload, &response) + if err != nil { + return err + } + + if err := response.Err(); err != nil { + return err + } + + return nil +} + +// WorkflowsFeaturedSet replaces all featured workflows in a channel with the given triggers. +// +// Slack API Docs:https://api.slack.com/methods/workflows.featured.set +func (api *Client) WorkflowsFeaturedSet(ctx context.Context, input *WorkflowsFeaturedSetInput) error { + response := struct { + SlackResponse + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal WorkflowsFeaturedSetInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.featured.set", api.token, jsonPayload, &response) + if err != nil { + return err + } + + if err := response.Err(); err != nil { + return err + } + + return nil +} diff --git a/workflows_featured_test.go b/workflows_featured_test.go new file mode 100644 index 000000000..3502afa8f --- /dev/null +++ b/workflows_featured_test.go @@ -0,0 +1,221 @@ +package slack + +import ( + "context" + "net/http" + "reflect" + "testing" +) + +type workflowsFeaturedHandler struct { + rawResponse string +} + +func (h *workflowsFeaturedHandler) handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(h.rawResponse)) +} + +func TestSlack_WorkflowsFeaturedAdd(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsFeaturedAddInput + rawResp string + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsFeaturedAddInput{ + ChannelID: "C012345678", + TriggerIDs: []string{"Ft1234", "Ft5678"}, + }, + rawResp: `{"ok": true}`, + expectedErr: nil, + }, + } + + h := &workflowsFeaturedHandler{} + http.HandleFunc("/workflows.featured.add", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + err := api.WorkflowsFeaturedAdd(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + }) + } +} + +func TestSlack_WorkflowsFeaturedList(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsFeaturedListInput + rawResp string + expectedResp *WorkflowsFeaturedListOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsFeaturedListInput{ + ChannelIDs: []string{"C012345678", "C987654321"}, + }, + rawResp: `{ + "ok": true, + "featured_workflows": [ + { + "channel_id": "C012345678", + "triggers": [ + {"id": "Ft1234", "title": "Tabby workflow"}, + {"id": "Ft5678", "title": "Tortoise workflow"} + ] + }, + { + "channel_id": "C987654321", + "triggers": [ + {"id": "Ft1234", "title": "Ragdoll workflow"} + ] + } + ] + }`, + expectedResp: &WorkflowsFeaturedListOutput{ + FeaturedWorkflows: []FeaturedWorkflow{ + { + ChannelID: "C012345678", + Triggers: []FeaturedWorkflowTrigger{ + {ID: "Ft1234", Title: "Tabby workflow"}, + {ID: "Ft5678", Title: "Tortoise workflow"}, + }, + }, + { + ChannelID: "C987654321", + Triggers: []FeaturedWorkflowTrigger{ + {ID: "Ft1234", Title: "Ragdoll workflow"}, + }, + }, + }, + }, + expectedErr: nil, + }, + } + + h := &workflowsFeaturedHandler{} + http.HandleFunc("/workflows.featured.list", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsFeaturedList(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsFeaturedRemove(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsFeaturedRemoveInput + rawResp string + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsFeaturedRemoveInput{ + ChannelID: "C012345678", + TriggerIDs: []string{"Ft1234"}, + }, + rawResp: `{"ok": true}`, + expectedErr: nil, + }, + } + + h := &workflowsFeaturedHandler{} + http.HandleFunc("/workflows.featured.remove", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + err := api.WorkflowsFeaturedRemove(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + }) + } +} + +func TestSlack_WorkflowsFeaturedSet(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsFeaturedSetInput + rawResp string + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsFeaturedSetInput{ + ChannelID: "C012345678", + TriggerIDs: []string{"Ft1234", "Ft5678"}, + }, + rawResp: `{"ok": true}`, + expectedErr: nil, + }, + } + + h := &workflowsFeaturedHandler{} + http.HandleFunc("/workflows.featured.set", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + err := api.WorkflowsFeaturedSet(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + }) + } +} diff --git a/workflows_triggers.go b/workflows_triggers.go new file mode 100644 index 000000000..348348061 --- /dev/null +++ b/workflows_triggers.go @@ -0,0 +1,177 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" +) + +type ( + WorkflowsTriggersPermissionsAddInput struct { + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsAddOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsListInput struct { + TriggerId string `json:"trigger_id"` + } + + WorkflowsTriggersPermissionsListOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsRemoveInput struct { + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsRemoveOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsSetInput struct { + PermissionType string `json:"permission_type"` + TriggerId string `json:"trigger_id"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } + + WorkflowsTriggersPermissionsSetOutput struct { + PermissionType string `json:"permission_type"` + ChannelIds []string `json:"channel_ids,omitempty"` + OrgIds []string `json:"org_ids,omitempty"` + TeamIds []string `json:"team_ids,omitempty"` + UserIds []string `json:"user_ids,omitempty"` + } +) + +// WorkflowsTriggersPermissionsAdd allows users to run a trigger that has its permission +// type set to named_entities. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.add +func (api *Client) WorkflowsTriggersPermissionsAdd(ctx context.Context, input *WorkflowsTriggersPermissionsAddInput) (*WorkflowsTriggersPermissionsAddOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsAddOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsAddInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.triggers.permissions.add", api.token, jsonPayload, &response) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsAddOutput, nil +} + +// WorkflowsTriggersPermissionsList returns the permission type of a trigger and if +// applicable, includes the entities that have been granted access. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.list +func (api *Client) WorkflowsTriggersPermissionsList(ctx context.Context, input *WorkflowsTriggersPermissionsListInput) (*WorkflowsTriggersPermissionsListOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsListOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsListInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.triggers.permissions.list", api.token, jsonPayload, &response) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsListOutput, nil +} + +// WorkflowsTriggersPermissionsRemove revoke an entity's access to a trigger that has its +// permission type set to named_entities. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.remove +func (api *Client) WorkflowsTriggersPermissionsRemove(ctx context.Context, input *WorkflowsTriggersPermissionsRemoveInput) (*WorkflowsTriggersPermissionsRemoveOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsRemoveOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsRemoveInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.triggers.permissions.remove", api.token, jsonPayload, &response) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsRemoveOutput, nil +} + +// WorkflowsTriggersPermissionsSet sets the permission type for who can run a trigger. +// +// Slack API Docs:https://api.slack.com/methods/workflows.triggers.permissions.set +func (api *Client) WorkflowsTriggersPermissionsSet(ctx context.Context, input *WorkflowsTriggersPermissionsSetInput) (*WorkflowsTriggersPermissionsSetOutput, error) { + response := struct { + SlackResponse + *WorkflowsTriggersPermissionsSetOutput + }{} + + jsonPayload, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal WorkflowsTriggersPermissionsSetInput: %w", err) + } + + err = api.postJSONMethod(ctx, "workflows.triggers.permissions.set", api.token, jsonPayload, &response) + if err != nil { + return nil, err + } + + if err := response.Err(); err != nil { + return nil, err + } + + return response.WorkflowsTriggersPermissionsSetOutput, nil +} diff --git a/workflows_triggers_test.go b/workflows_triggers_test.go new file mode 100644 index 000000000..695ec4870 --- /dev/null +++ b/workflows_triggers_test.go @@ -0,0 +1,274 @@ +package slack + +import ( + "context" + "net/http" + "reflect" + "testing" +) + +type workflowsHandler struct { + rawResponse string +} + +func (h *workflowsHandler) handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(h.rawResponse)) +} + +func TestSlack_WorkflowsTriggersPermissionsAdd(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsAddInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsAddOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsAddInput{ + TriggerId: "Ft0000000001", + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + UserIds: []string{"U0000000001", "U0000000002"}, + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsAddOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.add", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsAdd(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsTriggersPermissionsList(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsListInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsListOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsListInput{ + TriggerId: "Ft0000000001", + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsListOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.list", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsList(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsTriggersPermissionsRemove(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsRemoveInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsRemoveOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsRemoveInput{ + TriggerId: "Ft0000000001", + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + UserIds: []string{"U0000000001", "U0000000002"}, + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsRemoveOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.remove", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsRemove(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +} + +func TestSlack_WorkflowsTriggersPermissionsSet(t *testing.T) { + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + cases := []struct { + caseName string + input *WorkflowsTriggersPermissionsSetInput + rawResp string + expectedResp *WorkflowsTriggersPermissionsSetOutput + expectedErr error + }{ + { + caseName: "success", + input: &WorkflowsTriggersPermissionsSetInput{ + PermissionType: "named_entities", + TriggerId: "Ft0000000001", + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + UserIds: []string{"U0000000001", "U0000000002"}, + }, + rawResp: `{ + "ok": true, + "permission_type": "named_entities", + "user_ids": ["U0000000001", "U0000000002"], + "channel_ids": ["C0000000001"], + "org_ids": ["E00000001"], + "team_ids": ["T0000000001"] + }`, + expectedResp: &WorkflowsTriggersPermissionsSetOutput{ + PermissionType: "named_entities", + UserIds: []string{"U0000000001", "U0000000002"}, + ChannelIds: []string{"C0000000001"}, + OrgIds: []string{"E00000001"}, + TeamIds: []string{"T0000000001"}, + }, + expectedErr: nil, + }, + } + + h := &workflowsHandler{} + http.HandleFunc("/workflows.triggers.permissions.set", h.handler) + for _, c := range cases { + t.Run(c.caseName, func(t *testing.T) { + h.rawResponse = c.rawResp + + resp, err := api.WorkflowsTriggersPermissionsSet(context.Background(), c.input) + if c.expectedErr == nil && err != nil { + t.Fatalf("unexpected error: %s\n", err) + } + if c.expectedErr != nil && err == nil { + t.Fatalf("expected %s, but did not raise an error", c.expectedErr) + } + if c.expectedErr != nil && err != nil && c.expectedErr.Error() != err.Error() { + t.Fatalf("expected %s as error but got %s\n", c.expectedErr, err) + } + if resp == nil || c.expectedResp == nil { + return + } + if !reflect.DeepEqual(resp, c.expectedResp) { + t.Fatalf("expected:\n\t%v\n but got:\n\t%v\n", c.expectedResp, resp) + } + }) + } +}