diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e88bcb33..1885b147f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,13 +12,16 @@ jobs: strategy: matrix: go: - - '1.17' - '1.18' - '1.19' + - '1.20' + - '1.21' + - '1.22' + - '1.23' name: test go-${{ matrix.go }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: run test @@ -29,13 +32,17 @@ jobs: runs-on: ubuntu-22.04 name: lint steps: - - uses: actions/checkout@v3 + - uses: actions/setup-go@v5 + with: + go-version: '1.20' + cache: false + - uses: actions/checkout@v4 with: # NOTE: Because we are a fork, # we must fetch all history for all branches # and tags. - fetch-depth: '0' + fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@537aa1903e5d359d0b27dbc19ddd22c5087f3fbc # v3.2.0 + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 with: - version: v1.49.0 + version: v1.52.2 diff --git a/.gitignore b/.gitignore index ac6f3eeb3..027f4de5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.test *~ .idea/ +/vendor/ \ No newline at end of file diff --git a/apps.go b/apps.go index 10d429752..7322b15b9 100644 --- a/apps.go +++ b/apps.go @@ -19,11 +19,15 @@ type EventAuthorization struct { IsEnterpriseInstall bool `json:"is_enterprise_install"` } +// ListEventAuthorizations lists authed users and teams for the given event_context. +// You must provide an app-level token to the client using OptionAppLevelToken. +// For more details, see ListEventAuthorizationsContext documentation. func (api *Client) ListEventAuthorizations(eventContext string) ([]EventAuthorization, error) { return api.ListEventAuthorizationsContext(context.Background(), eventContext) } -// ListEventAuthorizationsContext lists authed users and teams for the given event_context. You must provide an app-level token to the client using OptionAppLevelToken. More info: https://api.slack.com/methods/apps.event.authorizations.list +// ListEventAuthorizationsContext lists authed users and teams for the given event_context with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.event.authorizations.list func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventContext string) ([]EventAuthorization, error) { resp := &listEventAuthorizationsResponse{} @@ -43,10 +47,14 @@ func (api *Client) ListEventAuthorizationsContext(ctx context.Context, eventCont return resp.Authorizations, nil } +// UninstallApp uninstalls your app from a workspace. +// For more details, see UninstallAppContext documentation. func (api *Client) UninstallApp(clientID, clientSecret string) error { return api.UninstallAppContext(context.Background(), clientID, clientSecret) } +// UninstallAppContext uninstalls your app from a workspace with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.uninstall func (api *Client) UninstallAppContext(ctx context.Context, clientID, clientSecret string) error { values := url.Values{ "client_id": {clientID}, diff --git a/auth.go b/auth.go index 332be8069..972f59ea6 100644 --- a/auth.go +++ b/auth.go @@ -23,12 +23,14 @@ func (api *Client) authRequest(ctx context.Context, path string, values url.Valu return response, response.Err() } -// SendAuthRevoke will send a revocation for our token +// SendAuthRevoke will send a revocation for our token. +// For more details, see SendAuthRevokeContext documentation. func (api *Client) SendAuthRevoke(token string) (*AuthRevokeResponse, error) { return api.SendAuthRevokeContext(context.Background(), token) } -// SendAuthRevokeContext will send a revocation request for our token to api.revoke with context +// SendAuthRevokeContext will send a revocation request for our token to api.revoke with a custom context. +// Slack API docs: https://api.slack.com/methods/auth.revoke func (api *Client) SendAuthRevokeContext(ctx context.Context, token string) (*AuthRevokeResponse, error) { if token == "" { token = api.token @@ -52,12 +54,13 @@ type ListTeamsParameters struct { } // ListTeams returns all workspaces a token can access. -// More info: https://api.slack.com/methods/admin.teams.list +// For more details, see ListTeamsContext documentation. func (api *Client) ListTeams(params ListTeamsParameters) ([]Team, string, error) { return api.ListTeamsContext(context.Background(), params) } -// ListTeams returns all workspaces a token can access with a custom context. +// ListTeamsContext returns all workspaces a token can access with a custom context. +// Slack API docs: https://api.slack.com/methods/auth.teams.list func (api *Client) ListTeamsContext(ctx context.Context, params ListTeamsParameters) ([]Team, string, error) { values := url.Values{ "token": {api.token}, diff --git a/block.go b/block.go index 3a59f0a7d..562733d80 100644 --- a/block.go +++ b/block.go @@ -19,6 +19,7 @@ const ( MBTHeader MessageBlockType = "header" MBTRichText MessageBlockType = "rich_text" MBTCall MessageBlockType = "call" + MBTVideo MessageBlockType = "video" ) // Block defines an interface all block types should implement @@ -41,6 +42,7 @@ type BlockAction struct { Text TextBlockObject `json:"text"` Value string `json:"value"` RichTextValue RichTextBlock `json:"rich_text_value"` + Files []File `json:"files"` ActionTs string `json:"action_ts"` SelectedOption OptionBlockObject `json:"selected_option"` SelectedOptions []OptionBlockObject `json:"selected_options"` diff --git a/block_call.go b/block_call.go index 8fff17775..cf1414a61 100644 --- a/block_call.go +++ b/block_call.go @@ -1,53 +1,24 @@ package slack -// CallBlock defines information +// CallBlock defines data that is used to display a call in slack. +// +// More Information: https://api.slack.com/apis/calls#post_to_channel type CallBlock struct { - Type MessageBlockType `json:"type"` - CallID string `json:"call_id"` - BlockID string `json:"block_id"` - APIDecorationAvailable bool `json:"api_decoration_available"` - Call Call `json:"call"` -} - -type AppIconUrls struct { - Image32 string `json:"image_32"` - Image36 string `json:"image_36"` - Image48 string `json:"image_48"` - Image64 string `json:"image_64"` - Image72 string `json:"image_72"` - Image96 string `json:"image_96"` - Image128 string `json:"image_128"` - Image192 string `json:"image_192"` - Image512 string `json:"image_512"` - Image1024 string `json:"image_1024"` - ImageOriginal string `json:"image_original"` -} -type CallInfo struct { - ID string `json:"id"` - AppID string `json:"app_id"` - AppIconUrls AppIconUrls `json:"app_icon_urls"` - DateStart int `json:"date_start"` - ActiveParticipants []interface{} `json:"active_participants"` - AllParticipants []interface{} `json:"all_participants"` - DisplayID string `json:"display_id"` - JoinURL string `json:"join_url"` - DesktopAppJoinURL string `json:"desktop_app_join_url"` - Name string `json:"name"` - CreatedBy string `json:"created_by"` - DateEnd int `json:"date_end"` - Channels []string `json:"channels"` - IsDmCall bool `json:"is_dm_call"` - WasRejected bool `json:"was_rejected"` - WasMissed bool `json:"was_missed"` - WasAccepted bool `json:"was_accepted"` - HasEnded bool `json:"has_ended"` -} -type Call struct { - CallInfo CallInfo `json:"v1"` - MediaBackendType string `json:"media_backend_type"` + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + CallID string `json:"call_id"` + Call Call `json:"call"` } // BlockType returns the type of the block func (s CallBlock) BlockType() MessageBlockType { return s.Type } + +// NewCallBlock returns a new instance of a file block +func NewCallBlock(callID string) *CallBlock { + return &CallBlock{ + Type: MBTCall, + CallID: callID, + } +} diff --git a/block_call_test.go b/block_call_test.go new file mode 100644 index 000000000..c118542a5 --- /dev/null +++ b/block_call_test.go @@ -0,0 +1,13 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCallBlock(t *testing.T) { + callBlock := NewCallBlock("ACallID") + assert.Equal(t, string(callBlock.Type), "call") + assert.Equal(t, callBlock.CallID, "ACallID") +} diff --git a/block_conv.go b/block_conv.go index 9efaae6a2..6d3323a0e 100644 --- a/block_conv.go +++ b/block_conv.go @@ -2,7 +2,6 @@ package slack import ( "encoding/json" - "errors" "fmt" ) @@ -72,6 +71,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &SectionBlock{} case "call": block = &CallBlock{} + case "video": + block = &VideoBlock{} default: block = &UnknownBlock{} } @@ -136,8 +137,10 @@ func (b *InputBlock) UnmarshalJSON(data []byte) error { e = &RadioButtonsBlockElement{} case "number_input": e = &NumberInputBlockElement{} + case "file_input": + e = &FileInputBlockElement{} default: - return errors.New("unsupported block element type") + return fmt.Errorf("unsupported block element type %v", s.TypeVal) } if err := json.Unmarshal(a.Element, e); err != nil { @@ -443,7 +446,7 @@ func (e *ContextElements) UnmarshalJSON(data []byte) error { e.Elements = append(e.Elements, elem.(*ImageBlockElement)) default: - return errors.New("unsupported context element type") + return fmt.Errorf("unsupported context element type %v", contextElementType) } } diff --git a/block_element.go b/block_element.go index 8a474e062..9e6a5a208 100644 --- a/block_element.go +++ b/block_element.go @@ -12,10 +12,11 @@ const ( METDatetimepicker MessageElementType = "datetimepicker" METPlainTextInput MessageElementType = "plain_text_input" METRadioButtons MessageElementType = "radio_buttons" - METEmailInput MessageElementType = "email_text_input" - METNumberInput MessageElementType = "number_input" - METURLInput MessageElementType = "url_text_input" METRichTextInput MessageElementType = "rich_text_input" + METEmailTextInput MessageElementType = "email_text_input" + METURLTextInput MessageElementType = "url_text_input" + METNumber MessageElementType = "number_input" + METFileInput MessageElementType = "file_input" MixedElementImage MixedElementType = "mixed_image" MixedElementText MixedElementType = "mixed_text" @@ -181,6 +182,12 @@ func (s *ButtonBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *Butt return s } +// WithURL adds a URL for the button to link to and returns the modified ButtonBlockElement +func (s *ButtonBlockElement) WithURL(url string) *ButtonBlockElement { + s.URL = url + return s +} + // NewButtonBlockElement returns an instance of a new button element to be used within a block func NewButtonBlockElement(actionID, value string, text *TextBlockObject) *ButtonBlockElement { return &ButtonBlockElement{ @@ -251,6 +258,36 @@ func NewOptionsSelectBlockElement(optType string, placeholder *TextBlockObject, } } +// WithInitialOption sets the initial option for the select element +func (s *SelectBlockElement) WithInitialOption(option *OptionBlockObject) *SelectBlockElement { + s.InitialOption = option + return s +} + +// WithInitialUser sets the initial user for the select element +func (s *SelectBlockElement) WithInitialUser(user string) *SelectBlockElement { + s.InitialUser = user + return s +} + +// WithInitialConversation sets the initial conversation for the select element +func (s *SelectBlockElement) WithInitialConversation(conversation string) *SelectBlockElement { + s.InitialConversation = conversation + return s +} + +// WithInitialChannel sets the initial channel for the select element +func (s *SelectBlockElement) WithInitialChannel(channel string) *SelectBlockElement { + s.InitialChannel = channel + return s +} + +// WithConfirm adds a confirmation dialogue to the select element +func (s *SelectBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *SelectBlockElement { + s.Confirm = confirm + return s +} + // NewOptionsGroupSelectBlockElement returns a new instance of SelectBlockElement for use with // the Options object only. func NewOptionsGroupSelectBlockElement( @@ -302,6 +339,48 @@ func NewOptionsMultiSelectBlockElement(optType string, placeholder *TextBlockObj } } +// WithInitialOptions sets the initial options for the multi-select element +func (s *MultiSelectBlockElement) WithInitialOptions(options ...*OptionBlockObject) *MultiSelectBlockElement { + s.InitialOptions = options + return s +} + +// WithInitialUsers sets the initial users for the multi-select element +func (s *MultiSelectBlockElement) WithInitialUsers(users ...string) *MultiSelectBlockElement { + s.InitialUsers = users + return s +} + +// WithInitialConversations sets the initial conversations for the multi-select element +func (s *MultiSelectBlockElement) WithInitialConversations(conversations ...string) *MultiSelectBlockElement { + s.InitialConversations = conversations + return s +} + +// WithInitialChannels sets the initial channels for the multi-select element +func (s *MultiSelectBlockElement) WithInitialChannels(channels ...string) *MultiSelectBlockElement { + s.InitialChannels = channels + return s +} + +// WithConfirm adds a confirmation dialogue to the multi-select element +func (s *MultiSelectBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *MultiSelectBlockElement { + s.Confirm = confirm + return s +} + +// WithMaxSelectedItems sets the maximum number of items that can be selected +func (s *MultiSelectBlockElement) WithMaxSelectedItems(maxSelectedItems int) *MultiSelectBlockElement { + s.MaxSelectedItems = &maxSelectedItems + return s +} + +// WithMinQueryLength sets the minimum query length for the multi-select element +func (s *MultiSelectBlockElement) WithMinQueryLength(minQueryLength int) *MultiSelectBlockElement { + s.MinQueryLength = &minQueryLength + return s +} + // NewOptionsGroupMultiSelectBlockElement returns a new instance of MultiSelectBlockElement for use with // the Options object only. func NewOptionsGroupMultiSelectBlockElement( @@ -345,6 +424,12 @@ func NewOverflowBlockElement(actionID string, options ...*OptionBlockObject) *Ov } } +// WithConfirm adds a confirmation dialogue to the overflow element +func (s *OverflowBlockElement) WithConfirm(confirm *ConfirmationBlockObject) *OverflowBlockElement { + s.Confirm = confirm + return s +} + // DatePickerBlockElement defines an element which lets users easily select a // date from a calendar style UI. Date picker elements can be used inside of // section and actions blocks. @@ -444,43 +529,12 @@ func (s EmailInputBlockElement) ElementType() MessageElementType { // element that only accepts email addresses. func NewEmailInputBlockElement(placeholder *TextBlockObject, actionID string) *EmailInputBlockElement { return &EmailInputBlockElement{ - Type: METEmailInput, + Type: METEmailTextInput, ActionID: actionID, Placeholder: placeholder, } } -// NumberInputBlockElement defines an element which lets users select a number -// from a nice UI. This element can only be used inside modals. -// -// More Information: https://api.slack.com/reference/block-kit/block-elements#number -type NumberInputBlockElement struct { - Type MessageElementType `json:"type"` - ActionID string `json:"action_id,omitempty"` - IsDecimalAllowed bool `json:"is_decimal_allowed"` - InitialValue string `json:"initial_value,omitempty"` - MinValue string `json:"min_value,omitempty"` - MaxValue string `json:"max_value,omitempty"` - DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` - FocusOnLoad bool `json:"focus_on_load,omitempty"` - Placeholder *TextBlockObject `json:"placeholder,omitempty"` -} - -// ElementType returns the type of the Element -func (s NumberInputBlockElement) ElementType() MessageElementType { - return s.Type -} - -// NewNumberInputBlockElement returns an instance of a number input. -func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, isDecimalAllowed bool) *NumberInputBlockElement { - return &NumberInputBlockElement{ - Type: METNumberInput, - ActionID: actionID, - Placeholder: placeholder, - IsDecimalAllowed: isDecimalAllowed, - } -} - // PlainTextInputBlockElement creates a field where a user can enter freeform // data. // Plain-text input elements are currently only available in modals. @@ -516,6 +570,36 @@ func NewPlainTextInputBlockElement(placeholder *TextBlockObject, actionID string } } +// WithInitialValue sets the initial value for the plain-text input element +func (s *PlainTextInputBlockElement) WithInitialValue(initialValue string) *PlainTextInputBlockElement { + s.InitialValue = initialValue + return s +} + +// WithMinLength sets the minimum length for the plain-text input element +func (s *PlainTextInputBlockElement) WithMinLength(minLength int) *PlainTextInputBlockElement { + s.MinLength = minLength + return s +} + +// WithMaxLength sets the maximum length for the plain-text input element +func (s *PlainTextInputBlockElement) WithMaxLength(maxLength int) *PlainTextInputBlockElement { + s.MaxLength = maxLength + return s +} + +// WithMultiline sets the multiline property for the plain-text input element +func (s *PlainTextInputBlockElement) WithMultiline(multiline bool) *PlainTextInputBlockElement { + s.Multiline = multiline + return s +} + +// WithDispatchActionConfig sets the dispatch action config for the plain-text input element +func (s *PlainTextInputBlockElement) WithDispatchActionConfig(config *DispatchActionConfig) *PlainTextInputBlockElement { + s.DispatchActionConfig = config + return s +} + // RichTextInputBlockElement creates a field where allows users to enter formatted text // in a WYSIWYG composer, offering the same messaging writing experience as in Slack // More Information: https://api.slack.com/reference/block-kit/block-elements#rich_text_input @@ -564,7 +648,7 @@ func (s URLInputBlockElement) ElementType() MessageElementType { // element func NewURLInputBlockElement(placeholder *TextBlockObject, actionID string) *URLInputBlockElement { return &URLInputBlockElement{ - Type: METURLInput, + Type: METURLTextInput, ActionID: actionID, Placeholder: placeholder, } @@ -621,3 +705,96 @@ func NewRadioButtonsBlockElement(actionID string, options ...*OptionBlockObject) Options: options, } } + +// NumberInputBlockElement creates a field where a user can enter number +// data. +// Number input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#number +type NumberInputBlockElement struct { + Type MessageElementType `json:"type"` + IsDecimalAllowed bool `json:"is_decimal_allowed"` + ActionID string `json:"action_id,omitempty"` + Placeholder *TextBlockObject `json:"placeholder,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + MinValue string `json:"min_value,omitempty"` + MaxValue string `json:"max_value,omitempty"` + DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` + FocusOnLoad bool `json:"focus_on_load,omitempty"` +} + +// ElementType returns the type of the Element +func (s NumberInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewNumberInputBlockElement returns an instance of a number input element +func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, isDecimalAllowed bool) *NumberInputBlockElement { + return &NumberInputBlockElement{ + Type: METNumber, + ActionID: actionID, + Placeholder: placeholder, + IsDecimalAllowed: isDecimalAllowed, + } +} + +// WithInitialValue sets the initial value for the number input element +func (s *NumberInputBlockElement) WithInitialValue(initialValue string) *NumberInputBlockElement { + s.InitialValue = initialValue + return s +} + +// WithMinValue sets the minimum value for the number input element +func (s *NumberInputBlockElement) WithMinValue(minValue string) *NumberInputBlockElement { + s.MinValue = minValue + return s +} + +// WithMaxValue sets the maximum value for the number input element +func (s *NumberInputBlockElement) WithMaxValue(maxValue string) *NumberInputBlockElement { + s.MaxValue = maxValue + return s +} + +// WithDispatchActionConfig sets the dispatch action config for the number input element +func (s *NumberInputBlockElement) WithDispatchActionConfig(config *DispatchActionConfig) *NumberInputBlockElement { + s.DispatchActionConfig = config + return s +} + +// FileInputBlockElement creates a field where a user can upload a file. +// +// File input elements are currently only available in modals. +// +// More Information: https://api.slack.com/reference/block-kit/block-elements#file_input +type FileInputBlockElement struct { + Type MessageElementType `json:"type"` + ActionID string `json:"action_id,omitempty"` + FileTypes []string `json:"filetypes,omitempty"` + MaxFiles int `json:"max_files,omitempty"` +} + +// ElementType returns the type of the Element +func (s FileInputBlockElement) ElementType() MessageElementType { + return s.Type +} + +// NewFileInputBlockElement returns an instance of a file input element +func NewFileInputBlockElement(actionID string) *FileInputBlockElement { + return &FileInputBlockElement{ + Type: METFileInput, + ActionID: actionID, + } +} + +// WithFileTypes sets the file types that can be uploaded +func (s *FileInputBlockElement) WithFileTypes(fileTypes ...string) *FileInputBlockElement { + s.FileTypes = fileTypes + return s +} + +// WithMaxFiles sets the maximum number of files that can be uploaded +func (s *FileInputBlockElement) WithMaxFiles(maxFiles int) *FileInputBlockElement { + s.MaxFiles = maxFiles + return s +} diff --git a/block_element_test.go b/block_element_test.go index c38f17466..83edcc798 100644 --- a/block_element_test.go +++ b/block_element_test.go @@ -43,6 +43,16 @@ func TestWithStyleForButtonElement(t *testing.T) { } +func TestWithURLForButtonElement(t *testing.T) { + + btnTxt := NewTextBlockObject("plain_text", "Next 2 Results", false, false) + btnElement := NewButtonBlockElement("test", "click_me_123", btnTxt) + + btnElement.WithURL("https://foo.bar") + assert.Equal(t, btnElement.URL, "https://foo.bar") + +} + func TestNewOptionsSelectBlockElement(t *testing.T) { testOptionText := NewTextBlockObject("plain_text", "Option One", false, false) @@ -165,16 +175,6 @@ func TestNewRichTextInputBlockElement(t *testing.T) { assert.Equal(t, richTextInputElement.ActionID, "test") } -func TestNewNumberInputBlockElement(t *testing.T) { - - numberInputElement := NewNumberInputBlockElement(nil, "test", true) - - assert.Equal(t, string(numberInputElement.Type), "number_input") - assert.Equal(t, numberInputElement.ActionID, "test") - assert.Equal(t, numberInputElement.IsDecimalAllowed, true) - -} - func TestNewURLInputBlockElement(t *testing.T) { urlInputElement := NewURLInputBlockElement(nil, "test") @@ -227,3 +227,29 @@ func TestNewRadioButtonsBlockElement(t *testing.T) { assert.Equal(t, len(radioButtonsElement.Options), 3) } + +func TestNewNumberInputBlockElement(t *testing.T) { + + numberInputElement := NewNumberInputBlockElement(nil, "test", true) + + assert.Equal(t, string(numberInputElement.Type), "number_input") + assert.Equal(t, numberInputElement.ActionID, "test") + assert.Equal(t, numberInputElement.IsDecimalAllowed, true) + +} + +func TestNewFileInputBlockElement(t *testing.T) { + + fileInputElement := NewFileInputBlockElement("test") + + assert.Equal(t, string(fileInputElement.Type), "file_input") + assert.Equal(t, fileInputElement.ActionID, "test") + + fileInputElement.WithFileTypes("jpg", "png") + assert.Equal(t, len(fileInputElement.FileTypes), 2) + assert.Contains(t, fileInputElement.FileTypes, "jpg") + assert.Contains(t, fileInputElement.FileTypes, "png") + + fileInputElement.WithMaxFiles(10) + assert.Equal(t, fileInputElement.MaxFiles, 10) +} diff --git a/block_image.go b/block_image.go index 90cbd14e4..b3d2cb8cf 100644 --- a/block_image.go +++ b/block_image.go @@ -4,11 +4,21 @@ package slack // // More Information: https://api.slack.com/reference/messaging/blocks#image type ImageBlock struct { - Type MessageBlockType `json:"type"` - ImageURL string `json:"image_url"` - AltText string `json:"alt_text"` - BlockID string `json:"block_id,omitempty"` - Title *TextBlockObject `json:"title,omitempty"` + Type MessageBlockType `json:"type"` + ImageURL string `json:"image_url,omitempty"` + AltText string `json:"alt_text"` + BlockID string `json:"block_id,omitempty"` + Title *TextBlockObject `json:"title,omitempty"` + SlackFile *SlackFileObject `json:"slack_file,omitempty"` +} + +// SlackFileObject Defines an object containing Slack file information to be used in an +// image block or image element. +// +// More Information: https://api.slack.com/reference/block-kit/composition-objects#slack_file +type SlackFileObject struct { + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` } // BlockType returns the type of the block diff --git a/block_input.go b/block_input.go index 78ffcdb81..7c1272a64 100644 --- a/block_input.go +++ b/block_input.go @@ -28,3 +28,15 @@ func NewInputBlock(blockID string, label, hint *TextBlockObject, element BlockEl Hint: hint, } } + +// WithOptional sets the optional flag on the input block +func (s *InputBlock) WithOptional(optional bool) *InputBlock { + s.Optional = optional + return s +} + +// WithDispatchAction sets the dispatch action flag on the input block +func (s *InputBlock) WithDispatchAction(dispatchAction bool) *InputBlock { + s.DispatchAction = dispatchAction + return s +} diff --git a/block_object.go b/block_object.go index f70405eba..a3e78d4ea 100644 --- a/block_object.go +++ b/block_object.go @@ -147,6 +147,16 @@ func (s TextBlockObject) Validate() error { return errors.New("emoji cannot be true in mrkdown") } + // https://api.slack.com/reference/block-kit/composition-objects#text__fields + if len(s.Text) == 0 { + return errors.New("text must have a minimum length of 1") + } + + // https://api.slack.com/reference/block-kit/composition-objects#text__fields + if len(s.Text) > 3000 { + return errors.New("text cannot be longer than 3000 characters") + } + return nil } @@ -177,7 +187,7 @@ type ConfirmationBlockObject struct { Title *TextBlockObject `json:"title"` Text *TextBlockObject `json:"text"` Confirm *TextBlockObject `json:"confirm"` - Deny *TextBlockObject `json:"deny"` + Deny *TextBlockObject `json:"deny,omitempty"` Style Style `json:"style,omitempty"` } diff --git a/block_object_test.go b/block_object_test.go index 9889fae41..1f4874a02 100644 --- a/block_object_test.go +++ b/block_object_test.go @@ -2,6 +2,7 @@ package slack import ( "errors" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -126,6 +127,24 @@ func TestValidateTextBlockObject(t *testing.T) { }, expected: errors.New("emoji cannot be true in mrkdown"), }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: "", + Emoji: false, + Verbatim: false, + }, + expected: errors.New("text must have a minimum length of 1"), + }, + { + input: TextBlockObject{ + Type: "mrkdwn", + Text: strings.Repeat("a", 3001), + Emoji: false, + Verbatim: false, + }, + expected: errors.New("text cannot be longer than 3000 characters"), + }, } for _, test := range tests { diff --git a/block_video.go b/block_video.go new file mode 100644 index 000000000..322c614f9 --- /dev/null +++ b/block_video.go @@ -0,0 +1,65 @@ +package slack + +// VideoBlock defines data required to display a video as a block element +// +// More Information: https://api.slack.com/reference/block-kit/blocks#video +type VideoBlock struct { + Type MessageBlockType `json:"type"` + VideoURL string `json:"video_url"` + ThumbnailURL string `json:"thumbnail_url"` + AltText string `json:"alt_text"` + Title *TextBlockObject `json:"title"` + BlockID string `json:"block_id,omitempty"` + TitleURL string `json:"title_url,omitempty"` + AuthorName string `json:"author_name,omitempty"` + ProviderName string `json:"provider_name,omitempty"` + ProviderIconURL string `json:"provider_icon_url,omitempty"` + Description *TextBlockObject `json:"description,omitempty"` +} + +// BlockType returns the type of the block +func (s VideoBlock) BlockType() MessageBlockType { + return s.Type +} + +// NewVideoBlock returns an instance of a new Video Block type +func NewVideoBlock(videoURL, thumbnailURL, altText, blockID string, title *TextBlockObject) *VideoBlock { + return &VideoBlock{ + Type: MBTVideo, + VideoURL: videoURL, + ThumbnailURL: thumbnailURL, + AltText: altText, + BlockID: blockID, + Title: title, + } +} + +// WithAuthorName sets the author name for the VideoBlock +func (s *VideoBlock) WithAuthorName(authorName string) *VideoBlock { + s.AuthorName = authorName + return s +} + +// WithTitleURL sets the title URL for the VideoBlock +func (s *VideoBlock) WithTitleURL(titleURL string) *VideoBlock { + s.TitleURL = titleURL + return s +} + +// WithDescription sets the description for the VideoBlock +func (s *VideoBlock) WithDescription(description *TextBlockObject) *VideoBlock { + s.Description = description + return s +} + +// WithProviderIconURL sets the provider icon URL for the VideoBlock +func (s *VideoBlock) WithProviderIconURL(providerIconURL string) *VideoBlock { + s.ProviderIconURL = providerIconURL + return s +} + +// WithProviderName sets the provider name for the VideoBlock +func (s *VideoBlock) WithProviderName(providerName string) *VideoBlock { + s.ProviderName = providerName + return s +} diff --git a/block_video_test.go b/block_video_test.go new file mode 100644 index 000000000..d4b791fab --- /dev/null +++ b/block_video_test.go @@ -0,0 +1,23 @@ +package slack + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewVideoBlock(t *testing.T) { + + videoTitle := NewTextBlockObject("plain_text", "VideoTitle", false, false) + videoBlock := NewVideoBlock( + "https://example.com/example.mp4", + "https://example.com/thumbnail.png", + "alternative text", "blockID", videoTitle) + + assert.Equal(t, string(videoBlock.Type), "video") + assert.Equal(t, videoBlock.Title.Type, "plain_text") + assert.Equal(t, videoBlock.BlockID, "blockID") + assert.Contains(t, videoBlock.Title.Text, "VideoTitle") + assert.Contains(t, videoBlock.VideoURL, "example.mp4") + +} diff --git a/bookmarks.go b/bookmarks.go index bc1bbfff5..e8c541bb4 100644 --- a/bookmarks.go +++ b/bookmarks.go @@ -24,102 +24,133 @@ type Bookmark struct { AppID *string `json:"app_id"` } -// ListBookmarks returns all the bookmarks in the given channel -func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) { - return api.ListBookmarksContext(context.Background(), channelID) +type AddBookmarkParameters struct { + Title string // A required title for the bookmark + Type string // A required type for the bookmark + Link string // URL required for type:link + Emoji string // An optional emoji + EntityID string + ParentID string + ChannelID string `json:"channel_id"` } -// ListBookmarksContext returns all the bookmarks in the given channel -func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) { - values := url.Values{ - "token": {api.token}, - "channel_id": {channelID}, - } +type EditBookmarkParameters struct { + Title *string // Change the title. Set to "" to clear + Emoji *string // Change the emoji. Set to "" to clear + Link string // Change the link + ChannelID string `json:"channel_id"` + BookmarkID string `json:"bookmark_id"` + Type string `json:"type,omitempty"` +} - response := &listBookmarksResponseFull{} - err := api.postMethod(ctx, "bookmarks.list", values, response) - if err != nil { - return nil, err - } - if !response.Ok { - return nil, errors.New(response.Error) - } - return response.Bookmarks, nil +type addBookmarkResponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse } -type AddBookmarkParameters struct { - Title string `json:"title"` - Type string `json:"type"` - Link string `json:"link,omitempty"` - Emoji string `json:"emoji,omitempty"` - EntityID string `json:"entity_id,omitempty"` - ParentID string `json:"parent_id,omitempty"` - ChannelID string `json:"channel_id"` +type editBookmarkResponse struct { + Bookmark Bookmark `json:"bookmark"` + SlackResponse +} + +type listBookmarksResponse struct { + Bookmarks []Bookmark `json:"bookmarks"` + SlackResponse } -// AddBookmark creates a new bookmark. ChannelID, Title, and Type are required -// (`Type=link` is the sensible default!). The other params are all optional. -func (api *Client) AddBookmark(params AddBookmarkParameters) (*Bookmark, error) { - return api.AddBookmarkContext(context.Background(), params) +// AddBookmark adds a bookmark in a channel. +// For more details, see AddBookmarkContext documentation. +func (api *Client) AddBookmark(channelID string, params AddBookmarkParameters) (Bookmark, error) { + return api.AddBookmarkContext(context.Background(), channelID, params) } -// AddBookmarkContext creates a new bookmark. ChannelID, Title, and Type are required -// (`Type: "link"` is the sensible default!). The other params are all optional. -func (api *Client) AddBookmarkContext(ctx context.Context, params AddBookmarkParameters) (*Bookmark, error) { - response := &singleBookmarkResponse{} +// AddBookmarkContext adds a bookmark in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.add +func (api *Client) AddBookmarkContext(ctx context.Context, channelID string, params AddBookmarkParameters) (Bookmark, error) { values := url.Values{ + "channel_id": {channelID}, "token": {api.token}, - "channel_id": {params.ChannelID}, "title": {params.Title}, "type": {params.Type}, } - + if params.Link != "" { + values.Set("link", params.Link) + } if params.Emoji != "" { - values["emoji"] = []string{params.Emoji} + values.Set("emoji", params.Emoji) } - if params.EntityID != "" { - values["entity_id"] = []string{params.EntityID} + values.Set("entity_id", params.EntityID) } - - if params.Link != "" { - values["link"] = []string{params.Link} + if params.ParentID != "" { + values.Set("parent_id", params.ParentID) } - if params.ParentID != "" { - values["parent_id"] = []string{params.ParentID} + response := &addBookmarkResponse{} + if err := api.postMethod(ctx, "bookmarks.add", values, response); err != nil { + return Bookmark{}, err } - err := api.postMethod(ctx, "bookmarks.add", values, response) - if err != nil { - return nil, err + return response.Bookmark, response.Err() +} + +// RemoveBookmark removes a bookmark from a channel. +// For more details, see RemoveBookmarkContext documentation. +func (api *Client) RemoveBookmark(channelID, bookmarkID string) error { + return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID) +} + +// RemoveBookmarkContext removes a bookmark from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.remove +func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error { + values := url.Values{ + "channel_id": {channelID}, + "token": {api.token}, + "bookmark_id": {bookmarkID}, } - if err := response.Err(); err != nil { - return nil, err + + response := &SlackResponse{} + if err := api.postMethod(ctx, "bookmarks.remove", values, response); err != nil { + return err } - return &response.Bookmark, nil + return response.Err() } -type EditBookmarkParameters struct { - Title string `json:"title,omitempty"` - Emoji string `json:"emoji,omitempty"` - Link string `json:"link,omitempty"` - ChannelID string `json:"channel_id"` - BookmarkID string `json:"bookmark_id"` - Type string `json:"type,omitempty"` +// ListBookmarks returns all bookmarks for a channel. +// For more details, see ListBookmarksContext documentation. +func (api *Client) ListBookmarks(channelID string) ([]Bookmark, error) { + return api.ListBookmarksContext(context.Background(), channelID) } -// EditBookmark updates an existing bookmark. ChannelID and BookmarkID are -// required, other params are optional. -func (api *Client) EditBookmark(params EditBookmarkParameters) (*Bookmark, error) { - return api.EditBookmarkContext(context.Background(), params) +// ListBookmarksContext returns all bookmarks for a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.edit +func (api *Client) ListBookmarksContext(ctx context.Context, channelID string) ([]Bookmark, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channelID}, + } + + response := &listBookmarksResponseFull{} + err := api.postMethod(ctx, "bookmarks.list", values, response) + if err != nil { + return nil, err + } + if !response.Ok { + return nil, errors.New(response.Error) + } + return response.Bookmarks, nil +} + +// EditBookmark edits a bookmark in a channel. +// For more details, see EditBookmarkContext documentation. +func (api *Client) EditBookmark(channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { + return api.EditBookmarkContext(context.Background(), channelID, bookmarkID, params) } -// EditBookmarkContext updates an existing bookmark. ChannelID and BookmarkID -// are required, other params are optional. -func (api *Client) EditBookmarkContext(ctx context.Context, params EditBookmarkParameters) (*Bookmark, error) { - response := &singleBookmarkResponse{} +// EditBookmarkContext edits a bookmark in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/bookmarks.edit +func (api *Client) EditBookmarkContext(ctx context.Context, channelID, bookmarkID string, params EditBookmarkParameters) (Bookmark, error) { values := url.Values{ "token": {api.token}, "channel_id": {params.ChannelID}, @@ -130,49 +161,29 @@ func (api *Client) EditBookmarkContext(ctx context.Context, params EditBookmarkP values["type"] = []string{params.Type} } - if params.Emoji != "" { - values["emoji"] = []string{params.Emoji} + if params.Emoji != nil { + values["emoji"] = []string{*params.Emoji} } if params.Link != "" { values["link"] = []string{params.Link} } - if params.Title != "" { - values["title"] = []string{params.Title} + if params.Title != nil { + values["title"] = []string{*params.Title} } + response := &editBookmarkResponse{} err := api.postMethod(ctx, "bookmarks.edit", values, &response) if err != nil { - return nil, err + return Bookmark{}, err } if err := response.Err(); err != nil { - return nil, err - } - - return &response.Bookmark, nil -} - -// RemoveBookmark deletes a bookmark from the given channel -func (api *Client) RemoveBookmark(channelID, bookmarkID string) error { - return api.RemoveBookmarkContext(context.Background(), channelID, bookmarkID) -} - -// RemoveBookmarkContext deletes a bookmark from the given channel -func (api *Client) RemoveBookmarkContext(ctx context.Context, channelID, bookmarkID string) error { - response := &SlackResponse{} - values := url.Values{ - "token": {api.token}, - "channel_id": {channelID}, - "bookmark_id": {bookmarkID}, + return Bookmark{}, err } - err := api.postMethod(ctx, "bookmarks.remove", values, response) - if err != nil { - return err - } - return response.Err() + return response.Bookmark, nil } type listBookmarksResponseFull struct { diff --git a/bookmarks_test.go b/bookmarks_test.go index 60221f37e..6da45c182 100644 --- a/bookmarks_test.go +++ b/bookmarks_test.go @@ -87,10 +87,9 @@ func TestAddBookmark(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bookmark, err := api.AddBookmark(AddBookmarkParameters{ - ChannelID: "C12345", - Title: "Homepage", - Type: "link", + bookmark, err := api.AddBookmark("C12345", AddBookmarkParameters{ + Title: "Homepage", + Type: "link", }) if err != nil { @@ -98,11 +97,6 @@ func TestAddBookmark(t *testing.T) { return } - if bookmark == nil { - t.Fatal("bookmark returned was nil") - return - } - if bookmark.ID != "Bk12345" { t.Errorf("bookmark ID should be Bk12345, got %s", bookmark.ID) } @@ -112,12 +106,13 @@ func TestEditBookmark(t *testing.T) { http.HandleFunc("/bookmarks.edit", getBookmark) once.Do(startServer) + emoji := ":siren:" + title := "hello2" + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bookmark, err := api.EditBookmark(EditBookmarkParameters{ - ChannelID: "C12345", - BookmarkID: "Bk12345", - Emoji: ":siren:", - Title: "hello2", + bookmark, err := api.EditBookmark("C12345", "Bk12345", EditBookmarkParameters{ + Emoji: &emoji, + Title: &title, }) if err != nil { @@ -125,11 +120,6 @@ func TestEditBookmark(t *testing.T) { return } - if bookmark == nil { - t.Fatal("bookmark returned was nil") - return - } - if bookmark.ID != "Bk12345" { t.Errorf("bookmark ID should be Bk12345, got %s", bookmark.ID) } diff --git a/bots.go b/bots.go index da21ba0c9..1ab946962 100644 --- a/bots.go +++ b/bots.go @@ -35,19 +35,30 @@ func (api *Client) botRequest(ctx context.Context, path string, values url.Value return response, nil } -// GetBotInfo will retrieve the complete bot information -func (api *Client) GetBotInfo(bot string) (*Bot, error) { - return api.GetBotInfoContext(context.Background(), bot) +type GetBotInfoParameters struct { + Bot string + TeamID string } -// GetBotInfoContext will retrieve the complete bot information using a custom context -func (api *Client) GetBotInfoContext(ctx context.Context, bot string) (*Bot, error) { +// GetBotInfo will retrieve the complete bot information. +// For more details, see GetBotInfoContext documentation. +func (api *Client) GetBotInfo(parameters GetBotInfoParameters) (*Bot, error) { + return api.GetBotInfoContext(context.Background(), parameters) +} + +// GetBotInfoContext will retrieve the complete bot information using a custom context. +// Slack API docs: https://api.slack.com/methods/bots.info +func (api *Client) GetBotInfoContext(ctx context.Context, parameters GetBotInfoParameters) (*Bot, error) { values := url.Values{ "token": {api.token}, } - if bot != "" { - values.Add("bot", bot) + if parameters.Bot != "" { + values.Add("bot", parameters.Bot) + } + + if parameters.TeamID != "" { + values.Add("team_id", parameters.TeamID) } response, err := api.botRequest(ctx, "bots.info", values) diff --git a/bots_test.go b/bots_test.go index ce7f66805..14a509e5f 100644 --- a/bots_test.go +++ b/bots_test.go @@ -29,7 +29,7 @@ func TestGetBotInfo(t *testing.T) { once.Do(startServer) api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) - bot, err := api.GetBotInfo("B02875YLA") + bot, err := api.GetBotInfo(GetBotInfoParameters{Bot: "B02875YLA"}) if err != nil { t.Errorf("Unexpected error: %s", err) return diff --git a/calls.go b/calls.go new file mode 100644 index 000000000..2d6e91f16 --- /dev/null +++ b/calls.go @@ -0,0 +1,216 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "time" +) + +type Call struct { + ID string `json:"id"` + Title string `json:"title"` + DateStart JSONTime `json:"date_start"` + DateEnd JSONTime `json:"date_end"` + ExternalUniqueID string `json:"external_unique_id"` + JoinURL string `json:"join_url"` + DesktopAppJoinURL string `json:"desktop_app_join_url"` + ExternalDisplayID string `json:"external_display_id"` + Participants []CallParticipant `json:"users"` + Channels []string `json:"channels"` +} + +// CallParticipant is a thin user representation which has a SlackID, ExternalID, or both. +// +// See: https://api.slack.com/apis/calls#users +type CallParticipant struct { + SlackID string `json:"slack_id,omitempty"` + ExternalID string `json:"external_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// Valid checks if the CallUser has a is valid with a SlackID or ExternalID or both. +func (u CallParticipant) Valid() bool { + return u.SlackID != "" || u.ExternalID != "" +} + +type AddCallParameters struct { + JoinURL string // Required + ExternalUniqueID string // Required + CreatedBy string // Required if using a bot token + Title string + DesktopAppJoinURL string + ExternalDisplayID string + DateStart JSONTime + Participants []CallParticipant +} + +type UpdateCallParameters struct { + Title string + DesktopAppJoinURL string + JoinURL string +} + +type EndCallParameters struct { + // Duration is the duration of the call in seconds. Omitted if 0. + Duration time.Duration +} + +type callResponse struct { + Call Call `json:"call"` + SlackResponse +} + +// AddCall adds a new Call to the Slack API. +func (api *Client) AddCall(params AddCallParameters) (Call, error) { + return api.AddCallContext(context.Background(), params) +} + +// AddCallContext adds a new Call to the Slack API. +func (api *Client) AddCallContext(ctx context.Context, params AddCallParameters) (Call, error) { + values := url.Values{ + "token": {api.token}, + "join_url": {params.JoinURL}, + "external_unique_id": {params.ExternalUniqueID}, + } + if params.CreatedBy != "" { + values.Set("created_by", params.CreatedBy) + } + if params.DateStart != 0 { + values.Set("date_start", strconv.FormatInt(int64(params.DateStart), 10)) + } + if params.DesktopAppJoinURL != "" { + values.Set("desktop_app_join_url", params.DesktopAppJoinURL) + } + if params.ExternalDisplayID != "" { + values.Set("external_display_id", params.ExternalDisplayID) + } + if params.Title != "" { + values.Set("title", params.Title) + } + if len(params.Participants) > 0 { + data, err := json.Marshal(params.Participants) + if err != nil { + return Call{}, err + } + values.Set("users", string(data)) + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.add", values, response); err != nil { + return Call{}, err + } + + return response.Call, response.Err() +} + +// GetCallInfo returns information about a Call. +func (api *Client) GetCall(callID string) (Call, error) { + return api.GetCallContext(context.Background(), callID) +} + +// GetCallInfoContext returns information about a Call. +func (api *Client) GetCallContext(ctx context.Context, callID string) (Call, error) { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.info", values, response); err != nil { + return Call{}, err + } + return response.Call, response.Err() +} + +func (api *Client) UpdateCall(callID string, params UpdateCallParameters) (Call, error) { + return api.UpdateCallContext(context.Background(), callID, params) +} + +// UpdateCallContext updates a Call with the given parameters. +func (api *Client) UpdateCallContext(ctx context.Context, callID string, params UpdateCallParameters) (Call, error) { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + if params.DesktopAppJoinURL != "" { + values.Set("desktop_app_join_url", params.DesktopAppJoinURL) + } + if params.JoinURL != "" { + values.Set("join_url", params.JoinURL) + } + if params.Title != "" { + values.Set("title", params.Title) + } + + response := &callResponse{} + if err := api.postMethod(ctx, "calls.update", values, response); err != nil { + return Call{}, err + } + return response.Call, response.Err() +} + +// EndCall ends a Call. +func (api *Client) EndCall(callID string, params EndCallParameters) error { + return api.EndCallContext(context.Background(), callID, params) +} + +// EndCallContext ends a Call. +func (api *Client) EndCallContext(ctx context.Context, callID string, params EndCallParameters) error { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + if params.Duration != 0 { + values.Set("duration", strconv.FormatInt(int64(params.Duration.Seconds()), 10)) + } + + response := &SlackResponse{} + if err := api.postMethod(ctx, "calls.end", values, response); err != nil { + return err + } + return response.Err() +} + +// CallAddParticipants adds users to a Call. +func (api *Client) CallAddParticipants(callID string, participants []CallParticipant) error { + return api.CallAddParticipantsContext(context.Background(), callID, participants) +} + +// CallAddParticipantsContext adds users to a Call. +func (api *Client) CallAddParticipantsContext(ctx context.Context, callID string, participants []CallParticipant) error { + return api.setCallParticipants(ctx, "calls.participants.add", callID, participants) +} + +// CallRemoveParticipants removes users from a Call. +func (api *Client) CallRemoveParticipants(callID string, participants []CallParticipant) error { + return api.CallRemoveParticipantsContext(context.Background(), callID, participants) +} + +// CallRemoveParticipantsContext removes users from a Call. +func (api *Client) CallRemoveParticipantsContext(ctx context.Context, callID string, participants []CallParticipant) error { + return api.setCallParticipants(ctx, "calls.participants.remove", callID, participants) +} + +func (api *Client) setCallParticipants(ctx context.Context, method, callID string, participants []CallParticipant) error { + values := url.Values{ + "token": {api.token}, + "id": {callID}, + } + + data, err := json.Marshal(participants) + if err != nil { + return err + } + values.Set("users", string(data)) + + response := &SlackResponse{} + if err := api.postMethod(ctx, method, values, response); err != nil { + return err + } + return response.Err() +} diff --git a/calls_test.go b/calls_test.go new file mode 100644 index 000000000..0c225fb86 --- /dev/null +++ b/calls_test.go @@ -0,0 +1,189 @@ +package slack + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestCall(callID string) Call { + return Call{ + ID: callID, + Title: "test call", + JoinURL: "https://example.com/example", + ExternalUniqueID: "123", + } +} + +func testClient(api string, f http.HandlerFunc) *Client { + http.HandleFunc(api, f) + once.Do(startServer) + return New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) +} + +var callTestId = 999 + +func addCallHandler(t *testing.T) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + call := Call{ + ID: fmt.Sprintf("R%d", callTestId), + Title: r.FormValue("title"), + JoinURL: r.FormValue("join_url"), + ExternalUniqueID: r.FormValue("external_unique_id"), + ExternalDisplayID: r.FormValue("external_display_id"), + DesktopAppJoinURL: r.FormValue("desktop_app_join_url"), + } + callTestId += 1 + json.Unmarshal([]byte(r.FormValue("users")), &call.Participants) + if start := r.FormValue("date_start"); start != "" { + dateStart, err := strconv.ParseInt(start, 10, 64) + require.NoError(t, err) + call.DateStart = JSONTime(dateStart) + } + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + } +} + +func TestAddCall(t *testing.T) { + api := testClient("/calls.add", addCallHandler(t)) + params := AddCallParameters{ + Title: "test call", + JoinURL: "https://example.com/example", + ExternalUniqueID: "123", + } + call, err := api.AddCall(params) + require.NoError(t, err) + assert.Equal(t, params.Title, call.Title) + assert.Equal(t, params.JoinURL, call.JoinURL) + assert.Equal(t, params.ExternalUniqueID, call.ExternalUniqueID) +} + +func getCallHandler(calls []Call) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + callID := r.FormValue("id") + + rw.Header().Set("Content-Type", "application/json") + for _, call := range calls { + if call.ID == callID { + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + return + } + } + // Fail if the call doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestGetCall(t *testing.T) { + calls := []Call{ + getTestCall("R1234567890"), + getTestCall("R1234567891"), + } + http.HandleFunc("/calls.info", getCallHandler(calls)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + for _, call := range calls { + resp, err := api.GetCall(call.ID) + require.NoError(t, err) + assert.Equal(t, call, resp) + } + // Test a call that doesn't exist + _, err := api.GetCall("R1234567892") + require.Error(t, err) +} + +func updateCallHandler(calls []Call) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + callID := r.FormValue("id") + + rw.Header().Set("Content-Type", "application/json") + if err := r.ParseForm(); err != nil { + httpTestErrReply(rw, true, fmt.Sprintf("err parsing form: %s", err.Error())) + return + } + + for _, call := range calls { + if call.ID == callID { + if title := r.FormValue("title"); title != "" { + call.Title = title + } + if joinURL := r.FormValue("join_url"); joinURL != "" { + call.JoinURL = joinURL + } + if desktopAppJoinURL := r.FormValue("desktop_app_join_url"); desktopAppJoinURL != "" { + call.DesktopAppJoinURL = desktopAppJoinURL + } + resp, _ := json.Marshal(callResponse{Call: call, SlackResponse: SlackResponse{Ok: true}}) + rw.Write(resp) + return + } + } + // Fail if the call doesn't exist + rw.Write([]byte(`{ "ok": false, "error": "not_found" }`)) + } +} + +func TestUpdateCall(t *testing.T) { + calls := []Call{ + getTestCall("R1234567890"), + getTestCall("R1234567891"), + getTestCall("R1234567892"), + getTestCall("R1234567893"), + getTestCall("R1234567894"), + } + http.HandleFunc("/calls.update", updateCallHandler(calls)) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + changes := []struct { + callID string + params UpdateCallParameters + }{ + { + callID: "R1234567890", + params: UpdateCallParameters{Title: "test"}, + }, + { + callID: "R1234567891", + params: UpdateCallParameters{JoinURL: "https://example.com/join"}, + }, + { + callID: "R1234567892", + params: UpdateCallParameters{DesktopAppJoinURL: "https://example.com/join"}, + }, + { // Change multiple fields at once + callID: "R1234567893", + params: UpdateCallParameters{ + Title: "test", + JoinURL: "https://example.com/join", + }, + }, + } + + for _, change := range changes { + call, err := api.UpdateCall(change.callID, change.params) + require.NoError(t, err) + if change.params.Title != "" && call.Title != change.params.Title { + t.Fatalf("Expected title to be %s, got %s", change.params.Title, call.Title) + } + if change.params.JoinURL != "" && call.JoinURL != change.params.JoinURL { + t.Fatalf("Expected join_url to be %s, got %s", change.params.JoinURL, call.JoinURL) + } + if change.params.DesktopAppJoinURL != "" && call.DesktopAppJoinURL != change.params.DesktopAppJoinURL { + t.Fatalf("Expected desktop_app_join_url to be %s, got %s", change.params.DesktopAppJoinURL, call.DesktopAppJoinURL) + } + } +} diff --git a/canvas.go b/canvas.go new file mode 100644 index 000000000..5225afa35 --- /dev/null +++ b/canvas.go @@ -0,0 +1,264 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +type CanvasDetails struct { + CanvasID string `json:"canvas_id"` +} + +type DocumentContent struct { + Type string `json:"type"` + Markdown string `json:"markdown,omitempty"` +} + +type CanvasChange struct { + Operation string `json:"operation"` + SectionID string `json:"section_id,omitempty"` + DocumentContent DocumentContent `json:"document_content"` +} + +type EditCanvasParams struct { + CanvasID string `json:"canvas_id"` + Changes []CanvasChange `json:"changes"` +} + +type SetCanvasAccessParams struct { + CanvasID string `json:"canvas_id"` + AccessLevel string `json:"access_level"` + ChannelIDs []string `json:"channel_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type DeleteCanvasAccessParams struct { + CanvasID string `json:"canvas_id"` + ChannelIDs []string `json:"channel_ids,omitempty"` + UserIDs []string `json:"user_ids,omitempty"` +} + +type LookupCanvasSectionsCriteria struct { + SectionTypes []string `json:"section_types,omitempty"` + ContainsText string `json:"contains_text,omitempty"` +} + +type LookupCanvasSectionsParams struct { + CanvasID string `json:"canvas_id"` + Criteria LookupCanvasSectionsCriteria `json:"criteria"` +} + +type CanvasSection struct { + ID string `json:"id"` +} + +type LookupCanvasSectionsResponse struct { + SlackResponse + Sections []CanvasSection `json:"sections"` +} + +// CreateCanvas creates a new canvas. +// For more details, see CreateCanvasContext documentation. +func (api *Client) CreateCanvas(title string, documentContent DocumentContent) (string, error) { + return api.CreateCanvasContext(context.Background(), title, documentContent) +} + +// CreateCanvasContext creates a new canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.create +func (api *Client) CreateCanvasContext(ctx context.Context, title string, documentContent DocumentContent) (string, error) { + values := url.Values{ + "token": {api.token}, + } + if title != "" { + values.Add("title", title) + } + if documentContent.Type != "" { + documentContentJSON, err := json.Marshal(documentContent) + if err != nil { + return "", err + } + values.Add("document_content", string(documentContentJSON)) + } + + response := struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{} + + err := api.postMethod(ctx, "canvases.create", values, &response) + if err != nil { + return "", err + } + + return response.CanvasID, response.Err() +} + +// DeleteCanvas deletes an existing canvas. +// For more details, see DeleteCanvasContext documentation. +func (api *Client) DeleteCanvas(canvasID string) error { + return api.DeleteCanvasContext(context.Background(), canvasID) +} + +// DeleteCanvasContext deletes an existing canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.delete +func (api *Client) DeleteCanvasContext(ctx context.Context, canvasID string) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {canvasID}, + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.delete", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// EditCanvas edits an existing canvas. +// For more details, see EditCanvasContext documentation. +func (api *Client) EditCanvas(params EditCanvasParams) error { + return api.EditCanvasContext(context.Background(), params) +} + +// EditCanvasContext edits an existing canvas with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.edit +func (api *Client) EditCanvasContext(ctx context.Context, params EditCanvasParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + + changesJSON, err := json.Marshal(params.Changes) + if err != nil { + return err + } + values.Add("changes", string(changesJSON)) + + response := struct { + SlackResponse + }{} + + err = api.postMethod(ctx, "canvases.edit", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// SetCanvasAccess sets the access level to a canvas for specified entities. +// For more details, see SetCanvasAccessContext documentation. +func (api *Client) SetCanvasAccess(params SetCanvasAccessParams) error { + return api.SetCanvasAccessContext(context.Background(), params) +} + +// SetCanvasAccessContext sets the access level to a canvas for specified entities with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.access.set +func (api *Client) SetCanvasAccessContext(ctx context.Context, params SetCanvasAccessParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + "access_level": {params.AccessLevel}, + } + if len(params.ChannelIDs) > 0 { + channelIDsJSON, err := json.Marshal(params.ChannelIDs) + if err != nil { + return err + } + values.Add("channel_ids", string(channelIDsJSON)) + } + if len(params.UserIDs) > 0 { + userIDsJSON, err := json.Marshal(params.UserIDs) + if err != nil { + return err + } + values.Add("user_ids", string(userIDsJSON)) + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.access.set", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// DeleteCanvasAccess removes access to a canvas for specified entities. +// For more details, see DeleteCanvasAccessContext documentation. +func (api *Client) DeleteCanvasAccess(params DeleteCanvasAccessParams) error { + return api.DeleteCanvasAccessContext(context.Background(), params) +} + +// DeleteCanvasAccessContext removes access to a canvas for specified entities with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.access.delete +func (api *Client) DeleteCanvasAccessContext(ctx context.Context, params DeleteCanvasAccessParams) error { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + if len(params.ChannelIDs) > 0 { + channelIDsJSON, err := json.Marshal(params.ChannelIDs) + if err != nil { + return err + } + values.Add("channel_ids", string(channelIDsJSON)) + } + if len(params.UserIDs) > 0 { + userIDsJSON, err := json.Marshal(params.UserIDs) + if err != nil { + return err + } + values.Add("user_ids", string(userIDsJSON)) + } + + response := struct { + SlackResponse + }{} + + err := api.postMethod(ctx, "canvases.access.delete", values, &response) + if err != nil { + return err + } + + return response.Err() +} + +// LookupCanvasSections finds sections matching the provided criteria. +// For more details, see LookupCanvasSectionsContext documentation. +func (api *Client) LookupCanvasSections(params LookupCanvasSectionsParams) ([]CanvasSection, error) { + return api.LookupCanvasSectionsContext(context.Background(), params) +} + +// LookupCanvasSectionsContext finds sections matching the provided criteria with a custom context. +// Slack API docs: https://api.slack.com/methods/canvases.sections.lookup +func (api *Client) LookupCanvasSectionsContext(ctx context.Context, params LookupCanvasSectionsParams) ([]CanvasSection, error) { + values := url.Values{ + "token": {api.token}, + "canvas_id": {params.CanvasID}, + } + + criteriaJSON, err := json.Marshal(params.Criteria) + if err != nil { + return nil, err + } + values.Add("criteria", string(criteriaJSON)) + + response := LookupCanvasSectionsResponse{} + + err = api.postMethod(ctx, "canvases.sections.lookup", values, &response) + if err != nil { + return nil, err + } + + return response.Sections, response.Err() +} diff --git a/canvas_test.go b/canvas_test.go new file mode 100644 index 000000000..c0e301039 --- /dev/null +++ b/canvas_test.go @@ -0,0 +1,216 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func createCanvasHandler(rw http.ResponseWriter, r *http.Request) { + title := r.FormValue("title") + documentContent := r.FormValue("document_content") + + rw.Header().Set("Content-Type", "application/json") + + if title != "" && documentContent != "" { + resp, _ := json.Marshal(&struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{ + SlackResponse: SlackResponse{Ok: true}, + CanvasID: "F1234ABCD", + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestCreateCanvas(t *testing.T) { + http.HandleFunc("/canvases.create", createCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "Test Content", + } + + canvasID, err := api.CreateCanvas("Test Canvas", documentContent) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if canvasID != "F1234ABCD" { + t.Fatalf("Expected canvas ID to be F1234ABCD, got %s", canvasID) + } +} + +func deleteCanvasHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestDeleteCanvas(t *testing.T) { + http.HandleFunc("/canvases.delete", deleteCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.DeleteCanvas("F1234ABCD") + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func editCanvasHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestEditCanvas(t *testing.T) { + http.HandleFunc("/canvases.edit", editCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := EditCanvasParams{ + CanvasID: "F1234ABCD", + Changes: []CanvasChange{ + { + Operation: "update", + SectionID: "S1234", + DocumentContent: DocumentContent{ + Type: "markdown", + Markdown: "Updated Content", + }, + }, + }, + } + + err := api.EditCanvas(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func setCanvasAccessHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestSetCanvasAccess(t *testing.T) { + http.HandleFunc("/canvases.access.set", setCanvasAccessHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := SetCanvasAccessParams{ + CanvasID: "F1234ABCD", + AccessLevel: "read", + ChannelIDs: []string{"C1234ABCD"}, + UserIDs: []string{"U1234ABCD"}, + } + + err := api.SetCanvasAccess(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func deleteCanvasAccessHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + rw.Write([]byte(`{ "ok": true }`)) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestDeleteCanvasAccess(t *testing.T) { + http.HandleFunc("/canvases.access.delete", deleteCanvasAccessHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := DeleteCanvasAccessParams{ + CanvasID: "F1234ABCD", + ChannelIDs: []string{"C1234ABCD"}, + UserIDs: []string{"U1234ABCD"}, + } + + err := api.DeleteCanvasAccess(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } +} + +func lookupCanvasSectionsHandler(rw http.ResponseWriter, r *http.Request) { + canvasID := r.FormValue("canvas_id") + + rw.Header().Set("Content-Type", "application/json") + + if canvasID == "F1234ABCD" { + sections := []CanvasSection{ + {ID: "S1234"}, + {ID: "S5678"}, + } + + resp, _ := json.Marshal(&LookupCanvasSectionsResponse{ + SlackResponse: SlackResponse{Ok: true}, + Sections: sections, + }) + rw.Write(resp) + } else { + rw.Write([]byte(`{ "ok": false, "error": "errored" }`)) + } +} + +func TestLookupCanvasSections(t *testing.T) { + http.HandleFunc("/canvases.sections.lookup", lookupCanvasSectionsHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + params := LookupCanvasSectionsParams{ + CanvasID: "F1234ABCD", + Criteria: LookupCanvasSectionsCriteria{ + SectionTypes: []string{"h1", "h2"}, + ContainsText: "Test", + }, + } + + sections, err := api.LookupCanvasSections(params) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + expectedSections := []CanvasSection{ + {ID: "S1234"}, + {ID: "S5678"}, + } + + if !reflect.DeepEqual(expectedSections, sections) { + t.Fatalf("Expected sections %v, got %v", expectedSections, sections) + } +} diff --git a/channels.go b/channels.go index 2fca8b92e..88d567bff 100644 --- a/channels.go +++ b/channels.go @@ -19,10 +19,11 @@ type channelResponseFull struct { // Channel contains information about the channel type Channel struct { GroupConversation - IsChannel bool `json:"is_channel"` - IsGeneral bool `json:"is_general"` - IsMember bool `json:"is_member"` - Locale string `json:"locale"` + IsChannel bool `json:"is_channel"` + IsGeneral bool `json:"is_general"` + IsMember bool `json:"is_member"` + Locale string `json:"locale"` + Properties *Properties `json:"properties"` } func (api *Client) channelRequest(ctx context.Context, path string, values url.Values) (*channelResponseFull, error) { diff --git a/chat.go b/chat.go index 4448e9f40..96843d68d 100644 --- a/chat.go +++ b/chat.go @@ -4,9 +4,10 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net/http" "net/url" + "regexp" "strconv" "github.com/slack-go/slack/slackutilsx" @@ -29,14 +30,14 @@ const ( type chatResponseFull struct { Channel string `json:"channel"` - Timestamp string `json:"ts"` //Regular message timestamp - MessageTimeStamp string `json:"message_ts"` //Ephemeral message timestamp - ScheduledMessageID string `json:"scheduled_message_id,omitempty"` //Scheduled message id + Timestamp string `json:"ts"` // Regular message timestamp + MessageTimeStamp string `json:"message_ts"` // Ephemeral message timestamp + ScheduledMessageID string `json:"scheduled_message_id,omitempty"` // Scheduled message id Text string `json:"text"` SlackResponse } -// getMessageTimestamp will inspect the `chatResponseFull` to ruturn a timestamp value +// getMessageTimestamp will inspect the `chatResponseFull` to return a timestamp value // in `chat.postMessage` its under `ts` // in `chat.postEphemeral` its under `message_ts` func (c chatResponseFull) getMessageTimestamp() string { @@ -87,12 +88,14 @@ func NewPostMessageParameters() PostMessageParameters { } } -// DeleteMessage deletes a message in a channel +// DeleteMessage deletes a message in a channel. +// For more details, see DeleteMessageContext documentation. func (api *Client) DeleteMessage(channel, messageTimestamp string) (string, string, error) { return api.DeleteMessageContext(context.Background(), channel, messageTimestamp) } -// DeleteMessageContext deletes a message in a channel with a custom context +// DeleteMessageContext deletes a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.delete func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTimestamp string) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -105,13 +108,13 @@ func (api *Client) DeleteMessageContext(ctx context.Context, channel, messageTim // ScheduleMessage sends a message to a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see ScheduleMessageContext documentation. func (api *Client) ScheduleMessage(channelID, postAt string, options ...MsgOption) (string, string, error) { return api.ScheduleMessageContext(context.Background(), channelID, postAt, options...) } -// ScheduleMessageContext sends a message to a channel with a custom context -// -// For more details, see ScheduleMessage documentation. +// ScheduleMessageContext sends a message to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.scheduleMessage func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt string, options ...MsgOption) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -125,12 +128,13 @@ func (api *Client) ScheduleMessageContext(ctx context.Context, channelID, postAt // PostMessage sends a message to a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see PostMessageContext documentation. func (api *Client) PostMessage(channelID string, options ...MsgOption) (string, string, error) { return api.PostMessageContext(context.Background(), channelID, options...) } -// PostMessageContext sends a message to a channel with a custom context -// For more details, see PostMessage documentation. +// PostMessageContext sends a message to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) PostMessageContext(ctx context.Context, channelID string, options ...MsgOption) (string, string, error) { respChannel, respTimestamp, _, err := api.SendMessageContext( ctx, @@ -144,12 +148,13 @@ func (api *Client) PostMessageContext(ctx context.Context, channelID string, opt // PostEphemeral sends an ephemeral message to a user in a channel. // Message is escaped by default according to https://api.slack.com/docs/formatting // Use http://davestevens.github.io/slack-message-builder/ to help crafting your message. +// For more details, see PostEphemeralContext documentation. func (api *Client) PostEphemeral(channelID, userID string, options ...MsgOption) (string, error) { return api.PostEphemeralContext(context.Background(), channelID, userID, options...) } -// PostEphemeralContext sends an ephemeal message to a user in a channel with a custom context -// For more details, see PostEphemeral documentation +// PostEphemeralContext sends an ephemeral message to a user in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postEphemeral func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID string, options ...MsgOption) (timestamp string, err error) { _, timestamp, _, err = api.SendMessageContext( ctx, @@ -160,12 +165,14 @@ func (api *Client) PostEphemeralContext(ctx context.Context, channelID, userID s return timestamp, err } -// UpdateMessage updates a message in a channel +// UpdateMessage updates a message in a channel. +// For more details, see UpdateMessageContext documentation. func (api *Client) UpdateMessage(channelID, timestamp string, options ...MsgOption) (string, string, string, error) { return api.UpdateMessageContext(context.Background(), channelID, timestamp, options...) } -// UpdateMessageContext updates a message in a channel +// UpdateMessageContext updates a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.update func (api *Client) UpdateMessageContext(ctx context.Context, channelID, timestamp string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext( ctx, @@ -175,38 +182,38 @@ func (api *Client) UpdateMessageContext(ctx context.Context, channelID, timestam ) } -// UnfurlMessage unfurls a message in a channel +// UnfurlMessage unfurls a message in a channel. +// For more details, see UnfurlMessageContext documentation. func (api *Client) UnfurlMessage(channelID, timestamp string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { return api.UnfurlMessageContext(context.Background(), channelID, timestamp, unfurls, options...) } -// UnfurlMessageContext unfurls a message in a channel with a custom context +// UnfurlMessageContext unfurls a message in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.unfurl func (api *Client) UnfurlMessageContext(ctx context.Context, channelID, timestamp string, unfurls map[string]Attachment, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(ctx, channelID, MsgOptionUnfurl(timestamp, unfurls), MsgOptionCompose(options...)) } -// UnfurlMessageWithAuthURL sends an unfurl request containing an -// authentication URL. -// For more details see: -// https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls +// UnfurlMessageWithAuthURL sends an unfurl request containing an authentication URL. +// For more details, see UnfurlMessageWithAuthURLContext documentation. func (api *Client) UnfurlMessageWithAuthURL(channelID, timestamp string, userAuthURL string, options ...MsgOption) (string, string, string, error) { return api.UnfurlMessageWithAuthURLContext(context.Background(), channelID, timestamp, userAuthURL, options...) } -// UnfurlMessageWithAuthURLContext sends an unfurl request containing an -// authentication URL. -// For more details see: -// https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls +// UnfurlMessageWithAuthURLContext sends an unfurl request containing an authentication URL with a custom context. +// For more details see: https://api.slack.com/reference/messaging/link-unfurling#authenticated_unfurls func (api *Client) UnfurlMessageWithAuthURLContext(ctx context.Context, channelID, timestamp string, userAuthURL string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(ctx, channelID, MsgOptionUnfurlAuthURL(timestamp, userAuthURL), MsgOptionCompose(options...)) } // SendMessage more flexible method for configuring messages. +// For more details, see SendMessageContext documentation. func (api *Client) SendMessage(channel string, options ...MsgOption) (string, string, string, error) { return api.SendMessageContext(context.Background(), channel, options...) } // SendMessageContext more flexible method for configuring messages with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.postMessage func (api *Client) SendMessageContext(ctx context.Context, channelID string, options ...MsgOption) (_channel string, _timestamp string, _text string, err error) { var ( req *http.Request @@ -219,12 +226,12 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt } if api.Debug() { - reqBody, err := ioutil.ReadAll(req.Body) + reqBody, err := io.ReadAll(req.Body) if err != nil { return "", "", "", err } - req.Body = ioutil.NopCloser(bytes.NewBuffer(reqBody)) - api.Debugf("Sending request: %s", string(reqBody)) + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + api.Debugf("Sending request: %s", redactToken(reqBody)) } if err = doPost(ctx, api.httpclient, req, parser(&response), api); err != nil { @@ -234,6 +241,20 @@ func (api *Client) SendMessageContext(ctx context.Context, channelID string, opt return response.Channel, response.getMessageTimestamp(), response.Text, response.Err() } +func redactToken(b []byte) []byte { + // See https://api.slack.com/authentication/token-types + // and https://api.slack.com/authentication/rotation + re, err := regexp.Compile(`(token=x[a-z.]+)-[0-9A-Za-z-]+`) + if err != nil { + // The regular expression above should never result in errors, + // but just in case, do no harm. + return b + } + // Keep "token=" and the first element of the token, which identifies its type + // (this could be useful for debugging, e.g. when using a wrong token). + return re.ReplaceAll(b, []byte("$1-REDACTED")) +} + // UnsafeApplyMsgOptions utility function for debugging/testing chat requests. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this function // will be supported by the library. @@ -356,6 +377,7 @@ func (t responseURLSender) BuildRequestContext(ctx context.Context) (*http.Reque req, err := jsonReq(ctx, t.endpoint, Msg{ Text: t.values.Get("text"), Timestamp: t.values.Get("ts"), + ThreadTimestamp: t.values.Get("thread_ts"), Attachments: t.attachments, Blocks: t.blocks, Metadata: t.metadata, @@ -681,6 +703,14 @@ func MsgOptionMetadata(metadata SlackMetadata) MsgOption { } } +// MsgOptionLinkNames finds and links user groups. Does not support linking individual users +func MsgOptionLinkNames(linkName bool) MsgOption { + return func(config *sendConfig) error { + config.values.Set("link_names", strconv.FormatBool(linkName)) + return nil + } +} + // UnsafeMsgOptionEndpoint deliver the message to the specified endpoint. // NOTE: USE AT YOUR OWN RISK: No issues relating to the use of this Option // will be supported by the library, it is subject to change without notice that @@ -748,22 +778,21 @@ func MsgOptionPostMessageParameters(params PostMessageParameters) MsgOption { } } -// PermalinkParameters are the parameters required to get a permalink to a -// message. Slack documentation can be found here: -// https://api.slack.com/methods/chat.getPermalink +// PermalinkParameters are the parameters required to get a permalink to a message. type PermalinkParameters struct { Channel string Ts string } -// GetPermalink returns the permalink for a message. It takes -// PermalinkParameters and returns a string containing the permalink. It -// returns an error if unable to retrieve the permalink. +// GetPermalink returns the permalink for a message. It takes PermalinkParameters and returns a string containing the +// permalink. It returns an error if unable to retrieve the permalink. +// For more details, see GetPermalinkContext documentation. func (api *Client) GetPermalink(params *PermalinkParameters) (string, error) { return api.GetPermalinkContext(context.Background(), params) } // GetPermalinkContext returns the permalink for a message using a custom context. +// Slack API docs: https://api.slack.com/methods/chat.getPermalink func (api *Client) GetPermalinkContext(ctx context.Context, params *PermalinkParameters) (string, error) { values := url.Values{ "channel": {params.Channel}, @@ -784,18 +813,21 @@ func (api *Client) GetPermalinkContext(ctx context.Context, params *PermalinkPar type GetScheduledMessagesParameters struct { Channel string + TeamID string Cursor string Latest string Limit int Oldest string } -// GetScheduledMessages returns the list of scheduled messages based on params +// GetScheduledMessages returns the list of scheduled messages based on params. +// For more details, see GetScheduledMessagesContext documentation. func (api *Client) GetScheduledMessages(params *GetScheduledMessagesParameters) (channels []ScheduledMessage, nextCursor string, err error) { return api.GetScheduledMessagesContext(context.Background(), params) } -// GetScheduledMessagesContext returns the list of scheduled messages in a Slack team with a custom context +// GetScheduledMessagesContext returns the list of scheduled messages based on params with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.getScheduledMessages.list func (api *Client) GetScheduledMessagesContext(ctx context.Context, params *GetScheduledMessagesParameters) (channels []ScheduledMessage, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -803,6 +835,9 @@ func (api *Client) GetScheduledMessagesContext(ctx context.Context, params *GetS if params.Channel != "" { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Cursor != "" { values.Add("cursor", params.Cursor) } @@ -835,12 +870,14 @@ type DeleteScheduledMessageParameters struct { AsUser bool } -// DeleteScheduledMessage returns the list of scheduled messages based on params +// DeleteScheduledMessage deletes a pending scheduled message. +// For more details, see DeleteScheduledMessageContext documentation. func (api *Client) DeleteScheduledMessage(params *DeleteScheduledMessageParameters) (bool, error) { return api.DeleteScheduledMessageContext(context.Background(), params) } -// DeleteScheduledMessageContext returns the list of scheduled messages in a Slack team with a custom context +// DeleteScheduledMessageContext deletes a pending scheduled message with a custom context. +// Slack API docs: https://api.slack.com/methods/chat.deleteScheduledMessage func (api *Client) DeleteScheduledMessageContext(ctx context.Context, params *DeleteScheduledMessageParameters) (bool, error) { values := url.Values{ "token": {api.token}, diff --git a/chat_test.go b/chat_test.go index 7a549fa90..917ff965e 100644 --- a/chat_test.go +++ b/chat_test.go @@ -1,11 +1,14 @@ package slack import ( + "bytes" "encoding/json" - "io/ioutil" + "io" + "log" "net/http" "net/url" "reflect" + "regexp" "testing" ) @@ -39,7 +42,6 @@ func TestGetPermalink(t *testing.T) { timeStamp := "p135854651500008" http.HandleFunc("/chat.getPermalink", func(rw http.ResponseWriter, r *http.Request) { - if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want { t.Errorf("request uses unexpected content type: got %s, want %s", got, want) } @@ -183,6 +185,28 @@ func TestPostMessage(t *testing.T) { "user_auth_message": []string{"Please!"}, }, }, + "LinkNames true": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionLinkNames(true), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "link_names": []string{"true"}, + }, + }, + "LinkNames false": { + endpoint: "/chat.postMessage", + opt: []MsgOption{ + MsgOptionLinkNames(false), + }, + expected: url.Values{ + "channel": []string{"CXXX"}, + "token": []string{"testing-token"}, + "link_names": []string{"false"}, + }, + }, } once.Do(startServer) @@ -192,7 +216,7 @@ func TestPostMessage(t *testing.T) { t.Run(name, func(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc(test.endpoint, func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -218,7 +242,7 @@ func TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -246,7 +270,7 @@ func TestPostMessageWithBlocksWhenMsgOptionResponseURLApplied(t *testing.T) { func TestPostMessageWhenMsgOptionReplaceOriginalApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -273,7 +297,7 @@ func TestPostMessageWhenMsgOptionReplaceOriginalApplied(t *testing.T) { func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { http.DefaultServeMux = new(http.ServeMux) http.HandleFunc("/response-url", func(rw http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("unexpected error: %v", err) return @@ -296,3 +320,50 @@ func TestPostMessageWhenMsgOptionDeleteOriginalApplied(t *testing.T) { _, _, _ = api.PostMessage("CXXX", MsgOptionDeleteOriginal(responseURL)) } + +func TestSendMessageContextRedactsTokenInDebugLog(t *testing.T) { + tests := []struct { + name string + token string + want string + }{ + { + name: "regular token", + token: "xtest-token-1234-abcd", + want: "xtest-REDACTED", + }, + { + name: "refresh token", + token: "xoxe.xtest-token-1234-abcd", + want: "xoxe.xtest-REDACTED", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + once.Do(startServer) + buf := bytes.NewBufferString("") + + opts := []Option{ + OptionAPIURL("http://" + serverAddr + "/"), + OptionLog(log.New(buf, "", log.Lshortfile)), + OptionDebug(true), + } + api := New(tt.token, opts...) + // Why send the token in the message text too? To test that we're not + // redacting substrings in the request which look like a token but aren't. + api.SendMessage("CXXX", MsgOptionText(token, false)) + s := buf.String() + + re := regexp.MustCompile(`token=[\w.-]*`) + want := "token=" + tt.want + if got := re.FindString(s); got != want { + t.Errorf("Logged token in SendMessageContext(): got %q, want %q", got, want) + } + re = regexp.MustCompile(`text=[\w.-]*`) + want = "text=" + token + if got := re.FindString(s); got != want { + t.Errorf("Logged text in SendMessageContext(): got %q, want %q", got, want) + } + }) + } +} diff --git a/conversation.go b/conversation.go index ace6a5737..73b2ed474 100644 --- a/conversation.go +++ b/conversation.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "errors" "net/url" "strconv" @@ -22,8 +23,10 @@ type Conversation struct { IsIM bool `json:"is_im"` IsExtShared bool `json:"is_ext_shared"` IsOrgShared bool `json:"is_org_shared"` + IsGlobalShared bool `json:"is_global_shared"` IsPendingExtShared bool `json:"is_pending_ext_shared"` IsPrivate bool `json:"is_private"` + IsReadOnly bool `json:"is_read_only"` IsMpIM bool `json:"is_mpim"` Unlinked int `json:"unlinked"` NameNormalized string `json:"name_normalized"` @@ -32,6 +35,9 @@ type Conversation struct { Priority float64 `json:"priority"` User string `json:"user"` ConversationHostID string `json:"conversation_host_id,omitempty"` + ConnectedTeamIDs []string `json:"connected_team_ids,omitempty"` + SharedTeamIDs []string `json:"shared_team_ids,omitempty"` + InternalTeamIDs []string `json:"internal_team_ids,omitempty"` // TODO support pending_shared } @@ -61,6 +67,17 @@ type Purpose struct { LastSet JSONTime `json:"last_set"` } +// Properties contains the Canvas associated to the channel. +type Properties struct { + Canvas Canvas `json:"canvas"` +} + +type Canvas struct { + FileId string `json:"file_id"` + IsEmpty bool `json:"is_empty"` + QuipThreadId string `json:"quip_thread_id"` +} + type GetUsersInConversationParameters struct { ChannelID string Cursor string @@ -80,12 +97,14 @@ type responseMetaData struct { NextCursor string `json:"next_cursor"` } -// GetUsersInConversation returns the list of users in a conversation +// GetUsersInConversation returns the list of users in a conversation. +// For more details, see GetUsersInConversationContext documentation. func (api *Client) GetUsersInConversation(params *GetUsersInConversationParameters) ([]string, string, error) { return api.GetUsersInConversationContext(context.Background(), params) } -// GetUsersInConversationContext returns the list of users in a conversation with a custom context +// GetUsersInConversationContext returns the list of users in a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.members func (api *Client) GetUsersInConversationContext(ctx context.Context, params *GetUsersInConversationParameters) ([]string, string, error) { values := url.Values{ "token": {api.token}, @@ -115,12 +134,14 @@ func (api *Client) GetUsersInConversationContext(ctx context.Context, params *Ge return response.Members, response.ResponseMetaData.NextCursor, nil } -// GetConversationsForUser returns the list conversations for a given user +// GetConversationsForUser returns the list conversations for a given user. +// For more details, see GetConversationsForUserContext documentation. func (api *Client) GetConversationsForUser(params *GetConversationsForUserParameters) (channels []Channel, nextCursor string, err error) { return api.GetConversationsForUserContext(context.Background(), params) } // GetConversationsForUserContext returns the list conversations for a given user with a custom context +// Slack API docs: https://api.slack.com/methods/users.conversations func (api *Client) GetConversationsForUserContext(ctx context.Context, params *GetConversationsForUserParameters) (channels []Channel, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -157,12 +178,14 @@ func (api *Client) GetConversationsForUserContext(ctx context.Context, params *G return response.Channels, response.ResponseMetaData.NextCursor, response.Err() } -// ArchiveConversation archives a conversation +// ArchiveConversation archives a conversation. +// For more details, see ArchiveConversationContext documentation. func (api *Client) ArchiveConversation(channelID string) error { return api.ArchiveConversationContext(context.Background(), channelID) } -// ArchiveConversationContext archives a conversation with a custom context +// ArchiveConversationContext archives a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.archive func (api *Client) ArchiveConversationContext(ctx context.Context, channelID string) error { values := url.Values{ "token": {api.token}, @@ -178,12 +201,14 @@ func (api *Client) ArchiveConversationContext(ctx context.Context, channelID str return response.Err() } -// UnArchiveConversation reverses conversation archival +// UnArchiveConversation reverses conversation archival. +// For more details, see UnArchiveConversationContext documentation. func (api *Client) UnArchiveConversation(channelID string) error { return api.UnArchiveConversationContext(context.Background(), channelID) } -// UnArchiveConversationContext reverses conversation archival with a custom context +// UnArchiveConversationContext reverses conversation archival with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.unarchive func (api *Client) UnArchiveConversationContext(ctx context.Context, channelID string) error { values := url.Values{ "token": {api.token}, @@ -198,12 +223,14 @@ func (api *Client) UnArchiveConversationContext(ctx context.Context, channelID s return response.Err() } -// SetTopicOfConversation sets the topic for a conversation +// SetTopicOfConversation sets the topic for a conversation. +// For more details, see SetTopicOfConversationContext documentation. func (api *Client) SetTopicOfConversation(channelID, topic string) (*Channel, error) { return api.SetTopicOfConversationContext(context.Background(), channelID, topic) } -// SetTopicOfConversationContext sets the topic for a conversation with a custom context +// SetTopicOfConversationContext sets the topic for a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.setTopic func (api *Client) SetTopicOfConversationContext(ctx context.Context, channelID, topic string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -222,12 +249,14 @@ func (api *Client) SetTopicOfConversationContext(ctx context.Context, channelID, return response.Channel, response.Err() } -// SetPurposeOfConversation sets the purpose for a conversation +// SetPurposeOfConversation sets the purpose for a conversation. +// For more details, see SetPurposeOfConversationContext documentation. func (api *Client) SetPurposeOfConversation(channelID, purpose string) (*Channel, error) { return api.SetPurposeOfConversationContext(context.Background(), channelID, purpose) } -// SetPurposeOfConversationContext sets the purpose for a conversation with a custom context +// SetPurposeOfConversationContext sets the purpose for a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.setPurpose func (api *Client) SetPurposeOfConversationContext(ctx context.Context, channelID, purpose string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -247,12 +276,14 @@ func (api *Client) SetPurposeOfConversationContext(ctx context.Context, channelI return response.Channel, response.Err() } -// RenameConversation renames a conversation +// RenameConversation renames a conversation. +// For more details, see RenameConversationContext documentation. func (api *Client) RenameConversation(channelID, channelName string) (*Channel, error) { return api.RenameConversationContext(context.Background(), channelID, channelName) } -// RenameConversationContext renames a conversation with a custom context +// RenameConversationContext renames a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.rename func (api *Client) RenameConversationContext(ctx context.Context, channelID, channelName string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -272,12 +303,14 @@ func (api *Client) RenameConversationContext(ctx context.Context, channelID, cha return response.Channel, response.Err() } -// InviteUsersToConversation invites users to a channel +// InviteUsersToConversation invites users to a channel. +// For more details, see InviteUsersToConversation documentation. func (api *Client) InviteUsersToConversation(channelID string, users ...string) (*Channel, error) { return api.InviteUsersToConversationContext(context.Background(), channelID, users...) } -// InviteUsersToConversationContext invites users to a channel with a custom context +// InviteUsersToConversationContext invites users to a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.invite func (api *Client) InviteUsersToConversationContext(ctx context.Context, channelID string, users ...string) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -297,12 +330,95 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel return response.Channel, response.Err() } -// KickUserFromConversation removes a user from a conversation +// InviteSharedEmailsToConversation invites users to a shared channels by email. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) +} + +// InviteSharedEmailsToConversationContext invites users to a shared channels by email using context. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedEmailsToConversationContext(ctx context.Context, channelID string, emails ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) +} + +// InviteSharedUserIDsToConversation invites users to a shared channels by user id. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedUserIDsToConversation(channelID string, userIDs ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) +} + +// InviteSharedUserIDsToConversationContext invites users to a shared channels by user id with context. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, channelID string, userIDs ...string) (string, bool, error) { + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) +} + +// InviteSharedToConversationParams defines the parameters for the InviteSharedToConversation and InviteSharedToConversationContext functions. +type InviteSharedToConversationParams struct { + ChannelID string + Emails []string + UserIDs []string + ExternalLimited *bool +} + +// InviteSharedToConversation invites emails or userIDs to a channel. +// For more details, see InviteSharedToConversationContext documentation. +func (api *Client) InviteSharedToConversation(params InviteSharedToConversationParams) (string, bool, error) { + return api.InviteSharedToConversationContext(context.Background(), params) +} + +// InviteSharedToConversationContext invites emails or userIDs to a channel with a custom context. +// This is a helper function for InviteSharedEmailsToConversation and InviteSharedUserIDsToConversation. +// It accepts either emails or userIDs, but not both. +// Slack API docs: https://api.slack.com/methods/conversations.inviteShared +func (api *Client) InviteSharedToConversationContext(ctx context.Context, params InviteSharedToConversationParams) (string, bool, error) { + values := url.Values{ + "token": {api.token}, + "channel": {params.ChannelID}, + } + if len(params.Emails) > 0 { + values.Add("emails", strings.Join(params.Emails, ",")) + } else if len(params.UserIDs) > 0 { + values.Add("user_ids", strings.Join(params.UserIDs, ",")) + } + if params.ExternalLimited != nil { + values.Add("external_limited", strconv.FormatBool(*params.ExternalLimited)) + } + response := struct { + SlackResponse + InviteID string `json:"invite_id"` + IsLegacySharedChannel bool `json:"is_legacy_shared_channel"` + }{} + + err := api.postMethod(ctx, "conversations.inviteShared", values, &response) + if err != nil { + return "", false, err + } + + return response.InviteID, response.IsLegacySharedChannel, response.Err() +} + +// KickUserFromConversation removes a user from a conversation. +// For more details, see KickUserFromConversationContext documentation. func (api *Client) KickUserFromConversation(channelID string, user string) error { return api.KickUserFromConversationContext(context.Background(), channelID, user) } -// KickUserFromConversationContext removes a user from a conversation with a custom context +// KickUserFromConversationContext removes a user from a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.kick func (api *Client) KickUserFromConversationContext(ctx context.Context, channelID string, user string) error { values := url.Values{ "token": {api.token}, @@ -319,12 +435,14 @@ func (api *Client) KickUserFromConversationContext(ctx context.Context, channelI return response.Err() } -// CloseConversation closes a direct message or multi-person direct message +// CloseConversation closes a direct message or multi-person direct message. +// For more details, see CloseConversationContext documentation. func (api *Client) CloseConversation(channelID string) (noOp bool, alreadyClosed bool, err error) { return api.CloseConversationContext(context.Background(), channelID) } -// CloseConversationContext closes a direct message or multi-person direct message with a custom context +// CloseConversationContext closes a direct message or multi-person direct message with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.close func (api *Client) CloseConversationContext(ctx context.Context, channelID string) (noOp bool, alreadyClosed bool, err error) { values := url.Values{ "token": {api.token}, @@ -350,12 +468,14 @@ type CreateConversationParams struct { TeamID string } -// CreateConversation initiates a public or private channel-based conversation +// CreateConversation initiates a public or private channel-based conversation. +// For more details, see CreateConversationContext documentation. func (api *Client) CreateConversation(params CreateConversationParams) (*Channel, error) { return api.CreateConversationContext(context.Background(), params) } -// CreateConversationContext initiates a public or private channel-based conversation with a custom context +// CreateConversationContext initiates a public or private channel-based conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.create func (api *Client) CreateConversationContext(ctx context.Context, params CreateConversationParams) (*Channel, error) { values := url.Values{ "token": {api.token}, @@ -380,12 +500,14 @@ type GetConversationInfoInput struct { IncludeNumMembers bool } -// GetConversationInfo retrieves information about a conversation +// GetConversationInfo retrieves information about a conversation. +// For more details, see GetConversationInfoContext documentation. func (api *Client) GetConversationInfo(input *GetConversationInfoInput) (*Channel, error) { return api.GetConversationInfoContext(context.Background(), input) } -// GetConversationInfoContext retrieves information about a conversation with a custom context +// GetConversationInfoContext retrieves information about a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.info func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetConversationInfoInput) (*Channel, error) { if input == nil { return nil, errors.New("GetConversationInfoInput must not be nil") @@ -408,12 +530,14 @@ func (api *Client) GetConversationInfoContext(ctx context.Context, input *GetCon return &response.Channel, response.Err() } -// LeaveConversation leaves a conversation +// LeaveConversation leaves a conversation. +// For more details, see LeaveConversationContext documentation. func (api *Client) LeaveConversation(channelID string) (bool, error) { return api.LeaveConversationContext(context.Background(), channelID) } -// LeaveConversationContext leaves a conversation with a custom context +// LeaveConversationContext leaves a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.leave func (api *Client) LeaveConversationContext(ctx context.Context, channelID string) (bool, error) { values := url.Values{ "token": {api.token}, @@ -439,12 +563,14 @@ type GetConversationRepliesParameters struct { IncludeAllMetadata bool } -// GetConversationReplies retrieves a thread of messages posted to a conversation +// GetConversationReplies retrieves a thread of messages posted to a conversation. +// For more details, see GetConversationRepliesContext documentation. func (api *Client) GetConversationReplies(params *GetConversationRepliesParameters) (msgs []Message, hasMore bool, nextCursor string, err error) { return api.GetConversationRepliesContext(context.Background(), params) } -// GetConversationRepliesContext retrieves a thread of messages posted to a conversation with a custom context +// GetConversationRepliesContext retrieves a thread of messages posted to a conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.replies func (api *Client) GetConversationRepliesContext(ctx context.Context, params *GetConversationRepliesParameters) (msgs []Message, hasMore bool, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -498,12 +624,14 @@ type GetConversationsParameters struct { TeamID string } -// GetConversations returns the list of channels in a Slack team +// GetConversations returns the list of channels in a Slack team. +// For more details, see GetConversationsContext documentation. func (api *Client) GetConversations(params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { return api.GetConversationsContext(context.Background(), params) } -// GetConversationsContext returns the list of channels in a Slack team with a custom context +// GetConversationsContext returns the list of channels in a Slack team with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.list func (api *Client) GetConversationsContext(ctx context.Context, params *GetConversationsParameters) (channels []Channel, nextCursor string, err error) { values := url.Values{ "token": {api.token}, @@ -544,12 +672,14 @@ type OpenConversationParameters struct { Users []string } -// OpenConversation opens or resumes a direct message or multi-person direct message +// OpenConversation opens or resumes a direct message or multi-person direct message. +// For more details, see OpenConversationContext documentation. func (api *Client) OpenConversation(params *OpenConversationParameters) (*Channel, bool, bool, error) { return api.OpenConversationContext(context.Background(), params) } -// OpenConversationContext opens or resumes a direct message or multi-person direct message with a custom context +// OpenConversationContext opens or resumes a direct message or multi-person direct message with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.open func (api *Client) OpenConversationContext(ctx context.Context, params *OpenConversationParameters) (*Channel, bool, bool, error) { values := url.Values{ "token": {api.token}, @@ -576,12 +706,14 @@ func (api *Client) OpenConversationContext(ctx context.Context, params *OpenConv return response.Channel, response.NoOp, response.AlreadyOpen, response.Err() } -// JoinConversation joins an existing conversation +// JoinConversation joins an existing conversation. +// For more details, see JoinConversationContext documentation. func (api *Client) JoinConversation(channelID string) (*Channel, string, []string, error) { return api.JoinConversationContext(context.Background(), channelID) } -// JoinConversationContext joins an existing conversation with a custom context +// JoinConversationContext joins an existing conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.join func (api *Client) JoinConversationContext(ctx context.Context, channelID string) (*Channel, string, []string, error) { values := url.Values{"token": {api.token}, "channel": {channelID}} response := struct { @@ -628,12 +760,14 @@ type GetConversationHistoryResponse struct { Messages []Message `json:"messages"` } -// GetConversationHistory joins an existing conversation +// GetConversationHistory joins an existing conversation. +// For more details, see GetConversationHistoryContext documentation. func (api *Client) GetConversationHistory(params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { return api.GetConversationHistoryContext(context.Background(), params) } -// GetConversationHistoryContext joins an existing conversation with a custom context +// GetConversationHistoryContext joins an existing conversation with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.history func (api *Client) GetConversationHistoryContext(ctx context.Context, params *GetConversationHistoryParameters) (*GetConversationHistoryResponse, error) { values := url.Values{"token": {api.token}, "channel": {params.ChannelID}} if params.Cursor != "" { @@ -669,12 +803,14 @@ func (api *Client) GetConversationHistoryContext(ctx context.Context, params *Ge return &response, response.Err() } -// MarkConversation sets the read mark of a conversation to a specific point +// MarkConversation sets the read mark of a conversation to a specific point. +// For more details, see MarkConversationContext documentation. func (api *Client) MarkConversation(channel, ts string) (err error) { return api.MarkConversationContext(context.Background(), channel, ts) } -// MarkConversationContext sets the read mark of a conversation to a specific point with a custom context +// MarkConversationContext sets the read mark of a conversation to a specific point with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.mark func (api *Client) MarkConversationContext(ctx context.Context, channel, ts string) error { values := url.Values{ "token": {api.token}, @@ -690,3 +826,36 @@ func (api *Client) MarkConversationContext(ctx context.Context, channel, ts stri } return response.Err() } + +// CreateChannelCanvas creates a new canvas in a channel. +// For more details, see CreateChannelCanvasContext documentation. +func (api *Client) CreateChannelCanvas(channel string, documentContent DocumentContent) (string, error) { + return api.CreateChannelCanvasContext(context.Background(), channel, documentContent) +} + +// CreateChannelCanvasContext creates a new canvas in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/conversations.canvases.create +func (api *Client) CreateChannelCanvasContext(ctx context.Context, channel string, documentContent DocumentContent) (string, error) { + values := url.Values{ + "token": {api.token}, + "channel_id": {channel}, + } + if documentContent.Type != "" { + documentContentJSON, err := json.Marshal(documentContent) + if err != nil { + return "", err + } + values.Add("document_content", string(documentContentJSON)) + } + + response := struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{} + err := api.postMethod(ctx, "conversations.canvases.create", values, &response) + if err != nil { + return "", err + } + + return response.CanvasID, response.Err() +} diff --git a/conversation_test.go b/conversation_test.go index 066e98fc4..6c39a101c 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -56,8 +56,6 @@ func assertSimpleChannel(t *testing.T, channel *Channel) { assert.NotNil(t, channel) assert.Equal(t, "C024BE91L", channel.ID) assert.Equal(t, "fun", channel.Name) - assert.Len(t, channel.PreviousNames, 1) - assert.Equal(t, channel.PreviousNames[0], "not-fun") assert.Equal(t, true, channel.IsChannel) assert.Equal(t, JSONTime(1360782804), channel.Created) assert.Equal(t, "U024BE7LH", channel.Creator) @@ -155,6 +153,85 @@ func TestCreateSimpleGroup(t *testing.T) { assertSimpleGroup(t, group) } +// Channel with Canvas +var channelWithCanvas = `{ + "id": "C024BE91L", + "name": "fun", + "is_channel": true, + "created": 1360782804, + "creator": "U024BE7LH", + "is_archived": false, + "is_general": false, + "members": [ + "U024BE7LH" + ], + "topic": { + "value": "Fun times", + "creator": "U024BE7LV", + "last_set": 1369677212 + }, + "purpose": { + "value": "This channel is for fun", + "creator": "U024BE7LH", + "last_set": 1360782804 + }, + "is_member": true, + "last_read": "1401383885.000061", + "unread_count": 0, + "unread_count_display": 0, + "properties": { + "canvas": { + "file_id": "F05RQ01LJU0", + "is_empty": true, + "quip_thread_id": "XFB9AAlvIyJ" + } + } +}` + +func unmarshalChannelWithCanvas(j string) (*Channel, error) { + channel := &Channel{} + if err := json.Unmarshal([]byte(j), &channel); err != nil { + return nil, err + } + return channel, nil +} + +func TestChannelWithCanvas(t *testing.T) { + channel, err := unmarshalChannelWithCanvas(channelWithCanvas) + assert.Nil(t, err) + assertChannelWithCanvas(t, channel) +} + +func assertChannelWithCanvas(t *testing.T, channel *Channel) { + assertSimpleChannel(t, channel) + assert.Equal(t, "F05RQ01LJU0", channel.Properties.Canvas.FileId) + assert.Equal(t, true, channel.Properties.Canvas.IsEmpty) + assert.Equal(t, "XFB9AAlvIyJ", channel.Properties.Canvas.QuipThreadId) +} + +func TestCreateChannelWithCanvas(t *testing.T) { + channel := &Channel{} + channel.ID = "C024BE91L" + channel.Name = "fun" + channel.IsChannel = true + channel.Created = JSONTime(1360782804) + channel.Creator = "U024BE7LH" + channel.IsArchived = false + channel.IsGeneral = false + channel.IsMember = true + channel.LastRead = "1401383885.000061" + channel.UnreadCount = 0 + channel.UnreadCountDisplay = 0 + channel.Properties = &Properties{ + Canvas: Canvas{ + FileId: "F05RQ01LJU0", + IsEmpty: true, + QuipThreadId: "XFB9AAlvIyJ", + }, + } + assertChannelWithCanvas(t, channel) +} + // IM var simpleIM = `{ "id": "D024BFF1M", @@ -293,6 +370,20 @@ func okChannelJsonHandler(rw http.ResponseWriter, r *http.Request) { rw.Write(response) } +func okInviteSharedJsonHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { + SlackResponse + InviteID string `json:"invite_id"` + IsLegacySharedChannel bool `json:"is_legacy_shared_channel"` + }{ + SlackResponse: SlackResponse{Ok: true}, + InviteID: "I01234567", + IsLegacySharedChannel: false, + }) + rw.Write(response) +} + func TestSetTopicOfConversation(t *testing.T) { http.HandleFunc("/conversations.setTopic", okChannelJsonHandler) once.Do(startServer) @@ -354,6 +445,65 @@ func TestInviteUsersToConversation(t *testing.T) { } } +func TestInviteSharedToConversation(t *testing.T) { + http.HandleFunc("/conversations.inviteShared", okInviteSharedJsonHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + t.Run("user_ids", func(t *testing.T) { + userIDs := []string{"UXXXXXXX1", "UXXXXXXX2"} + inviteID, isLegacySharedChannel, err := api.InviteSharedUserIDsToConversation("CXXXXXXXX", userIDs...) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) + + t.Run("emails", func(t *testing.T) { + emails := []string{"nopcoder@slack.com", "nopcoder@example.com"} + inviteID, isLegacySharedChannel, err := api.InviteSharedEmailsToConversation("CXXXXXXXX", emails...) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) + + t.Run("external_limited", func(t *testing.T) { + userIDs := []string{"UXXXXXXX1", "UXXXXXXX2"} + externalLimited := true + inviteID, isLegacySharedChannel, err := api.InviteSharedToConversation(InviteSharedToConversationParams{ + ChannelID: "CXXXXXXXX", + UserIDs: userIDs, + ExternalLimited: &externalLimited, + }) + if err != nil { + t.Errorf("Unexpected error: %s", err) + return + } + if inviteID == "" { + t.Error("invite id should have a value") + return + } + if isLegacySharedChannel { + t.Error("is legacy shared channel should be false") + } + }) +} + func TestKickUserFromConversation(t *testing.T) { http.HandleFunc("/conversations.kick", okJSONHandler) once.Do(startServer) @@ -597,3 +747,34 @@ func TestMarkConversation(t *testing.T) { return } } + +func createChannelCanvasHandler(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(struct { + SlackResponse + CanvasID string `json:"canvas_id"` + }{ + SlackResponse: SlackResponse{Ok: true}, + CanvasID: "F05RQ01LJU0", + }) + rw.Write(response) +} + +func TestCreateChannelCanvas(t *testing.T) { + http.HandleFunc("/conversations.canvases.create", createChannelCanvasHandler) + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + documentContent := DocumentContent{ + Type: "markdown", + Markdown: "> channel canvas!", + } + + canvasID, err := api.CreateChannelCanvas("C1234567890", documentContent) + if err != nil { + t.Errorf("Failed to create channel canvas: %v", err) + return + } + + assert.Equal(t, "F05RQ01LJU0", canvasID) +} diff --git a/dnd.go b/dnd.go index a3aa680cd..81eaf5024 100644 --- a/dnd.go +++ b/dnd.go @@ -45,12 +45,14 @@ func (api *Client) dndRequest(ctx context.Context, path string, values url.Value return response, response.Err() } -// EndDND ends the user's scheduled Do Not Disturb session +// EndDND ends the user's scheduled Do Not Disturb session. +// For more information see the EndDNDContext documentation. func (api *Client) EndDND() error { return api.EndDNDContext(context.Background()) } -// EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context +// EndDNDContext ends the user's scheduled Do Not Disturb session with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.endDnd func (api *Client) EndDNDContext(ctx context.Context) error { values := url.Values{ "token": {api.token}, @@ -65,12 +67,14 @@ func (api *Client) EndDNDContext(ctx context.Context) error { return response.Err() } -// EndSnooze ends the current user's snooze mode +// EndSnooze ends the current user's snooze mode. +// For more information see the EndSnoozeContext documentation. func (api *Client) EndSnooze() (*DNDStatus, error) { return api.EndSnoozeContext(context.Background()) } -// EndSnoozeContext ends the current user's snooze mode with a custom context +// EndSnoozeContext ends the current user's snooze mode with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.endSnooze func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -84,11 +88,13 @@ func (api *Client) EndSnoozeContext(ctx context.Context) (*DNDStatus, error) { } // GetDNDInfo provides information about a user's current Do Not Disturb settings. +// For more information see the GetDNDInfoContext documentation. func (api *Client) GetDNDInfo(user *string) (*DNDStatus, error) { return api.GetDNDInfoContext(context.Background(), user) } // GetDNDInfoContext provides information about a user's current Do Not Disturb settings with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.info func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -105,11 +111,13 @@ func (api *Client) GetDNDInfoContext(ctx context.Context, user *string) (*DNDSta } // GetDNDTeamInfo provides information about a user's current Do Not Disturb settings. +// For more information see the GetDNDTeamInfoContext documentation. func (api *Client) GetDNDTeamInfo(users []string) (map[string]DNDStatus, error) { return api.GetDNDTeamInfoContext(context.Background(), users) } // GetDNDTeamInfoContext provides information about a user's current Do Not Disturb settings with a custom context. +// Slack API docs: https://api.slack.com/methods/dnd.teamInfo func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (map[string]DNDStatus, error) { values := url.Values{ "token": {api.token}, @@ -128,15 +136,16 @@ func (api *Client) GetDNDTeamInfoContext(ctx context.Context, users []string) (m return response.Users, nil } -// SetSnooze adjusts the snooze duration for a user's Do Not Disturb -// settings. If a snooze session is not already active for the user, invoking -// this method will begin one for the specified duration. +// SetSnooze adjusts the snooze duration for a user's Do Not Disturb settings. +// For more information see the SetSnoozeContext documentation. func (api *Client) SetSnooze(minutes int) (*DNDStatus, error) { return api.SetSnoozeContext(context.Background(), minutes) } -// SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings with a custom context. -// For more information see the SetSnooze docs +// SetSnoozeContext adjusts the snooze duration for a user's Do Not Disturb settings. +// If a snooze session is not already active for the user, invoking this method will +// begin one for the specified duration. +// Slack API docs: https://api.slack.com/methods/dnd.setSnooze func (api *Client) SetSnoozeContext(ctx context.Context, minutes int) (*DNDStatus, error) { values := url.Values{ "token": {api.token}, diff --git a/emoji.go b/emoji.go index b2b0c6c90..139df0fd2 100644 --- a/emoji.go +++ b/emoji.go @@ -10,12 +10,14 @@ type emojiResponseFull struct { SlackResponse } -// GetEmoji retrieves all the emojis +// GetEmoji retrieves all the emojis. +// For more details see GetEmojiContext documentation. func (api *Client) GetEmoji() (map[string]string, error) { return api.GetEmojiContext(context.Background()) } -// GetEmojiContext retrieves all the emojis with a custom context +// GetEmojiContext retrieves all the emojis with a custom context. +// Slack API docs: https://api.slack.com/methods/emoji.list func (api *Client) GetEmojiContext(ctx context.Context) (map[string]string, error) { values := url.Values{ "token": {api.token}, diff --git a/examples/blocks/README.md b/examples/blocks/README.md index 588f2485a..2e5d81fef 100644 --- a/examples/blocks/README.md +++ b/examples/blocks/README.md @@ -53,7 +53,7 @@ To preview this block on the builder website, you should copy just the contents The first example demonstrates usage of Sections, Fields and Action buttons. You can view the [Approval Example](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3CfakeLink.toEmployeeProfile.com%7CFred%20Enriquez%20-%20New%20device%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22fields%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Type%3A*%5CnComputer%20(laptop)%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*When%3A*%5CnSubmitted%20Aut%2010%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Last%20Update%3A*%5CnMar%2010%2C%202015%20(3%20years%2C%205%20months)%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Reason%3A*%5CnAll%20vowel%20keys%20aren%27t%20working.%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Specs%3A*%5Cn%5C%22Cheetah%20Pro%2015%5C%22%20-%20Fast%2C%20really%20fast%5C%22%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleOne`. #### Example 2 - Approval - With Images -The secoond example adds additional complexity by introducing images as accessories to main blocks of text. You can view this [Approval Example with Images](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3Cgoogle.com%7CFred%20Enriquez%20-%20Time%20Off%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Type%3A*%5CnPaid%20time%20off%5Cn*When%3A*%5CnAug%2010-Aug%2013%5Cn*Hours%3A*%2016.0%20(2%20days)%5Cn*Remaining%20balance%3A*%2032.0%20hours%20(4%20days)%5Cn*Comments%3A*%20%5C%22Family%20in%20town%2C%20going%20camping!%5C%22%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FapprovalsNewDevice.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22computer%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleTwo`. +The second example adds additional complexity by introducing images as accessories to main blocks of text. You can view this [Approval Example with Images](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22You%20have%20a%20new%20request%3A%5Cn*%3Cgoogle.com%7CFred%20Enriquez%20-%20Time%20Off%20request%3E*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Type%3A*%5CnPaid%20time%20off%5Cn*When%3A*%5CnAug%2010-Aug%2013%5Cn*Hours%3A*%2016.0%20(2%20days)%5Cn*Remaining%20balance%3A*%2032.0%20hours%20(4%20days)%5Cn*Comments%3A*%20%5C%22Family%20in%20town%2C%20going%20camping!%5C%22%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FapprovalsNewDevice.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22computer%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22actions%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Approve%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%09%22text%22%3A%20%22Deny%22%0A%09%09%09%09%7D%2C%0A%09%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%0A%5D) on the block kit builder website. This example can be generated with the function named `exampleTwo`. #### Example 3 - Notifications This example shows how to add actions to your block that will trigger an interactive message to your application. You can view the rendered example for [Notifications](https://api.slack.com/tools/block-kit-builder?blocks=%5B%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%22text%22%3A%20%22Looks%20like%20you%20have%20a%20scheduling%20conflict%20with%20this%20event%3A%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3CfakeLink.toUserProfiles.com%7CIris%20%2F%20Zelda%201-1%3E*%5CnTuesday%2C%20January%2021%204%3A00-4%3A30pm%5CnBuilding%202%20-%20Havarti%20Cheese%20(3)%5Cn2%20guests%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2Fnotifications.png%22%2C%0A%09%09%09%22alt_text%22%3A%20%22calendar%20thumbnail%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22context%22%2C%0A%09%09%22elements%22%3A%20%5B%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22image%22%2C%0A%09%09%09%09%22image_url%22%3A%20%22https%3A%2F%2Fapi.slack.com%2Fimg%2Fblocks%2Fbkb_template_images%2FnotificationsWarningIcon.png%22%2C%0A%09%09%09%09%22alt_text%22%3A%20%22notifications%20warning%20icon%22%0A%09%09%09%7D%2C%0A%09%09%09%7B%0A%09%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%09%22text%22%3A%20%22*Conflicts%20with%20Team%20Huddle%3A%204%3A15-4%3A30pm*%22%0A%09%09%09%7D%0A%09%09%5D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22divider%22%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Propose%20a%20new%20time%3A*%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Today%20-%204%3A30-5pm*%5CnEveryone%20is%20available%3A%20%40iris%2C%20%40zelda%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Tomorrow%20-%204-4%3A30pm*%5CnEveryone%20is%20available%3A%20%40iris%2C%20%40zelda%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*Tomorrow%20-%206-6%3A30pm*%5CnSome%20people%20aren%27t%20available%3A%20%40iris%2C%20~%40zelda~%22%0A%09%09%7D%2C%0A%09%09%22accessory%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22button%22%2C%0A%09%09%09%22text%22%3A%20%7B%0A%09%09%09%09%22type%22%3A%20%22plain_text%22%2C%0A%09%09%09%09%22emoji%22%3A%20true%2C%0A%09%09%09%09%22text%22%3A%20%22Choose%22%0A%09%09%09%7D%2C%0A%09%09%09%22value%22%3A%20%22click_me_123%22%0A%09%09%7D%0A%09%7D%2C%0A%09%7B%0A%09%09%22type%22%3A%20%22section%22%2C%0A%09%09%22text%22%3A%20%7B%0A%09%09%09%22type%22%3A%20%22mrkdwn%22%2C%0A%09%09%09%22text%22%3A%20%22*%3Cfakelink.ToMoreTimes.com%7CShow%20more%20times%3E*%22%0A%09%09%7D%0A%09%7D%0A%5D) on the block builder website. Refer to the function `exampleThree` for details on how this block can be generated. diff --git a/examples/buttons/buttons.go b/examples/buttons/buttons.go index 82772faa1..b5ef09c49 100644 --- a/examples/buttons/buttons.go +++ b/examples/buttons/buttons.go @@ -50,7 +50,7 @@ func main() { if err != nil { fmt.Printf("Could not send message: %v", err) } - fmt.Printf("Message with buttons sucessfully sent to channel %s at %s", channelID, timestamp) + fmt.Printf("Message with buttons successfully sent to channel %s at %s", channelID, timestamp) http.HandleFunc("/actions", actionHandler) http.ListenAndServe(":3000", nil) } diff --git a/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go new file mode 100644 index 000000000..569c12d68 --- /dev/null +++ b/examples/conversation_history/conversation_history.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN") + params := slack.GetConversationHistoryParameters{ + ChannelID: "C0123456789", + } + messages, err := api.GetConversationHistoryContext(context.Background(), ¶ms) + if err != nil { + fmt.Printf("%s\n", err) + return + } + for _, message := range messages.Messages { + fmt.Printf("Message: %s\n", message.Attachments[0].Color) + } +} diff --git a/examples/dialog/dialog.go b/examples/dialog/dialog.go index 4b4d6ab18..dc0d431c3 100644 --- a/examples/dialog/dialog.go +++ b/examples/dialog/dialog.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -26,7 +26,7 @@ func handler(w http.ResponseWriter, r *http.Request) { // Read request body defer r.Body.Close() - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Printf("[ERROR] Fail to read request body: %v", err) diff --git a/examples/eventsapi/events.go b/examples/eventsapi/events.go index 25049ad07..b3bb70ab4 100644 --- a/examples/eventsapi/events.go +++ b/examples/eventsapi/events.go @@ -3,7 +3,7 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "os" @@ -18,7 +18,7 @@ func main() { signingSecret := os.Getenv("SLACK_SIGNING_SECRET") http.HandleFunc("/events-endpoint", func(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return diff --git a/examples/files_remote/files_remote.go b/examples/files_remote/files_remote.go new file mode 100644 index 000000000..60bcfa967 --- /dev/null +++ b/examples/files_remote/files_remote.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "fmt" + + "github.com/slack-go/slack" +) + +func main() { + api := slack.New("YOUR_TOKEN") + params := slack.RemoteFileParameters{ + Title: "My File", + ExternalID: "my-file-123", + ExternalURL: "https://raw.githubusercontent.com/slack-go/slack/master/README.md", + } + file, err := api.AddRemoteFileContext(context.Background(), params) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("Name: %s, URL: %s\n", file.Name, file.URLPrivate) + + err = api.DeleteFileContext(context.Background(), file.ID) + if err != nil { + fmt.Printf("%s\n", err) + return + } + fmt.Printf("File %s deleted successfully.\n", file.Name) +} diff --git a/examples/function/function.go b/examples/function/function.go new file mode 100644 index 000000000..d654890fe --- /dev/null +++ b/examples/function/function.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + "os" +) + +func main() { + api := slack.New( + os.Getenv("SLACK_BOT_TOKEN"), + slack.OptionDebug(true), + slack.OptionAppLevelToken(os.Getenv("SLACK_APP_TOKEN")), + ) + client := socketmode.New(api, socketmode.OptionDebug(true)) + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + continue + } + + fmt.Printf("Event received: %+v\n", eventsAPIEvent) + client.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.FunctionExecutedEvent: + callbackID := ev.Function.CallbackID + if callbackID == "sample_function" { + userId := ev.Inputs["user_id"] + payload := map[string]string{ + "user_id": userId, + } + + err := api.FunctionCompleteSuccess(ev.FunctionExecutionID, slack.FunctionCompleteSuccessRequestOptionOutput(payload)) + if err != nil { + fmt.Printf("failed posting message: %v \n", err) + } + } + } + default: + client.Debugf("unsupported Events API event received\n") + } + + default: + fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) + } + } + }() + client.Run() +} diff --git a/examples/function/manifest.json b/examples/function/manifest.json new file mode 100644 index 000000000..5f673f96d --- /dev/null +++ b/examples/function/manifest.json @@ -0,0 +1,56 @@ +{ + "display_information": { + "name": "Function Example" + }, + "features": { + "app_home": { + "home_tab_enabled": false, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": true + }, + "bot_user": { + "display_name": "Function Example", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": true, + "socket_mode_enabled": true, + "token_rotation_enabled": false + }, + "functions": { + "sample_function": { + "title": "Sample function", + "description": "Runs sample function", + "input_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "Message recipient", + "is_required": true, + "hint": "Select a user in the workspace", + "name": "user_id" + } + }, + "output_parameters": { + "user_id": { + "type": "slack#/types/user_id", + "title": "User", + "description": "User that completed the function", + "is_required": true, + "name": "user_id" + } + } + } + } +} diff --git a/examples/manifests/README.md b/examples/manifests/README.md new file mode 100644 index 000000000..ba6db2332 --- /dev/null +++ b/examples/manifests/README.md @@ -0,0 +1,38 @@ +# Manifest examples + +This example shows how to interact with the +new [manifest endpoints](https://api.slack.com/reference/manifests#manifest_apis). These endpoints require a special set +of tokens called `configuration tokens`. Refer to +the [relevant documentation](https://api.slack.com/authentication/config-tokens) for how to create these tokens. + +For examples on how to use configuration tokens, see the [tokens example](../tokens). + +## Usage info + +The manifest endpoints allow you to configure your application programmatically instead of manually creating +a `manifest.yaml` file and uploading it on your Slack application's dashboard. + +A manifest should follow a specific structure and has a handful of required fields. These are describe in +the [manifest documentation](https://api.slack.com/reference/manifests#fields), but Slack additionally returns very +informative error messages for malformed templates to help you pin down what the issue is. The library itself does not +attempt to perform any form of validation on your manifest. + +**Note that each configuration token may only be used once before being invalidated. Again refer to the tokens example +for more information.** + +## Available methods + +- ``Slack.CreateManifest()`` +- ``Slack.DeleteManifest()`` +- ``Slack.ExportManifest()`` +- ``Slack.UpdateManifest()`` + +## Example details + +The example code here only shows how to _update_ an application using a manifest. The other available methods are either +identical in usage or trivial to use, so no full example is provided for them. + +The example doesn't rotate the configuration tokens after updating the manifest. **You should almost always do this**. +Your access token is invalidated after sending a request, and rotating your tokens will allow you to make another +request in the future. This example does not do this explicitly as it would just repeat the tokens example. For sake of +simplicity, it only focuses on the manifest part. diff --git a/examples/manifests/manifest.go b/examples/manifests/manifest.go new file mode 100644 index 000000000..bfcfa9cab --- /dev/null +++ b/examples/manifests/manifest.go @@ -0,0 +1,45 @@ +package manifests + +import ( + "fmt" + "github.com/slack-go/slack" +) + +// createManifest programmatically creates a Slack app manifest +func createManifest() *slack.Manifest { + return &slack.Manifest{ + Display: slack.Display{ + Name: "Your Application", + }, + // ... other configuration here + } +} + +func main() { + api := slack.New( + "YOUR_TOKEN_HERE", + // You may choose to provide your access token when creating your Slack client + // or when invoking the method calls + slack.OptionConfigToken("YOUR_CONFIG_ACCESS_TOKEN_HERE"), + ) + + // Create a new Manifest object + manifest := createManifest() + + // Update your application using the new manifest + // You may pass your token as a parameter here as well, if you didn't do it above + response, err := api.UpdateManifest(manifest, "", "YOUR_APP_ID_HERE") + if err != nil { + fmt.Printf("error updating Slack application: %v\n", err) + return + } + + if !response.Ok { + fmt.Printf("unable to update Slack application: %v\n", response.Errors) + } + + fmt.Println("successfully updated Slack application") + + // The access token is now invalid, so it should be rotated for future use + // Refer to the examples about tokens for more details +} diff --git a/examples/modal/modal.go b/examples/modal/modal.go index e628e853f..13115a826 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -15,10 +15,11 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "github.com/slack-go/slack" + "time" ) func generateModalRequest() slack.ModalViewRequest { @@ -60,6 +61,30 @@ func generateModalRequest() slack.ModalViewRequest { return modalRequest } +func updateModal() slack.ModalViewRequest { + // Create a ModalViewRequest with a header and two inputs + titleText := slack.NewTextBlockObject("plain_text", "My App", false, false) + closeText := slack.NewTextBlockObject("plain_text", "Close", false, false) + submitText := slack.NewTextBlockObject("plain_text", "Submit", false, false) + + headerText := slack.NewTextBlockObject("mrkdwn", "Modal updated!", false, false) + headerSection := slack.NewSectionBlock(headerText, nil, nil) + + blocks := slack.Blocks{ + BlockSet: []slack.Block{ + headerSection, + }, + } + + var modalRequest slack.ModalViewRequest + modalRequest.Type = slack.ViewType("modal") + modalRequest.Title = titleText + modalRequest.Close = closeText + modalRequest.Submit = submitText + modalRequest.Blocks = blocks + return modalRequest +} + // This was taken from the slash example // https://github.com/slack-go/slack/blob/master/examples/slash/slash.go func verifySigningSecret(r *http.Request) error { @@ -70,13 +95,13 @@ func verifySigningSecret(r *http.Request) error { return err } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { fmt.Println(err.Error()) return err } // Need to use r.Body again when unmarshalling SlashCommand and InteractionCallback - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + r.Body = io.NopCloser(bytes.NewBuffer(body)) verifier.Write(body) if err = verifier.Ensure(); err != nil { @@ -104,7 +129,7 @@ func handleSlash(w http.ResponseWriter, r *http.Request) { } switch s.Command { - case "/humboldttest": + case "/slash": api := slack.New("YOUR_TOKEN_HERE") modalRequest := generateModalRequest() _, err = api.OpenView(s.TriggerID, modalRequest) @@ -134,21 +159,30 @@ func handleModal(w http.ResponseWriter, r *http.Request) { return } - // Note there might be a better way to get this info, but I figured this structure out from looking at the json response - firstName := i.View.State.Values["First Name"]["firstName"].Value - lastName := i.View.State.Values["Last Name"]["lastName"].Value - - msg := fmt.Sprintf("Hello %s %s, nice to meet you!", firstName, lastName) - api := slack.New("YOUR_TOKEN_HERE") - _, _, err = api.PostMessage(i.User.ID, - slack.MsgOptionText(msg, false), - slack.MsgOptionAttachments()) if err != nil { fmt.Printf(err.Error()) w.WriteHeader(http.StatusUnauthorized) return } + + // update modal sample + switch i.Type { + //update when interaction type is view_submission + case slack.InteractionTypeViewSubmission: + //you can use any modal you want to show to users just like creating modal. + updateModal := updateModal() + // You must set one of external_id or view_id and you can use hash for avoiding race condition. + // More details: https://api.slack.com/surfaces/modals/using#updating_apis + _, err := api.UpdateView(updateModal, "", i.View.Hash, i.View.ID) + // Wait for a few seconds to see result this code is necesarry due to slack server modal is going to be closed after the update + time.Sleep(time.Second * 2) + if err != nil { + fmt.Printf("Error updating view: %s", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } } func main() { diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go new file mode 100644 index 000000000..265910137 --- /dev/null +++ b/examples/pagination/pagination.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/slack-go/slack" +) + +func getAllUserUIDs(ctx context.Context, client *slack.Client, pageSize int) ([]string, error) { + var uids []string + var err error + + pages := 0 + pager := client.GetUsersPaginated(slack.GetUsersOptionLimit(pageSize)) + for { + // Note reassignment of pager to the value returned by Next() + pager, err = pager.Next(ctx) + if failedErr := pager.Failure(err); failedErr != nil { + var rateLimited *slack.RateLimitedError + if errors.As(failedErr, &rateLimited) && rateLimited.Retryable() { + fmt.Println("Rate limited by Slack API; sleeping", rateLimited.RetryAfter) + select { + case <-ctx.Done(): + return uids, ctx.Err() + case <-time.After(rateLimited.RetryAfter): + continue + } + } + return uids, fmt.Errorf("paginating users: %w", failedErr) + } + if pager.Done(err) { + break + } + + for _, user := range pager.Users { + uids = append(uids, user.ID) + } + + pages++ + } + + fmt.Printf("Pagination complete after %d pages\n", pages) + + return uids, nil +} + +func main() { + client := slack.New("YOUR_TOKEN_HERE") + + uids, err := getAllUserUIDs(context.Background(), client, 1000) + if err != nil { + panic(err) + } + + fmt.Printf("Collected %d UIDs\n", len(uids)) +} diff --git a/examples/pins/pins.go b/examples/pins/pins.go index 08d2ab120..03e7793c3 100644 --- a/examples/pins/pins.go +++ b/examples/pins/pins.go @@ -7,9 +7,8 @@ import ( "github.com/slack-go/slack" ) -/* -WARNING: This example is destructive in the sense that it create a channel called testpinning -*/ +// WARNING: This example is destructive in the sense that it create a channel called testpinning + func main() { var ( apiToken string diff --git a/examples/slash/slash.go b/examples/slash/slash.go index c70c865ff..e35ab10a8 100644 --- a/examples/slash/slash.go +++ b/examples/slash/slash.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "io" - "io/ioutil" "net/http" "github.com/slack-go/slack" @@ -27,7 +26,7 @@ func main() { return } - r.Body = ioutil.NopCloser(io.TeeReader(r.Body, &verifier)) + r.Body = io.NopCloser(io.TeeReader(r.Body, &verifier)) s, err := slack.SlashCommandParse(r) if err != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/examples/socketmode/socketmode.go b/examples/socketmode/socketmode.go index 6f1e65b48..1490a932b 100644 --- a/examples/socketmode/socketmode.go +++ b/examples/socketmode/socketmode.go @@ -72,7 +72,7 @@ func main() { innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { case *slackevents.AppMentionEvent: - _, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + _, _, err := client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) if err != nil { fmt.Printf("failed posting message: %v", err) } @@ -137,7 +137,8 @@ func main() { ), ), ), - }} + }, + } client.Ack(*evt.Request, payload) default: diff --git a/examples/team/team.go b/examples/team/team.go index 8d2fcdbc6..cba71aa74 100644 --- a/examples/team/team.go +++ b/examples/team/team.go @@ -9,17 +9,16 @@ import ( func main() { api := slack.New("YOUR_TOKEN_HERE") //Example for single user - billingActive, err := api.GetBillableInfo("U023BECGF") + billingActive, err := api.GetBillableInfo(slack.GetBillableInfoParams{User: "U023BECGF"}) if err != nil { fmt.Printf("%s\n", err) return } fmt.Printf("ID: U023BECGF, BillingActive: %v\n\n\n", billingActive["U023BECGF"]) - //Example for team - billingActiveForTeam, _ := api.GetBillableInfoForTeam() + //Example for team. Note: passing empty TeamID just uses the current user team. + billingActiveForTeam, _ := api.GetBillableInfo(slack.GetBillableInfoParams{}) for id, value := range billingActiveForTeam { fmt.Printf("ID: %v, BillingActive: %v\n", id, value) } - } diff --git a/examples/tokens/README.md b/examples/tokens/README.md new file mode 100644 index 000000000..7e8e163e7 --- /dev/null +++ b/examples/tokens/README.md @@ -0,0 +1,10 @@ +# Tokens examples + +The refresh token endpoint can be used to update +your [configuration tokenset](https://api.slack.com/authentication/config-tokens). These tokens may only be used **once +** before being invalidated, and are only valid for up to **12 hours**. + +Once a token has been used, or before it expires, you can use the `RotateTokens()` method to obtain a fresh set to use +for the next request. Depending on your use-case you may want to store these somewhere for a future run, so they are +only returned by the method call. If you wish to update the tokens inside the active Slack client, this can be done +using `UpdateConfigTokens()`. diff --git a/examples/tokens/tokens.go b/examples/tokens/tokens.go new file mode 100644 index 000000000..63d46d25f --- /dev/null +++ b/examples/tokens/tokens.go @@ -0,0 +1,33 @@ +package tokens + +import ( + "fmt" + "github.com/slack-go/slack" +) + +func main() { + api := slack.New( + "YOUR_TOKEN_HERE", + // You may choose to provide your config tokens when creating your Slack client + // or when invoking the method calls + slack.OptionConfigToken("YOUR_CONFIG_ACCESS_TOKEN_HERE"), + slack.OptionConfigRefreshToken("YOUR_REFRESH_TOKEN_HERE"), + ) + + // Obtain a fresh set of tokens + // You may pass your tokens as a parameter here as well, if you didn't do it above + freshTokens, err := api.RotateTokens("", "") + if err != nil { + fmt.Printf("error rotating tokens: %v\n", err) + return + } + + fmt.Printf("new access token: %s\n", freshTokens.Token) + fmt.Printf("new refresh token: %s\n", freshTokens.RefreshToken) + fmt.Printf("new tokenset expires at: %d\n", freshTokens.ExpiresAt) + + // Optionally: update the tokens inside the running Slack client + // This isn't necessary if you restart the application after storing the tokens elsewhere, + // or pass them as parameters to RotateTokens() explicitly + api.UpdateConfigTokens(freshTokens) +} diff --git a/examples/workflow_step/handler.go b/examples/workflow_step/handler.go index b25a5be42..d0eee101f 100644 --- a/examples/workflow_step/handler.go +++ b/examples/workflow_step/handler.go @@ -3,7 +3,7 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -25,7 +25,7 @@ func handleMyWorkflowStep(w http.ResponseWriter, r *http.Request) { } // see: https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return @@ -88,7 +88,7 @@ func handleInteraction(w http.ResponseWriter, r *http.Request) { return } - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return diff --git a/examples/workflow_step/middleware.go b/examples/workflow_step/middleware.go index 0684e50d6..6fe826910 100644 --- a/examples/workflow_step/middleware.go +++ b/examples/workflow_step/middleware.go @@ -2,20 +2,20 @@ package main import ( "bytes" - "io/ioutil" + "io" "net/http" "github.com/slack-go/slack" ) func (v *SecretsVerifierMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } r.Body.Close() - r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + r.Body = io.NopCloser(bytes.NewBuffer(body)) sv, err := slack.NewSecretsVerifier(r.Header, appCtx.config.signingSecret) if err != nil { diff --git a/files.go b/files.go index 356284420..b26317145 100644 --- a/files.go +++ b/files.go @@ -11,7 +11,7 @@ import ( ) const ( - // Add here the defaults in the siten + // Add here the defaults in the site DEFAULT_FILES_USER = "" DEFAULT_FILES_CHANNEL = "" DEFAULT_FILES_TS_FROM = 0 @@ -129,6 +129,7 @@ type FileUploadParameters struct { type GetFilesParameters struct { User string Channel string + TeamID string TimestampFrom JSONTime TimestampTo JSONTime Types string @@ -142,6 +143,7 @@ type ListFilesParameters struct { Limit int User string Channel string + TeamID string Types string Cursor string } @@ -232,12 +234,14 @@ func (api *Client) fileRequest(ctx context.Context, path string, values url.Valu return response, response.Err() } -// GetFileInfo retrieves a file and related comments +// GetFileInfo retrieves a file and related comments. +// For more details, see GetFileInfoContext documentation. func (api *Client) GetFileInfo(fileID string, count, page int) (*File, []Comment, *Paging, error) { return api.GetFileInfoContext(context.Background(), fileID, count, page) } -// GetFileInfoContext retrieves a file and related comments with a custom context +// GetFileInfoContext retrieves a file and related comments with a custom context. +// Slack API docs: https://api.slack.com/methods/files.info func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, page int) (*File, []Comment, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -253,24 +257,25 @@ func (api *Client) GetFileInfoContext(ctx context.Context, fileID string, count, return &response.File, response.Comments, &response.Paging, nil } -// GetFile retreives a given file from its private download URL +// GetFile retrieves a given file from its private download URL. func (api *Client) GetFile(downloadURL string, writer io.Writer) error { return api.GetFileContext(context.Background(), downloadURL, writer) } -// GetFileContext retreives a given file from its private download URL with a custom context -// +// GetFileContext retrieves a given file from its private download URL with a custom context. // For more details, see GetFile documentation. func (api *Client) GetFileContext(ctx context.Context, downloadURL string, writer io.Writer) error { return downloadFile(ctx, api.httpclient, api.token, downloadURL, writer, api) } -// GetFiles retrieves all files according to the parameters given +// GetFiles retrieves all files according to the parameters given. +// For more details, see GetFilesContext documentation. func (api *Client) GetFiles(params GetFilesParameters) ([]File, *Paging, error) { return api.GetFilesContext(context.Background(), params) } -// GetFilesContext retrieves all files according to the parameters given with a custom context +// GetFilesContext retrieves all files according to the parameters given with a custom context. +// Slack API docs: https://api.slack.com/methods/files.list func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameters) ([]File, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -281,6 +286,9 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter if params.Channel != DEFAULT_FILES_CHANNEL { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.TimestampFrom != DEFAULT_FILES_TS_FROM { values.Add("ts_from", strconv.FormatInt(int64(params.TimestampFrom), 10)) } @@ -308,13 +316,13 @@ func (api *Client) GetFilesContext(ctx context.Context, params GetFilesParameter } // ListFiles retrieves all files according to the parameters given. Uses cursor based pagination. +// For more details, see ListFilesContext documentation. func (api *Client) ListFiles(params ListFilesParameters) ([]File, *ListFilesParameters, error) { return api.ListFilesContext(context.Background(), params) } // ListFilesContext retrieves all files according to the parameters given with a custom context. -// -// For more details, see ListFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.list func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParameters) ([]File, *ListFilesParameters, error) { values := url.Values{ "token": {api.token}, @@ -326,6 +334,9 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet if params.Channel != DEFAULT_FILES_CHANNEL { values.Add("channel", params.Channel) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Limit != DEFAULT_FILES_COUNT { values.Add("limit", strconv.Itoa(params.Limit)) } @@ -343,12 +354,18 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet return response.Files, ¶ms, nil } -// UploadFile uploads a file +// UploadFile uploads a file. +// +// Deprecated: Use [Client.UploadFileV2] instead. This will stop functioning on March 11, 2025. +// For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFile(params FileUploadParameters) (file *File, err error) { return api.UploadFileContext(context.Background(), params) } -// UploadFileContext uploads a file and setting a custom context +// UploadFileContext uploads a file and setting a custom context. +// +// Deprecated: Use [Client.UploadFileV2Context] instead. This will stop functioning on March 11, 2025. +// For more details, see: https://api.slack.com/methods/files.upload#markdown func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParameters) (file *File, err error) { // Test if user token is valid. This helps because client.Do doesn't like this for some reason. XXX: More // investigation needed, but for now this will do. @@ -396,12 +413,14 @@ func (api *Client) UploadFileContext(ctx context.Context, params FileUploadParam return &response.File, response.Err() } -// DeleteFileComment deletes a file's comment +// DeleteFileComment deletes a file's comment. +// For more details, see DeleteFileCommentContext documentation. func (api *Client) DeleteFileComment(commentID, fileID string) error { return api.DeleteFileCommentContext(context.Background(), fileID, commentID) } -// DeleteFileCommentContext deletes a file's comment with a custom context +// DeleteFileCommentContext deletes a file's comment with a custom context. +// Slack API docs: https://api.slack.com/methods/files.comments.delete func (api *Client) DeleteFileCommentContext(ctx context.Context, fileID, commentID string) (err error) { if fileID == "" || commentID == "" { return ErrParametersMissing @@ -416,12 +435,14 @@ func (api *Client) DeleteFileCommentContext(ctx context.Context, fileID, comment return err } -// DeleteFile deletes a file +// DeleteFile deletes a file. +// For more details, see DeleteFileContext documentation. func (api *Client) DeleteFile(fileID string) error { return api.DeleteFileContext(context.Background(), fileID) } -// DeleteFileContext deletes a file with a custom context +// DeleteFileContext deletes a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.delete func (api *Client) DeleteFileContext(ctx context.Context, fileID string) (err error) { values := url.Values{ "token": {api.token}, @@ -432,12 +453,14 @@ func (api *Client) DeleteFileContext(ctx context.Context, fileID string) (err er return err } -// RevokeFilePublicURL disables public/external sharing for a file +// RevokeFilePublicURL disables public/external sharing for a file. +// For more details, see RevokeFilePublicURLContext documentation. func (api *Client) RevokeFilePublicURL(fileID string) (*File, error) { return api.RevokeFilePublicURLContext(context.Background(), fileID) } -// RevokeFilePublicURLContext disables public/external sharing for a file with a custom context +// RevokeFilePublicURLContext disables public/external sharing for a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.revokePublicURL func (api *Client) RevokeFilePublicURLContext(ctx context.Context, fileID string) (*File, error) { values := url.Values{ "token": {api.token}, @@ -451,12 +474,14 @@ func (api *Client) RevokeFilePublicURLContext(ctx context.Context, fileID string return &response.File, nil } -// ShareFilePublicURL enabled public/external sharing for a file +// ShareFilePublicURL enabled public/external sharing for a file. +// For more details, see ShareFilePublicURLContext documentation. func (api *Client) ShareFilePublicURL(fileID string) (*File, []Comment, *Paging, error) { return api.ShareFilePublicURLContext(context.Background(), fileID) } -// ShareFilePublicURLContext enabled public/external sharing for a file with a custom context +// ShareFilePublicURLContext enabled public/external sharing for a file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.sharedPublicURL func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) (*File, []Comment, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -470,7 +495,7 @@ func (api *Client) ShareFilePublicURLContext(ctx context.Context, fileID string) return &response.File, response.Comments, &response.Paging, nil } -// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file +// getUploadURLExternal gets a URL and fileID from slack which can later be used to upload a file. func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURLExternalParameters) (*getUploadURLExternalResponse, error) { values := url.Values{ "token": {api.token}, @@ -496,9 +521,8 @@ func (api *Client) getUploadURLExternal(ctx context.Context, params getUploadURL func (api *Client) uploadToURL(ctx context.Context, params uploadToURLParameters) (err error) { values := url.Values{} if params.Content != "" { - values.Add("content", params.Content) - values.Add("token", api.token) - err = postForm(ctx, api.httpclient, params.UploadURL, values, nil, api) + contentReader := strings.NewReader(params.Content) + err = postWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.Filename, "file", api.token, values, contentReader, nil, api) } else if params.File != "" { err = postLocalWithMultipartResponse(ctx, api.httpclient, params.UploadURL, params.File, "file", api.token, values, nil, api) } else if params.Reader != nil { @@ -515,11 +539,13 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return nil, err } values := url.Values{ - "token": {api.token}, - "files": {string(requestBytes)}, - "channel_id": {params.channel}, + "token": {api.token}, + "files": {string(requestBytes)}, } + if params.channel != "" { + values.Add("channel_id", params.channel) + } if params.initialComment != "" { values.Add("initial_comment", params.initialComment) } @@ -537,18 +563,18 @@ func (api *Client) completeUploadExternal(ctx context.Context, fileID string, pa return response, nil } -// UploadFileV2 uploads file to a given slack channel using 3 steps - -// 1. Get an upload URL using files.getUploadURLExternal API -// 2. Send the file as a post to the URL provided by slack -// 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// UploadFileV2 uploads file to a given slack channel using 3 steps. +// For more details, see UploadFileV2Context documentation. func (api *Client) UploadFileV2(params UploadFileV2Parameters) (*FileSummary, error) { return api.UploadFileV2Context(context.Background(), params) } -// UploadFileV2 uploads file to a given slack channel using 3 steps with a custom context - +// UploadFileV2Context uploads file to a given slack channel using 3 steps - // 1. Get an upload URL using files.getUploadURLExternal API // 2. Send the file as a post to the URL provided by slack // 3. Complete the upload and share it to the specified channel using files.completeUploadExternal +// +// Slack Docs: https://api.slack.com/messaging/files#uploading_files func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2Parameters) (file *FileSummary, err error) { if params.Filename == "" { return nil, fmt.Errorf("file.upload.v2: filename cannot be empty") @@ -556,9 +582,7 @@ func (api *Client) UploadFileV2Context(ctx context.Context, params UploadFileV2P if params.FileSize == 0 { return nil, fmt.Errorf("file.upload.v2: file size cannot be 0") } - if params.Channel == "" { - return nil, fmt.Errorf("file.upload.v2: channel cannot be empty") - } + u, err := api.getUploadURLExternal(ctx, getUploadURLExternalParameters{ altText: params.AltTxt, fileName: params.Filename, diff --git a/files_test.go b/files_test.go index f304a4e95..1df46aa6e 100644 --- a/files_test.go +++ b/files_test.go @@ -3,7 +3,7 @@ package slack import ( "bytes" "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -44,7 +44,7 @@ func (h *fileCommentHandler) handler(w http.ResponseWriter, r *http.Request) { type mockHTTPClient struct{} func (m *mockHTTPClient) Do(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewBufferString(`OK`))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(`OK`))}, nil } func TestSlack_GetFile(t *testing.T) { @@ -272,4 +272,13 @@ func TestUploadFileV2(t *testing.T) { if _, err := api.UploadFileV2(params); err != nil { t.Errorf("Unexpected error: %s", err) } + + reader = bytes.NewBufferString("test no channel") + params = UploadFileV2Parameters{ + Filename: "test.txt", + Reader: reader, + FileSize: 15} + if _, err := api.UploadFileV2(params); err != nil { + t.Errorf("Unexpected error: %s", err) + } } diff --git a/function_execute.go b/function_execute.go new file mode 100644 index 000000000..4ec8f9f4c --- /dev/null +++ b/function_execute.go @@ -0,0 +1,93 @@ +package slack + +import ( + "context" + "encoding/json" +) + +type ( + FunctionCompleteSuccessRequest struct { + FunctionExecutionID string `json:"function_execution_id"` + Outputs map[string]string `json:"outputs"` + } + + FunctionCompleteErrorRequest struct { + FunctionExecutionID string `json:"function_execution_id"` + Error string `json:"error"` + } +) + +type FunctionCompleteSuccessRequestOption func(opt *FunctionCompleteSuccessRequest) error + +func FunctionCompleteSuccessRequestOptionOutput(outputs map[string]string) FunctionCompleteSuccessRequestOption { + return func(opt *FunctionCompleteSuccessRequest) error { + if len(outputs) > 0 { + opt.Outputs = outputs + } + return nil + } +} + +// FunctionCompleteSuccess indicates function is completed +func (api *Client) FunctionCompleteSuccess(functionExecutionId string, options ...FunctionCompleteSuccessRequestOption) error { + return api.FunctionCompleteSuccessContext(context.Background(), functionExecutionId, options...) +} + +// FunctionCompleteSuccess indicates function is completed +func (api *Client) FunctionCompleteSuccessContext(ctx context.Context, functionExecutionId string, options ...FunctionCompleteSuccessRequestOption) error { + // More information: https://api.slack.com/methods/functions.completeSuccess + r := &FunctionCompleteSuccessRequest{ + FunctionExecutionID: functionExecutionId, + } + for _, option := range options { + option(r) + } + + endpoint := api.endpoint + "functions.completeSuccess" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} + +// FunctionCompleteError indicates function is completed with error +func (api *Client) FunctionCompleteError(functionExecutionID string, errorMessage string) error { + return api.FunctionCompleteErrorContext(context.Background(), functionExecutionID, errorMessage) +} + +// FunctionCompleteErrorContext indicates function is completed with error +func (api *Client) FunctionCompleteErrorContext(ctx context.Context, functionExecutionID string, errorMessage string) error { + // More information: https://api.slack.com/methods/functions.completeError + r := FunctionCompleteErrorRequest{ + FunctionExecutionID: functionExecutionID, + } + r.Error = errorMessage + + endpoint := api.endpoint + "functions.completeError" + jsonData, err := json.Marshal(r) + if err != nil { + return err + } + + response := &SlackResponse{} + if err := postJSON(ctx, api.httpclient, endpoint, api.token, jsonData, response, api); err != nil { + return err + } + + if !response.Ok { + return response.Err() + } + + return nil +} diff --git a/function_execute_test.go b/function_execute_test.go new file mode 100644 index 000000000..356e22328 --- /dev/null +++ b/function_execute_test.go @@ -0,0 +1,80 @@ +package slack + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" +) + +func postHandler(t *testing.T) func(rw http.ResponseWriter, r *http.Request) { + return func(rw http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + t.Error(err) + return + } + + var req FunctionCompleteSuccessRequest + err = json.Unmarshal(body, &req) + if err != nil { + t.Error(err) + return + } + + switch req.FunctionExecutionID { + case "function-success": + postSuccess(rw, r) + case "function-failure": + postFailure(rw, r) + } + } +} + +func postSuccess(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": true + }`) + rw.Write(response) +} + +func postFailure(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + response := []byte(`{ + "ok": false, + "error": "function_execution_not_found" + }`) + rw.Write(response) + rw.WriteHeader(500) +} + +func TestFunctionComplete(t *testing.T) { + http.HandleFunc("/functions.completeSuccess", postHandler(t)) + + once.Do(startServer) + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + err := api.FunctionCompleteSuccess("function-success") + if err != nil { + t.Error(err) + } + + err = api.FunctionCompleteSuccess("function-failure") + if err == nil { + t.Fail() + } + + err = api.FunctionCompleteSuccessContext(context.Background(), "function-success") + if err != nil { + t.Error(err) + } + + err = api.FunctionCompleteSuccessContext(context.Background(), "function-failure") + if err == nil { + t.Fail() + } +} diff --git a/interactions.go b/interactions.go index e362caa86..8c6414707 100644 --- a/interactions.go +++ b/interactions.go @@ -33,29 +33,30 @@ const ( // InteractionCallback is sent from slack when a user interactions with a button or dialog. type InteractionCallback struct { - Type InteractionType `json:"type"` - Token string `json:"token"` - CallbackID string `json:"callback_id"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - ActionTs string `json:"action_ts"` - Team Team `json:"team"` - Channel Channel `json:"channel"` - User User `json:"user"` - OriginalMessage Message `json:"original_message"` - Message Message `json:"message"` - Name string `json:"name"` - Value string `json:"value"` - MessageTs string `json:"message_ts"` - AttachmentID string `json:"attachment_id"` - ActionCallback ActionCallbacks `json:"actions"` - View View `json:"view"` - ActionID string `json:"action_id"` - APIAppID string `json:"api_app_id"` - BlockID string `json:"block_id"` - Container Container `json:"container"` - Enterprise Enterprise `json:"enterprise"` - WorkflowStep InteractionWorkflowStep `json:"workflow_step"` + Type InteractionType `json:"type"` + Token string `json:"token"` + CallbackID string `json:"callback_id"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + ActionTs string `json:"action_ts"` + Team Team `json:"team"` + Channel Channel `json:"channel"` + User User `json:"user"` + OriginalMessage Message `json:"original_message"` + Message Message `json:"message"` + Name string `json:"name"` + Value string `json:"value"` + MessageTs string `json:"message_ts"` + AttachmentID string `json:"attachment_id"` + ActionCallback ActionCallbacks `json:"actions"` + View View `json:"view"` + ActionID string `json:"action_id"` + APIAppID string `json:"api_app_id"` + BlockID string `json:"block_id"` + Container Container `json:"container"` + Enterprise Enterprise `json:"enterprise"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + WorkflowStep InteractionWorkflowStep `json:"workflow_step"` DialogSubmissionCallback ViewSubmissionCallback ViewClosedCallback diff --git a/interactions_test.go b/interactions_test.go index 400811b8a..830552814 100644 --- a/interactions_test.go +++ b/interactions_test.go @@ -293,13 +293,13 @@ func TestViewSubmissionCallback(t *testing.T) { State: &ViewState{ Values: map[string]map[string]BlockAction{ "multi-line": { - "ml-value": BlockAction{ + "ml-value": { Type: "plain_text_input", Value: "No onions", }, }, "target_channel": { - "target_select": BlockAction{ + "target_select": { Type: "conversations_select", Value: "C1AB2C3DE", }, diff --git a/manifests.go b/manifests.go new file mode 100644 index 000000000..0a972a25d --- /dev/null +++ b/manifests.go @@ -0,0 +1,297 @@ +package slack + +import ( + "context" + "encoding/json" + "net/url" +) + +// Manifest is an application manifest schema +type Manifest struct { + Metadata ManifestMetadata `json:"_metadata,omitempty" yaml:"_metadata,omitempty"` + Display Display `json:"display_information" yaml:"display_information"` + Settings Settings `json:"settings,omitempty" yaml:"settings,omitempty"` + Features Features `json:"features,omitempty" yaml:"features,omitempty"` + OAuthConfig OAuthConfig `json:"oauth_config,omitempty" yaml:"oauth_config,omitempty"` +} + +// CreateManifest creates an app from an app manifest. +// For more details, see CreateManifestContext documentation. +func (api *Client) CreateManifest(manifest *Manifest, token string) (*ManifestResponse, error) { + return api.CreateManifestContext(context.Background(), manifest, token) +} + +// CreateManifestContext creates an app from an app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.create +func (api *Client) CreateManifestContext(ctx context.Context, manifest *Manifest, token string) (*ManifestResponse, error) { + if token == "" { + token = api.configToken + } + + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "manifest": {string(jsonBytes)}, + } + + response := &ManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.create", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// DeleteManifest permanently deletes an app created through app manifests. +// For more details, see DeleteManifestContext documentation. +func (api *Client) DeleteManifest(token string, appId string) (*SlackResponse, error) { + return api.DeleteManifestContext(context.Background(), token, appId) +} + +// DeleteManifestContext permanently deletes an app created through app manifests with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.delete +func (api *Client) DeleteManifestContext(ctx context.Context, token string, appId string) (*SlackResponse, error) { + if token == "" { + token = api.configToken + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + } + + response := &SlackResponse{} + err := api.postMethod(ctx, "apps.manifest.delete", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// ExportManifest exports an app manifest from an existing app. +// For more details, see ExportManifestContext documentation. +func (api *Client) ExportManifest(token string, appId string) (*Manifest, error) { + return api.ExportManifestContext(context.Background(), token, appId) +} + +// ExportManifestContext exports an app manifest from an existing app with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.export +func (api *Client) ExportManifestContext(ctx context.Context, token string, appId string) (*Manifest, error) { + if token == "" { + token = api.configToken + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + } + + response := &ExportManifestResponse{} + err := api.postMethod(ctx, "apps.manifest.export", values, response) + if err != nil { + return nil, err + } + + return &response.Manifest, response.Err() +} + +// UpdateManifest updates an app from an app manifest. +// For more details, see UpdateManifestContext documentation. +func (api *Client) UpdateManifest(manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { + return api.UpdateManifestContext(context.Background(), manifest, token, appId) +} + +// UpdateManifestContext updates an app from an app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.update +func (api *Client) UpdateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*UpdateManifestResponse, error) { + if token == "" { + token = api.configToken + } + + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "app_id": {appId}, + "manifest": {string(jsonBytes)}, + } + + response := &UpdateManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.update", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// ValidateManifest sends a request to apps.manifest.validate to validate your app manifest. +// For more details, see ValidateManifestContext documentation. +func (api *Client) ValidateManifest(manifest *Manifest, token string, appId string) (*ManifestResponse, error) { + return api.ValidateManifestContext(context.Background(), manifest, token, appId) +} + +// ValidateManifestContext sends a request to apps.manifest.validate to validate your app manifest with a custom context. +// Slack API docs: https://api.slack.com/methods/apps.manifest.validate +func (api *Client) ValidateManifestContext(ctx context.Context, manifest *Manifest, token string, appId string) (*ManifestResponse, error) { + if token == "" { + token = api.configToken + } + + // Marshal manifest into string + jsonBytes, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + + values := url.Values{ + "token": {token}, + "manifest": {string(jsonBytes)}, + } + + if appId != "" { + values.Add("app_id", appId) + } + + response := &ManifestResponse{} + err = api.postMethod(ctx, "apps.manifest.validate", values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// ManifestMetadata is a group of settings that describe the manifest +type ManifestMetadata struct { + MajorVersion int `json:"major_version,omitempty" yaml:"major_version,omitempty"` + MinorVersion int `json:"minor_version,omitempty" yaml:"minor_version,omitempty"` +} + +// Display is a group of settings that describe parts of an app's appearance within Slack +type Display struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + LongDescription string `json:"long_description,omitempty" yaml:"long_description,omitempty"` + BackgroundColor string `json:"background_color,omitempty" yaml:"background_color,omitempty"` +} + +// Settings is a group of settings corresponding to the Settings section of the app config pages. +type Settings struct { + AllowedIPAddressRanges []string `json:"allowed_ip_address_ranges,omitempty" yaml:"allowed_ip_address_ranges,omitempty"` + EventSubscriptions EventSubscriptions `json:"event_subscriptions,omitempty" yaml:"event_subscriptions,omitempty"` + Interactivity Interactivity `json:"interactivity,omitempty" yaml:"interactivity,omitempty"` + OrgDeployEnabled bool `json:"org_deploy_enabled,omitempty" yaml:"org_deploy_enabled,omitempty"` + SocketModeEnabled bool `json:"socket_mode_enabled,omitempty" yaml:"socket_mode_enabled,omitempty"` +} + +// EventSubscriptions is a group of settings that describe the Events API configuration +type EventSubscriptions struct { + RequestUrl string `json:"request_url,omitempty" yaml:"request_url,omitempty"` + BotEvents []string `json:"bot_events,omitempty" yaml:"bot_events,omitempty"` + UserEvents []string `json:"user_events,omitempty" yaml:"user_events,omitempty"` +} + +// Interactivity is a group of settings that describe the interactivity configuration +type Interactivity struct { + IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` + RequestUrl string `json:"request_url,omitempty" yaml:"request_url,omitempty"` + MessageMenuOptionsUrl string `json:"message_menu_options_url,omitempty" yaml:"message_menu_options_url,omitempty"` +} + +// Features is a group of settings corresponding to the Features section of the app config pages +type Features struct { + AppHome AppHome `json:"app_home,omitempty" yaml:"app_home,omitempty"` + BotUser BotUser `json:"bot_user,omitempty" yaml:"bot_user,omitempty"` + Shortcuts []Shortcut `json:"shortcuts,omitempty" yaml:"shortcuts,omitempty"` + SlashCommands []ManifestSlashCommand `json:"slash_commands,omitempty" yaml:"slash_commands,omitempty"` + WorkflowSteps []WorkflowStep `json:"workflow_steps,omitempty" yaml:"workflow_steps,omitempty"` +} + +// AppHome is a group of settings that describe the App Home configuration +type AppHome struct { + HomeTabEnabled bool `json:"home_tab_enabled,omitempty" yaml:"home_tab_enabled,omitempty"` + MessagesTabEnabled bool `json:"messages_tab_enabled,omitempty" yaml:"messages_tab_enabled,omitempty"` + MessagesTabReadOnlyEnabled bool `json:"messages_tab_read_only_enabled,omitempty" yaml:"messages_tab_read_only_enabled,omitempty"` +} + +// BotUser is a group of settings that describe bot user configuration +type BotUser struct { + DisplayName string `json:"display_name" yaml:"display_name"` + AlwaysOnline bool `json:"always_online,omitempty" yaml:"always_online,omitempty"` +} + +// Shortcut is a group of settings that describes shortcut configuration +type Shortcut struct { + Name string `json:"name" yaml:"name"` + CallbackID string `json:"callback_id" yaml:"callback_id"` + Description string `json:"description" yaml:"description"` + Type ShortcutType `json:"type" yaml:"type"` +} + +// ShortcutType is a new string type for the available types of shortcuts +type ShortcutType string + +const ( + MessageShortcut ShortcutType = "message" + GlobalShortcut ShortcutType = "global" +) + +// ManifestSlashCommand is a group of settings that describes slash command configuration +type ManifestSlashCommand struct { + Command string `json:"command" yaml:"command"` + Description string `json:"description" yaml:"description"` + ShouldEscape bool `json:"should_escape,omitempty" yaml:"should_escape,omitempty"` + Url string `json:"url,omitempty" yaml:"url,omitempty"` + UsageHint string `json:"usage_hint,omitempty" yaml:"usage_hint,omitempty"` +} + +// WorkflowStep is a group of settings that describes workflow steps configuration +type WorkflowStep struct { + Name string `json:"name" yaml:"name"` + CallbackID string `json:"callback_id" yaml:"callback_id"` +} + +// OAuthConfig is a group of settings that describe OAuth configuration for the app +type OAuthConfig struct { + RedirectUrls []string `json:"redirect_urls,omitempty" yaml:"redirect_urls,omitempty"` + Scopes OAuthScopes `json:"scopes,omitempty" yaml:"scopes,omitempty"` +} + +// OAuthScopes is a group of settings that describe permission scopes configuration +type OAuthScopes struct { + Bot []string `json:"bot,omitempty" yaml:"bot,omitempty"` + User []string `json:"user,omitempty" yaml:"user,omitempty"` +} + +// ManifestResponse is the response returned by the API for apps.manifest.x endpoints +type ManifestResponse struct { + Errors []ManifestValidationError `json:"errors,omitempty"` + SlackResponse +} + +// ManifestValidationError is an error message returned for invalid manifests +type ManifestValidationError struct { + Message string `json:"message"` + Pointer string `json:"pointer"` +} + +type ExportManifestResponse struct { + Manifest Manifest `json:"manifest,omitempty"` + SlackResponse +} + +type UpdateManifestResponse struct { + AppId string `json:"app_id,omitempty"` + PermissionsUpdated bool `json:"permissions_updated,omitempty"` + ManifestResponse +} diff --git a/manifests_test.go b/manifests_test.go new file mode 100644 index 000000000..9133e68c8 --- /dev/null +++ b/manifests_test.go @@ -0,0 +1,149 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestCreateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.create", handleCreateManifest) + once.Do(startServer) + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.CreateManifest(&manif, "token") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(resp, getTestManifestResponse()) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleCreateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(getTestManifestResponse()) + rw.Write(response) +} + +func TestDeleteManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.delete", handleDeleteManifest) + expectedResponse := SlackResponse{Ok: true} + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.DeleteManifest("token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleDeleteManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(SlackResponse{Ok: true}) + rw.Write(response) +} + +func TestExportManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.export", handleExportManifest) + expectedResponse := getTestManifest() + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + resp, err := api.ExportManifest("token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleExportManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(ExportManifestResponse{Manifest: getTestManifest()}) + rw.Write(response) +} + +func TestUpdateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.update", handleUpdateManifest) + expectedResponse := UpdateManifestResponse{AppId: "app id"} + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.UpdateManifest(&manif, "token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleUpdateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(UpdateManifestResponse{AppId: "app id"}) + rw.Write(response) +} + +func TestValidateManifest(t *testing.T) { + http.HandleFunc("/apps.manifest.validate", handleValidateManifest) + expectedResponse := ManifestResponse{SlackResponse: SlackResponse{Ok: true}} + + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + manif := getTestManifest() + resp, err := api.ValidateManifest(&manif, "token", "app id") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expectedResponse, *resp) { + t.Fatal(ErrIncorrectResponse) + } +} + +func handleValidateManifest(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(ManifestResponse{SlackResponse: SlackResponse{Ok: true}}) + rw.Write(response) +} + +func getTestManifest() Manifest { + return Manifest{ + Display: Display{ + Name: "test", + Description: "this is a test", + }, + } +} + +func getTestManifestResponse() *ManifestResponse { + return &ManifestResponse{ + SlackResponse: SlackResponse{ + Ok: true, + }, + } +} diff --git a/messages.go b/messages.go index 333404563..13a1ede98 100644 --- a/messages.go +++ b/messages.go @@ -100,10 +100,11 @@ type Msg struct { Members []string `json:"members,omitempty"` // channels.replies, groups.replies, im.replies, mpim.replies - ReplyCount int `json:"reply_count,omitempty"` - Replies []Reply `json:"replies,omitempty"` - ParentUserId string `json:"parent_user_id,omitempty"` - LatestReply string `json:"latest_reply,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + ReplyUsers []string `json:"reply_users,omitempty"` + Replies []Reply `json:"replies,omitempty"` + ParentUserId string `json:"parent_user_id,omitempty"` + LatestReply string `json:"latest_reply,omitempty"` // file_share, file_comment, file_mention Files []File `json:"files,omitempty"` diff --git a/misc.go b/misc.go index 3fe189943..a8c64bf83 100644 --- a/misc.go +++ b/misc.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "mime" "mime/multipart" "net/http" @@ -85,13 +84,12 @@ func (e RateLimitedError) Retryable() bool { return true } -func fileUploadReq(ctx context.Context, path string, values url.Values, r io.Reader) (*http.Request, error) { +func fileUploadReq(ctx context.Context, path string, r io.Reader) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, r) if err != nil { return nil, err } - req.URL.RawQuery = values.Encode() return req, nil } @@ -149,7 +147,7 @@ func jsonReq(ctx context.Context, endpoint string, body interface{}) (req *http. } func parseResponseBody(body io.ReadCloser, intf interface{}, d Debug) error { - response, err := ioutil.ReadAll(body) + response, err := io.ReadAll(body) if err != nil { return err } @@ -178,9 +176,16 @@ func postLocalWithMultipartResponse(ctx context.Context, client httpClient, meth func postWithMultipartResponse(ctx context.Context, client httpClient, path, name, fieldname, token string, values url.Values, r io.Reader, intf interface{}, d Debug) error { pipeReader, pipeWriter := io.Pipe() wr := multipart.NewWriter(pipeWriter) + errc := make(chan error) go func() { defer pipeWriter.Close() + defer wr.Close() + err := createFormFields(wr, values) + if err != nil { + errc <- err + return + } ioWriter, err := wr.CreateFormFile(fieldname, name) if err != nil { errc <- err @@ -196,7 +201,8 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam return } }() - req, err := fileUploadReq(ctx, path, values, pipeReader) + + req, err := fileUploadReq(ctx, path, pipeReader) if err != nil { return err } @@ -222,6 +228,20 @@ func postWithMultipartResponse(ctx context.Context, client httpClient, path, nam } } +func createFormFields(mw *multipart.Writer, values url.Values) error { + for key, value := range values { + writer, err := mw.CreateFormField(key) + if err != nil { + return err + } + _, err = writer.Write([]byte(value[0])) + if err != nil { + return err + } + } + return nil +} + func doPost(ctx context.Context, client httpClient, req *http.Request, parser responseParser, d Debug) error { resp, err := client.Do(req) if err != nil { @@ -338,7 +358,7 @@ func newJSONParser(dst interface{}) responseParser { func newTextParser(dst interface{}) responseParser { return func(resp *http.Response) error { - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { return err } diff --git a/oauth.go b/oauth.go index e7969b020..0c77eca40 100644 --- a/oauth.go +++ b/oauth.go @@ -70,12 +70,23 @@ type OAuthV2ResponseAuthedUser struct { TokenType string `json:"token_type"` } -// GetOAuthToken retrieves an AccessToken +// OpenIDConnectResponse ... +type OpenIDConnectResponse struct { + Ok bool `json:"ok"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + IdToken string `json:"id_token"` + SlackResponse +} + +// GetOAuthToken retrieves an AccessToken. +// For more details, see GetOAuthTokenContext documentation. func GetOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { return GetOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthTokenContext retrieves an AccessToken with a custom context +// GetOAuthTokenContext retrieves an AccessToken with a custom context. +// For more details, see GetOAuthResponseContext documentation. func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, err error) { response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI) if err != nil { @@ -85,11 +96,13 @@ func GetOAuthTokenContext(ctx context.Context, client httpClient, clientID, clie } // GetBotOAuthToken retrieves top-level and bot AccessToken - https://api.slack.com/legacy/oauth#bot_user_access_tokens +// For more details, see GetBotOAuthTokenContext documentation. func GetBotOAuthToken(client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) { return GetBotOAuthTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context +// GetBotOAuthTokenContext retrieves top-level and bot AccessToken with a custom context. +// For more details, see GetOAuthResponseContext documentation. func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (accessToken string, scope string, bot OAuthResponseBot, err error) { response, err := GetOAuthResponseContext(ctx, client, clientID, clientSecret, code, redirectURI) if err != nil { @@ -98,12 +111,14 @@ func GetBotOAuthTokenContext(ctx context.Context, client httpClient, clientID, c return response.AccessToken, response.Scope, response.Bot, nil } -// GetOAuthResponse retrieves OAuth response +// GetOAuthResponse retrieves OAuth response. +// For more details, see GetOAuthResponseContext documentation. func GetOAuthResponse(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) { return GetOAuthResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthResponseContext retrieves OAuth response with custom context +// GetOAuthResponseContext retrieves OAuth response with custom context. +// Slack API docs: https://api.slack.com/methods/oauth.access func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthResponse, err error) { values := url.Values{ "client_id": {clientID}, @@ -118,12 +133,14 @@ func GetOAuthResponseContext(ctx context.Context, client httpClient, clientID, c return response, response.Err() } -// GetOAuthV2Response gets a V2 OAuth access token response - https://api.slack.com/methods/oauth.v2.access +// GetOAuthV2Response gets a V2 OAuth access token response. +// For more details, see GetOAuthV2ResponseContext documentation. func GetOAuthV2Response(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) { return GetOAuthV2ResponseContext(context.Background(), client, clientID, clientSecret, code, redirectURI) } -// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response +// GetOAuthV2ResponseContext with a context, gets a V2 OAuth access token response. +// Slack API docs: https://api.slack.com/methods/oauth.v2.access func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, @@ -138,12 +155,14 @@ func GetOAuthV2ResponseContext(ctx context.Context, client httpClient, clientID, return response, response.Err() } -// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2Token with a context, gets a V2 OAuth access token response. +// For more details, see RefreshOAuthV2TokenContext documentation. func RefreshOAuthV2Token(client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { return RefreshOAuthV2TokenContext(context.Background(), client, clientID, clientSecret, refreshToken) } -// RefreshOAuthV2AccessContext with a context, gets a V2 OAuth access token response +// RefreshOAuthV2TokenContext with a context, gets a V2 OAuth access token response. +// Slack API docs: https://api.slack.com/methods/oauth.v2.access func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID, clientSecret, refreshToken string) (resp *OAuthV2Response, err error) { values := url.Values{ "client_id": {clientID}, @@ -157,3 +176,25 @@ func RefreshOAuthV2TokenContext(ctx context.Context, client httpClient, clientID } return response, response.Err() } + +// GetOpenIDConnectToken exchanges a temporary OAuth verifier code for an access token for Sign in with Slack. +// For more details, see GetOpenIDConnectTokenContext documentation. +func GetOpenIDConnectToken(client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { + return GetOpenIDConnectTokenContext(context.Background(), client, clientID, clientSecret, code, redirectURI) +} + +// GetOpenIDConnectTokenContext with a context, gets an access token for Sign in with Slack. +// Slack API docs: https://api.slack.com/methods/openid.connect.token +func GetOpenIDConnectTokenContext(ctx context.Context, client httpClient, clientID, clientSecret, code, redirectURI string) (resp *OpenIDConnectResponse, err error) { + values := url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "redirect_uri": {redirectURI}, + } + response := &OpenIDConnectResponse{} + if err = postForm(ctx, client, APIURL+"openid.connect.token", values, response, discard{}); err != nil { + return nil, err + } + return response, response.Err() +} diff --git a/pins.go b/pins.go index ef97c8dfb..5e6cf0c7f 100644 --- a/pins.go +++ b/pins.go @@ -12,12 +12,14 @@ type listPinsResponseFull struct { SlackResponse } -// AddPin pins an item in a channel +// AddPin pins an item in a channel. +// For more details, see AddPinContext documentation. func (api *Client) AddPin(channel string, item ItemRef) error { return api.AddPinContext(context.Background(), channel, item) } -// AddPinContext pins an item in a channel with a custom context +// AddPinContext pins an item in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.add func (api *Client) AddPinContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -41,12 +43,14 @@ func (api *Client) AddPinContext(ctx context.Context, channel string, item ItemR return response.Err() } -// RemovePin un-pins an item from a channel +// RemovePin un-pins an item from a channel. +// For more details, see RemovePinContext documentation. func (api *Client) RemovePin(channel string, item ItemRef) error { return api.RemovePinContext(context.Background(), channel, item) } -// RemovePinContext un-pins an item from a channel with a custom context +// RemovePinContext un-pins an item from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.remove func (api *Client) RemovePinContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -71,11 +75,13 @@ func (api *Client) RemovePinContext(ctx context.Context, channel string, item It } // ListPins returns information about the items a user reacted to. +// For more details, see ListPinsContext documentation. func (api *Client) ListPins(channel string) ([]Item, *Paging, error) { return api.ListPinsContext(context.Background(), channel) } // ListPinsContext returns information about the items a user reacted to with a custom context. +// Slack API docs: https://api.slack.com/methods/pins.list func (api *Client) ListPinsContext(ctx context.Context, channel string) ([]Item, *Paging, error) { values := url.Values{ "channel": {channel}, diff --git a/reactions.go b/reactions.go index 2a9bd42e7..240f5ba92 100644 --- a/reactions.go +++ b/reactions.go @@ -67,10 +67,11 @@ const ( // ListReactionsParameters is the inputs to find all reactions by a user. type ListReactionsParameters struct { - User string - Count int - Page int - Full bool + User string + TeamID string + Count int + Page int + Full bool } // NewListReactionsParameters initializes the inputs to find all reactions @@ -128,11 +129,13 @@ func (res listReactionsResponseFull) extractReactedItems() []ReactedItem { } // AddReaction adds a reaction emoji to a message, file or file comment. +// For more details, see AddReactionContext documentation. func (api *Client) AddReaction(name string, item ItemRef) error { return api.AddReactionContext(context.Background(), name, item) } // AddReactionContext adds a reaction emoji to a message, file or file comment with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.add func (api *Client) AddReactionContext(ctx context.Context, name string, item ItemRef) error { values := url.Values{ "token": {api.token}, @@ -162,11 +165,13 @@ func (api *Client) AddReactionContext(ctx context.Context, name string, item Ite } // RemoveReaction removes a reaction emoji from a message, file or file comment. +// For more details, see RemoveReactionContext documentation. func (api *Client) RemoveReaction(name string, item ItemRef) error { return api.RemoveReactionContext(context.Background(), name, item) } // RemoveReactionContext removes a reaction emoji from a message, file or file comment with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.remove func (api *Client) RemoveReactionContext(ctx context.Context, name string, item ItemRef) error { values := url.Values{ "token": {api.token}, @@ -196,11 +201,13 @@ func (api *Client) RemoveReactionContext(ctx context.Context, name string, item } // GetReactions returns details about the reactions on an item. +// For more details, see GetReactionsContext documentation. func (api *Client) GetReactions(item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { return api.GetReactionsContext(context.Background(), item, params) } -// GetReactionsContext returns details about the reactions on an item with a custom context +// GetReactionsContext returns details about the reactions on an item with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.get func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params GetReactionsParameters) ([]ItemReaction, error) { values := url.Values{ "token": {api.token}, @@ -234,11 +241,13 @@ func (api *Client) GetReactionsContext(ctx context.Context, item ItemRef, params } // ListReactions returns information about the items a user reacted to. +// For more details, see ListReactionsContext documentation. func (api *Client) ListReactions(params ListReactionsParameters) ([]ReactedItem, *Paging, error) { return api.ListReactionsContext(context.Background(), params) } // ListReactionsContext returns information about the items a user reacted to with a custom context. +// Slack API docs: https://api.slack.com/methods/reactions.list func (api *Client) ListReactionsContext(ctx context.Context, params ListReactionsParameters) ([]ReactedItem, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -246,6 +255,9 @@ func (api *Client) ListReactionsContext(ctx context.Context, params ListReaction if params.User != DEFAULT_REACTIONS_USER { values.Add("user", params.User) } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Count != DEFAULT_REACTIONS_COUNT { values.Add("count", strconv.Itoa(params.Count)) } diff --git a/reminders.go b/reminders.go index 53d67c03c..e025bc9b6 100644 --- a/reminders.go +++ b/reminders.go @@ -41,23 +41,18 @@ func (api *Client) doReminders(ctx context.Context, path string, values url.Valu // create an array of pointers to reminders var reminders = make([]*Reminder, 0, len(response.Reminders)) - for _, reminder := range response.Reminders { - reminders = append(reminders, reminder) - } - + reminders = append(reminders, response.Reminders...) return reminders, response.Err() } // ListReminders lists all the reminders created by or for the authenticated user -// -// See https://api.slack.com/methods/reminders.list +// For more details, see ListRemindersContext documentation. func (api *Client) ListReminders() ([]*Reminder, error) { return api.ListRemindersContext(context.Background()) } -// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context -// -// For more details, see ListReminders documentation. +// ListRemindersContext lists all the reminders created by or for the authenticated user with a custom context. +// Slack API docs: https://api.slack.com/methods/reminders.list func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -66,17 +61,14 @@ func (api *Client) ListRemindersContext(ctx context.Context) ([]*Reminder, error } // AddChannelReminder adds a reminder for a channel. -// -// See https://api.slack.com/methods/reminders.add (NOTE: the ability to set -// reminders on a channel is currently undocumented but has been tested to -// work) +// For more details, see AddChannelReminderContext documentation. func (api *Client) AddChannelReminder(channelID, text, time string) (*Reminder, error) { return api.AddChannelReminderContext(context.Background(), channelID, text, time) } // AddChannelReminderContext adds a reminder for a channel with a custom context -// -// For more details, see AddChannelReminder documentation. +// NOTE: the ability to set reminders on a channel is currently undocumented but has been tested to work. +// Slack API docs: https://api.slack.com/methods/reminders.add func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, text, time string) (*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -88,17 +80,13 @@ func (api *Client) AddChannelReminderContext(ctx context.Context, channelID, tex } // AddUserReminder adds a reminder for a user. -// -// See https://api.slack.com/methods/reminders.add (NOTE: the ability to set -// reminders on a channel is currently undocumented but has been tested to -// work) +// For more details, see AddUserReminderContext documentation. func (api *Client) AddUserReminder(userID, text, time string) (*Reminder, error) { return api.AddUserReminderContext(context.Background(), userID, text, time) } // AddUserReminderContext adds a reminder for a user with a custom context -// -// For more details, see AddUserReminder documentation. +// Slack API docs: https://api.slack.com/methods/reminders.add func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, time string) (*Reminder, error) { values := url.Values{ "token": {api.token}, @@ -110,15 +98,13 @@ func (api *Client) AddUserReminderContext(ctx context.Context, userID, text, tim } // DeleteReminder deletes an existing reminder. -// -// See https://api.slack.com/methods/reminders.delete +// For more details, see DeleteReminderContext documentation. func (api *Client) DeleteReminder(id string) error { return api.DeleteReminderContext(context.Background(), id) } // DeleteReminderContext deletes an existing reminder with a custom context -// -// For more details, see DeleteReminder documentation. +// Slack API docs: https://api.slack.com/methods/reminders.delete func (api *Client) DeleteReminderContext(ctx context.Context, id string) error { values := url.Values{ "token": {api.token}, diff --git a/reminders_test.go b/reminders_test.go index 25291b543..09dd6a096 100644 --- a/reminders_test.go +++ b/reminders_test.go @@ -2,7 +2,7 @@ package slack import ( "bytes" - "io/ioutil" + "io" "net/http" "reflect" "testing" @@ -185,7 +185,7 @@ func (m *mockRemindersListHTTPClient) Do(*http.Request) (*http.Response, error) ] }` - return &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewBufferString(responseString))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewBufferString(responseString))}, nil } func TestSlack_ListReminders(t *testing.T) { diff --git a/remotefiles.go b/remotefiles.go index 8a908a8f3..42639a178 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -97,14 +97,13 @@ func (api *Client) remoteFileRequest(ctx context.Context, path string, values ur } // AddRemoteFile adds a remote file. Unlike regular files, remote files must be explicitly shared. -// For more details: -// https://api.slack.com/methods/files.remote.add +// For more details see the AddRemoteFileContext documentation. func (api *Client) AddRemoteFile(params RemoteFileParameters) (*RemoteFile, error) { return api.AddRemoteFileContext(context.Background(), params) } // AddRemoteFileContext adds a remote file and setting a custom context -// For more details see the AddRemoteFile documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.add func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFileParameters) (remotefile *RemoteFile, err error) { if params.ExternalID == "" || params.ExternalURL == "" || params.Title == "" { return nil, ErrParametersMissing @@ -138,14 +137,13 @@ func (api *Client) AddRemoteFileContext(ctx context.Context, params RemoteFilePa } // ListRemoteFiles retrieves all remote files according to the parameters given. Uses cursor based pagination. -// For more details: -// https://api.slack.com/methods/files.remote.list +// For more details see the ListRemoteFilesContext documentation. func (api *Client) ListRemoteFiles(params ListRemoteFilesParameters) ([]RemoteFile, error) { return api.ListRemoteFilesContext(context.Background(), params) } // ListRemoteFilesContext retrieves all remote files according to the parameters given with a custom context. Uses cursor based pagination. -// For more details see the ListRemoteFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.list func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemoteFilesParameters) ([]RemoteFile, error) { values := url.Values{ "token": {api.token}, @@ -177,14 +175,13 @@ func (api *Client) ListRemoteFilesContext(ctx context.Context, params ListRemote } // GetRemoteFileInfo retrieves the complete remote file information. -// For more details: -// https://api.slack.com/methods/files.remote.info +// For more details see the GetRemoteFileInfoContext documentation. func (api *Client) GetRemoteFileInfo(externalID, fileID string) (remotefile *RemoteFile, err error) { return api.GetRemoteFileInfoContext(context.Background(), externalID, fileID) } // GetRemoteFileInfoContext retrieves the complete remote file information given with a custom context. -// For more details see the GetRemoteFileInfo documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.info func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fileID string) (remotefile *RemoteFile, err error) { if fileID == "" && externalID == "" { return nil, fmt.Errorf("either externalID or fileID is required") @@ -208,15 +205,14 @@ func (api *Client) GetRemoteFileInfoContext(ctx context.Context, externalID, fil return &response.RemoteFile, err } -// ShareRemoteFile shares a remote file to channels -// For more details: -// https://api.slack.com/methods/files.remote.share +// ShareRemoteFile shares a remote file to channels. +// For more details see the ShareRemoteFileContext documentation. func (api *Client) ShareRemoteFile(channels []string, externalID, fileID string) (file *RemoteFile, err error) { return api.ShareRemoteFileContext(context.Background(), channels, externalID, fileID) } // ShareRemoteFileContext shares a remote file to channels with a custom context. -// For more details see the ShareRemoteFile documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.share func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string, externalID, fileID string) (file *RemoteFile, err error) { if channels == nil || len(channels) == 0 { return nil, ErrParametersMissing @@ -241,20 +237,17 @@ func (api *Client) ShareRemoteFileContext(ctx context.Context, channels []string return &response.RemoteFile, err } -// UpdateRemoteFile updates a remote file -// For more details: -// https://api.slack.com/methods/files.remote.update +// UpdateRemoteFile updates a remote file. +// For more details see the UpdateRemoteFileContext documentation. func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { return api.UpdateRemoteFileContext(context.Background(), fileID, params) } -// UpdateRemoteFileContext updates a remote file with a custom context -// For more details see the UpdateRemoteFile documentation. +// UpdateRemoteFileContext updates a remote file with a custom context. +// Slack API docs: https://api.slack.com/methods/files.remote.update func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, params RemoteFileParameters) (remotefile *RemoteFile, err error) { response := &remoteFileResponseFull{} - values := url.Values{ - "token": {api.token}, - } + values := url.Values{} if fileID != "" { values.Add("file", fileID) } @@ -276,6 +269,7 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p if params.PreviewImageReader != nil { err = postWithMultipartResponse(ctx, api.httpclient, api.endpoint+"files.remote.update", "preview.png", "preview_image", api.token, values, params.PreviewImageReader, response, api) } else { + values.Add("token", api.token) response, err = api.remoteFileRequest(ctx, "files.remote.update", values) } @@ -287,14 +281,13 @@ func (api *Client) UpdateRemoteFileContext(ctx context.Context, fileID string, p } // RemoveRemoteFile removes a remote file. -// For more details: -// https://api.slack.com/methods/files.remote.remove +// For more information see the RemoveRemoteFileContext documentation. func (api *Client) RemoveRemoteFile(externalID, fileID string) (err error) { return api.RemoveRemoteFileContext(context.Background(), externalID, fileID) } // RemoveRemoteFileContext removes a remote file with a custom context -// For more information see the RemoveRemoteFiles documentation. +// Slack API docs: https://api.slack.com/methods/files.remote.remove func (api *Client) RemoveRemoteFileContext(ctx context.Context, externalID, fileID string) (err error) { if fileID == "" && externalID == "" { return fmt.Errorf("either externalID or fileID is required") diff --git a/search.go b/search.go index de6b40acb..d27497aae 100644 --- a/search.go +++ b/search.go @@ -15,6 +15,7 @@ const ( ) type SearchParameters struct { + TeamID string Sort string SortDirection string Highlight bool @@ -93,6 +94,9 @@ func (api *Client) _search(ctx context.Context, path, query string, params Searc "token": {api.token}, "query": {query}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Sort != DEFAULT_SEARCH_SORT { values.Add("sort", params.Sort) } diff --git a/slack.go b/slack.go index ea3aab6d6..756106fe4 100644 --- a/slack.go +++ b/slack.go @@ -57,12 +57,14 @@ type authTestResponseFull struct { type ParamOption func(*url.Values) type Client struct { - token string - appLevelToken string - endpoint string - debug bool - log ilogger - httpclient httpClient + token string + appLevelToken string + configToken string + configRefreshToken string + endpoint string + debug bool + log ilogger + httpclient httpClient } // Option defines an option for a Client @@ -99,6 +101,16 @@ func OptionAppLevelToken(token string) func(*Client) { return func(c *Client) { c.appLevelToken = token } } +// OptionConfigToken sets a configuration token for the client. +func OptionConfigToken(token string) func(*Client) { + return func(c *Client) { c.configToken = token } +} + +// OptionConfigRefreshToken sets a configuration refresh token for the client. +func OptionConfigRefreshToken(token string) func(*Client) { + return func(c *Client) { c.configRefreshToken = token } +} + // New builds a slack client from the provided token and options. func New(token string, options ...Option) *Client { s := &Client{ diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 6fcb0b59b..7032d1f93 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -31,6 +31,9 @@ type AppMentionEvent struct { // BotID is filled out when a bot triggers the app_mention event BotID string `json:"bot_id,omitempty"` + + // When the app is mentioned in the edited message + Edited *Edited `json:"edited,omitempty"` } // AppHomeOpenedEvent Your Slack app home was opened. @@ -275,8 +278,11 @@ type MessageEvent struct { Upload bool `json:"upload"` Files []File `json:"files"` + Blocks slack.Blocks `json:"blocks,omitempty"` Attachments []slack.Attachment `json:"attachments,omitempty"` + Metadata slack.SlackMetadata `json:"metadata,omitempty"` + // Root is the message that was broadcast to the channel when the SubType is // thread_broadcast. If this is not a thread_broadcast message event, this // value is nil. @@ -346,12 +352,6 @@ type TeamJoinEvent struct { EventTimestamp string `json:"event_ts"` } -// UserChangeEvent happens when a user's profile changes -type UserChangeEvent struct { - Type string `json:"type"` - User *slack.User `json:"user"` -} - // UserProfileChangeEvent happens when a user's profile changes type UserProfileChangeEvent struct { Type string `json:"type"` @@ -476,6 +476,7 @@ type File struct { DisplayAsBot bool `json:"display_as_bot"` Username string `json:"username"` URLPrivate string `json:"url_private"` + FileAccess string `json:"file_access"` URLPrivateDownload string `json:"url_private_download"` Thumb64 string `json:"thumb_64"` Thumb80 string `json:"thumb_80"` @@ -556,6 +557,568 @@ type TeamAccessRevokedEvent struct { TeamIDs []string `json:"team_ids"` } +// UserProfileChangedEvent is sent if access to teams was revoked for your org-wide app. +type UserProfileChangedEvent struct { + User *slack.User `json:"user"` + CacheTs int `json:"cache_ts"` + Type string `json:"type"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteApprovedEvent is sent if your invitation has been approved +type SharedChannelInviteApprovedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *slack.Conversation `json:"channel"` + ApprovingTeamID string `json:"approving_team_id"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + ApprovingUser *SlackEventUser `json:"approving_user"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteAcceptedEvent is sent if external org accepts a Slack Connect channel invite +type SharedChannelInviteAcceptedEvent struct { + Type string `json:"type"` + ApprovalRequired bool `json:"approval_required"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + AcceptingUser *SlackEventUser `json:"accepting_user"` + EventTs string `json:"event_ts"` + RequiresSponsorship bool `json:"requires_sponsorship,omitempty"` +} + +// SharedChannelInviteDeclinedEvent is sent if external or internal org declines the Slack Connect invite +type SharedChannelInviteDeclinedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + DecliningTeamID string `json:"declining_team_id"` + TeamsInChannel []*SlackEventTeam `json:"teams_in_channel"` + DecliningUser *SlackEventUser `json:"declining_user"` + EventTs string `json:"event_ts"` +} + +// SharedChannelInviteReceivedEvent is sent if a bot or app is invited to a Slack Connect channel +type SharedChannelInviteReceivedEvent struct { + Type string `json:"type"` + Invite *SharedInvite `json:"invite"` + Channel *SharedChannel `json:"channel"` + EventTs string `json:"event_ts"` +} + +// SlackEventTeam is a struct for teams in ShareChannel events +type SlackEventTeam struct { + ID string `json:"id"` + Name string `json:"name"` + Icon *SlackEventIcon `json:"icon,omitempty"` + AvatarBaseURL string `json:"avatar_base_url,omitempty"` + IsVerified bool `json:"is_verified"` + Domain string `json:"domain"` + DateCreated int `json:"date_created"` + RequiresSponsorship bool `json:"requires_sponsorship,omitempty"` + // TeamID string `json:"team_id,omitempty"` +} + +// SlackEventIcon is a struct for icons in ShareChannel events +type SlackEventIcon struct { + ImageDefault bool `json:"image_default,omitempty"` + Image34 string `json:"image_34,omitempty"` + Image44 string `json:"image_44,omitempty"` + Image68 string `json:"image_68,omitempty"` + Image88 string `json:"image_88,omitempty"` + Image102 string `json:"image_102,omitempty"` + Image132 string `json:"image_132,omitempty"` + Image230 string `json:"image_230,omitempty"` +} + +// SlackEventUser is a struct for users in ShareChannel events +type SlackEventUser struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Updated int `json:"updated,omitempty"` + Profile *slack.UserProfile `json:"profile,omitempty"` + WhoCanShareContactCard string `json:"who_can_share_contact_card,omitempty"` +} + +// SharedChannel is a struct for shared channels in ShareChannel events +type SharedChannel struct { + ID string `json:"id"` + IsPrivate bool `json:"is_private"` + IsIm bool `json:"is_im"` + Name string `json:"name,omitempty"` +} + +// SharedInvite is a struct for shared invites in ShareChannel events +type SharedInvite struct { + ID string `json:"id"` + DateCreated int `json:"date_created"` + DateInvalid int `json:"date_invalid"` + InvitingTeam *SlackEventTeam `json:"inviting_team,omitempty"` + InvitingUser *SlackEventUser `json:"inviting_user,omitempty"` + RecipientEmail string `json:"recipient_email,omitempty"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + IsSponsored bool `json:"is_sponsored,omitempty"` + IsExternalLimited bool `json:"is_external_limited,omitempty"` +} + +type ChannelHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type CommandsChangedEvent struct { + Type string `json:"type"` + EventTs string `json:"event_ts"` +} + +type DndUpdatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + DndStatus struct { + DndEnabled bool `json:"dnd_enabled"` + NextDndStartTs int64 `json:"next_dnd_start_ts"` + NextDndEndTs int64 `json:"next_dnd_end_ts"` + SnoozeEnabled bool `json:"snooze_enabled"` + SnoozeEndtime int64 `json:"snooze_endtime"` + } `json:"dnd_status"` +} + +type DndUpdatedUserEvent struct { + Type string `json:"type"` + User string `json:"user"` + DndStatus struct { + DndEnabled bool `json:"dnd_enabled"` + NextDndStartTs int64 `json:"next_dnd_start_ts"` + NextDndEndTs int64 `json:"next_dnd_end_ts"` + } `json:"dnd_status"` +} + +type EmailDomainChangedEvent struct { + Type string `json:"type"` + EmailDomain string `json:"email_domain"` + EventTs string `json:"event_ts"` +} + +type GroupCloseEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type GroupHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type GroupOpenEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type ImCloseEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type ImCreatedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel struct { + ID string `json:"id"` + } `json:"channel"` +} + +type ImHistoryChangedEvent struct { + Type string `json:"type"` + Latest string `json:"latest"` + Ts string `json:"ts"` + EventTs string `json:"event_ts"` +} + +type ImOpenEvent struct { + Type string `json:"type"` + User string `json:"user"` + Channel string `json:"channel"` +} + +type SubTeam struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + IsUsergroup bool `json:"is_usergroup"` + Name string `json:"name"` + Description string `json:"description"` + Handle string `json:"handle"` + IsExternal bool `json:"is_external"` + DateCreate int64 `json:"date_create"` + DateUpdate int64 `json:"date_update"` + DateDelete int64 `json:"date_delete"` + AutoType string `json:"auto_type"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + DeletedBy string `json:"deleted_by"` + Prefs struct { + Channels []string `json:"channels"` + Groups []string `json:"groups"` + } `json:"prefs"` + Users []string `json:"users"` + UserCount int `json:"user_count"` +} + +type SubteamCreatedEvent struct { + Type string `json:"type"` + Subteam SubTeam `json:"subteam"` +} + +type SubteamMembersChangedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` + TeamID string `json:"team_id"` + DatePreviousUpdate int `json:"date_previous_update"` + DateUpdate int64 `json:"date_update"` + AddedUsers []string `json:"added_users"` + AddedUsersCount string `json:"added_users_count"` + RemovedUsers []string `json:"removed_users"` + RemovedUsersCount string `json:"removed_users_count"` +} + +type SubteamSelfAddedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` +} + +type SubteamSelfRemovedEvent struct { + Type string `json:"type"` + SubteamID string `json:"subteam_id"` +} + +type SubteamUpdatedEvent struct { + Type string `json:"type"` + Subteam SubTeam `json:"subteam"` +} + +type TeamDomainChangeEvent struct { + Type string `json:"type"` + URL string `json:"url"` + Domain string `json:"domain"` + TeamID string `json:"team_id"` +} + +type TeamRenameEvent struct { + Type string `json:"type"` + Name string `json:"name"` + TeamID string `json:"team_id"` +} + +type UserChangeEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type AppDeletedEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type AppInstalledEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type AppRequestedEvent struct { + Type string `json:"type"` + AppRequest struct { + ID string `json:"id"` + App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + HelpURL string `json:"help_url"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + AppHomepageURL string `json:"app_homepage_url"` + AppDirectoryURL string `json:"app_directory_url"` + IsAppDirectoryApproved bool `json:"is_app_directory_approved"` + IsInternal bool `json:"is_internal"` + AdditionalInfo string `json:"additional_info"` + } `json:"app"` + PreviousResolution struct { + Status string `json:"status"` + Scopes []struct { + Name string `json:"name"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + TokenType string `json:"token_type"` + } `json:"scopes"` + } `json:"previous_resolution"` + User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + } `json:"team"` + Enterprise interface{} `json:"enterprise"` + Scopes []struct { + Name string `json:"name"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + TokenType string `json:"token_type"` + } `json:"scopes"` + Message string `json:"message"` + } `json:"app_request"` +} + +type AppUninstalledTeamEvent struct { + Type string `json:"type"` + AppID string `json:"app_id"` + AppName string `json:"app_name"` + AppOwnerID string `json:"app_owner_id"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EventTs string `json:"event_ts"` +} + +type CallRejectedEvent struct { + Token string `json:"token"` + TeamID string `json:"team_id"` + APIAppID string `json:"api_app_id"` + Event struct { + Type string `json:"type"` + CallID string `json:"call_id"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + ExternalUniqueID string `json:"external_unique_id"` + } `json:"event"` + Type string `json:"type"` + EventID string `json:"event_id"` + AuthedUsers []string `json:"authed_users"` +} + +type ChannelSharedEvent struct { + Type string `json:"type"` + ConnectedTeamID string `json:"connected_team_id"` + Channel string `json:"channel"` + EventTs string `json:"event_ts"` +} + +type FileCreatedEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File struct { + ID string `json:"id"` + } `json:"file"` +} + +type FilePublicEvent struct { + Type string `json:"type"` + FileID string `json:"file_id"` + File struct { + ID string `json:"id"` + } `json:"file"` +} + +type FunctionExecutedEvent struct { + Type string `json:"type"` + Function struct { + ID string `json:"id"` + CallbackID string `json:"callback_id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"type"` + InputParameters []struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Title string `json:"title"` + IsRequired bool `json:"is_required"` + } `json:"input_parameters"` + OutputParameters []struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Title string `json:"title"` + IsRequired bool `json:"is_required"` + } `json:"output_parameters"` + AppID string `json:"app_id"` + DateCreated int64 `json:"date_created"` + DateUpdated int64 `json:"date_updated"` + DateDeleted int64 `json:"date_deleted"` + } `json:"function"` + Inputs map[string]string `json:"inputs"` + FunctionExecutionID string `json:"function_execution_id"` + WorkflowExecutionID string `json:"workflow_execution_id"` + EventTs string `json:"event_ts"` + BotAccessToken string `json:"bot_access_token"` +} + +type InviteRequestedEvent struct { + Type string `json:"type"` + InviteRequest struct { + ID string `json:"id"` + Email string `json:"email"` + DateCreated int64 `json:"date_created"` + RequesterIDs []string `json:"requester_ids"` + ChannelIDs []string `json:"channel_ids"` + InviteType string `json:"invite_type"` + RealName string `json:"real_name"` + DateExpire int64 `json:"date_expire"` + RequestReason string `json:"request_reason"` + Team struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + } `json:"team"` + } `json:"invite_request"` +} + +type StarAddedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item struct { + } `json:"item"` + EventTS string `json:"event_ts"` +} + +type StarRemovedEvent struct { + Type string `json:"type"` + User string `json:"user"` + Item struct { + } `json:"item"` + EventTS string `json:"event_ts"` +} + +type UserHuddleChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type User struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` + Color string `json:"color"` + RealName string `json:"real_name"` + TZ string `json:"tz"` + TZLabel string `json:"tz_label"` + TZOffset int `json:"tz_offset"` + Profile Profile `json:"profile"` + IsAdmin bool `json:"is_admin"` + IsOwner bool `json:"is_owner"` + IsPrimaryOwner bool `json:"is_primary_owner"` + IsRestricted bool `json:"is_restricted"` + IsUltraRestricted bool `json:"is_ultra_restricted"` + IsBot bool `json:"is_bot"` + IsAppUser bool `json:"is_app_user"` + Updated int64 `json:"updated"` + IsEmailConfirmed bool `json:"is_email_confirmed"` + WhoCanShareContactCard string `json:"who_can_share_contact_card"` + Locale string `json:"locale"` +} + +type Profile struct { + Title string `json:"title"` + Phone string `json:"phone"` + Skype string `json:"skype"` + RealName string `json:"real_name"` + RealNameNormalized string `json:"real_name_normalized"` + DisplayName string `json:"display_name"` + DisplayNameNormalized string `json:"display_name_normalized"` + Fields map[string]interface{} `json:"fields"` + StatusText string `json:"status_text"` + StatusEmoji string `json:"status_emoji"` + StatusEmojiDisplayInfo []interface{} `json:"status_emoji_display_info"` + StatusExpiration int `json:"status_expiration"` + AvatarHash string `json:"avatar_hash"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Image24 string `json:"image_24"` + Image32 string `json:"image_32"` + Image48 string `json:"image_48"` + Image72 string `json:"image_72"` + Image192 string `json:"image_192"` + Image512 string `json:"image_512"` + StatusTextCanonical string `json:"status_text_canonical"` + Team string `json:"team"` +} + +type UserStatusChangedEvent struct { + Type string `json:"type"` + User User `json:"user"` + CacheTS int64 `json:"cache_ts"` + EventTS string `json:"event_ts"` +} + +type Actor struct { + ID string `json:"id"` + Name string `json:"name"` + IsBot bool `json:"is_bot"` + TeamID string `json:"team_id"` + Timezone string `json:"timezone"` + RealName string `json:"real_name"` + DisplayName string `json:"display_name"` +} + +type TargetUser struct { + Email string `json:"email"` + InviteID string `json:"invite_id"` +} + +type TeamIcon struct { + Image34 string `json:"image_34"` + ImageDefault bool `json:"image_default"` +} + +type Team struct { + ID string `json:"id"` + Icon TeamIcon `json:"icon"` + Name string `json:"name"` + Domain string `json:"domain"` + IsVerified bool `json:"is_verified"` + DateCreated int64 `json:"date_created"` + AvatarBaseURL string `json:"avatar_base_url"` + RequiresSponsorship bool `json:"requires_sponsorship"` +} + +type SharedChannelInviteRequestedEvent struct { + Actor Actor `json:"actor"` + ChannelID string `json:"channel_id"` + EventType string `json:"event_type"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` + TargetUsers []TargetUser `json:"target_users"` + TeamsInChannel []Team `json:"teams_in_channel"` + IsExternalLimited bool `json:"is_external_limited"` + ChannelDateCreated int64 `json:"channel_date_created"` + ChannelMessageLatestCounted int64 `json:"channel_message_latest_counted_timestamp"` +} + type EventsAPIType string const ( @@ -605,9 +1168,9 @@ const ( LinkShared = EventsAPIType("link_shared") // Message A message was posted to a channel, private channel (group), im, or mim Message = EventsAPIType("message") - // Member Joined Channel + // MemberJoinedChannel is sent if a member joined a channel. MemberJoinedChannel = EventsAPIType("member_joined_channel") - // Member Left Channel + // MemberLeftChannel is sent if a member left a channel. MemberLeftChannel = EventsAPIType("member_left_channel") // PinAdded An item was pinned to a channel PinAdded = EventsAPIType("pin_added") @@ -619,70 +1182,185 @@ const ( ReactionRemoved = EventsAPIType("reaction_removed") // TeamJoin A new user joined the workspace TeamJoin = EventsAPIType("team_join") + // Slack connect app or bot invite received + SharedChannelInviteReceived = EventsAPIType("shared_channel_invite_received") + // Slack connect channel invite approved + SharedChannelInviteApproved = EventsAPIType("shared_channel_invite_approved") + // Slack connect channel invite declined + SharedChannelInviteDeclined = EventsAPIType("shared_channel_invite_declined") + // Slack connect channel invite accepted by an end user + SharedChannelInviteAccepted = EventsAPIType("shared_channel_invite_accepted") // TokensRevoked APP's API tokes are revoked TokensRevoked = EventsAPIType("tokens_revoked") // EmojiChanged A custom emoji has been added or changed EmojiChanged = EventsAPIType("emoji_changed") - // A user has changed - UserChange = EventsAPIType("user_change") // Specifically the user's profile has changed UserProfileChange = EventsAPIType("user_profile_changed") // WorkflowStepExecute Happens, if a workflow step of your app is invoked WorkflowStepExecute = EventsAPIType("workflow_step_execute") // MessageMetadataPosted A message with metadata was posted MessageMetadataPosted = EventsAPIType("message_metadata_posted") - // MessageMetadataPosted A message with metadata was updated + // MessageMetadataUpdated A message with metadata was updated MessageMetadataUpdated = EventsAPIType("message_metadata_updated") - // MessageMetadataPosted A message with metadata was deleted + // MessageMetadataDeleted A message with metadata was deleted MessageMetadataDeleted = EventsAPIType("message_metadata_deleted") // TeamAccessGranted is sent if access to teams was granted for your org-wide app. TeamAccessGranted = EventsAPIType("team_access_granted") - // TeamAccessrevoked is sent if access to teams was revoked for your org-wide app. - TeamAccessrevoked = EventsAPIType("team_access_revoked") + // TeamAccessRevoked is sent if access to teams was revoked for your org-wide app. + TeamAccessRevoked = EventsAPIType("team_access_revoked") + // UserProfileChanged is sent if a user's profile information has changed. + UserProfileChanged = EventsAPIType("user_profile_changed") + // ChannelHistoryChanged The history of a channel changed + ChannelHistoryChanged = EventsAPIType("channel_history_changed") + // CommandsChanged A command was changed + CommandsChanged = EventsAPIType("commands_changed") + // DndUpdated Do Not Disturb settings were updated + DndUpdated = EventsAPIType("dnd_updated") + // DndUpdatedUser Do Not Disturb settings for a user were updated + DndUpdatedUser = EventsAPIType("dnd_updated_user") + // EmailDomainChanged The email domain changed + EmailDomainChanged = EventsAPIType("email_domain_changed") + // GroupClose A group was closed + GroupClose = EventsAPIType("group_close") + // GroupHistoryChanged The history of a group changed + GroupHistoryChanged = EventsAPIType("group_history_changed") + // GroupOpen A group was opened + GroupOpen = EventsAPIType("group_open") + // ImClose An instant message channel was closed + ImClose = EventsAPIType("im_close") + // ImCreated An instant message channel was created + ImCreated = EventsAPIType("im_created") + // ImHistoryChanged The history of an instant message channel changed + ImHistoryChanged = EventsAPIType("im_history_changed") + // ImOpen An instant message channel was opened + ImOpen = EventsAPIType("im_open") + // SubteamCreated A subteam was created + SubteamCreated = EventsAPIType("subteam_created") + // SubteamMembersChanged The members of a subteam changed + SubteamMembersChanged = EventsAPIType("subteam_members_changed") + // SubteamSelfAdded The current user was added to a subteam + SubteamSelfAdded = EventsAPIType("subteam_self_added") + // SubteamSelfRemoved The current user was removed from a subteam + SubteamSelfRemoved = EventsAPIType("subteam_self_removed") + // SubteamUpdated A subteam was updated + SubteamUpdated = EventsAPIType("subteam_updated") + // TeamDomainChange The team's domain changed + TeamDomainChange = EventsAPIType("team_domain_change") + // TeamRename The team was renamed + TeamRename = EventsAPIType("team_rename") + // UserChange A user object has changed + UserChange = EventsAPIType("user_change") + // AppDeleted is an event when an app is deleted from a workspace + AppDeleted = EventsAPIType("app_deleted") + // AppInstalled is an event when an app is installed to a workspace + AppInstalled = EventsAPIType("app_installed") + // AppRequested is an event when a user requests to install an app to a workspace + AppRequested = EventsAPIType("app_requested") + // AppUninstalledTeam is an event when an app is uninstalled from a team + AppUninstalledTeam = EventsAPIType("app_uninstalled_team") + // CallRejected is an event when a Slack call is rejected + CallRejected = EventsAPIType("call_rejected") + // ChannelShared is an event when a channel is shared with another workspace + ChannelShared = EventsAPIType("channel_shared") + // FileCreated is an event when a file is created in a workspace + FileCreated = EventsAPIType("file_created") + // FilePublic is an event when a file is made public in a workspace + FilePublic = EventsAPIType("file_public") + // FunctionExecuted is an event when a Slack function is executed + FunctionExecuted = EventsAPIType("function_executed") + // InviteRequested is an event when a user requests an invite to a workspace + InviteRequested = EventsAPIType("invite_requested") + // SharedChannelInviteRequested is an event when an invitation to share a channel is requested + SharedChannelInviteRequested = EventsAPIType("shared_channel_invite_requested") + // StarAdded is an event when a star is added to a message or file + StarAdded = EventsAPIType("star_added") + // StarRemoved is an event when a star is removed from a message or file + StarRemoved = EventsAPIType("star_removed") + // UserHuddleChanged is an event when a user's huddle status changes + UserHuddleChanged = EventsAPIType("user_huddle_changed") + // UserStatusChanged is an event when a user's status changes + UserStatusChanged = EventsAPIType("user_status_changed") ) // EventsAPIInnerEventMapping maps INNER Event API events to their corresponding struct // implementations. The structs should be instances of the unmarshalling // target for the matching event type. var EventsAPIInnerEventMapping = map[EventsAPIType]interface{}{ - AppMention: AppMentionEvent{}, - AppHomeOpened: AppHomeOpenedEvent{}, - AppUninstalled: AppUninstalledEvent{}, - ChannelCreated: ChannelCreatedEvent{}, - ChannelDeleted: ChannelDeletedEvent{}, - ChannelArchive: ChannelArchiveEvent{}, - ChannelUnarchive: ChannelUnarchiveEvent{}, - ChannelLeft: ChannelLeftEvent{}, - ChannelRename: ChannelRenameEvent{}, - ChannelIDChanged: ChannelIDChangedEvent{}, - FileChange: FileChangeEvent{}, - FileDeleted: FileDeletedEvent{}, - FileShared: FileSharedEvent{}, - FileUnshared: FileUnsharedEvent{}, - GroupDeleted: GroupDeletedEvent{}, - GroupArchive: GroupArchiveEvent{}, - GroupUnarchive: GroupUnarchiveEvent{}, - GroupLeft: GroupLeftEvent{}, - GroupRename: GroupRenameEvent{}, - GridMigrationFinished: GridMigrationFinishedEvent{}, - GridMigrationStarted: GridMigrationStartedEvent{}, - LinkShared: LinkSharedEvent{}, - Message: MessageEvent{}, - MemberJoinedChannel: MemberJoinedChannelEvent{}, - MemberLeftChannel: MemberLeftChannelEvent{}, - PinAdded: PinAddedEvent{}, - PinRemoved: PinRemovedEvent{}, - ReactionAdded: ReactionAddedEvent{}, - ReactionRemoved: ReactionRemovedEvent{}, - TeamJoin: TeamJoinEvent{}, - TokensRevoked: TokensRevokedEvent{}, - EmojiChanged: EmojiChangedEvent{}, - UserProfileChange: UserProfileChangeEvent{}, - UserChange: UserChangeEvent{}, - WorkflowStepExecute: WorkflowStepExecuteEvent{}, - MessageMetadataPosted: MessageMetadataPostedEvent{}, - MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, - MessageMetadataDeleted: MessageMetadataDeletedEvent{}, - TeamAccessGranted: TeamAccessGrantedEvent{}, - TeamAccessrevoked: TeamAccessRevokedEvent{}, + AppMention: AppMentionEvent{}, + AppHomeOpened: AppHomeOpenedEvent{}, + AppUninstalled: AppUninstalledEvent{}, + ChannelCreated: ChannelCreatedEvent{}, + ChannelDeleted: ChannelDeletedEvent{}, + ChannelArchive: ChannelArchiveEvent{}, + ChannelUnarchive: ChannelUnarchiveEvent{}, + ChannelLeft: ChannelLeftEvent{}, + ChannelRename: ChannelRenameEvent{}, + ChannelIDChanged: ChannelIDChangedEvent{}, + FileChange: FileChangeEvent{}, + FileDeleted: FileDeletedEvent{}, + FileShared: FileSharedEvent{}, + FileUnshared: FileUnsharedEvent{}, + GroupDeleted: GroupDeletedEvent{}, + GroupArchive: GroupArchiveEvent{}, + GroupUnarchive: GroupUnarchiveEvent{}, + GroupLeft: GroupLeftEvent{}, + GroupRename: GroupRenameEvent{}, + GridMigrationFinished: GridMigrationFinishedEvent{}, + GridMigrationStarted: GridMigrationStartedEvent{}, + LinkShared: LinkSharedEvent{}, + Message: MessageEvent{}, + MemberJoinedChannel: MemberJoinedChannelEvent{}, + MemberLeftChannel: MemberLeftChannelEvent{}, + PinAdded: PinAddedEvent{}, + PinRemoved: PinRemovedEvent{}, + ReactionAdded: ReactionAddedEvent{}, + ReactionRemoved: ReactionRemovedEvent{}, + SharedChannelInviteApproved: SharedChannelInviteApprovedEvent{}, + SharedChannelInviteAccepted: SharedChannelInviteAcceptedEvent{}, + SharedChannelInviteDeclined: SharedChannelInviteDeclinedEvent{}, + SharedChannelInviteReceived: SharedChannelInviteReceivedEvent{}, + TeamJoin: TeamJoinEvent{}, + TokensRevoked: TokensRevokedEvent{}, + EmojiChanged: EmojiChangedEvent{}, + WorkflowStepExecute: WorkflowStepExecuteEvent{}, + MessageMetadataPosted: MessageMetadataPostedEvent{}, + MessageMetadataUpdated: MessageMetadataUpdatedEvent{}, + MessageMetadataDeleted: MessageMetadataDeletedEvent{}, + TeamAccessGranted: TeamAccessGrantedEvent{}, + TeamAccessRevoked: TeamAccessRevokedEvent{}, + UserProfileChanged: UserProfileChangedEvent{}, + ChannelHistoryChanged: ChannelHistoryChangedEvent{}, + DndUpdated: DndUpdatedEvent{}, + DndUpdatedUser: DndUpdatedUserEvent{}, + EmailDomainChanged: EmailDomainChangedEvent{}, + GroupClose: GroupCloseEvent{}, + GroupHistoryChanged: GroupHistoryChangedEvent{}, + GroupOpen: GroupOpenEvent{}, + ImClose: ImCloseEvent{}, + ImCreated: ImCreatedEvent{}, + ImHistoryChanged: ImHistoryChangedEvent{}, + ImOpen: ImOpenEvent{}, + SubteamCreated: SubteamCreatedEvent{}, + SubteamMembersChanged: SubteamMembersChangedEvent{}, + SubteamSelfAdded: SubteamSelfAddedEvent{}, + SubteamSelfRemoved: SubteamSelfRemovedEvent{}, + SubteamUpdated: SubteamUpdatedEvent{}, + TeamDomainChange: TeamDomainChangeEvent{}, + TeamRename: TeamRenameEvent{}, + UserChange: UserChangeEvent{}, + AppDeleted: AppDeletedEvent{}, + AppInstalled: AppInstalledEvent{}, + AppRequested: AppRequestedEvent{}, + AppUninstalledTeam: AppUninstalledTeamEvent{}, + CallRejected: CallRejectedEvent{}, + ChannelShared: ChannelSharedEvent{}, + FileCreated: FileCreatedEvent{}, + FilePublic: FilePublicEvent{}, + FunctionExecuted: FunctionExecutedEvent{}, + InviteRequested: InviteRequestedEvent{}, + SharedChannelInviteRequested: SharedChannelInviteRequestedEvent{}, + StarAdded: StarAddedEvent{}, + StarRemoved: StarRemovedEvent{}, + UserHuddleChanged: UserHuddleChangedEvent{}, + UserStatusChanged: UserStatusChangedEvent{}, } diff --git a/slackevents/inner_events_test.go b/slackevents/inner_events_test.go index ae3ee179c..4307e8ad6 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -2,7 +2,10 @@ package slackevents import ( "encoding/json" + "fmt" "testing" + + "github.com/stretchr/testify/assert" ) func TestAppMention(t *testing.T) { @@ -297,6 +300,12 @@ func TestMessageEvent(t *testing.T) { "ts": "1355517524.000000" } }, + "metadata": { + "event_type": "example", + "event_payload": { + "key": "value" + } + }, "previous_message": { "text": "Live long and prospect." } @@ -376,19 +385,31 @@ func TestThreadBroadcastEvent(t *testing.T) { func TestMemberJoinedChannelEvent(t *testing.T) { rawE := []byte(` - { - "type": "member_joined_channel", - "user": "W06GH7XHN", - "channel": "C0698JE0H", - "channel_type": "C", - "team": "T024BE7LD", - "inviter": "U123456789" + { + "type": "member_joined_channel", + "user": "W06GH7XHN", + "channel": "C0698JE0H", + "channel_type": "C", + "team": "T024BE7LD", + "inviter": "U123456789" } `) - err := json.Unmarshal(rawE, &MemberJoinedChannelEvent{}) + evt := MemberJoinedChannelEvent{} + err := json.Unmarshal(rawE, &evt) if err != nil { t.Error(err) } + + expected := MemberJoinedChannelEvent{ + Type: "member_joined_channel", + User: "W06GH7XHN", + Channel: "C0698JE0H", + ChannelType: "C", + Team: "T024BE7LD", + Inviter: "U123456789", + } + + assert.Equal(t, expected, evt) } func TestMemberLeftChannelEvent(t *testing.T) { @@ -821,3 +842,1814 @@ func TestMessageMetadataDeleted(t *testing.T) { t.Fail() } } + +func TestUserProfileChanged(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "user": { + "id": "whatever", + "team_id": "whatever", + "name": "whatever", + "deleted": true, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "whatever", + "real_name_normalized": "whatever", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "whatever", + "api_app_id": "whatever", + "always_active": true, + "bot_id": "whatever", + "first_name": "whatever", + "last_name": "", + "image_24": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_32": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_48": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_72": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_192": "https://secure.gravatar.com/avatar/whatever.jpg", + "image_512": "https://secure.gravatar.com/avatar/whatever.jpg", + "status_text_canonical": "", + "team": "whatever" + }, + "is_bot": true, + "is_app_user": false, + "updated": 1678984254 + }, + "cache_ts": 1678984254, + "type": "user_profile_changed", + "event_ts": "1678984255.006500" + }, + "type": "event_callback", + "event_id": "whatever", + "event_time": 1678984255, + "authorizations": [ + { + "enterprise_id": null, + "team_id": "whatever", + "user_id": "whatever", + "is_bot": false, + "is_enterprise_install": false + } + ], + "is_ext_shared_channel": false + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, &evt) + if err != nil { + t.Error(err) + } + + if evt.Type != "event_callback" { + t.Fail() + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Error(err) + } + + if parsedEvent.InnerEvent.Type != "user_profile_changed" { + t.Fail() + } + + actual, ok := parsedEvent.InnerEvent.Data.(*UserProfileChangedEvent) + if !ok { + t.Fail() + } + + if actual.User.Name != "whatever" { + t.Fail() + } +} + +func TestSharedChannelInvite(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_received", + "invite": { + "id": "I028YDERZSQ", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "event_ts": "1626876010.000100" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteReceivedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I028YDERZSQ" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_received" { + t.Fail() + } + +} + +// Test that the shared_channel_invite_accepted event can be unmarshalled +func TestSharedChannelAccepted(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_accepted", + "approval_required": false, + "invite": { + "id": "I028YDERZSQ", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com", + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "accepting_user": { + "id": "U87654321", + "team_id": "T87654321", + "name": "golden", + "updated": 1624406113, + "profile": { + "real_name": "Golden Doodle", + "display_name": "Golden", + "real_name_normalized": "Golden Doodle", + "display_name_normalized": "Golden", + "team": "T87654321", + "avatar_hash": "g717728b118x", + "email": "golden@doodle.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626877800.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteAcceptedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I028YDERZSQ" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.Channel.Name != "test-slack-connect" { + t.Fail() + fmt.Println(actual.Channel.Name + ", does not match the test name.") + } + + if actual.AcceptingUser.ID != "U87654321" { + t.Fail() + } + + if actual.AcceptingUser.Profile.RealName != "Golden Doodle" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_accepted" { + t.Fail() + } + +} + +// Test that the shared_channel_invite_declined event can be unmarshalled +func TestSharedChannelApproved(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_approved", + "invite": { + "id": "I01354X80CA", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com", + "recipient_user_id": "U87654321" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "approving_team_id": "T87654321", + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": { + "image_default": true, + "image_34": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-34.png", + "image_44": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-44.png", + "image_68": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-68.png", + "image_88": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-88.png", + "image_102": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-102.png", + "image_230": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-230.png", + "image_132": "https://a.slack-edge.com/80588/img/avatars-teams/ava_0011-132.png" + }, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "approving_user": { + "id": "U012A3CDE", + "team_id": "T87654321", + "name": "spengler", + "updated": 1624406532, + "profile": { + "real_name": "Egon Spengler", + "display_name": "Egon", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "Egon", + "team": "T87654321", + "avatar_hash": "g216425b1681", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626881400.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteApprovedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I01354X80CA" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientUserID != "U87654321" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.ApprovingTeamID != "T87654321" { + t.Fail() + } + + if actual.ApprovingUser.Name != "spengler" { + t.Fail() + } + + if actual.ApprovingUser.Profile.RealName != "Egon Spengler" { + t.Fail() + } + + if actual.TeamsInChannel[0].ID != "T12345678" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_approved" { + t.Fail() + } + +} + +func TestSharedChannelDeclined(t *testing.T) { + rawE := []byte(` + { + "token": "whatever", + "team_id": "whatever", + "api_app_id": "whatever", + "event": { + "type": "shared_channel_invite_declined", + "invite": { + "id": "I01354X80CA", + "date_created": 1626876000, + "date_invalid": 1628085600, + "inviting_team": { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1480946400 + }, + "inviting_user": { + "id": "U12345678", + "team_id": "T12345678", + "name": "crus", + "updated": 1608081902, + "profile": { + "real_name": "Corgis Rus", + "display_name": "Corgis Rus", + "real_name_normalized": "Corgis Rus", + "display_name_normalized": "Corgis Rus", + "team": "T12345678", + "avatar_hash": "gcfh83a4c72k", + "email": "corgisrus@slack-corp.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "recipient_email": "golden@doodle.com" + }, + "channel": { + "id": "C12345678", + "is_private": false, + "is_im": false, + "name": "test-slack-connect" + }, + "declining_team_id": "T87654321", + "teams_in_channel": [ + { + "id": "T12345678", + "name": "Corgis", + "icon": {}, + "is_verified": false, + "domain": "corgis", + "date_created": 1626789600 + } + ], + "declining_user": { + "id": "U012A3CDE", + "team_id": "T87654321", + "name": "spengler", + "updated": 1624406532, + "profile": { + "real_name": "Egon Spengler", + "display_name": "Egon", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "Egon", + "team": "T87654321", + "avatar_hash": "g216425b1681", + "email": "spengler@ghostbusters.example.com", + "image_24": "https://placekitten.com/24/24", + "image_32": "https://placekitten.com/32/32", + "image_48": "https://placekitten.com/48/48", + "image_72": "https://placekitten.com/72/72", + "image_192": "https://placekitten.com/192/192", + "image_512": "https://placekitten.com/512/512" + } + }, + "event_ts": "1626881400.000000" + } + } + `) + + evt := &EventsAPICallbackEvent{} + err := json.Unmarshal(rawE, evt) + if err != nil { + t.Fatal(err) + } + + parsedEvent, err := parseInnerEvent(evt) + if err != nil { + t.Fatal(err) + } + + actual, ok := parsedEvent.InnerEvent.Data.(*SharedChannelInviteDeclinedEvent) + if !ok { + t.Fail() + } + + if actual.Invite.ID != "I01354X80CA" { + t.Fail() + } + + if actual.Invite.InvitingTeam.ID != "T12345678" { + t.Fail() + } + + if actual.Invite.InvitingUser.ID != "U12345678" { + t.Fail() + } + + if actual.Invite.RecipientEmail != "golden@doodle.com" { + t.Fail() + } + + if actual.Channel.ID != "C12345678" { + t.Fail() + } + + if actual.DecliningTeamID != "T87654321" { + t.Fail() + } + + if actual.DecliningUser.Name != "spengler" { + t.Fail() + } + + if actual.DecliningUser.Profile.RealName != "Egon Spengler" { + t.Fail() + } + + if actual.TeamsInChannel[0].ID != "T12345678" { + t.Fail() + } + + if actual.EventTs != "1626881400.000000" { + t.Fail() + } + + if parsedEvent.InnerEvent.Type != "shared_channel_invite_declined" { + t.Fail() + } + +} + +func TestChannelHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "channel_history_changed", + "latest": "1358877455.000010", + "ts": "1358877455.000008", + "event_ts": "1358877455.000011" + } + `) + + var e ChannelHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "channel_history_changed" { + t.Errorf("type should be channel_history_changed, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1358877455.000008" { + t.Errorf("ts should be 1358877455.000008, was %s", e.Ts) + } + if e.EventTs != "1358877455.000011" { + t.Errorf("event_ts should be 1358877455.000011, was %s", e.EventTs) + } +} + +func TestDndUpdatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "dnd_updated", + "user": "U1234567890", + "dnd_status": { + "dnd_enabled": true, + "next_dnd_start_ts": 1624473600, + "next_dnd_end_ts": 1624516800, + "snooze_enabled": false + } + } + `) + + var e DndUpdatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "dnd_updated" { + t.Errorf("type should be dnd_updated, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if !e.DndStatus.DndEnabled { + t.Errorf("dnd_enabled should be true, was %v", e.DndStatus.DndEnabled) + } + if e.DndStatus.NextDndStartTs != 1624473600 { + t.Errorf("next_dnd_start_ts should be 1624473600, was %d", e.DndStatus.NextDndStartTs) + } + if e.DndStatus.NextDndEndTs != 1624516800 { + t.Errorf("next_dnd_end_ts should be 1624516800, was %d", e.DndStatus.NextDndEndTs) + } + if e.DndStatus.SnoozeEnabled { + t.Errorf("snooze_enabled should be false, was %v", e.DndStatus.SnoozeEnabled) + } +} + +func TestDndUpdatedUserEvent(t *testing.T) { + rawE := []byte(` + { + "type": "dnd_updated_user", + "user": "U1234", + "dnd_status": { + "dnd_enabled": true, + "next_dnd_start_ts": 1450387800, + "next_dnd_end_ts": 1450423800 + } + } + `) + + var e DndUpdatedUserEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "dnd_updated_user" { + t.Errorf("type should be dnd_updated_user, was %s", e.Type) + } + if e.User != "U1234" { + t.Errorf("user should be U1234, was %s", e.User) + } + if !e.DndStatus.DndEnabled { + t.Errorf("dnd_enabled should be true, was %v", e.DndStatus.DndEnabled) + } + if e.DndStatus.NextDndStartTs != 1450387800 { + t.Errorf("next_dnd_start_ts should be 1450387800, was %d", e.DndStatus.NextDndStartTs) + } + if e.DndStatus.NextDndEndTs != 1450423800 { + t.Errorf("next_dnd_end_ts should be 1450423800, was %d", e.DndStatus.NextDndEndTs) + } +} + +func TestEmailDomainChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "email_domain_changed", + "email_domain": "example.com", + "event_ts": "1234567890.123456" + } + `) + + var e EmailDomainChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "email_domain_changed" { + t.Errorf("type should be email_domain_changed, was %s", e.Type) + } + if e.EmailDomain != "example.com" { + t.Errorf("email_domain should be example.com, was %s", e.EmailDomain) + } + if e.EventTs != "1234567890.123456" { + t.Errorf("event_ts should be 1234567890.123456, was %s", e.EventTs) + } +} + +func TestGroupHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_history_changed", + "latest": "1358877455.000010", + "ts": "1361482916.000003", + "event_ts": "1361482916.000004" + } + `) + + var e GroupHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_history_changed" { + t.Errorf("type should be group_history_changed, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1361482916.000003" { + t.Errorf("ts should be 1361482916.000003, was %s", e.Ts) + } +} + +func TestGroupOpenEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_open", + "user": "U024BE7LH", + "channel": "G024BE91L" + } + `) + + var e GroupOpenEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_open" { + t.Errorf("type should be group_open, was %s", e.Type) + } + if e.User != "U024BE7LH" { + t.Errorf("user should be U024BE7LH, was %s", e.User) + } + if e.Channel != "G024BE91L" { + t.Errorf("channel should be G024BE91L, was %s", e.Channel) + } +} + +func TestGroupCloseEvent(t *testing.T) { + rawE := []byte(` + { + "type": "group_close", + "user": "U1234567890", + "channel": "G1234567890" + } + `) + + var e GroupCloseEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "group_close" { + t.Errorf("type should be group_close, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "G1234567890" { + t.Errorf("channel should be G1234567890, was %s", e.Channel) + } +} + +func TestImCloseEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_close", + "user": "U1234567890", + "channel": "D1234567890" + } + `) + + var e ImCloseEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_close" { + t.Errorf("type should be im_close, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "D1234567890" { + t.Errorf("channel should be D1234567890, was %s", e.Channel) + } +} + +func TestImCreatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_created", + "user": "U1234567890", + "channel": { + "id": "C12345678" + } + } + `) + + var e ImCreatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_created" { + t.Errorf("type should be im_created, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel.ID != "C12345678" { + t.Errorf("channel.id should be C12345678, was %s", e.Channel.ID) + } +} + +func TestImHistoryChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_history_changed", + "latest": "1358877455.000010", + "ts": "1361482916.000003", + "event_ts": "1361482916.000004" + } + `) + + var e ImHistoryChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_history_changed" { + t.Errorf("type should be im_created, was %s", e.Type) + } + if e.Latest != "1358877455.000010" { + t.Errorf("latest should be 1358877455.000010, was %s", e.Latest) + } + if e.Ts != "1361482916.000003" { + t.Errorf("ts should be 1361482916.000003, was %s", e.Ts) + } + if e.EventTs != "1361482916.000004" { + t.Errorf("event_ts should be 1361482916.000004, was %s", e.EventTs) + } +} + +func TestImOpenEvent(t *testing.T) { + rawE := []byte(` + { + "type": "im_open", + "user": "U1234567890", + "channel": "D1234567890" + } + `) + + var e ImOpenEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "im_open" { + t.Errorf("type should be im_open, was %s", e.Type) + } + if e.User != "U1234567890" { + t.Errorf("user should be U1234567890, was %s", e.User) + } + if e.Channel != "D1234567890" { + t.Errorf("channel should be D1234567890, was %s", e.Channel) + } +} + +func TestSubteamCreatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_created", + "subteam": { + "id": "S1234567890", + "team_id": "T1234567890", + "is_usergroup": true, + "name": "subteam", + "description": "A test subteam", + "handle": "subteam_handle", + "is_external": false, + "date_create": 1624473600, + "date_update": 1624473600, + "date_delete": 0, + "auto_type": "auto", + "created_by": "U1234567890", + "updated_by": "U1234567890", + "deleted_by": "", + "prefs": { + "channels": ["C1234567890"], + "groups": ["G1234567890"] + }, + "users": ["U1234567890"], + "user_count": 1 + } + } + `) + + var e SubteamCreatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_created" { + t.Errorf("type should be subteam_created, was %s", e.Type) + } + if e.Subteam.ID != "S1234567890" { + t.Errorf("subteam.id should be S1234567890, was %s", e.Subteam.ID) + } + if e.Subteam.TeamID != "T1234567890" { + t.Errorf("subteam.team_id should be T1234567890, was %s", e.Subteam.TeamID) + } + if !e.Subteam.IsUsergroup { + t.Errorf("subteam.is_usergroup should be true, was %v", e.Subteam.IsUsergroup) + } + if e.Subteam.Name != "subteam" { + t.Errorf("subteam.name should be subteam, was %s", e.Subteam.Name) + } + if e.Subteam.Description != "A test subteam" { + t.Errorf("subteam.description should be 'A test subteam', was %s", e.Subteam.Description) + } + if e.Subteam.Handle != "subteam_handle" { + t.Errorf("subteam.handle should be subteam_handle, was %s", e.Subteam.Handle) + } + if e.Subteam.IsExternal { + t.Errorf("subteam.is_external should be false, was %v", e.Subteam.IsExternal) + } + if e.Subteam.DateCreate != 1624473600 { + t.Errorf("subteam.date_create should be 1624473600, was %d", e.Subteam.DateCreate) + } + if e.Subteam.DateUpdate != 1624473600 { + t.Errorf("subteam.date_update should be 1624473600, was %d", e.Subteam.DateUpdate) + } + if e.Subteam.DateDelete != 0 { + t.Errorf("subteam.date_delete should be 0, was %d", e.Subteam.DateDelete) + } + if e.Subteam.AutoType != "auto" { + t.Errorf("subteam.auto_type should be auto, was %s", e.Subteam.AutoType) + } + if e.Subteam.CreatedBy != "U1234567890" { + t.Errorf("subteam.created_by should be U1234567890, was %s", e.Subteam.CreatedBy) + } + if e.Subteam.UpdatedBy != "U1234567890" { + t.Errorf("subteam.updated_by should be U1234567890, was %s", e.Subteam.UpdatedBy) + } + if e.Subteam.DeletedBy != "" { + t.Errorf("subteam.deleted_by should be empty, was %s", e.Subteam.DeletedBy) + } + if len(e.Subteam.Prefs.Channels) != 1 || e.Subteam.Prefs.Channels[0] != "C1234567890" { + t.Errorf("subteam.prefs.channels should contain C1234567890, was %v", e.Subteam.Prefs.Channels) + } + if len(e.Subteam.Prefs.Groups) != 1 || e.Subteam.Prefs.Groups[0] != "G1234567890" { + t.Errorf("subteam.prefs.groups should contain G1234567890, was %v", e.Subteam.Prefs.Groups) + } + if len(e.Subteam.Users) != 1 || e.Subteam.Users[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.Subteam.Users) + } + if e.Subteam.UserCount != 1 { + t.Errorf("subteam.user_count should be 1, was %d", e.Subteam.UserCount) + } +} + +func TestSubteamMembersChangedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_members_changed", + "subteam_id": "S1234567890", + "team_id": "T1234567890", + "date_previous_update": 1446670362, + "date_update": 1624473600, + "added_users": ["U1234567890"], + "added_users_count": "3", + "removed_users": ["U0987654321"], + "removed_users_count": "1" + } + `) + + var e SubteamMembersChangedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_members_changed" { + t.Errorf("type should be subteam_members_changed, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } + if e.TeamID != "T1234567890" { + t.Errorf("team_id should be T1234567890, was %s", e.TeamID) + } + if e.DateUpdate != 1624473600 { + t.Errorf("date_update should be 1624473600, was %d", e.DateUpdate) + } + if len(e.AddedUsers) != 1 || e.AddedUsers[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.AddedUsers) + } + if len(e.RemovedUsers) != 1 || e.RemovedUsers[0] != "U0987654321" { + t.Errorf("subteam.users should contain U0987654321, was %v", e.RemovedUsers) + } +} + +func TestSubteamSelfAddedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_self_added", + "subteam_id": "S1234567890" + } + `) + + var e SubteamSelfAddedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_self_added" { + t.Errorf("type should be subteam_self_added, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } +} + +func TestSubteamSelfRemovedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_self_removed", + "subteam_id": "S1234567890" + } + `) + + var e SubteamSelfRemovedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_self_removed" { + t.Errorf("type should be subteam_self_removed, was %s", e.Type) + } + if e.SubteamID != "S1234567890" { + t.Errorf("subteam_id should be S1234567890, was %s", e.SubteamID) + } +} + +func TestSubteamUpdatedEvent(t *testing.T) { + rawE := []byte(` + { + "type": "subteam_updated", + "subteam": { + "id": "S1234567890", + "team_id": "T1234567890", + "is_usergroup": true, + "name": "updated_subteam", + "description": "An updated test subteam", + "handle": "updated_subteam_handle", + "is_external": false, + "date_create": 1624473600, + "date_update": 1624473600, + "date_delete": 0, + "auto_type": "auto", + "created_by": "U1234567890", + "updated_by": "U1234567890", + "deleted_by": "", + "prefs": { + "channels": ["C1234567890"], + "groups": ["G1234567890"] + }, + "users": ["U1234567890"], + "user_count": 1 + } + } + `) + + var e SubteamUpdatedEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "subteam_updated" { + t.Errorf("type should be subteam_updated, was %s", e.Type) + } + if e.Subteam.ID != "S1234567890" { + t.Errorf("subteam.id should be S1234567890, was %s", e.Subteam.ID) + } + if e.Subteam.TeamID != "T1234567890" { + t.Errorf("subteam.team_id should be T1234567890, was %s", e.Subteam.TeamID) + } + if !e.Subteam.IsUsergroup { + t.Errorf("subteam.is_usergroup should be true, was %v", e.Subteam.IsUsergroup) + } + if e.Subteam.Name != "updated_subteam" { + t.Errorf("subteam.name should be updated_subteam, was %s", e.Subteam.Name) + } + if e.Subteam.Description != "An updated test subteam" { + t.Errorf("subteam.description should be 'An updated test subteam', was %s", e.Subteam.Description) + } + if e.Subteam.Handle != "updated_subteam_handle" { + t.Errorf("subteam.handle should be updated_subteam_handle, was %s", e.Subteam.Handle) + } + if e.Subteam.IsExternal { + t.Errorf("subteam.is_external should be false, was %v", e.Subteam.IsExternal) + } + if e.Subteam.DateCreate != 1624473600 { + t.Errorf("subteam.date_create should be 1624473600, was %d", e.Subteam.DateCreate) + } + if e.Subteam.DateUpdate != 1624473600 { + t.Errorf("subteam.date_update should be 1624473600, was %d", e.Subteam.DateUpdate) + } + if e.Subteam.DateDelete != 0 { + t.Errorf("subteam.date_delete should be 0, was %d", e.Subteam.DateDelete) + } + if e.Subteam.AutoType != "auto" { + t.Errorf("subteam.auto_type should be auto, was %s", e.Subteam.AutoType) + } + if e.Subteam.CreatedBy != "U1234567890" { + t.Errorf("subteam.created_by should be U1234567890, was %s", e.Subteam.CreatedBy) + } + if e.Subteam.UpdatedBy != "U1234567890" { + t.Errorf("subteam.updated_by should be U1234567890, was %s", e.Subteam.UpdatedBy) + } + if e.Subteam.DeletedBy != "" { + t.Errorf("subteam.deleted_by should be empty, was %s", e.Subteam.DeletedBy) + } + if len(e.Subteam.Prefs.Channels) != 1 || e.Subteam.Prefs.Channels[0] != "C1234567890" { + t.Errorf("subteam.prefs.channels should contain C1234567890, was %v", e.Subteam.Prefs.Channels) + } + if len(e.Subteam.Prefs.Groups) != 1 || e.Subteam.Prefs.Groups[0] != "G1234567890" { + t.Errorf("subteam.prefs.groups should contain G1234567890, was %v", e.Subteam.Prefs.Groups) + } + if len(e.Subteam.Users) != 1 || e.Subteam.Users[0] != "U1234567890" { + t.Errorf("subteam.users should contain U1234567890, was %v", e.Subteam.Users) + } + if e.Subteam.UserCount != 1 { + t.Errorf("subteam.user_count should be 1, was %d", e.Subteam.UserCount) + } +} + +func TestTeamDomainChangeEvent(t *testing.T) { + rawE := []byte(` + { + "type": "team_domain_change", + "url": "https://newdomain.slack.com", + "domain": "newdomain", + "team_id": "T1234" + } + `) + + var e TeamDomainChangeEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "team_domain_change" { + t.Errorf("type should be team_domain_change, was %s", e.Type) + } + if e.URL != "https://newdomain.slack.com" { + t.Errorf("url should be https://newdomain.slack.com, was %s", e.URL) + } + if e.Domain != "newdomain" { + t.Errorf("domain should be newdomain, was %s", e.Domain) + } + if e.TeamID != "T1234" { + t.Errorf("team_id should be 'T1234', was %s", e.TeamID) + } +} + +func TestTeamRenameEvent(t *testing.T) { + rawE := []byte(` + { + "type": "team_rename", + "name": "new_team_name", + "team_id": "T1234" + } + `) + + var e TeamRenameEvent + if err := json.Unmarshal(rawE, &e); err != nil { + t.Fatal(err) + } + if e.Type != "team_rename" { + t.Errorf("type should be team_rename, was %s", e.Type) + } + if e.Name != "new_team_name" { + t.Errorf("name should be new_team_name, was %s", e.Name) + } + if e.TeamID != "T1234" { + t.Errorf("team_id should be 'T1234', was %s", e.TeamID) + } +} + +func TestUserChangeEvent(t *testing.T) { + jsonStr := `{ + "user": { + "id": "U1234567", + "team_id": "T1234567", + "name": "some-user", + "deleted": false, + "color": "4bbe2e", + "real_name": "Some User", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "title": "", + "phone": "", + "skype": "", + "real_name": "Some User", + "real_name_normalized": "Some User", + "display_name": "", + "display_name_normalized": "", + "fields": {}, + "status_text": "riding a train", + "status_emoji": ":mountain_railway:", + "status_emoji_display_info": [], + "status_expiration": 0, + "avatar_hash": "g12345678910", + "first_name": "Some", + "last_name": "User", + "image_24": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=24&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-24.png", + "image_32": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=32&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-32.png", + "image_48": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=48&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-48.png", + "image_72": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=72&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-72.png", + "image_192": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=192&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-192.png", + "image_512": "https://secure.gravatar.com/avatar/cb0c2b2ca5e8de16be31a55a734d0f31.jpg?s=512&d=https%3A%2F%2Fdev.slack.com%2Fdev-cdn%2Fv1648136338%2Fimg%2Favatars%2Fuser_shapes%2Fava_0001-512.png", + "status_text_canonical": "", + "team": "T1234567" + }, + "is_admin": false, + "is_owner": false, + "is_primary_owner": false, + "is_restricted": false, + "is_ultra_restricted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1648596421, + "is_email_confirmed": true, + "who_can_share_contact_card": "EVERYONE", + "locale": "en-US" + }, + "cache_ts": 1648596421, + "type": "user_change", + "event_ts": "1648596712.000001" + }` + + var event UserChangeEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal UserChangeEvent: %v", err) + } + + if event.Type != "user_change" { + t.Errorf("Expected type to be 'user_change', got %s", event.Type) + } + + if event.User.ID != "U1234567" { + t.Errorf("Expected user ID to be 'U1234567', got %s", event.User.ID) + } + + if event.User.Profile.StatusText != "riding a train" { + t.Errorf("Expected status text to be 'riding a train', got %s", event.User.Profile.StatusText) + } + + if event.User.Profile.StatusEmoji != ":mountain_railway:" { + t.Errorf("Expected status emoji to be ':mountain_railway:', got %s", event.User.Profile.StatusEmoji) + } + + if event.CacheTS != 1648596421 { + t.Errorf("Expected cache_ts to be 1648596421, got %d", event.CacheTS) + } + + if event.EventTS != "1648596712.000001" { + t.Errorf("Expected event_ts to be '1648596712.000001', got %s", event.EventTS) + } +} + +func TestAppDeletedEvent(t *testing.T) { + jsonStr := `{ + "type": "app_deleted", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppDeletedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppDeletedEvent: %v", err) + } + + if event.Type != "app_deleted" { + t.Errorf("Expected type to be 'app_deleted', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestAppInstalledEvent(t *testing.T) { + jsonStr := `{ + "type": "app_installed", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "user_id": "U013B64J7SZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppInstalledEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppInstalledEvent: %v", err) + } + + if event.Type != "app_installed" { + t.Errorf("Expected type to be 'app_installed', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestAppRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "app_requested", + "app_request": { + "id": "1234", + "app": { + "id": "A5678", + "name": "Brent's app", + "description": "They're good apps, Bront.", + "help_url": "brontsapp.com", + "privacy_policy_url": "brontsapp.com", + "app_homepage_url": "brontsapp.com", + "app_directory_url": "https://slack.slack.com/apps/A102ARD7Y", + "is_app_directory_approved": true, + "is_internal": false, + "additional_info": "none" + }, + "previous_resolution": { + "status": "approved", + "scopes": [{ + "name": "app_requested", + "description": "allows this app to listen for app install requests", + "is_sensitive": false, + "token_type": "user" + }] + }, + "user": { + "id": "U1234", + "name": "Bront", + "email": "bront@brent.com" + }, + "team": { + "id": "T1234", + "name": "Brant App Team", + "domain": "brantappteam" + }, + "enterprise": null, + "scopes": [{ + "name": "app_requested", + "description": "allows this app to listen for app install requests", + "is_sensitive": false, + "token_type": "user" + }], + "message": "none" + } + }` + + var event AppRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppRequestedEvent: %v", err) + } + + if event.Type != "app_requested" { + t.Errorf("Expected type to be 'app_requested', got %s", event.Type) + } + + if event.AppRequest.ID != "1234" { + t.Errorf("app_request.id should be '1234', was %s", event.AppRequest.ID) + } + + if event.AppRequest.App.ID != "A5678" { + t.Fail() + } + + if event.AppRequest.User.ID != "U1234" { + t.Errorf("app_request.user.id should be 'U1234', was %s", event.AppRequest.User.ID) + } + + if event.AppRequest.Team.ID != "T1234" { + t.Fail() + } +} + +func TestAppUninstalledTeamEvent(t *testing.T) { + jsonStr := `{ + "type": "app_uninstalled_team", + "app_id": "A015CA1LGHG", + "app_name": "my-admin-app", + "app_owner_id": "U013B64J7MSZ", + "user_id": "U013B64J7SZ", + "team_id": "E073D7H7BBE", + "team_domain": "ACME Enterprises", + "event_ts": "1700001891.279278" + }` + + var event AppUninstalledTeamEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal AppUninstalledTeamEvent: %v", err) + } + + if event.Type != "app_uninstalled_team" { + t.Errorf("Expected type to be 'app_uninstalled_team', got %s", event.Type) + } + + if event.AppName != "my-admin-app" { + t.Errorf("app_name should be 'my-admin-app', was %s", event.AppName) + } + + if event.AppOwnerID != "U013B64J7MSZ" { + t.Errorf("app_owner_id should be 'U013B64J7MSZ', was %s", event.AppOwnerID) + } + + if event.TeamID != "E073D7H7BBE" { + t.Errorf("team_id should be 'E073D7H7BBE', was %s", event.TeamID) + } +} + +func TestCallRejectedEvent(t *testing.T) { + jsonStr := `{ + "token": "12345FVmRUzNDOAu12345h", + "team_id": "T123ABC456", + "api_app_id": "BBBU04BB4", + "event": { + "type": "call_rejected", + "call_id": "R123ABC456", + "user_id": "U123ABC456", + "channel_id": "D123ABC456", + "external_unique_id": "123-456-7890" + }, + "type": "event_callback", + "event_id": "Ev123ABC456", + "event_time": 1563448153, + "authed_users": ["U123ABC456"] + }` + + var event CallRejectedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal CallRejectedEvent: %v", err) + } + + if event.Event.Type != "call_rejected" { + t.Errorf("Expected event type to be 'call_rejected', got %s", event.Event.Type) + } + if event.TeamID != "T123ABC456" { + t.Errorf("Expected team_id to be 'T123ABC456', got %s", event.TeamID) + } + if event.Event.CallID != "R123ABC456" { + t.Fail() + } + +} + +func TestChannelSharedEvent(t *testing.T) { + jsonStr := `{ + "type": "channel_shared", + "connected_team_id": "E163Q94DX", + "channel": "C123ABC456", + "event_ts": "1561064063.001100" + }` + + var event ChannelSharedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal ChannelSharedEvent: %v", err) + } + + if event.Type != "channel_shared" { + t.Errorf("Expected type to be 'channel_shared', got %s", event.Type) + } + + if event.ConnectedTeamID != "E163Q94DX" { + t.Errorf("Expected connected_team_id to be 'E163Q94DX', got %s", event.ConnectedTeamID) + } + + if event.Channel != "C123ABC456" { + t.Fail() + } +} + +func TestFileCreatedEvent(t *testing.T) { + jsonStr := `{ + "type": "file_created", + "file_id": "F2147483862", + "file": { + "id": "F2147483862" + } + }` + + var event FileCreatedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FileCreatedEvent: %v", err) + } + + if event.Type != "file_created" { + t.Errorf("Expected type to be 'file_created', got %s", event.Type) + } + if event.FileID != "F2147483862" { + t.Errorf("Expected file_id to be 'F2147483862', got %s", event.FileID) + } +} + +func TestFilePublicEvent(t *testing.T) { + jsonStr := `{ + "type": "file_public", + "file_id": "F2147483862", + "file": { + "id": "F2147483862" + } + }` + + var event FilePublicEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FilePublicEvent: %v", err) + } + + if event.Type != "file_public" { + t.Errorf("Expected type to be 'file_public', got %s", event.Type) + } + + if event.FileID != "F2147483862" { + t.Errorf("Expected file_id to be 'F2147483862', got %s", event.FileID) + } +} + +func TestFunctionExecutedEvent(t *testing.T) { + jsonStr := `{ + "type": "function_executed", + "function": { + "id": "Fn123456789O", + "callback_id": "sample_function", + "title": "Sample function", + "description": "Runs sample function", + "type": "app", + "input_parameters": [ + { + "type": "slack#/types/user_id", + "name": "user_id", + "description": "Message recipient", + "title": "User", + "is_required": true + } + ], + "output_parameters": [ + { + "type": "slack#/types/user_id", + "name": "user_id", + "description": "User that completed the function", + "title": "Greeting", + "is_required": true + } + ], + "app_id": "AP123456789", + "date_created": 1694727597, + "date_updated": 1698947481, + "date_deleted": 0 + }, + "inputs": { "user_id": "USER12345678" }, + "function_execution_id": "Fx1234567O9L", + "workflow_execution_id": "WxABC123DEF0", + "event_ts": "1698958075.998738", + "bot_access_token": "abcd-1325532282098-1322446258629-6123648410839-527a1cab3979cad288c9e20330d212cf" + }` + + var event FunctionExecutedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal FunctionExecutedEvent: %v", err) + } + + if event.Type != "function_executed" { + t.Errorf("Expected type to be 'function_executed', got %s", event.Type) + } + + if event.Function.ID != "Fn123456789O" { + t.Errorf("Expected function.id to be 'Fn123456789O', got %s", event.Function.ID) + } + + if event.FunctionExecutionID != "Fx1234567O9L" { + t.Fail() + } +} + +func TestInviteRequestedEvent(t *testing.T) { + jsonStr := `{ + "type": "invite_requested", + "invite_request": { + "id": "12345", + "email": "bront@puppies.com", + "date_created": 123455, + "requester_ids": ["U123ABC456"], + "channel_ids": ["C123ABC456"], + "invite_type": "full_member", + "real_name": "Brent", + "date_expire": 123456, + "request_reason": "They're good dogs, Brant", + "team": { + "id": "T12345", + "name": "Puppy ratings workspace incorporated", + "domain": "puppiesrus" + } + } + }` + + var event InviteRequestedEvent + if err := json.Unmarshal([]byte(jsonStr), &event); err != nil { + t.Errorf("Failed to unmarshal InviteRequestedEvent: %v", err) + } + + if event.Type != "invite_requested" { + t.Errorf("Expected type to be 'invite_requested', got %s", event.Type) + } + + if event.InviteRequest.ID != "12345" { + t.Errorf("invite_request.id should be '12345', was %s", event.InviteRequest.ID) + } + + if event.InviteRequest.Email != "bront@puppies.com" { + t.Fail() + } +} + +func TestSharedChannelInviteRequested_UnmarshalJSON(t *testing.T) { + jsonData := ` + { + "actor": { + "id": "U012345ABCD", + "name": "primary-owner", + "is_bot": false, + "team_id": "E0123456ABC", + "timezone": "", + "real_name": "primary-owner", + "display_name": "" + }, + "channel_id": "C0123ABCDEF", + "event_type": "slack#/events/shared_channel_invite_requested", + "channel_name": "our-channel", + "channel_type": "public", + "target_users": [ + { + "email": "user@some-corp.com", + "invite_id": "I0123456ABC" + } + ], + "teams_in_channel": [ + { + "id": "E0123456ABC", + "icon": { + "image_34": "https://slack.com/some-corp/v123/img/abc_0123.png", + "image_default": true + }, + "name": "some_enterprise", + "domain": "someenterprise", + "is_verified": false, + "date_created": 1637947110, + "avatar_base_url": "https://slack.com/some-corp/", + "requires_sponsorship": false + }, + { + "id": "T012345ABCD", + "icon": { + "image_34": "https://slack.com/another-corp/v456/img/def_4567.png", + "image_default": true + }, + "name": "another_enterprise", + "domain": "anotherenterprise", + "is_verified": false, + "date_created": 1645550933, + "avatar_base_url": "https://slack.com/another-corp/", + "requires_sponsorship": false + } + ], + "is_external_limited": true, + "channel_date_created": 1718725442, + "channel_message_latest_counted_timestamp": 1718745614025449 + }` + + var event SharedChannelInviteRequestedEvent + err := json.Unmarshal([]byte(jsonData), &event) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if event.Actor.ID != "U012345ABCD" { + t.Errorf("Expected Actor.ID to be 'U012345ABCD', got '%s'", event.Actor.ID) + } + if event.ChannelID != "C0123ABCDEF" { + t.Errorf("Expected ChannelID to be 'C0123ABCDEF', got '%s'", event.ChannelID) + } + if len(event.TargetUsers) != 1 || event.TargetUsers[0].Email != "user@some-corp.com" { + t.Errorf("Expected one TargetUser with Email 'user@some-corp.com', got '%v'", event.TargetUsers) + } + if len(event.TeamsInChannel) != 2 || event.TeamsInChannel[1].Name != "another_enterprise" { + t.Errorf("Expected second team to have name 'another_enterprise', got '%v'", event.TeamsInChannel) + } +} diff --git a/slackevents/parsers.go b/slackevents/parsers.go index 96ba5681b..9e8c22b7f 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -117,7 +117,7 @@ func parseInnerEvent(e *EventsAPICallbackEvent) (EventsAPIEvent, error) { e.EnterpriseID, nil, EventsAPIInnerEvent{}, - }, fmt.Errorf("Inner Event does not exist! %s", iE.Type) + }, fmt.Errorf("inner Event does not exist! %s", iE.Type) } t := reflect.TypeOf(v) recvEvent := reflect.New(t).Interface() @@ -192,7 +192,7 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error } if !cfg.TokenVerified { - return EventsAPIEvent{}, errors.New("Invalid verification token") + return EventsAPIEvent{}, errors.New("invalid verification token") } if e.Type == CallbackEvent { @@ -212,6 +212,32 @@ func ParseEvent(rawEvent json.RawMessage, opts ...Option) (EventsAPIEvent, error } return innerEvent, nil } + + if e.Type == AppRateLimited { + appRateLimitedEvent := &EventsAPIAppRateLimited{} + err = json.Unmarshal(rawEvent, appRateLimitedEvent) + if err != nil { + return EventsAPIEvent{ + "", + "", + "unmarshalling_error", + "", + "", + &slack.UnmarshallingErrorEvent{ErrorObj: err}, + EventsAPIInnerEvent{}, + }, err + } + return EventsAPIEvent{ + e.Token, + e.TeamID, + e.Type, + e.APIAppID, + e.EnterpriseID, + appRateLimitedEvent, + EventsAPIInnerEvent{}, + }, nil + } + urlVerificationEvent := &EventsAPIURLVerificationEvent{} err = json.Unmarshal(rawEvent, urlVerificationEvent) if err != nil { diff --git a/slackevents/parsers_test.go b/slackevents/parsers_test.go index 821d50f70..2e2b63401 100644 --- a/slackevents/parsers_test.go +++ b/slackevents/parsers_test.go @@ -73,6 +73,33 @@ func TestParseURLVerificationEvent(t *testing.T) { } } +func TestParseAppRateLimitedEvent(t *testing.T) { + event := ` + { + "token": "fake-token", + "team_id": "T123ABC456", + "minute_rate_limited": 1518467820, + "api_app_id": "A123ABC456", + "type": "app_rate_limited" + } + ` + msg, e := ParseEvent(json.RawMessage(event), OptionVerifyToken(&TokenComparator{"fake-token"})) + if e != nil { + fmt.Println(e) + t.Fail() + } + switch ev := msg.Data.(type) { + case *EventsAPIAppRateLimited: + { + } + default: + { + fmt.Println(ev) + t.Fail() + } + } +} + func TestThatOuterCallbackEventHasInnerEvent(t *testing.T) { eventsAPIRawCallbackEvent := ` { diff --git a/slacktest/README.md b/slacktest/README.md index 3897c062a..9a09dad62 100644 --- a/slacktest/README.md +++ b/slacktest/README.md @@ -1,7 +1,9 @@ # slacktest -This package was copied from https://github.com/lusis/slack-test for historical reasons. -This package's license is the following. +This package was originally copied from https://github.com/lusis/slack-test for historical reasons. +It is currently in use with some modifications. + +The license of this package is as follows. --- diff --git a/slacktest/data.go b/slacktest/data.go index 70f0f5af5..ab659340b 100644 --- a/slacktest/data.go +++ b/slacktest/data.go @@ -3,7 +3,7 @@ package slacktest import ( "fmt" - slack "github.com/slack-go/slack" + "github.com/slack-go/slack" ) const defaultBotName = "TestSlackBot" @@ -35,11 +35,11 @@ var okWebResponse = slack.SlackResponse{ Ok: true, } -var defaultOkJSON = fmt.Sprintf(` +var defaultOkJSON = ` { "ok": true } - `) + ` var defaultChannelsListJSON = fmt.Sprintf(` { @@ -250,3 +250,9 @@ var renameConversationJSON = fmt.Sprintf(templateConversationJSON, "newName", var inviteConversationJSON = fmt.Sprintf(templateConversationJSON, defaultConversationName, nowAsJSONTime(), defaultBotID, defaultConversationName, "", "", 0, "", "", 0, 1, "") + +const inviteSharedResponseJSON = `{ + "ok": true, + "invite_id": "I02UKAJ6RJA", + "is_legacy_shared_channel": false +}` diff --git a/slacktest/handlers.go b/slacktest/handlers.go index 6ac37c289..22c1680f6 100644 --- a/slacktest/handlers.go +++ b/slacktest/handlers.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -46,7 +46,7 @@ type GroupConversationResponse struct { } func (sts *Server) conversationsInfoHandler(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) log.Printf(msg) @@ -108,6 +108,11 @@ func inviteConversationHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(inviteConversationJSON)) } +// handle conversations.inviteShared +func inviteSharedConversationHandler(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(inviteSharedResponseJSON)) +} + // handle groups.list func listGroupsHandler(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(defaultGroupsListJSON)) @@ -121,7 +126,7 @@ func reactionAddHandler(w http.ResponseWriter, _ *http.Request) { // handle chat.postMessage func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { serverAddr := r.Context().Value(ServerBotHubNameContextKey).(string) - data, err := ioutil.ReadAll(r.Body) + data, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("error reading body: %s", err.Error()) log.Printf(msg) @@ -213,7 +218,7 @@ func (sts *Server) postMessageHandler(w http.ResponseWriter, r *http.Request) { // RTMConnectHandler generates a valid connection func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { - _, err := ioutil.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) log.Printf(msg) @@ -243,7 +248,7 @@ func RTMConnectHandler(w http.ResponseWriter, r *http.Request) { } func rtmStartHandler(w http.ResponseWriter, r *http.Request) { - _, err := ioutil.ReadAll(r.Body) + _, err := io.ReadAll(r.Body) if err != nil { msg := fmt.Sprintf("Error reading body: %s", err.Error()) log.Printf(msg) diff --git a/slacktest/handlers_test.go b/slacktest/handlers_test.go index dc244e73c..30f1e50ee 100644 --- a/slacktest/handlers_test.go +++ b/slacktest/handlers_test.go @@ -119,7 +119,7 @@ func TestBotInfoHandler(t *testing.T) { go s.Start() client := slack.New("ABCDEFG", slack.OptionAPIURL(s.GetAPIURL())) - bot, err := client.GetBotInfo(s.BotID) + bot, err := client.GetBotInfo(slack.GetBotInfoParameters{Bot: s.BotID}) assert.NoError(t, err) assert.Equal(t, s.BotID, bot.ID) assert.Equal(t, s.BotName, bot.Name) diff --git a/slacktest/server.go b/slacktest/server.go index 1b18923a7..6d9849451 100644 --- a/slacktest/server.go +++ b/slacktest/server.go @@ -26,10 +26,10 @@ type Customize interface { Handle(pattern string, handler http.HandlerFunc) } -type binder func(Customize) +type Binder func(Customize) // NewTestServer returns a slacktest.Server ready to be started -func NewTestServer(custom ...binder) *Server { +func NewTestServer(custom ...Binder) *Server { serverChans := newMessageChannels() channels := &serverChannels{} @@ -55,6 +55,7 @@ func NewTestServer(custom ...binder) *Server { s.Handle("/conversations.setPurpose", setConversationPurposeHandler) s.Handle("/conversations.rename", renameConversationHandler) s.Handle("/conversations.invite", inviteConversationHandler) + s.Handle("/conversations.inviteShared", inviteSharedConversationHandler) s.Handle("/users.info", usersInfoHandler) s.Handle("/users.lookupByEmail", usersInfoHandler) s.Handle("/bots.info", botsInfoHandler) diff --git a/slacktest/server_test.go b/slacktest/server_test.go index ac4244471..45f620858 100644 --- a/slacktest/server_test.go +++ b/slacktest/server_test.go @@ -48,7 +48,7 @@ func TestBotDirectMessageBotHandler(t *testing.T) { s := NewTestServer() go s.Start() s.SendDirectMessageToBot("some text") - expectedMsg := fmt.Sprintf("some text") + expectedMsg := "some text" time.Sleep(2 * time.Second) assert.True(t, s.SawOutgoingMessage(expectedMsg)) s.Stop() diff --git a/slash.go b/slash.go index b2c509476..fd46abfc4 100644 --- a/slash.go +++ b/slash.go @@ -1,25 +1,29 @@ package slack import ( + "encoding/json" + "fmt" "net/http" + "strconv" ) // SlashCommand contains information about a request of the slash command type SlashCommand struct { - Token string `json:"token"` - TeamID string `json:"team_id"` - TeamDomain string `json:"team_domain"` - EnterpriseID string `json:"enterprise_id,omitempty"` - EnterpriseName string `json:"enterprise_name,omitempty"` - ChannelID string `json:"channel_id"` - ChannelName string `json:"channel_name"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - Command string `json:"command"` - Text string `json:"text"` - ResponseURL string `json:"response_url"` - TriggerID string `json:"trigger_id"` - APIAppID string `json:"api_app_id"` + Token string `json:"token"` + TeamID string `json:"team_id"` + TeamDomain string `json:"team_domain"` + EnterpriseID string `json:"enterprise_id,omitempty"` + EnterpriseName string `json:"enterprise_name,omitempty"` + IsEnterpriseInstall bool `json:"is_enterprise_install"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Command string `json:"command"` + Text string `json:"text"` + ResponseURL string `json:"response_url"` + TriggerID string `json:"trigger_id"` + APIAppID string `json:"api_app_id"` } // SlashCommandParse will parse the request of the slash command @@ -32,6 +36,7 @@ func SlashCommandParse(r *http.Request) (s SlashCommand, err error) { s.TeamDomain = r.PostForm.Get("team_domain") s.EnterpriseID = r.PostForm.Get("enterprise_id") s.EnterpriseName = r.PostForm.Get("enterprise_name") + s.IsEnterpriseInstall = r.PostForm.Get("is_enterprise_install") == "true" s.ChannelID = r.PostForm.Get("channel_id") s.ChannelName = r.PostForm.Get("channel_name") s.UserID = r.PostForm.Get("user_id") @@ -53,3 +58,34 @@ func (s SlashCommand) ValidateToken(verificationTokens ...string) bool { } return false } + +// UnmarshalJSON handles is_enterprise_install being either a boolean or a +// string when parsing JSON from various payloads +func (s *SlashCommand) UnmarshalJSON(data []byte) error { + type SlashCommandCopy SlashCommand + scopy := &struct { + *SlashCommandCopy + IsEnterpriseInstall interface{} `json:"is_enterprise_install"` + }{ + SlashCommandCopy: (*SlashCommandCopy)(s), + } + + if err := json.Unmarshal(data, scopy); err != nil { + return err + } + + switch rawValue := scopy.IsEnterpriseInstall.(type) { + case string: + b, err := strconv.ParseBool(rawValue) + if err != nil { + return fmt.Errorf("parsing boolean for is_enterprise_install: %w", err) + } + s.IsEnterpriseInstall = b + case bool: + s.IsEnterpriseInstall = rawValue + default: + return fmt.Errorf("wrong data type for is_enterprise_install: %T", scopy.IsEnterpriseInstall) + } + + return nil +} diff --git a/slash_test.go b/slash_test.go index e5c79e93f..e068fc40c 100644 --- a/slash_test.go +++ b/slash_test.go @@ -1,6 +1,7 @@ package slack import ( + "encoding/json" "fmt" "net/http" "net/url" @@ -99,3 +100,70 @@ func TestSlash_ServeHTTP(t *testing.T) { resp.Body.Close() } } + +func TestSlash_UnmarshalJSON(t *testing.T) { + tests := []struct { + body string + wantIsEnterpriseInstall bool + wantToken string + wantUnmarshalError string + }{ + { + body: `{"token":"blahblah","is_enterprise_install":"false"}`, + wantIsEnterpriseInstall: false, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":false}`, + wantIsEnterpriseInstall: false, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":"true"}`, + wantIsEnterpriseInstall: true, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":true}`, + wantIsEnterpriseInstall: true, + wantToken: "blahblah", + wantUnmarshalError: "", + }, + { + body: `{"token":"blahblah","is_enterprise_install":42}`, + wantUnmarshalError: "wrong data type for is_enterprise_install: float64", + }, + { + body: `{"token":"blahblah","is_enterprise_install":"unconvertable to bool"}`, + wantUnmarshalError: "parsing boolean for is_enterprise_install: strconv.ParseBool: parsing \"unconvertable to bool\": invalid syntax", + }, + } + + for i, test := range tests { + var result SlashCommand + + err := json.Unmarshal([]byte(test.body), &result) + if err != nil { + if err.Error() != test.wantUnmarshalError { + t.Errorf("%d: Got error %v, want error %q", i, err, test.wantUnmarshalError) + } + continue + } + + if test.wantUnmarshalError != "" { + t.Errorf("%d: Got no error, want error %q", i, test.wantUnmarshalError) + continue + } + + if result.IsEnterpriseInstall != test.wantIsEnterpriseInstall { + t.Errorf("%d: Got IsEnterpriseInstall %v, want IsEnterpriseInstall %v", i, result.IsEnterpriseInstall, test.wantIsEnterpriseInstall) + } + + if result.Token != test.wantToken { + t.Errorf("%d: Got Token %v, want Token %v", i, result.Token, test.wantToken) + } + } +} diff --git a/socketmode/deadman.go b/socketmode/deadman.go deleted file mode 100644 index 7aeea760e..000000000 --- a/socketmode/deadman.go +++ /dev/null @@ -1,31 +0,0 @@ -package socketmode - -import "time" - -type deadmanTimer struct { - timeout time.Duration - timer *time.Timer -} - -func newDeadmanTimer(timeout time.Duration) *deadmanTimer { - return &deadmanTimer{ - timeout: timeout, - timer: time.NewTimer(timeout), - } -} - -func (smc *deadmanTimer) Elapsed() <-chan time.Time { - return smc.timer.C -} - -func (smc *deadmanTimer) Reset() { - // Note that this is the correct way to Reset a non-expired timer - if !smc.timer.Stop() { - select { - case <-smc.timer.C: - default: - } - } - - smc.timer.Reset(smc.timeout) -} diff --git a/socketmode/socket_mode_managed_conn.go b/socketmode/socket_mode_managed_conn.go index b259fd624..b94456f49 100644 --- a/socketmode/socket_mode_managed_conn.go +++ b/socketmode/socket_mode_managed_conn.go @@ -54,13 +54,14 @@ func (smc *Client) RunContext(ctx context.Context) error { } func (smc *Client) run(ctx context.Context, connectionCount int) error { - messages := make(chan json.RawMessage) - defer close(messages) - - deadmanTimer := newDeadmanTimer(smc.maxPingInterval) + messages := make(chan json.RawMessage, 1) + pingChan := make(chan time.Time, 1) pingHandler := func(_ string) error { - deadmanTimer.Reset() + select { + case pingChan <- time.Now(): + default: + } return nil } @@ -82,20 +83,24 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - smc.Events <- newEvent(EventTypeConnected, &ConnectedEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnected, &ConnectedEvent{ ConnectionCount: connectionCount, Info: info, - }) + })) smc.Debugf("WebSocket connection succeeded on try %d", connectionCount) // We're now connected so we can set up listeners - var ( - wg sync.WaitGroup - firstErr error - firstErrOnce sync.Once - ) + wg := new(sync.WaitGroup) + // sendErr relies on the buffer of 1 here + errc := make(chan error, 1) + sendErr := func(err error) { + select { + case errc <- err: + default: + } + } wg.Add(1) go func() { @@ -104,9 +109,7 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The response sender sends Socket Mode responses over the WebSocket conn if err := smc.runResponseSender(ctx, conn); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() @@ -117,55 +120,79 @@ func (smc *Client) run(ctx context.Context, connectionCount int) error { // The handler reads Socket Mode requests, and enqueues responses for sending by the response sender if err := smc.runRequestHandler(ctx, messages); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() - // We don't wait on runMessageReceiver because it doesn't block on a select with the context, - // so we'd have to wait for the ReadJSON to time out, which can take a while. go func() { defer cancel() + // We close messages here as it is the producer for the channel. + defer close(messages) // The receiver reads WebSocket messages, and enqueues parsed Socket Mode requests to be handled by // the request handler if err := smc.runMessageReceiver(ctx, conn, messages); err != nil { - firstErrOnce.Do(func() { - firstErr = err - }) + sendErr(err) } }() wg.Add(1) - go func() { + go func(pingInterval time.Duration) { defer wg.Done() - - select { - case <-ctx.Done(): + defer func() { // Detect when the connection is dead and try close connection. - if err = conn.Close(); err != nil { + if err := conn.Close(); err != nil { smc.Debugf("Failed to close connection: %v", err) } - case <-deadmanTimer.Elapsed(): - firstErrOnce.Do(func() { - firstErr = errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval") - }) + }() + + done := ctx.Done() + var lastPing time.Time + + // More efficient than constantly resetting a timer w/ Stop+Reset + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + + for { + select { + case <-done: + return + + case lastPing = <-pingChan: + // This case gets the time of the last ping. + // If this case never fires then the pingHandler was never called + // in which case lastPing is the zero time.Time value, and will 'fail' + // the next tick, causing us to exit. - cancel() + case now := <-ticker.C: + // Our last ping is older than our interval + if now.Sub(lastPing) > pingInterval { + sendErr(errors.New("ping timeout: Slack did not send us WebSocket PING for more than Client.maxInterval")) + + cancel() + return + } + } } - }() + }(smc.maxPingInterval) wg.Wait() - if firstErr == context.Canceled { - return firstErr + select { + case err = <-errc: + // Get buffered error + default: + // Or nothing if they all exited nil + } + + if errors.Is(err, context.Canceled) { + return err } // wg.Wait() finishes only after any of the above go routines finishes and cancels the // context, allowing the other threads to shut down gracefully. - // Also, we can expect firstErr to be not nil, as goroutines can finish only on error. - smc.Debugf("Reconnecting due to %v", firstErr) + // Also, we can expect our (first)err to be not nil, as goroutines can finish only on error. + smc.Debugf("Reconnecting due to %v", err) return nil } @@ -193,10 +220,10 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP ) // send connecting event - smc.Events <- newEvent(EventTypeConnecting, &slack.ConnectingEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnecting, &slack.ConnectingEvent{ Attempt: boff.Attempts() + 1, ConnectionCount: connectionCount, - }) + })) // attempt to start the connection info, conn, err := smc.openAndDial(ctx, additionalPingHandler) @@ -212,26 +239,32 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP default: } - switch actual := err.(type) { - case slack.StatusCodeError: - if actual.Code == http.StatusNotFound { - smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) - smc.Events <- newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{}) - return nil, nil, err - } - case *slack.RateLimitedError: - backoff = actual.RetryAfter - default: + var ( + actual slack.StatusCodeError + rlError *slack.RateLimitedError + ) + + if errors.As(err, &actual) && actual.Code == http.StatusNotFound { + smc.Debugf("invalid auth when connecting with Socket Mode: %s", err) + smc.sendEvent(ctx, newEvent(EventTypeInvalidAuth, &slack.InvalidAuthEvent{})) + + return nil, nil, err + } else if errors.As(err, &rlError) { + backoff = rlError.RetryAfter } + // If we check for errors.Is(err, context.Canceled) here and + // return early then we don't send the Event below that some users + // may already rely on; ie a behavior change. + backoff = timex.Max(backoff, boff.Duration()) // any other errors are treated as recoverable and we try again after // sending the event along the Events channel - smc.Events <- newEvent(EventTypeConnectionError, &slack.ConnectionErrorEvent{ + smc.sendEvent(ctx, newEvent(EventTypeConnectionError, &slack.ConnectionErrorEvent{ Attempt: boff.Attempts(), Backoff: backoff, ErrorObj: err, - }) + })) // get time we should wait before attempting to connect again smc.Debugf("reconnection %d failed: %s reconnecting in %v\n", boff.Attempts(), err, backoff) @@ -239,9 +272,11 @@ func (smc *Client) connect(ctx context.Context, connectionCount int, additionalP // wait for one of the following to occur, // backoff duration has elapsed, disconnectCh is signalled, or // the smc finishes disconnecting. + timer := time.NewTimer(backoff) select { - case <-time.After(backoff): // retry after the backoff. + case <-timer.C: // retry after the backoff. case <-ctx.Done(): + timer.Stop() return nil, nil, ctx.Err() } } @@ -276,12 +311,13 @@ func (smc *Client) openAndDial(ctx context.Context, additionalPingHandler func(s smc.Debugf("Failed to dial to the websocket: %s", err) return nil, nil, err } + if additionalPingHandler == nil { + additionalPingHandler = func(_ string) error { return nil } + } conn.SetPingHandler(func(appData string) error { - if additionalPingHandler != nil { - if err := additionalPingHandler(appData); err != nil { - return err - } + if err := additionalPingHandler(appData); err != nil { + return err } smc.handlePing(conn, appData) @@ -312,10 +348,10 @@ func (smc *Client) runResponseSender(ctx context.Context, conn *websocket.Conn) smc.Debugf("Sending Socket Mode response with envelope ID %q: %v", res.EnvelopeID, res) if err := unsafeWriteSocketModeResponse(conn, res); err != nil { - smc.Events <- newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{ + smc.sendEvent(ctx, newEvent(EventTypeErrorWriteFailed, &ErrorWriteFailed{ Cause: err, Response: res, - }) + })) } smc.Debugf("Finished sending Socket Mode response with envelope ID %q", res.EnvelopeID) @@ -332,16 +368,22 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra select { case <-ctx.Done(): return ctx.Err() - case message := <-websocket: + case message, ok := <-websocket: + if !ok { + // The producer closed the channel because it encountered an error (or panic), + // we need only return. + return nil + } + smc.Debugf("Received WebSocket message: %s", message) // listen for incoming messages that need to be parsed evt, err := smc.parseEvent(message) if err != nil { - smc.Events <- newEvent(EventTypeErrorBadMessage, &ErrorBadMessage{ + smc.sendEvent(ctx, newEvent(EventTypeErrorBadMessage, &ErrorBadMessage{ Cause: err, Message: message, - }) + })) } else if evt != nil { if evt.Type == EventTypeDisconnect { // We treat the `disconnect` request from Slack as an error internally, @@ -349,7 +391,7 @@ func (smc *Client) runRequestHandler(ctx context.Context, websocket chan json.Ra return errorRequestedDisconnect{} } - smc.Events <- *evt + smc.sendEvent(ctx, *evt) } } } @@ -385,11 +427,7 @@ func unsafeWriteSocketModeResponse(conn *websocket.Conn, res *Response) error { // Remove write deadline regardless of WriteJSON succeeds or not defer conn.SetWriteDeadline(time.Time{}) - if err := conn.WriteJSON(res); err != nil { - return err - } - - return nil + return conn.WriteJSON(res) } func newEvent(tpe EventType, data interface{}, req ...*Request) Event { @@ -407,29 +445,54 @@ func newEvent(tpe EventType, data interface{}, req ...*Request) Event { // This tells Slack that the we have received the request denoted by the envelope ID, // by sending back the envelope ID over the WebSocket connection. func (smc *Client) Ack(req Request, payload ...interface{}) { - res := Response{ - EnvelopeID: req.EnvelopeID, - } - + var pld interface{} if len(payload) > 0 { - res.Payload = payload[0] + pld = payload[0] } - smc.Send(res) + smc.AckCtx(context.TODO(), req.EnvelopeID, pld) +} + +// AckCtx acknowledges the Socket Mode request envelope ID with the payload. +// +// This tells Slack that the we have received the request denoted by the request (envelope) ID, +// by sending back the ID over the WebSocket connection. +func (smc *Client) AckCtx(ctx context.Context, reqID string, payload interface{}) error { + return smc.SendCtx(ctx, Response{ + EnvelopeID: reqID, + Payload: payload, + }) } // Send sends the Socket Mode response over a WebSocket connection. // This is usually used for acknowledging requests, but if you need more control over Client.Ack(). // It's normally recommended to use Client.Ack() instead of this. func (smc *Client) Send(res Response) { - js, err := json.Marshal(res) - if err != nil { - panic(err) + smc.SendCtx(context.TODO(), res) +} + +// SendCtx sends the Socket Mode response over a WebSocket connection. +// This is usually used for acknowledging requests, but if you need more control +// it's normally recommended to use Client.AckCtx() instead of this. +func (smc *Client) SendCtx(ctx context.Context, res Response) error { + if smc.debug { + js, err := json.Marshal(res) + + // Log the error so users of `Send` don't see it entirely disappear as that method + // does not return an error and used to panic on failure (with or without debug) + smc.Debugf("Scheduling Socket Mode response (error: %v) for envelope ID %s: %s", err, res.EnvelopeID, js) + if err != nil { + return err + } } - smc.Debugf("Scheduling Socket Mode response for envelope ID %s: %s", res.EnvelopeID, js) + select { + case <-ctx.Done(): + return ctx.Err() + case smc.socketModeResponses <- &res: + } - smc.socketModeResponses <- &res + return nil } // receiveMessagesInto attempts to receive an event from the WebSocket connection for Socket Mode. @@ -441,53 +504,56 @@ func (smc *Client) receiveMessagesInto(ctx context.Context, conn *websocket.Conn event := json.RawMessage{} err := conn.ReadJSON(&event) + if err != nil { + // check if the connection was closed. + // This version of the gorilla/websocket package also does a type assertion + // on the error, rather than unwrapping it, so we'll do the unwrapping then pass + // the unwrapped error + var wsErr *websocket.CloseError + if errors.As(err, &wsErr) && websocket.IsUnexpectedCloseError(wsErr) { + return err + } - // check if the connection was closed. - if websocket.IsUnexpectedCloseError(err) { - return err - } + if errors.Is(err, io.ErrUnexpectedEOF) { + // EOF's don't seem to signify a failed connection so instead we ignore + // them here and detect a failed connection upon attempting to send a + // 'PING' message - switch { - case err == io.ErrUnexpectedEOF: - // EOF's don't seem to signify a failed connection so instead we ignore - // them here and detect a failed connection upon attempting to send a - // 'PING' message + // Unlike RTM, we don't ping from the our end as there seem to have no client ping. + // We just continue to the next loop so that we `smc.disconnected` should be received if + // this EOF error was actually due to disconnection. - // Unlike RTM, we don't ping from the our end as there seem to have no client ping. - // We just continue to the next loop so that we `smc.disconnected` should be received if - // this EOF error was actually due to disconnection. + return nil + } - return nil - case err != nil: // All other errors from ReadJSON come from NextReader, and should // kill the read loop and force a reconnect. - smc.Events <- newEvent(EventTypeIncomingError, &slack.IncomingEventError{ + // TODO: Unless it's a JSON unmarshal-type error in which case maybe reconnecting isn't needed... + smc.sendEvent(ctx, newEvent(EventTypeIncomingError, &slack.IncomingEventError{ ErrorObj: err, - }) + })) return err - case len(event) == 0: - smc.Debugln("Received empty event") - default: - if smc.debug { - buf := &bytes.Buffer{} - d := json.NewEncoder(buf) - d.SetIndent("", " ") - if err := d.Encode(event); err != nil { - smc.Debugln("Failed encoding decoded json:", err) - } - reencoded := buf.String() + } - smc.Debugln("Incoming WebSocket message:", reencoded) + if smc.debug { + buf := &bytes.Buffer{} + d := json.NewEncoder(buf) + d.SetIndent("", " ") + if err := d.Encode(event); err != nil { + smc.Debugln("Failed encoding decoded json:", err) } + reencoded := buf.String() - select { - case sink <- event: - case <-ctx.Done(): - smc.Debugln("cancelled while attempting to send raw event") + smc.Debugln("Incoming WebSocket message:", reencoded) + } - return ctx.Err() - } + select { + case sink <- event: + case <-ctx.Done(): + smc.Debugln("cancelled while attempting to send raw event") + + return ctx.Err() } return nil @@ -500,7 +566,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { req := &Request{} err := json.Unmarshal(wsMsg, req) if err != nil { - return nil, fmt.Errorf("unmarshalling WebSocket message: %v", err) + return nil, fmt.Errorf("unmarshalling WebSocket message: %w", err) } var evt Event @@ -516,7 +582,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { eventsAPIEvent, err := slackevents.ParseEvent(payloadEvent, slackevents.OptionNoVerifyToken()) if err != nil { - return nil, fmt.Errorf("parsing Events API event: %v", err) + return nil, fmt.Errorf("parsing Events API event: %w", err) } evt = newEvent(EventTypeEventsAPI, eventsAPIEvent, req) @@ -529,7 +595,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { var cmd slack.SlashCommand if err := json.Unmarshal(req.Payload, &cmd); err != nil { - return nil, fmt.Errorf("parsing slash command: %v", err) + return nil, fmt.Errorf("parsing slash command: %w", err) } evt = newEvent(EventTypeSlashCommand, cmd, req) @@ -543,7 +609,7 @@ func (smc *Client) parseEvent(wsMsg json.RawMessage) (*Event, error) { var callback slack.InteractionCallback if err := json.Unmarshal(req.Payload, &callback); err != nil { - return nil, fmt.Errorf("parsing interaction callback: %v", err) + return nil, fmt.Errorf("parsing interaction callback: %w", err) } evt = newEvent(EventTypeInteractive, callback, req) diff --git a/socketmode/socketmode.go b/socketmode/socketmode.go index 1871e6763..6ca8f487c 100644 --- a/socketmode/socketmode.go +++ b/socketmode/socketmode.go @@ -119,3 +119,15 @@ func New(api *slack.Client, options ...Option) *Client { return result } + +// sendEvent safely sends an event into the Clients Events channel +// and blocks until buffer space is had, or the context is canceled. +// This prevents deadlocking in the event that Events buffer is full, +// other goroutines are waiting, and/or timing allows receivers to exit +// before all senders are finished. +func (smc *Client) sendEvent(ctx context.Context, event Event) { + select { + case smc.Events <- event: + case <-ctx.Done(): + } +} diff --git a/socketmode/socketmode_handler.go b/socketmode/socketmode_handler.go index 636feefa5..5b56f2954 100644 --- a/socketmode/socketmode_handler.go +++ b/socketmode/socketmode_handler.go @@ -1,6 +1,8 @@ package socketmode import ( + "context" + "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" ) @@ -105,15 +107,31 @@ func (r *SocketmodeHandler) HandleDefault(f SocketmodeHandlerFunc) { // RunSlackEventLoop receives the event via the socket func (r *SocketmodeHandler) RunEventLoop() error { - go r.runEventLoop() + go r.runEventLoop(context.Background()) return r.Client.Run() } +func (r *SocketmodeHandler) RunEventLoopContext(ctx context.Context) error { + go r.runEventLoop(ctx) + + return r.Client.RunContext(ctx) +} + // Call the dispatcher for each incomming event -func (r *SocketmodeHandler) runEventLoop() { - for evt := range r.Client.Events { - r.dispatcher(evt) +func (r *SocketmodeHandler) runEventLoop(ctx context.Context) { + for { + select { + case evt, ok := <-r.Client.Events: + if !ok { + return + } + + r.dispatcher(evt) + + case <-ctx.Done(): + return + } } } diff --git a/stars.go b/stars.go index 6e0ebbe32..51926854e 100644 --- a/stars.go +++ b/stars.go @@ -36,12 +36,14 @@ func NewStarsParameters() StarsParameters { } } -// AddStar stars an item in a channel +// AddStar stars an item in a channel. +// For more information see the AddStarContext documentation. func (api *Client) AddStar(channel string, item ItemRef) error { return api.AddStarContext(context.Background(), channel, item) } -// AddStarContext stars an item in a channel with a custom context +// AddStarContext stars an item in a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.add func (api *Client) AddStarContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -65,12 +67,14 @@ func (api *Client) AddStarContext(ctx context.Context, channel string, item Item return response.Err() } -// RemoveStar removes a starred item from a channel +// RemoveStar removes a starred item from a channel. +// For more information see the RemoveStarContext documentation. func (api *Client) RemoveStar(channel string, item ItemRef) error { return api.RemoveStarContext(context.Background(), channel, item) } -// RemoveStarContext removes a starred item from a channel with a custom context +// RemoveStarContext removes a starred item from a channel with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.remove func (api *Client) RemoveStarContext(ctx context.Context, channel string, item ItemRef) error { values := url.Values{ "channel": {channel}, @@ -94,12 +98,14 @@ func (api *Client) RemoveStarContext(ctx context.Context, channel string, item I return response.Err() } -// ListStars returns information about the stars a user added +// ListStars returns information about the stars a user added. +// For more information see the ListStarsContext documentation. func (api *Client) ListStars(params StarsParameters) ([]Item, *Paging, error) { return api.ListStarsContext(context.Background(), params) } -// ListStarsContext returns information about the stars a user added with a custom context +// ListStarsContext returns information about the stars a user added with a custom context. +// Slack API docs: https://api.slack.com/methods/stars.list func (api *Client) ListStarsContext(ctx context.Context, params StarsParameters) ([]Item, *Paging, error) { values := url.Values{ "token": {api.token}, @@ -147,7 +153,6 @@ func (api *Client) GetStarred(params StarsParameters) ([]StarredItem, *Paging, e } // GetStarredContext returns a list of StarredItem items with a custom context -// // For more details see GetStarred func (api *Client) GetStarredContext(ctx context.Context, params StarsParameters) ([]StarredItem, *Paging, error) { items, paging, err := api.ListStarsContext(ctx, params) diff --git a/team.go b/team.go index d21a1b642..4e890c2be 100644 --- a/team.go +++ b/team.go @@ -74,8 +74,9 @@ type BillingActive struct { // AccessLogParameters contains all the parameters necessary (including the optional ones) for a GetAccessLogs() request type AccessLogParameters struct { - Count int - Page int + TeamID string + Count int + Page int } // NewAccessLogParameters provides an instance of AccessLogParameters with all the sane default values set @@ -124,12 +125,14 @@ func (api *Client) teamProfileRequest(ctx context.Context, client httpClient, pa return response, response.Err() } -// GetTeamInfo gets the Team Information of the user +// GetTeamInfo gets the Team Information of the user. +// For more information see the GetTeamInfoContext documentation. func (api *Client) GetTeamInfo() (*TeamInfo, error) { return api.GetTeamInfoContext(context.Background()) } -// GetOtherTeamInfoContext gets Team information for any team with a custom context +// GetOtherTeamInfoContext gets Team information for any team with a custom context. +// Slack API docs: https://api.slack.com/methods/team.info func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*TeamInfo, error) { if team == "" { return api.GetTeamInfoContext(ctx) @@ -145,12 +148,14 @@ func (api *Client) GetOtherTeamInfoContext(ctx context.Context, team string) (*T return &response.Team, nil } -// GetOtherTeamInfo gets Team information for any team +// GetOtherTeamInfo gets Team information for any team. +// For more information see the GetOtherTeamInfoContext documentation. func (api *Client) GetOtherTeamInfo(team string) (*TeamInfo, error) { return api.GetOtherTeamInfoContext(context.Background(), team) } -// GetTeamInfoContext gets the Team Information of the user with a custom context +// GetTeamInfoContext gets the Team Information of the user with a custom context. +// Slack API docs: https://api.slack.com/methods/team.info func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { values := url.Values{ "token": {api.token}, @@ -163,35 +168,44 @@ func (api *Client) GetTeamInfoContext(ctx context.Context) (*TeamInfo, error) { return &response.Team, nil } -// GetTeamProfile gets the Team Profile settings of the user -func (api *Client) GetTeamProfile() (*TeamProfile, error) { - return api.GetTeamProfileContext(context.Background()) +// GetTeamProfile gets the Team Profile settings of the user. +// For more information see the GetTeamProfileContext documentation. +func (api *Client) GetTeamProfile(teamID ...string) (*TeamProfile, error) { + return api.GetTeamProfileContext(context.Background(), teamID...) } -// GetTeamProfileContext gets the Team Profile settings of the user with a custom context -func (api *Client) GetTeamProfileContext(ctx context.Context) (*TeamProfile, error) { +// GetTeamProfileContext gets the Team Profile settings of the user with a custom context. +// Slack API docs: https://api.slack.com/methods/team.profile.get +func (api *Client) GetTeamProfileContext(ctx context.Context, teamID ...string) (*TeamProfile, error) { values := url.Values{ "token": {api.token}, } + if len(teamID) > 0 { + values["team_id"] = teamID + } response, err := api.teamProfileRequest(ctx, api.httpclient, "team.profile.get", values) if err != nil { return nil, err } return &response.Profile, nil - } -// GetAccessLogs retrieves a page of logins according to the parameters given +// GetAccessLogs retrieves a page of logins according to the parameters given. +// For more information see the GetAccessLogsContext documentation. func (api *Client) GetAccessLogs(params AccessLogParameters) ([]Login, *Paging, error) { return api.GetAccessLogsContext(context.Background(), params) } -// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context +// GetAccessLogsContext retrieves a page of logins according to the parameters given with a custom context. +// Slack API docs: https://api.slack.com/methods/team.accessLogs func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogParameters) ([]Login, *Paging, error) { values := url.Values{ "token": {api.token}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.Count != DEFAULT_LOGINS_COUNT { values.Add("count", strconv.Itoa(params.Count)) } @@ -206,30 +220,30 @@ func (api *Client) GetAccessLogsContext(ctx context.Context, params AccessLogPar return response.Logins, &response.Paging, nil } -// GetBillableInfo ... -func (api *Client) GetBillableInfo(user string) (map[string]BillingActive, error) { - return api.GetBillableInfoContext(context.Background(), user) +type GetBillableInfoParams struct { + User string + TeamID string } -// GetBillableInfoContext ... -func (api *Client) GetBillableInfoContext(ctx context.Context, user string) (map[string]BillingActive, error) { +// GetBillableInfo gets the billable users information of the team. +// For more information see the GetBillableInfoContext documentation. +func (api *Client) GetBillableInfo(params GetBillableInfoParams) (map[string]BillingActive, error) { + return api.GetBillableInfoContext(context.Background(), params) +} + +// GetBillableInfoContext gets the billable users information of the team with a custom context. +// Slack API docs: https://api.slack.com/methods/team.billableInfo +func (api *Client) GetBillableInfoContext(ctx context.Context, params GetBillableInfoParams) (map[string]BillingActive, error) { values := url.Values{ "token": {api.token}, - "user": {user}, } - return api.billableInfoRequest(ctx, "team.billableInfo", values) -} - -// GetBillableInfoForTeam returns the billing_active status of all users on the team. -func (api *Client) GetBillableInfoForTeam() (map[string]BillingActive, error) { - return api.GetBillableInfoForTeamContext(context.Background()) -} + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } -// GetBillableInfoForTeamContext returns the billing_active status of all users on the team with a custom context -func (api *Client) GetBillableInfoForTeamContext(ctx context.Context) (map[string]BillingActive, error) { - values := url.Values{ - "token": {api.token}, + if params.User != "" { + values.Add("user", params.User) } return api.billableInfoRequest(ctx, "team.billableInfo", values) diff --git a/tokens.go b/tokens.go new file mode 100644 index 000000000..49bbde9b1 --- /dev/null +++ b/tokens.go @@ -0,0 +1,52 @@ +package slack + +import ( + "context" + "net/url" +) + +// RotateTokens exchanges a refresh token for a new app configuration token. +// For more information see the RotateTokensContext documentation. +func (api *Client) RotateTokens(configToken string, refreshToken string) (*TokenResponse, error) { + return api.RotateTokensContext(context.Background(), configToken, refreshToken) +} + +// RotateTokensContext exchanges a refresh token for a new app configuration token with a custom context. +// Slack API docs: https://api.slack.com/methods/tooling.tokens.rotate +func (api *Client) RotateTokensContext(ctx context.Context, configToken string, refreshToken string) (*TokenResponse, error) { + if configToken == "" { + configToken = api.configToken + } + + if refreshToken == "" { + refreshToken = api.configRefreshToken + } + + values := url.Values{ + "refresh_token": {refreshToken}, + } + + response := &TokenResponse{} + err := api.getMethod(ctx, "tooling.tokens.rotate", configToken, values, response) + if err != nil { + return nil, err + } + + return response, response.Err() +} + +// UpdateConfigTokens replaces the configuration tokens in the client with those returned by the API +func (api *Client) UpdateConfigTokens(response *TokenResponse) { + api.configToken = response.Token + api.configRefreshToken = response.RefreshToken +} + +type TokenResponse struct { + Token string `json:"token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + TeamId string `json:"team_id,omitempty"` + UserId string `json:"user_id,omitempty"` + IssuedAt uint64 `json:"iat,omitempty"` + ExpiresAt uint64 `json:"exp,omitempty"` + SlackResponse +} diff --git a/tokens_test.go b/tokens_test.go new file mode 100644 index 000000000..621174598 --- /dev/null +++ b/tokens_test.go @@ -0,0 +1,45 @@ +package slack + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" +) + +func TestRotateTokens(t *testing.T) { + http.HandleFunc("/tooling.tokens.rotate", handleRotateToken) + expected := getTestTokenResponse() + + once.Do(startServer) + api := New("testing-token", OptionAPIURL("http://"+serverAddr+"/")) + + tok, err := api.RotateTokens("expired-config", "old-refresh") + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if !reflect.DeepEqual(expected, *tok) { + t.Fatal(ErrIncorrectResponse) + } +} + +func getTestTokenResponse() TokenResponse { + return TokenResponse{ + Token: "token", + RefreshToken: "refresh", + UserId: "uid", + TeamId: "tid", + IssuedAt: 1, + ExpiresAt: 1, + SlackResponse: SlackResponse{Ok: true}, + } +} + +func handleRotateToken(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + response, _ := json.Marshal(getTestTokenResponse()) + rw.Write(response) +} diff --git a/usergroups.go b/usergroups.go index 050aa6f28..41e381459 100644 --- a/usergroups.go +++ b/usergroups.go @@ -50,18 +50,24 @@ func (api *Client) userGroupRequest(ctx context.Context, path string, values url return response, response.Err() } -// CreateUserGroup creates a new user group +// CreateUserGroup creates a new user group. +// For more information see the CreateUserGroupContext documentation. func (api *Client) CreateUserGroup(userGroup UserGroup) (UserGroup, error) { return api.CreateUserGroupContext(context.Background(), userGroup) } -// CreateUserGroupContext creates a new user group with a custom context +// CreateUserGroupContext creates a new user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.create func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGroup) (UserGroup, error) { values := url.Values{ "token": {api.token}, "name": {userGroup.Name}, } + if userGroup.TeamID != "" { + values["team_id"] = []string{userGroup.TeamID} + } + if userGroup.Handle != "" { values["handle"] = []string{userGroup.Handle} } @@ -81,12 +87,14 @@ func (api *Client) CreateUserGroupContext(ctx context.Context, userGroup UserGro return response.UserGroup, nil } -// DisableUserGroup disables an existing user group +// DisableUserGroup disables an existing user group. +// For more information see the DisableUserGroupContext documentation. func (api *Client) DisableUserGroup(userGroup string) (UserGroup, error) { return api.DisableUserGroupContext(context.Background(), userGroup) } -// DisableUserGroupContext disables an existing user group with a custom context +// DisableUserGroupContext disables an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.disable func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -100,12 +108,14 @@ func (api *Client) DisableUserGroupContext(ctx context.Context, userGroup string return response.UserGroup, nil } -// EnableUserGroup enables an existing user group +// EnableUserGroup enables an existing user group. +// For more information see the EnableUserGroupContext documentation. func (api *Client) EnableUserGroup(userGroup string) (UserGroup, error) { return api.EnableUserGroupContext(context.Background(), userGroup) } -// EnableUserGroupContext enables an existing user group with a custom context +// EnableUserGroupContext enables an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.enable func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) (UserGroup, error) { values := url.Values{ "token": {api.token}, @@ -122,6 +132,12 @@ func (api *Client) EnableUserGroupContext(ctx context.Context, userGroup string) // GetUserGroupsOption options for the GetUserGroups method call. type GetUserGroupsOption func(*GetUserGroupsParams) +func GetUserGroupsOptionWithTeamID(teamID string) GetUserGroupsOption { + return func(params *GetUserGroupsParams) { + params.TeamID = teamID + } +} + // GetUserGroupsOptionIncludeCount include the number of users in each User Group (default: false) func GetUserGroupsOptionIncludeCount(b bool) GetUserGroupsOption { return func(params *GetUserGroupsParams) { @@ -152,18 +168,20 @@ func GetUserGroupsOptionTeamID(teamID string) GetUserGroupsOption { // GetUserGroupsParams contains arguments for GetUserGroups method call type GetUserGroupsParams struct { + TeamID string IncludeCount bool IncludeDisabled bool IncludeUsers bool - TeamID string } -// GetUserGroups returns a list of user groups for the team +// GetUserGroups returns a list of user groups for the team. +// For more information see the GetUserGroupsContext documentation. func (api *Client) GetUserGroups(options ...GetUserGroupsOption) ([]UserGroup, error) { return api.GetUserGroupsContext(context.Background(), options...) } -// GetUserGroupsContext returns a list of user groups for the team with a custom context +// GetUserGroupsContext returns a list of user groups for the team with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.list func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserGroupsOption) ([]UserGroup, error) { params := GetUserGroupsParams{} @@ -174,6 +192,9 @@ func (api *Client) GetUserGroupsContext(ctx context.Context, options ...GetUserG values := url.Values{ "token": {api.token}, } + if params.TeamID != "" { + values.Add("team_id", params.TeamID) + } if params.IncludeCount { values.Add("include_count", "true") } @@ -233,12 +254,14 @@ type UpdateUserGroupsParams struct { Channels *[]string } -// UpdateUserGroup will update an existing user group +// UpdateUserGroup will update an existing user group. +// For more information see the UpdateUserGroupContext documentation. func (api *Client) UpdateUserGroup(userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { return api.UpdateUserGroupContext(context.Background(), userGroupID, options...) } -// UpdateUserGroupContext will update an existing user group with a custom context +// UpdateUserGroupContext will update an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.update func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID string, options ...UpdateUserGroupsOption) (UserGroup, error) { params := UpdateUserGroupsParams{} @@ -274,12 +297,14 @@ func (api *Client) UpdateUserGroupContext(ctx context.Context, userGroupID strin return response.UserGroup, nil } -// GetUserGroupMembers will retrieve the current list of users in a group +// GetUserGroupMembers will retrieve the current list of users in a group. +// For more information see the GetUserGroupMembersContext documentation. func (api *Client) GetUserGroupMembers(userGroup string) ([]string, error) { return api.GetUserGroupMembersContext(context.Background(), userGroup) } -// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context +// GetUserGroupMembersContext will retrieve the current list of users in a group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.users.list func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup string) ([]string, error) { values := url.Values{ "token": {api.token}, @@ -293,12 +318,14 @@ func (api *Client) GetUserGroupMembersContext(ctx context.Context, userGroup str return response.Users, nil } -// UpdateUserGroupMembers will update the members of an existing user group +// UpdateUserGroupMembers will update the members of an existing user group. +// For more information see the UpdateUserGroupMembersContext documentation. func (api *Client) UpdateUserGroupMembers(userGroup string, members string) (UserGroup, error) { return api.UpdateUserGroupMembersContext(context.Background(), userGroup, members) } -// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context +// UpdateUserGroupMembersContext will update the members of an existing user group with a custom context. +// Slack API docs: https://api.slack.com/methods/usergroups.update func (api *Client) UpdateUserGroupMembersContext(ctx context.Context, userGroup string, members string) (UserGroup, error) { values := url.Values{ "token": {api.token}, diff --git a/users.go b/users.go index 55f42118f..b51b1721f 100644 --- a/users.go +++ b/users.go @@ -17,31 +17,32 @@ const ( // UserProfile contains all the information details of a given user type UserProfile struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` RealName string `json:"real_name"` RealNameNormalized string `json:"real_name_normalized"` DisplayName string `json:"display_name"` DisplayNameNormalized string `json:"display_name_normalized"` - Email string `json:"email"` - Skype string `json:"skype"` - Phone string `json:"phone"` + AvatarHash string `json:"avatar_hash"` + Email string `json:"email,omitempty"` + Skype string `json:"skyp,omitempty"` + Phone string `json:"phone,omitempty"` Image24 string `json:"image_24"` Image32 string `json:"image_32"` Image48 string `json:"image_48"` Image72 string `json:"image_72"` Image192 string `json:"image_192"` Image512 string `json:"image_512"` - ImageOriginal string `json:"image_original"` - Title string `json:"title"` + ImageOriginal string `json:"image_original,omitempty"` + Title string `json:"title,omitempty"` BotID string `json:"bot_id,omitempty"` ApiAppID string `json:"api_app_id,omitempty"` StatusText string `json:"status_text,omitempty"` StatusEmoji string `json:"status_emoji,omitempty"` StatusEmojiDisplayInfo []UserProfileStatusEmojiDisplayInfo `json:"status_emoji_display_info,omitempty"` - StatusExpiration int `json:"status_expiration"` + StatusExpiration int `json:"status_expiration,omitempty"` Team string `json:"team"` - Fields UserProfileCustomFields `json:"fields"` + Fields UserProfileCustomFields `json:"fields,omitempty"` } type UserProfileStatusEmojiDisplayInfo struct { @@ -130,6 +131,7 @@ type User struct { IsAppUser bool `json:"is_app_user"` IsInvitedUser bool `json:"is_invited_user"` Has2FA bool `json:"has_2fa"` + TwoFactorType *string `json:"two_factor_type"` HasFiles bool `json:"has_files"` Presence string `json:"presence"` Locale string `json:"locale"` @@ -225,11 +227,13 @@ func (api *Client) userRequest(ctx context.Context, path string, values url.Valu } // GetUserPresence will retrieve the current presence status of given user. +// For more information see the GetUserPresenceContext documentation. func (api *Client) GetUserPresence(user string) (*UserPresence, error) { return api.GetUserPresenceContext(context.Background(), user) } // GetUserPresenceContext will retrieve the current presence status of given user with a custom context. +// Slack API docs: https://api.slack.com/methods/users.getPresence func (api *Client) GetUserPresenceContext(ctx context.Context, user string) (*UserPresence, error) { values := url.Values{ "token": {api.token}, @@ -243,12 +247,14 @@ func (api *Client) GetUserPresenceContext(ctx context.Context, user string) (*Us return &response.UserPresence, nil } -// GetUserInfo will retrieve the complete user information +// GetUserInfo will retrieve the complete user information. +// For more information see the GetUserInfoContext documentation. func (api *Client) GetUserInfo(user string) (*User, error) { return api.GetUserInfoContext(context.Background(), user) } -// GetUserInfoContext will retrieve the complete user information with a custom context +// GetUserInfoContext will retrieve the complete user information with a custom context. +// Slack API docs: https://api.slack.com/methods/users.info func (api *Client) GetUserInfoContext(ctx context.Context, user string) (*User, error) { values := url.Values{ "token": {api.token}, @@ -263,12 +269,14 @@ func (api *Client) GetUserInfoContext(ctx context.Context, user string) (*User, return &response.User, nil } -// GetUsersInfo will retrieve the complete multi-users information +// GetUsersInfo will retrieve the complete multi-users information. +// For more information see the GetUsersInfoContext documentation. func (api *Client) GetUsersInfo(users ...string) (*[]User, error) { return api.GetUsersInfoContext(context.Background(), users...) } -// GetUsersInfoContext will retrieve the complete multi-users information with a custom context +// GetUsersInfoContext will retrieve the complete multi-users information with a custom context. +// Slack API docs: https://api.slack.com/methods/users.info func (api *Client) GetUsersInfoContext(ctx context.Context, users ...string) (*[]User, error) { values := url.Values{ "token": {api.token}, @@ -405,12 +413,14 @@ func (api *Client) GetUsersContext(ctx context.Context, options ...GetUsersOptio return results, p.Failure(err) } -// GetUserByEmail will retrieve the complete user information by email +// GetUserByEmail will retrieve the complete user information by email. +// For more information see the GetUserByEmailContext documentation. func (api *Client) GetUserByEmail(email string) (*User, error) { return api.GetUserByEmailContext(context.Background(), email) } -// GetUserByEmailContext will retrieve the complete user information by email with a custom context +// GetUserByEmailContext will retrieve the complete user information by email with a custom context. +// Slack API docs: https://api.slack.com/methods/users.lookupByEmail func (api *Client) GetUserByEmailContext(ctx context.Context, email string) (*User, error) { values := url.Values{ "token": {api.token}, @@ -423,12 +433,14 @@ func (api *Client) GetUserByEmailContext(ctx context.Context, email string) (*Us return &response.User, nil } -// SetUserAsActive marks the currently authenticated user as active +// SetUserAsActive marks the currently authenticated user as active. +// For more information see the SetUserAsActiveContext documentation. func (api *Client) SetUserAsActive() error { return api.SetUserAsActiveContext(context.Background()) } -// SetUserAsActiveContext marks the currently authenticated user as active with a custom context +// SetUserAsActiveContext marks the currently authenticated user as active with a custom context. +// Slack API docs: https://api.slack.com/methods/users.setActive func (api *Client) SetUserAsActiveContext(ctx context.Context) (err error) { values := url.Values{ "token": {api.token}, @@ -438,12 +450,14 @@ func (api *Client) SetUserAsActiveContext(ctx context.Context) (err error) { return err } -// SetUserPresence changes the currently authenticated user presence +// SetUserPresence changes the currently authenticated user presence. +// For more information see the SetUserPresenceContext documentation. func (api *Client) SetUserPresence(presence string) error { return api.SetUserPresenceContext(context.Background(), presence) } -// SetUserPresenceContext changes the currently authenticated user presence with a custom context +// SetUserPresenceContext changes the currently authenticated user presence with a custom context. +// Slack API docs: https://api.slack.com/methods/users.setPresence func (api *Client) SetUserPresenceContext(ctx context.Context, presence string) error { values := url.Values{ "token": {api.token}, @@ -454,12 +468,14 @@ func (api *Client) SetUserPresenceContext(ctx context.Context, presence string) return err } -// GetUserIdentity will retrieve user info available per identity scopes +// GetUserIdentity will retrieve user info available per identity scopes. +// For more information see the GetUserIdentityContext documentation. func (api *Client) GetUserIdentity() (*UserIdentityResponse, error) { return api.GetUserIdentityContext(context.Background()) } -// GetUserIdentityContext will retrieve user info available per identity scopes with a custom context +// GetUserIdentityContext will retrieve user info available per identity scopes with a custom context. +// Slack API docs: https://api.slack.com/methods/users.identity func (api *Client) GetUserIdentityContext(ctx context.Context) (response *UserIdentityResponse, err error) { values := url.Values{ "token": {api.token}, @@ -478,12 +494,14 @@ func (api *Client) GetUserIdentityContext(ctx context.Context) (response *UserId return response, nil } -// SetUserPhoto changes the currently authenticated user's profile image +// SetUserPhoto changes the currently authenticated user's profile image. +// For more information see the SetUserPhotoContext documentation. func (api *Client) SetUserPhoto(image string, params UserSetPhotoParams) error { return api.SetUserPhotoContext(context.Background(), image, params) } -// SetUserPhotoContext changes the currently authenticated user's profile image using a custom context +// SetUserPhotoContext changes the currently authenticated user's profile image using a custom context. +// Slack API docs: https://api.slack.com/methods/users.setPhoto func (api *Client) SetUserPhotoContext(ctx context.Context, image string, params UserSetPhotoParams) (err error) { response := &SlackResponse{} values := url.Values{} @@ -505,12 +523,14 @@ func (api *Client) SetUserPhotoContext(ctx context.Context, image string, params return response.Err() } -// DeleteUserPhoto deletes the current authenticated user's profile image +// DeleteUserPhoto deletes the current authenticated user's profile image. +// For more information see the DeleteUserPhotoContext documentation. func (api *Client) DeleteUserPhoto() error { return api.DeleteUserPhotoContext(context.Background()) } -// DeleteUserPhotoContext deletes the current authenticated user's profile image with a custom context +// DeleteUserPhotoContext deletes the current authenticated user's profile image with a custom context. +// Slack API docs: https://api.slack.com/methods/users.deletePhoto func (api *Client) DeleteUserPhotoContext(ctx context.Context) (err error) { response := &SlackResponse{} values := url.Values{ @@ -526,13 +546,13 @@ func (api *Client) DeleteUserPhotoContext(ctx context.Context) (err error) { } // SetUserRealName changes the currently authenticated user's realName -// -// For more information see SetUserRealNameContextWithUser +// For more information see the SetUserRealNameContextWithUser documentation. func (api *Client) SetUserRealName(realName string) error { return api.SetUserRealNameContextWithUser(context.Background(), "", realName) } -// SetUserRealNameContextWithUser will set a real name for the provided user with a custom context +// SetUserRealNameContextWithUser will set a real name for the provided user with a custom context. +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, realName string) error { profile, err := json.Marshal( &struct { @@ -564,20 +584,22 @@ func (api *Client) SetUserRealNameContextWithUser(ctx context.Context, user, rea return response.Err() } -// SetUserCustomFields sets Custom Profile fields on the provided users account. Due to the non-repeating elements -// within the request, a map fields is required. The key in the map signifies the field that will be updated. -// -// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console from -// SCIM or User Entered to API. -// -// See GetTeamProfile for information to retrieve possible fields for your account. +// SetUserCustomFields sets Custom Profile fields on the provided users account. +// For more information see the SetUserCustomFieldsContext documentation. func (api *Client) SetUserCustomFields(userID string, customFields map[string]UserProfileCustomField) error { return api.SetUserCustomFieldsContext(context.Background(), userID, customFields) } -// SetUserCustomFieldsContext will set a users custom profile field with context. +// SetUserCustomFieldsContext sets Custom Profile fields on the provided users account. +// Due to the non-repeating elements within the request, a map fields is required. +// The key in the map signifies the field that will be updated. +// +// Note: You may need to change the way the custom field is populated within the Profile section of the Admin Console +// from SCIM or User Entered to API. // -// For more information see SetUserCustomFields +// See GetTeamProfile for information to retrieve possible fields for your account. +// +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string, customFields map[string]UserProfileCustomField) error { // Convert data to data type with custom marshall / unmarshall @@ -613,32 +635,30 @@ func (api *Client) SetUserCustomFieldsContext(ctx context.Context, userID string } -// SetUserCustomStatus will set a custom status and emoji for the currently -// authenticated user. If statusEmoji is "" and statusText is not, the Slack API -// will automatically set it to ":speech_balloon:". Otherwise, if both are "" -// the Slack API will unset the custom status/emoji. If statusExpiration is set to 0 -// the status will not expire. +// SetUserCustomStatus will set a custom status and emoji for the currently authenticated user. +// For more information see the SetUserCustomStatusContext documentation. func (api *Client) SetUserCustomStatus(statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(context.Background(), "", statusText, statusEmoji, statusExpiration) } -// SetUserCustomStatusContext will set a custom status and emoji for the currently authenticated user with a custom context -// -// For more information see SetUserCustomStatus +// SetUserCustomStatusContext will set a custom status and emoji for the currently authenticated user with a custom context. +// For more information see the SetUserCustomStatusContextWithUser documentation. func (api *Client) SetUserCustomStatusContext(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(ctx, "", statusText, statusEmoji, statusExpiration) } // SetUserCustomStatusWithUser will set a custom status and emoji for the provided user. -// -// For more information see SetUserCustomStatus +// For more information see the SetUserCustomStatusContextWithUser documentation. func (api *Client) SetUserCustomStatusWithUser(user, statusText, statusEmoji string, statusExpiration int64) error { return api.SetUserCustomStatusContextWithUser(context.Background(), user, statusText, statusEmoji, statusExpiration) } -// SetUserCustomStatusContextWithUser will set a custom status and emoji for the provided user with a custom context +// SetUserCustomStatusContextWithUser will set a custom status and emoji for the currently authenticated user. +// If statusEmoji is "" and statusText is not, the Slack API will automatically set it to ":speech_balloon:". +// Otherwise, if both are "" the Slack API will unset the custom status/emoji. If statusExpiration is set to 0 +// the status will not expire. // -// For more information see SetUserCustomStatus +// Slack API docs: https://api.slack.com/methods/users.profile.set func (api *Client) SetUserCustomStatusContextWithUser(ctx context.Context, user, statusText, statusEmoji string, statusExpiration int64) error { // XXX(theckman): this anonymous struct is for making requests to the Slack // API for setting and unsetting a User's Custom Status/Emoji. To change @@ -703,6 +723,7 @@ type GetUserProfileParameters struct { } // GetUserProfile retrieves a user's profile information. +// For more information see the GetUserProfileContext documentation. func (api *Client) GetUserProfile(params *GetUserProfileParameters) (*UserProfile, error) { return api.GetUserProfileContext(context.Background(), params) } @@ -713,6 +734,7 @@ type getUserProfileResponse struct { } // GetUserProfileContext retrieves a user's profile information with a context. +// Slack API docs: https://api.slack.com/methods/users.profile.get func (api *Client) GetUserProfileContext(ctx context.Context, params *GetUserProfileParameters) (*UserProfile, error) { values := url.Values{"token": {api.token}} diff --git a/users_test.go b/users_test.go index 5ba915995..eedfd2d60 100644 --- a/users_test.go +++ b/users_test.go @@ -8,7 +8,6 @@ import ( "image/draw" "image/png" "io" - "io/ioutil" "net/http" "os" "reflect" @@ -556,7 +555,7 @@ func setUserPhotoHandler(wantBytes []byte, wantParams UserSetPhotoParams) http.H httpTestErrReply(w, true, fmt.Sprintf("failed to open uploaded file: %+v", err)) return } - gotBytes, err := ioutil.ReadAll(file) + gotBytes, err := io.ReadAll(file) if err != nil { httpTestErrReply(w, true, fmt.Sprintf("failed to read uploaded file: %+v", err)) return @@ -577,7 +576,7 @@ func createUserPhoto(t *testing.T) (*os.File, []byte, func()) { photo := image.NewRGBA(image.Rect(0, 0, 64, 64)) draw.Draw(photo, photo.Bounds(), image.Black, image.ZP, draw.Src) - f, err := ioutil.TempFile(os.TempDir(), "profile.png") + f, err := os.CreateTemp(os.TempDir(), "profile.png") if err != nil { t.Fatalf("failed to create test photo: %+v\n", err) } diff --git a/views.go b/views.go index e6a961788..0822d2cb2 100644 --- a/views.go +++ b/views.go @@ -155,6 +155,7 @@ type ViewResponse struct { } // OpenView opens a view for a user. +// For more information see the OpenViewContext documentation. func (api *Client) OpenView(triggerID string, view ModalViewRequest) (*ViewResponse, error) { return api.OpenViewContext(context.Background(), triggerID, view) } @@ -177,6 +178,7 @@ func ValidateUniqueBlockID(view ModalViewRequest) bool { } // OpenViewContext opens a view for a user with a custom context. +// Slack API docs: https://api.slack.com/methods/views.open func (api *Client) OpenViewContext( ctx context.Context, triggerID string, @@ -208,11 +210,13 @@ func (api *Client) OpenViewContext( } // PublishView publishes a static view for a user. +// For more information see the PublishViewContext documentation. func (api *Client) PublishView(userID string, view HomeTabViewRequest, hash string) (*ViewResponse, error) { return api.PublishViewContext(context.Background(), userID, view, hash) } // PublishViewContext publishes a static view for a user with a custom context. +// Slack API docs: https://api.slack.com/methods/views.publish func (api *Client) PublishViewContext( ctx context.Context, userID string, @@ -241,11 +245,13 @@ func (api *Client) PublishViewContext( } // PushView pushes a view onto the stack of a root view. +// For more information see the PushViewContext documentation. func (api *Client) PushView(triggerID string, view ModalViewRequest) (*ViewResponse, error) { return api.PushViewContext(context.Background(), triggerID, view) } -// PublishViewContext pushes a view onto the stack of a root view with a custom context. +// PushViewContext pushes a view onto the stack of a root view with a custom context. +// Slack API docs: https://api.slack.com/methods/views.push func (api *Client) PushViewContext( ctx context.Context, triggerID string, @@ -272,11 +278,13 @@ func (api *Client) PushViewContext( } // UpdateView updates an existing view. +// For more information see the UpdateViewContext documentation. func (api *Client) UpdateView(view ModalViewRequest, externalID, hash, viewID string) (*ViewResponse, error) { return api.UpdateViewContext(context.Background(), view, externalID, hash, viewID) } // UpdateViewContext updates an existing view with a custom context. +// Slack API docs: https://api.slack.com/methods/views.update func (api *Client) UpdateViewContext( ctx context.Context, view ModalViewRequest, diff --git a/webhooks.go b/webhooks.go index e3233536a..5a854f38b 100644 --- a/webhooks.go +++ b/webhooks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" ) @@ -24,6 +23,8 @@ type WebhookMessage struct { ReplaceOriginal bool `json:"replace_original"` DeleteOriginal bool `json:"delete_original"` ReplyBroadcast bool `json:"reply_broadcast,omitempty"` + UnfurlLinks bool `json:"unfurl_links,omitempty"` + UnfurlMedia bool `json:"unfurl_media,omitempty"` } func PostWebhook(url string, msg *WebhookMessage) error { @@ -55,7 +56,7 @@ func PostWebhookCustomHTTPContext(ctx context.Context, url string, httpClient *h return fmt.Errorf("failed to post webhook: %w", err) } defer func() { - io.Copy(ioutil.Discard, resp.Body) + io.Copy(io.Discard, resp.Body) resp.Body.Close() }() diff --git a/workflow_step.go b/workflow_step.go index bcc892c5a..747a5dc00 100644 --- a/workflow_step.go +++ b/workflow_step.go @@ -44,12 +44,15 @@ func NewConfigurationModalRequest(blocks Blocks, privateMetaData string, externa } } +// SaveWorkflowStepConfiguration opens a configuration modal for a workflow step. +// For more information see the SaveWorkflowStepConfigurationContext documentation. func (api *Client) SaveWorkflowStepConfiguration(workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { return api.SaveWorkflowStepConfigurationContext(context.Background(), workflowStepEditID, inputs, outputs) } +// SaveWorkflowStepConfigurationContext saves the configuration of a workflow step with a custom context. +// Slack API docs: https://api.slack.com/methods/workflows.updateStep func (api *Client) SaveWorkflowStepConfigurationContext(ctx context.Context, workflowStepEditID string, inputs *WorkflowStepInputs, outputs *[]WorkflowStepOutput) error { - // More information: https://api.slack.com/methods/workflows.updateStep wscr := WorkflowStepCompleteResponse{ WorkflowStepEditID: workflowStepEditID, Inputs: inputs,