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/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) + } + } +}