diff --git a/bun.lock b/bun.lock
index 7411c097e..580b4eaed 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "@metorial/integrations-root",
@@ -5373,7 +5374,7 @@
},
"integrations/google-cloud-speech": {
"name": "@slates-integrations/google-cloud-speech",
- "version": "0.2.0-rc.3",
+ "version": "0.2.0-rc.4",
"dependencies": {
"@types/node": "^20",
"slates": "1.0.0-rc.9",
@@ -5387,7 +5388,7 @@
},
"integrations/google-cloud-storage": {
"name": "@slates-integrations/google-cloud-storage",
- "version": "0.2.0-rc.3",
+ "version": "0.2.0-rc.4",
"dependencies": {
"@types/node": "^20",
"slates": "1.0.0-rc.9",
@@ -5569,7 +5570,7 @@
},
"integrations/google-tasks": {
"name": "@slates-integrations/google-tasks",
- "version": "0.2.0-rc.3",
+ "version": "0.2.0-rc.4",
"dependencies": {
"@types/node": "^20",
"slates": "1.0.0-rc.9",
@@ -11118,9 +11119,10 @@
},
"integrations/slack": {
"name": "@slates-integrations/slack",
- "version": "0.2.0-rc.5",
+ "version": "0.2.0-rc.10",
"dependencies": {
- "@slates/slack-tools": "1.0.0-rc.1",
+ "@lowerdeck/error": "^1.1.0",
+ "@slates/slack-tools": "1.0.0-rc.4",
"@types/node": "^20",
"slates": "1.0.0-rc.9",
"zod": "^4.2",
@@ -11131,19 +11133,6 @@
"vitest": "^3.1.2",
},
},
- "integrations/slack-user": {
- "name": "@slates-integrations/slack-user",
- "version": "0.2.0-rc.4",
- "dependencies": {
- "@slates/slack-tools": "1.0.0-rc.1",
- "@types/node": "^20",
- "slates": "1.0.0-rc.9",
- "zod": "^4.2",
- },
- "devDependencies": {
- "typescript": "^5",
- },
- },
"integrations/slite": {
"name": "@slates-integrations/slite",
"version": "0.2.0-rc.2",
@@ -13638,8 +13627,9 @@
},
"packages/slack-tools": {
"name": "@slates/slack-tools",
- "version": "1.0.0-rc.1",
+ "version": "1.0.0-rc.4",
"dependencies": {
+ "@lowerdeck/error": "^1.1.0",
"slates": "1.0.0-rc.9",
"zod": "^4.2",
},
@@ -13685,6 +13675,19 @@
"@types/node": "^22.15.3",
},
},
+ "test-integrations/test-attachments": {
+ "name": "@slates-integrations/test-attachments",
+ "version": "0.1.0-rc.3",
+ "dependencies": {
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.9",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
"test-integrations/test-errors": {
"name": "@slates-integrations/test-errors",
"version": "0.1.0-rc.3",
@@ -13750,6 +13753,19 @@
"vitest": "^3.1.2",
},
},
+ "test-integrations/test-triggers": {
+ "name": "@slates-integrations/test-triggers",
+ "version": "0.1.0-rc.3",
+ "dependencies": {
+ "@types/node": "^20",
+ "slates": "1.0.0-rc.9",
+ "zod": "^4.2",
+ },
+ "devDependencies": {
+ "typescript": "^5",
+ "vitest": "^3.1.2",
+ },
+ },
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -15984,8 +16000,6 @@
"@slates-integrations/slack": ["@slates-integrations/slack@workspace:integrations/slack"],
- "@slates-integrations/slack-user": ["@slates-integrations/slack-user@workspace:integrations/slack-user"],
-
"@slates-integrations/slite": ["@slates-integrations/slite@workspace:integrations/slite"],
"@slates-integrations/smartsheet": ["@slates-integrations/smartsheet@workspace:integrations/smartsheet"],
@@ -16126,6 +16140,8 @@
"@slates-integrations/terraform-cloud": ["@slates-integrations/terraform-cloud@workspace:integrations/terraform-cloud"],
+ "@slates-integrations/test-attachments": ["@slates-integrations/test-attachments@workspace:test-integrations/test-attachments"],
+
"@slates-integrations/test-errors": ["@slates-integrations/test-errors@workspace:test-integrations/test-errors"],
"@slates-integrations/test-http": ["@slates-integrations/test-http@workspace:test-integrations/test-http"],
@@ -16136,6 +16152,8 @@
"@slates-integrations/test-public-apis": ["@slates-integrations/test-public-apis@workspace:test-integrations/test-public-apis"],
+ "@slates-integrations/test-triggers": ["@slates-integrations/test-triggers@workspace:test-integrations/test-triggers"],
+
"@slates-integrations/textcortex": ["@slates-integrations/textcortex@workspace:integrations/textcortex"],
"@slates-integrations/textit": ["@slates-integrations/textit@workspace:integrations/textit"],
diff --git a/integrations/slack-user/README.md b/integrations/slack-user/README.md
deleted file mode 100644
index e68e5d32d..000000000
--- a/integrations/slack-user/README.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Slack User
-
-Act as the authorized Slack user. Send, update, delete, and schedule messages; list and cancel scheduled messages; open DMs and group DMs; manage conversations, files, reactions, pins, bookmarks, reminders, and user groups; search workspace messages and files; manage the connected user's Slack status; and retrieve user, conversation, and workspace info.
-
-## Tools
-
-### Get Conversation History
-
-Retrieve message history from a Slack channel, DM, or group DM. Supports pagination, time range filtering, and fetching thread replies.
-
-### Get Conversation Info
-
-Retrieve stable metadata for a Slack conversation, including channel type, membership, topic, purpose, member count, and timestamps.
-
-### Get Team Info
-
-Retrieve information about the Slack workspace (team), including its name, domain, email domain, and icon.
-
-### Get User Info
-
-Look up a Slack user's profile and status. Search by user ID, email address, or list all workspace members.
-
-### List Conversations
-
-List Slack conversations (channels, private channels, DMs, and group DMs) accessible to the authenticated user or bot. Supports filtering by conversation type and pagination.
-
-### Manage Bookmarks
-
-Add, edit, remove, or list bookmarks (saved links) in a Slack channel. Bookmarks appear at the top of a channel for quick access.
-
-### Manage Channel Members
-
-Invite users to or remove users from a Slack channel. Also supports listing current channel members and joining/leaving channels.
-
-### Manage Channel
-
-Create, update, archive, unarchive, or configure a Slack channel. Combine multiple channel operations in a single action — create a new channel, rename it, set its topic/purpose, or manage its lifecycle.
-
-### Manage Files
-
-Upload, list, get info about, or delete files in Slack. Upload text content as a file snippet, retrieve file metadata, or list files shared in a channel or by a user.
-
-### Manage Pins
-
-Pin or unpin messages in a Slack channel, or list all pinned items. Pinned messages are highlighted and easily accessible by all channel members.
-
-### Manage Reactions
-
-Add, remove, or list emoji reactions on a Slack message. Use this to react to messages, remove existing reactions, or see all reactions on a message.
-
-### Manage Reminders
-
-Create, complete, delete, or list Slack reminders. Reminders notify a user at a specified time with a custom message.
-
-### Manage Scheduled Messages
-
-List or delete Slack messages that are scheduled to be sent later.
-
-### Manage User Status
-
-Get, set, or clear the authorized Slack user's custom status.
-
-### Manage User Groups
-
-Create, update, enable, disable, or list user groups (also known as @mention handle groups) in Slack. Manage group membership by setting the full member list.
-
-### Open Conversation
-
-Open or resume a Slack direct message or group direct message with one or more users.
-
-### Schedule Message
-
-Schedule a message to be sent to a Slack channel at a future time. The message will be delivered automatically at the specified time.
-
-### Search Files
-
-Search for files across a Slack workspace by keyword query. Requires a user token with the `search:read` scope.
-
-### Search Messages
-
-Search for messages across a Slack workspace by keyword query. Results include the message text, channel, sender, and timestamp. Requires a user token with `search:read`.
-
-### Send Message
-
-Send a message to a Slack channel, group DM, or direct message conversation. Supports plain text, rich Block Kit formatting, threaded replies, and ephemeral messages visible only to a specific user.
-
-### Update Message
-
-Update or delete an existing Slack message. Use this to edit message content or remove a message entirely.
-
-## License
-
-This integration is licensed under the [FSL-1.1](https://github.com/metorial/metorial-platform/blob/dev/LICENSE).
-
-
diff --git a/integrations/slack-user/docs/SPEC.md b/integrations/slack-user/docs/SPEC.md
deleted file mode 100644
index 23c7d939d..000000000
--- a/integrations/slack-user/docs/SPEC.md
+++ /dev/null
@@ -1,198 +0,0 @@
-Now let me get the full list of event types from Slack's reference page:Now I have comprehensive information. Let me compile the specification.
-
-# Slates Specification for Slack
-
-## Overview
-
-Slack is a workplace messaging and collaboration platform by Salesforce. Its API provides programmatic access to messaging, channels, users, files, reactions, and workspace administration. The Slack Web API is an interface for querying information from and enacting change in a Slack workspace.
-
-## Authentication
-
-Slack uses **OAuth 2.0** as its primary authentication mechanism. OAuth allows a user in any Slack workspace to install your app. At the end of the OAuth flow, your app gains an access token, which opens the door to Slack API methods, events, and other features.
-
-**OAuth 2.0 Flow (V2):**
-
-1. Redirect the user to the authorization URL: `https://slack.com/oauth/v2/authorize` with query parameters including `client_id`, `scope` (bot scopes), optionally `user_scope` (user-level scopes), `redirect_uri`, and `state`.
-2. Parse the HTTP request that lands at your Redirect URL for a `code` field — that's a temporary authorization code, which expires after ten minutes.
-3. Exchange the code for an access token by calling `oauth.v2.access` with your `code`, `client_id`, and `client_secret`.
-
-**Credentials Required:**
-
-- **Client ID** and **Client Secret**: Obtained when creating a Slack app at `api.slack.com/apps`.
-- **Redirect URL**: Must be configured in the app settings under OAuth & Permissions. Must use HTTPS.
-
-**Token Types:**
-
-Slack uses bot tokens (`xoxb-` prefix), user tokens (`xoxp-` prefix), and app-level tokens (`xapp-` prefix).
-
-- **Bot Token (`xoxb-`)**: The app's independent identity. Posts messages, listens to events. This is the default token for most integrations.
-- **User Token (`xoxp-`)**: Acts on behalf of a specific user for user-centric actions.
-- **App-Level Token (`xapp-`)**: Enables Socket Mode and cross-workspace management.
-
-**Token Usage:**
-
-Authenticate your Web API requests by providing a bearer token, which identifies a single user or bot user relationship. Tokens are passed in the `Authorization: Bearer ` header.
-
-**Token Expiration:**
-
-OAuth tokens do not expire. If they are no longer needed, they can be revoked. Additionally, for Slack apps using granular permissions, you can exchange your access token for a refresh token and an expiring access token with token rotation.
-
-**Scopes:**
-
-Slack apps use OAuth scopes to govern what they can access. These are added in the app settings when building an app. You will attach these scopes to your tokens. Slack uses scopes that refer to the object they grant access to, followed by the class of actions on that object they allow (e.g., `file:write`).
-
-Key scope categories include:
-
-- `channels:read`, `channels:write`, `channels:history` — Public channel access (user token; not `channels:manage` / `channels:join` / `chat:write.public`, which are bot-oriented)
-- `groups:read`, `groups:history` — Private channel access
-- `im:read`, `im:history`, `im:write` — Direct message access
-- `mpim:read`, `mpim:history`, `mpim:write` — Group DM access
-- `chat:write` — Send messages
-- `files:read`, `files:write` — File access
-- `users:read`, `users:write` — User information
-- `reactions:read`, `reactions:write` — Emoji reactions
-- `pins:read`, `pins:write` — Pinned items
-- `usergroups:read`, `usergroups:write` — User groups
-- `team:read` — Workspace information
-- `bookmarks:read`, `bookmarks:write` — Channel bookmarks
-- `canvases:read`, `canvases:write` — Canvas documents
-- `lists:write` — Lists management
-- `search:read` — Search messages and files
-- Various `admin.*` scopes for Enterprise Grid administration
-
-## Features
-
-### Messaging
-
-Send, update, delete, and schedule messages in channels, group DMs, and direct messages. Messages support rich formatting via Block Kit, attachments, and threaded replies. You can also post ephemeral messages visible only to specific users.
-
-### Conversations (Channels) Management
-
-Create, archive, and manage conversations using conversation-specific Web APIs. This includes public channels, private channels, direct messages, and group DMs. You can invite/remove members, set topics and purposes, and retrieve conversation history.
-
-### User Management
-
-Retrieve user profiles, presence status, and workspace membership information. On Enterprise Grid plans, admin APIs allow provisioning users, assigning roles, and managing invite requests. SCIM API is available for user provisioning at scale.
-
-### File Management
-
-Upload, list, retrieve, and delete files shared within a workspace. Files can be shared to specific channels or conversations.
-
-### Reactions
-
-Add, remove, and list emoji reactions on messages, files, and file comments.
-
-### Pins & Bookmarks
-
-Pin and unpin messages in channels. Create and manage channel bookmarks (saved links).
-
-### Search
-
-Search for messages and files across the workspace, filtered by query terms. Requires a user token with the `search:read` scope.
-
-### User Groups
-
-Create, update, disable, and manage user groups (also known as handle groups). Manage group membership.
-
-### Reminders
-
-Create, list, complete, and delete reminders for users.
-
-### Incoming Webhooks
-
-Incoming webhooks allow external services to post messages directly into a specified Slack channel, providing real-time updates or notifications. These are simple URLs that accept a JSON payload and do not require a full OAuth flow.
-
-### Canvases & Lists
-
-Create and edit canvases (rich documents) and lists within Slack.
-
-### Workspace & Team Administration
-
-Access workspace information, manage preferences, and configure workspace settings. On Enterprise Grid, admin APIs provide organization-wide management of conversations, users, teams, roles, and app approvals.
-
-### Slack Connect
-
-Manage shared channels between different Slack organizations, including sending, accepting, approving, and declining invitations.
-
-### Interactive Surfaces
-
-Create and update a Home tab to give users a persistent space to interact. Empower users to invoke interaction at any time with shortcuts. Open modals to collect info and provide a space for displaying dynamic details.
-
-- **Slash Commands**: Register custom commands that users can invoke with `/command`.
-- **Shortcuts**: Global and message-level shortcuts for quick actions.
-- **Modals**: Multi-step forms and interactive dialogs.
-- **App Home**: A dedicated tab for your app per user.
-
-### Audit Logs (Enterprise Grid)
-
-Audit Logs API are tailored for building security information and event management tools. Available only for Enterprise Grid organizations.
-
-## Events
-
-The Events API is a streamlined way to build apps that respond to activities in Slack. When you use the Events API, Slack calls you. You have two options: you can either use Socket Mode or you can designate a public HTTP endpoint that your app listens on, choose what events to subscribe to, and Slack sends the appropriate events to you.
-
-The Events API leverages Slack's existing object-driven OAuth scope system to control access to events. For example, if your app has access to files through the `files:read` scope, you can choose to subscribe to any or none of the file-related events such as `file_created` and `file_deleted`.
-
-Event subscriptions are configured in the app settings and are split into **Bot Events** (received on behalf of the bot user) and **User Events** (received on behalf of users who installed the app).
-
-### Message Events
-
-Activity related to messages across channels, DMs, group DMs, and the App Home. Includes new messages, edits, deletions, and message metadata changes. Subtypes include `message.channels`, `message.groups`, `message.im`, `message.mpim`, and `message.app_home`.
-
-- Requires scopes like `channels:history`, `groups:history`, `im:history`, `mpim:history`.
-
-### Channel / Conversation Events
-
-Lifecycle events for channels and groups: creation, deletion, archiving, unarchiving, renaming, membership changes (`member_joined_channel`, `member_left_channel`), shared channel events, and channel ID changes.
-
-- Requires scopes like `channels:read`, `groups:read`.
-
-### Reaction Events
-
-When reactions (emoji) are added to or removed from messages, files, or comments (`reaction_added`, `reaction_removed`).
-
-- Requires `reactions:read` scope.
-
-### File Events
-
-File lifecycle events: `file_created`, `file_change`, `file_deleted`, `file_shared`, `file_unshared`, `file_public`.
-
-- Requires `files:read` scope.
-
-### User & Team Events
-
-User profile changes (`user_change`), new team members (`team_join`), user presence/status changes, huddle state changes, and Do Not Disturb updates (`dnd_updated`).
-
-- Requires scopes like `users:read`.
-
-### User Group Events
-
-User group (subteam) creation, updates, and membership changes (`subteam_created`, `subteam_updated`, `subteam_members_changed`).
-
-- Requires `usergroups:read` scope.
-
-### Pin Events
-
-When items are pinned or unpinned from a channel (`pin_added`, `pin_removed`).
-
-- Requires `pins:read` scope.
-
-### App Lifecycle Events
-
-With app events, you can track app uninstallation, token revocation, Enterprise org migration, and more. Includes `app_uninstalled`, `app_home_opened`, `app_mention`, `app_rate_limited`, `tokens_revoked`, and `app_requested`.
-
-### Link Shared Events
-
-When a URL domain registered by the app is shared in a message (`link_shared`), enabling URL unfurling.
-
-### Workspace Events
-
-Workspace-level changes such as `team_rename`, `team_domain_change`, `emoji_changed`, `email_domain_changed`, and workspace preference changes.
-
-### Slack Connect Events
-
-Shared channel invite lifecycle: `shared_channel_invite_received`, `shared_channel_invite_accepted`, `shared_channel_invite_approved`, `shared_channel_invite_declined`.
-
-### Invite Request Events
-
-When a user requests an invitation to a workspace (`invite_requested`). Relevant for workspaces with admin-approved invitations.
diff --git a/integrations/slack-user/logo.svg b/integrations/slack-user/logo.svg
deleted file mode 100644
index 3cf48ae2d..000000000
--- a/integrations/slack-user/logo.svg
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/integrations/slack-user/package.json b/integrations/slack-user/package.json
deleted file mode 100644
index c7d73c7ee..000000000
--- a/integrations/slack-user/package.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "name": "@slates-integrations/slack-user",
- "main": "src/index.ts",
- "type": "module",
- "scripts": {
- "build": "bunx @vercel/ncc build src/index.ts -o dist -m -s",
- "typecheck": "tsc --noEmit"
- },
- "dependencies": {
- "@slates/slack-tools": "1.0.0-rc.1",
- "@types/node": "^20",
- "slates": "1.0.0-rc.9",
- "zod": "^4.2"
- },
- "devDependencies": {
- "typescript": "^5"
- },
- "version": "0.2.0-rc.4"
-}
diff --git a/integrations/slack-user/slate.json b/integrations/slack-user/slate.json
deleted file mode 100644
index 17e9bff91..000000000
--- a/integrations/slack-user/slate.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "@metorial/slack-user",
- "description": "Slack (User): OAuth uses user tokens — API actions and messages appear as the authorized user. Use this integration for workspace search, reminders, connected-user status, scheduled message management, DMs and group DMs, and user-acting versions of messaging, conversation, file, reaction, pin, bookmark, user group, and workspace info tools. Enable scopes under User Token Scopes in the Slack app.",
- "categories": ["email-and-messaging"],
- "skills": [
- "send messages as the connected user",
- "list and cancel scheduled messages",
- "manage channels and conversations",
- "open DMs and group DMs",
- "search messages and files",
- "manage the connected user's status",
- "upload and share files",
- "manage emoji reactions",
- "create and manage reminders",
- "retrieve user profiles",
- "manage user groups",
- "pin and bookmark messages"
- ],
- "logoUrl": "https://provider-logos.metorial-cdn.com/slack.svg"
-}
diff --git a/integrations/slack-user/src/auth.ts b/integrations/slack-user/src/auth.ts
deleted file mode 100644
index aaf9fde94..000000000
--- a/integrations/slack-user/src/auth.ts
+++ /dev/null
@@ -1,319 +0,0 @@
-import { SlateAuth, createAxios } from 'slates';
-import { z } from 'zod';
-
-export let auth = SlateAuth.create()
- .output(
- z.object({
- token: z.string(),
- teamId: z.string().optional(),
- teamName: z.string().optional(),
- userId: z.string().optional()
- })
- )
- .addOauth({
- type: 'auth.oauth',
- name: 'Slack OAuth (User)',
- key: 'oauth',
-
- // User tokens only accept scopes marked for [`User`] on docs.slack.dev — not bot-only scopes
- // like chat:write.public, channels:manage, or channels:join (use channels:write for join/create).
- scopes: [
- {
- title: 'Send Messages',
- description: 'Send messages as the authorized user',
- scope: 'chat:write'
- },
-
- {
- title: 'Read Channels',
- description: 'View basic information about public channels',
- scope: 'channels:read'
- },
- {
- title: 'Manage Public Channels',
- description: 'Create, join, rename, archive public channels on the user’s behalf',
- scope: 'channels:write'
- },
- {
- title: 'Channel History',
- description: 'View messages and content in public channels',
- scope: 'channels:history'
- },
-
- {
- title: 'Read Private Channels',
- description: 'View basic information about private channels',
- scope: 'groups:read'
- },
- {
- title: 'Private Channel History',
- description: 'View messages and content in private channels',
- scope: 'groups:history'
- },
- {
- title: 'Write Private Channels',
- description: 'Manage private channels and create new ones',
- scope: 'groups:write'
- },
-
- {
- title: 'Read DMs',
- description: 'View basic information about direct messages',
- scope: 'im:read'
- },
- {
- title: 'DM History',
- description: 'View messages and content in direct messages',
- scope: 'im:history'
- },
- {
- title: 'Write DMs',
- description: 'Start direct messages with people',
- scope: 'im:write'
- },
-
- {
- title: 'Read Group DMs',
- description: 'View basic information about group direct messages',
- scope: 'mpim:read'
- },
- {
- title: 'Group DM History',
- description: 'View messages and content in group direct messages',
- scope: 'mpim:history'
- },
- {
- title: 'Write Group DMs',
- description: 'Start group direct messages with people',
- scope: 'mpim:write'
- },
-
- { title: 'Read Users', description: 'View people in a workspace', scope: 'users:read' },
- {
- title: 'Read User Emails',
- description: 'View email addresses of people in a workspace',
- scope: 'users:read.email'
- },
- {
- title: 'Read User Profile',
- description: 'View profile details about people in a workspace',
- scope: 'users.profile:read'
- },
- {
- title: 'Write User Profile',
- description: 'Set and clear the authorized user’s Slack status',
- scope: 'users.profile:write'
- },
-
- {
- title: 'Read Files',
- description: 'View files shared in channels and conversations',
- scope: 'files:read'
- },
- {
- title: 'Write Files',
- description: 'Upload, edit, and delete files',
- scope: 'files:write'
- },
-
- {
- title: 'Read Reactions',
- description: 'View emoji reactions and their associated content',
- scope: 'reactions:read'
- },
- {
- title: 'Write Reactions',
- description: 'Add and edit emoji reactions',
- scope: 'reactions:write'
- },
-
- {
- title: 'Read Pins',
- description: 'View pinned content in channels',
- scope: 'pins:read'
- },
- {
- title: 'Write Pins',
- description: 'Add and remove pinned messages in channels',
- scope: 'pins:write'
- },
-
- {
- title: 'Read Bookmarks',
- description: 'List bookmarks in channels',
- scope: 'bookmarks:read'
- },
- {
- title: 'Write Bookmarks',
- description: 'Add, edit, and remove bookmarks in channels',
- scope: 'bookmarks:write'
- },
-
- {
- title: 'Read User Groups',
- description: 'View user groups in a workspace',
- scope: 'usergroups:read'
- },
- {
- title: 'Write User Groups',
- description: 'Create and manage user groups',
- scope: 'usergroups:write'
- },
-
- { title: 'Read Reminders', description: 'View reminders', scope: 'reminders:read' },
- {
- title: 'Write Reminders',
- description: 'Add, remove, and mark reminders as complete',
- scope: 'reminders:write'
- },
-
- {
- title: 'Read Team Info',
- description: 'View the name, email domain, and icon for workspaces',
- scope: 'team:read'
- },
-
- {
- title: 'Search Workspace',
- description:
- 'Search messages and files (`search.messages` / `search.files`); add under User Token Scopes in the Slack app',
- scope: 'search:read'
- }
- ],
-
- getAuthorizationUrl: async ctx => {
- let params = new URLSearchParams({
- client_id: ctx.clientId,
- user_scope: ctx.scopes.join(','),
- redirect_uri: ctx.redirectUri,
- state: ctx.state
- });
-
- return {
- url: `https://slack.com/oauth/v2/authorize?${params.toString()}`
- };
- },
-
- handleCallback: async ctx => {
- let client = createAxios({ baseURL: 'https://slack.com/api' });
-
- let response = await client.post('/oauth.v2.access', null, {
- params: {
- code: ctx.code,
- client_id: ctx.clientId,
- client_secret: ctx.clientSecret,
- redirect_uri: ctx.redirectUri
- }
- });
-
- let data = response.data as {
- ok: boolean;
- authed_user?: {
- id?: string;
- access_token?: string;
- scope?: string;
- };
- team?: { id?: string; name?: string };
- error?: string;
- };
-
- let token = data.authed_user?.access_token;
- if (!data.ok || !token) {
- throw new Error(`Slack OAuth error: ${data.error || 'missing user access token'}`);
- }
-
- return {
- output: {
- token,
- teamId: data.team?.id,
- teamName: data.team?.name,
- userId: data.authed_user?.id
- }
- };
- },
-
- getProfile: async (ctx: { output: { token: string }; input: {}; scopes: string[] }) => {
- let client = createAxios({ baseURL: 'https://slack.com/api' });
-
- let response = await client.get('/auth.test', {
- headers: { Authorization: `Bearer ${ctx.output.token}` }
- });
-
- let data = response.data as {
- ok: boolean;
- user_id?: string;
- user?: string;
- team_id?: string;
- team?: string;
- url?: string;
- };
-
- let profile: Record = {
- id: data.user_id,
- name: data.user,
- teamId: data.team_id,
- teamName: data.team
- };
-
- try {
- let teamResponse = await client.get('/team.info', {
- headers: { Authorization: `Bearer ${ctx.output.token}` }
- });
-
- let teamData = teamResponse.data as {
- ok: boolean;
- team?: { icon?: { image_132?: string } };
- };
-
- if (teamData.ok && teamData.team?.icon?.image_132) {
- profile.imageUrl = teamData.team.icon.image_132;
- }
- } catch {
- // Ignore if team.info fails
- }
-
- return { profile };
- }
- })
- .addTokenAuth({
- type: 'auth.token',
- name: 'User Token',
- key: 'user_token',
-
- inputSchema: z.object({
- token: z.string().describe('Slack user token (starts with xoxp-)')
- }),
-
- getOutput: async ctx => {
- return {
- output: {
- token: ctx.input.token
- }
- };
- },
-
- getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => {
- let client = createAxios({ baseURL: 'https://slack.com/api' });
-
- let response = await client.get('/auth.test', {
- headers: { Authorization: `Bearer ${ctx.output.token}` }
- });
-
- let data = response.data as {
- ok: boolean;
- user_id?: string;
- user?: string;
- team_id?: string;
- team?: string;
- };
-
- return {
- profile: {
- id: data.user_id,
- name: data.user,
- teamId: data.team_id,
- teamName: data.team
- }
- };
- }
- });
diff --git a/integrations/slack-user/src/config.ts b/integrations/slack-user/src/config.ts
deleted file mode 100644
index f32a0aed8..000000000
--- a/integrations/slack-user/src/config.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { SlateConfig } from 'slates';
-import { z } from 'zod';
-
-export let config = SlateConfig.create(z.object({}));
diff --git a/integrations/slack-user/src/index.ts b/integrations/slack-user/src/index.ts
deleted file mode 100644
index d48ae5534..000000000
--- a/integrations/slack-user/src/index.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Slate } from 'slates';
-import { spec } from './spec';
-import {
- sendMessage,
- updateMessage,
- scheduleMessage,
- manageScheduledMessages,
- getConversationHistory,
- getConversationInfo,
- openConversation,
- listConversations,
- manageChannel,
- manageChannelMembers,
- getUserInfo,
- manageUserStatus,
- manageReactions,
- managePins,
- manageFiles,
- searchMessages,
- searchFiles,
- manageReminders,
- manageUserGroups,
- manageBookmarks,
- getTeamInfo
-} from './tools';
-import {
- newMessage,
- newMessageWebhook,
- channelActivity,
- newReaction,
- newFile,
- userChange
-} from './triggers';
-
-export let provider = Slate.create({
- spec,
- tools: [
- sendMessage,
- updateMessage,
- scheduleMessage,
- manageScheduledMessages,
- getConversationHistory,
- getConversationInfo,
- openConversation,
- listConversations,
- manageChannel,
- manageChannelMembers,
- getUserInfo,
- manageUserStatus,
- manageReactions,
- managePins,
- manageFiles,
- searchMessages,
- searchFiles,
- manageReminders,
- manageUserGroups,
- manageBookmarks,
- getTeamInfo
- ],
- triggers: [newMessage, newMessageWebhook, channelActivity, newReaction, newFile, userChange]
-});
diff --git a/integrations/slack-user/src/lib/client.ts b/integrations/slack-user/src/lib/client.ts
deleted file mode 100644
index 9347e7be4..000000000
--- a/integrations/slack-user/src/lib/client.ts
+++ /dev/null
@@ -1,855 +0,0 @@
-import { createAxios } from 'slates';
-import type {
- SlackResponse,
- SlackMessage,
- SlackConversation,
- SlackUser,
- SlackFile,
- SlackScheduledMessage,
- SlackPin,
- SlackUserGroup,
- SlackReminder,
- SlackTeamInfo,
- SlackBookmark
-} from './types';
-
-export class SlackClient {
- private axios: ReturnType;
-
- constructor(token: string) {
- this.axios = createAxios({
- baseURL: 'https://slack.com/api',
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json; charset=utf-8'
- }
- });
- }
-
- private async call(
- method: string,
- params?: Record
- ): Promise {
- let response = await this.axios.post(`/${method}`, params || {});
- let data = response.data as T;
- if (!data.ok) {
- throw new Error(`Slack API error (${method}): ${data.error || 'Unknown error'}`);
- }
- return data;
- }
-
- private async get(
- method: string,
- params?: Record
- ): Promise {
- let response = await this.axios.get(`/${method}`, { params });
- let data = response.data as T;
- if (!data.ok) {
- throw new Error(`Slack API error (${method}): ${data.error || 'Unknown error'}`);
- }
- return data;
- }
-
- // ─── Messaging ──────────────────────────────────────────────────
-
- async postMessage(params: {
- channel: string;
- text?: string;
- blocks?: any[];
- threadTs?: string;
- replyBroadcast?: boolean;
- unfurlLinks?: boolean;
- unfurlMedia?: boolean;
- mrkdwn?: boolean;
- metadata?: any;
- }): Promise {
- let body: Record = { channel: params.channel };
- if (params.text !== undefined) body.text = params.text;
- if (params.blocks) body.blocks = params.blocks;
- if (params.threadTs) body.thread_ts = params.threadTs;
- if (params.replyBroadcast) body.reply_broadcast = params.replyBroadcast;
- if (params.unfurlLinks !== undefined) body.unfurl_links = params.unfurlLinks;
- if (params.unfurlMedia !== undefined) body.unfurl_media = params.unfurlMedia;
- if (params.mrkdwn !== undefined) body.mrkdwn = params.mrkdwn;
- if (params.metadata) body.metadata = params.metadata;
-
- let data = await this.call<
- SlackResponse & { message: SlackMessage; ts: string; channel: string }
- >('chat.postMessage', body);
- return { ...data.message, ts: data.ts, channel: data.channel };
- }
-
- async postEphemeral(params: {
- channel: string;
- user: string;
- text?: string;
- blocks?: any[];
- threadTs?: string;
- }): Promise {
- let body: Record = { channel: params.channel, user: params.user };
- if (params.text !== undefined) body.text = params.text;
- if (params.blocks) body.blocks = params.blocks;
- if (params.threadTs) body.thread_ts = params.threadTs;
-
- let data = await this.call(
- 'chat.postEphemeral',
- body
- );
- return data.message_ts;
- }
-
- async updateMessage(params: {
- channel: string;
- ts: string;
- text?: string;
- blocks?: any[];
- }): Promise {
- let body: Record = { channel: params.channel, ts: params.ts };
- if (params.text !== undefined) body.text = params.text;
- if (params.blocks) body.blocks = params.blocks;
-
- let data = await this.call<
- SlackResponse & { message: SlackMessage; ts: string; channel: string }
- >('chat.update', body);
- return { ...data.message, ts: data.ts, channel: data.channel };
- }
-
- async deleteMessage(params: { channel: string; ts: string }): Promise {
- await this.call('chat.delete', { channel: params.channel, ts: params.ts });
- }
-
- async scheduleMessage(params: {
- channel: string;
- postAt: number;
- text?: string;
- blocks?: any[];
- threadTs?: string;
- }): Promise<{ scheduledMessageId: string; postAt: number }> {
- let body: Record = {
- channel: params.channel,
- post_at: params.postAt
- };
- if (params.text !== undefined) body.text = params.text;
- if (params.blocks) body.blocks = params.blocks;
- if (params.threadTs) body.thread_ts = params.threadTs;
-
- let data = await this.call<
- SlackResponse & { scheduled_message_id: string; post_at: number }
- >('chat.scheduleMessage', body);
- return { scheduledMessageId: data.scheduled_message_id, postAt: data.post_at };
- }
-
- async deleteScheduledMessage(params: {
- channel: string;
- scheduledMessageId: string;
- }): Promise {
- await this.call('chat.deleteScheduledMessage', {
- channel: params.channel,
- scheduled_message_id: params.scheduledMessageId
- });
- }
-
- async listScheduledMessages(params?: {
- channel?: string;
- oldest?: string;
- latest?: string;
- limit?: number;
- cursor?: string;
- }): Promise<{ scheduledMessages: SlackScheduledMessage[]; nextCursor?: string }> {
- let query: Record = {};
- if (params?.channel) query.channel = params.channel;
- if (params?.oldest) query.oldest = params.oldest;
- if (params?.latest) query.latest = params.latest;
- if (params?.limit) query.limit = params.limit;
- if (params?.cursor) query.cursor = params.cursor;
-
- let data = await this.get<
- SlackResponse & { scheduled_messages: SlackScheduledMessage[] }
- >('chat.scheduledMessages.list', query);
- return {
- scheduledMessages: data.scheduled_messages,
- nextCursor: data.response_metadata?.next_cursor || undefined
- };
- }
-
- async getPermalink(params: { channel: string; messageTs: string }): Promise {
- let data = await this.get('chat.getPermalink', {
- channel: params.channel,
- message_ts: params.messageTs
- });
- return data.permalink;
- }
-
- // ─── Conversations ─────────────────────────────────────────────
-
- async listConversations(params?: {
- types?: string;
- excludeArchived?: boolean;
- limit?: number;
- cursor?: string;
- }): Promise<{ channels: SlackConversation[]; nextCursor?: string }> {
- let query: Record = {};
- if (params?.types) query.types = params.types;
- if (params?.excludeArchived !== undefined) query.exclude_archived = params.excludeArchived;
- if (params?.limit) query.limit = params.limit;
- if (params?.cursor) query.cursor = params.cursor;
-
- let data = await this.get(
- 'conversations.list',
- query
- );
- return {
- channels: data.channels,
- nextCursor: data.response_metadata?.next_cursor || undefined
- };
- }
-
- async getConversationInfo(channelId: string): Promise {
- let data = await this.get(
- 'conversations.info',
- { channel: channelId }
- );
- return data.channel;
- }
-
- async createConversation(params: {
- name: string;
- isPrivate?: boolean;
- }): Promise {
- let data = await this.call(
- 'conversations.create',
- {
- name: params.name,
- is_private: params.isPrivate || false
- }
- );
- return data.channel;
- }
-
- async archiveConversation(channelId: string): Promise {
- await this.call('conversations.archive', { channel: channelId });
- }
-
- async unarchiveConversation(channelId: string): Promise {
- await this.call('conversations.unarchive', { channel: channelId });
- }
-
- async renameConversation(channelId: string, name: string): Promise {
- let data = await this.call(
- 'conversations.rename',
- {
- channel: channelId,
- name
- }
- );
- return data.channel;
- }
-
- async setConversationTopic(channelId: string, topic: string): Promise {
- let data = await this.call(
- 'conversations.setTopic',
- {
- channel: channelId,
- topic
- }
- );
- return data.channel;
- }
-
- async setConversationPurpose(
- channelId: string,
- purpose: string
- ): Promise {
- let data = await this.call(
- 'conversations.setPurpose',
- {
- channel: channelId,
- purpose
- }
- );
- return data.channel;
- }
-
- async inviteToConversation(
- channelId: string,
- userIds: string[]
- ): Promise {
- let data = await this.call(
- 'conversations.invite',
- {
- channel: channelId,
- users: userIds.join(',')
- }
- );
- return data.channel;
- }
-
- async kickFromConversation(channelId: string, userId: string): Promise {
- await this.call('conversations.kick', { channel: channelId, user: userId });
- }
-
- async joinConversation(channelId: string): Promise {
- let data = await this.call(
- 'conversations.join',
- {
- channel: channelId
- }
- );
- return data.channel;
- }
-
- async leaveConversation(channelId: string): Promise {
- await this.call('conversations.leave', { channel: channelId });
- }
-
- async getConversationMembers(
- channelId: string,
- params?: { limit?: number; cursor?: string }
- ): Promise<{ members: string[]; nextCursor?: string }> {
- let query: Record = { channel: channelId };
- if (params?.limit) query.limit = params.limit;
- if (params?.cursor) query.cursor = params.cursor;
-
- let data = await this.get(
- 'conversations.members',
- query
- );
- return {
- members: data.members,
- nextCursor: data.response_metadata?.next_cursor || undefined
- };
- }
-
- async getConversationHistory(params: {
- channel: string;
- limit?: number;
- cursor?: string;
- oldest?: string;
- latest?: string;
- inclusive?: boolean;
- }): Promise<{ messages: SlackMessage[]; hasMore: boolean; nextCursor?: string }> {
- let query: Record = { channel: params.channel };
- if (params.limit) query.limit = params.limit;
- if (params.cursor) query.cursor = params.cursor;
- if (params.oldest) query.oldest = params.oldest;
- if (params.latest) query.latest = params.latest;
- if (params.inclusive !== undefined) query.inclusive = params.inclusive;
-
- let data = await this.get(
- 'conversations.history',
- query
- );
- return {
- messages: data.messages,
- hasMore: data.has_more,
- nextCursor: data.response_metadata?.next_cursor || undefined
- };
- }
-
- async getConversationReplies(params: {
- channel: string;
- ts: string;
- limit?: number;
- cursor?: string;
- oldest?: string;
- latest?: string;
- inclusive?: boolean;
- }): Promise<{ messages: SlackMessage[]; hasMore: boolean; nextCursor?: string }> {
- let query: Record = { channel: params.channel, ts: params.ts };
- if (params.limit) query.limit = params.limit;
- if (params.cursor) query.cursor = params.cursor;
- if (params.oldest) query.oldest = params.oldest;
- if (params.latest) query.latest = params.latest;
- if (params.inclusive !== undefined) query.inclusive = params.inclusive;
-
- let data = await this.get(
- 'conversations.replies',
- query
- );
- return {
- messages: data.messages,
- hasMore: data.has_more,
- nextCursor: data.response_metadata?.next_cursor || undefined
- };
- }
-
- // ─── Users ─────────────────────────────────────────────────────
-
- async listUsers(params?: {
- limit?: number;
- cursor?: string;
- }): Promise<{ members: SlackUser[]; nextCursor?: string }> {
- let query: Record = {};
- if (params?.limit) query.limit = params.limit;
- if (params?.cursor) query.cursor = params.cursor;
-
- let data = await this.get('users.list', query);
- return {
- members: data.members,
- nextCursor: data.response_metadata?.next_cursor || undefined
- };
- }
-
- async getUserInfo(userId: string): Promise {
- let data = await this.get('users.info', {
- user: userId
- });
- return data.user;
- }
-
- async lookupUserByEmail(email: string): Promise {
- let data = await this.get('users.lookupByEmail', {
- email
- });
- return data.user;
- }
-
- async getUserProfile(userId?: string): Promise {
- let query: Record = {};
- if (userId) query.user = userId;
-
- let data = await this.get(
- 'users.profile.get',
- query
- );
- return data.profile;
- }
-
- async setUserProfile(profile: {
- statusText?: string;
- statusEmoji?: string;
- statusExpiration?: number;
- }): Promise {
- let body: Record = {
- profile: {}
- };
- if (profile.statusText !== undefined) body.profile.status_text = profile.statusText;
- if (profile.statusEmoji !== undefined) body.profile.status_emoji = profile.statusEmoji;
- if (profile.statusExpiration !== undefined) {
- body.profile.status_expiration = profile.statusExpiration;
- }
-
- let data = await this.call(
- 'users.profile.set',
- body
- );
- return data.profile;
- }
-
- // ─── Reactions ─────────────────────────────────────────────────
-
- async addReaction(params: {
- channel: string;
- timestamp: string;
- name: string;
- }): Promise {
- await this.call('reactions.add', {
- channel: params.channel,
- timestamp: params.timestamp,
- name: params.name
- });
- }
-
- async removeReaction(params: {
- channel: string;
- timestamp: string;
- name: string;
- }): Promise {
- await this.call('reactions.remove', {
- channel: params.channel,
- timestamp: params.timestamp,
- name: params.name
- });
- }
-
- async getReactions(params: { channel: string; timestamp: string }): Promise {
- let data = await this.get('reactions.get', {
- channel: params.channel,
- timestamp: params.timestamp,
- full: true
- });
- return data.message;
- }
-
- // ─── Pins ──────────────────────────────────────────────────────
-
- async addPin(params: { channel: string; timestamp: string }): Promise {
- await this.call('pins.add', { channel: params.channel, timestamp: params.timestamp });
- }
-
- async removePin(params: { channel: string; timestamp: string }): Promise {
- await this.call('pins.remove', { channel: params.channel, timestamp: params.timestamp });
- }
-
- async listPins(channelId: string): Promise {
- let data = await this.get('pins.list', {
- channel: channelId
- });
- return data.items;
- }
-
- // ─── Files ─────────────────────────────────────────────────────
-
- async uploadFile(params: {
- channels?: string;
- content?: string;
- filename?: string;
- filetype?: string;
- initialComment?: string;
- title?: string;
- threadTs?: string;
- }): Promise {
- let content = Buffer.from(params.content ?? '', 'utf8');
- let uploadBody = new URLSearchParams({
- filename: params.filename ?? params.title ?? 'upload.txt',
- length: String(content.byteLength)
- });
- if (params.filetype) uploadBody.set('snippet_type', params.filetype);
- let uploadResponseMetadata = await this.axios.post(
- '/files.getUploadURLExternal',
- uploadBody,
- {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- }
- }
- );
- let upload = uploadResponseMetadata.data as SlackResponse & {
- file_id: string;
- upload_url: string;
- };
- if (!upload.ok) {
- throw new Error(
- `Slack API error (files.getUploadURLExternal): ${upload.error || 'Unknown error'}`
- );
- }
-
- let uploadResponse = await fetch(upload.upload_url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/octet-stream'
- },
- body: content
- });
- if (!uploadResponse.ok) {
- throw new Error(`Slack file upload failed: HTTP ${uploadResponse.status}`);
- }
-
- let completeBody: Record = {
- files: [
- {
- id: upload.file_id,
- title: params.title ?? params.filename
- }
- ]
- };
- let channelIds = params.channels
- ?.split(',')
- .map(channel => channel.trim())
- .filter(Boolean);
- if (channelIds?.length === 1) completeBody.channel_id = channelIds[0];
- if (channelIds && channelIds.length > 1) completeBody.channels = channelIds.join(',');
- if (params.initialComment) completeBody.initial_comment = params.initialComment;
- if (params.threadTs) completeBody.thread_ts = params.threadTs;
-
- let data = await this.call(
- 'files.completeUploadExternal',
- completeBody
- );
- return data.files[0]!;
- }
-
- async listFiles(params?: {
- channel?: string;
- user?: string;
- types?: string;
- count?: number;
- page?: number;
- tsFrom?: string;
- tsTo?: string;
- }): Promise<{ files: SlackFile[]; paging: any }> {
- let query: Record = {};
- if (params?.channel) query.channel = params.channel;
- if (params?.user) query.user = params.user;
- if (params?.types) query.types = params.types;
- if (params?.count) query.count = params.count;
- if (params?.page) query.page = params.page;
- if (params?.tsFrom) query.ts_from = params.tsFrom;
- if (params?.tsTo) query.ts_to = params.tsTo;
-
- let data = await this.get(
- 'files.list',
- query
- );
- return { files: data.files, paging: data.paging };
- }
-
- async getFileInfo(fileId: string): Promise {
- let data = await this.get('files.info', {
- file: fileId
- });
- return data.file;
- }
-
- async deleteFile(fileId: string): Promise {
- await this.call('files.delete', { file: fileId });
- }
-
- // ─── User Groups ──────────────────────────────────────────────
-
- async listUserGroups(params?: {
- includeUsers?: boolean;
- includeCount?: boolean;
- includeDisabled?: boolean;
- }): Promise {
- let query: Record = {};
- if (params?.includeUsers) query.include_users = params.includeUsers;
- if (params?.includeCount) query.include_count = params.includeCount;
- if (params?.includeDisabled) query.include_disabled = params.includeDisabled;
-
- let data = await this.get(
- 'usergroups.list',
- query
- );
- return data.usergroups;
- }
-
- async createUserGroup(params: {
- name: string;
- handle?: string;
- description?: string;
- channels?: string[];
- }): Promise {
- let body: Record = { name: params.name };
- if (params.handle) body.handle = params.handle;
- if (params.description) body.description = params.description;
- if (params.channels) body.channels = params.channels.join(',');
-
- let data = await this.call(
- 'usergroups.create',
- body
- );
- return data.usergroup;
- }
-
- async updateUserGroup(params: {
- usergroupId: string;
- name?: string;
- handle?: string;
- description?: string;
- channels?: string[];
- }): Promise {
- let body: Record = { usergroup: params.usergroupId };
- if (params.name) body.name = params.name;
- if (params.handle) body.handle = params.handle;
- if (params.description) body.description = params.description;
- if (params.channels) body.channels = params.channels.join(',');
-
- let data = await this.call(
- 'usergroups.update',
- body
- );
- return data.usergroup;
- }
-
- async disableUserGroup(usergroupId: string): Promise {
- let data = await this.call(
- 'usergroups.disable',
- { usergroup: usergroupId }
- );
- return data.usergroup;
- }
-
- async enableUserGroup(usergroupId: string): Promise {
- let data = await this.call(
- 'usergroups.enable',
- { usergroup: usergroupId }
- );
- return data.usergroup;
- }
-
- async updateUserGroupMembers(
- usergroupId: string,
- userIds: string[]
- ): Promise {
- let data = await this.call(
- 'usergroups.users.update',
- {
- usergroup: usergroupId,
- users: userIds.join(',')
- }
- );
- return data.usergroup;
- }
-
- async listUserGroupMembers(usergroupId: string): Promise {
- let data = await this.get('usergroups.users.list', {
- usergroup: usergroupId
- });
- return data.users;
- }
-
- // ─── Reminders ─────────────────────────────────────────────────
-
- async addReminder(params: {
- text: string;
- time: string | number;
- user?: string;
- }): Promise {
- let body: Record = { text: params.text, time: params.time };
- if (params.user) body.user = params.user;
-
- let data = await this.call(
- 'reminders.add',
- body
- );
- return data.reminder;
- }
-
- async completeReminder(reminderId: string): Promise {
- await this.call('reminders.complete', { reminder: reminderId });
- }
-
- async deleteReminder(reminderId: string): Promise {
- await this.call('reminders.delete', { reminder: reminderId });
- }
-
- async listReminders(): Promise {
- let data = await this.get(
- 'reminders.list'
- );
- return data.reminders;
- }
-
- // ─── Bookmarks ─────────────────────────────────────────────────
-
- async addBookmark(params: {
- channelId: string;
- title: string;
- type: string;
- link?: string;
- emoji?: string;
- }): Promise {
- let data = await this.call('bookmarks.add', {
- channel_id: params.channelId,
- title: params.title,
- type: params.type,
- link: params.link,
- emoji: params.emoji
- });
- return data.bookmark;
- }
-
- async editBookmark(params: {
- channelId: string;
- bookmarkId: string;
- title?: string;
- link?: string;
- emoji?: string;
- }): Promise {
- let body: Record = {
- channel_id: params.channelId,
- bookmark_id: params.bookmarkId
- };
- if (params.title) body.title = params.title;
- if (params.link) body.link = params.link;
- if (params.emoji) body.emoji = params.emoji;
-
- let data = await this.call(
- 'bookmarks.edit',
- body
- );
- return data.bookmark;
- }
-
- async removeBookmark(channelId: string, bookmarkId: string): Promise {
- await this.call('bookmarks.remove', { channel_id: channelId, bookmark_id: bookmarkId });
- }
-
- async listBookmarks(channelId: string): Promise {
- let data = await this.call(
- 'bookmarks.list',
- { channel_id: channelId }
- );
- return data.bookmarks;
- }
-
- // ─── Team ──────────────────────────────────────────────────────
-
- async getTeamInfo(): Promise {
- let data = await this.get('team.info');
- return data.team;
- }
-
- // ─── Search (requires user token) ─────────────────────────────
-
- async searchMessages(params: {
- query: string;
- sort?: string;
- sortDir?: string;
- count?: number;
- page?: number;
- }): Promise<{ messages: { total: number; matches: any[] }; nextCursor?: string }> {
- let query: Record = { query: params.query };
- if (params.sort) query.sort = params.sort;
- if (params.sortDir) query.sort_dir = params.sortDir;
- if (params.count) query.count = params.count;
- if (params.page) query.page = params.page;
-
- let data = await this.get(
- 'search.messages',
- query
- );
- return { messages: data.messages };
- }
-
- async searchFiles(params: {
- query: string;
- sort?: string;
- sortDir?: string;
- count?: number;
- page?: number;
- }): Promise<{ files: { total: number; matches: any[] } }> {
- let query: Record = { query: params.query };
- if (params.sort) query.sort = params.sort;
- if (params.sortDir) query.sort_dir = params.sortDir;
- if (params.count) query.count = params.count;
- if (params.page) query.page = params.page;
-
- let data = await this.get(
- 'search.files',
- query
- );
- return { files: data.files };
- }
-
- // ─── Open Conversation (DM) ───────────────────────────────────
-
- async openConversation(params: {
- users?: string;
- channel?: string;
- returnIm?: boolean;
- }): Promise<{
- channel: SlackConversation;
- alreadyOpen?: boolean;
- noOp?: boolean;
- }> {
- let body: Record = {};
- if (params.users) body.users = params.users;
- if (params.channel) body.channel = params.channel;
- if (params.returnIm !== undefined) body.return_im = params.returnIm;
-
- let data = await this.call<
- SlackResponse & {
- channel: SlackConversation;
- already_open?: boolean;
- no_op?: boolean;
- }
- >('conversations.open', body);
- return {
- channel: data.channel,
- alreadyOpen: data.already_open,
- noOp: data.no_op
- };
- }
-}
diff --git a/integrations/slack-user/src/lib/types.ts b/integrations/slack-user/src/lib/types.ts
deleted file mode 100644
index e55174d46..000000000
--- a/integrations/slack-user/src/lib/types.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-// Slack API response types
-
-export interface SlackResponse {
- ok: boolean;
- error?: string;
- response_metadata?: {
- next_cursor?: string;
- scopes?: string[];
- };
-}
-
-export interface SlackMessage {
- type?: string;
- subtype?: string;
- ts: string;
- text?: string;
- user?: string;
- bot_id?: string;
- team?: string;
- channel?: string;
- thread_ts?: string;
- reply_count?: number;
- reply_users_count?: number;
- latest_reply?: string;
- blocks?: any[];
- attachments?: any[];
- edited?: { user: string; ts: string };
- reactions?: { name: string; users: string[]; count: number }[];
- files?: SlackFile[];
- pinned_to?: string[];
-}
-
-export interface SlackScheduledMessage {
- id?: string;
- scheduled_message_id?: string;
- channel_id?: string;
- post_at?: number;
- date_created?: number;
- text?: string;
-}
-
-export interface SlackConversation {
- id: string;
- name?: string;
- is_channel?: boolean;
- is_group?: boolean;
- is_im?: boolean;
- is_mpim?: boolean;
- is_private?: boolean;
- is_archived?: boolean;
- is_general?: boolean;
- is_shared?: boolean;
- is_org_shared?: boolean;
- is_member?: boolean;
- creator?: string;
- name_normalized?: string;
- num_members?: number;
- topic?: { value: string; creator: string; last_set: number };
- purpose?: { value: string; creator: string; last_set: number };
- created?: number;
- updated?: number;
- unlinked?: number;
-}
-
-export interface SlackUser {
- id: string;
- team_id?: string;
- name?: string;
- deleted?: boolean;
- real_name?: string;
- tz?: string;
- tz_label?: string;
- tz_offset?: number;
- profile?: SlackUserProfile;
- is_admin?: boolean;
- is_owner?: boolean;
- is_primary_owner?: boolean;
- is_restricted?: boolean;
- is_ultra_restricted?: boolean;
- is_bot?: boolean;
- is_app_user?: boolean;
- updated?: number;
-}
-
-export interface SlackUserProfile {
- title?: string;
- phone?: string;
- skype?: string;
- real_name?: string;
- real_name_normalized?: string;
- display_name?: string;
- display_name_normalized?: string;
- status_text?: string;
- status_emoji?: string;
- status_expiration?: number;
- avatar_hash?: string;
- email?: string;
- image_24?: string;
- image_32?: string;
- image_48?: string;
- image_72?: string;
- image_192?: string;
- image_512?: string;
- image_1024?: string;
- image_original?: string;
- first_name?: string;
- last_name?: string;
-}
-
-export interface SlackFile {
- id: string;
- created?: number;
- timestamp?: number;
- name?: string;
- title?: string;
- mimetype?: string;
- filetype?: string;
- pretty_type?: string;
- user?: string;
- size?: number;
- mode?: string;
- is_external?: boolean;
- is_public?: boolean;
- url_private?: string;
- url_private_download?: string;
- permalink?: string;
- permalink_public?: string;
- channels?: string[];
- groups?: string[];
- ims?: string[];
- shares?: any;
-}
-
-export interface SlackReaction {
- name: string;
- users: string[];
- count: number;
-}
-
-export interface SlackPin {
- type: string;
- channel?: string;
- message?: SlackMessage;
- created?: number;
- created_by?: string;
-}
-
-export interface SlackUserGroup {
- id: string;
- team_id?: string;
- is_usergroup?: boolean;
- is_subteam?: boolean;
- name?: string;
- description?: string;
- handle?: string;
- is_external?: boolean;
- date_create?: number;
- date_update?: number;
- date_delete?: number;
- auto_type?: string | null;
- auto_provision?: boolean;
- created_by?: string;
- updated_by?: string;
- deleted_by?: string;
- user_count?: number;
- users?: string[];
-}
-
-export interface SlackReminder {
- id: string;
- creator?: string;
- user?: string;
- text?: string;
- recurring?: boolean;
- time?: number;
- complete_ts?: number;
-}
-
-export interface SlackTeamInfo {
- id: string;
- name?: string;
- domain?: string;
- email_domain?: string;
- icon?: {
- image_34?: string;
- image_44?: string;
- image_68?: string;
- image_88?: string;
- image_102?: string;
- image_132?: string;
- image_230?: string;
- image_original?: string;
- };
- enterprise_id?: string;
- enterprise_name?: string;
-}
-
-export interface SlackBookmark {
- id: string;
- channel_id?: string;
- title?: string;
- link?: string;
- emoji?: string;
- icon_url?: string;
- type?: string;
- date_created?: number;
- date_updated?: number;
- rank?: string;
- last_updated_by_user_id?: string;
- shortcut_id?: string;
- entity_id?: string;
-}
diff --git a/integrations/slack-user/src/spec.ts b/integrations/slack-user/src/spec.ts
deleted file mode 100644
index 85adca086..000000000
--- a/integrations/slack-user/src/spec.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { SlateSpecification } from 'slates';
-import { auth } from './auth';
-import { config } from './config';
-
-export let spec = SlateSpecification.create({
- key: 'slack_user',
- name: 'Slack (User)',
- description: undefined,
- metadata: {},
- config,
- auth
-});
diff --git a/integrations/slack-user/src/tools/get-conversation-history.ts b/integrations/slack-user/src/tools/get-conversation-history.ts
deleted file mode 100644
index a7bb36b58..000000000
--- a/integrations/slack-user/src/tools/get-conversation-history.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let messageSchema = z.object({
- ts: z.string().describe('Message timestamp'),
- text: z.string().optional().describe('Message text content'),
- userId: z.string().optional().describe('User ID of the message author'),
- threadTs: z
- .string()
- .optional()
- .describe('Thread parent timestamp if this is a thread reply'),
- replyCount: z.number().optional().describe('Number of replies in this thread'),
- subtype: z.string().optional().describe('Message subtype (e.g. bot_message, channel_join)'),
- botId: z.string().optional().describe('Bot ID if posted by a bot')
-});
-
-export let getConversationHistory = SlateTool.create(spec, {
- name: 'Get Conversation History',
- key: 'get_conversation_history',
- description: `Retrieve message history from a Slack channel, DM, or group DM. Supports pagination, time range filtering, and fetching thread replies.`,
- instructions: [
- 'To get thread replies, provide **threadTs** — the timestamp of the parent message.',
- 'Use **oldest** and **latest** timestamps to filter messages within a time range.'
- ],
- tags: {
- destructive: false,
- readOnly: true
- }
-})
- .input(
- z.object({
- channelId: z.string().describe('Channel, DM, or group DM ID'),
- threadTs: z
- .string()
- .optional()
- .describe('If provided, fetches replies in this thread instead of channel messages'),
- limit: z
- .number()
- .optional()
- .describe('Maximum number of messages to return (default 20, max 1000)'),
- cursor: z.string().optional().describe('Pagination cursor for fetching the next page'),
- oldest: z.string().optional().describe('Only messages after this Unix timestamp'),
- latest: z.string().optional().describe('Only messages before this Unix timestamp')
- })
- )
- .output(
- z.object({
- messages: z.array(messageSchema).describe('List of messages'),
- hasMore: z.boolean().describe('Whether there are more messages to fetch'),
- nextCursor: z.string().optional().describe('Cursor for the next page of results')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- if (ctx.input.threadTs) {
- let result = await client.getConversationReplies({
- channel: ctx.input.channelId,
- ts: ctx.input.threadTs,
- limit: ctx.input.limit,
- cursor: ctx.input.cursor,
- oldest: ctx.input.oldest,
- latest: ctx.input.latest
- });
-
- return {
- output: {
- messages: result.messages.map(m => ({
- ts: m.ts,
- text: m.text,
- userId: m.user,
- threadTs: m.thread_ts,
- replyCount: m.reply_count,
- subtype: m.subtype,
- botId: m.bot_id
- })),
- hasMore: result.hasMore,
- nextCursor: result.nextCursor
- },
- message: `Retrieved ${result.messages.length} thread replies in channel \`${ctx.input.channelId}\`.`
- };
- }
-
- let result = await client.getConversationHistory({
- channel: ctx.input.channelId,
- limit: ctx.input.limit,
- cursor: ctx.input.cursor,
- oldest: ctx.input.oldest,
- latest: ctx.input.latest
- });
-
- return {
- output: {
- messages: result.messages.map(m => ({
- ts: m.ts,
- text: m.text,
- userId: m.user,
- threadTs: m.thread_ts,
- replyCount: m.reply_count,
- subtype: m.subtype,
- botId: m.bot_id
- })),
- hasMore: result.hasMore,
- nextCursor: result.nextCursor
- },
- message: `Retrieved ${result.messages.length} messages from channel \`${ctx.input.channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/get-conversation-info.ts b/integrations/slack-user/src/tools/get-conversation-info.ts
deleted file mode 100644
index c020957ba..000000000
--- a/integrations/slack-user/src/tools/get-conversation-info.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createGetConversationInfoTool } from '@slates/slack-tools';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-
-export let getConversationInfo = createGetConversationInfoTool({ spec, SlackClient });
diff --git a/integrations/slack-user/src/tools/get-team-info.ts b/integrations/slack-user/src/tools/get-team-info.ts
deleted file mode 100644
index cc820d736..000000000
--- a/integrations/slack-user/src/tools/get-team-info.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let getTeamInfo = SlateTool.create(spec, {
- name: 'Get Team Info',
- key: 'get_team_info',
- description: `Retrieve information about the Slack workspace (team), including its name, domain, email domain, and icon.`,
- tags: {
- destructive: false,
- readOnly: true
- }
-})
- .input(z.object({}))
- .output(
- z.object({
- teamId: z.string().describe('Workspace team ID'),
- name: z.string().optional().describe('Workspace name'),
- domain: z.string().optional().describe('Workspace URL domain (e.g. "myworkspace")'),
- emailDomain: z.string().optional().describe('Email domain for the workspace'),
- iconUrl: z.string().optional().describe('Workspace icon URL'),
- enterpriseId: z
- .string()
- .optional()
- .describe('Enterprise Grid organization ID if applicable'),
- enterpriseName: z.string().optional().describe('Enterprise Grid organization name')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let team = await client.getTeamInfo();
-
- return {
- output: {
- teamId: team.id,
- name: team.name,
- domain: team.domain,
- emailDomain: team.email_domain,
- iconUrl: team.icon?.image_132 || team.icon?.image_230,
- enterpriseId: team.enterprise_id,
- enterpriseName: team.enterprise_name
- },
- message: `Workspace: **${team.name}** (\`${team.domain}.slack.com\`).`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/get-user-info.ts b/integrations/slack-user/src/tools/get-user-info.ts
deleted file mode 100644
index 8ecddbf2e..000000000
--- a/integrations/slack-user/src/tools/get-user-info.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let userOutputSchema = z.object({
- userId: z.string().describe('Slack user ID'),
- teamId: z.string().optional().describe('Workspace team ID'),
- name: z.string().optional().describe('Username'),
- realName: z.string().optional().describe('Full real name'),
- displayName: z.string().optional().describe('Display name'),
- email: z.string().optional().describe('Email address'),
- title: z.string().optional().describe('Job title'),
- phone: z.string().optional().describe('Phone number'),
- statusText: z.string().optional().describe('Custom status text'),
- statusEmoji: z.string().optional().describe('Custom status emoji'),
- timezone: z.string().optional().describe('Timezone label'),
- isAdmin: z.boolean().optional().describe('Whether the user is a workspace admin'),
- isOwner: z.boolean().optional().describe('Whether the user is a workspace owner'),
- isBot: z.boolean().optional().describe('Whether this is a bot user'),
- deleted: z.boolean().optional().describe('Whether the user is deactivated'),
- avatarUrl: z.string().optional().describe('User avatar image URL')
-});
-
-export let getUserInfo = SlateTool.create(spec, {
- name: 'Get User Info',
- key: 'get_user_info',
- description: `Look up a Slack user's profile and status. Search by user ID, email address, or list all workspace members.`,
- instructions: [
- 'Provide **userId** to look up a specific user.',
- 'Provide **email** to find a user by their email address.',
- 'Set **listAll** to true to list workspace members (paginated).'
- ],
- tags: {
- destructive: false,
- readOnly: true
- }
-})
- .input(
- z.object({
- userId: z.string().optional().describe('Slack user ID to look up'),
- email: z.string().optional().describe('Email address to look up a user'),
- listAll: z.boolean().optional().describe('List all workspace members'),
- limit: z.number().optional().describe('Maximum users to return when listing all'),
- cursor: z.string().optional().describe('Pagination cursor for listing all users')
- })
- )
- .output(
- z.object({
- users: z.array(userOutputSchema).describe('List of user profiles'),
- nextCursor: z.string().optional().describe('Cursor for next page when listing all users')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- let mapUser = (u: any) => ({
- userId: u.id,
- teamId: u.team_id,
- name: u.name,
- realName: u.real_name || u.profile?.real_name,
- displayName: u.profile?.display_name,
- email: u.profile?.email,
- title: u.profile?.title,
- phone: u.profile?.phone,
- statusText: u.profile?.status_text,
- statusEmoji: u.profile?.status_emoji,
- timezone: u.tz_label,
- isAdmin: u.is_admin,
- isOwner: u.is_owner,
- isBot: u.is_bot,
- deleted: u.deleted,
- avatarUrl: u.profile?.image_192 || u.profile?.image_72
- });
-
- if (ctx.input.userId) {
- let user = await client.getUserInfo(ctx.input.userId);
- return {
- output: { users: [mapUser(user)] },
- message: `Found user **${user.real_name || user.name}** (\`${user.id}\`).`
- };
- }
-
- if (ctx.input.email) {
- let user = await client.lookupUserByEmail(ctx.input.email);
- return {
- output: { users: [mapUser(user)] },
- message: `Found user **${user.real_name || user.name}** (\`${user.id}\`) by email.`
- };
- }
-
- if (ctx.input.listAll) {
- let result = await client.listUsers({
- limit: ctx.input.limit,
- cursor: ctx.input.cursor
- });
- return {
- output: {
- users: result.members.map(mapUser),
- nextCursor: result.nextCursor
- },
- message: `Listed ${result.members.length} workspace member(s).`
- };
- }
-
- throw new Error('Provide userId, email, or set listAll to true');
- })
- .build();
diff --git a/integrations/slack-user/src/tools/index.ts b/integrations/slack-user/src/tools/index.ts
deleted file mode 100644
index 6fa034018..000000000
--- a/integrations/slack-user/src/tools/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export * from './send-message';
-export * from './update-message';
-export * from './schedule-message';
-export * from './manage-scheduled-messages';
-export * from './get-conversation-history';
-export * from './get-conversation-info';
-export * from './open-conversation';
-export * from './list-conversations';
-export * from './manage-channel';
-export * from './manage-channel-members';
-export * from './get-user-info';
-export * from './manage-user-status';
-export * from './manage-reactions';
-export * from './manage-pins';
-export * from './manage-files';
-export * from './search-messages';
-export * from './search-files';
-export * from './manage-reminders';
-export * from './manage-user-groups';
-export * from './manage-bookmarks';
-export * from './get-team-info';
diff --git a/integrations/slack-user/src/tools/list-conversations.ts b/integrations/slack-user/src/tools/list-conversations.ts
deleted file mode 100644
index 68071979d..000000000
--- a/integrations/slack-user/src/tools/list-conversations.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let conversationSchema = z.object({
- channelId: z.string().describe('Channel ID'),
- name: z.string().optional().describe('Channel name'),
- isChannel: z.boolean().optional().describe('Whether this is a public channel'),
- isPrivate: z.boolean().optional().describe('Whether this is a private channel'),
- isIm: z.boolean().optional().describe('Whether this is a direct message'),
- isMpim: z.boolean().optional().describe('Whether this is a group DM'),
- isArchived: z.boolean().optional().describe('Whether the channel is archived'),
- isMember: z.boolean().optional().describe('Whether the bot/user is a member'),
- numMembers: z.number().optional().describe('Number of members in the channel'),
- topic: z.string().optional().describe('Channel topic'),
- purpose: z.string().optional().describe('Channel purpose'),
- creator: z.string().optional().describe('User ID of the channel creator')
-});
-
-export let listConversations = SlateTool.create(spec, {
- name: 'List Conversations',
- key: 'list_conversations',
- description: `List Slack conversations (channels, private channels, DMs, and group DMs) accessible to the authenticated user or bot. Supports filtering by conversation type and pagination.`,
- tags: {
- destructive: false,
- readOnly: true
- }
-})
- .input(
- z.object({
- types: z
- .string()
- .optional()
- .describe(
- 'Comma-separated conversation types to include: public_channel, private_channel, im, mpim (default: public_channel)'
- ),
- excludeArchived: z.boolean().optional().describe('Whether to exclude archived channels'),
- limit: z
- .number()
- .optional()
- .describe('Maximum number of results (default 100, max 1000)'),
- cursor: z.string().optional().describe('Pagination cursor for fetching the next page')
- })
- )
- .output(
- z.object({
- conversations: z.array(conversationSchema).describe('List of conversations'),
- nextCursor: z.string().optional().describe('Cursor for the next page of results')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- let result = await client.listConversations({
- types: ctx.input.types,
- excludeArchived: ctx.input.excludeArchived,
- limit: ctx.input.limit,
- cursor: ctx.input.cursor
- });
-
- return {
- output: {
- conversations: result.channels.map(c => ({
- channelId: c.id,
- name: c.name,
- isChannel: c.is_channel,
- isPrivate: c.is_private,
- isIm: c.is_im,
- isMpim: c.is_mpim,
- isArchived: c.is_archived,
- isMember: c.is_member,
- numMembers: c.num_members,
- topic: c.topic?.value,
- purpose: c.purpose?.value,
- creator: c.creator
- })),
- nextCursor: result.nextCursor
- },
- message: `Listed ${result.channels.length} conversations.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-bookmarks.ts b/integrations/slack-user/src/tools/manage-bookmarks.ts
deleted file mode 100644
index 3419e035b..000000000
--- a/integrations/slack-user/src/tools/manage-bookmarks.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let bookmarkSchema = z.object({
- bookmarkId: z.string().describe('Bookmark ID'),
- channelId: z.string().optional().describe('Channel ID'),
- title: z.string().optional().describe('Bookmark title'),
- link: z.string().optional().describe('Bookmark URL'),
- emoji: z.string().optional().describe('Bookmark emoji'),
- type: z.string().optional().describe('Bookmark type'),
- dateCreated: z.number().optional().describe('Unix timestamp when created')
-});
-
-export let manageBookmarks = SlateTool.create(spec, {
- name: 'Manage Bookmarks',
- key: 'manage_bookmarks',
- description: `Add, edit, remove, or list bookmarks (saved links) in a Slack channel. Bookmarks appear at the top of a channel for quick access.`,
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z.enum(['add', 'edit', 'remove', 'list']).describe('Bookmark action to perform'),
- channelId: z.string().describe('Channel ID'),
- bookmarkId: z.string().optional().describe('Bookmark ID (for edit/remove actions)'),
- title: z.string().optional().describe('Bookmark title (required for add)'),
- link: z.string().optional().describe('Bookmark URL (required for add with type "link")'),
- emoji: z.string().optional().describe('Emoji to display with the bookmark'),
- type: z.string().optional().describe('Bookmark type, usually "link" (required for add)')
- })
- )
- .output(
- z.object({
- bookmark: bookmarkSchema.optional().describe('Bookmark details (for add/edit actions)'),
- bookmarks: z
- .array(bookmarkSchema)
- .optional()
- .describe('List of bookmarks (for list action)'),
- removed: z.boolean().optional().describe('Whether the bookmark was removed')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action, channelId } = ctx.input;
-
- let mapBookmark = (b: any) => ({
- bookmarkId: b.id,
- channelId: b.channel_id,
- title: b.title,
- link: b.link,
- emoji: b.emoji ?? undefined,
- type: b.type,
- dateCreated: b.date_created
- });
-
- if (action === 'add') {
- if (!ctx.input.title) throw new Error('title is required for add action');
- let bookmark = await client.addBookmark({
- channelId,
- title: ctx.input.title,
- type: ctx.input.type || 'link',
- link: ctx.input.link,
- emoji: ctx.input.emoji
- });
- return {
- output: { bookmark: mapBookmark(bookmark) },
- message: `Added bookmark **${ctx.input.title}** to channel \`${channelId}\`.`
- };
- }
-
- if (action === 'edit') {
- if (!ctx.input.bookmarkId) throw new Error('bookmarkId is required for edit action');
- let bookmark = await client.editBookmark({
- channelId,
- bookmarkId: ctx.input.bookmarkId,
- title: ctx.input.title,
- link: ctx.input.link,
- emoji: ctx.input.emoji
- });
- return {
- output: { bookmark: mapBookmark(bookmark) },
- message: `Updated bookmark \`${ctx.input.bookmarkId}\`.`
- };
- }
-
- if (action === 'remove') {
- if (!ctx.input.bookmarkId) throw new Error('bookmarkId is required for remove action');
- await client.removeBookmark(channelId, ctx.input.bookmarkId);
- return {
- output: { removed: true },
- message: `Removed bookmark \`${ctx.input.bookmarkId}\` from channel \`${channelId}\`.`
- };
- }
-
- // list
- let bookmarks = await client.listBookmarks(channelId);
- return {
- output: { bookmarks: bookmarks.map(mapBookmark) },
- message: `Found ${bookmarks.length} bookmark(s) in channel \`${channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-channel-members.ts b/integrations/slack-user/src/tools/manage-channel-members.ts
deleted file mode 100644
index d1ed07aa3..000000000
--- a/integrations/slack-user/src/tools/manage-channel-members.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let manageChannelMembers = SlateTool.create(spec, {
- name: 'Manage Channel Members',
- key: 'manage_channel_members',
- description: `Invite users to or remove users from a Slack channel. Also supports listing current channel members and joining/leaving channels.`,
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z
- .enum(['invite', 'kick', 'join', 'leave', 'list'])
- .describe('The membership action to perform'),
- channelId: z.string().describe('Channel ID'),
- userIds: z
- .array(z.string())
- .optional()
- .describe(
- 'User IDs to invite or a single user ID to remove (for invite/kick actions)'
- ),
- limit: z.number().optional().describe('Maximum members to return (for list action)'),
- cursor: z.string().optional().describe('Pagination cursor (for list action)')
- })
- )
- .output(
- z.object({
- channelId: z.string().describe('Channel ID'),
- members: z
- .array(z.string())
- .optional()
- .describe('List of member user IDs (for list action)'),
- nextCursor: z.string().optional().describe('Cursor for next page (for list action)'),
- actionPerformed: z.string().describe('Description of the action that was performed')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action, channelId, userIds } = ctx.input;
-
- if (action === 'invite') {
- if (!userIds || userIds.length === 0)
- throw new Error('userIds is required for invite action');
- await client.inviteToConversation(channelId, userIds);
- return {
- output: {
- channelId,
- actionPerformed: `Invited ${userIds.length} user(s)`
- },
- message: `Invited ${userIds.length} user(s) to channel \`${channelId}\`.`
- };
- }
-
- if (action === 'kick') {
- if (!userIds || userIds.length === 0)
- throw new Error('userIds is required for kick action');
- for (let userId of userIds) {
- await client.kickFromConversation(channelId, userId);
- }
- return {
- output: {
- channelId,
- actionPerformed: `Removed ${userIds.length} user(s)`
- },
- message: `Removed ${userIds.length} user(s) from channel \`${channelId}\`.`
- };
- }
-
- if (action === 'join') {
- await client.joinConversation(channelId);
- return {
- output: {
- channelId,
- actionPerformed: 'Joined channel'
- },
- message: `Joined channel \`${channelId}\`.`
- };
- }
-
- if (action === 'leave') {
- await client.leaveConversation(channelId);
- return {
- output: {
- channelId,
- actionPerformed: 'Left channel'
- },
- message: `Left channel \`${channelId}\`.`
- };
- }
-
- // list
- let result = await client.getConversationMembers(channelId, {
- limit: ctx.input.limit,
- cursor: ctx.input.cursor
- });
-
- return {
- output: {
- channelId,
- members: result.members,
- nextCursor: result.nextCursor,
- actionPerformed: `Listed ${result.members.length} member(s)`
- },
- message: `Listed ${result.members.length} member(s) in channel \`${channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-channel.ts b/integrations/slack-user/src/tools/manage-channel.ts
deleted file mode 100644
index 89c0442ac..000000000
--- a/integrations/slack-user/src/tools/manage-channel.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let channelOutputSchema = z.object({
- channelId: z.string().describe('Channel ID'),
- name: z.string().optional().describe('Channel name'),
- isPrivate: z.boolean().optional().describe('Whether the channel is private'),
- isArchived: z.boolean().optional().describe('Whether the channel is archived'),
- topic: z.string().optional().describe('Channel topic'),
- purpose: z.string().optional().describe('Channel purpose')
-});
-
-export let manageChannel = SlateTool.create(spec, {
- name: 'Manage Channel',
- key: 'manage_channel',
- description: `Create, update, archive, unarchive, or configure a Slack channel. Combine multiple channel operations in a single action — create a new channel, rename it, set its topic/purpose, or manage its lifecycle.`,
- instructions: [
- 'To **create** a channel, set action to "create" and provide a name.',
- 'To **update** a channel, set action to "update" and provide the channelId plus fields to change (name, topic, purpose).',
- 'To **archive** or **unarchive**, set the corresponding action and provide the channelId.'
- ],
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z
- .enum(['create', 'update', 'archive', 'unarchive'])
- .describe('The channel management action to perform'),
- channelId: z
- .string()
- .optional()
- .describe('Channel ID (required for update, archive, unarchive)'),
- name: z
- .string()
- .optional()
- .describe('Channel name (required for create, optional for update/rename)'),
- isPrivate: z
- .boolean()
- .optional()
- .describe('Create the channel as private (only for create action)'),
- topic: z.string().optional().describe('Set the channel topic (for create or update)'),
- purpose: z.string().optional().describe('Set the channel purpose (for create or update)')
- })
- )
- .output(channelOutputSchema)
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action, channelId, name, isPrivate, topic, purpose } = ctx.input;
-
- if (action === 'create') {
- if (!name) throw new Error('Name is required to create a channel');
-
- let channel = await client.createConversation({ name, isPrivate });
-
- if (topic) await client.setConversationTopic(channel.id, topic);
- if (purpose) await client.setConversationPurpose(channel.id, purpose);
-
- return {
- output: {
- channelId: channel.id,
- name: channel.name,
- isPrivate: channel.is_private,
- isArchived: false,
- topic: topic,
- purpose: purpose
- },
- message: `Created ${isPrivate ? 'private' : 'public'} channel **#${name}** (\`${channel.id}\`).`
- };
- }
-
- if (!channelId) throw new Error('channelId is required for this action');
-
- if (action === 'archive') {
- await client.archiveConversation(channelId);
- return {
- output: { channelId, isArchived: true },
- message: `Archived channel \`${channelId}\`.`
- };
- }
-
- if (action === 'unarchive') {
- await client.unarchiveConversation(channelId);
- return {
- output: { channelId, isArchived: false },
- message: `Unarchived channel \`${channelId}\`.`
- };
- }
-
- // action === 'update'
- let updatedName: string | undefined;
- if (name) {
- let result = await client.renameConversation(channelId, name);
- updatedName = result.name;
- }
- if (topic !== undefined) await client.setConversationTopic(channelId, topic);
- if (purpose !== undefined) await client.setConversationPurpose(channelId, purpose);
-
- let info = await client.getConversationInfo(channelId);
- return {
- output: {
- channelId: info.id,
- name: info.name,
- isPrivate: info.is_private,
- isArchived: info.is_archived,
- topic: info.topic?.value,
- purpose: info.purpose?.value
- },
- message: `Updated channel \`${channelId}\`${updatedName ? ` (renamed to **#${updatedName}**)` : ''}.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-files.ts b/integrations/slack-user/src/tools/manage-files.ts
deleted file mode 100644
index 7a315e2bc..000000000
--- a/integrations/slack-user/src/tools/manage-files.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let fileSchema = z.object({
- fileId: z.string().describe('File ID'),
- name: z.string().optional().describe('Filename'),
- title: z.string().optional().describe('File title'),
- mimetype: z.string().optional().describe('MIME type'),
- filetype: z.string().optional().describe('File type identifier'),
- size: z.number().optional().describe('File size in bytes'),
- userId: z.string().optional().describe('User ID of the uploader'),
- permalink: z.string().optional().describe('Permalink to the file'),
- urlPrivate: z.string().optional().describe('Private download URL'),
- created: z.number().optional().describe('Unix timestamp when the file was created')
-});
-
-export let manageFiles = SlateTool.create(spec, {
- name: 'Manage Files',
- key: 'manage_files',
- description: `Upload, list, get info about, or delete files in Slack. Upload text content as a file snippet, retrieve file metadata, or list files shared in a channel or by a user.`,
- instructions: [
- 'To **upload**, provide content and optionally a filename, filetype, title, and channelIds to share to.',
- 'To **list**, optionally filter by channelId or userId.',
- 'To **get** file info, provide the fileId.',
- 'To **delete**, provide the fileId.'
- ],
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z.enum(['upload', 'list', 'get', 'delete']).describe('File management action'),
- fileId: z.string().optional().describe('File ID (for get/delete actions)'),
- content: z
- .string()
- .optional()
- .describe('Text content to upload as a file (for upload action)'),
- filename: z.string().optional().describe('Filename for the uploaded file'),
- filetype: z.string().optional().describe('File type (e.g. "txt", "py", "json")'),
- title: z.string().optional().describe('Title for the uploaded file'),
- channelIds: z
- .string()
- .optional()
- .describe('Comma-separated channel IDs to share the file to'),
- initialComment: z.string().optional().describe('Comment to add when sharing the file'),
- threadTs: z
- .string()
- .optional()
- .describe('Thread timestamp to share the file in a thread'),
- filterChannelId: z
- .string()
- .optional()
- .describe('Filter files by channel (for list action)'),
- filterUserId: z.string().optional().describe('Filter files by user (for list action)'),
- count: z.number().optional().describe('Number of files to return (for list action)'),
- page: z.number().optional().describe('Page number (for list action)')
- })
- )
- .output(
- z.object({
- file: fileSchema.optional().describe('File details (for upload/get actions)'),
- files: z.array(fileSchema).optional().describe('List of files (for list action)'),
- deleted: z.boolean().optional().describe('Whether the file was deleted')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action } = ctx.input;
-
- let mapFile = (f: any) => ({
- fileId: f.id,
- name: f.name,
- title: f.title,
- mimetype: f.mimetype,
- filetype: f.filetype,
- size: f.size,
- userId: f.user,
- permalink: f.permalink,
- urlPrivate: f.url_private,
- created: f.created || f.timestamp
- });
-
- if (action === 'upload') {
- if (!ctx.input.content) throw new Error('content is required for upload action');
- let file = await client.uploadFile({
- content: ctx.input.content,
- filename: ctx.input.filename,
- filetype: ctx.input.filetype,
- title: ctx.input.title,
- channels: ctx.input.channelIds,
- initialComment: ctx.input.initialComment,
- threadTs: ctx.input.threadTs
- });
- return {
- output: { file: mapFile(file) },
- message: `Uploaded file **${file.name || file.title || file.id}**.`
- };
- }
-
- if (action === 'get') {
- if (!ctx.input.fileId) throw new Error('fileId is required for get action');
- let file = await client.getFileInfo(ctx.input.fileId);
- return {
- output: { file: mapFile(file) },
- message: `Retrieved file info for **${file.name || file.title || file.id}**.`
- };
- }
-
- if (action === 'delete') {
- if (!ctx.input.fileId) throw new Error('fileId is required for delete action');
- await client.deleteFile(ctx.input.fileId);
- return {
- output: { deleted: true },
- message: `Deleted file \`${ctx.input.fileId}\`.`
- };
- }
-
- // list
- let result = await client.listFiles({
- channel: ctx.input.filterChannelId,
- user: ctx.input.filterUserId,
- count: ctx.input.count,
- page: ctx.input.page
- });
- return {
- output: { files: result.files.map(mapFile) },
- message: `Listed ${result.files.length} file(s).`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-pins.ts b/integrations/slack-user/src/tools/manage-pins.ts
deleted file mode 100644
index 40e4b32f7..000000000
--- a/integrations/slack-user/src/tools/manage-pins.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let managePins = SlateTool.create(spec, {
- name: 'Manage Pins',
- key: 'manage_pins',
- description: `Pin or unpin messages in a Slack channel, or list all pinned items. Pinned messages are highlighted and easily accessible by all channel members.`,
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z.enum(['pin', 'unpin', 'list']).describe('Pin action to perform'),
- channelId: z.string().describe('Channel ID'),
- messageTs: z
- .string()
- .optional()
- .describe('Message timestamp to pin or unpin (required for pin/unpin)')
- })
- )
- .output(
- z.object({
- channelId: z.string().describe('Channel ID'),
- pins: z
- .array(
- z.object({
- messageTs: z.string().optional().describe('Pinned message timestamp'),
- messageText: z.string().optional().describe('Pinned message text preview'),
- pinnedBy: z.string().optional().describe('User ID who pinned the item'),
- pinnedAt: z.number().optional().describe('Unix timestamp when the item was pinned')
- })
- )
- .optional()
- .describe('List of pinned items (for list action)')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action, channelId, messageTs } = ctx.input;
-
- if (action === 'pin') {
- if (!messageTs) throw new Error('messageTs is required for pin action');
- await client.addPin({ channel: channelId, timestamp: messageTs });
- return {
- output: { channelId },
- message: `Pinned message \`${messageTs}\` in channel \`${channelId}\`.`
- };
- }
-
- if (action === 'unpin') {
- if (!messageTs) throw new Error('messageTs is required for unpin action');
- await client.removePin({ channel: channelId, timestamp: messageTs });
- return {
- output: { channelId },
- message: `Unpinned message \`${messageTs}\` from channel \`${channelId}\`.`
- };
- }
-
- // list
- let pins = await client.listPins(channelId);
- return {
- output: {
- channelId,
- pins: pins.map(p => ({
- messageTs: p.message?.ts,
- messageText: p.message?.text,
- pinnedBy: p.created_by,
- pinnedAt: p.created
- }))
- },
- message: `Found ${pins.length} pinned item(s) in channel \`${channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-reactions.ts b/integrations/slack-user/src/tools/manage-reactions.ts
deleted file mode 100644
index f38c00711..000000000
--- a/integrations/slack-user/src/tools/manage-reactions.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let manageReactions = SlateTool.create(spec, {
- name: 'Manage Reactions',
- key: 'manage_reactions',
- description: `Add, remove, or list emoji reactions on a Slack message. Use this to react to messages, remove existing reactions, or see all reactions on a message.`,
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z.enum(['add', 'remove', 'list']).describe('The reaction action to perform'),
- channelId: z.string().describe('Channel ID where the message is'),
- messageTs: z.string().describe('Timestamp of the message'),
- emoji: z
- .string()
- .optional()
- .describe('Emoji name without colons (e.g. "thumbsup") — required for add/remove')
- })
- )
- .output(
- z.object({
- channelId: z.string().describe('Channel ID'),
- messageTs: z.string().describe('Message timestamp'),
- reactions: z
- .array(
- z.object({
- name: z.string().describe('Emoji name'),
- count: z.number().describe('Number of users who reacted'),
- userIds: z.array(z.string()).describe('User IDs who reacted')
- })
- )
- .optional()
- .describe('List of reactions on the message (for list action)')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action, channelId, messageTs, emoji } = ctx.input;
-
- if (action === 'add') {
- if (!emoji) throw new Error('emoji is required for add action');
- await client.addReaction({ channel: channelId, timestamp: messageTs, name: emoji });
- return {
- output: { channelId, messageTs },
- message: `Added :${emoji}: reaction to message \`${messageTs}\`.`
- };
- }
-
- if (action === 'remove') {
- if (!emoji) throw new Error('emoji is required for remove action');
- await client.removeReaction({ channel: channelId, timestamp: messageTs, name: emoji });
- return {
- output: { channelId, messageTs },
- message: `Removed :${emoji}: reaction from message \`${messageTs}\`.`
- };
- }
-
- // list
- let message = await client.getReactions({ channel: channelId, timestamp: messageTs });
- let reactions = (message.reactions || []).map(r => ({
- name: r.name,
- count: r.count,
- userIds: r.users
- }));
-
- return {
- output: { channelId, messageTs, reactions },
- message: `Found ${reactions.length} reaction(s) on message \`${messageTs}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/manage-scheduled-messages.ts b/integrations/slack-user/src/tools/manage-scheduled-messages.ts
deleted file mode 100644
index 8e75a78ab..000000000
--- a/integrations/slack-user/src/tools/manage-scheduled-messages.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createManageScheduledMessagesTool } from '@slates/slack-tools';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-
-export let manageScheduledMessages = createManageScheduledMessagesTool({ spec, SlackClient });
diff --git a/integrations/slack-user/src/tools/manage-user-groups.ts b/integrations/slack-user/src/tools/manage-user-groups.ts
deleted file mode 100644
index a0a1ed00c..000000000
--- a/integrations/slack-user/src/tools/manage-user-groups.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let userGroupSchema = z.object({
- userGroupId: z.string().describe('User group ID'),
- name: z.string().optional().describe('Group name'),
- handle: z.string().optional().describe('Group mention handle'),
- description: z.string().optional().describe('Group description'),
- userCount: z.number().optional().describe('Number of members'),
- members: z.array(z.string()).optional().describe('Member user IDs'),
- createdBy: z.string().optional().describe('User ID who created the group'),
- dateCreated: z.number().optional().describe('Unix timestamp when the group was created')
-});
-
-export let manageUserGroups = SlateTool.create(spec, {
- name: 'Manage User Groups',
- key: 'manage_user_groups',
- description: `Create, update, enable, disable, or list user groups (also known as @mention handle groups) in Slack. Manage group membership by setting the full member list.`,
- instructions: [
- 'To **set members**, use the "set_members" action with the full list of user IDs (replaces the existing member list).',
- 'The "list_members" action returns user IDs for a specific group.'
- ],
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- action: z
- .enum(['create', 'update', 'enable', 'disable', 'list', 'set_members', 'list_members'])
- .describe('User group management action'),
- userGroupId: z
- .string()
- .optional()
- .describe(
- 'User group ID (required for update/enable/disable/set_members/list_members)'
- ),
- name: z.string().optional().describe('Group name (required for create)'),
- handle: z.string().optional().describe('Group mention handle (e.g. "engineering")'),
- description: z.string().optional().describe('Group description'),
- channels: z.array(z.string()).optional().describe('Default channel IDs for the group'),
- userIds: z
- .array(z.string())
- .optional()
- .describe('Full list of member user IDs (for set_members action)'),
- includeDisabled: z.boolean().optional().describe('Include disabled groups when listing')
- })
- )
- .output(
- z.object({
- userGroup: userGroupSchema.optional().describe('User group details'),
- userGroups: z
- .array(userGroupSchema)
- .optional()
- .describe('List of user groups (for list action)'),
- members: z
- .array(z.string())
- .optional()
- .describe('Member user IDs (for list_members action)')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let { action } = ctx.input;
-
- let mapGroup = (g: any) => ({
- userGroupId: g.id,
- name: g.name,
- handle: g.handle,
- description: g.description,
- userCount: g.user_count,
- members: g.users,
- createdBy: g.created_by,
- dateCreated: g.date_create
- });
-
- if (action === 'create') {
- if (!ctx.input.name) throw new Error('name is required for create action');
- let group = await client.createUserGroup({
- name: ctx.input.name,
- handle: ctx.input.handle,
- description: ctx.input.description,
- channels: ctx.input.channels
- });
- return {
- output: { userGroup: mapGroup(group) },
- message: `Created user group **@${group.handle || group.name}** (\`${group.id}\`).`
- };
- }
-
- if (action === 'update') {
- if (!ctx.input.userGroupId) throw new Error('userGroupId is required for update action');
- let group = await client.updateUserGroup({
- usergroupId: ctx.input.userGroupId,
- name: ctx.input.name,
- handle: ctx.input.handle,
- description: ctx.input.description,
- channels: ctx.input.channels
- });
- return {
- output: { userGroup: mapGroup(group) },
- message: `Updated user group **@${group.handle || group.name}**.`
- };
- }
-
- if (action === 'enable') {
- if (!ctx.input.userGroupId) throw new Error('userGroupId is required for enable action');
- let group = await client.enableUserGroup(ctx.input.userGroupId);
- return {
- output: { userGroup: mapGroup(group) },
- message: `Enabled user group \`${ctx.input.userGroupId}\`.`
- };
- }
-
- if (action === 'disable') {
- if (!ctx.input.userGroupId)
- throw new Error('userGroupId is required for disable action');
- let group = await client.disableUserGroup(ctx.input.userGroupId);
- return {
- output: { userGroup: mapGroup(group) },
- message: `Disabled user group \`${ctx.input.userGroupId}\`.`
- };
- }
-
- if (action === 'set_members') {
- if (!ctx.input.userGroupId)
- throw new Error('userGroupId is required for set_members action');
- if (!ctx.input.userIds) throw new Error('userIds is required for set_members action');
- let group = await client.updateUserGroupMembers(
- ctx.input.userGroupId,
- ctx.input.userIds
- );
- return {
- output: { userGroup: mapGroup(group) },
- message: `Set ${ctx.input.userIds.length} member(s) for user group \`${ctx.input.userGroupId}\`.`
- };
- }
-
- if (action === 'list_members') {
- if (!ctx.input.userGroupId)
- throw new Error('userGroupId is required for list_members action');
- let members = await client.listUserGroupMembers(ctx.input.userGroupId);
- return {
- output: { members },
- message: `Found ${members.length} member(s) in user group \`${ctx.input.userGroupId}\`.`
- };
- }
-
- // list
- let groups = await client.listUserGroups({
- includeUsers: true,
- includeCount: true,
- includeDisabled: ctx.input.includeDisabled
- });
- return {
- output: { userGroups: groups.map(mapGroup) },
- message: `Listed ${groups.length} user group(s).`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/open-conversation.ts b/integrations/slack-user/src/tools/open-conversation.ts
deleted file mode 100644
index 941c5c431..000000000
--- a/integrations/slack-user/src/tools/open-conversation.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createOpenConversationTool } from '@slates/slack-tools';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-
-export let openConversation = createOpenConversationTool({ spec, SlackClient });
diff --git a/integrations/slack-user/src/tools/schedule-message.ts b/integrations/slack-user/src/tools/schedule-message.ts
deleted file mode 100644
index 92c4fc824..000000000
--- a/integrations/slack-user/src/tools/schedule-message.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let scheduleMessage = SlateTool.create(spec, {
- name: 'Schedule Message',
- key: 'schedule_message',
- description: `Schedule a message to be sent to a Slack channel at a future time. The message will be delivered automatically at the specified time.`,
- constraints: ['The post_at time must be in the future and within 120 days.'],
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- channelId: z.string().describe('Channel ID to send the scheduled message to'),
- postAt: z.number().describe('Unix timestamp (in seconds) for when to send the message'),
- text: z.string().optional().describe('Message text (supports Slack mrkdwn formatting)'),
- blocks: z.array(z.any()).optional().describe('Array of Block Kit block objects'),
- threadTs: z
- .string()
- .optional()
- .describe('Timestamp of a parent message to reply in a thread')
- })
- )
- .output(
- z.object({
- scheduledMessageId: z.string().describe('ID of the scheduled message'),
- postAt: z.number().describe('Unix timestamp when the message will be sent'),
- channelId: z.string().describe('Channel ID where the message will be sent')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- let result = await client.scheduleMessage({
- channel: ctx.input.channelId,
- postAt: ctx.input.postAt,
- text: ctx.input.text,
- blocks: ctx.input.blocks,
- threadTs: ctx.input.threadTs
- });
-
- let scheduledDate = new Date(result.postAt * 1000).toISOString();
-
- return {
- output: {
- scheduledMessageId: result.scheduledMessageId,
- postAt: result.postAt,
- channelId: ctx.input.channelId
- },
- message: `Scheduled message for **${scheduledDate}** in channel \`${ctx.input.channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/send-message.ts b/integrations/slack-user/src/tools/send-message.ts
deleted file mode 100644
index 8d34508fe..000000000
--- a/integrations/slack-user/src/tools/send-message.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let sendMessage = SlateTool.create(spec, {
- name: 'Send Message',
- key: 'send_message',
- description: `Send a message to a Slack channel, group DM, or direct message conversation. Supports plain text, rich Block Kit formatting, threaded replies, and ephemeral messages visible only to a specific user.`,
- instructions: [
- 'Provide either **text** or **blocks** (or both). If only blocks are provided, include text as a fallback for notifications.',
- 'To reply in a thread, set **threadTs** to the parent message timestamp.',
- 'For ephemeral messages, set **ephemeral** to true and provide a **targetUserId**.'
- ],
- tags: {
- destructive: false,
- readOnly: false
- }
-})
- .input(
- z.object({
- channelId: z.string().describe('Channel, DM, or group DM ID to send the message to'),
- text: z.string().optional().describe('Message text (supports Slack mrkdwn formatting)'),
- blocks: z
- .array(z.any())
- .optional()
- .describe('Array of Block Kit block objects for rich message layouts'),
- threadTs: z
- .string()
- .optional()
- .describe('Timestamp of a parent message to reply in a thread'),
- replyBroadcast: z
- .boolean()
- .optional()
- .describe('When replying in a thread, also post to the channel'),
- unfurlLinks: z.boolean().optional().describe('Enable or disable link unfurling'),
- unfurlMedia: z.boolean().optional().describe('Enable or disable media unfurling'),
- ephemeral: z
- .boolean()
- .optional()
- .describe('If true, send an ephemeral message visible only to targetUserId'),
- targetUserId: z
- .string()
- .optional()
- .describe('User ID for ephemeral messages (required when ephemeral is true)')
- })
- )
- .output(
- z.object({
- messageTs: z.string().describe('Timestamp identifier of the sent message'),
- channelId: z.string().describe('Channel ID where the message was sent')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- if (ctx.input.ephemeral) {
- if (!ctx.input.targetUserId) {
- throw new Error('targetUserId is required for ephemeral messages');
- }
- let messageTs = await client.postEphemeral({
- channel: ctx.input.channelId,
- user: ctx.input.targetUserId,
- text: ctx.input.text,
- blocks: ctx.input.blocks,
- threadTs: ctx.input.threadTs
- });
- return {
- output: {
- messageTs,
- channelId: ctx.input.channelId
- },
- message: `Sent ephemeral message to user \`${ctx.input.targetUserId}\` in channel \`${ctx.input.channelId}\`.`
- };
- }
-
- let message = await client.postMessage({
- channel: ctx.input.channelId,
- text: ctx.input.text,
- blocks: ctx.input.blocks,
- threadTs: ctx.input.threadTs,
- replyBroadcast: ctx.input.replyBroadcast,
- unfurlLinks: ctx.input.unfurlLinks,
- unfurlMedia: ctx.input.unfurlMedia
- });
-
- return {
- output: {
- messageTs: message.ts,
- channelId: message.channel || ctx.input.channelId
- },
- message: ctx.input.threadTs
- ? `Sent threaded reply in channel \`${ctx.input.channelId}\`.`
- : `Sent message to channel \`${ctx.input.channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/tools/update-message.ts b/integrations/slack-user/src/tools/update-message.ts
deleted file mode 100644
index a3fd87d90..000000000
--- a/integrations/slack-user/src/tools/update-message.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { SlateTool } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let updateMessage = SlateTool.create(spec, {
- name: 'Update Message',
- key: 'update_message',
- description: `Update or delete an existing Slack message. Use this to edit message content or remove a message entirely.`,
- tags: {
- destructive: true,
- readOnly: false
- }
-})
- .input(
- z.object({
- channelId: z.string().describe('Channel ID where the message exists'),
- messageTs: z.string().describe('Timestamp of the message to update or delete'),
- action: z.enum(['update', 'delete']).describe('Whether to update or delete the message'),
- text: z.string().optional().describe('New message text (for update action)'),
- blocks: z.array(z.any()).optional().describe('New Block Kit blocks (for update action)')
- })
- )
- .output(
- z.object({
- messageTs: z.string().describe('Timestamp of the updated/deleted message'),
- channelId: z.string().describe('Channel ID of the message'),
- deleted: z.boolean().describe('Whether the message was deleted')
- })
- )
- .handleInvocation(async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- if (ctx.input.action === 'delete') {
- await client.deleteMessage({
- channel: ctx.input.channelId,
- ts: ctx.input.messageTs
- });
- return {
- output: {
- messageTs: ctx.input.messageTs,
- channelId: ctx.input.channelId,
- deleted: true
- },
- message: `Deleted message \`${ctx.input.messageTs}\` from channel \`${ctx.input.channelId}\`.`
- };
- }
-
- let message = await client.updateMessage({
- channel: ctx.input.channelId,
- ts: ctx.input.messageTs,
- text: ctx.input.text,
- blocks: ctx.input.blocks
- });
-
- return {
- output: {
- messageTs: message.ts,
- channelId: message.channel || ctx.input.channelId,
- deleted: false
- },
- message: `Updated message \`${ctx.input.messageTs}\` in channel \`${ctx.input.channelId}\`.`
- };
- })
- .build();
diff --git a/integrations/slack-user/src/triggers/channel-activity.ts b/integrations/slack-user/src/triggers/channel-activity.ts
deleted file mode 100644
index 7a81325a5..000000000
--- a/integrations/slack-user/src/triggers/channel-activity.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let channelActivity = SlateTrigger.create(spec, {
- name: 'Channel Activity',
- key: 'channel_activity',
- description:
- '[Polling fallback] Triggers when channels are created, archived, unarchived, or their membership changes. Polls the conversations list to detect changes.'
-})
- .input(
- z.object({
- eventType: z
- .enum(['created', 'archived', 'unarchived', 'updated'])
- .describe('Type of channel event'),
- channelId: z.string().describe('Channel ID'),
- channelName: z.string().optional().describe('Channel name'),
- isPrivate: z.boolean().optional().describe('Whether the channel is private'),
- creator: z.string().optional().describe('Creator user ID')
- })
- )
- .output(
- z.object({
- channelId: z.string().describe('Channel ID'),
- channelName: z.string().optional().describe('Channel name'),
- isPrivate: z.boolean().optional().describe('Whether the channel is private'),
- isArchived: z.boolean().optional().describe('Whether the channel is archived'),
- creator: z.string().optional().describe('Creator user ID'),
- topic: z.string().optional().describe('Channel topic'),
- purpose: z.string().optional().describe('Channel purpose'),
- numMembers: z.number().optional().describe('Number of members')
- })
- )
- .polling({
- options: {
- intervalInSeconds: SlateDefaultPollingIntervalSeconds * 2
- },
-
- pollEvents: async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let state = ctx.state as {
- knownChannels?: Record;
- } | null;
- let knownChannels = state?.knownChannels || {};
-
- let result = await client.listConversations({
- types: 'public_channel,private_channel',
- limit: 200
- });
-
- let inputs: Array<{
- eventType: 'created' | 'archived' | 'unarchived' | 'updated';
- channelId: string;
- channelName?: string;
- isPrivate?: boolean;
- creator?: string;
- }> = [];
-
- let updatedKnownChannels: Record = {};
-
- for (let channel of result.channels) {
- updatedKnownChannels[channel.id] = {
- name: channel.name,
- isArchived: channel.is_archived
- };
-
- let known = knownChannels[channel.id];
-
- if (!known) {
- // Only emit if we already had state (i.e., not first poll)
- if (Object.keys(knownChannels).length > 0) {
- inputs.push({
- eventType: 'created',
- channelId: channel.id,
- channelName: channel.name,
- isPrivate: channel.is_private,
- creator: channel.creator
- });
- }
- } else {
- if (channel.is_archived && !known.isArchived) {
- inputs.push({
- eventType: 'archived',
- channelId: channel.id,
- channelName: channel.name,
- isPrivate: channel.is_private
- });
- } else if (!channel.is_archived && known.isArchived) {
- inputs.push({
- eventType: 'unarchived',
- channelId: channel.id,
- channelName: channel.name,
- isPrivate: channel.is_private
- });
- } else if (channel.name !== known.name) {
- inputs.push({
- eventType: 'updated',
- channelId: channel.id,
- channelName: channel.name,
- isPrivate: channel.is_private
- });
- }
- }
- }
-
- return {
- inputs,
- updatedState: {
- knownChannels: updatedKnownChannels
- }
- };
- },
-
- handleEvent: async ctx => {
- let client = new SlackClient(ctx.auth.token);
-
- let channelInfo: any = {};
- try {
- channelInfo = await client.getConversationInfo(ctx.input.channelId);
- } catch {
- // Channel may have been deleted
- }
-
- return {
- type: `channel.${ctx.input.eventType}`,
- id: `channel-${ctx.input.channelId}-${ctx.input.eventType}-${Date.now()}`,
- output: {
- channelId: ctx.input.channelId,
- channelName: ctx.input.channelName || channelInfo.name,
- isPrivate: ctx.input.isPrivate || channelInfo.is_private,
- isArchived: channelInfo.is_archived,
- creator: ctx.input.creator || channelInfo.creator,
- topic: channelInfo.topic?.value,
- purpose: channelInfo.purpose?.value,
- numMembers: channelInfo.num_members
- }
- };
- }
- })
- .build();
diff --git a/integrations/slack-user/src/triggers/index.ts b/integrations/slack-user/src/triggers/index.ts
deleted file mode 100644
index 658aafc55..000000000
--- a/integrations/slack-user/src/triggers/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export * from './new-message';
-export * from './new-message-webhook';
-export * from './channel-activity';
-export * from './new-reaction';
-export * from './new-file';
-export * from './user-change';
diff --git a/integrations/slack-user/src/triggers/new-file.ts b/integrations/slack-user/src/triggers/new-file.ts
deleted file mode 100644
index cd20ea6e9..000000000
--- a/integrations/slack-user/src/triggers/new-file.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let newFile = SlateTrigger.create(spec, {
- name: 'New File',
- key: 'new_file',
- description:
- '[Polling fallback] Triggers when a new file is uploaded or shared in the workspace. Polls the files list for newly created files.'
-})
- .input(
- z.object({
- fileId: z.string().describe('File ID'),
- name: z.string().optional().describe('Filename'),
- title: z.string().optional().describe('File title'),
- mimetype: z.string().optional().describe('MIME type'),
- filetype: z.string().optional().describe('File type'),
- size: z.number().optional().describe('File size in bytes'),
- userId: z.string().optional().describe('Uploader user ID'),
- created: z.number().optional().describe('Creation timestamp')
- })
- )
- .output(
- z.object({
- fileId: z.string().describe('File ID'),
- name: z.string().optional().describe('Filename'),
- title: z.string().optional().describe('File title'),
- mimetype: z.string().optional().describe('MIME type'),
- filetype: z.string().optional().describe('File type'),
- size: z.number().optional().describe('File size in bytes'),
- userId: z.string().optional().describe('Uploader user ID'),
- permalink: z.string().optional().describe('Permalink to the file'),
- urlPrivate: z.string().optional().describe('Private download URL'),
- created: z.number().optional().describe('Unix timestamp when the file was created'),
- channels: z.array(z.string()).optional().describe('Channel IDs where the file is shared')
- })
- )
- .polling({
- options: {
- intervalInSeconds: SlateDefaultPollingIntervalSeconds
- },
-
- pollEvents: async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let state = ctx.state as { lastTs?: string } | null;
- let lastTs = state?.lastTs;
-
- let result = await client.listFiles({
- count: 50,
- tsFrom: lastTs
- });
-
- let newLastTs = lastTs;
- let inputs: Array<{
- fileId: string;
- name?: string;
- title?: string;
- mimetype?: string;
- filetype?: string;
- size?: number;
- userId?: string;
- created?: number;
- }> = [];
-
- for (let file of result.files) {
- let fileTs = String(file.created || file.timestamp || 0);
- if (lastTs && fileTs <= lastTs) continue;
-
- inputs.push({
- fileId: file.id,
- name: file.name,
- title: file.title,
- mimetype: file.mimetype,
- filetype: file.filetype,
- size: file.size,
- userId: file.user,
- created: file.created || file.timestamp
- });
-
- if (!newLastTs || fileTs > newLastTs) {
- newLastTs = fileTs;
- }
- }
-
- return {
- inputs,
- updatedState: {
- lastTs: newLastTs || lastTs
- }
- };
- },
-
- handleEvent: async ctx => {
- let fileDetails: any = {};
- try {
- let client = new SlackClient(ctx.auth.token);
- fileDetails = await client.getFileInfo(ctx.input.fileId);
- } catch {
- // Couldn't fetch full file details
- }
-
- return {
- type: 'file.created',
- id: `file-${ctx.input.fileId}`,
- output: {
- fileId: ctx.input.fileId,
- name: ctx.input.name || fileDetails.name,
- title: ctx.input.title || fileDetails.title,
- mimetype: ctx.input.mimetype || fileDetails.mimetype,
- filetype: ctx.input.filetype || fileDetails.filetype,
- size: ctx.input.size || fileDetails.size,
- userId: ctx.input.userId || fileDetails.user,
- permalink: fileDetails.permalink,
- urlPrivate: fileDetails.url_private,
- created: ctx.input.created || fileDetails.created,
- channels: fileDetails.channels
- }
- };
- }
- })
- .build();
diff --git a/integrations/slack-user/src/triggers/new-message-webhook.ts b/integrations/slack-user/src/triggers/new-message-webhook.ts
deleted file mode 100644
index 45d6fe93e..000000000
--- a/integrations/slack-user/src/triggers/new-message-webhook.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { SlateTrigger } from 'slates';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-let slackEventBody = z.object({
- type: z.string(),
- challenge: z.string().optional(),
- event: z
- .object({
- type: z.string(),
- channel: z.string().optional(),
- user: z.string().optional(),
- text: z.string().optional(),
- ts: z.string().optional(),
- thread_ts: z.string().optional(),
- subtype: z.string().optional(),
- bot_id: z.string().optional()
- })
- .passthrough()
- .optional()
-});
-
-export let newMessageWebhook = SlateTrigger.create(spec, {
- name: 'New Message (Events API)',
- key: 'new_message_webhook',
- description:
- 'Triggers when Slack sends a `message` event to the Metorial Events URL. Use with Slack Event Subscriptions and hub route POST /slates-hub/slack/events. Complements the polling “New Message” trigger.'
-})
- .input(
- z.object({
- messageTs: z.string().describe('Message timestamp'),
- channelId: z.string().describe('Channel ID where the message was posted'),
- text: z.string().optional().describe('Message text'),
- userId: z.string().optional().describe('User ID of the message author'),
- threadTs: z.string().optional().describe('Thread parent timestamp'),
- subtype: z.string().optional().describe('Message subtype'),
- botId: z.string().optional().describe('Bot ID if from a bot')
- })
- )
- .output(
- z.object({
- messageTs: z.string().describe('Message timestamp'),
- channelId: z.string().describe('Channel ID'),
- text: z.string().optional().describe('Message text'),
- userId: z.string().optional().describe('User ID of the message author'),
- threadTs: z.string().optional().describe('Thread parent timestamp if a thread reply'),
- subtype: z.string().optional().describe('Message subtype'),
- botId: z.string().optional().describe('Bot ID if posted by a bot'),
- isThread: z.boolean().describe('Whether this message is a thread reply')
- })
- )
- .webhook({
- handleRequest: async ctx => {
- let raw = await ctx.request.text();
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return { inputs: [] };
- }
-
- let body = slackEventBody.safeParse(parsed);
- if (!body.success) {
- return { inputs: [] };
- }
-
- if (body.data.type === 'url_verification' && body.data.challenge) {
- return { inputs: [] };
- }
-
- if (body.data.type !== 'event_callback' || !body.data.event) {
- return { inputs: [] };
- }
-
- let ev = body.data.event;
- if (ev.type !== 'message' || !ev.ts || !ev.channel) {
- return { inputs: [] };
- }
-
- return {
- inputs: [
- {
- messageTs: ev.ts,
- channelId: ev.channel,
- text: ev.text,
- userId: ev.user,
- threadTs: ev.thread_ts,
- subtype: ev.subtype,
- botId: ev.bot_id
- }
- ]
- };
- },
-
- handleEvent: async ctx => {
- return {
- type: ctx.input.subtype ? `message.${ctx.input.subtype}` : 'message.new',
- id: `${ctx.input.channelId}-${ctx.input.messageTs}`,
- output: {
- messageTs: ctx.input.messageTs,
- channelId: ctx.input.channelId,
- text: ctx.input.text,
- userId: ctx.input.userId,
- threadTs: ctx.input.threadTs,
- subtype: ctx.input.subtype,
- botId: ctx.input.botId,
- isThread: !!ctx.input.threadTs
- }
- };
- }
- })
- .build();
diff --git a/integrations/slack-user/src/triggers/new-message.ts b/integrations/slack-user/src/triggers/new-message.ts
deleted file mode 100644
index 71ba9ba53..000000000
--- a/integrations/slack-user/src/triggers/new-message.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let newMessage = SlateTrigger.create(spec, {
- name: 'New Message',
- key: 'new_message',
- description:
- '[Polling fallback] Triggers when a new message is posted in one or more Slack channels. Polls conversation history for new messages.'
-})
- .input(
- z.object({
- messageTs: z.string().describe('Message timestamp'),
- channelId: z.string().describe('Channel ID where the message was posted'),
- text: z.string().optional().describe('Message text'),
- userId: z.string().optional().describe('User ID of the message author'),
- threadTs: z.string().optional().describe('Thread parent timestamp'),
- subtype: z.string().optional().describe('Message subtype'),
- botId: z.string().optional().describe('Bot ID if from a bot')
- })
- )
- .output(
- z.object({
- messageTs: z.string().describe('Message timestamp'),
- channelId: z.string().describe('Channel ID'),
- text: z.string().optional().describe('Message text'),
- userId: z.string().optional().describe('User ID of the message author'),
- threadTs: z.string().optional().describe('Thread parent timestamp if a thread reply'),
- subtype: z.string().optional().describe('Message subtype'),
- botId: z.string().optional().describe('Bot ID if posted by a bot'),
- isThread: z.boolean().describe('Whether this message is a thread reply')
- })
- )
- .polling({
- options: {
- intervalInSeconds: SlateDefaultPollingIntervalSeconds
- },
-
- pollEvents: async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let state = ctx.state as { lastTs?: string; channelIds?: string[] } | null;
- let lastTs = state?.lastTs;
-
- // Fetch channels the bot is a member of
- let channelResult = await client.listConversations({
- types: 'public_channel,private_channel',
- excludeArchived: true,
- limit: 100
- });
-
- let memberChannels = channelResult.channels.filter(c => c.is_member);
- let allInputs: Array<{
- messageTs: string;
- channelId: string;
- text?: string;
- userId?: string;
- threadTs?: string;
- subtype?: string;
- botId?: string;
- }> = [];
-
- let newestTs = lastTs;
-
- for (let channel of memberChannels) {
- try {
- let history = await client.getConversationHistory({
- channel: channel.id,
- oldest: lastTs,
- limit: 50
- });
-
- for (let msg of history.messages) {
- if (lastTs && msg.ts <= lastTs) continue;
-
- allInputs.push({
- messageTs: msg.ts,
- channelId: channel.id,
- text: msg.text,
- userId: msg.user,
- threadTs: msg.thread_ts,
- subtype: msg.subtype,
- botId: msg.bot_id
- });
-
- if (!newestTs || msg.ts > newestTs) {
- newestTs = msg.ts;
- }
- }
- } catch {
- // Skip channels we can't read
- }
- }
-
- return {
- inputs: allInputs,
- updatedState: {
- lastTs: newestTs || lastTs
- }
- };
- },
-
- handleEvent: async ctx => {
- return {
- type: ctx.input.subtype ? `message.${ctx.input.subtype}` : 'message.new',
- id: `${ctx.input.channelId}-${ctx.input.messageTs}`,
- output: {
- messageTs: ctx.input.messageTs,
- channelId: ctx.input.channelId,
- text: ctx.input.text,
- userId: ctx.input.userId,
- threadTs: ctx.input.threadTs,
- subtype: ctx.input.subtype,
- botId: ctx.input.botId,
- isThread: !!ctx.input.threadTs
- }
- };
- }
- })
- .build();
diff --git a/integrations/slack-user/src/triggers/new-reaction.ts b/integrations/slack-user/src/triggers/new-reaction.ts
deleted file mode 100644
index 79bdacda4..000000000
--- a/integrations/slack-user/src/triggers/new-reaction.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let newReaction = SlateTrigger.create(spec, {
- name: 'New Reaction',
- key: 'new_reaction',
- description:
- '[Polling fallback] Triggers when a new emoji reaction is added to a message. Polls recent messages in channels the bot is a member of to detect new reactions.'
-})
- .input(
- z.object({
- channelId: z.string().describe('Channel ID'),
- messageTs: z.string().describe('Message timestamp that was reacted to'),
- emoji: z.string().describe('Emoji name'),
- reactedUserIds: z.array(z.string()).describe('User IDs who reacted with this emoji'),
- count: z.number().describe('Reaction count')
- })
- )
- .output(
- z.object({
- channelId: z.string().describe('Channel ID'),
- messageTs: z.string().describe('Message timestamp'),
- messageText: z.string().optional().describe('Text of the message that was reacted to'),
- emoji: z.string().describe('Emoji name'),
- reactedUserIds: z.array(z.string()).describe('User IDs who reacted'),
- count: z.number().describe('Total reaction count for this emoji')
- })
- )
- .polling({
- options: {
- intervalInSeconds: SlateDefaultPollingIntervalSeconds
- },
-
- pollEvents: async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let state = ctx.state as {
- trackedReactions?: Record>;
- } | null;
- let trackedReactions = state?.trackedReactions || {};
-
- let channelResult = await client.listConversations({
- types: 'public_channel,private_channel',
- excludeArchived: true,
- limit: 50
- });
-
- let memberChannels = channelResult.channels.filter(c => c.is_member);
- let inputs: Array<{
- channelId: string;
- messageTs: string;
- emoji: string;
- reactedUserIds: string[];
- count: number;
- }> = [];
-
- let updatedTracked: Record> = {};
-
- for (let channel of memberChannels.slice(0, 10)) {
- try {
- let history = await client.getConversationHistory({
- channel: channel.id,
- limit: 20
- });
-
- for (let msg of history.messages) {
- if (!msg.reactions || msg.reactions.length === 0) continue;
-
- let msgKey = `${channel.id}:${msg.ts}`;
- let existingReactions = trackedReactions[msgKey] || {};
- let updatedMsgReactions: Record = {};
-
- for (let reaction of msg.reactions) {
- updatedMsgReactions[reaction.name] = reaction.count;
- let previousCount = existingReactions[reaction.name] || 0;
-
- if (reaction.count > previousCount && Object.keys(trackedReactions).length > 0) {
- inputs.push({
- channelId: channel.id,
- messageTs: msg.ts,
- emoji: reaction.name,
- reactedUserIds: reaction.users,
- count: reaction.count
- });
- }
- }
-
- updatedTracked[msgKey] = updatedMsgReactions;
- }
- } catch {
- // Skip unreadable channels
- }
- }
-
- return {
- inputs,
- updatedState: {
- trackedReactions: updatedTracked
- }
- };
- },
-
- handleEvent: async ctx => {
- let messageText: string | undefined;
- try {
- let client = new SlackClient(ctx.auth.token);
- let msgData = await client.getReactions({
- channel: ctx.input.channelId,
- timestamp: ctx.input.messageTs
- });
- messageText = msgData.text;
- } catch {
- // Couldn't fetch message text
- }
-
- return {
- type: 'reaction.added',
- id: `reaction-${ctx.input.channelId}-${ctx.input.messageTs}-${ctx.input.emoji}-${ctx.input.count}`,
- output: {
- channelId: ctx.input.channelId,
- messageTs: ctx.input.messageTs,
- messageText,
- emoji: ctx.input.emoji,
- reactedUserIds: ctx.input.reactedUserIds,
- count: ctx.input.count
- }
- };
- }
- })
- .build();
diff --git a/integrations/slack-user/src/triggers/user-change.ts b/integrations/slack-user/src/triggers/user-change.ts
deleted file mode 100644
index 6897e8344..000000000
--- a/integrations/slack-user/src/triggers/user-change.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
-import { SlackClient } from '../lib/client';
-import { spec } from '../spec';
-import { z } from 'zod';
-
-export let userChange = SlateTrigger.create(spec, {
- name: 'User Change',
- key: 'user_change',
- description:
- '[Polling fallback] Triggers when a user joins the workspace or when user profile/status changes. Polls the user list to detect new members and profile updates.'
-})
- .input(
- z.object({
- eventType: z.enum(['joined', 'updated']).describe('Type of user event'),
- userId: z.string().describe('User ID'),
- name: z.string().optional().describe('Username'),
- realName: z.string().optional().describe('Real name'),
- email: z.string().optional().describe('Email address'),
- isBot: z.boolean().optional().describe('Whether this is a bot'),
- deleted: z.boolean().optional().describe('Whether the user is deactivated'),
- updatedAt: z.number().optional().describe('Last update timestamp')
- })
- )
- .output(
- z.object({
- userId: z.string().describe('User ID'),
- name: z.string().optional().describe('Username'),
- realName: z.string().optional().describe('Full name'),
- displayName: z.string().optional().describe('Display name'),
- email: z.string().optional().describe('Email address'),
- title: z.string().optional().describe('Job title'),
- statusText: z.string().optional().describe('Custom status text'),
- statusEmoji: z.string().optional().describe('Custom status emoji'),
- isAdmin: z.boolean().optional().describe('Whether the user is an admin'),
- isBot: z.boolean().optional().describe('Whether this is a bot user'),
- deleted: z.boolean().optional().describe('Whether the user is deactivated'),
- avatarUrl: z.string().optional().describe('User avatar URL')
- })
- )
- .polling({
- options: {
- intervalInSeconds: SlateDefaultPollingIntervalSeconds * 3
- },
-
- pollEvents: async ctx => {
- let client = new SlackClient(ctx.auth.token);
- let state = ctx.state as { knownUsers?: Record } | null;
- let knownUsers = state?.knownUsers || {};
-
- let result = await client.listUsers({ limit: 200 });
- let inputs: Array<{
- eventType: 'joined' | 'updated';
- userId: string;
- name?: string;
- realName?: string;
- email?: string;
- isBot?: boolean;
- deleted?: boolean;
- updatedAt?: number;
- }> = [];
-
- let updatedKnown: Record = {};
-
- for (let user of result.members) {
- let updatedTs = user.updated || 0;
- updatedKnown[user.id] = updatedTs;
-
- let previousTs = knownUsers[user.id];
-
- if (previousTs === undefined) {
- if (Object.keys(knownUsers).length > 0) {
- inputs.push({
- eventType: 'joined',
- userId: user.id,
- name: user.name,
- realName: user.real_name,
- email: user.profile?.email,
- isBot: user.is_bot,
- deleted: user.deleted,
- updatedAt: updatedTs
- });
- }
- } else if (updatedTs > previousTs) {
- inputs.push({
- eventType: 'updated',
- userId: user.id,
- name: user.name,
- realName: user.real_name,
- email: user.profile?.email,
- isBot: user.is_bot,
- deleted: user.deleted,
- updatedAt: updatedTs
- });
- }
- }
-
- return {
- inputs,
- updatedState: {
- knownUsers: updatedKnown
- }
- };
- },
-
- handleEvent: async ctx => {
- let userDetails: any = {};
- try {
- let client = new SlackClient(ctx.auth.token);
- userDetails = await client.getUserInfo(ctx.input.userId);
- } catch {
- // Couldn't fetch full user details
- }
-
- return {
- type: `user.${ctx.input.eventType}`,
- id: `user-${ctx.input.userId}-${ctx.input.eventType}-${ctx.input.updatedAt || Date.now()}`,
- output: {
- userId: ctx.input.userId,
- name: ctx.input.name || userDetails.name,
- realName: ctx.input.realName || userDetails.real_name,
- displayName: userDetails.profile?.display_name,
- email: ctx.input.email || userDetails.profile?.email,
- title: userDetails.profile?.title,
- statusText: userDetails.profile?.status_text,
- statusEmoji: userDetails.profile?.status_emoji,
- isAdmin: userDetails.is_admin,
- isBot: ctx.input.isBot || userDetails.is_bot,
- deleted: ctx.input.deleted || userDetails.deleted,
- avatarUrl: userDetails.profile?.image_192
- }
- };
- }
- })
- .build();
diff --git a/integrations/slack-user/tsconfig.json b/integrations/slack-user/tsconfig.json
deleted file mode 100644
index 2abe72783..000000000
--- a/integrations/slack-user/tsconfig.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "compilerOptions": {
- "types": ["node"],
- "lib": ["ESNext"],
- "target": "ESNext",
- "module": "Preserve",
- "moduleDetection": "force",
- "jsx": "react-jsx",
- "allowJs": true,
- "moduleResolution": "bundler",
-
- "noEmit": true,
- "strict": true,
- "skipLibCheck": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- "noImplicitOverride": true,
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false
- },
- "include": ["src"]
-}
diff --git a/integrations/slack/README.md b/integrations/slack/README.md
index 94bc26a3d..e13ca2f77 100644
--- a/integrations/slack/README.md
+++ b/integrations/slack/README.md
@@ -1,6 +1,6 @@
# Slack
-Use a Slack bot token to send, update, delete, and schedule messages; list and cancel scheduled messages; open DMs and group DMs; manage conversations, members, files, reactions, pins, bookmarks, and user groups; and retrieve user, conversation, and workspace info.
+Use Slack bot OAuth or user OAuth to send, update, delete, and schedule messages; list and cancel scheduled messages; open DMs and group DMs; manage conversations, members, files, reactions, pins, bookmarks, reminders, user groups, and user status; search messages and files with user scopes; and retrieve user, conversation, and workspace info.
## Tools
@@ -52,6 +52,22 @@ Add, remove, or list emoji reactions on a Slack message. Use this to react to me
Create, update, enable, disable, or list user groups (also known as @mention handle groups) in Slack. Manage group membership by setting the full member list.
+### Manage User Status
+
+Get, set, or clear the authorized Slack user's custom status.
+
+### Manage Reminders
+
+Create, complete, delete, or list Slack reminders. Reminders notify a user at a specified time with a custom message.
+
+### Search Messages
+
+Search for messages across a Slack workspace by keyword query.
+
+### Search Files
+
+Search for files across a Slack workspace by keyword query.
+
### Manage Scheduled Messages
List or delete Slack messages that are scheduled to be sent later.
diff --git a/integrations/slack/package.json b/integrations/slack/package.json
index a13426e5d..97483e44b 100644
--- a/integrations/slack/package.json
+++ b/integrations/slack/package.json
@@ -8,7 +8,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@slates/slack-tools": "1.0.0-rc.1",
+ "@lowerdeck/error": "^1.1.0",
+ "@slates/slack-tools": "1.0.0-rc.4",
"@types/node": "^20",
"slates": "1.0.0-rc.9",
"zod": "^4.2"
@@ -18,5 +19,5 @@
"typescript": "^5",
"vitest": "^3.1.2"
},
- "version": "0.2.0-rc.5"
+ "version": "0.2.0-rc.10"
}
diff --git a/integrations/slack/slate.json b/integrations/slack/slate.json
index 43d74e4ea..7ef122c9c 100644
--- a/integrations/slack/slate.json
+++ b/integrations/slack/slate.json
@@ -1,12 +1,14 @@
{
"name": "@metorial/slack",
- "description": "Slack (Bot): OAuth uses bot tokens — messages and API actions appear as your Slack app. Send, update, delete, and schedule messages; list and cancel scheduled messages; open DMs and group DMs; manage conversations, members, files, reactions, pins, bookmarks, and user groups; and retrieve user, conversation, and workspace info.",
+ "description": "Slack: connect with bot OAuth or user OAuth. Send, update, delete, and schedule messages; list and cancel scheduled messages; open DMs and group DMs; manage conversations, members, files, reactions, pins, bookmarks, reminders, user groups, and user status; search messages and files with user scopes; and retrieve user, conversation, and workspace info.",
"categories": ["email-and-messaging"],
"skills": [
- "send and schedule messages",
+ "send and schedule messages as a bot or user",
"list and cancel scheduled messages",
"manage channels and conversations",
"open DMs and group DMs",
+ "search messages and files",
+ "manage reminders and user status",
"upload and share files",
"manage emoji reactions",
"retrieve user profiles",
diff --git a/integrations/slack/src/auth.contract.test.ts b/integrations/slack/src/auth.contract.test.ts
new file mode 100644
index 000000000..ac9b68584
--- /dev/null
+++ b/integrations/slack/src/auth.contract.test.ts
@@ -0,0 +1,157 @@
+import { createLocalSlateTestClient } from '@slates/test';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+let oauthPost = vi.fn();
+let profileGet = vi.fn();
+
+let loadProviderClient = async () => {
+ vi.resetModules();
+ oauthPost.mockReset();
+ profileGet.mockReset();
+
+ vi.doMock('slates', async () => {
+ let actual = await vi.importActual('slates');
+
+ return {
+ ...actual,
+ createAxios: vi.fn(() => ({
+ post: oauthPost,
+ get: profileGet
+ }))
+ };
+ });
+
+ let { provider } = await import('./index');
+ return createLocalSlateTestClient({ slate: provider });
+};
+
+afterEach(() => {
+ vi.doUnmock('slates');
+ vi.resetModules();
+});
+
+describe('slack auth contract', () => {
+ it('exposes bot and user auth methods', async () => {
+ let client = await loadProviderClient();
+ let methods = (await client.listAuthMethods()).authenticationMethods;
+
+ expect(methods.map(method => method.id)).toEqual([
+ 'oauth',
+ 'user_oauth',
+ 'bot_token',
+ 'user_token'
+ ]);
+ expect(methods.find(method => method.id === 'oauth')?.name).toBe('Slack OAuth (Bot)');
+ expect(methods.find(method => method.id === 'user_oauth')?.name).toBe(
+ 'Slack OAuth (User)'
+ );
+ });
+
+ it('builds bot OAuth URLs with scope and user OAuth URLs with user_scope', async () => {
+ let client = await loadProviderClient();
+
+ let bot = await client.getAuthorizationUrl({
+ authenticationMethodId: 'oauth',
+ redirectUri: 'https://example.com/callback',
+ state: 'state-123',
+ input: {},
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ scopes: ['chat:write', 'channels:read']
+ });
+ let botUrl = new URL(bot.authorizationUrl);
+ expect(botUrl.searchParams.get('scope')).toBe('chat:write,channels:read');
+ expect(botUrl.searchParams.get('user_scope')).toBeNull();
+
+ let user = await client.getAuthorizationUrl({
+ authenticationMethodId: 'user_oauth',
+ redirectUri: 'https://example.com/callback',
+ state: 'state-123',
+ input: {},
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ scopes: ['search:read', 'users.profile:write']
+ });
+ let userUrl = new URL(user.authorizationUrl);
+ expect(userUrl.searchParams.get('scope')).toBeNull();
+ expect(userUrl.searchParams.get('user_scope')).toBe('search:read,users.profile:write');
+ });
+
+ it('maps bot OAuth callback responses and granted scopes', async () => {
+ let client = await loadProviderClient();
+ oauthPost.mockResolvedValueOnce({
+ data: {
+ ok: true,
+ access_token: 'xoxb-token',
+ scope: 'chat:write,channels:read',
+ team: { id: 'T123', name: 'Acme' },
+ bot_user_id: 'Ubot'
+ }
+ });
+
+ let result = await client.handleAuthorizationCallback({
+ authenticationMethodId: 'oauth',
+ code: 'code-123',
+ state: 'state-123',
+ redirectUri: 'https://example.com/callback',
+ input: {},
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ scopes: ['chat:write'],
+ callbackState: undefined
+ });
+
+ expect(oauthPost).toHaveBeenCalledWith('/oauth.v2.access', null, {
+ params: {
+ code: 'code-123',
+ client_id: 'client-id',
+ client_secret: 'client-secret',
+ redirect_uri: 'https://example.com/callback'
+ }
+ });
+ expect(result.output).toMatchObject({
+ token: 'xoxb-token',
+ actorType: 'bot',
+ teamId: 'T123',
+ teamName: 'Acme',
+ botUserId: 'Ubot'
+ });
+ expect(result.scopes).toEqual(['chat:write', 'channels:read']);
+ });
+
+ it('maps user OAuth callback responses and granted scopes', async () => {
+ let client = await loadProviderClient();
+ oauthPost.mockResolvedValueOnce({
+ data: {
+ ok: true,
+ authed_user: {
+ id: 'Uuser',
+ access_token: 'xoxp-token',
+ scope: 'search:read,users.profile:write'
+ },
+ team: { id: 'T123', name: 'Acme' }
+ }
+ });
+
+ let result = await client.handleAuthorizationCallback({
+ authenticationMethodId: 'user_oauth',
+ code: 'code-123',
+ state: 'state-123',
+ redirectUri: 'https://example.com/callback',
+ input: {},
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ scopes: ['search:read'],
+ callbackState: undefined
+ });
+
+ expect(result.output).toMatchObject({
+ token: 'xoxp-token',
+ actorType: 'user',
+ teamId: 'T123',
+ teamName: 'Acme',
+ userId: 'Uuser'
+ });
+ expect(result.scopes).toEqual(['search:read', 'users.profile:write']);
+ });
+});
diff --git a/integrations/slack/src/auth.ts b/integrations/slack/src/auth.ts
index b43a15def..007a48430 100644
--- a/integrations/slack/src/auth.ts
+++ b/integrations/slack/src/auth.ts
@@ -1,209 +1,95 @@
import { SlateAuth, createAxios } from 'slates';
import { z } from 'zod';
+import {
+ parseSlackGrantedScopes,
+ slackBotOAuthScopes,
+ slackUserOAuthScopes
+} from './lib/scopes';
+import { slackOAuthError } from './lib/errors';
+
+type SlackProfile = {
+ id?: string;
+ name?: string;
+ teamId?: string;
+ teamName?: string;
+ imageUrl?: string;
+};
+
+let getSlackProfile = async (token: string): Promise => {
+ let client = createAxios({ baseURL: 'https://slack.com/api' });
+
+ let response = await client.get('/auth.test', {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+
+ let data = response.data as {
+ ok: boolean;
+ user_id?: string;
+ user?: string;
+ team_id?: string;
+ team?: string;
+ url?: string;
+ };
+
+ let profile: SlackProfile = {
+ id: data.user_id,
+ name: data.user,
+ teamId: data.team_id,
+ teamName: data.team
+ };
+
+ try {
+ let teamResponse = await client.get('/team.info', {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+
+ let teamData = teamResponse.data as {
+ ok: boolean;
+ team?: { icon?: { image_132?: string } };
+ };
+
+ if (teamData.ok && teamData.team?.icon?.image_132) {
+ profile.imageUrl = teamData.team.icon.image_132;
+ }
+ } catch {
+ // Team icons are nice-to-have profile metadata.
+ }
+
+ return profile;
+};
+
+let getAuthorizationUrl =
+ (scopeParam: 'scope' | 'user_scope') =>
+ async (ctx: { clientId: string; scopes: string[]; redirectUri: string; state: string }) => {
+ let params = new URLSearchParams({
+ client_id: ctx.clientId,
+ [scopeParam]: ctx.scopes.join(','),
+ redirect_uri: ctx.redirectUri,
+ state: ctx.state
+ });
+
+ return {
+ url: `https://slack.com/oauth/v2/authorize?${params.toString()}`
+ };
+ };
export let auth = SlateAuth.create()
.output(
z.object({
token: z.string(),
+ actorType: z.enum(['bot', 'user']).optional(),
teamId: z.string().optional(),
teamName: z.string().optional(),
- botUserId: z.string().optional()
+ botUserId: z.string().optional(),
+ userId: z.string().optional()
})
)
.addOauth({
type: 'auth.oauth',
name: 'Slack OAuth (Bot)',
key: 'oauth',
-
- scopes: [
- // Messaging
- { title: 'Send Messages', description: 'Send messages as the app', scope: 'chat:write' },
- {
- title: 'Send Public Messages',
- description: 'Send messages to channels the app is not a member of',
- scope: 'chat:write.public'
- },
-
- // Channels
- {
- title: 'Read Channels',
- description: 'View basic information about public channels',
- scope: 'channels:read'
- },
- {
- title: 'Manage Channels',
- description: 'Manage public channels and create new ones',
- scope: 'channels:manage'
- },
- {
- title: 'Channel History',
- description: 'View messages and content in public channels',
- scope: 'channels:history'
- },
- {
- title: 'Join Channels',
- description: 'Join public channels in a workspace',
- scope: 'channels:join'
- },
-
- // Private Channels (Groups)
- {
- title: 'Read Private Channels',
- description: 'View basic information about private channels',
- scope: 'groups:read'
- },
- {
- title: 'Private Channel History',
- description: 'View messages and content in private channels',
- scope: 'groups:history'
- },
- {
- title: 'Write Private Channels',
- description: 'Manage private channels and create new ones',
- scope: 'groups:write'
- },
-
- // Direct Messages
- {
- title: 'Read DMs',
- description: 'View basic information about direct messages',
- scope: 'im:read'
- },
- {
- title: 'DM History',
- description: 'View messages and content in direct messages',
- scope: 'im:history'
- },
- {
- title: 'Write DMs',
- description: 'Start direct messages with people',
- scope: 'im:write'
- },
-
- // Group DMs
- {
- title: 'Read Group DMs',
- description: 'View basic information about group direct messages',
- scope: 'mpim:read'
- },
- {
- title: 'Group DM History',
- description: 'View messages and content in group direct messages',
- scope: 'mpim:history'
- },
- {
- title: 'Write Group DMs',
- description: 'Start group direct messages with people',
- scope: 'mpim:write'
- },
-
- // Users
- { title: 'Read Users', description: 'View people in a workspace', scope: 'users:read' },
- {
- title: 'Read User Emails',
- description: 'View email addresses of people in a workspace',
- scope: 'users:read.email'
- },
- {
- title: 'Read User Profile',
- description: 'View profile details about people in a workspace',
- scope: 'users.profile:read'
- },
-
- // Files
- {
- title: 'Read Files',
- description: 'View files shared in channels and conversations',
- scope: 'files:read'
- },
- {
- title: 'Write Files',
- description: 'Upload, edit, and delete files',
- scope: 'files:write'
- },
-
- // Reactions
- {
- title: 'Read Reactions',
- description: 'View emoji reactions and their associated content',
- scope: 'reactions:read'
- },
- {
- title: 'Write Reactions',
- description: 'Add and edit emoji reactions',
- scope: 'reactions:write'
- },
-
- // Pins
- {
- title: 'Read Pins',
- description: 'View pinned content in channels',
- scope: 'pins:read'
- },
- {
- title: 'Write Pins',
- description: 'Add and remove pinned messages in channels',
- scope: 'pins:write'
- },
-
- // Bookmarks
- {
- title: 'Read Bookmarks',
- description: 'List bookmarks in channels',
- scope: 'bookmarks:read'
- },
- {
- title: 'Write Bookmarks',
- description: 'Add, edit, and remove bookmarks in channels',
- scope: 'bookmarks:write'
- },
-
- // User Groups
- {
- title: 'Read User Groups',
- description: 'View user groups in a workspace',
- scope: 'usergroups:read'
- },
- {
- title: 'Write User Groups',
- description: 'Create and manage user groups',
- scope: 'usergroups:write'
- },
-
- // Team/Workspace
- {
- title: 'Read Team Info',
- description: 'View the name, email domain, and icon for workspaces',
- scope: 'team:read'
- },
-
- // Commands
- {
- title: 'Commands',
- description: 'Add shortcuts and slash commands that people can use',
- scope: 'commands'
- },
-
- // Incoming Webhooks
- {
- title: 'Incoming Webhooks',
- description: 'Post messages to specific channels',
- scope: 'incoming-webhook'
- }
- ],
-
- getAuthorizationUrl: async ctx => {
- let params = new URLSearchParams({
- client_id: ctx.clientId,
- scope: ctx.scopes.join(','),
- redirect_uri: ctx.redirectUri,
- state: ctx.state
- });
-
- return {
- url: `https://slack.com/oauth/v2/authorize?${params.toString()}`
- };
- },
+ scopes: slackBotOAuthScopes,
+ getAuthorizationUrl: getAuthorizationUrl('scope'),
handleCallback: async ctx => {
let client = createAxios({ baseURL: 'https://slack.com/api' });
@@ -220,68 +106,88 @@ export let auth = SlateAuth.create()
let data = response.data as {
ok: boolean;
access_token?: string;
+ scope?: string;
team?: { id?: string; name?: string };
bot_user_id?: string;
+ authed_user?: { id?: string };
error?: string;
};
if (!data.ok || !data.access_token) {
- throw new Error(`Slack OAuth error: ${data.error || 'Unknown error'}`);
+ throw slackOAuthError(data.error);
}
+ let scopes = parseSlackGrantedScopes(data.scope);
+
return {
output: {
token: data.access_token,
+ actorType: 'bot' as const,
teamId: data.team?.id,
teamName: data.team?.name,
- botUserId: data.bot_user_id
- }
+ botUserId: data.bot_user_id,
+ userId: data.authed_user?.id
+ },
+ scopes: scopes.length > 0 ? scopes : undefined
};
},
- getProfile: async (ctx: { output: { token: string }; input: {}; scopes: string[] }) => {
+ getProfile: async (ctx: { output: { token: string } }) => ({
+ profile: await getSlackProfile(ctx.output.token)
+ })
+ })
+ .addOauth({
+ type: 'auth.oauth',
+ name: 'Slack OAuth (User)',
+ key: 'user_oauth',
+ scopes: slackUserOAuthScopes,
+ getAuthorizationUrl: getAuthorizationUrl('user_scope'),
+
+ handleCallback: async ctx => {
let client = createAxios({ baseURL: 'https://slack.com/api' });
- let response = await client.get('/auth.test', {
- headers: { Authorization: `Bearer ${ctx.output.token}` }
+ let response = await client.post('/oauth.v2.access', null, {
+ params: {
+ code: ctx.code,
+ client_id: ctx.clientId,
+ client_secret: ctx.clientSecret,
+ redirect_uri: ctx.redirectUri
+ }
});
let data = response.data as {
ok: boolean;
- user_id?: string;
- user?: string;
- team_id?: string;
- team?: string;
- url?: string;
- };
-
- let profile: Record = {
- id: data.user_id,
- name: data.user,
- teamId: data.team_id,
- teamName: data.team
+ authed_user?: {
+ id?: string;
+ access_token?: string;
+ scope?: string;
+ };
+ team?: { id?: string; name?: string };
+ error?: string;
};
- // Try to fetch the team icon as the profile image
- try {
- let teamResponse = await client.get('/team.info', {
- headers: { Authorization: `Bearer ${ctx.output.token}` }
- });
+ let token = data.authed_user?.access_token;
+ if (!data.ok || !token) {
+ throw slackOAuthError(data.error || 'missing user access token');
+ }
- let teamData = teamResponse.data as {
- ok: boolean;
- team?: { icon?: { image_132?: string } };
- };
+ let scopes = parseSlackGrantedScopes(data.authed_user?.scope);
- if (teamData.ok && teamData.team?.icon?.image_132) {
- profile.imageUrl = teamData.team.icon.image_132;
- }
- } catch {
- // Ignore if team.info fails
- }
+ return {
+ output: {
+ token,
+ actorType: 'user' as const,
+ teamId: data.team?.id,
+ teamName: data.team?.name,
+ userId: data.authed_user?.id
+ },
+ scopes: scopes.length > 0 ? scopes : undefined
+ };
+ },
- return { profile };
- }
+ getProfile: async (ctx: { output: { token: string } }) => ({
+ profile: await getSlackProfile(ctx.output.token)
+ })
})
.addTokenAuth({
type: 'auth.token',
@@ -292,36 +198,34 @@ export let auth = SlateAuth.create()
token: z.string().describe('Slack Bot Token (starts with xoxb-)')
}),
- getOutput: async ctx => {
- return {
- output: {
- token: ctx.input.token
- }
- };
- },
+ getOutput: async ctx => ({
+ output: {
+ token: ctx.input.token,
+ actorType: 'bot' as const
+ }
+ }),
- getProfile: async (ctx: { output: { token: string }; input: { token: string } }) => {
- let client = createAxios({ baseURL: 'https://slack.com/api' });
+ getProfile: async (ctx: { output: { token: string } }) => ({
+ profile: await getSlackProfile(ctx.output.token)
+ })
+ })
+ .addTokenAuth({
+ type: 'auth.token',
+ name: 'User Token',
+ key: 'user_token',
- let response = await client.get('/auth.test', {
- headers: { Authorization: `Bearer ${ctx.output.token}` }
- });
+ inputSchema: z.object({
+ token: z.string().describe('Slack User Token (starts with xoxp-)')
+ }),
- let data = response.data as {
- ok: boolean;
- user_id?: string;
- user?: string;
- team_id?: string;
- team?: string;
- };
+ getOutput: async ctx => ({
+ output: {
+ token: ctx.input.token,
+ actorType: 'user' as const
+ }
+ }),
- return {
- profile: {
- id: data.user_id,
- name: data.user,
- teamId: data.team_id,
- teamName: data.team
- }
- };
- }
+ getProfile: async (ctx: { output: { token: string } }) => ({
+ profile: await getSlackProfile(ctx.output.token)
+ })
});
diff --git a/integrations/slack/src/index.ts b/integrations/slack/src/index.ts
index 0499aa3cf..d48ae5534 100644
--- a/integrations/slack/src/index.ts
+++ b/integrations/slack/src/index.ts
@@ -12,9 +12,13 @@ import {
manageChannel,
manageChannelMembers,
getUserInfo,
+ manageUserStatus,
manageReactions,
managePins,
manageFiles,
+ searchMessages,
+ searchFiles,
+ manageReminders,
manageUserGroups,
manageBookmarks,
getTeamInfo
@@ -42,9 +46,13 @@ export let provider = Slate.create({
manageChannel,
manageChannelMembers,
getUserInfo,
+ manageUserStatus,
manageReactions,
managePins,
manageFiles,
+ searchMessages,
+ searchFiles,
+ manageReminders,
manageUserGroups,
manageBookmarks,
getTeamInfo
diff --git a/integrations/slack/src/lib/client.ts b/integrations/slack/src/lib/client.ts
index b6a04bc0a..fe5947f78 100644
--- a/integrations/slack/src/lib/client.ts
+++ b/integrations/slack/src/lib/client.ts
@@ -1,4 +1,5 @@
import { createAxios } from 'slates';
+import { slackApiError, slackServiceError } from './errors';
import type {
SlackResponse,
SlackMessage,
@@ -8,6 +9,7 @@ import type {
SlackScheduledMessage,
SlackPin,
SlackUserGroup,
+ SlackReminder,
SlackTeamInfo,
SlackBookmark
} from './types';
@@ -32,7 +34,7 @@ export class SlackClient {
let response = await this.axios.post(`/${method}`, params || {});
let data = response.data as T;
if (!data.ok) {
- throw new Error(`Slack API error (${method}): ${data.error || 'Unknown error'}`);
+ throw slackApiError(method, data.error);
}
return data;
}
@@ -44,7 +46,7 @@ export class SlackClient {
let response = await this.axios.get(`/${method}`, { params });
let data = response.data as T;
if (!data.ok) {
- throw new Error(`Slack API error (${method}): ${data.error || 'Unknown error'}`);
+ throw slackApiError(method, data.error);
}
return data;
}
@@ -162,9 +164,10 @@ export class SlackClient {
if (params?.limit) query.limit = params.limit;
if (params?.cursor) query.cursor = params.cursor;
- let data = await this.get<
- SlackResponse & { scheduled_messages: SlackScheduledMessage[] }
- >('chat.scheduledMessages.list', query);
+ let data = await this.get(
+ 'chat.scheduledMessages.list',
+ query
+ );
return {
scheduledMessages: data.scheduled_messages,
nextCursor: data.response_metadata?.next_cursor || undefined
@@ -403,10 +406,34 @@ export class SlackClient {
return data.user;
}
- async getUserProfile(userId: string): Promise {
+ async getUserProfile(userId?: string): Promise {
+ let query: Record = {};
+ if (userId) query.user = userId;
+
let data = await this.get(
'users.profile.get',
- { user: userId }
+ query
+ );
+ return data.profile;
+ }
+
+ async setUserProfile(profile: {
+ statusText?: string;
+ statusEmoji?: string;
+ statusExpiration?: number;
+ }): Promise {
+ let body: Record = {
+ profile: {}
+ };
+ if (profile.statusText !== undefined) body.profile.status_text = profile.statusText;
+ if (profile.statusEmoji !== undefined) body.profile.status_emoji = profile.statusEmoji;
+ if (profile.statusExpiration !== undefined) {
+ body.profile.status_expiration = profile.statusExpiration;
+ }
+
+ let data = await this.call(
+ 'users.profile.set',
+ body
);
return data.profile;
}
@@ -494,9 +521,7 @@ export class SlackClient {
upload_url: string;
};
if (!upload.ok) {
- throw new Error(
- `Slack API error (files.getUploadURLExternal): ${upload.error || 'Unknown error'}`
- );
+ throw slackApiError('files.getUploadURLExternal', upload.error);
}
let uploadResponse = await fetch(upload.upload_url, {
@@ -507,7 +532,7 @@ export class SlackClient {
body: content
});
if (!uploadResponse.ok) {
- throw new Error(`Slack file upload failed: HTTP ${uploadResponse.status}`);
+ throw slackServiceError(`Slack file upload failed: HTTP ${uploadResponse.status}`);
}
let completeBody: Record = {
@@ -664,6 +689,38 @@ export class SlackClient {
return data.users;
}
+ // ─── Reminders ─────────────────────────────────────────────────
+
+ async addReminder(params: {
+ text: string;
+ time: string | number;
+ user?: string;
+ }): Promise {
+ let body: Record = { text: params.text, time: params.time };
+ if (params.user) body.user = params.user;
+
+ let data = await this.call(
+ 'reminders.add',
+ body
+ );
+ return data.reminder;
+ }
+
+ async completeReminder(reminderId: string): Promise {
+ await this.call('reminders.complete', { reminder: reminderId });
+ }
+
+ async deleteReminder(reminderId: string): Promise {
+ await this.call('reminders.delete', { reminder: reminderId });
+ }
+
+ async listReminders(): Promise {
+ let data = await this.get(
+ 'reminders.list'
+ );
+ return data.reminders;
+ }
+
// ─── Bookmarks ─────────────────────────────────────────────────
async addBookmark(params: {
@@ -724,6 +781,48 @@ export class SlackClient {
return data.team;
}
+ // ─── Search ───────────────────────────────────────────────────
+
+ async searchMessages(params: {
+ query: string;
+ sort?: string;
+ sortDir?: string;
+ count?: number;
+ page?: number;
+ }): Promise<{ messages: { total: number; matches: any[] }; nextCursor?: string }> {
+ let query: Record = { query: params.query };
+ if (params.sort) query.sort = params.sort;
+ if (params.sortDir) query.sort_dir = params.sortDir;
+ if (params.count) query.count = params.count;
+ if (params.page) query.page = params.page;
+
+ let data = await this.get(
+ 'search.messages',
+ query
+ );
+ return { messages: data.messages };
+ }
+
+ async searchFiles(params: {
+ query: string;
+ sort?: string;
+ sortDir?: string;
+ count?: number;
+ page?: number;
+ }): Promise<{ files: { total: number; matches: any[] } }> {
+ let query: Record = { query: params.query };
+ if (params.sort) query.sort = params.sort;
+ if (params.sortDir) query.sort_dir = params.sortDir;
+ if (params.count) query.count = params.count;
+ if (params.page) query.page = params.page;
+
+ let data = await this.get(
+ 'search.files',
+ query
+ );
+ return { files: data.files };
+ }
+
// ─── Open Conversation (DM) ───────────────────────────────────
async openConversation(params: {
diff --git a/integrations/slack/src/lib/errors.ts b/integrations/slack/src/lib/errors.ts
new file mode 100644
index 000000000..1fa43662b
--- /dev/null
+++ b/integrations/slack/src/lib/errors.ts
@@ -0,0 +1,26 @@
+import { ServiceError, badRequestError, forbiddenError } from '@lowerdeck/error';
+
+export let slackServiceError = (message: string) =>
+ new ServiceError(badRequestError({ message }));
+
+export let slackApiError = (method: string, error?: string | null) =>
+ slackServiceError(`Slack API error (${method}): ${error || 'Unknown error'}`);
+
+export let slackOAuthError = (error?: string | null) =>
+ slackServiceError(`Slack OAuth error: ${error || 'Unknown error'}`);
+
+export let missingRequiredFieldError = (field: string, context?: string) => {
+ let message = `${field} is required${context ? ` for ${context}` : ''}`;
+
+ return slackServiceError(message);
+};
+
+export let missingRequiredAlternativeError = (message: string) => slackServiceError(message);
+
+export let userTokenRequiredError = (message: string) =>
+ new ServiceError(
+ forbiddenError({
+ message,
+ reason: 'user_token_required'
+ })
+ );
diff --git a/integrations/slack/src/lib/scopes.ts b/integrations/slack/src/lib/scopes.ts
new file mode 100644
index 000000000..248104774
--- /dev/null
+++ b/integrations/slack/src/lib/scopes.ts
@@ -0,0 +1,349 @@
+import { allOf, anyOf } from 'slates';
+
+export let parseSlackGrantedScopes = (value?: string | null) =>
+ (value ?? '')
+ .split(/[,\s]+/)
+ .map(scope => scope.trim())
+ .filter(Boolean);
+
+export let slackBotOAuthScopes = [
+ { title: 'Send Messages', description: 'Send messages as the app', scope: 'chat:write' },
+ {
+ title: 'Send Public Messages',
+ description: 'Send messages to channels the app is not a member of',
+ scope: 'chat:write.public'
+ },
+ {
+ title: 'Read Channels',
+ description: 'View basic information about public channels',
+ scope: 'channels:read'
+ },
+ {
+ title: 'Manage Channels',
+ description: 'Manage public channels and create new ones',
+ scope: 'channels:manage'
+ },
+ {
+ title: 'Channel History',
+ description: 'View messages and content in public channels',
+ scope: 'channels:history'
+ },
+ {
+ title: 'Join Channels',
+ description: 'Join public channels in a workspace',
+ scope: 'channels:join'
+ },
+ {
+ title: 'Read Private Channels',
+ description: 'View basic information about private channels',
+ scope: 'groups:read'
+ },
+ {
+ title: 'Private Channel History',
+ description: 'View messages and content in private channels',
+ scope: 'groups:history'
+ },
+ {
+ title: 'Write Private Channels',
+ description: 'Manage private channels and create new ones',
+ scope: 'groups:write'
+ },
+ {
+ title: 'Read DMs',
+ description: 'View basic information about direct messages',
+ scope: 'im:read'
+ },
+ {
+ title: 'DM History',
+ description: 'View messages and content in direct messages',
+ scope: 'im:history'
+ },
+ { title: 'Write DMs', description: 'Start direct messages with people', scope: 'im:write' },
+ {
+ title: 'Read Group DMs',
+ description: 'View basic information about group direct messages',
+ scope: 'mpim:read'
+ },
+ {
+ title: 'Group DM History',
+ description: 'View messages and content in group direct messages',
+ scope: 'mpim:history'
+ },
+ {
+ title: 'Write Group DMs',
+ description: 'Start group direct messages with people',
+ scope: 'mpim:write'
+ },
+ { title: 'Read Users', description: 'View people in a workspace', scope: 'users:read' },
+ {
+ title: 'Read User Emails',
+ description: 'View email addresses of people in a workspace',
+ scope: 'users:read.email'
+ },
+ {
+ title: 'Read User Profile',
+ description: 'View profile details about people in a workspace',
+ scope: 'users.profile:read'
+ },
+ {
+ title: 'Read Files',
+ description: 'View files shared in channels and conversations',
+ scope: 'files:read'
+ },
+ {
+ title: 'Write Files',
+ description: 'Upload, edit, and delete files',
+ scope: 'files:write'
+ },
+ {
+ title: 'Read Reactions',
+ description: 'View emoji reactions and their associated content',
+ scope: 'reactions:read'
+ },
+ {
+ title: 'Write Reactions',
+ description: 'Add and edit emoji reactions',
+ scope: 'reactions:write'
+ },
+ { title: 'Read Pins', description: 'View pinned content in channels', scope: 'pins:read' },
+ {
+ title: 'Write Pins',
+ description: 'Add and remove pinned messages in channels',
+ scope: 'pins:write'
+ },
+ {
+ title: 'Read Bookmarks',
+ description: 'List bookmarks in channels',
+ scope: 'bookmarks:read'
+ },
+ {
+ title: 'Write Bookmarks',
+ description: 'Add, edit, and remove bookmarks in channels',
+ scope: 'bookmarks:write'
+ },
+ {
+ title: 'Read User Groups',
+ description: 'View user groups in a workspace',
+ scope: 'usergroups:read'
+ },
+ {
+ title: 'Write User Groups',
+ description: 'Create and manage user groups',
+ scope: 'usergroups:write'
+ },
+ {
+ title: 'Read Team Info',
+ description: 'View the name, email domain, and icon for workspaces',
+ scope: 'team:read'
+ },
+ {
+ title: 'Commands',
+ description: 'Add shortcuts and slash commands that people can use',
+ scope: 'commands'
+ },
+ {
+ title: 'Incoming Webhooks',
+ description: 'Post messages to specific channels',
+ scope: 'incoming-webhook'
+ }
+];
+
+export let slackUserOAuthScopes = [
+ {
+ title: 'Send Messages',
+ description: 'Send messages as the authorized user',
+ scope: 'chat:write'
+ },
+ {
+ title: 'Read Channels',
+ description: 'View basic information about public channels',
+ scope: 'channels:read'
+ },
+ {
+ title: 'Manage Public Channels',
+ description: "Create, join, rename, and archive public channels on the user's behalf",
+ scope: 'channels:write'
+ },
+ {
+ title: 'Channel History',
+ description: 'View messages and content in public channels',
+ scope: 'channels:history'
+ },
+ {
+ title: 'Read Private Channels',
+ description: 'View basic information about private channels',
+ scope: 'groups:read'
+ },
+ {
+ title: 'Private Channel History',
+ description: 'View messages and content in private channels',
+ scope: 'groups:history'
+ },
+ {
+ title: 'Write Private Channels',
+ description: 'Manage private channels and create new ones',
+ scope: 'groups:write'
+ },
+ {
+ title: 'Read DMs',
+ description: 'View basic information about direct messages',
+ scope: 'im:read'
+ },
+ {
+ title: 'DM History',
+ description: 'View messages and content in direct messages',
+ scope: 'im:history'
+ },
+ { title: 'Write DMs', description: 'Start direct messages with people', scope: 'im:write' },
+ {
+ title: 'Read Group DMs',
+ description: 'View basic information about group direct messages',
+ scope: 'mpim:read'
+ },
+ {
+ title: 'Group DM History',
+ description: 'View messages and content in group direct messages',
+ scope: 'mpim:history'
+ },
+ {
+ title: 'Write Group DMs',
+ description: 'Start group direct messages with people',
+ scope: 'mpim:write'
+ },
+ { title: 'Read Users', description: 'View people in a workspace', scope: 'users:read' },
+ {
+ title: 'Read User Emails',
+ description: 'View email addresses of people in a workspace',
+ scope: 'users:read.email'
+ },
+ {
+ title: 'Read User Profile',
+ description: 'View profile details about people in a workspace',
+ scope: 'users.profile:read'
+ },
+ {
+ title: 'Write User Profile',
+ description: "Set and clear the authorized user's Slack status",
+ scope: 'users.profile:write'
+ },
+ {
+ title: 'Read Files',
+ description: 'View files shared in channels and conversations',
+ scope: 'files:read'
+ },
+ {
+ title: 'Write Files',
+ description: 'Upload, edit, and delete files',
+ scope: 'files:write'
+ },
+ {
+ title: 'Read Reactions',
+ description: 'View emoji reactions and their associated content',
+ scope: 'reactions:read'
+ },
+ {
+ title: 'Write Reactions',
+ description: 'Add and edit emoji reactions',
+ scope: 'reactions:write'
+ },
+ { title: 'Read Pins', description: 'View pinned content in channels', scope: 'pins:read' },
+ {
+ title: 'Write Pins',
+ description: 'Add and remove pinned messages in channels',
+ scope: 'pins:write'
+ },
+ {
+ title: 'Read Bookmarks',
+ description: 'List bookmarks in channels',
+ scope: 'bookmarks:read'
+ },
+ {
+ title: 'Write Bookmarks',
+ description: 'Add, edit, and remove bookmarks in channels',
+ scope: 'bookmarks:write'
+ },
+ {
+ title: 'Read User Groups',
+ description: 'View user groups in a workspace',
+ scope: 'usergroups:read'
+ },
+ {
+ title: 'Write User Groups',
+ description: 'Create and manage user groups',
+ scope: 'usergroups:write'
+ },
+ { title: 'Read Reminders', description: 'View reminders', scope: 'reminders:read' },
+ {
+ title: 'Write Reminders',
+ description: 'Add, remove, and mark reminders as complete',
+ scope: 'reminders:write'
+ },
+ {
+ title: 'Read Team Info',
+ description: 'View the name, email domain, and icon for workspaces',
+ scope: 'team:read'
+ },
+ {
+ title: 'Search Workspace',
+ description: 'Search messages and files with user-token search APIs',
+ scope: 'search:read'
+ }
+];
+
+let slackConversationReadScopes = allOf(
+ 'channels:read',
+ 'groups:read',
+ 'im:read',
+ 'mpim:read'
+);
+let slackConversationHistoryScopes = allOf(
+ 'channels:history',
+ 'groups:history',
+ 'im:history',
+ 'mpim:history'
+);
+let slackPublicPrivateConversationReadScopes = allOf('channels:read', 'groups:read');
+let slackPublicPrivateConversationHistoryScopes = allOf(
+ 'channels:history',
+ 'groups:history'
+);
+let slackUserInfoScopes = allOf('users:read', 'users:read.email');
+
+export let slackActionScopes = {
+ chatWrite: anyOf('chat:write'),
+ conversationRead: slackConversationReadScopes,
+ conversationHistory: slackConversationHistoryScopes,
+ channelManagement: allOf(['channels:manage', 'channels:write'], 'groups:write'),
+ channelMembership: allOf(
+ 'channels:read',
+ 'groups:read',
+ 'im:read',
+ 'mpim:read',
+ ['channels:manage', 'channels:write'],
+ ['channels:join', 'channels:write'],
+ 'groups:write',
+ 'im:write',
+ 'mpim:write'
+ ),
+ openConversation: allOf('im:write', 'mpim:write'),
+ userInfo: slackUserInfoScopes,
+ reactions: allOf('reactions:read', 'reactions:write'),
+ pins: allOf('pins:read', 'pins:write'),
+ files: allOf('files:read', 'files:write'),
+ userGroups: allOf('usergroups:read', 'usergroups:write'),
+ bookmarks: allOf('bookmarks:read', 'bookmarks:write'),
+ teamInfo: anyOf('team:read'),
+ search: anyOf('search:read'),
+ userStatus: allOf('users.profile:read', 'users.profile:write'),
+ reminders: allOf('reminders:read', 'reminders:write'),
+ messagePolling: allOf(slackConversationReadScopes, slackConversationHistoryScopes),
+ messageEvents: slackConversationHistoryScopes,
+ channelActivity: slackPublicPrivateConversationReadScopes,
+ fileEvents: anyOf('files:read'),
+ reactionEvents: allOf(
+ slackPublicPrivateConversationReadScopes,
+ slackPublicPrivateConversationHistoryScopes,
+ 'reactions:read'
+ ),
+ userChange: slackUserInfoScopes
+};
diff --git a/integrations/slack/src/provider.contract.test.ts b/integrations/slack/src/provider.contract.test.ts
new file mode 100644
index 000000000..fe8d6b082
--- /dev/null
+++ b/integrations/slack/src/provider.contract.test.ts
@@ -0,0 +1,147 @@
+import { createLocalSlateTestClient, expectSlateContract } from '@slates/test';
+import { describe, expect, it } from 'vitest';
+import { provider } from './index';
+import { slackActionScopes, slackBotOAuthScopes, slackUserOAuthScopes } from './lib/scopes';
+
+let grantedScopeIds = (scopes: Array<{ scope: string }>) => scopes.map(scope => scope.scope);
+
+let actionIdsAvailableForScopes = (
+ actions: Array<{ id: string; scopes?: { AND: Array<{ OR: string[] }> } }>,
+ grantedScopes: string[]
+) => {
+ let granted = new Set(grantedScopes);
+ return actions
+ .filter(action =>
+ action.scopes?.AND.every(clause => clause.OR.some(scope => granted.has(scope)))
+ )
+ .map(action => action.id);
+};
+
+describe('slack provider contract', () => {
+ it('exposes the merged Slack tool and auth surface with scopes', async () => {
+ let client = createLocalSlateTestClient({ slate: provider });
+ let contract = await expectSlateContract({
+ client,
+ provider: {
+ id: 'slack',
+ name: 'Slack'
+ },
+ toolIds: [
+ 'send_message',
+ 'update_message',
+ 'schedule_message',
+ 'manage_scheduled_messages',
+ 'get_conversation_history',
+ 'get_conversation_info',
+ 'open_conversation',
+ 'list_conversations',
+ 'manage_channel',
+ 'manage_channel_members',
+ 'get_user_info',
+ 'manage_user_status',
+ 'manage_reactions',
+ 'manage_pins',
+ 'manage_files',
+ 'search_messages',
+ 'search_files',
+ 'manage_reminders',
+ 'manage_user_groups',
+ 'manage_bookmarks',
+ 'get_team_info'
+ ],
+ triggerIds: [
+ 'new_message',
+ 'new_message_webhook',
+ 'channel_activity',
+ 'new_reaction',
+ 'new_file',
+ 'user_change'
+ ],
+ authMethodIds: ['oauth', 'user_oauth', 'bot_token', 'user_token']
+ });
+
+ for (let action of [...contract.tools, ...contract.triggers]) {
+ expect(action.scopes?.AND.length).toBeGreaterThan(0);
+ }
+
+ expect(contract.actions.find(action => action.id === 'send_message')?.scopes).toEqual(
+ slackActionScopes.chatWrite
+ );
+ expect(contract.actions.find(action => action.id === 'search_messages')?.scopes).toEqual(
+ slackActionScopes.search
+ );
+ expect(
+ contract.actions.find(action => action.id === 'manage_user_status')?.scopes
+ ).toEqual(slackActionScopes.userStatus);
+ expect(contract.actions.find(action => action.id === 'manage_reminders')?.scopes).toEqual(
+ slackActionScopes.reminders
+ );
+ expect(contract.actions.find(action => action.id === 'new_message')?.scopes).toEqual(
+ slackActionScopes.messagePolling
+ );
+ expect(
+ contract.actions.find(action => action.id === 'new_message_webhook')?.scopes
+ ).toEqual(slackActionScopes.messageEvents);
+ expect(
+ contract.actions.find(action => action.id === 'channel_activity')?.scopes
+ ).toEqual(slackActionScopes.channelActivity);
+ expect(contract.actions.find(action => action.id === 'new_reaction')?.scopes).toEqual(
+ slackActionScopes.reactionEvents
+ );
+ expect(contract.actions.find(action => action.id === 'new_file')?.scopes).toEqual(
+ slackActionScopes.fileEvents
+ );
+ expect(contract.actions.find(action => action.id === 'user_change')?.scopes).toEqual(
+ slackActionScopes.userChange
+ );
+
+ let botOAuth = await client.getAuthMethod('oauth');
+ let userOAuth = await client.getAuthMethod('user_oauth');
+ expect(
+ botOAuth.authenticationMethod.scopes?.some(scope => scope.id === 'search:read')
+ ).toBe(false);
+ expect(
+ userOAuth.authenticationMethod.scopes?.some(scope => scope.id === 'search:read')
+ ).toBe(true);
+ });
+
+ it('models bot and user scope availability distinctly', async () => {
+ let client = createLocalSlateTestClient({ slate: provider });
+ let actions = (await client.listActions()).actions as Array<{
+ id: string;
+ scopes?: { AND: Array<{ OR: string[] }> };
+ }>;
+
+ let botActions = actionIdsAvailableForScopes(
+ actions,
+ grantedScopeIds(slackBotOAuthScopes)
+ );
+ expect(botActions).toContain('send_message');
+ expect(botActions).toContain('new_message');
+ expect(botActions).toContain('new_message_webhook');
+ expect(botActions).not.toContain('manage_reminders');
+ expect(botActions).not.toContain('search_messages');
+ expect(botActions).not.toContain('search_files');
+ expect(botActions).not.toContain('manage_user_status');
+
+ let userActions = actionIdsAvailableForScopes(
+ actions,
+ grantedScopeIds(slackUserOAuthScopes)
+ );
+ expect(userActions).toContain('send_message');
+ expect(userActions).toContain('new_message');
+ expect(userActions).toContain('new_message_webhook');
+ expect(userActions).toContain('manage_reminders');
+ expect(userActions).toContain('search_messages');
+ expect(userActions).toContain('search_files');
+ expect(userActions).toContain('manage_user_status');
+
+ let chatOnlyActions = actionIdsAvailableForScopes(actions, ['chat:write']);
+ expect(chatOnlyActions).toEqual([
+ 'send_message',
+ 'update_message',
+ 'schedule_message',
+ 'manage_scheduled_messages'
+ ]);
+ });
+});
diff --git a/integrations/slack/src/spec.ts b/integrations/slack/src/spec.ts
index 21e0e02bc..c46d66616 100644
--- a/integrations/slack/src/spec.ts
+++ b/integrations/slack/src/spec.ts
@@ -4,7 +4,7 @@ import { config } from './config';
export let spec = SlateSpecification.create({
key: 'slack',
- name: 'Slack (Bot)',
+ name: 'Slack',
description: undefined,
metadata: {},
config,
diff --git a/integrations/slack/src/tools/get-conversation-history.ts b/integrations/slack/src/tools/get-conversation-history.ts
index a7bb36b58..9c54a7767 100644
--- a/integrations/slack/src/tools/get-conversation-history.ts
+++ b/integrations/slack/src/tools/get-conversation-history.ts
@@ -1,5 +1,6 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -29,6 +30,7 @@ export let getConversationHistory = SlateTool.create(spec, {
readOnly: true
}
})
+ .scopes(slackActionScopes.conversationHistory)
.input(
z.object({
channelId: z.string().describe('Channel, DM, or group DM ID'),
diff --git a/integrations/slack/src/tools/get-conversation-info.ts b/integrations/slack/src/tools/get-conversation-info.ts
index c020957ba..a36267d2e 100644
--- a/integrations/slack/src/tools/get-conversation-info.ts
+++ b/integrations/slack/src/tools/get-conversation-info.ts
@@ -1,5 +1,12 @@
import { createGetConversationInfoTool } from '@slates/slack-tools';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
-export let getConversationInfo = createGetConversationInfoTool({ spec, SlackClient });
+export let getConversationInfo = createGetConversationInfoTool({
+ spec,
+ SlackClient,
+ scopes: {
+ getConversationInfo: slackActionScopes.conversationRead
+ }
+});
diff --git a/integrations/slack/src/tools/get-team-info.ts b/integrations/slack/src/tools/get-team-info.ts
index cc820d736..9474b9a7e 100644
--- a/integrations/slack/src/tools/get-team-info.ts
+++ b/integrations/slack/src/tools/get-team-info.ts
@@ -1,5 +1,6 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -12,6 +13,7 @@ export let getTeamInfo = SlateTool.create(spec, {
readOnly: true
}
})
+ .scopes(slackActionScopes.teamInfo)
.input(z.object({}))
.output(
z.object({
diff --git a/integrations/slack/src/tools/get-user-info.ts b/integrations/slack/src/tools/get-user-info.ts
index 8ecddbf2e..d8f585092 100644
--- a/integrations/slack/src/tools/get-user-info.ts
+++ b/integrations/slack/src/tools/get-user-info.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredAlternativeError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -36,6 +38,7 @@ export let getUserInfo = SlateTool.create(spec, {
readOnly: true
}
})
+ .scopes(slackActionScopes.userInfo)
.input(
z.object({
userId: z.string().optional().describe('Slack user ID to look up'),
@@ -103,6 +106,6 @@ export let getUserInfo = SlateTool.create(spec, {
};
}
- throw new Error('Provide userId, email, or set listAll to true');
+ throw missingRequiredAlternativeError('Provide userId, email, or set listAll to true');
})
.build();
diff --git a/integrations/slack/src/tools/index.ts b/integrations/slack/src/tools/index.ts
index 9eb2bf280..6fa034018 100644
--- a/integrations/slack/src/tools/index.ts
+++ b/integrations/slack/src/tools/index.ts
@@ -9,9 +9,13 @@ export * from './list-conversations';
export * from './manage-channel';
export * from './manage-channel-members';
export * from './get-user-info';
+export * from './manage-user-status';
export * from './manage-reactions';
export * from './manage-pins';
export * from './manage-files';
+export * from './search-messages';
+export * from './search-files';
+export * from './manage-reminders';
export * from './manage-user-groups';
export * from './manage-bookmarks';
export * from './get-team-info';
diff --git a/integrations/slack/src/tools/list-conversations.ts b/integrations/slack/src/tools/list-conversations.ts
index 68071979d..006415151 100644
--- a/integrations/slack/src/tools/list-conversations.ts
+++ b/integrations/slack/src/tools/list-conversations.ts
@@ -1,5 +1,6 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -27,6 +28,7 @@ export let listConversations = SlateTool.create(spec, {
readOnly: true
}
})
+ .scopes(slackActionScopes.conversationRead)
.input(
z.object({
types: z
diff --git a/integrations/slack/src/tools/manage-bookmarks.ts b/integrations/slack/src/tools/manage-bookmarks.ts
index 3419e035b..2cd487c60 100644
--- a/integrations/slack/src/tools/manage-bookmarks.ts
+++ b/integrations/slack/src/tools/manage-bookmarks.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -22,6 +24,7 @@ export let manageBookmarks = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.bookmarks)
.input(
z.object({
action: z.enum(['add', 'edit', 'remove', 'list']).describe('Bookmark action to perform'),
@@ -58,7 +61,7 @@ export let manageBookmarks = SlateTool.create(spec, {
});
if (action === 'add') {
- if (!ctx.input.title) throw new Error('title is required for add action');
+ if (!ctx.input.title) throw missingRequiredFieldError('title', 'add action');
let bookmark = await client.addBookmark({
channelId,
title: ctx.input.title,
@@ -73,7 +76,7 @@ export let manageBookmarks = SlateTool.create(spec, {
}
if (action === 'edit') {
- if (!ctx.input.bookmarkId) throw new Error('bookmarkId is required for edit action');
+ if (!ctx.input.bookmarkId) throw missingRequiredFieldError('bookmarkId', 'edit action');
let bookmark = await client.editBookmark({
channelId,
bookmarkId: ctx.input.bookmarkId,
@@ -88,7 +91,9 @@ export let manageBookmarks = SlateTool.create(spec, {
}
if (action === 'remove') {
- if (!ctx.input.bookmarkId) throw new Error('bookmarkId is required for remove action');
+ if (!ctx.input.bookmarkId) {
+ throw missingRequiredFieldError('bookmarkId', 'remove action');
+ }
await client.removeBookmark(channelId, ctx.input.bookmarkId);
return {
output: { removed: true },
diff --git a/integrations/slack/src/tools/manage-channel-members.ts b/integrations/slack/src/tools/manage-channel-members.ts
index d1ed07aa3..248fb9c3e 100644
--- a/integrations/slack/src/tools/manage-channel-members.ts
+++ b/integrations/slack/src/tools/manage-channel-members.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -12,6 +14,7 @@ export let manageChannelMembers = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.channelMembership)
.input(
z.object({
action: z
@@ -45,7 +48,7 @@ export let manageChannelMembers = SlateTool.create(spec, {
if (action === 'invite') {
if (!userIds || userIds.length === 0)
- throw new Error('userIds is required for invite action');
+ throw missingRequiredFieldError('userIds', 'invite action');
await client.inviteToConversation(channelId, userIds);
return {
output: {
@@ -58,7 +61,7 @@ export let manageChannelMembers = SlateTool.create(spec, {
if (action === 'kick') {
if (!userIds || userIds.length === 0)
- throw new Error('userIds is required for kick action');
+ throw missingRequiredFieldError('userIds', 'kick action');
for (let userId of userIds) {
await client.kickFromConversation(channelId, userId);
}
diff --git a/integrations/slack/src/tools/manage-channel.ts b/integrations/slack/src/tools/manage-channel.ts
index 89c0442ac..256bd7ba2 100644
--- a/integrations/slack/src/tools/manage-channel.ts
+++ b/integrations/slack/src/tools/manage-channel.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -26,6 +28,7 @@ export let manageChannel = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.channelManagement)
.input(
z.object({
action: z
@@ -53,7 +56,7 @@ export let manageChannel = SlateTool.create(spec, {
let { action, channelId, name, isPrivate, topic, purpose } = ctx.input;
if (action === 'create') {
- if (!name) throw new Error('Name is required to create a channel');
+ if (!name) throw missingRequiredFieldError('name', 'create action');
let channel = await client.createConversation({ name, isPrivate });
@@ -73,7 +76,7 @@ export let manageChannel = SlateTool.create(spec, {
};
}
- if (!channelId) throw new Error('channelId is required for this action');
+ if (!channelId) throw missingRequiredFieldError('channelId', `${action} action`);
if (action === 'archive') {
await client.archiveConversation(channelId);
diff --git a/integrations/slack/src/tools/manage-files.ts b/integrations/slack/src/tools/manage-files.ts
index 7a315e2bc..d2a8c51f3 100644
--- a/integrations/slack/src/tools/manage-files.ts
+++ b/integrations/slack/src/tools/manage-files.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -31,6 +33,7 @@ export let manageFiles = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.files)
.input(
z.object({
action: z.enum(['upload', 'list', 'get', 'delete']).describe('File management action'),
@@ -85,7 +88,7 @@ export let manageFiles = SlateTool.create(spec, {
});
if (action === 'upload') {
- if (!ctx.input.content) throw new Error('content is required for upload action');
+ if (!ctx.input.content) throw missingRequiredFieldError('content', 'upload action');
let file = await client.uploadFile({
content: ctx.input.content,
filename: ctx.input.filename,
@@ -102,7 +105,7 @@ export let manageFiles = SlateTool.create(spec, {
}
if (action === 'get') {
- if (!ctx.input.fileId) throw new Error('fileId is required for get action');
+ if (!ctx.input.fileId) throw missingRequiredFieldError('fileId', 'get action');
let file = await client.getFileInfo(ctx.input.fileId);
return {
output: { file: mapFile(file) },
@@ -111,7 +114,7 @@ export let manageFiles = SlateTool.create(spec, {
}
if (action === 'delete') {
- if (!ctx.input.fileId) throw new Error('fileId is required for delete action');
+ if (!ctx.input.fileId) throw missingRequiredFieldError('fileId', 'delete action');
await client.deleteFile(ctx.input.fileId);
return {
output: { deleted: true },
diff --git a/integrations/slack/src/tools/manage-pins.ts b/integrations/slack/src/tools/manage-pins.ts
index 40e4b32f7..7aab638f4 100644
--- a/integrations/slack/src/tools/manage-pins.ts
+++ b/integrations/slack/src/tools/manage-pins.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -12,6 +14,7 @@ export let managePins = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.pins)
.input(
z.object({
action: z.enum(['pin', 'unpin', 'list']).describe('Pin action to perform'),
@@ -43,7 +46,7 @@ export let managePins = SlateTool.create(spec, {
let { action, channelId, messageTs } = ctx.input;
if (action === 'pin') {
- if (!messageTs) throw new Error('messageTs is required for pin action');
+ if (!messageTs) throw missingRequiredFieldError('messageTs', 'pin action');
await client.addPin({ channel: channelId, timestamp: messageTs });
return {
output: { channelId },
@@ -52,7 +55,7 @@ export let managePins = SlateTool.create(spec, {
}
if (action === 'unpin') {
- if (!messageTs) throw new Error('messageTs is required for unpin action');
+ if (!messageTs) throw missingRequiredFieldError('messageTs', 'unpin action');
await client.removePin({ channel: channelId, timestamp: messageTs });
return {
output: { channelId },
diff --git a/integrations/slack/src/tools/manage-reactions.ts b/integrations/slack/src/tools/manage-reactions.ts
index f38c00711..e42e77282 100644
--- a/integrations/slack/src/tools/manage-reactions.ts
+++ b/integrations/slack/src/tools/manage-reactions.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -12,6 +14,7 @@ export let manageReactions = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.reactions)
.input(
z.object({
action: z.enum(['add', 'remove', 'list']).describe('The reaction action to perform'),
@@ -44,7 +47,7 @@ export let manageReactions = SlateTool.create(spec, {
let { action, channelId, messageTs, emoji } = ctx.input;
if (action === 'add') {
- if (!emoji) throw new Error('emoji is required for add action');
+ if (!emoji) throw missingRequiredFieldError('emoji', 'add action');
await client.addReaction({ channel: channelId, timestamp: messageTs, name: emoji });
return {
output: { channelId, messageTs },
@@ -53,7 +56,7 @@ export let manageReactions = SlateTool.create(spec, {
}
if (action === 'remove') {
- if (!emoji) throw new Error('emoji is required for remove action');
+ if (!emoji) throw missingRequiredFieldError('emoji', 'remove action');
await client.removeReaction({ channel: channelId, timestamp: messageTs, name: emoji });
return {
output: { channelId, messageTs },
diff --git a/integrations/slack-user/src/tools/manage-reminders.ts b/integrations/slack/src/tools/manage-reminders.ts
similarity index 79%
rename from integrations/slack-user/src/tools/manage-reminders.ts
rename to integrations/slack/src/tools/manage-reminders.ts
index ac9302cf8..a170a2564 100644
--- a/integrations/slack-user/src/tools/manage-reminders.ts
+++ b/integrations/slack/src/tools/manage-reminders.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError, userTokenRequiredError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -21,6 +23,7 @@ export let manageReminders = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.reminders)
.input(
z.object({
action: z
@@ -51,6 +54,14 @@ export let manageReminders = SlateTool.create(spec, {
})
)
.handleInvocation(async ctx => {
+ let isUserToken =
+ ctx.auth.actorType === 'user' || String(ctx.auth.token ?? '').startsWith('xoxp-');
+ if (!isUserToken) {
+ throw userTokenRequiredError(
+ 'Slack reminders require a user token. Use user_oauth or user_token.'
+ );
+ }
+
let client = new SlackClient(ctx.auth.token);
let { action } = ctx.input;
@@ -64,8 +75,8 @@ export let manageReminders = SlateTool.create(spec, {
});
if (action === 'create') {
- if (!ctx.input.text) throw new Error('text is required for create action');
- if (!ctx.input.time) throw new Error('time is required for create action');
+ if (!ctx.input.text) throw missingRequiredFieldError('text', 'create action');
+ if (!ctx.input.time) throw missingRequiredFieldError('time', 'create action');
let reminder = await client.addReminder({
text: ctx.input.text,
time: ctx.input.time,
@@ -78,7 +89,9 @@ export let manageReminders = SlateTool.create(spec, {
}
if (action === 'complete') {
- if (!ctx.input.reminderId) throw new Error('reminderId is required for complete action');
+ if (!ctx.input.reminderId) {
+ throw missingRequiredFieldError('reminderId', 'complete action');
+ }
await client.completeReminder(ctx.input.reminderId);
return {
output: { reminder: { reminderId: ctx.input.reminderId } },
@@ -87,7 +100,9 @@ export let manageReminders = SlateTool.create(spec, {
}
if (action === 'delete') {
- if (!ctx.input.reminderId) throw new Error('reminderId is required for delete action');
+ if (!ctx.input.reminderId) {
+ throw missingRequiredFieldError('reminderId', 'delete action');
+ }
await client.deleteReminder(ctx.input.reminderId);
return {
output: { deleted: true },
@@ -95,7 +110,6 @@ export let manageReminders = SlateTool.create(spec, {
};
}
- // list
let reminders = await client.listReminders();
return {
output: { reminders: reminders.map(mapReminder) },
diff --git a/integrations/slack/src/tools/manage-scheduled-messages.ts b/integrations/slack/src/tools/manage-scheduled-messages.ts
index 8e75a78ab..8794fb4e3 100644
--- a/integrations/slack/src/tools/manage-scheduled-messages.ts
+++ b/integrations/slack/src/tools/manage-scheduled-messages.ts
@@ -1,5 +1,12 @@
import { createManageScheduledMessagesTool } from '@slates/slack-tools';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
-export let manageScheduledMessages = createManageScheduledMessagesTool({ spec, SlackClient });
+export let manageScheduledMessages = createManageScheduledMessagesTool({
+ spec,
+ SlackClient,
+ scopes: {
+ manageScheduledMessages: slackActionScopes.chatWrite
+ }
+});
diff --git a/integrations/slack/src/tools/manage-user-groups.ts b/integrations/slack/src/tools/manage-user-groups.ts
index a0a1ed00c..15ef04657 100644
--- a/integrations/slack/src/tools/manage-user-groups.ts
+++ b/integrations/slack/src/tools/manage-user-groups.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -27,6 +29,7 @@ export let manageUserGroups = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.userGroups)
.input(
z.object({
action: z
@@ -78,7 +81,7 @@ export let manageUserGroups = SlateTool.create(spec, {
});
if (action === 'create') {
- if (!ctx.input.name) throw new Error('name is required for create action');
+ if (!ctx.input.name) throw missingRequiredFieldError('name', 'create action');
let group = await client.createUserGroup({
name: ctx.input.name,
handle: ctx.input.handle,
@@ -92,7 +95,9 @@ export let manageUserGroups = SlateTool.create(spec, {
}
if (action === 'update') {
- if (!ctx.input.userGroupId) throw new Error('userGroupId is required for update action');
+ if (!ctx.input.userGroupId) {
+ throw missingRequiredFieldError('userGroupId', 'update action');
+ }
let group = await client.updateUserGroup({
usergroupId: ctx.input.userGroupId,
name: ctx.input.name,
@@ -107,7 +112,9 @@ export let manageUserGroups = SlateTool.create(spec, {
}
if (action === 'enable') {
- if (!ctx.input.userGroupId) throw new Error('userGroupId is required for enable action');
+ if (!ctx.input.userGroupId) {
+ throw missingRequiredFieldError('userGroupId', 'enable action');
+ }
let group = await client.enableUserGroup(ctx.input.userGroupId);
return {
output: { userGroup: mapGroup(group) },
@@ -117,7 +124,7 @@ export let manageUserGroups = SlateTool.create(spec, {
if (action === 'disable') {
if (!ctx.input.userGroupId)
- throw new Error('userGroupId is required for disable action');
+ throw missingRequiredFieldError('userGroupId', 'disable action');
let group = await client.disableUserGroup(ctx.input.userGroupId);
return {
output: { userGroup: mapGroup(group) },
@@ -127,8 +134,8 @@ export let manageUserGroups = SlateTool.create(spec, {
if (action === 'set_members') {
if (!ctx.input.userGroupId)
- throw new Error('userGroupId is required for set_members action');
- if (!ctx.input.userIds) throw new Error('userIds is required for set_members action');
+ throw missingRequiredFieldError('userGroupId', 'set_members action');
+ if (!ctx.input.userIds) throw missingRequiredFieldError('userIds', 'set_members action');
let group = await client.updateUserGroupMembers(
ctx.input.userGroupId,
ctx.input.userIds
@@ -141,7 +148,7 @@ export let manageUserGroups = SlateTool.create(spec, {
if (action === 'list_members') {
if (!ctx.input.userGroupId)
- throw new Error('userGroupId is required for list_members action');
+ throw missingRequiredFieldError('userGroupId', 'list_members action');
let members = await client.listUserGroupMembers(ctx.input.userGroupId);
return {
output: { members },
diff --git a/integrations/slack-user/src/tools/manage-user-status.ts b/integrations/slack/src/tools/manage-user-status.ts
similarity index 90%
rename from integrations/slack-user/src/tools/manage-user-status.ts
rename to integrations/slack/src/tools/manage-user-status.ts
index a3c8ffc6c..ae5940324 100644
--- a/integrations/slack-user/src/tools/manage-user-status.ts
+++ b/integrations/slack/src/tools/manage-user-status.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredAlternativeError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -30,6 +32,7 @@ export let manageUserStatus = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.userStatus)
.input(
z.object({
action: z.enum(['get', 'set', 'clear']).describe('Status action to perform'),
@@ -77,7 +80,9 @@ export let manageUserStatus = SlateTool.create(spec, {
}
if (!ctx.input.statusText && !ctx.input.statusEmoji) {
- throw new Error('statusText or statusEmoji is required for set action');
+ throw missingRequiredAlternativeError(
+ 'statusText or statusEmoji is required for set action'
+ );
}
let profile = await client.setUserProfile({
diff --git a/integrations/slack/src/tools/open-conversation.ts b/integrations/slack/src/tools/open-conversation.ts
index 941c5c431..edcc3a32d 100644
--- a/integrations/slack/src/tools/open-conversation.ts
+++ b/integrations/slack/src/tools/open-conversation.ts
@@ -1,5 +1,12 @@
import { createOpenConversationTool } from '@slates/slack-tools';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
-export let openConversation = createOpenConversationTool({ spec, SlackClient });
+export let openConversation = createOpenConversationTool({
+ spec,
+ SlackClient,
+ scopes: {
+ openConversation: slackActionScopes.openConversation
+ }
+});
diff --git a/integrations/slack/src/tools/schedule-message.ts b/integrations/slack/src/tools/schedule-message.ts
index 92c4fc824..2342110fa 100644
--- a/integrations/slack/src/tools/schedule-message.ts
+++ b/integrations/slack/src/tools/schedule-message.ts
@@ -1,5 +1,6 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -13,6 +14,7 @@ export let scheduleMessage = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.chatWrite)
.input(
z.object({
channelId: z.string().describe('Channel ID to send the scheduled message to'),
diff --git a/integrations/slack-user/src/tools/search-files.ts b/integrations/slack/src/tools/search-files.ts
similarity index 94%
rename from integrations/slack-user/src/tools/search-files.ts
rename to integrations/slack/src/tools/search-files.ts
index 48473cc5e..3fd055082 100644
--- a/integrations/slack-user/src/tools/search-files.ts
+++ b/integrations/slack/src/tools/search-files.ts
@@ -1,5 +1,6 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -21,12 +22,13 @@ export let searchFiles = SlateTool.create(spec, {
name: 'Search Files',
key: 'search_files',
description: `Search for files across a Slack workspace by keyword query. Requires a user token with the \`search:read\` scope.`,
- constraints: ['This endpoint requires a **user token** (xoxp-), not a bot token.'],
+ constraints: ['This endpoint requires a user token with the search:read scope.'],
tags: {
destructive: false,
readOnly: true
}
})
+ .scopes(slackActionScopes.search)
.input(
z.object({
query: z
diff --git a/integrations/slack-user/src/tools/search-messages.ts b/integrations/slack/src/tools/search-messages.ts
similarity index 93%
rename from integrations/slack-user/src/tools/search-messages.ts
rename to integrations/slack/src/tools/search-messages.ts
index 44d881eba..3ee0a22ad 100644
--- a/integrations/slack-user/src/tools/search-messages.ts
+++ b/integrations/slack/src/tools/search-messages.ts
@@ -1,18 +1,20 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
export let searchMessages = SlateTool.create(spec, {
name: 'Search Messages',
key: 'search_messages',
- description: `Search for messages across a Slack workspace by keyword query. Results include the message text, channel, sender, and timestamp. Requires a user token with the \`search:read\` user scope (include it under User Token Scopes for the Slack app used with OAuth).`,
- constraints: ['This endpoint requires a **user token** (xoxp-), not a bot token.'],
+ description: `Search for messages across a Slack workspace by keyword query. Results include the message text, channel, sender, and timestamp. Requires a user token with the \`search:read\` user scope.`,
+ constraints: ['This endpoint requires a user token with the search:read scope.'],
tags: {
destructive: false,
readOnly: true
}
})
+ .scopes(slackActionScopes.search)
.input(
z.object({
query: z
diff --git a/integrations/slack/src/tools/send-message.ts b/integrations/slack/src/tools/send-message.ts
index 8d34508fe..fea77f9b2 100644
--- a/integrations/slack/src/tools/send-message.ts
+++ b/integrations/slack/src/tools/send-message.ts
@@ -1,5 +1,7 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { missingRequiredFieldError } from '../lib/errors';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -17,6 +19,7 @@ export let sendMessage = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.chatWrite)
.input(
z.object({
channelId: z.string().describe('Channel, DM, or group DM ID to send the message to'),
@@ -56,7 +59,7 @@ export let sendMessage = SlateTool.create(spec, {
if (ctx.input.ephemeral) {
if (!ctx.input.targetUserId) {
- throw new Error('targetUserId is required for ephemeral messages');
+ throw missingRequiredFieldError('targetUserId', 'ephemeral messages');
}
let messageTs = await client.postEphemeral({
channel: ctx.input.channelId,
diff --git a/integrations/slack/src/tools/update-message.ts b/integrations/slack/src/tools/update-message.ts
index a3fd87d90..c589dcc49 100644
--- a/integrations/slack/src/tools/update-message.ts
+++ b/integrations/slack/src/tools/update-message.ts
@@ -1,5 +1,6 @@
import { SlateTool } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -12,6 +13,7 @@ export let updateMessage = SlateTool.create(spec, {
readOnly: false
}
})
+ .scopes(slackActionScopes.chatWrite)
.input(
z.object({
channelId: z.string().describe('Channel ID where the message exists'),
diff --git a/integrations/slack/src/triggers/channel-activity.ts b/integrations/slack/src/triggers/channel-activity.ts
index 7a81325a5..2a9566f5c 100644
--- a/integrations/slack/src/triggers/channel-activity.ts
+++ b/integrations/slack/src/triggers/channel-activity.ts
@@ -1,5 +1,6 @@
import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -9,6 +10,7 @@ export let channelActivity = SlateTrigger.create(spec, {
description:
'[Polling fallback] Triggers when channels are created, archived, unarchived, or their membership changes. Polls the conversations list to detect changes.'
})
+ .scopes(slackActionScopes.channelActivity)
.input(
z.object({
eventType: z
diff --git a/integrations/slack/src/triggers/new-file.ts b/integrations/slack/src/triggers/new-file.ts
index cd20ea6e9..f2ff40bd8 100644
--- a/integrations/slack/src/triggers/new-file.ts
+++ b/integrations/slack/src/triggers/new-file.ts
@@ -1,5 +1,6 @@
import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -9,6 +10,7 @@ export let newFile = SlateTrigger.create(spec, {
description:
'[Polling fallback] Triggers when a new file is uploaded or shared in the workspace. Polls the files list for newly created files.'
})
+ .scopes(slackActionScopes.fileEvents)
.input(
z.object({
fileId: z.string().describe('File ID'),
diff --git a/integrations/slack/src/triggers/new-message-webhook.ts b/integrations/slack/src/triggers/new-message-webhook.ts
index 45d6fe93e..0bd6ef10b 100644
--- a/integrations/slack/src/triggers/new-message-webhook.ts
+++ b/integrations/slack/src/triggers/new-message-webhook.ts
@@ -1,4 +1,5 @@
import { SlateTrigger } from 'slates';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -26,6 +27,7 @@ export let newMessageWebhook = SlateTrigger.create(spec, {
description:
'Triggers when Slack sends a `message` event to the Metorial Events URL. Use with Slack Event Subscriptions and hub route POST /slates-hub/slack/events. Complements the polling “New Message” trigger.'
})
+ .scopes(slackActionScopes.messageEvents)
.input(
z.object({
messageTs: z.string().describe('Message timestamp'),
diff --git a/integrations/slack/src/triggers/new-message.ts b/integrations/slack/src/triggers/new-message.ts
index 71ba9ba53..f48799716 100644
--- a/integrations/slack/src/triggers/new-message.ts
+++ b/integrations/slack/src/triggers/new-message.ts
@@ -1,5 +1,6 @@
import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -9,6 +10,7 @@ export let newMessage = SlateTrigger.create(spec, {
description:
'[Polling fallback] Triggers when a new message is posted in one or more Slack channels. Polls conversation history for new messages.'
})
+ .scopes(slackActionScopes.messagePolling)
.input(
z.object({
messageTs: z.string().describe('Message timestamp'),
diff --git a/integrations/slack/src/triggers/new-reaction.ts b/integrations/slack/src/triggers/new-reaction.ts
index 79bdacda4..392ec2732 100644
--- a/integrations/slack/src/triggers/new-reaction.ts
+++ b/integrations/slack/src/triggers/new-reaction.ts
@@ -1,5 +1,6 @@
import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -9,6 +10,7 @@ export let newReaction = SlateTrigger.create(spec, {
description:
'[Polling fallback] Triggers when a new emoji reaction is added to a message. Polls recent messages in channels the bot is a member of to detect new reactions.'
})
+ .scopes(slackActionScopes.reactionEvents)
.input(
z.object({
channelId: z.string().describe('Channel ID'),
diff --git a/integrations/slack/src/triggers/user-change.ts b/integrations/slack/src/triggers/user-change.ts
index 6897e8344..a01aeae83 100644
--- a/integrations/slack/src/triggers/user-change.ts
+++ b/integrations/slack/src/triggers/user-change.ts
@@ -1,5 +1,6 @@
import { SlateTrigger, SlateDefaultPollingIntervalSeconds } from 'slates';
import { SlackClient } from '../lib/client';
+import { slackActionScopes } from '../lib/scopes';
import { spec } from '../spec';
import { z } from 'zod';
@@ -9,6 +10,7 @@ export let userChange = SlateTrigger.create(spec, {
description:
'[Polling fallback] Triggers when a user joins the workspace or when user profile/status changes. Polls the user list to detect new members and profile updates.'
})
+ .scopes(slackActionScopes.userChange)
.input(
z.object({
eventType: z.enum(['joined', 'updated']).describe('Type of user event'),
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index f3818c4dc..35f8418b2 100755
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -59,203 +59,233 @@ if (isGlobalTestCommand) {
cli.parse([process.argv[0] ?? 'bun', process.argv[1] ?? 'slates', ...argv]);
} else {
+ cli
+ .command('profiles add')
+ .option('--name', 'Profile name')
+ .option('--entry', 'Local slate entry file')
+ .option('--export-name', 'Named export for the local slate provider')
+ .option('--default', 'Use this profile as the default')
+ .action(opts =>
+ printResult(() =>
+ addProfile({
+ integration: integration!,
+ name: opts.name,
+ entry: opts.entry,
+ exportName: opts.exportName,
+ useAsDefault: Boolean(opts.default)
+ })
+ )
+ );
-cli
- .command('profiles add')
- .option('--name', 'Profile name')
- .option('--entry', 'Local slate entry file')
- .option('--export-name', 'Named export for the local slate provider')
- .option('--default', 'Use this profile as the default')
- .action(opts =>
- printResult(() =>
- addProfile({
- integration: integration!,
- name: opts.name,
- entry: opts.entry,
- exportName: opts.exportName,
- useAsDefault: Boolean(opts.default)
- })
- )
- );
+ cli
+ .command('profiles list')
+ .action(() => printResult(() => listProfiles({ integration: integration! })));
-cli
- .command('profiles list')
- .action(() => printResult(() => listProfiles({ integration: integration! })));
+ cli
+ .command('profiles get [profile]')
+ .action((profile: string | undefined) =>
+ printResult(() => getProfile({ integration: integration!, profile }))
+ );
-cli
- .command('profiles get [profile]')
- .action((profile: string | undefined) =>
- printResult(() => getProfile({ integration: integration!, profile }))
- );
+ cli
+ .command('profiles use [profile]')
+ .action((profile: string | undefined) =>
+ printResult(() => useProfile({ integration: integration!, profile }))
+ );
-cli
- .command('profiles use [profile]')
- .action((profile: string | undefined) =>
- printResult(() => useProfile({ integration: integration!, profile }))
- );
+ cli
+ .command('profiles remove [profile]')
+ .action((profile: string | undefined) =>
+ printResult(() => removeProfile({ integration: integration!, profile }))
+ );
-cli
- .command('profiles remove [profile]')
- .action((profile: string | undefined) =>
- printResult(() => removeProfile({ integration: integration!, profile }))
- );
+ cli
+ .command('setup')
+ .option('--name', 'Profile name')
+ .option('--export-name', 'Named export for the local slate provider')
+ .action(opts =>
+ printResult(() =>
+ setupIntegration({
+ integration: integration!,
+ name: opts.name,
+ exportName: opts.exportName
+ })
+ )
+ );
-cli
- .command('setup')
- .option('--name', 'Profile name')
- .option('--export-name', 'Named export for the local slate provider')
- .action(opts =>
- printResult(() =>
- setupIntegration({
- integration: integration!,
- name: opts.name,
- exportName: opts.exportName
- })
- )
- );
+ cli
+ .command('tools list')
+ .option('--profile', 'Profile ID or name')
+ .action(opts =>
+ printResult(() =>
+ listTools({
+ integration: integration!,
+ profile: opts.profile
+ })
+ )
+ );
-cli
- .command('tools list')
- .option('--profile', 'Profile ID or name')
- .action(opts => printResult(() => listTools({ integration: integration!, profile: opts.profile })));
+ cli
+ .command('tools get [toolId]')
+ .option('--profile', 'Profile ID or name')
+ .action((toolId: string | undefined, opts) =>
+ printResult(() =>
+ getTool({
+ integration: integration!,
+ profile: opts.profile,
+ toolId
+ })
+ )
+ );
-cli
- .command('tools get [toolId]')
- .option('--profile', 'Profile ID or name')
- .action((toolId: string | undefined, opts) =>
- printResult(() => getTool({ integration: integration!, profile: opts.profile, toolId }))
- );
-
-cli
- .command('tools schema [toolId]')
- .option('--profile', 'Profile ID or name')
- .action((toolId: string | undefined, opts) =>
- printResult(async () => {
- let tool = await getTool({ integration: integration!, profile: opts.profile, toolId });
- return tool.inputSchema;
- })
- );
-
-cli
- .command('tools call [toolId]')
- .option('--profile', 'Profile ID or name')
- .option('--input', 'JSON input object')
- .option('--auth-method-id', 'Preferred auth method ID')
- .action((toolId: string | undefined, opts) =>
- printResult(() =>
- callTool({
- integration: integration!,
- profile: opts.profile,
- toolId,
- input: opts.input,
- authMethodId: opts.authMethodId
+ cli
+ .command('tools schema [toolId]')
+ .option('--profile', 'Profile ID or name')
+ .action((toolId: string | undefined, opts) =>
+ printResult(async () => {
+ let tool = await getTool({
+ integration: integration!,
+ profile: opts.profile,
+ toolId
+ });
+ return tool.inputSchema;
})
- )
- );
+ );
-cli
- .command('auth list')
- .option('--profile', 'Profile ID or name')
- .action(opts => printResult(() => listAuth({ integration: integration!, profile: opts.profile })));
+ cli
+ .command('tools call [toolId]')
+ .option('--profile', 'Profile ID or name')
+ .option('--input', 'JSON input object')
+ .option('--auth-method-id', 'Preferred auth method ID')
+ .action((toolId: string | undefined, opts) =>
+ printResult(() =>
+ callTool({
+ integration: integration!,
+ profile: opts.profile,
+ toolId,
+ input: opts.input,
+ authMethodId: opts.authMethodId
+ })
+ )
+ );
-cli
- .command('auth get [authMethodId]')
- .option('--profile', 'Profile ID or name')
- .action((authMethodId: string | undefined, opts) =>
- printResult(() => getAuth({ integration: integration!, profile: opts.profile, authMethodId }))
- );
+ cli
+ .command('auth list')
+ .option('--profile', 'Profile ID or name')
+ .action(opts =>
+ printResult(() => listAuth({ integration: integration!, profile: opts.profile }))
+ );
-cli
- .command('auth setup [authMethodId]')
- .option('--profile', 'Profile ID or name')
- .option('--input', 'JSON auth input object')
- .option('--oauth-credential', 'OAuth credential ID or name')
- .option('--client-id', 'OAuth client ID')
- .option('--client-secret', 'OAuth client secret')
- .option('--scopes', 'Comma-separated OAuth scopes')
- .action((authMethodId: string | undefined, opts) =>
- printResult(() =>
- setupAuth({
- integration: integration!,
- profile: opts.profile,
- authMethodId,
- input: opts.input,
- oauthCredential: opts.oauthCredential,
- clientId: opts.clientId,
- clientSecret: opts.clientSecret,
- scopes: opts.scopes
- })
- )
- );
+ cli
+ .command('auth get [authMethodId]')
+ .option('--profile', 'Profile ID or name')
+ .action((authMethodId: string | undefined, opts) =>
+ printResult(() =>
+ getAuth({ integration: integration!, profile: opts.profile, authMethodId })
+ )
+ );
-cli
- .command('auth credentials list [authMethodId]')
- .action((authMethodId: string | undefined) =>
- printResult(() => listOAuthCredentials({ integration: integration!, authMethodId }))
- );
+ cli
+ .command('auth setup [authMethodId]')
+ .option('--profile', 'Profile ID or name')
+ .option('--input', 'JSON auth input object')
+ .option('--oauth-credential', 'OAuth credential ID or name')
+ .option('--client-id', 'OAuth client ID')
+ .option('--client-secret', 'OAuth client secret')
+ .option('--scopes', 'Comma-separated OAuth scopes')
+ .action((authMethodId: string | undefined, opts) =>
+ printResult(() =>
+ setupAuth({
+ integration: integration!,
+ profile: opts.profile,
+ authMethodId,
+ input: opts.input,
+ oauthCredential: opts.oauthCredential,
+ clientId: opts.clientId,
+ clientSecret: opts.clientSecret,
+ scopes: opts.scopes
+ })
+ )
+ );
-cli
- .command('auth credentials add [authMethodId]')
- .option('--name', 'Credential name')
- .option('--client-id', 'OAuth client ID')
- .option('--client-secret', 'OAuth client secret')
- .action((authMethodId: string | undefined, opts) =>
- printResult(() =>
- addOAuthCredentials({
- integration: integration!,
- authMethodId,
- name: opts.name,
- clientId: opts.clientId,
- clientSecret: opts.clientSecret
- })
- )
- );
+ cli
+ .command('auth credentials list [authMethodId]')
+ .action((authMethodId: string | undefined) =>
+ printResult(() => listOAuthCredentials({ integration: integration!, authMethodId }))
+ );
-cli
- .command('auth refresh [authMethodId]')
- .option('--profile', 'Profile ID or name')
- .action((authMethodId: string | undefined, opts) =>
- printResult(() => refreshAuth({ integration: integration!, profile: opts.profile, authMethodId }))
- );
+ cli
+ .command('auth credentials add [authMethodId]')
+ .option('--name', 'Credential name')
+ .option('--client-id', 'OAuth client ID')
+ .option('--client-secret', 'OAuth client secret')
+ .action((authMethodId: string | undefined, opts) =>
+ printResult(() =>
+ addOAuthCredentials({
+ integration: integration!,
+ authMethodId,
+ name: opts.name,
+ clientId: opts.clientId,
+ clientSecret: opts.clientSecret
+ })
+ )
+ );
-cli
- .command('config get')
- .option('--profile', 'Profile ID or name')
- .action(opts => printResult(() => getConfig({ integration: integration!, profile: opts.profile })));
+ cli
+ .command('auth refresh [authMethodId]')
+ .option('--profile', 'Profile ID or name')
+ .action((authMethodId: string | undefined, opts) =>
+ printResult(() =>
+ refreshAuth({ integration: integration!, profile: opts.profile, authMethodId })
+ )
+ );
-cli
- .command('config set')
- .option('--profile', 'Profile ID or name')
- .option('--input', 'JSON config object')
- .action(opts =>
- printResult(() => setConfig({ integration: integration!, profile: opts.profile, input: opts.input }))
- );
+ cli
+ .command('config get')
+ .option('--profile', 'Profile ID or name')
+ .action(opts =>
+ printResult(() => getConfig({ integration: integration!, profile: opts.profile }))
+ );
-cli
- .command('config schema')
- .option('--profile', 'Profile ID or name')
- .action(opts => printResult(() => getConfigSchema({ integration: integration!, profile: opts.profile })));
+ cli
+ .command('config set')
+ .option('--profile', 'Profile ID or name')
+ .option('--input', 'JSON config object')
+ .action(opts =>
+ printResult(() =>
+ setConfig({ integration: integration!, profile: opts.profile, input: opts.input })
+ )
+ );
-cli
- .command('test')
- .option('--profile', 'Profile ID or name')
- .action(opts =>
- printResult(async () => {
- let separatorIndex = process.argv.indexOf('--');
- await runVitestWithProfile({
- integration: integration!,
- profile: opts.profile,
- vitestArgs: separatorIndex === -1 ? [] : process.argv.slice(separatorIndex + 1)
- });
+ cli
+ .command('config schema')
+ .option('--profile', 'Profile ID or name')
+ .action(opts =>
+ printResult(() => getConfigSchema({ integration: integration!, profile: opts.profile }))
+ );
- return { success: true };
- })
- );
+ cli
+ .command('test')
+ .option('--profile', 'Profile ID or name')
+ .action(opts =>
+ printResult(async () => {
+ let separatorIndex = process.argv.indexOf('--');
+ await runVitestWithProfile({
+ integration: integration!,
+ profile: opts.profile,
+ vitestArgs: separatorIndex === -1 ? [] : process.argv.slice(separatorIndex + 1)
+ });
+
+ return { success: true };
+ })
+ );
-cli
- .command('repl')
- .option('--profile', 'Profile ID or name')
- .action(opts => printResult(() => startRepl({ integration: integration!, profile: opts.profile })));
+ cli
+ .command('repl')
+ .option('--profile', 'Profile ID or name')
+ .action(opts =>
+ printResult(() => startRepl({ integration: integration!, profile: opts.profile }))
+ );
cli.parse([process.argv[0] ?? 'bun', process.argv[1] ?? 'slates', ...argv.slice(1)]);
}
diff --git a/packages/slack-tools/package.json b/packages/slack-tools/package.json
index 2a54ea3f2..3c60372c2 100644
--- a/packages/slack-tools/package.json
+++ b/packages/slack-tools/package.json
@@ -1,6 +1,6 @@
{
"name": "@slates/slack-tools",
- "version": "1.0.0-rc.1",
+ "version": "1.0.0-rc.4",
"publishConfig": {
"access": "public"
},
@@ -32,6 +32,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@lowerdeck/error": "^1.1.0",
"slates": "1.0.0-rc.9",
"zod": "^4.2"
},
diff --git a/packages/slack-tools/src/index.ts b/packages/slack-tools/src/index.ts
index bb327dc36..e3fb86d99 100644
--- a/packages/slack-tools/src/index.ts
+++ b/packages/slack-tools/src/index.ts
@@ -1,4 +1,5 @@
-import { SlateTool } from 'slates';
+import { ServiceError, badRequestError } from '@lowerdeck/error';
+import { SlateTool, type SlateActionScopes } from 'slates';
import { z } from 'zod';
type SlackClientCtor = new (token: string) => {
@@ -24,6 +25,20 @@ type SlackClientCtor = new (token: string) => {
type SlackToolFactoryDeps = {
spec: any;
SlackClient: SlackClientCtor;
+ scopes?: {
+ getConversationInfo?: SlateActionScopes;
+ manageScheduledMessages?: SlateActionScopes;
+ openConversation?: SlateActionScopes;
+ };
+};
+
+let applyScopes = (builder: any, scopes?: SlateActionScopes) =>
+ scopes ? builder.scopes(scopes) : builder;
+
+let missingRequiredFieldError = (field: string, context?: string) => {
+ let message = `${field} is required${context ? ` for ${context}` : ''}`;
+
+ return new ServiceError(badRequestError({ message }));
};
let conversationInfoOutputSchema = z.object({
@@ -81,16 +96,23 @@ let openConversationOutputSchema = z.object({
noOp: z.boolean().optional().describe('Whether Slack reported no action was needed')
});
-export let createGetConversationInfoTool = ({ spec, SlackClient }: SlackToolFactoryDeps) =>
- SlateTool.create(spec, {
- name: 'Get Conversation Info',
- key: 'get_conversation_info',
- description: `Retrieve stable metadata for a Slack conversation, including channel type, membership, topic, purpose, member count, and timestamps.`,
- tags: {
- destructive: false,
- readOnly: true
- }
- })
+export let createGetConversationInfoTool = ({
+ spec,
+ SlackClient,
+ scopes
+}: SlackToolFactoryDeps) =>
+ applyScopes(
+ SlateTool.create(spec, {
+ name: 'Get Conversation Info',
+ key: 'get_conversation_info',
+ description: `Retrieve stable metadata for a Slack conversation, including channel type, membership, topic, purpose, member count, and timestamps.`,
+ tags: {
+ destructive: false,
+ readOnly: true
+ }
+ }),
+ scopes?.getConversationInfo
+ )
.input(
z.object({
channelId: z.string().describe('Slack conversation, channel, DM, or group DM ID')
@@ -132,20 +154,27 @@ export let createGetConversationInfoTool = ({ spec, SlackClient }: SlackToolFact
})
.build();
-export let createManageScheduledMessagesTool = ({ spec, SlackClient }: SlackToolFactoryDeps) =>
- SlateTool.create(spec, {
- name: 'Manage Scheduled Messages',
- key: 'manage_scheduled_messages',
- description: `List or delete Slack messages that are scheduled to be sent later.`,
- instructions: [
- 'To **list**, optionally provide channelId, oldest, latest, limit, or cursor.',
- 'To **delete**, provide channelId and scheduledMessageId.'
- ],
- tags: {
- destructive: true,
- readOnly: false
- }
- })
+export let createManageScheduledMessagesTool = ({
+ spec,
+ SlackClient,
+ scopes
+}: SlackToolFactoryDeps) =>
+ applyScopes(
+ SlateTool.create(spec, {
+ name: 'Manage Scheduled Messages',
+ key: 'manage_scheduled_messages',
+ description: `List or delete Slack messages that are scheduled to be sent later.`,
+ instructions: [
+ 'To **list**, optionally provide channelId, oldest, latest, limit, or cursor.',
+ 'To **delete**, provide channelId and scheduledMessageId.'
+ ],
+ tags: {
+ destructive: true,
+ readOnly: false
+ }
+ }),
+ scopes?.manageScheduledMessages
+ )
.input(
z.object({
action: z.enum(['list', 'delete']).describe('Scheduled message action to perform'),
@@ -186,10 +215,10 @@ export let createManageScheduledMessagesTool = ({ spec, SlackClient }: SlackTool
if (ctx.input.action === 'delete') {
if (!ctx.input.channelId) {
- throw new Error('channelId is required for delete action');
+ throw missingRequiredFieldError('channelId', 'delete action');
}
if (!ctx.input.scheduledMessageId) {
- throw new Error('scheduledMessageId is required for delete action');
+ throw missingRequiredFieldError('scheduledMessageId', 'delete action');
}
await client.deleteScheduledMessage({
@@ -232,17 +261,24 @@ export let createManageScheduledMessagesTool = ({ spec, SlackClient }: SlackTool
})
.build();
-export let createOpenConversationTool = ({ spec, SlackClient }: SlackToolFactoryDeps) =>
- SlateTool.create(spec, {
- name: 'Open Conversation',
- key: 'open_conversation',
- description: `Open or resume a Slack direct message or group direct message with one or more users.`,
- constraints: ['Provide 1 to 8 Slack user IDs.'],
- tags: {
- destructive: false,
- readOnly: false
- }
- })
+export let createOpenConversationTool = ({
+ spec,
+ SlackClient,
+ scopes
+}: SlackToolFactoryDeps) =>
+ applyScopes(
+ SlateTool.create(spec, {
+ name: 'Open Conversation',
+ key: 'open_conversation',
+ description: `Open or resume a Slack direct message or group direct message with one or more users.`,
+ constraints: ['Provide 1 to 8 Slack user IDs.'],
+ tags: {
+ destructive: false,
+ readOnly: false
+ }
+ }),
+ scopes?.openConversation
+ )
.input(
z.object({
userIds: z
diff --git a/packages/test/src/index.test.ts b/packages/test/src/index.test.ts
index a4cb812f7..ef10402f2 100644
--- a/packages/test/src/index.test.ts
+++ b/packages/test/src/index.test.ts
@@ -40,6 +40,7 @@ afterEach(async () => {
delete process.env.SLATES_INTEGRATION;
delete process.env.SLATES_PROFILE_ID;
delete process.env.SLATES_STORE_PATH;
+ delete process.env.SLATES_STORE_ROOT_DIR;
delete process.env.SLATES_TEST_CONTEXT_PATH;
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
});
@@ -304,6 +305,66 @@ describe('@slates/test', () => {
expect(context.storePath).toBe(store.storePath);
});
+ it('loads only the selected auth method from the runtime context', async () => {
+ let cwd = await createTempDir();
+ let store = await openSlatesCliStore({
+ cwd,
+ scope: {
+ key: 'integrations/demo',
+ name: 'demo'
+ }
+ });
+ let profile = store.upsertProfile({
+ name: 'Demo',
+ target: {
+ type: 'local',
+ entry: './demo-slate.mjs',
+ exportName: 'provider'
+ }
+ });
+ store.upsertAuth(profile.id, {
+ authMethodId: 'oauth',
+ authMethodName: 'Bot OAuth',
+ authType: 'auth.oauth',
+ input: {},
+ output: {
+ token: 'bot-token'
+ },
+ scopes: ['chat:write']
+ });
+ store.upsertAuth(profile.id, {
+ authMethodId: 'user_oauth',
+ authMethodName: 'User OAuth',
+ authType: 'auth.oauth',
+ input: {},
+ output: {
+ token: 'user-token'
+ },
+ scopes: ['search:read']
+ });
+ await store.save();
+
+ let runtimeContextPath = path.join(cwd, 'runtime.json');
+ await writeFile(
+ runtimeContextPath,
+ JSON.stringify({
+ integration: 'integrations/demo',
+ profileId: profile.id,
+ authMethodId: 'user_oauth',
+ storePath: store.storePath,
+ cliDir: store.dirPath
+ }),
+ 'utf-8'
+ );
+
+ process.env.SLATES_TEST_CONTEXT_PATH = runtimeContextPath;
+
+ let context = await loadSlatesRuntimeContext({ cwd });
+ expect(context.authMethodId).toBe('user_oauth');
+ expect(Object.keys(context.profile?.auth ?? {})).toEqual(['user_oauth']);
+ expect(context.profile?.auth.user_oauth?.output.token).toBe('user-token');
+ });
+
it('creates local slate clients and asserts provider contracts', async () => {
let client = createLocalSlateTestClient({
slate: createDemoSlate(),
diff --git a/packages/test/src/runtime.ts b/packages/test/src/runtime.ts
index 79f208508..8ebfb4f83 100644
--- a/packages/test/src/runtime.ts
+++ b/packages/test/src/runtime.ts
@@ -14,6 +14,7 @@ import { readFile } from 'fs/promises';
export interface SlatesRuntimeContext {
integration: string | null;
profileId: string | null;
+ authMethodId: string | null;
profile: SlatesProfileRecord | null;
rootDir: string;
storePath: string;
@@ -24,6 +25,30 @@ export type SlatesTestClient = ReturnType;
type LocalSlate = Parameters[0]['slate'];
+let selectProfileAuth = (profile: SlatesProfileRecord | null, authMethodId: string | null) => {
+ if (!profile || !authMethodId) {
+ return profile;
+ }
+
+ let selectedAuth = profile.auth[authMethodId];
+ if (!selectedAuth) {
+ let availableAuthMethods = Object.keys(profile.auth);
+ throw new Error(
+ `No stored authentication found for auth method "${authMethodId}" in profile "${profile.name}".` +
+ (availableAuthMethods.length > 0
+ ? ` Available auth methods: ${availableAuthMethods.join(', ')}.`
+ : ' No auth methods are stored for this profile.')
+ );
+ }
+
+ return {
+ ...profile,
+ auth: {
+ [selectedAuth.authMethodId]: selectedAuth
+ }
+ };
+};
+
export interface ExpectedSlateAction {
id: string;
name?: string;
@@ -34,8 +59,9 @@ export interface ExpectedSlateAction {
}
export let getVitestExpect = () => {
- let maybeExpect = (globalThis as typeof globalThis & { expect?: typeof import('vitest').expect })
- .expect;
+ let maybeExpect = (
+ globalThis as typeof globalThis & { expect?: typeof import('vitest').expect }
+ ).expect;
if (!maybeExpect) {
throw new Error('Vitest expect is not available in the current runtime.');
@@ -56,6 +82,7 @@ export let loadSlatesRuntimeContext = async (
let parsed = JSON.parse(raw) as {
integration?: string | null;
profileId: string | null;
+ authMethodId?: string | null;
rootDir?: string;
storePath: string;
cliDir: string;
@@ -64,11 +91,16 @@ export let loadSlatesRuntimeContext = async (
storePath: parsed.storePath,
rootDir: parsed.rootDir ?? process.env.SLATES_STORE_ROOT_DIR
});
- let profile = store.getProfile(opts.profile ?? parsed.profileId ?? null);
+ let authMethodId = parsed.authMethodId ?? null;
+ let profile = selectProfileAuth(
+ store.getProfile(opts.profile ?? parsed.profileId ?? null),
+ authMethodId
+ );
return {
integration: parsed.integration ?? store.scope?.key ?? null,
profileId: profile?.id ?? parsed.profileId ?? null,
+ authMethodId,
profile,
rootDir: parsed.rootDir ?? store.rootDir,
storePath: parsed.storePath,
@@ -83,11 +115,13 @@ export let loadSlatesRuntimeContext = async (
})
: await openSlatesCliStore({ cwd: opts.cwd });
let profileId = opts.profile ?? process.env.SLATES_PROFILE_ID ?? null;
- let profile = store.getProfile(profileId);
+ let authMethodId = null;
+ let profile = selectProfileAuth(store.getProfile(profileId), authMethodId);
return {
integration: process.env.SLATES_INTEGRATION ?? store.scope?.key ?? null,
profileId: profile?.id ?? null,
+ authMethodId,
profile,
rootDir: store.rootDir,
storePath: store.storePath,
@@ -176,7 +210,10 @@ export let getSlateContract = async (client: SlatesTestClient) => {
};
};
-let expectActionMatches = (actual: Record | undefined, expected: ExpectedSlateAction) => {
+let expectActionMatches = (
+ actual: Record | undefined,
+ expected: ExpectedSlateAction
+) => {
let expect = getVitestExpect();
expect(actual).toBeTruthy();
expect(actual?.id).toBe(expected.id);
@@ -243,11 +280,17 @@ export let expectSlateContract = async (d: {
}
for (let tool of d.tools ?? []) {
- expectActionMatches(contract.tools.find(action => action.id === tool.id), tool);
+ expectActionMatches(
+ contract.tools.find(action => action.id === tool.id),
+ tool
+ );
}
for (let trigger of d.triggers ?? []) {
- expectActionMatches(contract.triggers.find(action => action.id === trigger.id), trigger);
+ expectActionMatches(
+ contract.triggers.find(action => action.id === trigger.id),
+ trigger
+ );
}
return contract;