Abdul Rahman
2026-05-20 15:51:22 +05:30
committed by GitHub
parent a26fe3bb65
commit c800eccc65
54 changed files with 6572 additions and 53 deletions
@@ -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`** (Slacks 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 Slacks 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 Slacks **bot**
OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not
only under **User Token Scopes** — or Slack will refuse install with *“doesnt
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 Slacks 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 Twentys `SERVER_URL` at the
same public base URL. See Slacks [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,
},
},
});
@@ -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,
});
@@ -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,
});
@@ -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';
@@ -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);
}
};
@@ -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);
}
};
@@ -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',
};
}
};
@@ -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);
}
};
@@ -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);
}
};
@@ -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);
}
};
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -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 actions `messageFormat` description.',
},
},
required: ['slackChannelId', 'recipientSlackUserId', 'messageText'],
additionalProperties: false,
};
@@ -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,
};
@@ -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 peoples 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,
});
@@ -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,
});
@@ -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,
});
@@ -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,
});
@@ -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 messages 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,
});
@@ -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 messages 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,
});
@@ -0,0 +1,5 @@
export type SlackAddReactionInput = {
slackChannelId: string;
messageTimestamp: string;
emojiName: string;
};
@@ -0,0 +1,4 @@
export type SlackDeleteMessageInput = {
slackChannelId: string;
messageTimestamp: string;
};
@@ -0,0 +1,7 @@
export type SlackChannelType = 'Public' | 'Private' | 'All';
export type SlackListChannelsInput = {
channelType?: SlackChannelType;
excludeArchived?: boolean;
limit?: number;
};
@@ -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;
};
@@ -0,0 +1 @@
export type SlackMessageBodyFormat = 'plain' | 'markdown';
@@ -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;
};
@@ -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;
};
@@ -0,0 +1,7 @@
export type SlackToolResult = {
success: boolean;
message: string;
error?: string;
slackTs?: string;
channel?: string;
};
@@ -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;
};
@@ -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 };
}
};
@@ -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),
};
};
@@ -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 };
}
};
@@ -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
@@ -4,5 +4,5 @@
export type AppOAuthTokens = {
accessToken: string;
refreshToken: string;
refreshToken: string | null;
};
@@ -207,7 +207,7 @@ export class ApplicationConnectionsListService {
}
try {
const tokens = await this.refreshTokensService.refreshAndSaveTokens(
const tokens = await this.refreshTokensService.resolveTokens(
account,
workspaceId,
);
@@ -73,7 +73,7 @@ export class CalendarAccountAuthenticationService {
workspaceId,
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
try {
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
return await this.connectedAccountRefreshTokensService.resolveTokens(
connectedAccount,
workspaceId,
);
@@ -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,
);
@@ -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,
);
@@ -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(
@@ -85,7 +85,7 @@ export class MessagingAccountAuthenticationService {
messageChannelId,
}: ValidateAndRefreshConnectedAccountAuthenticationParams): Promise<ConnectedAccountTokens> {
try {
return await this.connectedAccountRefreshTokensService.refreshAndSaveTokens(
return await this.connectedAccountRefreshTokensService.resolveTokens(
connectedAccount,
workspaceId,
);
@@ -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,