Repository: slack-go/slack (go)

Issue:
Add support for canvas API methods
### Description
Slack has recently added API methods to manage canvases. These methods could be added to the library.

### Slack's documentation
[canvases.access.delete](https://api.slack.com/methods/canvases.access.delete)
[canvases.access.set](https://api.slack.com/methods/canvases.access.set)
[canvases.create](https://api.slack.com/methods/canvases.create)
[canvases.delete](https://api.slack.com/methods/canvases.delete)
[canvases.edit](https://api.slack.com/methods/canvases.edit)
[canvases.sections.lookup](https://api.slack.com/methods/canvases.sections.lookup)
[conversations.canvases.create](https://api.slack.com/methods/conversations.canvases.create)

Pull request:
feat: Add support for Canvas API methods
This PR introduces new functionalities for managing canvases and creating channel-specific canvases.

- CreateCanvas
- DeleteCanvas
- EditCanvas
- SetCanvasAccess
- DeleteCanvasAccess
- LookupCanvasSections
- CreateChannelCanvas

Closes #1333 

Patch:
diff --git a/canvas.go b/canvas.go
new file mode 100644
index 0000000..5225afa
--- /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/conversation.go b/conversation.go
index 6b941b5..67d28be 100644
--- a/conversation.go
+++ b/conversation.go
@@ -2,6 +2,7 @@ package slack
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"net/url"
 	"strconv"
@@ -825,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()
+}


Test patch:
diff --git a/canvas_test.go b/canvas_test.go
new file mode 100644
index 0000000..c0e3010
--- /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/conversation_test.go b/conversation_test.go
index 0ee6f4d..6b3576c 100644
--- a/conversation_test.go
+++ b/conversation_test.go
@@ -743,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)
+}