From c800eccc65f008dad5b0412b53a9c5dc356d961d Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Wed, 20 May 2026 15:51:22 +0530 Subject: [PATCH] Slack workflow connector (#20427) https://github.com/user-attachments/assets/5a746414-988b-473c-9401-b8863a3e1c15 https://github.com/user-attachments/assets/0cdebdb1-f7c8-43cb-beef-f279387b6ce9 https://github.com/user-attachments/assets/df31c631-0781-42d8-8e6e-e5a16573ee3b https://github.com/user-attachments/assets/6adaeae4-f3c9-4a5f-b0df-50c1f9a78428 --------- Co-authored-by: Cursor Co-authored-by: martmull --- .../internal/twenty-slack/.oxlintrc.json | 18 + .../internal/twenty-slack/README.md | 126 + .../internal/twenty-slack/package.json | 35 + .../twenty-slack/public/twenty-slack.svg | 26 + .../twenty-slack/src/application.config.ts | 37 + .../send-slack-message.command-menu-item.ts | 17 + .../send-message-form.front-component.tsx | 673 +++ .../connection-providers/slack-connection.ts | 26 + .../src/constants/universal-identifiers.ts | 38 + .../handlers/slack-add-reaction-handler.ts | 37 + .../handlers/slack-delete-message-handler.ts | 36 + .../handlers/slack-list-channels-handler.ts | 104 + .../slack-post-ephemeral-message-handler.ts | 44 + .../handlers/slack-post-message-handler.ts | 52 + .../handlers/slack-update-message-handler.ts | 45 + .../slack-add-reaction-input.schema.ts | 27 + .../slack-delete-message-input.schema.ts | 21 + .../slack-list-channels-input.schema.ts | 29 + ...ack-post-ephemeral-message-input.schema.ts | 35 + .../slack-post-message-input.schema.ts | 35 + .../slack-update-message-input.schema.ts | 35 + .../src/logic-functions/slack-add-reaction.ts | 35 + .../logic-functions/slack-delete-message.ts | 35 + .../slack-list-channels-route.ts | 45 + .../logic-functions/slack-list-channels.ts | 49 + .../slack-post-ephemeral-message.ts | 34 + .../slack-post-message-route.ts | 39 + .../src/logic-functions/slack-post-message.ts | 35 + .../logic-functions/slack-update-message.ts | 35 + .../types/slack-add-reaction-input.type.ts | 5 + .../types/slack-delete-message-input.type.ts | 4 + .../types/slack-list-channels-input.type.ts | 7 + .../types/slack-list-channels-result.type.ts | 17 + .../types/slack-message-body-format.type.ts | 1 + ...slack-post-ephemeral-message-input.type.ts | 8 + .../types/slack-post-message-input.type.ts | 8 + .../types/slack-tool-result.type.ts | 7 + .../types/slack-update-message-input.type.ts | 8 + .../get-slack-chat-message-body-fields.ts | 19 + .../logic-functions/utils/get-slack-client.ts | 18 + .../utils/get-slack-connection.ts | 29 + .../utils/slack-tool-failure.ts | 10 + .../src/roles/default-function.role.ts | 21 + .../internal/twenty-slack/tsconfig.json | 31 + .../internal/twenty-slack/vitest.config.ts | 7 + .../internal/twenty-slack/yarn.lock | 4461 +++++++++++++++++ .../refresh/types/app-oauth-tokens.type.ts | 2 +- .../application-connections-list.service.ts | 2 +- ...calendar-account-authentication.service.ts | 2 +- ...ted-account-refresh-tokens.service.spec.ts | 54 +- ...onnected-account-refresh-tokens.service.ts | 92 +- .../messaging-messages-import.service.spec.ts | 4 +- ...essaging-account-authentication.service.ts | 2 +- .../workflow-schema.workspace-service.ts | 3 +- 54 files changed, 6572 insertions(+), 53 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-slack/.oxlintrc.json create mode 100644 packages/twenty-apps/internal/twenty-slack/README.md create mode 100644 packages/twenty-apps/internal/twenty-slack/package.json create mode 100644 packages/twenty-apps/internal/twenty-slack/public/twenty-slack.svg create mode 100644 packages/twenty-apps/internal/twenty-slack/src/application.config.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx create mode 100644 packages/twenty-apps/internal/twenty-slack/src/connection-providers/slack-connection.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/constants/universal-identifiers.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-add-reaction-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-delete-message-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-update-message-handler.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-add-reaction-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-delete-message-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-list-channels-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-post-message-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-update-message-input.schema.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-add-reaction-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-delete-message-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-list-channels-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-list-channels-result.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-message-body-format.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-post-message-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-tool-result.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-update-message-input.type.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-chat-message-body-fields.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-client.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-connection.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/slack-tool-failure.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/tsconfig.json create mode 100644 packages/twenty-apps/internal/twenty-slack/vitest.config.ts create mode 100644 packages/twenty-apps/internal/twenty-slack/yarn.lock diff --git a/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json b/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json new file mode 100644 index 0000000000..e0ce53c21f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "no-unused-vars": "off", + "typescript/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/no-explicit-any": "off" + } +} diff --git a/packages/twenty-apps/internal/twenty-slack/README.md b/packages/twenty-apps/internal/twenty-slack/README.md new file mode 100644 index 0000000000..32a5fafb9a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/README.md @@ -0,0 +1,126 @@ +# twenty-slack + +Slack tools for **Twenty workflows** and **agents** (the same logic functions +are available as workflow steps and as tools where your deployment exposes +them). Uses the official +[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient` +(Slack retries and error types). + +## What you can do + +Once the app is installed and Slack is **connected** (see **Twenty setup** +below): + +- **Workflow steps** — post, update, or delete bot messages; send ephemerals; + add reactions; list channels. Pick a **workspace shared** or **just for me** + connection; steps run with that token. +- **Agents / AI** — when your Twenty instance surfaces app tools to the model, + these functions can be invoked the same way as other app logic functions. +- **Quick-send** — command menu **Send Slack message** opens a side panel to + pick a channel and post (same Slack connection as workflows). + +## Tools + +| Name | Slack API | +|------|-----------| +| `slack-post-message` | `chat.postMessage` | +| `slack-post-ephemeral-message` | `chat.postEphemeral` | +| `slack-update-message` | `chat.update` | +| `slack-delete-message` | `chat.delete` | +| `slack-add-reaction` | `reactions.add` | +| `slack-list-channels` | `conversations.list` | + +### Workflow field names (for authors) + +Fields use camelCase names in the step UI, for example **`slackChannelId`** (Slack channel or DM: **name** or **ID**), **`messageText`**, and **`messageTimestamp`** (Slack’s per-message id — same value as tool output **`slackTs`** when chaining steps). Optional **`parentMessageTimestamp`** is only for **thread replies**. Post / update / ephemeral steps support optional **`messageFormat`**: **`markdown`** sends the body as Slack **`markdown_text`** (e.g. **`**bold**`**), **`plain`** sends **`text`** with markup disabled, omit uses Slack’s default for **`text`**. Ephemeral steps use **`recipientSlackUserId`**; reactions use **`emojiName`** (Slack shortcode, for example `white_check_mark`). Updating a message uses **`newMessageText`**. + +### Quick-send command menu item + +This app also ships a global command menu item — **Send Slack message** — that opens a side-panel form to pick a channel (from `conversations.list`) and post a message via `chat.postMessage`. The form is backed by two HTTP routes exposed by the app: + +- `GET /slack/channels` — lists channels visible to the bot (mirrors `slack-list-channels`). +- `POST /slack/messages` — posts a message (mirrors `slack-post-message`). + +Both routes require an authenticated Twenty user and use the same shared Slack connection as the workflow tools. + +### Prerequisites (Slack workspace) + +- You can **install** the Slack app on a workspace you administer (or get an + admin to approve it). +- For **posting**: invite the bot to the channel, **or** rely on + **`chat:write.public`** (included in OAuth) to post to **public** channels + without joining — private channels still require membership. +- **`slack-list-channels`** and the quick-send channel picker need + **`channels:read`** / **`groups:read`** on the token (requested at connect + time; see below). + +## Slack app setup + +1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) + (dedicated to this Twenty app — do not reuse for other Twenty apps). +2. **OAuth & Permissions** → **Bot Token Scopes**. Twenty uses Slack’s **bot** + OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not + only under **User Token Scopes** — or Slack will refuse install with *“doesn’t + have a bot user to install”* until at least one bot scope exists. + + The scopes **requested at connect time** are defined in + `src/connection-providers/slack-connection.ts` and must also appear under + **Bot Token Scopes** on the Slack app (Slack validates the set). Current + list: + + - `channels:read` — `conversations.list` / channel picker (public) + - `chat:write` — post, update, delete, ephemeral + - `chat:write.public` — post to public channels without the bot joining + - `groups:read` — list private channels the bot is in + - `reactions:write` — add reactions + + If you **add or remove** scopes in that file or in the Slack app, existing + installs must **re-authorize** (disconnect and **Add connection** again, or + reinstall the Slack app to the workspace) so the token picks up new scopes. + +3. Set the **Redirect URL** on the Slack app to + `/apps/oauth/callback` — the same origin your + Twenty **server** uses for API routes (the callback is not served by the SPA + alone). Local monorepo dev often uses `http://localhost:3000` (confirm the + port your `twenty-server` / `SERVER_URL` actually uses). + + **Slack “PKCE” app setting vs `localhost`:** If you turn on **PKCE** for the + Slack app under **OAuth & Permissions**, Slack treats `http://localhost…` + redirect URLs as **desktop** redirects. **Desktop redirects cannot request + bot scopes**, so OAuth will fail for this integration while you use a + localhost callback. For local dev you can either **leave Slack’s PKCE + opt-in disabled** on that Slack app, or use an **`https://` redirect** (for + example a tunnel such as ngrok or Cloudflare Tunnel to your local server), + register that URL in the Slack app, and point Twenty’s `SERVER_URL` at the + same public base URL. See Slack’s [Using + PKCE](https://docs.slack.dev/authentication/using-pkce) docs (this is + separate from Twenty sending a PKCE challenge on the authorize request). + +4. Copy the Slack **Client ID** and **Client Secret**. + +## Twenty setup + +1. Register / install this app on your Twenty server (`twenty-slack`). +2. In **Settings → Applications → Twenty Slack**, open the **Application registration** + tab (admin-only) and set: + - `SLACK_CLIENT_ID` + - `SLACK_CLIENT_SECRET` +3. In the same app, open the **Connections** tab and click **Add connection**. + Choose **Just for me** or **Workspace shared**, then complete the Slack sign-in. + +Once connected, workflow steps use the connection access token: a +**workspace** connection is preferred when present; otherwise the first +connection returned for the Slack provider is used (see +`src/logic-functions/utils/get-slack-connection.ts`). + +## Development + +```bash +cd packages/twenty-apps/internal/twenty-slack +yarn install +yarn lint +yarn test +``` + +Use `yarn twenty dev` from this directory to develop against a local Twenty +instance (see other internal apps in this monorepo). diff --git a/packages/twenty-apps/internal/twenty-slack/package.json b/packages/twenty-apps/internal/twenty-slack/package.json new file mode 100644 index 0000000000..0ae216dc3f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/package.json @@ -0,0 +1,35 @@ +{ + "name": "twenty-slack", + "version": "0.1.0", + "description": "Slack workflow connector for Twenty", + "license": "MIT", + "engines": { + "node": "^24.5.0", + "npm": "please-use-yarn", + "yarn": ">=4.0.2" + }, + "keywords": [ + "twenty-app" + ], + "packageManager": "yarn@4.9.2", + "scripts": { + "twenty": "twenty", + "lint": "oxlint -c .oxlintrc.json .", + "lint:fix": "oxlint --fix -c .oxlintrc.json .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@slack/web-api": "^7.8.0", + "twenty-sdk": "2.4.0" + }, + "devDependencies": { + "@types/node": "^24.7.2", + "@types/react": "^18.2.0", + "oxlint": "^0.16.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.9.3", + "vitest": "^3.1.1" + } +} diff --git a/packages/twenty-apps/internal/twenty-slack/public/twenty-slack.svg b/packages/twenty-apps/internal/twenty-slack/public/twenty-slack.svg new file mode 100644 index 0000000000..b25391603c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/public/twenty-slack.svg @@ -0,0 +1,26 @@ + + Slack + + + + + diff --git a/packages/twenty-apps/internal/twenty-slack/src/application.config.ts b/packages/twenty-apps/internal/twenty-slack/src/application.config.ts new file mode 100644 index 0000000000..b615e3f686 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/application.config.ts @@ -0,0 +1,37 @@ +import { defineApplication } from 'twenty-sdk/define'; + +import { + APPLICATION_UNIVERSAL_IDENTIFIER, + DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineApplication({ + universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + displayName: 'Twenty Slack', + description: + 'Connect Slack to Twenty. Each workspace member (or a shared workspace connection) can authenticate Slack; workflow steps then post messages, ephemerals, updates, deletes, and reactions on behalf of that connection.', + logoUrl: 'public/twenty-slack.svg', + author: 'Twenty', + category: 'Communication', + aboutDescription: + 'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.', + websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started', + termsUrl: 'https://www.twenty.com/terms', + emailSupport: 'contact@twenty.com', + issueReportUrl: 'https://github.com/twentyhq/twenty/issues', + defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + serverVariables: { + SLACK_CLIENT_ID: { + description: + 'OAuth client ID from your Slack app (api.slack.com/apps). Public in OAuth flows; only the client secret must stay confidential.', + isSecret: false, + isRequired: true, + }, + SLACK_CLIENT_SECRET: { + description: + 'OAuth client secret from your Slack app. Stored encrypted; never exposed in API responses.', + isSecret: true, + isRequired: true, + }, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts b/packages/twenty-apps/internal/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts new file mode 100644 index 0000000000..68b344629a --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts @@ -0,0 +1,17 @@ +import { defineCommandMenuItem } from 'twenty-sdk/define'; + +import { + SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineCommandMenuItem({ + universalIdentifier: SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER, + label: 'Send Slack message', + shortLabel: 'Slack message', + icon: 'IconBrandSlack', + isPinned: false, + availabilityType: 'GLOBAL', + frontComponentUniversalIdentifier: + SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx b/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx new file mode 100644 index 0000000000..ba20b4eb83 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx @@ -0,0 +1,673 @@ +import { + useCallback, + useEffect, + useState, + type CSSProperties, + type SyntheticEvent, +} from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + closeSidePanel, + enqueueSnackbar, + unmountFrontComponent, +} from 'twenty-sdk/front-component'; +import { themeCssVariables } from 'twenty-sdk/ui'; + +import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +type SlackChannel = { + id: string; + name: string; + isPrivate: boolean; + isArchived: boolean; + isMember: boolean; + numMembers: number; + topic: string; + purpose: string; +}; + +type ListChannelsResponse = { + success: boolean; + channels?: SlackChannel[]; + error?: string; + message?: string; +}; + +type PostMessageResponse = { + success: boolean; + slackTs?: string; + error?: string; + message?: string; +}; + +type PostedMessage = { + channelId: string; + channelName: string; + slackTs?: string; +}; + +type MessageFormat = 'plain' | 'markdown'; + +const FORMAT_OPTIONS: { value: MessageFormat; label: string }[] = [ + { value: 'plain', label: 'Plain text' }, + { value: 'markdown', label: 'Markdown' }, +]; + +const isMessageFormat = (value: string): value is MessageFormat => + value === 'plain' || value === 'markdown'; + +const readSerializedValue = ( + event: SyntheticEvent, +): string | undefined => { + const object = event as { + detail?: { value?: string }; + value?: string; + target?: { value?: string }; + }; + + if (typeof object.detail?.value === 'string') { + return object.detail.value; + } + if (typeof object.value === 'string') { + return object.value; + } + if (typeof object.target?.value === 'string') { + return object.target.value; + } + + return undefined; +}; + +const onValueChange = + (setValue: (value: string) => void) => + (event: SyntheticEvent) => { + const value = readSerializedValue(event); + + if (typeof value === 'string') { + setValue(value); + } + }; + +const callAppRoute = async ( + path: string, + method: 'GET' | 'POST', + body?: Record, +): Promise => { + const apiBaseUrl = process.env.TWENTY_API_URL; + const token = + process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY; + + if (!apiBaseUrl || !token) { + throw new Error('App is missing API URL or access token configuration.'); + } + + const response = await fetch(`${apiBaseUrl}/s${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + + let errorMessage: string | undefined; + + try { + const parsed = JSON.parse(text) as { + messages?: string[]; + message?: string; + error?: string; + }; + + errorMessage = parsed.messages?.[0] ?? parsed.message ?? parsed.error; + } catch { + // Body is not JSON; fall through to raw text or status message. + } + + throw new Error( + errorMessage ?? + (text.length > 0 + ? text.slice(0, 200) + : `Request failed with status ${response.status}.`), + ); + } + + return response.json() as Promise; +}; + +const sortChannels = (channels: SlackChannel[]): SlackChannel[] => + [...channels].sort((a, b) => { + if (a.isMember !== b.isMember) { + return a.isMember ? -1 : 1; + } + + return a.name.localeCompare(b.name); + }); + +const formatChannelOptionLabel = (channel: SlackChannel): string => { + const prefix = channel.isPrivate ? '🔒' : '#'; + const suffix = channel.isMember ? '' : ' — bot is not a member'; + + return `${prefix} ${channel.name}${suffix}`; +}; + +const getChannelHelperText = ( + selectedChannel: SlackChannel | undefined, +): string => { + if (selectedChannel === undefined) { + return 'Pick a channel to post to.'; + } + + if (selectedChannel.isMember) { + return selectedChannel.isPrivate + ? 'Private channel · bot is a member.' + : 'Public channel · bot is a member.'; + } + + return 'Bot is not a member of this channel — it must be invited before it can post.'; +}; + +const getStyles = (): Record => ({ + container: { + fontFamily: themeCssVariables.font.family, + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.primary, + background: themeCssVariables.background.primary, + display: 'flex', + flexDirection: 'column', + height: '100%', + boxSizing: 'border-box', + }, + header: { + padding: themeCssVariables.spacing[4], + borderBottom: `1px solid ${themeCssVariables.border.color.light}`, + flexShrink: 0, + }, + headerTitleBlock: { + marginBottom: themeCssVariables.spacing[4], + }, + pageTitle: { + fontSize: themeCssVariables.font.size.md, + fontWeight: themeCssVariables.font.weight.semiBold, + color: themeCssVariables.font.color.primary, + margin: 0, + }, + pageSubtitle: { + fontSize: themeCssVariables.font.size.md, + fontWeight: themeCssVariables.font.weight.regular, + color: themeCssVariables.font.color.tertiary, + margin: 0, + marginTop: themeCssVariables.spacing[2], + lineHeight: 1.5, + }, + body: { + flex: 1, + minHeight: 0, + padding: themeCssVariables.spacing[4], + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[4], + overflowY: 'auto', + }, + field: { + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[1], + }, + fieldGrowing: { + display: 'flex', + flexDirection: 'column', + gap: themeCssVariables.spacing[1], + flex: 1, + minHeight: 0, + }, + label: { + fontSize: themeCssVariables.font.size.xs, + fontWeight: themeCssVariables.font.weight.medium, + color: themeCssVariables.font.color.secondary, + }, + helperText: { + fontSize: themeCssVariables.font.size.xs, + color: themeCssVariables.font.color.tertiary, + }, + select: { + appearance: 'none', + WebkitAppearance: 'none', + background: themeCssVariables.background.secondary, + border: `1px solid ${themeCssVariables.border.color.medium}`, + borderRadius: themeCssVariables.border.radius.sm, + padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}`, + color: themeCssVariables.font.color.primary, + fontSize: themeCssVariables.font.size.sm, + fontFamily: themeCssVariables.font.family, + height: themeCssVariables.spacing[8], + cursor: 'pointer', + outline: 'none', + width: '100%', + boxSizing: 'border-box', + }, + textarea: { + background: themeCssVariables.background.secondary, + border: `1px solid ${themeCssVariables.border.color.medium}`, + borderRadius: themeCssVariables.border.radius.sm, + padding: themeCssVariables.spacing[3], + color: themeCssVariables.font.color.primary, + fontSize: themeCssVariables.font.size.sm, + fontFamily: themeCssVariables.font.family, + lineHeight: 1.5, + width: '100%', + boxSizing: 'border-box', + outline: 'none', + resize: 'none', + flex: 1, + minHeight: themeCssVariables.spacing[20], + }, + footer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: themeCssVariables.spacing[2], + padding: themeCssVariables.spacing[3], + borderTop: `1px solid ${themeCssVariables.border.color.light}`, + flexShrink: 0, + }, + buttonBase: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: themeCssVariables.spacing[1], + height: themeCssVariables.spacing[8], + padding: `0 ${themeCssVariables.spacing[3]}`, + borderRadius: themeCssVariables.border.radius.sm, + fontSize: themeCssVariables.font.size.sm, + fontFamily: themeCssVariables.font.family, + fontWeight: themeCssVariables.font.weight.medium, + cursor: 'pointer', + border: '1px solid transparent', + boxSizing: 'border-box', + }, + secondaryButton: { + background: themeCssVariables.background.secondary, + color: themeCssVariables.font.color.secondary, + border: `1px solid ${themeCssVariables.border.color.medium}`, + }, + primaryButton: { + background: themeCssVariables.color.blue, + color: themeCssVariables.font.color.inverted, + }, + primaryButtonDisabled: { + background: themeCssVariables.accent.accent4060, + cursor: 'not-allowed', + }, + centeredState: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: themeCssVariables.spacing[4], + height: '100%', + boxSizing: 'border-box', + }, + stateBlock: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: themeCssVariables.spacing[3], + maxWidth: '320px', + textAlign: 'center', + }, + stateTitle: { + fontSize: themeCssVariables.font.size.md, + fontWeight: themeCssVariables.font.weight.medium, + color: themeCssVariables.font.color.primary, + margin: 0, + }, + stateDescription: { + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.tertiary, + margin: 0, + lineHeight: 1.5, + }, + stateError: { + fontSize: themeCssVariables.font.size.sm, + color: themeCssVariables.font.color.danger, + margin: 0, + lineHeight: 1.5, + }, +}); + +const SendMessageForm = () => { + const [channels, setChannels] = useState([]); + const [channelsLoading, setChannelsLoading] = useState(true); + const [channelsError, setChannelsError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [postedMessage, setPostedMessage] = useState( + null, + ); + + const [channelId, setChannelId] = useState(''); + const [messageText, setMessageText] = useState(''); + const [messageFormat, setMessageFormat] = useState('markdown'); + + const styles = getStyles(); + + const fetchChannels = useCallback(async () => { + try { + setChannelsError(null); + setChannelsLoading(true); + + const result = await callAppRoute( + '/slack/channels?limit=200', + 'GET', + ); + + if (!result.success) { + setChannelsError( + result.error ?? result.message ?? 'Failed to load Slack channels.', + ); + + return; + } + + const sorted = sortChannels(result.channels ?? []); + + setChannels(sorted); + + const firstMemberChannel = sorted.find((channel) => channel.isMember); + + if (firstMemberChannel !== undefined) { + setChannelId(firstMemberChannel.id); + } else if (sorted.length === 1) { + setChannelId(sorted[0].id); + } + } catch (error) { + setChannelsError( + error instanceof Error + ? error.message + : 'Failed to load Slack channels.', + ); + } finally { + setChannelsLoading(false); + } + }, []); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const handleClose = () => { + unmountFrontComponent(); + closeSidePanel(); + }; + + const handleSendAnother = () => { + setMessageText(''); + setPostedMessage(null); + }; + + const handleSubmit = async () => { + const trimmedMessage = messageText.trim(); + + if (channelId === '' || trimmedMessage === '') { + return; + } + + setSubmitting(true); + + try { + const result = await callAppRoute( + '/slack/messages', + 'POST', + { + slackChannelId: channelId, + messageText: trimmedMessage, + messageFormat, + }, + ); + + if (!result.success) { + await enqueueSnackbar({ + message: + result.error ?? result.message ?? 'Failed to send Slack message.', + variant: 'error', + }); + + return; + } + + const channel = channels.find( + (channelItem) => channelItem.id === channelId, + ); + + setPostedMessage({ + channelId, + channelName: channel?.name ?? channelId, + slackTs: result.slackTs, + }); + + await enqueueSnackbar({ + message: `Message sent to #${channel?.name ?? channelId}`, + variant: 'success', + }); + } catch (error) { + await enqueueSnackbar({ + message: + error instanceof Error + ? error.message + : 'Failed to send Slack message.', + variant: 'error', + }); + } finally { + setSubmitting(false); + } + }; + + if (channelsLoading) { + return ( +
+
+

Loading channels…

+

+ Fetching the list of Slack channels your bot can post to. +

+
+
+ ); + } + + if (channelsError !== null) { + return ( +
+
+

Couldn't load channels

+

{channelsError}

+ +
+
+ ); + } + + if (channels.length === 0) { + return ( +
+
+

No channels available

+

+ The bot doesn't have access to any Slack channels yet. Invite it to + a channel and try again. +

+ +
+
+ ); + } + + if (postedMessage !== null) { + return ( +
+
+
+

Message sent

+

+ {`Posted to #${postedMessage.channelName}.`} +

+
+
+
+

+ Your message is now visible in Slack. +

+
+
+ + +
+
+ ); + } + + const trimmedMessage = messageText.trim(); + const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting; + const selectedChannel = channels.find( + (channelItem) => channelItem.id === channelId, + ); + const channelHelperText = getChannelHelperText(selectedChannel); + + return ( +
+
+
+

Send Slack message

+

+ Post a message to any channel your bot has access to. +

+
+
+ +
+
+ + + {channelHelperText} +
+ +
+ + + + {messageFormat === 'markdown' + ? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).' + : 'The message will be sent exactly as written, with no formatting.'} + +
+ +
+ +