diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index e99b5e620..e860f783b 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: any-of-labels: 'feedback given' days-before-stale: 45 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cbf91c669..61eaa92dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,10 +17,11 @@ jobs: - '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 @@ -31,14 +32,14 @@ jobs: runs-on: ubuntu-22.04 name: lint steps: - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '1.20' cache: false - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: golangci-lint - uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 with: version: v1.52.2 diff --git a/block.go b/block.go index 1d6ba2d95..a3fb1a0a7 100644 --- a/block.go +++ b/block.go @@ -18,6 +18,7 @@ const ( MBTInput MessageBlockType = "input" MBTHeader MessageBlockType = "header" MBTRichText MessageBlockType = "rich_text" + MBTCall MessageBlockType = "call" MBTVideo MessageBlockType = "video" ) diff --git a/block_call.go b/block_call.go new file mode 100644 index 000000000..98f2c0255 --- /dev/null +++ b/block_call.go @@ -0,0 +1,23 @@ +package slack + +// CallBlock defines data that is used to display a call in slack. +// +// More Information: https://api.slack.com/apis/calls#post_to_channel +type CallBlock struct { + Type MessageBlockType `json:"type"` + BlockID string `json:"block_id,omitempty"` + CallID string `json:"call_id"` +} + +// BlockType returns the type of the block +func (s CallBlock) BlockType() MessageBlockType { + return s.Type +} + +// NewFileBlock 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 7570be2ab..26c57bbbb 100644 --- a/block_conv.go +++ b/block_conv.go @@ -69,6 +69,8 @@ func (b *Blocks) UnmarshalJSON(data []byte) error { block = &RichTextBlock{} case "section": block = &SectionBlock{} + case "call": + block = &CallBlock{} case "video": block = &VideoBlock{} default: diff --git a/block_element.go b/block_element.go index a2b755be2..ad3b67006 100644 --- a/block_element.go +++ b/block_element.go @@ -258,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( @@ -309,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( @@ -352,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. @@ -520,6 +598,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 @@ -527,7 +635,7 @@ type RichTextInputBlockElement struct { Type MessageElementType `json:"type"` ActionID string `json:"action_id,omitempty"` Placeholder *TextBlockObject `json:"placeholder,omitempty"` - InitialValue string `json:"initial_value,omitempty"` + InitialValue *RichTextBlock `json:"initial_value,omitempty"` DispatchActionConfig *DispatchActionConfig `json:"dispatch_action_config,omitempty"` FocusOnLoad bool `json:"focus_on_load,omitempty"` } @@ -629,6 +737,30 @@ func NewNumberInputBlockElement(placeholder *TextBlockObject, actionID string, i } } +// 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. 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_rich_text.go b/block_rich_text.go index b6a4b4ce4..c6eb0b1ba 100644 --- a/block_rich_text.go +++ b/block_rich_text.go @@ -341,6 +341,7 @@ type RichTextSectionEmojiElement struct { Type RichTextSectionElementType `json:"type"` Name string `json:"name"` SkinTone int `json:"skin_tone"` + Unicode string `json:"unicode,omitempty"` Style *RichTextSectionTextStyle `json:"style,omitempty"` } @@ -414,16 +415,22 @@ func NewRichTextSectionUserGroupElement(usergroupID string) *RichTextSectionUser type RichTextSectionDateElement struct { Type RichTextSectionElementType `json:"type"` Timestamp JSONTime `json:"timestamp"` + Format string `json:"format"` + URL *string `json:"url,omitempty"` + Fallback *string `json:"fallback,omitempty"` } func (r RichTextSectionDateElement) RichTextSectionElementType() RichTextSectionElementType { return r.Type } -func NewRichTextSectionDateElement(timestamp int64) *RichTextSectionDateElement { +func NewRichTextSectionDateElement(timestamp int64, format string, url *string, fallback *string) *RichTextSectionDateElement { return &RichTextSectionDateElement{ Type: RTSEDate, Timestamp: JSONTime(timestamp), + Format: format, + URL: url, + Fallback: fallback, } } diff --git a/block_rich_text_test.go b/block_rich_text_test.go index a9f04b7de..dc4a0cf5b 100644 --- a/block_rich_text_test.go +++ b/block_rich_text_test.go @@ -167,13 +167,14 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { err error }{ { - []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629}]}`), + []byte(`{"elements":[{"type":"unknown","value":10},{"type":"text","text":"hi"},{"type":"date","timestamp":1636961629,"format":"{date_short_pretty}"},{"type":"date","timestamp":1636961629,"format":"{date_short_pretty}","url":"https://example.com","fallback":"default"}]}`), RichTextSection{ Type: RTESection, Elements: []RichTextSectionElement{ &RichTextSectionUnknownElement{Type: RTSEUnknown, Raw: `{"type":"unknown","value":10}`}, &RichTextSectionTextElement{Type: RTSEText, Text: "hi"}, - &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629)}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}"}, + &RichTextSectionDateElement{Type: RTSEDate, Timestamp: JSONTime(1636961629), Format: "{date_short_pretty}", URL: strp("https://example.com"), Fallback: strp("default")}, }, }, nil, @@ -186,6 +187,16 @@ func TestRichTextSection_UnmarshalJSON(t *testing.T) { }, nil, }, + { + []byte(`{"type": "rich_text_section","elements":[{"type": "emoji","name": "+1","unicode": "1f44d-1f3fb","skin_tone": 2}]}`), + RichTextSection{ + Type: RTESection, + Elements: []RichTextSectionElement{ + &RichTextSectionEmojiElement{Type: RTSEEmoji, Name: "+1", Unicode: "1f44d-1f3fb", SkinTone: 2}, + }, + }, + nil, + }, } for _, tc := range cases { var actual RichTextSection @@ -361,3 +372,5 @@ func TestRichTextQuote_Marshal(t *testing.T) { } }) } + +func strp(in string) *string { return &in } 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 2a600c237..9b3b6ed49 100644 --- a/chat.go +++ b/chat.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "encoding/json" - "io/ioutil" + "io" "net/http" "net/url" "regexp" @@ -226,11 +226,11 @@ 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)) + req.Body = io.NopCloser(bytes.NewBuffer(reqBody)) api.Debugf("Sending request: %s", redactToken(reqBody)) } @@ -377,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, diff --git a/chat_test.go b/chat_test.go index ee93bddff..917ff965e 100644 --- a/chat_test.go +++ b/chat_test.go @@ -3,7 +3,7 @@ package slack import ( "bytes" "encoding/json" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -216,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 @@ -242,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 @@ -270,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 @@ -297,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 diff --git a/conversation.go b/conversation.go index 6e608eba5..67d28be3b 100644 --- a/conversation.go +++ b/conversation.go @@ -2,6 +2,7 @@ package slack import ( "context" + "encoding/json" "errors" "net/url" "strconv" @@ -65,6 +66,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 @@ -318,42 +330,71 @@ func (api *Client) InviteUsersToConversationContext(ctx context.Context, channel } // InviteSharedEmailsToConversation invites users to a shared channels by email. -// For more details, see InviteSharedEmailsToConversationContext documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversation(channelID string, emails ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(context.Background(), channelID, emails, nil) + 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 inviteSharedToConversationHelper documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedEmailsToConversationContext(ctx context.Context, channelID string, emails ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(ctx, channelID, emails, nil) + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + Emails: emails, + }) } // InviteSharedUserIDsToConversation invites users to a shared channels by user id. -// For more details, see InviteSharedUserIDsToConversationContext documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedUserIDsToConversation(channelID string, userIDs ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(context.Background(), channelID, nil, userIDs) + 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 inviteSharedToConversationHelper documentation. +// For more details, see InviteSharedToConversationContext documentation. func (api *Client) InviteSharedUserIDsToConversationContext(ctx context.Context, channelID string, userIDs ...string) (string, bool, error) { - return api.inviteSharedToConversationHelper(ctx, channelID, nil, userIDs) + return api.InviteSharedToConversationContext(ctx, InviteSharedToConversationParams{ + ChannelID: channelID, + UserIDs: userIDs, + }) } -// inviteSharedToConversationHelper invites emails or userIDs to a channel with a custom context. +// 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) inviteSharedToConversationHelper(ctx context.Context, channelID string, emails []string, userIDs []string) (string, bool, error) { +func (api *Client) InviteSharedToConversationContext(ctx context.Context, params InviteSharedToConversationParams) (string, bool, error) { values := url.Values{ "token": {api.token}, - "channel": {channelID}, + "channel": {params.ChannelID}, } - if len(emails) > 0 { - values.Add("emails", strings.Join(emails, ",")) - } else if len(userIDs) > 0 { - values.Add("user_ids", strings.Join(userIDs, ",")) + 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 @@ -785,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 1c6b8a211..6b3576c97 100644 --- a/conversation_test.go +++ b/conversation_test.go @@ -149,6 +149,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", @@ -398,6 +477,27 @@ func TestInviteSharedToConversation(t *testing.T) { 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) { @@ -643,3 +743,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/examples/conversation_history/conversation_history.go b/examples/conversation_history/conversation_history.go index 569c12d68..54d5e20a2 100644 --- a/examples/conversation_history/conversation_history.go +++ b/examples/conversation_history/conversation_history.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/slack-go/slack" + "github.com/rusq/slack" ) func main() { diff --git a/examples/dialog/dialog.go b/examples/dialog/dialog.go index 5df6b109b..4c70b9cc7 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 2f5b608b0..a1e10955b 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 index 60bcfa967..3728979de 100644 --- a/examples/files_remote/files_remote.go +++ b/examples/files_remote/files_remote.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/slack-go/slack" + "github.com/rusq/slack" ) func main() { diff --git a/examples/function/function.go b/examples/function/function.go new file mode 100644 index 000000000..b49f7e7a5 --- /dev/null +++ b/examples/function/function.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "os" + + "github.com/rusq/slack" + "github.com/rusq/slack/slackevents" + "github.com/rusq/slack/socketmode" +) + +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/modal/modal.go b/examples/modal/modal.go index 2a8636c06..a9ee52f82 100644 --- a/examples/modal/modal.go +++ b/examples/modal/modal.go @@ -15,7 +15,7 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "time" @@ -96,13 +96,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 { diff --git a/examples/pagination/pagination.go b/examples/pagination/pagination.go index 265910137..91e168311 100644 --- a/examples/pagination/pagination.go +++ b/examples/pagination/pagination.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/slack-go/slack" + "github.com/rusq/slack" ) func getAllUserUIDs(ctx context.Context, client *slack.Client, pageSize int) ([]string, error) { diff --git a/examples/slash/slash.go b/examples/slash/slash.go index 7dbd7dcc5..7b21f1219 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/rusq/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/workflow_step/handler.go b/examples/workflow_step/handler.go index 73e3f2aa3..c6543af14 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" @@ -24,8 +24,8 @@ func handleMyWorkflowStep(w http.ResponseWriter, r *http.Request) { return } - // see: https://github.com/rusq/slack/blob/master/examples/eventsapi/events.go - body, err := ioutil.ReadAll(r.Body) + // see: https://github.com/slack-go/slack/blob/master/examples/eventsapi/events.go + 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 96d39cea4..8616a37aa 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/rusq/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 6844edd51..b26317145 100644 --- a/files.go +++ b/files.go @@ -355,14 +355,16 @@ func (api *Client) ListFilesContext(ctx context.Context, params ListFilesParamet } // UploadFile uploads a file. -// DEPRECATED: Use UploadFileV2 instead. This will stop functioning on March 11, 2025. +// +// 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. -// DEPRECATED: Use UploadFileV2Context instead. This will stop functioning on March 11, 2025. +// +// 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 diff --git a/files_test.go b/files_test.go index 59afc1c4b..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) { 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/misc.go b/misc.go index 7e5a8d54a..df779c1ad 100644 --- a/misc.go +++ b/misc.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "mime" "mime/multipart" "net/http" @@ -63,13 +62,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 } @@ -127,7 +125,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 } @@ -156,9 +154,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 @@ -174,7 +179,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 } @@ -200,6 +206,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 { @@ -316,7 +336,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/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 0467d7e7c..42639a178 100644 --- a/remotefiles.go +++ b/remotefiles.go @@ -247,9 +247,7 @@ func (api *Client) UpdateRemoteFile(fileID string, params RemoteFileParameters) // 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) } @@ -271,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) } diff --git a/slackevents/inner_events.go b/slackevents/inner_events.go index 4d5fd76db..1e55e3289 100644 --- a/slackevents/inner_events.go +++ b/slackevents/inner_events.go @@ -465,6 +465,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"` @@ -651,6 +652,462 @@ type SharedInvite struct { 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 ( @@ -740,53 +1197,157 @@ const ( 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{}, - 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{}, + 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 71eb402f8..4307e8ad6 100644 --- a/slackevents/inner_events_test.go +++ b/slackevents/inner_events_test.go @@ -1504,3 +1504,1152 @@ func TestSharedChannelDeclined(t *testing.T) { } } + +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 e05b83863..236b50c0b 100644 --- a/slackevents/parsers.go +++ b/slackevents/parsers.go @@ -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 1c506cb63..2013e71db 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/handlers.go b/slacktest/handlers.go index d216fb607..7237059ed 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) @@ -126,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) @@ -218,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) @@ -248,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/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/webhooks.go b/webhooks.go index 8fc149d90..5a854f38b 100644 --- a/webhooks.go +++ b/webhooks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" ) @@ -57,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() }()