Repository: slack-go/slack
Pull request #1328 (2024-10-01 18:26:11)

feat: Add functions.completeError and functions.completeSuccess
Completion of https://github.com/slack-go/slack/pull/1301

- Adds the new complete functions for the Function Execution Event
- Adds the context version of those methods

---
> this PR to handle event [function_executed](https://api.slack.com/events/function_executed) and response the function with [functions.completeSuccess](https://api.slack.com/methods/functions.completeSuccess) and [functions.completeError](https://api.slack.com/methods/functions.completeError)




Patch:
diff --git a/block.go b/block.go
index 1d6ba2d..a3fb1a0 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 0000000..98f2c02
--- /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_conv.go b/block_conv.go
index 7570be2..26c57bb 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 06bffe3..ad3b670 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
@@ -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_input.go b/block_input.go
index 78ffcdb..7c1272a 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/calls.go b/calls.go
new file mode 100644
index 0000000..2d6e91f
--- /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/examples/function/function.go b/examples/function/function.go
new file mode 100644
index 0000000..d654890
--- /dev/null
+++ b/examples/function/function.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+	"fmt"
+	"github.com/slack-go/slack"
+	"github.com/slack-go/slack/slackevents"
+	"github.com/slack-go/slack/socketmode"
+	"os"
+)
+
+func main() {
+	api := slack.New(
+		os.Getenv("SLACK_BOT_TOKEN"),
+		slack.OptionDebug(true),
+		slack.OptionAppLevelToken(os.Getenv("SLACK_APP_TOKEN")),
+	)
+	client := socketmode.New(api, socketmode.OptionDebug(true))
+
+	go func() {
+		for evt := range client.Events {
+			switch evt.Type {
+			case socketmode.EventTypeEventsAPI:
+				eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
+				if !ok {
+					fmt.Printf("Ignored %+v\n", evt)
+					continue
+				}
+
+				fmt.Printf("Event received: %+v\n", eventsAPIEvent)
+				client.Ack(*evt.Request)
+
+				switch eventsAPIEvent.Type {
+				case slackevents.CallbackEvent:
+					innerEvent := eventsAPIEvent.InnerEvent
+					switch ev := innerEvent.Data.(type) {
+					case *slackevents.FunctionExecutedEvent:
+						callbackID := ev.Function.CallbackID
+						if callbackID == "sample_function" {
+							userId := ev.Inputs["user_id"]
+							payload := map[string]string{
+								"user_id": userId,
+							}
+
+							err := api.FunctionCompleteSuccess(ev.FunctionExecutionID, slack.FunctionCompleteSuccessRequestOptionOutput(payload))
+							if err != nil {
+								fmt.Printf("failed posting message: %v \n", err)
+							}
+						}
+					}
+				default:
+					client.Debugf("unsupported Events API event received\n")
+				}
+
+			default:
+				fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type)
+			}
+		}
+	}()
+	client.Run()
+}
diff --git a/examples/function/manifest.json b/examples/function/manifest.json
new file mode 100644
index 0000000..5f673f9
--- /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/function_execute.go b/function_execute.go
new file mode 100644
index 0000000..4ec8f9f
--- /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
+}


Test patch:
diff --git a/block_call_test.go b/block_call_test.go
new file mode 100644
index 0000000..c118542
--- /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/calls_test.go b/calls_test.go
new file mode 100644
index 0000000..0c225fb
--- /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/function_execute_test.go b/function_execute_test.go
new file mode 100644
index 0000000..356e223
--- /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()
+	}
+}