diff --git a/packages/twenty-apps/public/slack/README.md b/packages/twenty-apps/public/slack/README.md index 0b608ce3f3..ca160ee95e 100644 --- a/packages/twenty-apps/public/slack/README.md +++ b/packages/twenty-apps/public/slack/README.md @@ -17,6 +17,8 @@ Anyone who can message the bot acts with the **Slack Assistant** role, which by One Slack workspace answers into one Twenty workspace. +When the bot is added to a channel it introduces itself once, with a short message in the channel and the details (what to ask it, what it reads, and the shared-role caveat above) in a thread reply. It needs the `member_joined_channel` subscription, so leave that one off if you want the bot to arrive quietly. + ## 🧰 The workflow steps | Step | Slack API | diff --git a/packages/twenty-apps/public/slack/SETUP.md b/packages/twenty-apps/public/slack/SETUP.md index b84b4ea6b3..c899e6fd63 100644 --- a/packages/twenty-apps/public/slack/SETUP.md +++ b/packages/twenty-apps/public/slack/SETUP.md @@ -60,6 +60,7 @@ The assistant reuses the same Slack connection — no second bot identity. - `message.im` — direct messages to the bot - `message.channels` — replies in public-channel threads, for un-mentioned follow-ups - `message.groups` — same, for private channels the bot is in + - `member_joined_channel` — optional; lets the bot introduce itself when it is added to a channel Invite the bot to any channel where it should follow threads. Slack may ask you to reinstall after changing subscriptions. @@ -70,6 +71,7 @@ The assistant reuses the same Slack connection — no second bot identity. ## Behaviour notes - **Thread memory.** After a successful reply the bot stays active in that thread, so follow-ups need no mention. Channel threads stay active for 24 hours after the last reply (each reply renews it); DM threads never expire. +- **Channel welcome.** With `member_joined_channel` subscribed, the bot posts a short introduction the first time it is added to a channel, with the details (what to ask it, what it reads, and the shared-role caveat from step 4 above) in a thread reply so the channel itself stays quiet. It fires once per channel for 30 days, and only for the bot's own join — humans joining afterwards trigger nothing. Skip the subscription if you would rather it arrived silently. - **One Slack workspace per Twenty workspace.** Connecting Slack claims that Slack team for the connecting Twenty workspace. On the same server, a second Twenty workspace connecting the same Slack team is rejected. The claim is not released on disconnect yet, so moving a Slack workspace needs a server admin. ## Workflow field names (for step authors) diff --git a/packages/twenty-apps/public/slack/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/slack/src/constants/universal-identifiers.ts index 4518f72ace..b05c4559fb 100644 --- a/packages/twenty-apps/public/slack/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/public/slack/src/constants/universal-identifiers.ts @@ -49,6 +49,9 @@ export const SLACK_EVENTS_ROUTE_UNIVERSAL_IDENTIFIER = export const SLACK_EVENTS_ENQUEUE_UNIVERSAL_IDENTIFIER = '8f2e1d3c-4b5a-4c6d-9e7f-0a1b2c3d4e5f'; +export const SLACK_CHANNEL_WELCOME_UNIVERSAL_IDENTIFIER = + 'c5e8b13a-7f42-4d90-8a6b-1e3c9d052f47'; + export const SLACK_ASSISTANT_WORKER_UNIVERSAL_IDENTIFIER = '4b92a49f-d674-46ea-a3d9-e8d658ae3a17'; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-channel-welcome-text.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-channel-welcome-text.ts new file mode 100644 index 0000000000..9b7a506506 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-channel-welcome-text.ts @@ -0,0 +1,5 @@ +export const SLACK_CHANNEL_WELCOME_TEXT = [ + ":wave: Hi! I'm Twenty, your CRM in this channel.", + "Mention me here or send me a DM and I'll answer in the thread: pipeline questions, company and contact lookups, creating and updating records, capturing notes and tasks.", + 'Details on how I work and what I can see are in the thread. Ask me anything.', +].join('\n\n'); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-channel-welcome-thread-text.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-channel-welcome-thread-text.ts new file mode 100644 index 0000000000..200075c409 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-channel-welcome-thread-text.ts @@ -0,0 +1,15 @@ +export const SLACK_CHANNEL_WELCOME_THREAD_TEXT = [ + '**A few things to ask me**', + [ + '- **Look things up**: "how many open opportunities are in the pipeline?", "who owns the ACME account?"', + '- **Create and update records**: "add ACME as a company", "move the ACME deal to Proposal"', + '- **Capture notes and tasks**: "note that ACME wants a security review", "task Alice with sending the pricing deck"', + ].join('\n'), + '**How I work with your data**', + [ + "- I only act when you mention me, or in a thread I've already replied in. Channel threads stay open to me for 24 hours after my last reply", + '- When you mention me I read recent messages in that thread for context', + "- I can read, create, update and archive people, companies, opportunities, notes and tasks. I can't permanently delete anything and I can't change workspace settings", + '- Everyone in this channel talks to me through the same **Slack Assistant** role, so anyone who can message me sees whatever that role can see', + ].join('\n'), +].join('\n\n'); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts b/packages/twenty-apps/public/slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts index a89051b292..1e4d01feb8 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts @@ -108,6 +108,21 @@ describe('slackPostMessageHandler', () => { ); }); + it('should treat a runtime non-string parent timestamp as no thread without throwing', async () => { + postMessageMock.mockResolvedValue({ ts: '1700000000.000450' }); + + const result = await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: 'standalone', + parentMessageTimestamp: 1700000000 as unknown as string, + }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ thread_ts: undefined }), + ); + expect(result.success).toBe(true); + }); + it('should return a failure result when the Slack API throws', async () => { postMessageMock.mockRejectedValue(new Error('channel_not_found')); @@ -122,4 +137,43 @@ describe('slackPostMessageHandler', () => { error: 'channel_not_found', }); }); + + it('should retry as plain text when the workspace rejects markdown_text', async () => { + postMessageMock + .mockRejectedValueOnce( + Object.assign(new Error('invalid_arguments'), { + data: { error: 'invalid_arguments' }, + }), + ) + .mockResolvedValueOnce({ ts: '1700000000.000500', channel: CHANNEL_ID }); + + const result = await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: '**hello**', + messageFormat: 'markdown', + }); + + expect(postMessageMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ text: '**hello**', mrkdwn: false }), + ); + expect(result.success).toBe(true); + }); + + it('should not retry when a markdown post fails for an unrelated reason', async () => { + postMessageMock.mockRejectedValue( + Object.assign(new Error('channel_not_found'), { + data: { error: 'channel_not_found' }, + }), + ); + + const result = await slackPostMessageHandler({ + slackChannelId: CHANNEL_ID, + messageText: '**hello**', + messageFormat: 'markdown', + }); + + expect(postMessageMock).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + }); }); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-message-handler.ts b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-message-handler.ts index 9c6e1f5460..e8b5e9f4be 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-message-handler.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-message-handler.ts @@ -1,8 +1,7 @@ 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'; +import { postSlackMessage } from 'src/logic-functions/utils/post-slack-message'; export const slackPostMessageHandler = async ( parameters: SlackPostMessageInput, @@ -17,34 +16,5 @@ export const slackPostMessageHandler = async ( }; } - 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: - parentTimestamp != null && 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); - } + return await postSlackMessage(slackClientResult.client, parameters); }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-update-message-handler.ts b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-update-message-handler.ts index 8229ae2d94..13ba74bffb 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-update-message-handler.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-update-message-handler.ts @@ -2,8 +2,7 @@ import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-resul 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 { isSlackMarkdownFormatError } from 'src/logic-functions/utils/is-slack-markdown-format-error'; -import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure'; +import { sendSlackMessageWithMarkdownFallback } from 'src/logic-functions/utils/send-slack-message-with-markdown-fallback'; export const slackUpdateMessageHandler = async ( parameters: SlackUpdateMessageInput, @@ -20,42 +19,27 @@ export const slackUpdateMessageHandler = async ( const { client } = slackClientResult; - const updateWithFormat = async ( - messageFormat: SlackUpdateMessageInput['messageFormat'], - ): Promise => { - const bodyFields = getSlackChatMessageBodyFields( - parameters.newMessageText, - messageFormat, - ); + return await sendSlackMessageWithMarkdownFallback({ + messageFormat: parameters.messageFormat, + failureMessage: 'Failed to update Slack message', + sendMessage: async (messageFormat) => { + const bodyFields = getSlackChatMessageBodyFields( + parameters.newMessageText, + messageFormat, + ); - const data = await client.chat.update({ - channel: parameters.slackChannelId, - ts: parameters.messageTimestamp, - ...bodyFields, - }); + const data = await client.chat.update({ + channel: parameters.slackChannelId, + ts: parameters.messageTimestamp, + ...bodyFields, + }); - return { - success: true, - message: 'Slack message updated.', - slackTs: data.ts, - channel: parameters.slackChannelId, - }; - }; - - try { - return await updateWithFormat(parameters.messageFormat); - } catch (error) { - if ( - parameters.messageFormat !== 'markdown' || - !isSlackMarkdownFormatError(error) - ) { - return slackToolFailure('Failed to update Slack message', error); - } - } - - try { - return await updateWithFormat('plain'); - } catch (error) { - return slackToolFailure('Failed to update Slack message', error); - } + return { + success: true, + message: 'Slack message updated.', + slackTs: data.ts, + channel: parameters.slackChannelId, + }; + }, + }); }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/slack-channel-welcome.ts b/packages/twenty-apps/public/slack/src/logic-functions/slack-channel-welcome.ts new file mode 100644 index 0000000000..39a8bc542d --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/slack-channel-welcome.ts @@ -0,0 +1,13 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { SLACK_CHANNEL_WELCOME_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { postSlackChannelWelcome } from 'src/logic-functions/utils/post-slack-channel-welcome'; + +export default defineLogicFunction({ + universalIdentifier: SLACK_CHANNEL_WELCOME_UNIVERSAL_IDENTIFIER, + name: 'slack-channel-welcome', + description: + 'Runs in the resolved workspace: posts a one-off introduction when the bot itself is added to a Slack channel.', + timeoutSeconds: 15, + handler: postSlackChannelWelcome, +}); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/slack-events-resolver.ts b/packages/twenty-apps/public/slack/src/logic-functions/slack-events-resolver.ts index f1c1760025..841c0efee3 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/slack-events-resolver.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/slack-events-resolver.ts @@ -3,6 +3,7 @@ import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; import { Response } from 'twenty-sdk/logic-function'; import { + SLACK_CHANNEL_WELCOME_UNIVERSAL_IDENTIFIER, SLACK_EVENTS_ENQUEUE_UNIVERSAL_IDENTIFIER, SLACK_EVENTS_ROUTE_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; @@ -58,7 +59,9 @@ export const slackEventsResolverHandler = async ( return { workspaceId: await resolveTargetWorkspaceId(body), targetLogicFunctionUniversalIdentifier: - SLACK_EVENTS_ENQUEUE_UNIVERSAL_IDENTIFIER, + body.event?.type === 'member_joined_channel' + ? SLACK_CHANNEL_WELCOME_UNIVERSAL_IDENTIFIER + : SLACK_EVENTS_ENQUEUE_UNIVERSAL_IDENTIFIER, payload: body, }; }; @@ -67,7 +70,7 @@ export default defineLogicFunction({ universalIdentifier: SLACK_EVENTS_ROUTE_UNIVERSAL_IDENTIFIER, name: 'slack-events-resolver', description: - 'Receives Slack Events API callbacks, verifies the request signature in the owner workspace, answers the url_verification handshake, and resolves the target workspace + enqueue function for the assistant.', + 'Receives Slack Events API callbacks, verifies the request signature in the owner workspace, answers the url_verification handshake, and resolves the target workspace plus the function that handles the event (assistant enqueue, or the channel welcome on member_joined_channel).', timeoutSeconds: 15, handler: slackEventsResolverHandler, serverRouteTriggerSettings: { diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-channel-welcome.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-channel-welcome.type.ts new file mode 100644 index 0000000000..bc69a12983 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-channel-welcome.type.ts @@ -0,0 +1,3 @@ +export type SlackChannelWelcome = { + expiresAt: number; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-channel-welcome.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-channel-welcome.ts new file mode 100644 index 0000000000..b841264114 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-channel-welcome.ts @@ -0,0 +1,28 @@ +import { isNumber } from '@sniptt/guards'; +import { kv } from 'twenty-sdk/logic-function'; + +import { type SlackChannelWelcome } from 'src/logic-functions/types/slack-channel-welcome.type'; +import { getSlackChannelWelcomeKvKey } from 'src/logic-functions/utils/get-slack-channel-welcome-kv-key'; + +const SLACK_CHANNEL_WELCOME_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +export const claimSlackChannelWelcome = async ( + channelId: string, +): Promise => { + const key = getSlackChannelWelcomeKvKey(channelId); + const existingWelcome = await kv.get(key); + + if ( + existingWelcome !== null && + isNumber(existingWelcome.expiresAt) && + existingWelcome.expiresAt > Date.now() + ) { + return false; + } + + await kv.set(key, { + expiresAt: Date.now() + SLACK_CHANNEL_WELCOME_TTL_MS, + } satisfies SlackChannelWelcome); + + return true; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-channel-welcome-kv-key.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-channel-welcome-kv-key.ts new file mode 100644 index 0000000000..e0c2147b89 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-channel-welcome-kv-key.ts @@ -0,0 +1,2 @@ +export const getSlackChannelWelcomeKvKey = (channelId: string): string => + `slack-channel-welcome:${channelId}`; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-channel-welcome-event.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-channel-welcome-event.ts new file mode 100644 index 0000000000..9be39872da --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-channel-welcome-event.ts @@ -0,0 +1,42 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type SlackEventsRequestBody } from 'src/logic-functions/types/slack-events-request-body.type'; + +type ParsedSlackChannelWelcomeEvent = + | { channelJoin: { slackChannelId: string; slackUserId: string } } + | { channelJoin: null; skipReason: string }; + +export const parseSlackChannelWelcomeEvent = ( + body: SlackEventsRequestBody, +): ParsedSlackChannelWelcomeEvent => { + if (body.type !== 'event_callback') { + return { + channelJoin: null, + skipReason: `Unhandled body type: ${body.type}`, + }; + } + + const event = body.event; + + if (!event) { + return { channelJoin: null, skipReason: 'Missing event payload' }; + } + + if (event.type !== 'member_joined_channel') { + return { + channelJoin: null, + skipReason: `Unhandled event type: ${event.type}`, + }; + } + + if (!isNonEmptyString(event.channel) || !isNonEmptyString(event.user)) { + return { + channelJoin: null, + skipReason: 'Event is missing required fields', + }; + } + + return { + channelJoin: { slackChannelId: event.channel, slackUserId: event.user }, + }; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/post-slack-channel-welcome.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/post-slack-channel-welcome.ts new file mode 100644 index 0000000000..41035dd17e --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/post-slack-channel-welcome.ts @@ -0,0 +1,76 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { SLACK_CHANNEL_WELCOME_TEXT } from 'src/logic-functions/constants/slack-channel-welcome-text'; +import { SLACK_CHANNEL_WELCOME_THREAD_TEXT } from 'src/logic-functions/constants/slack-channel-welcome-thread-text'; +import { type SlackEventsRequestBody } from 'src/logic-functions/types/slack-events-request-body.type'; +import { claimSlackChannelWelcome } from 'src/logic-functions/utils/claim-slack-channel-welcome'; +import { getSlackClient } from 'src/logic-functions/utils/get-slack-client'; +import { parseSlackChannelWelcomeEvent } from 'src/logic-functions/utils/parse-slack-channel-welcome-event'; +import { postSlackMessage } from 'src/logic-functions/utils/post-slack-message'; +import { releaseSlackChannelWelcome } from 'src/logic-functions/utils/release-slack-channel-welcome'; + +type SlackChannelWelcomeResult = { ok: boolean; skipped?: string }; + +export const postSlackChannelWelcome = async ( + body: SlackEventsRequestBody, +): Promise => { + const parsed = parseSlackChannelWelcomeEvent(body); + + if (parsed.channelJoin === null) { + return { ok: true, skipped: parsed.skipReason }; + } + + const { slackChannelId, slackUserId } = parsed.channelJoin; + + const slackClientResult = await getSlackClient(); + + if (!slackClientResult.success) { + throw new Error(slackClientResult.error); + } + + const { client } = slackClientResult; + + const authResult = await client.auth.test(); + + if (authResult.user_id !== slackUserId) { + return { ok: true, skipped: 'Someone other than the bot joined' }; + } + + const isFirstWelcome = await claimSlackChannelWelcome(slackChannelId); + + if (!isFirstWelcome) { + return { ok: true, skipped: 'Channel was already welcomed' }; + } + + const channelMessageResult = await postSlackMessage(client, { + slackChannelId, + messageText: SLACK_CHANNEL_WELCOME_TEXT, + messageFormat: 'markdown', + }); + + if ( + !channelMessageResult.success || + !isNonEmptyString(channelMessageResult.slackTs) + ) { + await releaseSlackChannelWelcome(slackChannelId); + + throw new Error( + `Failed to post the Slack welcome in channel ${slackChannelId}: ${channelMessageResult.error ?? channelMessageResult.message}`, + ); + } + + const threadMessageResult = await postSlackMessage(client, { + slackChannelId, + messageText: SLACK_CHANNEL_WELCOME_THREAD_TEXT, + parentMessageTimestamp: channelMessageResult.slackTs, + messageFormat: 'markdown', + }); + + if (!threadMessageResult.success) { + throw new Error( + `Failed to post the Slack welcome thread reply in channel ${slackChannelId}: ${threadMessageResult.error ?? threadMessageResult.message}`, + ); + } + + return { ok: true }; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/post-slack-message.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/post-slack-message.ts new file mode 100644 index 0000000000..63ca2d07b6 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/post-slack-message.ts @@ -0,0 +1,42 @@ +import { type WebClient } from '@slack/web-api'; +import { isNonEmptyString } from '@sniptt/guards'; + +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 { sendSlackMessageWithMarkdownFallback } from 'src/logic-functions/utils/send-slack-message-with-markdown-fallback'; + +export const postSlackMessage = async ( + client: WebClient, + parameters: SlackPostMessageInput, +): Promise => { + const parentTimestamp = isNonEmptyString(parameters.parentMessageTimestamp) + ? parameters.parentMessageTimestamp.trim() || undefined + : undefined; + + return await sendSlackMessageWithMarkdownFallback({ + messageFormat: parameters.messageFormat, + failureMessage: 'Failed to post Slack message', + sendMessage: async (messageFormat) => { + const bodyFields = getSlackChatMessageBodyFields( + parameters.messageText, + messageFormat, + ); + + const data = await client.chat.postMessage({ + channel: parameters.slackChannelId, + thread_ts: parentTimestamp, + ...bodyFields, + }); + + return { + success: true, + message: data.ts + ? `Message posted to Slack (ts=${data.ts}).` + : 'Message posted to Slack.', + slackTs: data.ts, + channel: data.channel, + }; + }, + }); +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-channel-welcome.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-channel-welcome.ts new file mode 100644 index 0000000000..98e3d1a517 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-channel-welcome.ts @@ -0,0 +1,9 @@ +import { kv } from 'twenty-sdk/logic-function'; + +import { getSlackChannelWelcomeKvKey } from 'src/logic-functions/utils/get-slack-channel-welcome-kv-key'; + +export const releaseSlackChannelWelcome = async ( + channelId: string, +): Promise => { + await kv.delete(getSlackChannelWelcomeKvKey(channelId)); +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/send-slack-message-with-markdown-fallback.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/send-slack-message-with-markdown-fallback.ts new file mode 100644 index 0000000000..383b020dd2 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/send-slack-message-with-markdown-fallback.ts @@ -0,0 +1,32 @@ +import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type'; +import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type'; +import { isSlackMarkdownFormatError } from 'src/logic-functions/utils/is-slack-markdown-format-error'; +import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure'; + +type SendSlackMessageWithMarkdownFallbackParams = { + messageFormat: SlackMessageBodyFormat | undefined; + failureMessage: string; + sendMessage: ( + messageFormat: SlackMessageBodyFormat | undefined, + ) => Promise; +}; + +export const sendSlackMessageWithMarkdownFallback = async ({ + messageFormat, + failureMessage, + sendMessage, +}: SendSlackMessageWithMarkdownFallbackParams): Promise => { + try { + return await sendMessage(messageFormat); + } catch (error) { + if (messageFormat !== 'markdown' || !isSlackMarkdownFormatError(error)) { + return slackToolFailure(failureMessage, error); + } + } + + try { + return await sendMessage('plain'); + } catch (error) { + return slackToolFailure(failureMessage, error); + } +};