diff --git a/.changeset/slack-platform-tools-expansion.md b/.changeset/slack-platform-tools-expansion.md new file mode 100644 index 0000000000..d38e9f331e --- /dev/null +++ b/.changeset/slack-platform-tools-expansion.md @@ -0,0 +1,5 @@ +--- +"server": minor +--- + +Slack assistants can now manage the full message and channel lifecycle: edit, delete, and ephemeral messages; pull permalinks; open DMs; create, join, leave, invite, archive, and rename channels; manage pins, bookmarks, usergroup membership, reminders, file uploads, canvases, and presence/DND. Closes the previous gap where assistants could read Slack but barely write to it. diff --git a/client/dashboard/src/pages/assistants/onboarding/slackManifest.ts b/client/dashboard/src/pages/assistants/onboarding/slackManifest.ts index 572bf4372b..07138273b7 100644 --- a/client/dashboard/src/pages/assistants/onboarding/slackManifest.ts +++ b/client/dashboard/src/pages/assistants/onboarding/slackManifest.ts @@ -23,6 +23,115 @@ export const SLACK_TOOL_SCOPES: Record = { get_reactions: ["reactions:read"], list_reactions: ["reactions:read"], list_emoji: ["emoji:read"], + update_message: ["chat:write"], + delete_message: ["chat:write"], + post_ephemeral: ["chat:write"], + get_permalink: [], + delete_scheduled_message: ["chat:write"], + list_scheduled_messages: [], + me_message: ["chat:write"], + get_channel_info: ["channels:read", "groups:read", "im:read", "mpim:read"], + list_channel_members: [ + "channels:read", + "groups:read", + "im:read", + "mpim:read", + ], + open_conversation: [ + "channels:manage", + "groups:write", + "im:write", + "mpim:write", + ], + create_channel: ["channels:manage", "groups:write", "im:write", "mpim:write"], + join_channel: ["channels:join"], + leave_channel: ["channels:manage", "groups:write", "im:write", "mpim:write"], + invite_to_channel: [ + "channels:manage", + "channels:write.invites", + "groups:write", + "groups:write.invites", + "im:write", + "mpim:write", + ], + set_channel_topic: [ + "channels:manage", + "channels:write.topic", + "groups:write", + "groups:write.topic", + "im:write", + "im:write.topic", + "mpim:write", + "mpim:write.topic", + ], + set_channel_purpose: [ + "channels:manage", + "channels:write.topic", + "groups:write", + "groups:write.topic", + "im:write", + "im:write.topic", + "mpim:write", + "mpim:write.topic", + ], + mark_conversation: [ + "channels:manage", + "groups:write", + "im:write", + "mpim:write", + ], + archive_channel: [ + "channels:manage", + "groups:write", + "im:write", + "mpim:write", + ], + unarchive_channel: [ + "channels:manage", + "groups:write", + "im:write", + "mpim:write", + ], + rename_channel: ["channels:manage", "groups:write", "im:write", "mpim:write"], + remove_from_channel: [ + "channels:manage", + "groups:write", + "im:write", + "mpim:write", + ], + lookup_user_by_email: ["users:read", "users:read.email"], + list_user_conversations: [ + "users:read", + "channels:read", + "groups:read", + "im:read", + "mpim:read", + ], + get_user_presence: ["users:read"], + get_user_profile_fields: ["users.profile:read"], + get_user_dnd: ["dnd:read"], + get_team_dnd: ["dnd:read"], + upload_file: ["files:write"], + get_file_info: ["files:read"], + list_files: ["files:read"], + delete_file: ["files:write"], + pin_message: ["pins:write"], + unpin_message: ["pins:write"], + list_pins: ["pins:read"], + add_bookmark: ["bookmarks:write"], + edit_bookmark: ["bookmarks:write"], + remove_bookmark: ["bookmarks:write"], + list_bookmarks: ["bookmarks:read"], + list_usergroups: ["usergroups:read"], + list_usergroup_members: ["usergroups:read"], + get_team_info: ["team:read"], + create_canvas: ["canvases:write"], + edit_canvas: ["canvases:write"], + delete_canvas: ["canvases:write"], + lookup_canvas_sections: ["canvases:read"], + set_canvas_access: ["canvases:write"], + remove_canvas_access: ["canvases:write"], + create_channel_canvas: ["canvases:write"], }; // Always grant the full user-scope superset alongside bot scopes. Slack mints @@ -43,6 +152,8 @@ export const SLACK_USER_SCOPES: readonly string[] = [ "mpim:read", "pins:read", "reactions:read", + "reminders:read", + "reminders:write", "search:read", "users:read", "users:read.email", diff --git a/server/internal/platformtools/registry.go b/server/internal/platformtools/registry.go index d12cb044d3..97eb620e5f 100644 --- a/server/internal/platformtools/registry.go +++ b/server/internal/platformtools/registry.go @@ -64,6 +64,165 @@ var registry = []toolFactory{ func(deps Dependencies) PlatformToolExecutor { return platformslack.NewListEmojiTool(deps.SlackHTTPClient) }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatUpdateTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatDeleteTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatPostEphemeralTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatGetPermalinkTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatDeleteScheduledMessageTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatListScheduledMessagesTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewChatMeMessageTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetChannelInfoTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListChannelMembersTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewOpenConversationTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewCreateChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewJoinChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewLeaveChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewInviteToChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewSetChannelTopicTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewSetChannelPurposeTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewMarkConversationTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewArchiveChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewUnarchiveChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewRenameChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewRemoveFromChannelTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewLookupUserByEmailTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListUserConversationsTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetUserPresenceTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetUserProfileFieldsTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetUserDndTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetTeamDndTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewAddReminderTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListRemindersTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewCompleteReminderTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewDeleteReminderTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetReminderTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewUploadFileTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetFileInfoTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListFilesTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewDeleteFileTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewPinMessageTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewUnpinMessageTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListPinsTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewAddBookmarkTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewEditBookmarkTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewRemoveBookmarkTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListBookmarksTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListUsergroupsTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewListUsergroupMembersTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewGetTeamInfoTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewCreateCanvasTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewEditCanvasTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewDeleteCanvasTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewLookupCanvasSectionsTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewSetCanvasAccessTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewRemoveCanvasAccessTool(deps.SlackHTTPClient) + }, + func(deps Dependencies) PlatformToolExecutor { + return platformslack.NewCreateChannelCanvasTool(deps.SlackHTTPClient) + }, } // BuildExecutors materializes executors for built-in plus caller-supplied diff --git a/server/internal/platformtools/slack/canvas_types.go b/server/internal/platformtools/slack/canvas_types.go new file mode 100644 index 0000000000..2342c84036 --- /dev/null +++ b/server/internal/platformtools/slack/canvas_types.go @@ -0,0 +1,6 @@ +package slack + +type canvasDocumentContent struct { + Type string `json:"type" jsonschema:"Document content type. Slack currently accepts \"markdown\"."` + Markdown string `json:"markdown" jsonschema:"Canvas body authored in Slack-flavoured markdown."` +} diff --git a/server/internal/platformtools/slack/tool_add_bookmark.go b/server/internal/platformtools/slack/tool_add_bookmark.go new file mode 100644 index 0000000000..046c9fd49d --- /dev/null +++ b/server/internal/platformtools/slack/tool_add_bookmark.go @@ -0,0 +1,86 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameAddBookmark = "platform_slack_add_bookmark" + +type addBookmarkInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to attach the bookmark to."` + Title string `json:"title" jsonschema:"Title shown in the channel bookmark bar."` + Type string `json:"type" jsonschema:"Bookmark type. Slack currently accepts \"link\"."` + Link string `json:"link" jsonschema:"URL the bookmark resolves to."` + Emoji *string `json:"emoji,omitempty" jsonschema:"Optional emoji tag (e.g. \":memo:\") displayed alongside the bookmark."` + EntityID *string `json:"entity_id,omitempty" jsonschema:"Optional ID of the bookmarked entity (messages or files only)."` + ParentID *string `json:"parent_id,omitempty" jsonschema:"Optional ID of a parent bookmark to nest under."` +} + +func NewAddBookmarkTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "add_bookmark", + Name: toolNameAddBookmark, + Description: "Add a bookmark to a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[addBookmarkInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callAddBookmark, + } +} + +func callAddBookmark(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input addBookmarkInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + title, err := requireString("title", input.Title) + if err != nil { + return err + } + bookmarkType, err := requireString("type", input.Type) + if err != nil { + return err + } + link, err := requireString("link", input.Link) + if err != nil { + return err + } + + request := map[string]any{ + "channel_id": channelID, + "title": title, + "type": bookmarkType, + "link": link, + } + setOptionalString(request, "emoji", input.Emoji) + setOptionalString(request, "entity_id", input.EntityID) + setOptionalString(request, "parent_id", input.ParentID) + + body, err := client.call(ctx, "bookmarks.add", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_add_bookmark_test.go b/server/internal/platformtools/slack/tool_add_bookmark_test.go new file mode 100644 index 0000000000..bda3658d93 --- /dev/null +++ b/server/internal/platformtools/slack/tool_add_bookmark_test.go @@ -0,0 +1,87 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAddBookmarkTool_PassesOptionalFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"bookmark":{"id":"Bk1"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewAddBookmarkTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callAddBookmark, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "title":"Docs", + "type":"link", + "link":"https://example.com", + "emoji":":memo:", + "entity_id":"E123", + "parent_id":"Bk0" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/bookmarks.add", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel_id")) + require.Equal(t, "Docs", requestPayload.Get("title")) + require.Equal(t, "link", requestPayload.Get("type")) + require.Equal(t, "https://example.com", requestPayload.Get("link")) + require.Equal(t, ":memo:", requestPayload.Get("emoji")) + require.Equal(t, "E123", requestPayload.Get("entity_id")) + require.Equal(t, "Bk0", requestPayload.Get("parent_id")) + require.JSONEq(t, `{"ok":true,"bookmark":{"id":"Bk1"}}`, out.String()) +} + +func TestAddBookmarkTool_RequiresFields(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewAddBookmarkTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callAddBookmark, + } + + cases := []struct { + name string + payload string + field string + }{ + {"missing channel", `{"title":"T","type":"link","link":"https://x"}`, "channel_id"}, + {"missing title", `{"channel_id":"C","type":"link","link":"https://x"}`, "title"}, + {"missing type", `{"channel_id":"C","title":"T","link":"https://x"}`, "type"}, + {"missing link", `{"channel_id":"C","title":"T","type":"link"}`, "link"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(tc.payload), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, tc.field) + }) + } +} diff --git a/server/internal/platformtools/slack/tool_add_reminder.go b/server/internal/platformtools/slack/tool_add_reminder.go new file mode 100644 index 0000000000..e8fddb2332 --- /dev/null +++ b/server/internal/platformtools/slack/tool_add_reminder.go @@ -0,0 +1,83 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameAddReminder = "platform_slack_add_reminder" + +type addReminderRecurrence struct { + Frequency string `json:"frequency" jsonschema:"Recurrence frequency. Slack accepts daily, weekly, monthly, or yearly."` + Weekdays []string `json:"weekdays,omitempty" jsonschema:"Days of the week when frequency is weekly. Slack accepts lowercase names like monday, tuesday."` +} + +type addReminderInput struct { + Text string `json:"text" jsonschema:"Reminder content to deliver to the user."` + Time string `json:"time" jsonschema:"When Slack should fire the reminder. Accepts a Unix timestamp (in seconds), a number of seconds from now, or a natural-language phrase like \"in 5 minutes\" or \"every Thursday\"."` + User *string `json:"user,omitempty" jsonschema:"Slack user ID who receives the reminder. Defaults to the authenticated user. Slack has restricted setting reminders for other users via API."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team identifier, required only when calling with an org-level token."` + Recurrence *addReminderRecurrence `json:"recurrence,omitempty" jsonschema:"Recurrence rule. Provide frequency (daily, weekly, monthly, yearly) and, for weekly frequency, weekdays."` +} + +func NewAddReminderTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "add_reminder", + Name: toolNameAddReminder, + Description: "Create a Slack reminder via reminders.add. Requires a user token with reminders:write (SLACK_USER_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[addReminderInput]( + core.WithPropertyEnum("frequency", "daily", "weekly", "monthly", "yearly"), + ), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callAddReminder, + } +} + +func callAddReminder(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input addReminderInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + text, err := requireString("text", input.Text) + if err != nil { + return err + } + timeValue, err := requireString("time", input.Time) + if err != nil { + return err + } + + request := map[string]any{ + "text": text, + "time": timeValue, + } + setOptionalString(request, "user", input.User) + setOptionalString(request, "team_id", input.TeamID) + if input.Recurrence != nil { + request["recurrence"] = input.Recurrence + } + + body, err := client.call(ctx, "reminders.add", request, tokenRequireUser, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_add_reminder_test.go b/server/internal/platformtools/slack/tool_add_reminder_test.go new file mode 100644 index 0000000000..b41de447b5 --- /dev/null +++ b/server/internal/platformtools/slack/tool_add_reminder_test.go @@ -0,0 +1,99 @@ +package slack + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/speakeasy-api/gram/server/internal/toolconfig" + "github.com/stretchr/testify/require" +) + +func TestAddReminderTool_RequiresUserToken(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewAddReminderTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callAddReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackBotTokenEnvVar: "xoxb-bot-only"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{"text":"ping","time":"in 5 minutes"}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, slackUserTokenEnvVar) +} + +func TestAddReminderTool_CallsRemindersAddWithRecurrence(t *testing.T) { + t.Parallel() + + var requestPath string + var authorization string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + authorization = r.Header.Get("Authorization") + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"reminder":{"id":"Rm123","text":"ship the PR"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewAddReminderTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callAddReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{ + "text":"ship the PR", + "time":"every Thursday at 9am", + "recurrence":{"frequency":"weekly","weekdays":["thursday"]} + }`), io.Discard) + require.NoError(t, err) + require.Equal(t, "/reminders.add", requestPath) + require.Equal(t, "Bearer xoxp-user-token", authorization) + require.Equal(t, "ship the PR", requestPayload.Get("text")) + require.Equal(t, "every Thursday at 9am", requestPayload.Get("time")) + + var recurrence map[string]any + require.NoError(t, json.Unmarshal([]byte(requestPayload.Get("recurrence")), &recurrence)) + require.Equal(t, "weekly", recurrence["frequency"]) + require.Equal(t, []any{"thursday"}, recurrence["weekdays"]) +} + +func TestAddReminderTool_RequiresTextAndTime(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewAddReminderTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callAddReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{"time":"in 5 minutes"}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "text") +} diff --git a/server/internal/platformtools/slack/tool_archive_channel.go b/server/internal/platformtools/slack/tool_archive_channel.go new file mode 100644 index 0000000000..503ba83f20 --- /dev/null +++ b/server/internal/platformtools/slack/tool_archive_channel.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameArchiveChannel = "platform_slack_archive_channel" + +type archiveChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to archive."` +} + +func NewArchiveChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "archive_channel", + Name: toolNameArchiveChannel, + Description: "Archive a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[archiveChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callArchiveChannel, + } +} + +func callArchiveChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input archiveChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + + body, err := client.call(ctx, "conversations.archive", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_archive_channel_test.go b/server/internal/platformtools/slack/tool_archive_channel_test.go new file mode 100644 index 0000000000..69aba0c2f0 --- /dev/null +++ b/server/internal/platformtools/slack/tool_archive_channel_test.go @@ -0,0 +1,42 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestArchiveChannelTool_PostsToConversationsArchive(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewArchiveChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callArchiveChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"channel_id":"C123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.archive", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) +} diff --git a/server/internal/platformtools/slack/tool_complete_reminder.go b/server/internal/platformtools/slack/tool_complete_reminder.go new file mode 100644 index 0000000000..87e99136c3 --- /dev/null +++ b/server/internal/platformtools/slack/tool_complete_reminder.go @@ -0,0 +1,64 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameCompleteReminder = "platform_slack_complete_reminder" + +type completeReminderInput struct { + Reminder string `json:"reminder" jsonschema:"Slack reminder ID to mark complete (e.g. Rm12345678)."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team identifier, required only when calling with an org-level token."` +} + +func NewCompleteReminderTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "complete_reminder", + Name: toolNameCompleteReminder, + Description: "Mark a Slack reminder complete via reminders.complete. Requires a user token with reminders:write (SLACK_USER_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[completeReminderInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callCompleteReminder, + } +} + +func callCompleteReminder(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input completeReminderInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + reminder, err := requireString("reminder", input.Reminder) + if err != nil { + return err + } + + request := map[string]any{ + "reminder": reminder, + } + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "reminders.complete", request, tokenRequireUser, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_complete_reminder_test.go b/server/internal/platformtools/slack/tool_complete_reminder_test.go new file mode 100644 index 0000000000..c8af2b8154 --- /dev/null +++ b/server/internal/platformtools/slack/tool_complete_reminder_test.go @@ -0,0 +1,66 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/speakeasy-api/gram/server/internal/toolconfig" + "github.com/stretchr/testify/require" +) + +func TestCompleteReminderTool_CallsRemindersCompleteWithUserToken(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewCompleteReminderTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callCompleteReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{"reminder":"Rm12345678"}`), io.Discard) + require.NoError(t, err) + require.Equal(t, "/reminders.complete", requestPath) + require.Equal(t, "Rm12345678", requestPayload.Get("reminder")) +} + +func TestCompleteReminderTool_RequiresReminderID(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewCompleteReminderTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callCompleteReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "reminder") +} diff --git a/server/internal/platformtools/slack/tool_create_canvas.go b/server/internal/platformtools/slack/tool_create_canvas.go new file mode 100644 index 0000000000..244d1a55c7 --- /dev/null +++ b/server/internal/platformtools/slack/tool_create_canvas.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameCreateCanvas = "platform_slack_create_canvas" + +type createCanvasInput struct { + Title *string `json:"title,omitempty" jsonschema:"Optional canvas title."` + DocumentContent *canvasDocumentContent `json:"document_content,omitempty" jsonschema:"Optional initial canvas content."` + ChannelID *string `json:"channel_id,omitempty" jsonschema:"Optional conversation ID to associate the canvas with at creation time."` +} + +func NewCreateCanvasTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "create_canvas", + Name: toolNameCreateCanvas, + Description: "Create a standalone Slack canvas using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[createCanvasInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callCreateCanvas, + } +} + +func callCreateCanvas(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input createCanvasInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "title", input.Title) + setOptionalString(request, "channel_id", input.ChannelID) + if input.DocumentContent != nil { + request["document_content"] = input.DocumentContent + } + + body, err := client.call(ctx, "canvases.create", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_create_canvas_test.go b/server/internal/platformtools/slack/tool_create_canvas_test.go new file mode 100644 index 0000000000..b6b2302930 --- /dev/null +++ b/server/internal/platformtools/slack/tool_create_canvas_test.go @@ -0,0 +1,83 @@ +package slack + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateCanvasTool_PassesOptionalFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"canvas_id":"F0123"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewCreateCanvasTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callCreateCanvas, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "title":"Quarterly Plan", + "channel_id":"C100", + "document_content":{"type":"markdown","markdown":"# Plan\n\nIntro."} + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/canvases.create", requestPath) + require.Equal(t, "Quarterly Plan", requestPayload.Get("title")) + require.Equal(t, "C100", requestPayload.Get("channel_id")) + + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(requestPayload.Get("document_content")), &doc)) + require.Equal(t, "markdown", doc["type"]) + require.Equal(t, "# Plan\n\nIntro.", doc["markdown"]) + require.JSONEq(t, `{"ok":true,"canvas_id":"F0123"}`, out.String()) +} + +func TestCreateCanvasTool_AllowsEmptyPayload(t *testing.T) { + t.Parallel() + + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"canvas_id":"F0999"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewCreateCanvasTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callCreateCanvas, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &out) + require.NoError(t, err) + + require.Empty(t, requestPayload.Get("title")) + require.Empty(t, requestPayload.Get("channel_id")) + require.Empty(t, requestPayload.Get("document_content")) +} diff --git a/server/internal/platformtools/slack/tool_create_channel.go b/server/internal/platformtools/slack/tool_create_channel.go new file mode 100644 index 0000000000..44ed9e6fa5 --- /dev/null +++ b/server/internal/platformtools/slack/tool_create_channel.go @@ -0,0 +1,66 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameCreateChannel = "platform_slack_create_channel" + +type createChannelInput struct { + Name string `json:"name" jsonschema:"Channel name. Slack lowercases the name and rejects characters outside letters, numbers, hyphens, and underscores; max 80 characters."` + IsPrivate *bool `json:"is_private,omitempty" jsonschema:"Create a private channel instead of a public one."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Workspace ID to create the channel in. Required only when using an org-level token."` +} + +func NewCreateChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "create_channel", + Name: toolNameCreateChannel, + Description: "Create a new Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[createChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callCreateChannel, + } +} + +func callCreateChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input createChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + name, err := requireString("name", input.Name) + if err != nil { + return err + } + + request := map[string]any{ + "name": name, + } + setOptionalBool(request, "is_private", input.IsPrivate) + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "conversations.create", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_create_channel_canvas.go b/server/internal/platformtools/slack/tool_create_channel_canvas.go new file mode 100644 index 0000000000..70bbe182ef --- /dev/null +++ b/server/internal/platformtools/slack/tool_create_channel_canvas.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameCreateChannelCanvas = "platform_slack_create_channel_canvas" + +type createChannelCanvasInput struct { + ChannelID string `json:"channel_id" jsonschema:"Conversation ID to attach the canvas to."` + Title *string `json:"title,omitempty" jsonschema:"Optional canvas title."` + DocumentContent *canvasDocumentContent `json:"document_content,omitempty" jsonschema:"Optional initial canvas content."` +} + +func NewCreateChannelCanvasTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "create_channel_canvas", + Name: toolNameCreateChannelCanvas, + Description: "Create a Slack canvas tied to a channel via conversations.canvases.create using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[createChannelCanvasInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callCreateChannelCanvas, + } +} + +func callCreateChannelCanvas(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input createChannelCanvasInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel_id": channelID, + } + setOptionalString(request, "title", input.Title) + if input.DocumentContent != nil { + request["document_content"] = input.DocumentContent + } + + body, err := client.call(ctx, "conversations.canvases.create", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_create_channel_canvas_test.go b/server/internal/platformtools/slack/tool_create_channel_canvas_test.go new file mode 100644 index 0000000000..9d87e620fe --- /dev/null +++ b/server/internal/platformtools/slack/tool_create_channel_canvas_test.go @@ -0,0 +1,67 @@ +package slack + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateChannelCanvasTool_PassesOptionalFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"canvas_id":"F1"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewCreateChannelCanvasTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callCreateChannelCanvas, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C100", + "title":"Channel doc", + "document_content":{"type":"markdown","markdown":"body"} + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.canvases.create", requestPath) + require.Equal(t, "C100", requestPayload.Get("channel_id")) + require.Equal(t, "Channel doc", requestPayload.Get("title")) + + var doc map[string]any + require.NoError(t, json.Unmarshal([]byte(requestPayload.Get("document_content")), &doc)) + require.Equal(t, "markdown", doc["type"]) + require.Equal(t, "body", doc["markdown"]) +} + +func TestCreateChannelCanvasTool_RequiresChannelID(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewCreateChannelCanvasTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callCreateChannelCanvas, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "channel_id") +} diff --git a/server/internal/platformtools/slack/tool_create_channel_test.go b/server/internal/platformtools/slack/tool_create_channel_test.go new file mode 100644 index 0000000000..5a024e024b --- /dev/null +++ b/server/internal/platformtools/slack/tool_create_channel_test.go @@ -0,0 +1,48 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateChannelTool_PostsToConversationsCreate(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":{"id":"C123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewCreateChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callCreateChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "name":"project-alpha", + "is_private":true, + "team_id":"T1" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.create", requestPath) + require.Equal(t, "project-alpha", requestPayload.Get("name")) + require.Equal(t, "true", requestPayload.Get("is_private")) + require.Equal(t, "T1", requestPayload.Get("team_id")) +} diff --git a/server/internal/platformtools/slack/tool_delete_canvas.go b/server/internal/platformtools/slack/tool_delete_canvas.go new file mode 100644 index 0000000000..0e7947d62d --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_canvas.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameDeleteCanvas = "platform_slack_delete_canvas" + +type deleteCanvasInput struct { + CanvasID string `json:"canvas_id" jsonschema:"ID of the canvas to delete."` +} + +func NewDeleteCanvasTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "delete_canvas", + Name: toolNameDeleteCanvas, + Description: "Delete a Slack canvas using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[deleteCanvasInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callDeleteCanvas, + } +} + +func callDeleteCanvas(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input deleteCanvasInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + canvasID, err := requireString("canvas_id", input.CanvasID) + if err != nil { + return err + } + + request := map[string]any{ + "canvas_id": canvasID, + } + + body, err := client.call(ctx, "canvases.delete", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_delete_canvas_test.go b/server/internal/platformtools/slack/tool_delete_canvas_test.go new file mode 100644 index 0000000000..92d0a858a6 --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_canvas_test.go @@ -0,0 +1,56 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeleteCanvasTool_SendsCanvasID(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewDeleteCanvasTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callDeleteCanvas, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"canvas_id":"F42"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/canvases.delete", requestPath) + require.Equal(t, "F42", requestPayload.Get("canvas_id")) +} + +func TestDeleteCanvasTool_RequiresCanvasID(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewDeleteCanvasTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callDeleteCanvas, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "canvas_id") +} diff --git a/server/internal/platformtools/slack/tool_delete_file.go b/server/internal/platformtools/slack/tool_delete_file.go new file mode 100644 index 0000000000..38b351f144 --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_file.go @@ -0,0 +1,58 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameDeleteFile = "platform_slack_delete_file" + +type deleteFileInput struct { + File string `json:"file" jsonschema:"Slack file ID to delete."` +} + +func NewDeleteFileTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "delete_file", + Name: toolNameDeleteFile, + Description: "Delete a Slack file via files.delete. Requires the files:write scope on the server's Slack token (SLACK_BOT_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[deleteFileInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callDeleteFile, + } +} + +func callDeleteFile(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input deleteFileInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + file, err := requireString("file", input.File) + if err != nil { + return err + } + + body, err := client.call(ctx, "files.delete", map[string]any{"file": file}, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_delete_file_test.go b/server/internal/platformtools/slack/tool_delete_file_test.go new file mode 100644 index 0000000000..17ca122e38 --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_file_test.go @@ -0,0 +1,58 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeleteFileTool_PostsFileID(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewDeleteFileTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callDeleteFile, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"file":"F123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/files.delete", requestPath) + require.Equal(t, "F123", requestPayload.Get("file")) + require.JSONEq(t, `{"ok":true}`, out.String()) +} + +func TestDeleteFileTool_RejectsMissingFile(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewDeleteFileTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callDeleteFile, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "file is required") +} diff --git a/server/internal/platformtools/slack/tool_delete_message.go b/server/internal/platformtools/slack/tool_delete_message.go new file mode 100644 index 0000000000..3e1e7a50fb --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_message.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameDeleteMessage = "platform_slack_delete_message" + +type deleteMessageInput struct { + ChannelID string `json:"channel_id" jsonschema:"Channel containing the message to delete."` + TS string `json:"ts" jsonschema:"Timestamp of the message to delete."` +} + +func NewChatDeleteTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "delete_message", + Name: toolNameDeleteMessage, + Description: "Delete an existing Slack message using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[deleteMessageInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callDeleteMessage, + } +} + +func callDeleteMessage(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input deleteMessageInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + ts, err := requireString("ts", input.TS) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "ts": ts, + } + + body, err := client.call(ctx, "chat.delete", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_delete_message_test.go b/server/internal/platformtools/slack/tool_delete_message_test.go new file mode 100644 index 0000000000..effc4cbbf6 --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_message_test.go @@ -0,0 +1,47 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeleteMessageTool_PostsToChatDelete(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":"C123","ts":"123.456"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatDeleteTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callDeleteMessage, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "ts":"123.456" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.delete", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "123.456", requestPayload.Get("ts")) + require.JSONEq(t, `{"ok":true,"channel":"C123","ts":"123.456"}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_delete_reminder.go b/server/internal/platformtools/slack/tool_delete_reminder.go new file mode 100644 index 0000000000..b6339c918b --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_reminder.go @@ -0,0 +1,64 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameDeleteReminder = "platform_slack_delete_reminder" + +type deleteReminderInput struct { + Reminder string `json:"reminder" jsonschema:"Slack reminder ID to delete (e.g. Rm12345678)."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team identifier, required only when calling with an org-level token."` +} + +func NewDeleteReminderTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "delete_reminder", + Name: toolNameDeleteReminder, + Description: "Delete a Slack reminder via reminders.delete. Requires a user token with reminders:write (SLACK_USER_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[deleteReminderInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callDeleteReminder, + } +} + +func callDeleteReminder(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input deleteReminderInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + reminder, err := requireString("reminder", input.Reminder) + if err != nil { + return err + } + + request := map[string]any{ + "reminder": reminder, + } + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "reminders.delete", request, tokenRequireUser, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_delete_reminder_test.go b/server/internal/platformtools/slack/tool_delete_reminder_test.go new file mode 100644 index 0000000000..741c968c3d --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_reminder_test.go @@ -0,0 +1,48 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/speakeasy-api/gram/server/internal/toolconfig" + "github.com/stretchr/testify/require" +) + +func TestDeleteReminderTool_CallsRemindersDeleteWithUserToken(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewDeleteReminderTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callDeleteReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{"reminder":"Rm12345678","team_id":"T999"}`), io.Discard) + require.NoError(t, err) + require.Equal(t, "/reminders.delete", requestPath) + require.Equal(t, "Rm12345678", requestPayload.Get("reminder")) + require.Equal(t, "T999", requestPayload.Get("team_id")) +} diff --git a/server/internal/platformtools/slack/tool_delete_scheduled_message.go b/server/internal/platformtools/slack/tool_delete_scheduled_message.go new file mode 100644 index 0000000000..fa18040397 --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_scheduled_message.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameDeleteScheduledMessage = "platform_slack_delete_scheduled_message" + +type deleteScheduledMessageInput struct { + ChannelID string `json:"channel_id" jsonschema:"Channel the scheduled message is queued for."` + ScheduledMessageID string `json:"scheduled_message_id" jsonschema:"scheduled_message_id returned from chat.scheduleMessage."` +} + +func NewChatDeleteScheduledMessageTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "delete_scheduled_message", + Name: toolNameDeleteScheduledMessage, + Description: "Cancel a Slack scheduled message before it is sent, using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[deleteScheduledMessageInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callDeleteScheduledMessage, + } +} + +func callDeleteScheduledMessage(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input deleteScheduledMessageInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + scheduledMessageID, err := requireString("scheduled_message_id", input.ScheduledMessageID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "scheduled_message_id": scheduledMessageID, + } + + body, err := client.call(ctx, "chat.deleteScheduledMessage", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_delete_scheduled_message_test.go b/server/internal/platformtools/slack/tool_delete_scheduled_message_test.go new file mode 100644 index 0000000000..5a683a3746 --- /dev/null +++ b/server/internal/platformtools/slack/tool_delete_scheduled_message_test.go @@ -0,0 +1,47 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeleteScheduledMessageTool_PostsToChatDeleteScheduledMessage(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatDeleteScheduledMessageTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callDeleteScheduledMessage, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "scheduled_message_id":"Q1298393284" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.deleteScheduledMessage", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "Q1298393284", requestPayload.Get("scheduled_message_id")) + require.JSONEq(t, `{"ok":true}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_edit_bookmark.go b/server/internal/platformtools/slack/tool_edit_bookmark.go new file mode 100644 index 0000000000..c47ba5172f --- /dev/null +++ b/server/internal/platformtools/slack/tool_edit_bookmark.go @@ -0,0 +1,74 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameEditBookmark = "platform_slack_edit_bookmark" + +type editBookmarkInput struct { + BookmarkID string `json:"bookmark_id" jsonschema:"ID of the bookmark to edit."` + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID owning the bookmark."` + Title *string `json:"title,omitempty" jsonschema:"Optional new title for the bookmark."` + Link *string `json:"link,omitempty" jsonschema:"Optional new URL for the bookmark."` + Emoji *string `json:"emoji,omitempty" jsonschema:"Optional new emoji tag (e.g. \":memo:\") for the bookmark."` +} + +func NewEditBookmarkTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "edit_bookmark", + Name: toolNameEditBookmark, + Description: "Edit an existing Slack channel bookmark using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[editBookmarkInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callEditBookmark, + } +} + +func callEditBookmark(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input editBookmarkInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + bookmarkID, err := requireString("bookmark_id", input.BookmarkID) + if err != nil { + return err + } + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "bookmark_id": bookmarkID, + "channel_id": channelID, + } + setOptionalString(request, "title", input.Title) + setOptionalString(request, "link", input.Link) + setOptionalString(request, "emoji", input.Emoji) + + body, err := client.call(ctx, "bookmarks.edit", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_edit_bookmark_test.go b/server/internal/platformtools/slack/tool_edit_bookmark_test.go new file mode 100644 index 0000000000..338a156954 --- /dev/null +++ b/server/internal/platformtools/slack/tool_edit_bookmark_test.go @@ -0,0 +1,80 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditBookmarkTool_PassesOptionalFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"bookmark":{"id":"Bk1"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewEditBookmarkTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callEditBookmark, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "bookmark_id":"Bk1", + "channel_id":"C123", + "title":"New", + "link":"https://example.com/new", + "emoji":":memo:" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/bookmarks.edit", requestPath) + require.Equal(t, "Bk1", requestPayload.Get("bookmark_id")) + require.Equal(t, "C123", requestPayload.Get("channel_id")) + require.Equal(t, "New", requestPayload.Get("title")) + require.Equal(t, "https://example.com/new", requestPayload.Get("link")) + require.Equal(t, ":memo:", requestPayload.Get("emoji")) +} + +func TestEditBookmarkTool_RequiresFields(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewEditBookmarkTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callEditBookmark, + } + + cases := []struct { + name string + payload string + field string + }{ + {"missing bookmark", `{"channel_id":"C"}`, "bookmark_id"}, + {"missing channel", `{"bookmark_id":"Bk1"}`, "channel_id"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(tc.payload), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, tc.field) + }) + } +} diff --git a/server/internal/platformtools/slack/tool_edit_canvas.go b/server/internal/platformtools/slack/tool_edit_canvas.go new file mode 100644 index 0000000000..932b716adf --- /dev/null +++ b/server/internal/platformtools/slack/tool_edit_canvas.go @@ -0,0 +1,73 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameEditCanvas = "platform_slack_edit_canvas" + +type canvasEditChange struct { + Operation string `json:"operation" jsonschema:"Edit operation. One of insert_after, insert_before, insert_at_start, insert_at_end, replace, delete."` + SectionID *string `json:"section_id,omitempty" jsonschema:"Target section ID. Required for insert_after, insert_before, replace and delete."` + DocumentContent *canvasDocumentContent `json:"document_content,omitempty" jsonschema:"Content to insert or use as replacement. Omitted for delete."` +} + +type editCanvasInput struct { + CanvasID string `json:"canvas_id" jsonschema:"ID of the canvas to edit."` + Change canvasEditChange `json:"change" jsonschema:"The edit operation to apply. Slack canvases.edit accepts a single change per call; issue separate tool calls for multi-step edits."` +} + +func NewEditCanvasTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "edit_canvas", + Name: toolNameEditCanvas, + Description: "Apply edit operations to a Slack canvas using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[editCanvasInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callEditCanvas, + } +} + +func callEditCanvas(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input editCanvasInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + canvasID, err := requireString("canvas_id", input.CanvasID) + if err != nil { + return err + } + if _, err := requireString("change.operation", input.Change.Operation); err != nil { + return err + } + + request := map[string]any{ + "canvas_id": canvasID, + "changes": []canvasEditChange{input.Change}, + } + + body, err := client.call(ctx, "canvases.edit", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_edit_canvas_test.go b/server/internal/platformtools/slack/tool_edit_canvas_test.go new file mode 100644 index 0000000000..23351af616 --- /dev/null +++ b/server/internal/platformtools/slack/tool_edit_canvas_test.go @@ -0,0 +1,82 @@ +package slack + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditCanvasTool_SendsChanges(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewEditCanvasTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callEditCanvas, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "canvas_id":"F1", + "change":{"operation":"insert_at_end","document_content":{"type":"markdown","markdown":"Appendix"}} + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/canvases.edit", requestPath) + require.Equal(t, "F1", requestPayload.Get("canvas_id")) + + var changes []map[string]any + require.NoError(t, json.Unmarshal([]byte(requestPayload.Get("changes")), &changes)) + require.Len(t, changes, 1) + require.Equal(t, "insert_at_end", changes[0]["operation"]) + doc, ok := changes[0]["document_content"].(map[string]any) + require.True(t, ok) + require.Equal(t, "Appendix", doc["markdown"]) +} + +func TestEditCanvasTool_RequiresCanvasID(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewEditCanvasTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callEditCanvas, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"change":{"operation":"insert_at_end"}}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "canvas_id") +} + +func TestEditCanvasTool_RequiresOperation(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewEditCanvasTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callEditCanvas, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"canvas_id":"F1"}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "change.operation") +} diff --git a/server/internal/platformtools/slack/tool_get_channel_info.go b/server/internal/platformtools/slack/tool_get_channel_info.go new file mode 100644 index 0000000000..e7948ed9c9 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_channel_info.go @@ -0,0 +1,66 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetChannelInfo = "platform_slack_get_channel_info" + +type getChannelInfoInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to inspect."` + IncludeLocale *bool `json:"include_locale,omitempty" jsonschema:"Include the conversation locale in the response."` + IncludeNumMembers *bool `json:"include_num_members,omitempty" jsonschema:"Include the member count for the conversation."` +} + +func NewGetChannelInfoTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_channel_info", + Name: toolNameGetChannelInfo, + Description: "Get metadata for a Slack conversation (channel, DM, or MPIM) using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getChannelInfoInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetChannelInfo, + } +} + +func callGetChannelInfo(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getChannelInfoInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + setOptionalBool(request, "include_locale", input.IncludeLocale) + setOptionalBool(request, "include_num_members", input.IncludeNumMembers) + + body, err := client.call(ctx, "conversations.info", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_channel_info_test.go b/server/internal/platformtools/slack/tool_get_channel_info_test.go new file mode 100644 index 0000000000..9d6846cdec --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_channel_info_test.go @@ -0,0 +1,49 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetChannelInfoTool_PostsToConversationsInfo(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":{"id":"C123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetChannelInfoTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetChannelInfo, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "include_locale":true, + "include_num_members":true + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.info", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "true", requestPayload.Get("include_locale")) + require.Equal(t, "true", requestPayload.Get("include_num_members")) + require.JSONEq(t, `{"ok":true,"channel":{"id":"C123"}}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_get_file_info.go b/server/internal/platformtools/slack/tool_get_file_info.go new file mode 100644 index 0000000000..32e9627b9b --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_file_info.go @@ -0,0 +1,70 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetFileInfo = "platform_slack_get_file_info" + +type getFileInfoInput struct { + File string `json:"file" jsonschema:"Slack file ID to inspect."` + Cursor *string `json:"cursor,omitempty" jsonschema:"Pagination cursor for paging through file comments."` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of comments to return per page."` + Page *int `json:"page,omitempty" jsonschema:"1-indexed page number when paginating comments via page/count."` + Count *int `json:"count,omitempty" jsonschema:"Number of comments per page when paginating via page/count. Defaults to 100."` +} + +func NewGetFileInfoTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_file_info", + Name: toolNameGetFileInfo, + Description: "Fetch metadata and comment history for a Slack file via files.info. Requires the files:read scope on the server's Slack token (SLACK_BOT_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[getFileInfoInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetFileInfo, + } +} + +func callGetFileInfo(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getFileInfoInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + file, err := requireString("file", input.File) + if err != nil { + return err + } + + request := map[string]any{ + "file": file, + } + setOptionalString(request, "cursor", input.Cursor) + setOptionalInt(request, "limit", input.Limit) + setOptionalInt(request, "page", input.Page) + setOptionalInt(request, "count", input.Count) + + body, err := client.call(ctx, "files.info", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_file_info_test.go b/server/internal/platformtools/slack/tool_get_file_info_test.go new file mode 100644 index 0000000000..8d46dc53a1 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_file_info_test.go @@ -0,0 +1,53 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetFileInfoTool_PassesPaginationFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"file":{"id":"F123","name":"notes.txt"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetFileInfoTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetFileInfo, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "file":"F123", + "cursor":"abc", + "limit":50, + "page":2, + "count":25 + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/files.info", requestPath) + require.Equal(t, "F123", requestPayload.Get("file")) + require.Equal(t, "abc", requestPayload.Get("cursor")) + require.Equal(t, "50", requestPayload.Get("limit")) + require.Equal(t, "2", requestPayload.Get("page")) + require.Equal(t, "25", requestPayload.Get("count")) + require.JSONEq(t, `{"ok":true,"file":{"id":"F123","name":"notes.txt"}}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_get_permalink.go b/server/internal/platformtools/slack/tool_get_permalink.go new file mode 100644 index 0000000000..e6028025b5 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_permalink.go @@ -0,0 +1,71 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetPermalink = "platform_slack_get_permalink" + +type getPermalinkInput struct { + ChannelID string `json:"channel_id" jsonschema:"Conversation containing the target message."` + MessageTS string `json:"message_ts" jsonschema:"Timestamp of the message to get a permalink for."` +} + +func NewChatGetPermalinkTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_permalink", + Name: toolNameGetPermalink, + Description: "Retrieve a permanent link to a Slack message using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getPermalinkInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetPermalink, + } +} + +func callGetPermalink(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getPermalinkInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + messageTS, err := requireString("message_ts", input.MessageTS) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "message_ts": messageTS, + } + + // Slack documents chat.getPermalink as a GET endpoint, but it also accepts + // form-encoded POST requests like the rest of the Web API, which keeps the + // shared apiClient transport in play. + body, err := client.call(ctx, "chat.getPermalink", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_permalink_test.go b/server/internal/platformtools/slack/tool_get_permalink_test.go new file mode 100644 index 0000000000..0d8a068205 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_permalink_test.go @@ -0,0 +1,47 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetPermalinkTool_PostsToChatGetPermalink(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":"C123","permalink":"https://example.slack.com/archives/C123/p123456"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatGetPermalinkTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetPermalink, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "message_ts":"123.456" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.getPermalink", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "123.456", requestPayload.Get("message_ts")) + require.JSONEq(t, `{"ok":true,"channel":"C123","permalink":"https://example.slack.com/archives/C123/p123456"}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_get_reminder.go b/server/internal/platformtools/slack/tool_get_reminder.go new file mode 100644 index 0000000000..f55e2b8444 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_reminder.go @@ -0,0 +1,64 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetReminder = "platform_slack_get_reminder" + +type getReminderInput struct { + Reminder string `json:"reminder" jsonschema:"Slack reminder ID to fetch (e.g. Rm12345678)."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team identifier, required only when calling with an org-level token."` +} + +func NewGetReminderTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_reminder", + Name: toolNameGetReminder, + Description: "Fetch a Slack reminder via reminders.info. Requires a user token with reminders:read (SLACK_USER_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[getReminderInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetReminder, + } +} + +func callGetReminder(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getReminderInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + reminder, err := requireString("reminder", input.Reminder) + if err != nil { + return err + } + + request := map[string]any{ + "reminder": reminder, + } + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "reminders.info", request, tokenRequireUser, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_reminder_test.go b/server/internal/platformtools/slack/tool_get_reminder_test.go new file mode 100644 index 0000000000..93a0618c4a --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_reminder_test.go @@ -0,0 +1,47 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/speakeasy-api/gram/server/internal/toolconfig" + "github.com/stretchr/testify/require" +) + +func TestGetReminderTool_CallsRemindersInfoWithUserToken(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"reminder":{"id":"Rm12345678"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetReminderTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetReminder, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{"reminder":"Rm12345678"}`), io.Discard) + require.NoError(t, err) + require.Equal(t, "/reminders.info", requestPath) + require.Equal(t, "Rm12345678", requestPayload.Get("reminder")) +} diff --git a/server/internal/platformtools/slack/tool_get_team_dnd.go b/server/internal/platformtools/slack/tool_get_team_dnd.go new file mode 100644 index 0000000000..57ffd4eafc --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_team_dnd.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "fmt" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetTeamDnd = "platform_slack_get_team_dnd" + +type getTeamDndInput struct { + UserIDs []string `json:"user_ids" jsonschema:"Slack user IDs whose Do Not Disturb status should be returned."` +} + +func NewGetTeamDndTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_team_dnd", + Name: toolNameGetTeamDnd, + Description: "Get the Do Not Disturb status for multiple Slack users via dnd.teamInfo using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getTeamDndInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetTeamDnd, + } +} + +func callGetTeamDnd(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getTeamDndInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + if len(input.UserIDs) == 0 { + return fmt.Errorf("user_ids is required") + } + + request := map[string]any{ + "users": input.UserIDs, + } + + body, err := client.call(ctx, "dnd.teamInfo", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_team_dnd_test.go b/server/internal/platformtools/slack/tool_get_team_dnd_test.go new file mode 100644 index 0000000000..52536cda2d --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_team_dnd_test.go @@ -0,0 +1,57 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetTeamDndTool_JoinsUserIDs(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"users":{"U1":{"dnd_enabled":false},"U2":{"dnd_enabled":true}}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetTeamDndTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetTeamDnd, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"user_ids":["U1","U2"]}`), &out) + require.NoError(t, err) + + require.Equal(t, "/dnd.teamInfo", requestPath) + require.Equal(t, "U1,U2", requestPayload.Get("users")) + require.Contains(t, out.String(), `"U2"`) +} + +func TestGetTeamDndTool_RequiresUserIDs(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewGetTeamDndTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callGetTeamDnd, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "user_ids") +} diff --git a/server/internal/platformtools/slack/tool_get_team_info.go b/server/internal/platformtools/slack/tool_get_team_info.go new file mode 100644 index 0000000000..d7d0952890 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_team_info.go @@ -0,0 +1,58 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetTeamInfo = "platform_slack_get_team_info" + +type getTeamInfoInput struct { + Team *string `json:"team,omitempty" jsonschema:"Optional workspace identifier. Defaults to the team owning the calling token."` + Domain *string `json:"domain,omitempty" jsonschema:"Optional workspace domain to look up instead of team ID. Only resolves within the same enterprise."` +} + +func NewGetTeamInfoTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_team_info", + Name: toolNameGetTeamInfo, + Description: "Get information about a Slack workspace using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getTeamInfoInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetTeamInfo, + } +} + +func callGetTeamInfo(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getTeamInfoInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "team", input.Team) + setOptionalString(request, "domain", input.Domain) + + body, err := client.call(ctx, "team.info", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_team_info_test.go b/server/internal/platformtools/slack/tool_get_team_info_test.go new file mode 100644 index 0000000000..4178ea85d0 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_team_info_test.go @@ -0,0 +1,72 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetTeamInfoTool_PassesOptionalFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"team":{"id":"T123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetTeamInfoTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetTeamInfo, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "team":"T123", + "domain":"acme" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/team.info", requestPath) + require.Equal(t, "T123", requestPayload.Get("team")) + require.Equal(t, "acme", requestPayload.Get("domain")) +} + +func TestGetTeamInfoTool_AllowsEmptyPayload(t *testing.T) { + t.Parallel() + + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"team":{"id":"T123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetTeamInfoTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetTeamInfo, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.NoError(t, err) + require.Empty(t, requestPayload.Get("team")) + require.Empty(t, requestPayload.Get("domain")) +} diff --git a/server/internal/platformtools/slack/tool_get_user_dnd.go b/server/internal/platformtools/slack/tool_get_user_dnd.go new file mode 100644 index 0000000000..986451f61a --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_user_dnd.go @@ -0,0 +1,56 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetUserDnd = "platform_slack_get_user_dnd" + +type getUserDndInput struct { + UserID *string `json:"user_id,omitempty" jsonschema:"Slack user ID to inspect. Defaults to the authed user when omitted."` +} + +func NewGetUserDndTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_user_dnd", + Name: toolNameGetUserDnd, + Description: "Get a Slack user's Do Not Disturb status and schedule via dnd.info using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getUserDndInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetUserDnd, + } +} + +func callGetUserDnd(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getUserDndInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "user", input.UserID) + + body, err := client.call(ctx, "dnd.info", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_user_dnd_test.go b/server/internal/platformtools/slack/tool_get_user_dnd_test.go new file mode 100644 index 0000000000..c635e7303d --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_user_dnd_test.go @@ -0,0 +1,43 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetUserDndTool_PassesUser(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"dnd_enabled":true,"next_dnd_start_ts":1700000000,"next_dnd_end_ts":1700003600}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetUserDndTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetUserDnd, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"user_id":"U123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/dnd.info", requestPath) + require.Equal(t, "U123", requestPayload.Get("user")) + require.Contains(t, out.String(), `"dnd_enabled":true`) +} diff --git a/server/internal/platformtools/slack/tool_get_user_presence.go b/server/internal/platformtools/slack/tool_get_user_presence.go new file mode 100644 index 0000000000..c9df441e2b --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_user_presence.go @@ -0,0 +1,56 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetUserPresence = "platform_slack_get_user_presence" + +type getUserPresenceInput struct { + UserID *string `json:"user_id,omitempty" jsonschema:"Slack user ID to inspect. Defaults to the authed user when omitted."` +} + +func NewGetUserPresenceTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_user_presence", + Name: toolNameGetUserPresence, + Description: "Get the presence (active/away) of a Slack user via users.getPresence using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getUserPresenceInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetUserPresence, + } +} + +func callGetUserPresence(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getUserPresenceInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "user", input.UserID) + + body, err := client.call(ctx, "users.getPresence", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_user_presence_test.go b/server/internal/platformtools/slack/tool_get_user_presence_test.go new file mode 100644 index 0000000000..c73fda4dcd --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_user_presence_test.go @@ -0,0 +1,43 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetUserPresenceTool_PassesUser(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"presence":"active","online":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetUserPresenceTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetUserPresence, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"user_id":"U123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/users.getPresence", requestPath) + require.Equal(t, "U123", requestPayload.Get("user")) + require.Contains(t, out.String(), `"presence":"active"`) +} diff --git a/server/internal/platformtools/slack/tool_get_user_profile_fields.go b/server/internal/platformtools/slack/tool_get_user_profile_fields.go new file mode 100644 index 0000000000..c7dc46563c --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_user_profile_fields.go @@ -0,0 +1,58 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameGetUserProfileFields = "platform_slack_get_user_profile_fields" + +type getUserProfileFieldsInput struct { + UserID *string `json:"user_id,omitempty" jsonschema:"Slack user ID to inspect. Defaults to the authed user when omitted."` + IncludeLabels *bool `json:"include_labels,omitempty" jsonschema:"Include labels for the workspace's custom profile fields."` +} + +func NewGetUserProfileFieldsTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "get_user_profile_fields", + Name: toolNameGetUserProfileFields, + Description: "Get the full profile (including custom fields) for a Slack user via users.profile.get using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[getUserProfileFieldsInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callGetUserProfileFields, + } +} + +func callGetUserProfileFields(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input getUserProfileFieldsInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "user", input.UserID) + setOptionalBool(request, "include_labels", input.IncludeLabels) + + body, err := client.call(ctx, "users.profile.get", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_get_user_profile_fields_test.go b/server/internal/platformtools/slack/tool_get_user_profile_fields_test.go new file mode 100644 index 0000000000..61c4248d50 --- /dev/null +++ b/server/internal/platformtools/slack/tool_get_user_profile_fields_test.go @@ -0,0 +1,44 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetUserProfileFieldsTool_PassesParams(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"profile":{"real_name":"Alice","fields":{"Xf01":{"value":"engineering","alt":""}}}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewGetUserProfileFieldsTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callGetUserProfileFields, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"user_id":"U123","include_labels":true}`), &out) + require.NoError(t, err) + + require.Equal(t, "/users.profile.get", requestPath) + require.Equal(t, "U123", requestPayload.Get("user")) + require.Equal(t, "true", requestPayload.Get("include_labels")) + require.Contains(t, out.String(), `"fields"`) +} diff --git a/server/internal/platformtools/slack/tool_invite_to_channel.go b/server/internal/platformtools/slack/tool_invite_to_channel.go new file mode 100644 index 0000000000..2144e42753 --- /dev/null +++ b/server/internal/platformtools/slack/tool_invite_to_channel.go @@ -0,0 +1,78 @@ +package slack + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameInviteToChannel = "platform_slack_invite_to_channel" + +type inviteToChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to invite users into."` + Users []string `json:"users" jsonschema:"User IDs to invite. Up to 100 IDs are accepted in a single call."` + Force *bool `json:"force,omitempty" jsonschema:"When multiple users are supplied, continue inviting the valid ones and ignore invalid IDs instead of failing the whole call."` +} + +func NewInviteToChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "invite_to_channel", + Name: toolNameInviteToChannel, + Description: "Invite users to a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[inviteToChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callInviteToChannel, + } +} + +func callInviteToChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input inviteToChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + users := make([]string, 0, len(input.Users)) + for _, u := range input.Users { + if trimmed := strings.TrimSpace(u); trimmed != "" { + users = append(users, trimmed) + } + } + if len(users) == 0 { + return fmt.Errorf("users is required") + } + + request := map[string]any{ + "channel": channelID, + "users": users, + } + setOptionalBool(request, "force", input.Force) + + body, err := client.call(ctx, "conversations.invite", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_invite_to_channel_test.go b/server/internal/platformtools/slack/tool_invite_to_channel_test.go new file mode 100644 index 0000000000..b40e237aa1 --- /dev/null +++ b/server/internal/platformtools/slack/tool_invite_to_channel_test.go @@ -0,0 +1,48 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInviteToChannelTool_JoinsUsersList(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":{"id":"C123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewInviteToChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callInviteToChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "users":["U1","U2"], + "force":true + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.invite", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "U1,U2", requestPayload.Get("users")) + require.Equal(t, "true", requestPayload.Get("force")) +} diff --git a/server/internal/platformtools/slack/tool_join_channel.go b/server/internal/platformtools/slack/tool_join_channel.go new file mode 100644 index 0000000000..376f54563e --- /dev/null +++ b/server/internal/platformtools/slack/tool_join_channel.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameJoinChannel = "platform_slack_join_channel" + +type joinChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to join."` +} + +func NewJoinChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "join_channel", + Name: toolNameJoinChannel, + Description: "Join a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[joinChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callJoinChannel, + } +} + +func callJoinChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input joinChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + + body, err := client.call(ctx, "conversations.join", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_join_channel_test.go b/server/internal/platformtools/slack/tool_join_channel_test.go new file mode 100644 index 0000000000..43f243d49e --- /dev/null +++ b/server/internal/platformtools/slack/tool_join_channel_test.go @@ -0,0 +1,42 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJoinChannelTool_PostsToConversationsJoin(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":{"id":"C123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewJoinChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callJoinChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"channel_id":"C123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.join", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) +} diff --git a/server/internal/platformtools/slack/tool_leave_channel.go b/server/internal/platformtools/slack/tool_leave_channel.go new file mode 100644 index 0000000000..21921938d7 --- /dev/null +++ b/server/internal/platformtools/slack/tool_leave_channel.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameLeaveChannel = "platform_slack_leave_channel" + +type leaveChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to leave."` +} + +func NewLeaveChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "leave_channel", + Name: toolNameLeaveChannel, + Description: "Leave a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[leaveChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callLeaveChannel, + } +} + +func callLeaveChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input leaveChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + + body, err := client.call(ctx, "conversations.leave", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_leave_channel_test.go b/server/internal/platformtools/slack/tool_leave_channel_test.go new file mode 100644 index 0000000000..67997e0dd4 --- /dev/null +++ b/server/internal/platformtools/slack/tool_leave_channel_test.go @@ -0,0 +1,42 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLeaveChannelTool_PostsToConversationsLeave(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewLeaveChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callLeaveChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"channel_id":"C123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.leave", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) +} diff --git a/server/internal/platformtools/slack/tool_list_bookmarks.go b/server/internal/platformtools/slack/tool_list_bookmarks.go new file mode 100644 index 0000000000..02896d268a --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_bookmarks.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListBookmarks = "platform_slack_list_bookmarks" + +type listBookmarksInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID whose bookmarks should be listed."` +} + +func NewListBookmarksTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_bookmarks", + Name: toolNameListBookmarks, + Description: "List the bookmarks on a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listBookmarksInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListBookmarks, + } +} + +func callListBookmarks(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listBookmarksInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel_id": channelID, + } + + body, err := client.call(ctx, "bookmarks.list", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_bookmarks_test.go b/server/internal/platformtools/slack/tool_list_bookmarks_test.go new file mode 100644 index 0000000000..dfb45a7347 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_bookmarks_test.go @@ -0,0 +1,57 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListBookmarksTool_PostsToBookmarksList(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"bookmarks":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListBookmarksTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListBookmarks, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"channel_id":"C123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/bookmarks.list", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel_id")) + require.JSONEq(t, `{"ok":true,"bookmarks":[]}`, out.String()) +} + +func TestListBookmarksTool_RequiresChannel(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewListBookmarksTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callListBookmarks, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "channel_id") +} diff --git a/server/internal/platformtools/slack/tool_list_channel_members.go b/server/internal/platformtools/slack/tool_list_channel_members.go new file mode 100644 index 0000000000..5b77fedb1f --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_channel_members.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListChannelMembers = "platform_slack_list_channel_members" + +type listChannelMembersInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID whose members should be listed."` + Cursor *string `json:"cursor,omitempty" jsonschema:"Pagination cursor from a previous response."` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of members to return. Slack default is 100."` +} + +func NewListChannelMembersTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_channel_members", + Name: toolNameListChannelMembers, + Description: "List the members of a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listChannelMembersInput]( + core.WithPropertyNumberRange("limit", 1, 1000), + ), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListChannelMembers, + } +} + +func callListChannelMembers(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listChannelMembersInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + setOptionalString(request, "cursor", input.Cursor) + setOptionalInt(request, "limit", input.Limit) + + body, err := client.call(ctx, "conversations.members", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_channel_members_test.go b/server/internal/platformtools/slack/tool_list_channel_members_test.go new file mode 100644 index 0000000000..3ac99b9e4a --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_channel_members_test.go @@ -0,0 +1,49 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListChannelMembersTool_PostsToConversationsMembers(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"members":["U1","U2"]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListChannelMembersTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListChannelMembers, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "cursor":"abc", + "limit":50 + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.members", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "abc", requestPayload.Get("cursor")) + require.Equal(t, "50", requestPayload.Get("limit")) + require.JSONEq(t, `{"ok":true,"members":["U1","U2"]}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_list_files.go b/server/internal/platformtools/slack/tool_list_files.go new file mode 100644 index 0000000000..78d7472d6b --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_files.go @@ -0,0 +1,70 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListFiles = "platform_slack_list_files" + +type listFilesInput struct { + User *string `json:"user,omitempty" jsonschema:"Filter to files created by this Slack user ID."` + Channel *string `json:"channel,omitempty" jsonschema:"Filter to files shared into this Slack channel ID."` + TSFrom *string `json:"ts_from,omitempty" jsonschema:"Only return files created after this Slack timestamp (inclusive)."` + TSTo *string `json:"ts_to,omitempty" jsonschema:"Only return files created before this Slack timestamp (inclusive)."` + Types *string `json:"types,omitempty" jsonschema:"Comma-separated file type filter (for example: images,pdfs,snippets). Defaults to all."` + Page *int `json:"page,omitempty" jsonschema:"1-indexed page number to fetch. Defaults to 1."` + Count *int `json:"count,omitempty" jsonschema:"Number of files per page. Defaults to 100."` + ShowFilesHiddenByLimit *bool `json:"show_files_hidden_by_limit,omitempty" jsonschema:"Include truncated info for files hidden by the workspace file-history limit."` +} + +func NewListFilesTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_files", + Name: toolNameListFiles, + Description: "List Slack files via files.list with optional filters by user, channel, time range, and type. Requires the files:read scope on the server's Slack token (SLACK_BOT_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[listFilesInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListFiles, + } +} + +func callListFiles(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listFilesInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "user", input.User) + setOptionalString(request, "channel", input.Channel) + setOptionalString(request, "ts_from", input.TSFrom) + setOptionalString(request, "ts_to", input.TSTo) + setOptionalString(request, "types", input.Types) + setOptionalInt(request, "page", input.Page) + setOptionalInt(request, "count", input.Count) + setOptionalBool(request, "show_files_hidden_by_limit", input.ShowFilesHiddenByLimit) + + body, err := client.call(ctx, "files.list", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_files_test.go b/server/internal/platformtools/slack/tool_list_files_test.go new file mode 100644 index 0000000000..ea1831eb2c --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_files_test.go @@ -0,0 +1,86 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListFilesTool_PassesAllFilters(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"files":[{"id":"F1"}]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListFilesTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListFiles, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "user":"U1", + "channel":"C1", + "ts_from":"1700000000.000000", + "ts_to":"1700001000.000000", + "types":"images,pdfs", + "page":3, + "count":50, + "show_files_hidden_by_limit":true + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/files.list", requestPath) + require.Equal(t, "U1", requestPayload.Get("user")) + require.Equal(t, "C1", requestPayload.Get("channel")) + require.Equal(t, "1700000000.000000", requestPayload.Get("ts_from")) + require.Equal(t, "1700001000.000000", requestPayload.Get("ts_to")) + require.Equal(t, "images,pdfs", requestPayload.Get("types")) + require.Equal(t, "3", requestPayload.Get("page")) + require.Equal(t, "50", requestPayload.Get("count")) + require.Equal(t, "true", requestPayload.Get("show_files_hidden_by_limit")) + require.JSONEq(t, `{"ok":true,"files":[{"id":"F1"}]}`, out.String()) +} + +func TestListFilesTool_OmitsUnsetOptionals(t *testing.T) { + t.Parallel() + + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"files":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListFilesTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListFiles, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.NoError(t, err) + require.Empty(t, requestPayload.Get("user")) + require.Empty(t, requestPayload.Get("channel")) + require.Empty(t, requestPayload.Get("page")) +} diff --git a/server/internal/platformtools/slack/tool_list_pins.go b/server/internal/platformtools/slack/tool_list_pins.go new file mode 100644 index 0000000000..63e4498cc3 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_pins.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListPins = "platform_slack_list_pins" + +type listPinsInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID whose pinned items should be listed."` +} + +func NewListPinsTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_pins", + Name: toolNameListPins, + Description: "List the pinned items in a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listPinsInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListPins, + } +} + +func callListPins(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listPinsInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + + body, err := client.call(ctx, "pins.list", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_pins_test.go b/server/internal/platformtools/slack/tool_list_pins_test.go new file mode 100644 index 0000000000..3027acbf09 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_pins_test.go @@ -0,0 +1,57 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListPinsTool_PostsToPinsList(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"items":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListPinsTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListPins, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"channel_id":"C123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/pins.list", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.JSONEq(t, `{"ok":true,"items":[]}`, out.String()) +} + +func TestListPinsTool_RequiresChannel(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewListPinsTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callListPins, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "channel_id") +} diff --git a/server/internal/platformtools/slack/tool_list_reminders.go b/server/internal/platformtools/slack/tool_list_reminders.go new file mode 100644 index 0000000000..c8913e2eda --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_reminders.go @@ -0,0 +1,56 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListReminders = "platform_slack_list_reminders" + +type listRemindersInput struct { + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team identifier, required only when calling with an org-level token."` +} + +func NewListRemindersTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_reminders", + Name: toolNameListReminders, + Description: "List reminders for the authenticated Slack user via reminders.list. Requires a user token with reminders:read (SLACK_USER_TOKEN or SLACK_TOKEN).", + InputSchema: core.BuildInputSchema[listRemindersInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListReminders, + } +} + +func callListReminders(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listRemindersInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "reminders.list", request, tokenRequireUser, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_reminders_test.go b/server/internal/platformtools/slack/tool_list_reminders_test.go new file mode 100644 index 0000000000..9e2b424ca2 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_reminders_test.go @@ -0,0 +1,50 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/speakeasy-api/gram/server/internal/toolconfig" + "github.com/stretchr/testify/require" +) + +func TestListRemindersTool_CallsRemindersListWithUserToken(t *testing.T) { + t.Parallel() + + var requestPath string + var authorization string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + authorization = r.Header.Get("Authorization") + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"reminders":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListRemindersTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListReminders, + } + + err := tool.Call(t.Context(), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.CIEnvFrom(map[string]string{slackUserTokenEnvVar: "xoxp-user-token"}), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + OAuthToken: "", + GramEmail: "", + }, bytes.NewBufferString(`{"team_id":"T123"}`), io.Discard) + require.NoError(t, err) + require.Equal(t, "/reminders.list", requestPath) + require.Equal(t, "Bearer xoxp-user-token", authorization) + require.Equal(t, "T123", requestPayload.Get("team_id")) +} diff --git a/server/internal/platformtools/slack/tool_list_scheduled_messages.go b/server/internal/platformtools/slack/tool_list_scheduled_messages.go new file mode 100644 index 0000000000..9fde45f61e --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_scheduled_messages.go @@ -0,0 +1,66 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListScheduledMessages = "platform_slack_list_scheduled_messages" + +type listScheduledMessagesInput struct { + ChannelID *string `json:"channel_id,omitempty" jsonschema:"Optional channel to filter scheduled messages by."` + Cursor *string `json:"cursor,omitempty" jsonschema:"Pagination cursor from a previous chat.scheduledMessages.list response."` + Latest *string `json:"latest,omitempty" jsonschema:"Unix timestamp for the latest scheduled time to return."` + Oldest *string `json:"oldest,omitempty" jsonschema:"Unix timestamp for the earliest scheduled time to return."` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of scheduled messages to return."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team id to scope the listing to. Required when calling with an org-level token."` +} + +func NewChatListScheduledMessagesTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_scheduled_messages", + Name: toolNameListScheduledMessages, + Description: "List Slack scheduled messages queued by the calling app using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listScheduledMessagesInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListScheduledMessages, + } +} + +func callListScheduledMessages(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listScheduledMessagesInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "channel", input.ChannelID) + setOptionalString(request, "cursor", input.Cursor) + setOptionalString(request, "latest", input.Latest) + setOptionalString(request, "oldest", input.Oldest) + setOptionalString(request, "team_id", input.TeamID) + setOptionalInt(request, "limit", input.Limit) + + body, err := client.call(ctx, "chat.scheduledMessages.list", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_scheduled_messages_test.go b/server/internal/platformtools/slack/tool_list_scheduled_messages_test.go new file mode 100644 index 0000000000..e8df7a173a --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_scheduled_messages_test.go @@ -0,0 +1,55 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListScheduledMessagesTool_PostsToChatScheduledMessagesList(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"scheduled_messages":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatListScheduledMessagesTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListScheduledMessages, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "cursor":"dXNlcjpVMDYxTkZUVDI=", + "latest":"1700000000", + "oldest":"1600000000", + "limit":50, + "team_id":"T9999" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.scheduledMessages.list", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "dXNlcjpVMDYxTkZUVDI=", requestPayload.Get("cursor")) + require.Equal(t, "1700000000", requestPayload.Get("latest")) + require.Equal(t, "1600000000", requestPayload.Get("oldest")) + require.Equal(t, "50", requestPayload.Get("limit")) + require.Equal(t, "T9999", requestPayload.Get("team_id")) + require.JSONEq(t, `{"ok":true,"scheduled_messages":[]}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_list_user_conversations.go b/server/internal/platformtools/slack/tool_list_user_conversations.go new file mode 100644 index 0000000000..250ce7836e --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_user_conversations.go @@ -0,0 +1,69 @@ +package slack + +import ( + "context" + "io" + "strings" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListUserConversations = "platform_slack_list_user_conversations" + +type listUserConversationsInput struct { + UserID *string `json:"user_id,omitempty" jsonschema:"Slack user ID to inspect. Defaults to the authed user when omitted."` + ChannelTypes []string `json:"channel_types,omitempty" jsonschema:"Conversation types to include. Allowed values are public_channel, private_channel, mpim, and im."` + Cursor *string `json:"cursor,omitempty" jsonschema:"Pagination cursor from a previous response."` + Limit *int `json:"limit,omitempty" jsonschema:"Maximum number of conversations to fetch per page. Slack allows up to 1000."` + ExcludeArchived *bool `json:"exclude_archived,omitempty" jsonschema:"Exclude archived conversations from the response."` +} + +func NewListUserConversationsTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_user_conversations", + Name: toolNameListUserConversations, + Description: "List conversations the calling or supplied Slack user is a member of via users.conversations using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listUserConversationsInput]( + core.WithPropertyNumberRange("limit", 1, 1000), + ), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListUserConversations, + } +} + +func callListUserConversations(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listUserConversationsInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalString(request, "user", input.UserID) + setOptionalString(request, "cursor", input.Cursor) + setOptionalInt(request, "limit", input.Limit) + setOptionalBool(request, "exclude_archived", input.ExcludeArchived) + if len(input.ChannelTypes) > 0 { + request["types"] = strings.Join(input.ChannelTypes, ",") + } + + body, err := client.call(ctx, "users.conversations", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_user_conversations_test.go b/server/internal/platformtools/slack/tool_list_user_conversations_test.go new file mode 100644 index 0000000000..7259ecce99 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_user_conversations_test.go @@ -0,0 +1,53 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListUserConversationsTool_PassesParams(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channels":[{"id":"C1","name":"general"}]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListUserConversationsTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListUserConversations, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "user_id":"U99", + "channel_types":["public_channel","im"], + "exclude_archived":true, + "limit":50, + "cursor":"dXNlcjpVMQ==" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/users.conversations", requestPath) + require.Equal(t, "U99", requestPayload.Get("user")) + require.Equal(t, "public_channel,im", requestPayload.Get("types")) + require.Equal(t, "true", requestPayload.Get("exclude_archived")) + require.Equal(t, "50", requestPayload.Get("limit")) + require.Equal(t, "dXNlcjpVMQ==", requestPayload.Get("cursor")) + require.Contains(t, out.String(), `"id":"C1"`) +} diff --git a/server/internal/platformtools/slack/tool_list_usergroup_members.go b/server/internal/platformtools/slack/tool_list_usergroup_members.go new file mode 100644 index 0000000000..779b4dec0f --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_usergroup_members.go @@ -0,0 +1,66 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListUsergroupMembers = "platform_slack_list_usergroup_members" + +type listUsergroupMembersInput struct { + Usergroup string `json:"usergroup" jsonschema:"Encoded user group ID (e.g. \"S0604QSJC\")."` + IncludeDisabled *bool `json:"include_disabled,omitempty" jsonschema:"Include members of disabled user groups."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team ID. Required when calling with an org-level token."` +} + +func NewListUsergroupMembersTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_usergroup_members", + Name: toolNameListUsergroupMembers, + Description: "List the members of a Slack user group using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listUsergroupMembersInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListUsergroupMembers, + } +} + +func callListUsergroupMembers(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listUsergroupMembersInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + usergroup, err := requireString("usergroup", input.Usergroup) + if err != nil { + return err + } + + request := map[string]any{ + "usergroup": usergroup, + } + setOptionalBool(request, "include_disabled", input.IncludeDisabled) + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "usergroups.users.list", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_usergroup_members_test.go b/server/internal/platformtools/slack/tool_list_usergroup_members_test.go new file mode 100644 index 0000000000..ca2cdd5642 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_usergroup_members_test.go @@ -0,0 +1,62 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListUsergroupMembersTool_PostsToUsergroupsUsersList(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"users":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListUsergroupMembersTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListUsergroupMembers, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "usergroup":"S123", + "include_disabled":true, + "team_id":"T123" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/usergroups.users.list", requestPath) + require.Equal(t, "S123", requestPayload.Get("usergroup")) + require.Equal(t, "true", requestPayload.Get("include_disabled")) + require.Equal(t, "T123", requestPayload.Get("team_id")) +} + +func TestListUsergroupMembersTool_RequiresUsergroup(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewListUsergroupMembersTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callListUsergroupMembers, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "usergroup") +} diff --git a/server/internal/platformtools/slack/tool_list_usergroups.go b/server/internal/platformtools/slack/tool_list_usergroups.go new file mode 100644 index 0000000000..6f150096b8 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_usergroups.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameListUsergroups = "platform_slack_list_usergroups" + +type listUsergroupsInput struct { + IncludeCount *bool `json:"include_count,omitempty" jsonschema:"Include the user count for each user group."` + IncludeDisabled *bool `json:"include_disabled,omitempty" jsonschema:"Include disabled user groups in the result."` + IncludeUsers *bool `json:"include_users,omitempty" jsonschema:"Include the list of users for each user group."` + TeamID *string `json:"team_id,omitempty" jsonschema:"Encoded team ID. Required when calling with an org-level token."` +} + +func NewListUsergroupsTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "list_usergroups", + Name: toolNameListUsergroups, + Description: "List the user groups in the Slack workspace using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[listUsergroupsInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callListUsergroups, + } +} + +func callListUsergroups(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input listUsergroupsInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + request := map[string]any{} + setOptionalBool(request, "include_count", input.IncludeCount) + setOptionalBool(request, "include_disabled", input.IncludeDisabled) + setOptionalBool(request, "include_users", input.IncludeUsers) + setOptionalString(request, "team_id", input.TeamID) + + body, err := client.call(ctx, "usergroups.list", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_list_usergroups_test.go b/server/internal/platformtools/slack/tool_list_usergroups_test.go new file mode 100644 index 0000000000..6f2aac84d0 --- /dev/null +++ b/server/internal/platformtools/slack/tool_list_usergroups_test.go @@ -0,0 +1,76 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListUsergroupsTool_PassesOptionalFields(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"usergroups":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListUsergroupsTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListUsergroups, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "include_count":true, + "include_disabled":false, + "include_users":true, + "team_id":"T123" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/usergroups.list", requestPath) + require.Equal(t, "true", requestPayload.Get("include_count")) + require.Equal(t, "false", requestPayload.Get("include_disabled")) + require.Equal(t, "true", requestPayload.Get("include_users")) + require.Equal(t, "T123", requestPayload.Get("team_id")) +} + +func TestListUsergroupsTool_AllowsEmptyPayload(t *testing.T) { + t.Parallel() + + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"usergroups":[]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewListUsergroupsTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callListUsergroups, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.NoError(t, err) + require.Empty(t, requestPayload.Get("include_count")) + require.Empty(t, requestPayload.Get("team_id")) +} diff --git a/server/internal/platformtools/slack/tool_lookup_canvas_sections.go b/server/internal/platformtools/slack/tool_lookup_canvas_sections.go new file mode 100644 index 0000000000..09f0dd0c58 --- /dev/null +++ b/server/internal/platformtools/slack/tool_lookup_canvas_sections.go @@ -0,0 +1,73 @@ +package slack + +import ( + "context" + "fmt" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameLookupCanvasSections = "platform_slack_lookup_canvas_sections" + +type canvasSectionsCriteria struct { + ContainsText *string `json:"contains_text,omitempty" jsonschema:"Substring to match against section text."` + SectionTypes []string `json:"section_types,omitempty" jsonschema:"Section kinds to match (e.g. h1, h2, h3, any_header)."` +} + +type lookupCanvasSectionsInput struct { + CanvasID string `json:"canvas_id" jsonschema:"ID of the canvas to inspect."` + Criteria canvasSectionsCriteria `json:"criteria" jsonschema:"Lookup criteria applied to the canvas sections."` +} + +func NewLookupCanvasSectionsTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "lookup_canvas_sections", + Name: toolNameLookupCanvasSections, + Description: "Find sections inside a Slack canvas via canvases.sections.lookup using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[lookupCanvasSectionsInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callLookupCanvasSections, + } +} + +func callLookupCanvasSections(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input lookupCanvasSectionsInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + canvasID, err := requireString("canvas_id", input.CanvasID) + if err != nil { + return err + } + if input.Criteria.ContainsText == nil && len(input.Criteria.SectionTypes) == 0 { + return fmt.Errorf("criteria is required") + } + + request := map[string]any{ + "canvas_id": canvasID, + "criteria": input.Criteria, + } + + body, err := client.call(ctx, "canvases.sections.lookup", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_lookup_canvas_sections_test.go b/server/internal/platformtools/slack/tool_lookup_canvas_sections_test.go new file mode 100644 index 0000000000..5216757d3a --- /dev/null +++ b/server/internal/platformtools/slack/tool_lookup_canvas_sections_test.go @@ -0,0 +1,67 @@ +package slack + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLookupCanvasSectionsTool_SendsCriteria(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"sections":[{"id":"sec-1"}]}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewLookupCanvasSectionsTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callLookupCanvasSections, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "canvas_id":"F1", + "criteria":{"contains_text":"intro","section_types":["h1","h2"]} + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/canvases.sections.lookup", requestPath) + require.Equal(t, "F1", requestPayload.Get("canvas_id")) + + var criteria map[string]any + require.NoError(t, json.Unmarshal([]byte(requestPayload.Get("criteria")), &criteria)) + require.Equal(t, "intro", criteria["contains_text"]) + types, ok := criteria["section_types"].([]any) + require.True(t, ok) + require.Equal(t, []any{"h1", "h2"}, types) +} + +func TestLookupCanvasSectionsTool_RequiresCriteria(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewLookupCanvasSectionsTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callLookupCanvasSections, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"canvas_id":"F1","criteria":{}}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "criteria") +} diff --git a/server/internal/platformtools/slack/tool_lookup_user_by_email.go b/server/internal/platformtools/slack/tool_lookup_user_by_email.go new file mode 100644 index 0000000000..4c6d17e524 --- /dev/null +++ b/server/internal/platformtools/slack/tool_lookup_user_by_email.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameLookupUserByEmail = "platform_slack_lookup_user_by_email" + +type lookupUserByEmailInput struct { + Email string `json:"email" jsonschema:"Email address registered to the Slack workspace user."` +} + +func NewLookupUserByEmailTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := true + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "lookup_user_by_email", + Name: toolNameLookupUserByEmail, + Description: "Look up a Slack workspace user by email address via users.lookupByEmail using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[lookupUserByEmailInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callLookupUserByEmail, + } +} + +func callLookupUserByEmail(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input lookupUserByEmailInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + email, err := requireString("email", input.Email) + if err != nil { + return err + } + + request := map[string]any{ + "email": email, + } + + body, err := client.call(ctx, "users.lookupByEmail", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_lookup_user_by_email_test.go b/server/internal/platformtools/slack/tool_lookup_user_by_email_test.go new file mode 100644 index 0000000000..88be9a75fc --- /dev/null +++ b/server/internal/platformtools/slack/tool_lookup_user_by_email_test.go @@ -0,0 +1,57 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLookupUserByEmailTool_SendsEmail(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"user":{"id":"U123","profile":{"email":"alice@example.com"}}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewLookupUserByEmailTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callLookupUserByEmail, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"email":"alice@example.com"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/users.lookupByEmail", requestPath) + require.Equal(t, "alice@example.com", requestPayload.Get("email")) + require.Contains(t, out.String(), `"id":"U123"`) +} + +func TestLookupUserByEmailTool_RequiresEmail(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewLookupUserByEmailTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callLookupUserByEmail, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{}`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "email") +} diff --git a/server/internal/platformtools/slack/tool_mark_conversation.go b/server/internal/platformtools/slack/tool_mark_conversation.go new file mode 100644 index 0000000000..b126007819 --- /dev/null +++ b/server/internal/platformtools/slack/tool_mark_conversation.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameMarkConversation = "platform_slack_mark_conversation" + +type markConversationInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID whose read cursor should be moved."` + Timestamp string `json:"timestamp" jsonschema:"Timestamp of the message to mark as the most recently seen (e.g. \"1234567890.123456\")."` +} + +func NewMarkConversationTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "mark_conversation", + Name: toolNameMarkConversation, + Description: "Move the read cursor in a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[markConversationInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callMarkConversation, + } +} + +func callMarkConversation(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input markConversationInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + timestamp, err := requireString("timestamp", input.Timestamp) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "ts": timestamp, + } + + body, err := client.call(ctx, "conversations.mark", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_mark_conversation_test.go b/server/internal/platformtools/slack/tool_mark_conversation_test.go new file mode 100644 index 0000000000..07744eb128 --- /dev/null +++ b/server/internal/platformtools/slack/tool_mark_conversation_test.go @@ -0,0 +1,46 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMarkConversationTool_PostsToConversationsMark(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewMarkConversationTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callMarkConversation, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "timestamp":"1234567890.123456" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.mark", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "1234567890.123456", requestPayload.Get("ts")) +} diff --git a/server/internal/platformtools/slack/tool_me_message.go b/server/internal/platformtools/slack/tool_me_message.go new file mode 100644 index 0000000000..10129d75f9 --- /dev/null +++ b/server/internal/platformtools/slack/tool_me_message.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameMeMessage = "platform_slack_me_message" + +type meMessageInput struct { + ChannelID string `json:"channel_id" jsonschema:"Channel, private group, or IM channel to post the /me message into."` + Text string `json:"text" jsonschema:"Text of the /me message."` +} + +func NewChatMeMessageTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "me_message", + Name: toolNameMeMessage, + Description: "Share a Slack /me message as the authenticated user using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[meMessageInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callMeMessage, + } +} + +func callMeMessage(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input meMessageInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + text, err := requireString("text", input.Text) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "text": text, + } + + body, err := client.call(ctx, "chat.meMessage", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_me_message_test.go b/server/internal/platformtools/slack/tool_me_message_test.go new file mode 100644 index 0000000000..60bb1e5e6b --- /dev/null +++ b/server/internal/platformtools/slack/tool_me_message_test.go @@ -0,0 +1,47 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMeMessageTool_PostsToChatMeMessage(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":"C123","ts":"123.456"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatMeMessageTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callMeMessage, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "text":"waves hello" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.meMessage", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "waves hello", requestPayload.Get("text")) + require.JSONEq(t, `{"ok":true,"channel":"C123","ts":"123.456"}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_open_conversation.go b/server/internal/platformtools/slack/tool_open_conversation.go new file mode 100644 index 0000000000..0b09fbeea2 --- /dev/null +++ b/server/internal/platformtools/slack/tool_open_conversation.go @@ -0,0 +1,83 @@ +package slack + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameOpenConversation = "platform_slack_open_conversation" + +type openConversationInput struct { + ChannelID *string `json:"channel_id,omitempty" jsonschema:"Existing IM or MPIM ID to resume. Provide either this or users."` + Users []string `json:"users,omitempty" jsonschema:"1-8 Slack user IDs to open a new direct or multi-party conversation with. Provide either this or channel_id."` + ReturnIM *bool `json:"return_im,omitempty" jsonschema:"Return the full IM channel definition in the response."` + PreventCreation *bool `json:"prevent_creation,omitempty" jsonschema:"Do not create a new conversation if one does not already exist."` +} + +func NewOpenConversationTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "open_conversation", + Name: toolNameOpenConversation, + Description: "Open or resume a Slack direct or multi-party direct message conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN. Provide either channel_id to resume an existing IM/MPIM or users (1-8 user IDs) to open a new one.", + InputSchema: core.BuildInputSchema[openConversationInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callOpenConversation, + } +} + +func callOpenConversation(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input openConversationInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID := strings.TrimSpace(derefString(input.ChannelID)) + users := make([]string, 0, len(input.Users)) + for _, u := range input.Users { + if trimmed := strings.TrimSpace(u); trimmed != "" { + users = append(users, trimmed) + } + } + + switch { + case channelID == "" && len(users) == 0: + return fmt.Errorf("either channel_id or users is required") + case channelID != "" && len(users) > 0: + return fmt.Errorf("provide only one of channel_id or users") + } + + request := map[string]any{} + if channelID != "" { + request["channel"] = channelID + } + if len(users) > 0 { + request["users"] = users + } + setOptionalBool(request, "return_im", input.ReturnIM) + setOptionalBool(request, "prevent_creation", input.PreventCreation) + + body, err := client.call(ctx, "conversations.open", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_open_conversation_test.go b/server/internal/platformtools/slack/tool_open_conversation_test.go new file mode 100644 index 0000000000..83a0231016 --- /dev/null +++ b/server/internal/platformtools/slack/tool_open_conversation_test.go @@ -0,0 +1,75 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenConversationTool_OpensWithUsers(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":{"id":"D123"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewOpenConversationTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callOpenConversation, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "users":["U1","U2"], + "return_im":true + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.open", requestPath) + require.Equal(t, "U1,U2", requestPayload.Get("users")) + require.Equal(t, "true", requestPayload.Get("return_im")) + require.Empty(t, requestPayload.Get("channel")) +} + +func TestOpenConversationTool_RejectsMissingAndBothInputs(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewOpenConversationTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callOpenConversation, + } + + cases := []struct { + name string + payload string + needle string + }{ + {"missing both", `{}`, "either channel_id or users"}, + {"both supplied", `{"channel_id":"D123","users":["U1"]}`, "only one"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(tc.payload), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, tc.needle) + }) + } +} diff --git a/server/internal/platformtools/slack/tool_pin_message.go b/server/internal/platformtools/slack/tool_pin_message.go new file mode 100644 index 0000000000..05dc5e89fc --- /dev/null +++ b/server/internal/platformtools/slack/tool_pin_message.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNamePinMessage = "platform_slack_pin_message" + +type pinMessageInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID containing the message to pin."` + Timestamp string `json:"timestamp" jsonschema:"Timestamp of the message to pin (e.g. \"1234567890.123456\")."` +} + +func NewPinMessageTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "pin_message", + Name: toolNamePinMessage, + Description: "Pin a Slack message to its channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[pinMessageInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callPinMessage, + } +} + +func callPinMessage(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input pinMessageInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + timestamp, err := requireString("timestamp", input.Timestamp) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "timestamp": timestamp, + } + + body, err := client.call(ctx, "pins.add", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_pin_message_test.go b/server/internal/platformtools/slack/tool_pin_message_test.go new file mode 100644 index 0000000000..1cf78eb1a7 --- /dev/null +++ b/server/internal/platformtools/slack/tool_pin_message_test.go @@ -0,0 +1,75 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPinMessageTool_PostsToPinsAdd(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewPinMessageTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callPinMessage, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "timestamp":"123.456" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/pins.add", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "123.456", requestPayload.Get("timestamp")) + require.JSONEq(t, `{"ok":true}`, out.String()) +} + +func TestPinMessageTool_RequiresFields(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewPinMessageTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callPinMessage, + } + + cases := []struct { + name string + payload string + field string + }{ + {"missing channel", `{"timestamp":"1.2"}`, "channel_id"}, + {"missing timestamp", `{"channel_id":"C"}`, "timestamp"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(tc.payload), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, tc.field) + }) + } +} diff --git a/server/internal/platformtools/slack/tool_post_ephemeral.go b/server/internal/platformtools/slack/tool_post_ephemeral.go new file mode 100644 index 0000000000..48aad82d57 --- /dev/null +++ b/server/internal/platformtools/slack/tool_post_ephemeral.go @@ -0,0 +1,95 @@ +package slack + +import ( + "context" + "fmt" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNamePostEphemeral = "platform_slack_post_ephemeral" + +type postEphemeralInput struct { + ChannelID string `json:"channel_id" jsonschema:"Channel, private group, or IM channel to post the ephemeral message into."` + UserID string `json:"user_id" jsonschema:"ID of the user who will see the ephemeral message."` + Text *string `json:"text,omitempty" jsonschema:"Message text. Required when neither 'blocks' nor 'attachments' is supplied; otherwise acts as the accessibility fallback."` + Blocks []slackBlock `json:"blocks,omitempty" jsonschema:"Optional Block Kit blocks."` + Attachments *string `json:"attachments,omitempty" jsonschema:"Optional JSON-encoded array of structured attachments."` + ThreadTS *string `json:"thread_ts,omitempty" jsonschema:"Optional parent message timestamp to anchor the ephemeral message inside a thread."` + LinkNames *bool `json:"link_names,omitempty" jsonschema:"Find and link channel names and usernames."` + Parse *string `json:"parse,omitempty" jsonschema:"Override Slack message parsing. Accepts 'none', 'full', 'mrkdwn', or 'false'."` + IconEmoji *string `json:"icon_emoji,omitempty" jsonschema:"Emoji to use as the message icon."` + IconURL *string `json:"icon_url,omitempty" jsonschema:"URL of an image to use as the message icon."` + Username *string `json:"username,omitempty" jsonschema:"Display name to show as the message sender."` +} + +func NewChatPostEphemeralTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "post_ephemeral", + Name: toolNamePostEphemeral, + Description: "Post a Slack ephemeral message visible only to the targeted user, using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[postEphemeralInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callPostEphemeral, + } +} + +func callPostEphemeral(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input postEphemeralInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + userID, err := requireString("user_id", input.UserID) + if err != nil { + return err + } + + hasText := input.Text != nil && *input.Text != "" + hasAttachments := input.Attachments != nil && *input.Attachments != "" + if !hasText && len(input.Blocks) == 0 && !hasAttachments { + return fmt.Errorf("at least one of text, blocks, or attachments is required") + } + + request := map[string]any{ + "channel": channelID, + "user": userID, + } + setOptionalString(request, "text", input.Text) + setOptionalString(request, "attachments", input.Attachments) + setOptionalString(request, "thread_ts", input.ThreadTS) + setOptionalString(request, "parse", input.Parse) + setOptionalString(request, "icon_emoji", input.IconEmoji) + setOptionalString(request, "icon_url", input.IconURL) + setOptionalString(request, "username", input.Username) + setOptionalBool(request, "link_names", input.LinkNames) + if len(input.Blocks) > 0 { + request["blocks"] = input.Blocks + } + + body, err := client.call(ctx, "chat.postEphemeral", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_post_ephemeral_test.go b/server/internal/platformtools/slack/tool_post_ephemeral_test.go new file mode 100644 index 0000000000..03a5a44146 --- /dev/null +++ b/server/internal/platformtools/slack/tool_post_ephemeral_test.go @@ -0,0 +1,74 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPostEphemeralTool_PostsToChatPostEphemeral(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"message_ts":"123.456"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatPostEphemeralTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callPostEphemeral, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "user_id":"U987", + "text":"hey only you", + "thread_ts":"123.000", + "link_names":true, + "icon_emoji":":robot_face:", + "username":"Gram" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.postEphemeral", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "U987", requestPayload.Get("user")) + require.Equal(t, "hey only you", requestPayload.Get("text")) + require.Equal(t, "123.000", requestPayload.Get("thread_ts")) + require.Equal(t, "true", requestPayload.Get("link_names")) + require.Equal(t, ":robot_face:", requestPayload.Get("icon_emoji")) + require.Equal(t, "Gram", requestPayload.Get("username")) + require.JSONEq(t, `{"ok":true,"message_ts":"123.456"}`, out.String()) +} + +func TestPostEphemeralTool_RequiresContent(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewChatPostEphemeralTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callPostEphemeral, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "user_id":"U987" + }`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "text, blocks, or attachments") +} diff --git a/server/internal/platformtools/slack/tool_remove_bookmark.go b/server/internal/platformtools/slack/tool_remove_bookmark.go new file mode 100644 index 0000000000..c4ba91f625 --- /dev/null +++ b/server/internal/platformtools/slack/tool_remove_bookmark.go @@ -0,0 +1,70 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameRemoveBookmark = "platform_slack_remove_bookmark" + +type removeBookmarkInput struct { + BookmarkID string `json:"bookmark_id" jsonschema:"ID of the bookmark to remove."` + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID owning the bookmark."` + QuipSectionID *string `json:"quip_section_id,omitempty" jsonschema:"Optional Quip section ID to unbookmark instead of the top-level bookmark."` +} + +func NewRemoveBookmarkTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "remove_bookmark", + Name: toolNameRemoveBookmark, + Description: "Remove a bookmark from a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[removeBookmarkInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callRemoveBookmark, + } +} + +func callRemoveBookmark(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input removeBookmarkInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + bookmarkID, err := requireString("bookmark_id", input.BookmarkID) + if err != nil { + return err + } + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "bookmark_id": bookmarkID, + "channel_id": channelID, + } + setOptionalString(request, "quip_section_id", input.QuipSectionID) + + body, err := client.call(ctx, "bookmarks.remove", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_remove_bookmark_test.go b/server/internal/platformtools/slack/tool_remove_bookmark_test.go new file mode 100644 index 0000000000..7496ca8794 --- /dev/null +++ b/server/internal/platformtools/slack/tool_remove_bookmark_test.go @@ -0,0 +1,76 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRemoveBookmarkTool_PostsToBookmarksRemove(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewRemoveBookmarkTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callRemoveBookmark, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "bookmark_id":"Bk1", + "channel_id":"C123", + "quip_section_id":"Q1" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/bookmarks.remove", requestPath) + require.Equal(t, "Bk1", requestPayload.Get("bookmark_id")) + require.Equal(t, "C123", requestPayload.Get("channel_id")) + require.Equal(t, "Q1", requestPayload.Get("quip_section_id")) +} + +func TestRemoveBookmarkTool_RequiresFields(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewRemoveBookmarkTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callRemoveBookmark, + } + + cases := []struct { + name string + payload string + field string + }{ + {"missing bookmark", `{"channel_id":"C"}`, "bookmark_id"}, + {"missing channel", `{"bookmark_id":"Bk1"}`, "channel_id"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(tc.payload), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, tc.field) + }) + } +} diff --git a/server/internal/platformtools/slack/tool_remove_canvas_access.go b/server/internal/platformtools/slack/tool_remove_canvas_access.go new file mode 100644 index 0000000000..69870b5355 --- /dev/null +++ b/server/internal/platformtools/slack/tool_remove_canvas_access.go @@ -0,0 +1,74 @@ +package slack + +import ( + "context" + "fmt" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameRemoveCanvasAccess = "platform_slack_remove_canvas_access" + +type removeCanvasAccessInput struct { + CanvasID string `json:"canvas_id" jsonschema:"ID of the canvas to remove access from."` + ChannelIDs []string `json:"channel_ids,omitempty" jsonschema:"Channel IDs whose access should be revoked."` + UserIDs []string `json:"user_ids,omitempty" jsonschema:"User IDs whose access should be revoked."` +} + +func NewRemoveCanvasAccessTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "remove_canvas_access", + Name: toolNameRemoveCanvasAccess, + Description: "Revoke access from a Slack canvas via canvases.access.delete using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN. At least one of channel_ids or user_ids must be supplied.", + InputSchema: core.BuildInputSchema[removeCanvasAccessInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callRemoveCanvasAccess, + } +} + +func callRemoveCanvasAccess(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input removeCanvasAccessInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + canvasID, err := requireString("canvas_id", input.CanvasID) + if err != nil { + return err + } + if len(input.ChannelIDs) == 0 && len(input.UserIDs) == 0 { + return fmt.Errorf("at least one of channel_ids or user_ids is required; canvases.access.delete returns invalid_parameters otherwise") + } + + request := map[string]any{ + "canvas_id": canvasID, + } + if len(input.ChannelIDs) > 0 { + request["channel_ids"] = input.ChannelIDs + } + if len(input.UserIDs) > 0 { + request["user_ids"] = input.UserIDs + } + + body, err := client.call(ctx, "canvases.access.delete", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_remove_canvas_access_test.go b/server/internal/platformtools/slack/tool_remove_canvas_access_test.go new file mode 100644 index 0000000000..7390d28d78 --- /dev/null +++ b/server/internal/platformtools/slack/tool_remove_canvas_access_test.go @@ -0,0 +1,63 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRemoveCanvasAccessTool_SendsTargets(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewRemoveCanvasAccessTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callRemoveCanvasAccess, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "canvas_id":"F1", + "channel_ids":["C1"], + "user_ids":["U1","U2"] + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/canvases.access.delete", requestPath) + require.Equal(t, "F1", requestPayload.Get("canvas_id")) + require.Equal(t, "C1", requestPayload.Get("channel_ids")) + require.Equal(t, "U1,U2", requestPayload.Get("user_ids")) +} + +func TestRemoveCanvasAccessTool_RequiresTarget(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewRemoveCanvasAccessTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callRemoveCanvasAccess, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"canvas_id":"F1"}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "channel_ids") + require.ErrorContains(t, err, "user_ids") +} diff --git a/server/internal/platformtools/slack/tool_remove_from_channel.go b/server/internal/platformtools/slack/tool_remove_from_channel.go new file mode 100644 index 0000000000..20f7f93ee5 --- /dev/null +++ b/server/internal/platformtools/slack/tool_remove_from_channel.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameRemoveFromChannel = "platform_slack_remove_from_channel" + +type removeFromChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to remove the user from."` + UserID string `json:"user_id" jsonschema:"Slack user ID to remove from the conversation."` +} + +func NewRemoveFromChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "remove_from_channel", + Name: toolNameRemoveFromChannel, + Description: "Remove a user from a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[removeFromChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callRemoveFromChannel, + } +} + +func callRemoveFromChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input removeFromChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + userID, err := requireString("user_id", input.UserID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "user": userID, + } + + body, err := client.call(ctx, "conversations.kick", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_remove_from_channel_test.go b/server/internal/platformtools/slack/tool_remove_from_channel_test.go new file mode 100644 index 0000000000..b0b7278e60 --- /dev/null +++ b/server/internal/platformtools/slack/tool_remove_from_channel_test.go @@ -0,0 +1,46 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRemoveFromChannelTool_PostsToConversationsKick(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewRemoveFromChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callRemoveFromChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "user_id":"U1" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.kick", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "U1", requestPayload.Get("user")) +} diff --git a/server/internal/platformtools/slack/tool_rename_channel.go b/server/internal/platformtools/slack/tool_rename_channel.go new file mode 100644 index 0000000000..eab91c2a98 --- /dev/null +++ b/server/internal/platformtools/slack/tool_rename_channel.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameRenameChannel = "platform_slack_rename_channel" + +type renameChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to rename."` + Name string `json:"name" jsonschema:"New channel name. Slack lowercases the value and limits it to letters, numbers, hyphens, and underscores (max 80 characters)."` +} + +func NewRenameChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "rename_channel", + Name: toolNameRenameChannel, + Description: "Rename a Slack channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[renameChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callRenameChannel, + } +} + +func callRenameChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input renameChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + name, err := requireString("name", input.Name) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "name": name, + } + + body, err := client.call(ctx, "conversations.rename", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_rename_channel_test.go b/server/internal/platformtools/slack/tool_rename_channel_test.go new file mode 100644 index 0000000000..d6d9a48ed8 --- /dev/null +++ b/server/internal/platformtools/slack/tool_rename_channel_test.go @@ -0,0 +1,46 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenameChannelTool_PostsToConversationsRename(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":{"id":"C123","name":"project-beta"}}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewRenameChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callRenameChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "name":"project-beta" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.rename", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "project-beta", requestPayload.Get("name")) +} diff --git a/server/internal/platformtools/slack/tool_set_canvas_access.go b/server/internal/platformtools/slack/tool_set_canvas_access.go new file mode 100644 index 0000000000..7577a93a59 --- /dev/null +++ b/server/internal/platformtools/slack/tool_set_canvas_access.go @@ -0,0 +1,85 @@ +package slack + +import ( + "context" + "fmt" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameSetCanvasAccess = "platform_slack_set_canvas_access" + +type setCanvasAccessInput struct { + CanvasID string `json:"canvas_id" jsonschema:"ID of the canvas to update access on."` + AccessLevel string `json:"access_level" jsonschema:"Access level to grant. One of read or write. Use the remove_canvas_access tool (canvases.access.delete) to revoke."` + ChannelIDs []string `json:"channel_ids,omitempty" jsonschema:"Channel IDs to grant access to."` + UserIDs []string `json:"user_ids,omitempty" jsonschema:"User IDs to grant access to."` +} + +func NewSetCanvasAccessTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "set_canvas_access", + Name: toolNameSetCanvasAccess, + Description: "Set access on a Slack canvas via canvases.access.set using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[setCanvasAccessInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callSetCanvasAccess, + } +} + +func callSetCanvasAccess(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input setCanvasAccessInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + canvasID, err := requireString("canvas_id", input.CanvasID) + if err != nil { + return err + } + accessLevel, err := requireString("access_level", input.AccessLevel) + if err != nil { + return err + } + hasChannels := len(input.ChannelIDs) > 0 + hasUsers := len(input.UserIDs) > 0 + switch { + case !hasChannels && !hasUsers: + return fmt.Errorf("exactly one of channel_ids or user_ids is required") + case hasChannels && hasUsers: + return fmt.Errorf("channel_ids and user_ids are mutually exclusive; canvases.access.set returns invalid_parameters when both are set") + } + + request := map[string]any{ + "canvas_id": canvasID, + "access_level": accessLevel, + } + if hasChannels { + request["channel_ids"] = input.ChannelIDs + } + if hasUsers { + request["user_ids"] = input.UserIDs + } + + body, err := client.call(ctx, "canvases.access.set", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_set_canvas_access_test.go b/server/internal/platformtools/slack/tool_set_canvas_access_test.go new file mode 100644 index 0000000000..7ab19095b6 --- /dev/null +++ b/server/internal/platformtools/slack/tool_set_canvas_access_test.go @@ -0,0 +1,83 @@ +package slack + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetCanvasAccessTool_SendsTargets(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewSetCanvasAccessTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callSetCanvasAccess, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "canvas_id":"F1", + "access_level":"write", + "channel_ids":["C1","C2"] + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/canvases.access.set", requestPath) + require.Equal(t, "F1", requestPayload.Get("canvas_id")) + require.Equal(t, "write", requestPayload.Get("access_level")) + require.Equal(t, "C1,C2", requestPayload.Get("channel_ids")) + require.Empty(t, requestPayload.Get("user_ids")) +} + +func TestSetCanvasAccessTool_RequiresTarget(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewSetCanvasAccessTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callSetCanvasAccess, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"canvas_id":"F1","access_level":"read"}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "channel_ids") + require.ErrorContains(t, err, "user_ids") +} + +func TestSetCanvasAccessTool_RejectsBothTargets(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewSetCanvasAccessTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callSetCanvasAccess, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "canvas_id":"F1", + "access_level":"read", + "channel_ids":["C1"], + "user_ids":["U1"] + }`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "mutually exclusive") +} diff --git a/server/internal/platformtools/slack/tool_set_channel_purpose.go b/server/internal/platformtools/slack/tool_set_channel_purpose.go new file mode 100644 index 0000000000..a95b6d60e5 --- /dev/null +++ b/server/internal/platformtools/slack/tool_set_channel_purpose.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameSetChannelPurpose = "platform_slack_set_channel_purpose" + +type setChannelPurposeInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID whose description should be set."` + Purpose string `json:"purpose" jsonschema:"New description. Max 250 characters."` +} + +func NewSetChannelPurposeTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "set_channel_purpose", + Name: toolNameSetChannelPurpose, + Description: "Set the description (purpose) of a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[setChannelPurposeInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callSetChannelPurpose, + } +} + +func callSetChannelPurpose(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input setChannelPurposeInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + purpose, err := requireString("purpose", input.Purpose) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "purpose": purpose, + } + + body, err := client.call(ctx, "conversations.setPurpose", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_set_channel_purpose_test.go b/server/internal/platformtools/slack/tool_set_channel_purpose_test.go new file mode 100644 index 0000000000..b2a16be845 --- /dev/null +++ b/server/internal/platformtools/slack/tool_set_channel_purpose_test.go @@ -0,0 +1,46 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetChannelPurposeTool_PostsToConversationsSetPurpose(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"purpose":"planning"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewSetChannelPurposeTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callSetChannelPurpose, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "purpose":"planning" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.setPurpose", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "planning", requestPayload.Get("purpose")) +} diff --git a/server/internal/platformtools/slack/tool_set_channel_topic.go b/server/internal/platformtools/slack/tool_set_channel_topic.go new file mode 100644 index 0000000000..e4d621a715 --- /dev/null +++ b/server/internal/platformtools/slack/tool_set_channel_topic.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameSetChannelTopic = "platform_slack_set_channel_topic" + +type setChannelTopicInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID whose topic should be set."` + Topic string `json:"topic" jsonschema:"New topic. Max 250 characters."` +} + +func NewSetChannelTopicTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "set_channel_topic", + Name: toolNameSetChannelTopic, + Description: "Set the topic of a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[setChannelTopicInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callSetChannelTopic, + } +} + +func callSetChannelTopic(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input setChannelTopicInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + topic, err := requireString("topic", input.Topic) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "topic": topic, + } + + body, err := client.call(ctx, "conversations.setTopic", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_set_channel_topic_test.go b/server/internal/platformtools/slack/tool_set_channel_topic_test.go new file mode 100644 index 0000000000..27014dd89a --- /dev/null +++ b/server/internal/platformtools/slack/tool_set_channel_topic_test.go @@ -0,0 +1,46 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetChannelTopicTool_PostsToConversationsSetTopic(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"topic":"welcome"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewSetChannelTopicTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callSetChannelTopic, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "topic":"welcome" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.setTopic", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "welcome", requestPayload.Get("topic")) +} diff --git a/server/internal/platformtools/slack/tool_unarchive_channel.go b/server/internal/platformtools/slack/tool_unarchive_channel.go new file mode 100644 index 0000000000..151a6ba393 --- /dev/null +++ b/server/internal/platformtools/slack/tool_unarchive_channel.go @@ -0,0 +1,62 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameUnarchiveChannel = "platform_slack_unarchive_channel" + +type unarchiveChannelInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID to unarchive."` +} + +func NewUnarchiveChannelTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "unarchive_channel", + Name: toolNameUnarchiveChannel, + Description: "Unarchive a Slack conversation using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN. Slack currently rejects bot tokens for this method; configure SLACK_USER_TOKEN if the bot token call fails.", + InputSchema: core.BuildInputSchema[unarchiveChannelInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callUnarchiveChannel, + } +} + +func callUnarchiveChannel(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input unarchiveChannelInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + } + + body, err := client.call(ctx, "conversations.unarchive", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_unarchive_channel_test.go b/server/internal/platformtools/slack/tool_unarchive_channel_test.go new file mode 100644 index 0000000000..fc7707f2cd --- /dev/null +++ b/server/internal/platformtools/slack/tool_unarchive_channel_test.go @@ -0,0 +1,42 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnarchiveChannelTool_PostsToConversationsUnarchive(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewUnarchiveChannelTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callUnarchiveChannel, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{"channel_id":"C123"}`), &out) + require.NoError(t, err) + + require.Equal(t, "/conversations.unarchive", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) +} diff --git a/server/internal/platformtools/slack/tool_unpin_message.go b/server/internal/platformtools/slack/tool_unpin_message.go new file mode 100644 index 0000000000..1593922cd5 --- /dev/null +++ b/server/internal/platformtools/slack/tool_unpin_message.go @@ -0,0 +1,68 @@ +package slack + +import ( + "context" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameUnpinMessage = "platform_slack_unpin_message" + +type unpinMessageInput struct { + ChannelID string `json:"channel_id" jsonschema:"Slack conversation ID containing the pinned message."` + Timestamp string `json:"timestamp" jsonschema:"Timestamp of the message to unpin (e.g. \"1234567890.123456\")."` +} + +func NewUnpinMessageTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := true + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "unpin_message", + Name: toolNameUnpinMessage, + Description: "Remove a pinned Slack message from its channel using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN.", + InputSchema: core.BuildInputSchema[unpinMessageInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callUnpinMessage, + } +} + +func callUnpinMessage(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input unpinMessageInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + timestamp, err := requireString("timestamp", input.Timestamp) + if err != nil { + return err + } + + request := map[string]any{ + "channel": channelID, + "timestamp": timestamp, + } + + body, err := client.call(ctx, "pins.remove", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_unpin_message_test.go b/server/internal/platformtools/slack/tool_unpin_message_test.go new file mode 100644 index 0000000000..3a40c134ca --- /dev/null +++ b/server/internal/platformtools/slack/tool_unpin_message_test.go @@ -0,0 +1,47 @@ +package slack + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnpinMessageTool_PostsToPinsRemove(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewUnpinMessageTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callUnpinMessage, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "timestamp":"123.456" + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/pins.remove", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "123.456", requestPayload.Get("timestamp")) + require.JSONEq(t, `{"ok":true}`, out.String()) +} diff --git a/server/internal/platformtools/slack/tool_update_message.go b/server/internal/platformtools/slack/tool_update_message.go new file mode 100644 index 0000000000..59d675e5b1 --- /dev/null +++ b/server/internal/platformtools/slack/tool_update_message.go @@ -0,0 +1,93 @@ +package slack + +import ( + "context" + "fmt" + "io" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameUpdateMessage = "platform_slack_update_message" + +type updateMessageInput struct { + ChannelID string `json:"channel_id" jsonschema:"Channel containing the message to update."` + TS string `json:"ts" jsonschema:"Timestamp of the message to update."` + Text *string `json:"text,omitempty" jsonschema:"Replacement message text. At least one of text, blocks, or attachments must be provided."` + Blocks []slackBlock `json:"blocks,omitempty" jsonschema:"Replacement Block Kit blocks. At least one of text, blocks, or attachments must be provided."` + Attachments *string `json:"attachments,omitempty" jsonschema:"Replacement attachments as a JSON-encoded array of structured attachments."` + LinkNames *bool `json:"link_names,omitempty" jsonschema:"Find and link channel names and usernames in the updated text."` + Parse *string `json:"parse,omitempty" jsonschema:"Override Slack message parsing. Accepts 'none' or 'full'."` + ReplyBroadcast *bool `json:"reply_broadcast,omitempty" jsonschema:"Broadcast an existing thread reply to the channel."` + FileIDs []string `json:"file_ids,omitempty" jsonschema:"New file ids to attach to the updated message."` +} + +func NewChatUpdateTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := true + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "update_message", + Name: toolNameUpdateMessage, + Description: "Update an existing Slack message using the server's Slack token from SLACK_BOT_TOKEN or SLACK_TOKEN. At least one of text, blocks, or attachments must be supplied.", + InputSchema: core.BuildInputSchema[updateMessageInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callUpdateMessage, + } +} + +func callUpdateMessage(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input updateMessageInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + channelID, err := requireString("channel_id", input.ChannelID) + if err != nil { + return err + } + ts, err := requireString("ts", input.TS) + if err != nil { + return err + } + + hasText := input.Text != nil && *input.Text != "" + hasAttachments := input.Attachments != nil && *input.Attachments != "" + if !hasText && len(input.Blocks) == 0 && !hasAttachments { + return fmt.Errorf("at least one of text, blocks, or attachments is required") + } + + request := map[string]any{ + "channel": channelID, + "ts": ts, + } + setOptionalString(request, "text", input.Text) + setOptionalString(request, "attachments", input.Attachments) + setOptionalString(request, "parse", input.Parse) + setOptionalBool(request, "link_names", input.LinkNames) + setOptionalBool(request, "reply_broadcast", input.ReplyBroadcast) + if len(input.Blocks) > 0 { + request["blocks"] = input.Blocks + } + if len(input.FileIDs) > 0 { + request["file_ids"] = input.FileIDs + } + + body, err := client.call(ctx, "chat.update", request, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} diff --git a/server/internal/platformtools/slack/tool_update_message_test.go b/server/internal/platformtools/slack/tool_update_message_test.go new file mode 100644 index 0000000000..e76017ce94 --- /dev/null +++ b/server/internal/platformtools/slack/tool_update_message_test.go @@ -0,0 +1,77 @@ +package slack + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdateMessageTool_PostsToChatUpdate(t *testing.T) { + t.Parallel() + + var requestPath string + var requestPayload url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + requestPayload = readForm(t, r) + + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"ok":true,"channel":"C123","ts":"123.456","text":"updated"}`)) + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + tool := &slackTool{ + descriptor: NewChatUpdateTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callUpdateMessage, + } + + var out bytes.Buffer + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "ts":"123.456", + "text":"updated", + "link_names":true, + "parse":"full", + "blocks":[{"type":"section","text":{"type":"mrkdwn","text":"updated"}}] + }`), &out) + require.NoError(t, err) + + require.Equal(t, "/chat.update", requestPath) + require.Equal(t, "C123", requestPayload.Get("channel")) + require.Equal(t, "123.456", requestPayload.Get("ts")) + require.Equal(t, "updated", requestPayload.Get("text")) + require.Equal(t, "true", requestPayload.Get("link_names")) + require.Equal(t, "full", requestPayload.Get("parse")) + + var blocks []map[string]any + require.NoError(t, json.Unmarshal([]byte(requestPayload.Get("blocks")), &blocks)) + require.Len(t, blocks, 1) + require.Equal(t, "section", blocks[0]["type"]) + require.JSONEq(t, `{"ok":true,"channel":"C123","ts":"123.456","text":"updated"}`, out.String()) +} + +func TestUpdateMessageTool_RequiresContent(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewChatUpdateTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callUpdateMessage, + } + + err := tool.Call(t.Context(), testSlackEnv(), bytes.NewBufferString(`{ + "channel_id":"C123", + "ts":"123.456" + }`), &bytes.Buffer{}) + require.Error(t, err) + require.ErrorContains(t, err, "text, blocks, or attachments") +} diff --git a/server/internal/platformtools/slack/tool_upload_file.go b/server/internal/platformtools/slack/tool_upload_file.go new file mode 100644 index 0000000000..1d0f085e8e --- /dev/null +++ b/server/internal/platformtools/slack/tool_upload_file.go @@ -0,0 +1,152 @@ +package slack + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/speakeasy-api/gram/server/internal/guardian" + "github.com/speakeasy-api/gram/server/internal/o11y" + "github.com/speakeasy-api/gram/server/internal/platformtools/core" + "github.com/speakeasy-api/gram/server/internal/toolconfig" +) + +const toolNameUploadFile = "platform_slack_upload_file" + +type uploadFileInput struct { + Filename string `json:"filename" jsonschema:"Name of the file being uploaded (used by Slack as the displayed filename)."` + ContentBase64 string `json:"content_base64" jsonschema:"Base64-encoded file bytes. The decoded length is sent to Slack as the file size."` + Title *string `json:"title,omitempty" jsonschema:"Optional human-friendly title for the file."` + AltText *string `json:"alt_text,omitempty" jsonschema:"Optional screen-reader description for image uploads. Sent to Slack as alt_txt."` + SnippetType *string `json:"snippet_type,omitempty" jsonschema:"Syntax type for code snippet uploads (for example: python, go, json)."` + ChannelID *string `json:"channel_id,omitempty" jsonschema:"Optional channel ID to share the file into. Omit to keep the file private to the uploader. To share into multiple channels, invoke the tool once per channel."` + InitialComment *string `json:"initial_comment,omitempty" jsonschema:"Optional message text to post alongside the shared file."` + ThreadTS *string `json:"thread_ts,omitempty" jsonschema:"Optional thread timestamp to share the file as a reply in an existing thread."` +} + +type getUploadURLExternalResponse struct { + slackResponseEnvelope + UploadURL string `json:"upload_url"` + FileID string `json:"file_id"` +} + +type completeUploadFileEntry struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` +} + +func NewUploadFileTool(httpClient *guardian.HTTPClient) core.PlatformToolExecutor { + readOnly := false + destructive := false + idempotent := false + openWorld := true + + return &slackTool{ + descriptor: core.ToolDescriptor{ + SourceSlug: sourceSlack, + HandlerName: "upload_file", + Name: toolNameUploadFile, + Description: "Upload a file to Slack using the modern external upload flow (files.getUploadURLExternal + binary upload + files.completeUploadExternal). File bytes are passed as base64. Optionally shares the file into a single channel with an initial comment or thread reply; call the tool multiple times to share into more channels.", + InputSchema: core.BuildInputSchema[uploadFileInput](), + Variables: nil, + Annotations: slackToolAnnotations(readOnly, destructive, idempotent, openWorld), + Managed: true, + OwnerKind: nil, + OwnerID: nil, + }, + client: newAPIClient(defaultSlackAPIBaseURL, httpClient), + callFn: callUploadFile, + } +} + +func callUploadFile(ctx context.Context, client *apiClient, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + var input uploadFileInput + if err := decodePayload(payload, &input); err != nil { + return err + } + + filename, err := requireString("filename", input.Filename) + if err != nil { + return err + } + if strings.TrimSpace(input.ContentBase64) == "" { + return fmt.Errorf("content_base64 is required") + } + fileBytes, err := base64.StdEncoding.DecodeString(input.ContentBase64) + if err != nil { + return fmt.Errorf("decode content_base64: %w", err) + } + if len(fileBytes) == 0 { + return fmt.Errorf("content_base64 decoded to zero bytes") + } + + startRequest := map[string]any{ + "filename": filename, + "length": len(fileBytes), + } + setOptionalString(startRequest, "alt_txt", input.AltText) + setOptionalString(startRequest, "snippet_type", input.SnippetType) + + startBody, err := client.call(ctx, "files.getUploadURLExternal", startRequest, tokenPreferBot, env) + if err != nil { + return err + } + + var start getUploadURLExternalResponse + if err := json.Unmarshal(startBody, &start); err != nil { + return fmt.Errorf("decode files.getUploadURLExternal response: %w", err) + } + if strings.TrimSpace(start.UploadURL) == "" || strings.TrimSpace(start.FileID) == "" { + return fmt.Errorf("files.getUploadURLExternal returned empty upload_url or file_id") + } + + if err := postBinary(ctx, client.httpClient, start.UploadURL, fileBytes); err != nil { + return err + } + + entry := completeUploadFileEntry{ID: start.FileID, Title: strings.TrimSpace(derefString(input.Title))} + completeRequest := map[string]any{ + "files": []completeUploadFileEntry{entry}, + } + setOptionalString(completeRequest, "channel_id", input.ChannelID) + setOptionalString(completeRequest, "initial_comment", input.InitialComment) + setOptionalString(completeRequest, "thread_ts", input.ThreadTS) + + body, err := client.call(ctx, "files.completeUploadExternal", completeRequest, tokenPreferBot, env) + if err != nil { + return err + } + return writeResponse(wr, body) +} + +func postBinary(ctx context.Context, httpClient *guardian.HTTPClient, uploadURL string, data []byte) error { + if httpClient == nil { + return fmt.Errorf("slack HTTP client not configured") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("build slack upload request: %w", err) + } + req.Header.Set("Content-Type", "application/octet-stream") + req.ContentLength = int64(len(data)) + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("upload file bytes to slack: %w", err) + } + defer o11y.NoLogDefer(func() error { return resp.Body.Close() }) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("slack upload URL returned %d: %s", resp.StatusCode, string(body)) + } + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + return fmt.Errorf("drain slack upload response: %w", err) + } + return nil +} diff --git a/server/internal/platformtools/slack/tool_upload_file_test.go b/server/internal/platformtools/slack/tool_upload_file_test.go new file mode 100644 index 0000000000..bf9ff72ff8 --- /dev/null +++ b/server/internal/platformtools/slack/tool_upload_file_test.go @@ -0,0 +1,158 @@ +package slack + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUploadFileTool_RunsThreeStepFlow(t *testing.T) { + t.Parallel() + + fileBytes := []byte("hello slack from gram") + fileBase64 := base64.StdEncoding.EncodeToString(fileBytes) + + var ( + getURLForm url.Values + uploadedBody []byte + uploadedCT string + uploadedAuth string + completeForm url.Values + ) + + mux := http.NewServeMux() + mux.HandleFunc("/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { + getURLForm = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + uploadURL := "http://" + r.Host + "/upload/v1/ABC" + resp := map[string]any{ + "ok": true, + "upload_url": uploadURL, + "file_id": "F123", + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("encode response: %v", err) + } + }) + mux.HandleFunc("/upload/v1/ABC", func(w http.ResponseWriter, r *http.Request) { + uploadedCT = r.Header.Get("Content-Type") + uploadedAuth = r.Header.Get("Authorization") + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read upload body: %v", err) + return + } + uploadedBody = body + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK - " + http.StatusText(http.StatusOK))) + }) + mux.HandleFunc("/files.completeUploadExternal", func(w http.ResponseWriter, r *http.Request) { + completeForm = readForm(t, r) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"files":[{"id":"F123","title":"my title"}]}`)) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + tool := &slackTool{ + descriptor: NewUploadFileTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callUploadFile, + } + + input := map[string]any{ + "filename": "notes.txt", + "content_base64": fileBase64, + "title": "my title", + "alt_text": "screen reader text", + "snippet_type": "text", + "channel_id": "C1,C2", + "initial_comment": "here you go", + "thread_ts": "1700000000.000100", + } + payload, err := json.Marshal(input) + require.NoError(t, err) + + var out bytes.Buffer + err = tool.Call(t.Context(), testSlackEnv(), bytes.NewReader(payload), &out) + require.NoError(t, err) + + require.Equal(t, "notes.txt", getURLForm.Get("filename")) + require.Equal(t, "21", getURLForm.Get("length")) + require.Equal(t, "screen reader text", getURLForm.Get("alt_txt")) + require.Equal(t, "text", getURLForm.Get("snippet_type")) + + require.Equal(t, "application/octet-stream", uploadedCT) + require.Empty(t, uploadedAuth) + require.Equal(t, fileBytes, uploadedBody) + + require.Equal(t, "C1,C2", completeForm.Get("channel_id")) + require.Equal(t, "here you go", completeForm.Get("initial_comment")) + require.Equal(t, "1700000000.000100", completeForm.Get("thread_ts")) + + var files []map[string]any + require.NoError(t, json.Unmarshal([]byte(completeForm.Get("files")), &files)) + require.Len(t, files, 1) + require.Equal(t, "F123", files[0]["id"]) + require.Equal(t, "my title", files[0]["title"]) + + require.JSONEq(t, `{"ok":true,"files":[{"id":"F123","title":"my title"}]}`, out.String()) +} + +func TestUploadFileTool_RejectsBadBase64(t *testing.T) { + t.Parallel() + + tool := &slackTool{ + descriptor: NewUploadFileTool(nil).Descriptor(), + client: newAPIClient("https://slack.test.invalid", nil), + callFn: callUploadFile, + } + + err := tool.Call(t.Context(), testSlackEnv(), strings.NewReader(`{"filename":"a.txt","content_base64":"!!!not-base64!!!"}`), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "content_base64") +} + +func TestUploadFileTool_StopsWhenStartReturnsError(t *testing.T) { + t.Parallel() + + var completeCalls int + mux := http.NewServeMux() + mux.HandleFunc("/files.getUploadURLExternal", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"file_too_large"}`)) + }) + mux.HandleFunc("/files.completeUploadExternal", func(w http.ResponseWriter, r *http.Request) { + completeCalls++ + w.WriteHeader(http.StatusInternalServerError) + }) + server := httptest.NewServer(mux) + defer server.Close() + + tool := &slackTool{ + descriptor: NewUploadFileTool(nil).Descriptor(), + client: newAPIClient(server.URL, server.Client()), + callFn: callUploadFile, + } + + payload := map[string]any{ + "filename": "huge.bin", + "content_base64": base64.StdEncoding.EncodeToString([]byte("xxx")), + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + err = tool.Call(t.Context(), testSlackEnv(), bytes.NewReader(body), io.Discard) + require.Error(t, err) + require.ErrorContains(t, err, "file_too_large") + require.Equal(t, 0, completeCalls) +}