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 <cursoragent@cursor.com> Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`<YOUR_TWENTY_SERVER_URL>/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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 127 127"
|
||||
fill="none"
|
||||
role="img"
|
||||
>
|
||||
<title>Slack</title>
|
||||
<path
|
||||
fill="#E01E5A"
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
/>
|
||||
<path
|
||||
fill="#36C5F0"
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
/>
|
||||
<path
|
||||
fill="#2EB67D"
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#ECB22E"
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
+17
@@ -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,
|
||||
});
|
||||
+673
@@ -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<HTMLElement>,
|
||||
): 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<HTMLElement>) => {
|
||||
const value = readSerializedValue(event);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
setValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
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<TResponse>;
|
||||
};
|
||||
|
||||
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<string, CSSProperties> => ({
|
||||
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<SlackChannel[]>([]);
|
||||
const [channelsLoading, setChannelsLoading] = useState(true);
|
||||
const [channelsError, setChannelsError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [postedMessage, setPostedMessage] = useState<PostedMessage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [messageFormat, setMessageFormat] = useState<MessageFormat>('markdown');
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
try {
|
||||
setChannelsError(null);
|
||||
setChannelsLoading(true);
|
||||
|
||||
const result = await callAppRoute<ListChannelsResponse>(
|
||||
'/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<PostMessageResponse>(
|
||||
'/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 (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Loading channels…</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
Fetching the list of Slack channels your bot can post to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channelsError !== null) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Couldn't load channels</h2>
|
||||
<p style={styles.stateError}>{channelsError}</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>No channels available</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
The bot doesn't have access to any Slack channels yet. Invite it to
|
||||
a channel and try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (postedMessage !== null) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Message sent</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
{`Posted to #${postedMessage.channelName}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.body}>
|
||||
<p style={styles.stateDescription}>
|
||||
Your message is now visible in Slack.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.primaryButton }}
|
||||
onClick={handleSendAnother}
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedMessage = messageText.trim();
|
||||
const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting;
|
||||
const selectedChannel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
const channelHelperText = getChannelHelperText(selectedChannel);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Send Slack message</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
Post a message to any channel your bot has access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.body}>
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-channel-select" style={styles.label}>
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
id="slack-channel-select"
|
||||
value={channelId}
|
||||
onChange={onValueChange(setChannelId)}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a channel…
|
||||
</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{formatChannelOptionLabel(channel)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>{channelHelperText}</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-message-format-select" style={styles.label}>
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="slack-message-format-select"
|
||||
value={messageFormat}
|
||||
onChange={onValueChange((value) => {
|
||||
if (isMessageFormat(value)) {
|
||||
setMessageFormat(value);
|
||||
}
|
||||
})}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>
|
||||
{messageFormat === 'markdown'
|
||||
? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).'
|
||||
: 'The message will be sent exactly as written, with no formatting.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.fieldGrowing}>
|
||||
<label htmlFor="slack-message-textarea" style={styles.label}>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="slack-message-textarea"
|
||||
value={messageText}
|
||||
onInput={onValueChange(setMessageText)}
|
||||
onChange={onValueChange(setMessageText)}
|
||||
placeholder="Write your message…"
|
||||
style={styles.textarea}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.buttonBase,
|
||||
...styles.primaryButton,
|
||||
...(canSubmit ? {} : styles.primaryButtonDisabled),
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'send-slack-message-form',
|
||||
description:
|
||||
'Form to send a Slack message to any channel the bot can post to, with plain-text or markdown formatting.',
|
||||
component: SendMessageForm,
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
|
||||
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
|
||||
revokeEndpoint: 'https://slack.com/api/auth.revoke',
|
||||
scopes: [
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'chat:write.public',
|
||||
'groups:read',
|
||||
'reactions:write',
|
||||
],
|
||||
clientIdVariable: 'SLACK_CLIENT_ID',
|
||||
clientSecretVariable: 'SLACK_CLIENT_SECRET',
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
usePkce: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'a8c47f21-3b9e-4d2a-8f61-9c0e7d4a2b51';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940';
|
||||
|
||||
export const SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
|
||||
'8b6c6fd9-8d61-4b6f-9f25-3d92a0f2cc5b';
|
||||
|
||||
export const SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'c6f25d09-1b7c-4e3f-ad42-7aec5b29830f';
|
||||
|
||||
export const SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'd5e14c98-0a6b-4e2e-ac31-69db4a18720e';
|
||||
|
||||
export const SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'e4d03b87-9a5b-4f1d-8b20-58ca3917620d';
|
||||
|
||||
export const SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'f3c92a76-8b4a-4a09-ba19-47b9280651c9';
|
||||
|
||||
export const SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER =
|
||||
'2a8c7f91-4d3e-5b6f-a7c8-9d0e1f2a3b4c';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
|
||||
'3b7d8a92-5e4f-4c7a-b8d9-0e1f2a3b4c5d';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'6e0c3d5f-9a4f-4f62-bc0e-3d5a7f9b4c6e';
|
||||
|
||||
export const SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'7f1d4e60-ab50-4273-9d1f-4e6b8a0c5d7f';
|
||||
|
||||
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'4c8a1b3f-7e2d-4f50-9a8c-1b3e5d7f2a4c';
|
||||
|
||||
export const SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'5d9b2c4e-8f3e-4f50-ab9d-2c4f6e8a3b5d';
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { type SlackAddReactionInput } from 'src/logic-functions/types/slack-add-reaction-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackAddReactionHandler = async (
|
||||
parameters: SlackAddReactionInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.reactions.add({
|
||||
channel: parameters.slackChannelId,
|
||||
timestamp: parameters.messageTimestamp,
|
||||
name: parameters.emojiName.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Reaction "${parameters.emojiName.trim()}" added to the message.`,
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to add Slack reaction', error);
|
||||
}
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { type SlackDeleteMessageInput } from 'src/logic-functions/types/slack-delete-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackDeleteMessageHandler = async (
|
||||
parameters: SlackDeleteMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.chat.delete({
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message deleted.',
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to delete Slack message', error);
|
||||
}
|
||||
};
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type SlackChannelType,
|
||||
type SlackListChannelsInput,
|
||||
} from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
import {
|
||||
type SlackListChannelsResult,
|
||||
type SlackListChannelsResultChannel,
|
||||
} from 'src/logic-functions/types/slack-list-channels-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 1000;
|
||||
const SLACK_PAGE_SIZE = 200;
|
||||
|
||||
const SLACK_TYPES_BY_CHANNEL_TYPE = {
|
||||
Public: 'public_channel',
|
||||
Private: 'private_channel',
|
||||
All: 'public_channel,private_channel',
|
||||
} as const satisfies Record<SlackChannelType, string>;
|
||||
|
||||
export const slackListChannelsHandler = async (
|
||||
parameters: SlackListChannelsInput,
|
||||
): Promise<SlackListChannelsResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(parameters.limit ?? DEFAULT_LIMIT, 1),
|
||||
MAX_LIMIT,
|
||||
);
|
||||
const excludeArchived = parameters.excludeArchived ?? true;
|
||||
const types = SLACK_TYPES_BY_CHANNEL_TYPE[parameters.channelType ?? 'All'];
|
||||
|
||||
const channels: SlackListChannelsResultChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
try {
|
||||
while (channels.length < limit) {
|
||||
const remaining = limit - channels.length;
|
||||
const response = await client.conversations.list({
|
||||
types,
|
||||
exclude_archived: excludeArchived,
|
||||
limit: Math.min(SLACK_PAGE_SIZE, remaining),
|
||||
cursor,
|
||||
});
|
||||
|
||||
const page = response.channels ?? [];
|
||||
|
||||
for (const channel of page) {
|
||||
if (channels.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isDefined(channel.id) || !isDefined(channel.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels.push({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
isPrivate: channel.is_private === true,
|
||||
isArchived: channel.is_archived === true,
|
||||
isMember: channel.is_member === true,
|
||||
numMembers: channel.num_members ?? 0,
|
||||
topic: channel.topic?.value ?? '',
|
||||
purpose: channel.purpose?.value ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const nextCursor = response.response_metadata?.next_cursor;
|
||||
|
||||
if (!isDefined(nextCursor) || nextCursor.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channels,
|
||||
count: channels.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { type SlackPostEphemeralMessageInput } from 'src/logic-functions/types/slack-post-ephemeral-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostEphemeralMessageHandler = async (
|
||||
parameters: SlackPostEphemeralMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const postEphemeralPayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
user: parameters.recipientSlackUserId,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
await client.chat.postEphemeral(postEphemeralPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ephemeral message sent to the user in the channel.',
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack ephemeral message', error);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SlackPostMessageInput } from 'src/logic-functions/types/slack-post-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostMessageHandler = async (
|
||||
parameters: SlackPostMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const parentTimestamp = parameters.parentMessageTimestamp;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const data = await client.chat.postMessage({
|
||||
channel: parameters.slackChannelId,
|
||||
thread_ts:
|
||||
isDefined(parentTimestamp) && parentTimestamp.trim().length > 0
|
||||
? parentTimestamp.trim()
|
||||
: undefined,
|
||||
...bodyFields,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.ts
|
||||
? `Message posted to Slack (ts=${data.ts}).`
|
||||
: 'Message posted to Slack.',
|
||||
slackTs: data.ts,
|
||||
channel: data.channel,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack message', error);
|
||||
}
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { type SlackUpdateMessageInput } from 'src/logic-functions/types/slack-update-message-input.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackUpdateMessageHandler = async (
|
||||
parameters: SlackUpdateMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.newMessageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const updatePayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
const data = await client.chat.update(updatePayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message updated.',
|
||||
slackTs: data.ts,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to update Slack message', error);
|
||||
}
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackAddReactionInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the message lives (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'The message to react to: use the **timestamp** from Slack (same value as `slackTs` after posting, or visible in “Copy link” URLs as the last number).',
|
||||
},
|
||||
emojiName: {
|
||||
type: 'string',
|
||||
label: 'Emoji name',
|
||||
description:
|
||||
'Emoji to add, **without** colons — for example `thumbsup`, `eyes`, or `white_check_mark`. Do not paste `:thumbsup:` style names.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'emojiName'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackDeleteMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to remove: its **timestamp** from Slack (same value as `slackTs` when the bot posted it). Only messages sent by this bot can be deleted.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackListChannelsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelType: {
|
||||
type: 'string',
|
||||
label: 'Channel type',
|
||||
enum: ['Public', 'Private', 'All'],
|
||||
description:
|
||||
'Optional. Which kinds of channels to include. `All` returns both public and private channels. Default: `All`.',
|
||||
},
|
||||
excludeArchived: {
|
||||
type: 'boolean',
|
||||
label: 'Exclude archived',
|
||||
description:
|
||||
'Optional. Hide archived channels from the list. Default: `true`.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
label: 'Max results',
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
description:
|
||||
'Optional. Cap the total number of channels returned across pagination. Default: `200`. Max: `1000`. Larger workspaces may not be fully covered within the function timeout.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostEphemeralMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the note appears (Slack channel ID like C…). The person you choose must already be in this channel, or they will not see the message.',
|
||||
},
|
||||
recipientSlackUserId: {
|
||||
type: 'string',
|
||||
label: 'Recipient (Slack user ID)',
|
||||
description:
|
||||
'Who can see this message: their Slack **member ID** (starts with U…). Only that person sees it; everyone else in the channel does not. In Slack: click their profile → three dots → Copy member ID (wording may vary by client).',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Short note shown only to the recipient above — for example a private hint, validation result, or next step.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` / `plain` / omit — see that action’s `messageFormat` description.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'recipientSlackUserId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Where to post: the Slack channel or DM ID (looks like C0123…, G… for private channels, or D… for DMs). In Slack: open the channel → channel name → scroll down → copy Channel ID. Using the ID is more reliable than a channel name.',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'The message people will read in Slack. Keep it concise; very long messages may be rejected by Slack.',
|
||||
},
|
||||
parentMessageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Parent message timestamp',
|
||||
description:
|
||||
'Optional. Only when you want a **thread reply**: paste the **Message timestamp** from an earlier Slack step (the value returned as `slackTs` after posting). Leave empty for a normal new message at the bottom of the channel.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. `markdown`: send as Slack `markdown_text` so usual Markdown in `messageText` works (**bold**, lists, fenced code). `plain`: send as `text` with `mrkdwn: false` (no markup). Omit: send as `text` and let Slack use its default (legacy mrkdwn may still apply in `text`).',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackUpdateMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to edit: its **timestamp** from Slack (same value as `slackTs` returned when the bot posted the message). You cannot edit other people’s messages—only ones this bot sent.',
|
||||
},
|
||||
newMessageText: {
|
||||
type: 'string',
|
||||
label: 'New message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Replacement text shown in Slack instead of the old content.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` → `markdown_text`, `plain` → plain `text` without markup, omit → Slack default on `text`.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'newMessageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler';
|
||||
import { slackAddReactionInputSchema } from './schemas/slack-add-reaction-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-add-reaction',
|
||||
description:
|
||||
'Add an emoji reaction to a message (for example a checkmark as `white_check_mark`) so the channel can see status at a glance.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackAddReactionInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Add Slack Reaction',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackAddReactionInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackAddReactionHandler,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackDeleteMessageHandler } from 'src/logic-functions/handlers/slack-delete-message-handler';
|
||||
import { slackDeleteMessageInputSchema } from './schemas/slack-delete-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-delete-message',
|
||||
description:
|
||||
'Remove a message this bot sent (for example after a mistake or when a workflow is cancelled).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackDeleteMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Delete Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackDeleteMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackDeleteMessageHandler,
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { type SlackChannelType } from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
|
||||
const VALID_CHANNEL_TYPES: SlackChannelType[] = ['Public', 'Private', 'All'];
|
||||
|
||||
const isSlackChannelType = (value: unknown): value is SlackChannelType =>
|
||||
typeof value === 'string' &&
|
||||
VALID_CHANNEL_TYPES.includes(value as SlackChannelType);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const params = event.queryStringParameters ?? {};
|
||||
|
||||
const rawLimit = params.limit;
|
||||
const parsedLimit =
|
||||
typeof rawLimit === 'string' && rawLimit.length > 0
|
||||
? Number(rawLimit)
|
||||
: undefined;
|
||||
|
||||
return slackListChannelsHandler({
|
||||
channelType: isSlackChannelType(params.channelType)
|
||||
? params.channelType
|
||||
: undefined,
|
||||
excludeArchived: params.excludeArchived === 'false' ? false : undefined,
|
||||
limit:
|
||||
typeof parsedLimit === 'number' && Number.isFinite(parsedLimit)
|
||||
? parsedLimit
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/channels',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { slackListChannelsInputSchema } from './schemas/slack-list-channels-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels',
|
||||
description:
|
||||
'List Slack channels visible to the bot, with basic metadata (id, name, privacy, archive state, membership, member count, topic, purpose). Useful for picking a channel by name or branching a workflow on whether the bot is a member.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackListChannelsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Slack Channels',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackListChannelsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
isPrivate: { type: 'boolean' },
|
||||
isArchived: { type: 'boolean' },
|
||||
isMember: { type: 'boolean' },
|
||||
numMembers: { type: 'number' },
|
||||
topic: { type: 'string' },
|
||||
purpose: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackListChannelsHandler,
|
||||
});
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler';
|
||||
import { slackPostEphemeralMessageInputSchema } from './schemas/slack-post-ephemeral-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-ephemeral-message',
|
||||
description:
|
||||
'Send a private-on-channel note: only the chosen teammate sees it in that channel (not a DM broadcast to everyone).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostEphemeralMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Ephemeral Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostEphemeralMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostEphemeralMessageHandler,
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
const VALID_MESSAGE_FORMATS: SlackMessageBodyFormat[] = ['plain', 'markdown'];
|
||||
|
||||
const isSlackMessageBodyFormat = (
|
||||
value: unknown,
|
||||
): value is SlackMessageBodyFormat =>
|
||||
typeof value === 'string' &&
|
||||
VALID_MESSAGE_FORMATS.includes(value as SlackMessageBodyFormat);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
return slackPostMessageHandler({
|
||||
slackChannelId: (body?.slackChannelId as string | undefined) ?? '',
|
||||
messageText: (body?.messageText as string | undefined) ?? '',
|
||||
parentMessageTimestamp: body?.parentMessageTimestamp as string | undefined,
|
||||
messageFormat: isSlackMessageBodyFormat(body?.messageFormat)
|
||||
? body.messageFormat
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/messages',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { slackPostMessageInputSchema } from './schemas/slack-post-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message',
|
||||
description:
|
||||
'Send a message to a Slack channel or DM. Optionally reply inside an existing thread using the parent message’s timestamp from a previous step.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostMessageHandler,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackUpdateMessageHandler } from 'src/logic-functions/handlers/slack-update-message-handler';
|
||||
import { slackUpdateMessageInputSchema } from './schemas/slack-update-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-update-message',
|
||||
description:
|
||||
'Change the text of a message this bot already sent. You need the channel ID and the message’s timestamp from when it was posted.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackUpdateMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Update Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackUpdateMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackUpdateMessageHandler,
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type SlackAddReactionInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
emojiName: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SlackDeleteMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type SlackChannelType = 'Public' | 'Private' | 'All';
|
||||
|
||||
export type SlackListChannelsInput = {
|
||||
channelType?: SlackChannelType;
|
||||
excludeArchived?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export type SlackListChannelsResultChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type SlackListChannelsResult = {
|
||||
success: boolean;
|
||||
channels: SlackListChannelsResultChannel[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type SlackMessageBodyFormat = 'plain' | 'markdown';
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostEphemeralMessageInput = {
|
||||
slackChannelId: string;
|
||||
recipientSlackUserId: string;
|
||||
messageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageText: string;
|
||||
parentMessageTimestamp?: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type SlackToolResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
slackTs?: string;
|
||||
channel?: string;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackUpdateMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
newMessageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
type SlackChatMessageBodyFields =
|
||||
| { markdown_text: string; text?: never; mrkdwn?: never }
|
||||
| { text: string; markdown_text?: never; mrkdwn?: boolean };
|
||||
|
||||
export const getSlackChatMessageBodyFields = (
|
||||
messageText: string,
|
||||
messageFormat: SlackMessageBodyFormat | undefined,
|
||||
): SlackChatMessageBodyFields => {
|
||||
switch (messageFormat) {
|
||||
case 'markdown':
|
||||
return { markdown_text: messageText };
|
||||
case 'plain':
|
||||
return { text: messageText, mrkdwn: false };
|
||||
default:
|
||||
return { text: messageText };
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { WebClient } from '@slack/web-api';
|
||||
|
||||
import { getSlackConnection } from 'src/logic-functions/utils/get-slack-connection';
|
||||
|
||||
export const getSlackClient = async (): Promise<
|
||||
{ success: true; client: WebClient } | { success: false; error: string }
|
||||
> => {
|
||||
const connectionResult = await getSlackConnection();
|
||||
|
||||
if (!connectionResult.success) {
|
||||
return connectionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
client: new WebClient(connectionResult.accessToken),
|
||||
};
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const getSlackConnection = async (): Promise<
|
||||
{ success: true; accessToken: string } | { success: false; error: string }
|
||||
> => {
|
||||
try {
|
||||
const connections = await listConnections({ providerName: 'slack' });
|
||||
const connection =
|
||||
connections.find((item) => item.visibility === 'workspace') ??
|
||||
connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Slack is not connected. Open the Slack app settings and click "Add connection" first.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, accessToken: connection.accessToken };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack connection.';
|
||||
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
|
||||
export const slackToolFailure = (
|
||||
message: string,
|
||||
error: unknown,
|
||||
): SlackToolResult => ({
|
||||
success: false,
|
||||
message,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Slack tools role',
|
||||
description:
|
||||
'No CRM data access — tools only forward requests to Slack using the configured connected account.',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,5 +4,5 @@
|
||||
|
||||
export type AppOAuthTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ export class ApplicationConnectionsListService {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await this.refreshTokensService.refreshAndSaveTokens(
|
||||
const tokens = await this.refreshTokensService.resolveTokens(
|
||||
account,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export class CalendarAccountAuthenticationService {
|
||||
workspaceId,
|
||||
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
|
||||
try {
|
||||
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
||||
return await this.connectedAccountRefreshTokensService.resolveTokens(
|
||||
connectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+42
-12
@@ -148,7 +148,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('refreshAndSaveTokens', () => {
|
||||
describe('resolveTokens', () => {
|
||||
it('should reuse the cached token, decrypt before returning to the caller, and skip the refresh call entirely', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
@@ -158,7 +158,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
|
||||
} as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -203,7 +203,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -240,7 +240,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -277,7 +277,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
|
||||
.mockResolvedValue(newTokens);
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -296,7 +296,37 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when refresh token is missing', async () => {
|
||||
it('should return decrypted access token and null refresh token when access token is valid but no refresh token exists', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.APP,
|
||||
accessToken: mockEncryptedAccessToken,
|
||||
refreshToken: null,
|
||||
lastCredentialsRefreshedAt: new Date(Date.now() - 30 * 60 * 1000),
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: mockAccessTokenPlaintext,
|
||||
refreshToken: null,
|
||||
});
|
||||
expect(
|
||||
connectedAccountTokenEncryptionService.decrypt,
|
||||
).toHaveBeenCalledWith({
|
||||
ciphertext: mockEncryptedAccessToken,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
expect(
|
||||
connectedAccountTokenEncryptionService.decrypt,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when refresh token is missing and access token is expired', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
@@ -306,7 +336,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
service.resolveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toThrow(
|
||||
new ConnectedAccountRefreshAccessTokenException(
|
||||
`No refresh token found for connected account ${mockConnectedAccountId} in workspace ${mockWorkspaceId}`,
|
||||
@@ -334,7 +364,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.mockRejectedValue(invalidGrantError);
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
service.resolveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
'Microsoft OAuth error: invalid_grant - Token has been revoked',
|
||||
@@ -362,7 +392,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
.mockRejectedValue(networkError);
|
||||
|
||||
await expect(
|
||||
service.refreshAndSaveTokens(connectedAccount, mockWorkspaceId),
|
||||
service.resolveTokens(connectedAccount, mockWorkspaceId),
|
||||
).rejects.toMatchObject({
|
||||
code: ConnectedAccountRefreshAccessTokenExceptionCode.TEMPORARY_NETWORK_ERROR,
|
||||
});
|
||||
@@ -443,7 +473,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAndSaveTokens - OIDC/SAML', () => {
|
||||
describe('resolveTokens - OIDC/SAML', () => {
|
||||
it('should decrypt and return existing tokens for OIDC without attempting a refresh', async () => {
|
||||
const connectedAccount = {
|
||||
id: mockConnectedAccountId,
|
||||
@@ -453,7 +483,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
@@ -480,7 +510,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
|
||||
lastCredentialsRefreshedAt: null,
|
||||
} as unknown as ConnectedAccountEntity;
|
||||
|
||||
const result = await service.refreshAndSaveTokens(
|
||||
const result = await service.resolveTokens(
|
||||
connectedAccount,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
|
||||
+58
-34
@@ -19,7 +19,7 @@ import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-acc
|
||||
|
||||
export type ConnectedAccountTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
const CONNECTED_ACCOUNT_ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60;
|
||||
@@ -40,14 +40,22 @@ export class ConnectedAccountRefreshTokensService {
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
) {}
|
||||
|
||||
async refreshAndSaveTokens(
|
||||
async resolveTokens(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountTokens> {
|
||||
const {
|
||||
refreshToken: encryptedRefreshToken,
|
||||
accessToken: encryptedAccessToken,
|
||||
} = connectedAccount;
|
||||
const isAccessTokenValid =
|
||||
await this.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
if (isAccessTokenValid) {
|
||||
this.logger.debug(
|
||||
`Reusing valid access token for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`,
|
||||
);
|
||||
|
||||
return this.decryptExistingTokens(connectedAccount, workspaceId);
|
||||
}
|
||||
|
||||
const encryptedRefreshToken = connectedAccount.refreshToken;
|
||||
|
||||
if (!isDefined(encryptedRefreshToken)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
@@ -56,37 +64,53 @@ export class ConnectedAccountRefreshTokensService {
|
||||
);
|
||||
}
|
||||
|
||||
const isAccessTokenValid =
|
||||
await this.isAccessTokenStillValid(connectedAccount);
|
||||
|
||||
if (isAccessTokenValid) {
|
||||
this.logger.debug(
|
||||
`Reusing valid access token for connected account ${connectedAccount.id.slice(0, 7)} in workspace ${workspaceId.slice(0, 7)}`,
|
||||
);
|
||||
if (!isDefined(encryptedAccessToken)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Access token is required for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedAccessToken,
|
||||
workspaceId,
|
||||
}),
|
||||
refreshToken: this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedRefreshToken,
|
||||
workspaceId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Access token expired for connected account ${connectedAccount.id} in workspace ${workspaceId}, refreshing...`,
|
||||
);
|
||||
|
||||
const decryptedRefreshTokenForRefreshCall =
|
||||
return this.performRefreshAndSave(
|
||||
connectedAccount,
|
||||
encryptedRefreshToken,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private decryptExistingTokens(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
workspaceId: string,
|
||||
): ConnectedAccountTokens {
|
||||
const {
|
||||
accessToken: encryptedAccessToken,
|
||||
refreshToken: encryptedRefreshToken,
|
||||
} = connectedAccount;
|
||||
|
||||
if (!isDefined(encryptedAccessToken)) {
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Access token is required for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.ACCESS_TOKEN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedAccessToken,
|
||||
workspaceId,
|
||||
}),
|
||||
refreshToken: isDefined(encryptedRefreshToken)
|
||||
? this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedRefreshToken,
|
||||
workspaceId,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async performRefreshAndSave(
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
encryptedRefreshToken: string,
|
||||
workspaceId: string,
|
||||
): Promise<ConnectedAccountTokens> {
|
||||
const decryptedRefreshToken =
|
||||
this.connectedAccountTokenEncryptionService.decrypt({
|
||||
ciphertext: encryptedRefreshToken,
|
||||
workspaceId,
|
||||
@@ -94,7 +118,7 @@ export class ConnectedAccountRefreshTokensService {
|
||||
|
||||
const connectedAccountTokens = await this.refreshTokens(
|
||||
connectedAccount,
|
||||
decryptedRefreshTokenForRefreshCall,
|
||||
decryptedRefreshToken,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ describe('MessagingMessagesImportService', () => {
|
||||
{
|
||||
provide: ConnectedAccountRefreshTokensService,
|
||||
useValue: {
|
||||
refreshAndSaveTokens: jest.fn().mockResolvedValue({
|
||||
resolveTokens: jest.fn().mockResolvedValue({
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
}),
|
||||
@@ -266,7 +266,7 @@ describe('MessagingMessagesImportService', () => {
|
||||
).toHaveBeenCalledWith([mockMessageChannel.id], workspaceId);
|
||||
|
||||
expect(
|
||||
connectedAccountRefreshTokensService.refreshAndSaveTokens,
|
||||
connectedAccountRefreshTokensService.resolveTokens,
|
||||
).toHaveBeenCalledWith(mockConnectedAccount, workspaceId);
|
||||
|
||||
expect(
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export class MessagingAccountAuthenticationService {
|
||||
messageChannelId,
|
||||
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
|
||||
try {
|
||||
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
|
||||
return await this.connectedAccountRefreshTokensService.resolveTokens(
|
||||
connectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
+2
-1
@@ -449,7 +449,8 @@ export class WorkflowSchemaWorkspaceService {
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.CODE:
|
||||
case WorkflowActionType.HTTP_REQUEST: {
|
||||
case WorkflowActionType.HTTP_REQUEST:
|
||||
case WorkflowActionType.LOGIC_FUNCTION: {
|
||||
const propertyPath = extractPropertyPathFromVariable(items);
|
||||
const schemaNode = navigateOutputSchemaProperty({
|
||||
schema: step.settings.outputSchema as BaseOutputSchemaV2,
|
||||
|
||||
Reference in New Issue
Block a user