From fd85e64fe3c3b9826f6c09313b995b5f6d7e76e8 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:38:57 +0530 Subject: [PATCH] slack: answer empty requests and nudge lapsed threads (#23835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Two assistant paths ended with no visible reaction for the member: - a mention with no request text (a bare `@twenty`) or an empty DM was skipped silently - a follow-up in a thread whose 24-hour subscription window had lapsed was dropped silently, with no hint that the bot "forgot" the thread ## What this does **Empty requests get answered.** `parseSlackAssistantRequest` flags an empty mention/DM with its channel and thread target, and the enqueue function answers in-thread. A bare mention that starts a fresh thread (or an empty DM) gets a short markdown list of example asks; a bare mention inside an existing thread gets a context-aware one-liner instead. There is no request record to dedupe on for these, so a KV claim (`slack-empty-request-reply:{channel}:{ts}`, 1-hour expiry) keeps Slack event redeliveries from double-posting, and the claim is released if the hint cannot be posted so a redelivery can retry. The hint's thread is subscribed like any answered thread, so a follow-up question there is picked up without a re-mention. Empty messages in unmentioned thread follow-ups still skip silently on purpose. **Lapsed threads get a nudge.** `is-slack-thread-active.ts` becomes `get-slack-thread-subscription-state.ts`, a pure read returning `active` / `expired` / `none`: an expired-but-present KV key proves the bot used to follow that thread, while `none` means it never did, so random threads stay untouched. On `expired` the author of the follow-up gets an ephemeral (only they see it) asking them to re-mention the bot. The lapsed key is cleared only after the nudge actually posted, via `clearLapsedSlackThreadSubscription`, which re-checks that the subscription was not renewed in the meantime; a failed nudge is retried on the next follow-up. Supporting changes: the `slack-post-ephemeral-message` step gains an optional `parentMessageTimestamp` (type, schema, handler) so the nudge can post inside the thread, mirroring `slack-post-message`; the enqueue result shape is extracted into a shared `SlackEventsEnqueueResult` type; nullish checks use `isDefined` from `twenty-sdk/utils`. README and SETUP.md behaviour notes describe the new behaviours. ## Demo https://github.com/user-attachments/assets/03add964-92fb-46e5-aa49-06faad85e18c Screenshot 2026-08-06 at 6 34 57 AM --- packages/twenty-apps/public/slack/README.md | 2 +- packages/twenty-apps/public/slack/SETUP.md | 1 + .../slack-assistant-empty-request-text.ts | 9 ++ ...ack-assistant-empty-thread-request-text.ts | 2 + .../slack-assistant-expired-thread-text.ts | 2 + .../slack-post-ephemeral-message-handler.ts | 6 ++ ...ack-post-ephemeral-message-input.schema.ts | 6 ++ .../slack-assistant-empty-request.type.ts | 6 ++ .../types/slack-events-enqueue-result.type.ts | 4 + .../types/slack-message-reference.type.ts | 4 + ...slack-post-ephemeral-message-input.type.ts | 1 + .../slack-thread-subscription-state.type.ts | 1 + ...et-slack-thread-subscription-state.test.ts | 75 +++++++++++++++ .../parse-slack-assistant-request.test.ts | 74 ++++++++++++++- .../utils/claim-slack-empty-request-reply.ts | 33 +++++++ ...clear-lapsed-slack-thread-subscription.ts} | 22 ++--- .../enqueue-slack-assistant-request-record.ts | 40 ++++++++ .../utils/enqueue-slack-assistant-request.ts | 56 +++-------- .../utils/gate-slack-thread-follow-up.ts | 32 +++++++ .../get-slack-empty-request-reply-kv-key.ts | 7 ++ .../get-slack-thread-subscription-state.ts | 27 ++++++ .../handle-expired-slack-thread-follow-up.ts | 31 ++++++ ...ormalize-slack-parent-message-timestamp.ts | 15 +++ .../utils/nudge-expired-slack-thread.ts | 18 ++++ .../utils/parse-slack-assistant-request.ts | 94 ++++++++++++++----- .../utils/post-slack-message.ts | 8 +- .../release-slack-empty-request-reply.ts | 13 +++ .../reply-to-empty-slack-assistant-request.ts | 59 ++++++++++++ 28 files changed, 561 insertions(+), 87 deletions(-) create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-request-text.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-thread-request-text.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-expired-thread-text.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/types/slack-assistant-empty-request.type.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/types/slack-events-enqueue-result.type.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/types/slack-message-reference.type.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/types/slack-thread-subscription-state.type.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/get-slack-thread-subscription-state.test.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-empty-request-reply.ts rename packages/twenty-apps/public/slack/src/logic-functions/utils/{is-slack-thread-active.ts => clear-lapsed-slack-thread-subscription.ts} (60%) create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request-record.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/gate-slack-thread-follow-up.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-empty-request-reply-kv-key.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-thread-subscription-state.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/handle-expired-slack-thread-follow-up.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/normalize-slack-parent-message-timestamp.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/nudge-expired-slack-thread.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-empty-request-reply.ts create mode 100644 packages/twenty-apps/public/slack/src/logic-functions/utils/reply-to-empty-slack-assistant-request.ts diff --git a/packages/twenty-apps/public/slack/README.md b/packages/twenty-apps/public/slack/README.md index ca160ee95e..d95e23b259 100644 --- a/packages/twenty-apps/public/slack/README.md +++ b/packages/twenty-apps/public/slack/README.md @@ -5,7 +5,7 @@ ## ✨ What you get - **A CRM assistant in Slack** — `@twenty how many open opportunities do we have?` or `@twenty create a company called ACME`. It answers in-thread, remembers the thread, and can read, create, update and soft-delete records -- **Follow-ups without re-mentioning** — once it has replied in a thread you can keep talking to it for 24 hours +- **Follow-ups without re-mentioning** — once it has replied in a thread you can keep talking to it for 24 hours; when that window lapses it privately nudges you to mention it again - **Slack steps for your workflows** — post, update or delete messages, send ephemerals, add reactions, list channels - **Send from anywhere in Twenty** — the **Send Slack message** command opens a side panel to pick a channel and post diff --git a/packages/twenty-apps/public/slack/SETUP.md b/packages/twenty-apps/public/slack/SETUP.md index 1f49d5413f..fc710154cf 100644 --- a/packages/twenty-apps/public/slack/SETUP.md +++ b/packages/twenty-apps/public/slack/SETUP.md @@ -71,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. +- **No silent dead-ends.** A mention or DM with no request text gets a short hint reply. The first follow-up in a thread whose 24-hour window has lapsed gets an ephemeral nudge (only that member sees it) to mention the bot again. - **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. Removing the connection releases the claim, so another Twenty workspace can then connect that Slack team. Uninstalling the app releases it too. diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-request-text.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-request-text.ts new file mode 100644 index 0000000000..8dc187c8e4 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-request-text.ts @@ -0,0 +1,9 @@ +export const SLACK_ASSISTANT_EMPTY_REQUEST_TEXT = [ + 'Hi! Ask me anything about your CRM, for example:', + [ + '- "How many open opportunities are in the pipeline?"', + '- "What deals are closing this month?"', + '- "Create a task to follow up on my newest lead"', + ].join('\n'), + 'Just reply here with your question.', +].join('\n\n'); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-thread-request-text.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-thread-request-text.ts new file mode 100644 index 0000000000..b1869f50ed --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-empty-thread-request-text.ts @@ -0,0 +1,2 @@ +export const SLACK_ASSISTANT_EMPTY_THREAD_REQUEST_TEXT = + "Happy to help here. Reply with your question and I'll pick it up."; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-expired-thread-text.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-expired-thread-text.ts new file mode 100644 index 0000000000..026e40f9fe --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-assistant-expired-thread-text.ts @@ -0,0 +1,2 @@ +export const SLACK_ASSISTANT_EXPIRED_THREAD_TEXT = + "This thread went quiet, so I stopped following it. Mention me and I'll pick it back up."; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts index 4cb604e778..3a04eb51e7 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts @@ -2,6 +2,7 @@ import { type SlackPostEphemeralMessageInput } from 'src/logic-functions/types/s 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 { normalizeSlackParentMessageTimestamp } from 'src/logic-functions/utils/normalize-slack-parent-message-timestamp'; import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure'; export const slackPostEphemeralMessageHandler = async ( @@ -19,6 +20,10 @@ export const slackPostEphemeralMessageHandler = async ( const { client } = slackClientResult; + const parentTimestamp = normalizeSlackParentMessageTimestamp( + parameters.parentMessageTimestamp, + ); + try { const bodyFields = getSlackChatMessageBodyFields({ messageText: parameters.messageText, @@ -28,6 +33,7 @@ export const slackPostEphemeralMessageHandler = async ( const postEphemeralPayload = { channel: parameters.slackChannelId, user: parameters.recipientSlackUserId, + thread_ts: parentTimestamp, ...bodyFields, }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts b/packages/twenty-apps/public/slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts index 4ab94f435e..79f392b33e 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts @@ -22,6 +22,12 @@ export const slackPostEphemeralMessageInputSchema: InputJsonSchema = { description: 'Short note shown only to the recipient above — for example a private hint, validation result, or next step.', }, + parentMessageTimestamp: { + type: 'string', + label: 'Parent message timestamp', + description: + 'Optional. Only when you want the note to appear **inside a thread**: paste the **Message timestamp** of the thread’s first message (the value returned as `slackTs` when it was posted). Leave empty to show it at the bottom of the channel.', + }, messageFormat: { type: 'string', label: 'Message format', diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-assistant-empty-request.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-assistant-empty-request.type.ts new file mode 100644 index 0000000000..c8eff98839 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-assistant-empty-request.type.ts @@ -0,0 +1,6 @@ +import { type SlackMessageReference } from 'src/logic-functions/types/slack-message-reference.type'; + +export type SlackAssistantEmptyRequest = SlackMessageReference & { + parentMessageTimestamp: string | undefined; + isInExistingThread: boolean; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-events-enqueue-result.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-events-enqueue-result.type.ts new file mode 100644 index 0000000000..cceb65ec37 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-events-enqueue-result.type.ts @@ -0,0 +1,4 @@ +export type SlackEventsEnqueueResult = { + ok: boolean; + skipped?: string; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-message-reference.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-message-reference.type.ts new file mode 100644 index 0000000000..ba61cef58f --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-message-reference.type.ts @@ -0,0 +1,4 @@ +export type SlackMessageReference = { + slackChannelId: string; + slackMessageTimestamp: string; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts index 92c5480286..8924db5ab0 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts @@ -4,5 +4,6 @@ export type SlackPostEphemeralMessageInput = { slackChannelId: string; recipientSlackUserId: string; messageText: string; + parentMessageTimestamp?: string; messageFormat?: SlackMessageBodyFormat; }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-thread-subscription-state.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-thread-subscription-state.type.ts new file mode 100644 index 0000000000..67d9d02bc0 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-thread-subscription-state.type.ts @@ -0,0 +1 @@ +export type SlackThreadSubscriptionState = 'active' | 'expired' | 'none'; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/get-slack-thread-subscription-state.test.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/get-slack-thread-subscription-state.test.ts new file mode 100644 index 0000000000..113575308c --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/get-slack-thread-subscription-state.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSlackThreadSubscriptionState } from 'src/logic-functions/utils/get-slack-thread-subscription-state'; + +const { kvGetMock, kvDeleteMock } = vi.hoisted(() => ({ + kvGetMock: vi.fn(), + kvDeleteMock: vi.fn(), +})); + +vi.mock('twenty-sdk/logic-function', () => ({ + kv: { get: kvGetMock, delete: kvDeleteMock }, +})); + +describe('getSlackThreadSubscriptionState', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return none without reading the store when the reference is blank', async () => { + const state = await getSlackThreadSubscriptionState({ + channelId: '', + threadTimestamp: '1700000000.000100', + }); + + expect(state).toBe('none'); + expect(kvGetMock).not.toHaveBeenCalled(); + }); + + it('should return none when no subscription is stored', async () => { + kvGetMock.mockResolvedValue(null); + + const state = await getSlackThreadSubscriptionState({ + channelId: 'C123', + threadTimestamp: '1700000000.000100', + }); + + expect(state).toBe('none'); + expect(kvDeleteMock).not.toHaveBeenCalled(); + }); + + it('should return none when the stored subscription is malformed', async () => { + kvGetMock.mockResolvedValue({}); + + const state = await getSlackThreadSubscriptionState({ + channelId: 'C123', + threadTimestamp: '1700000000.000100', + }); + + expect(state).toBe('none'); + }); + + it('should return active while the subscription has not lapsed', async () => { + kvGetMock.mockResolvedValue({ expiresAt: Date.now() + 60_000 }); + + const state = await getSlackThreadSubscriptionState({ + channelId: 'C123', + threadTimestamp: '1700000000.000100', + }); + + expect(state).toBe('active'); + expect(kvDeleteMock).not.toHaveBeenCalled(); + }); + + it('should return expired without clearing the key when the subscription lapsed', async () => { + kvGetMock.mockResolvedValue({ expiresAt: Date.now() - 60_000 }); + + const state = await getSlackThreadSubscriptionState({ + channelId: 'C123', + threadTimestamp: '1700000000.000100', + }); + + expect(state).toBe('expired'); + expect(kvDeleteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-assistant-request.test.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-assistant-request.test.ts index fa2599fb8d..a0e68932e3 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-assistant-request.test.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/parse-slack-assistant-request.test.ts @@ -141,12 +141,82 @@ describe('parseSlackAssistantRequest', () => { expect(result.request).toBeNull(); }); - it('should skip a mention with no remaining text', () => { + it('should flag a mention with no remaining text for a hint reply', () => { const result = parseSlackAssistantRequest( buildMentionBody({ text: '<@UBOT>' }), ); - expect(result.request).toBeNull(); + expect(result).toEqual({ + request: null, + skipReason: 'Empty request text', + emptyRequest: { + slackChannelId: 'C123', + slackMessageTimestamp: '1700000000.000100', + parentMessageTimestamp: '1700000000.000100', + isInExistingThread: false, + }, + }); + }); + + it('should target the existing thread when an empty mention is inside one', () => { + const result = parseSlackAssistantRequest( + buildMentionBody({ text: '<@UBOT>', thread_ts: '1699999999.000001' }), + ); + + expect(result).toMatchObject({ + request: null, + emptyRequest: { + parentMessageTimestamp: '1699999999.000001', + isInExistingThread: true, + }, + }); + }); + + it('should flag an empty direct message without a thread target', () => { + const result = parseSlackAssistantRequest({ + type: 'event_callback', + event_id: 'Ev456', + event: { + type: 'message', + channel_type: 'im', + user: 'U123', + text: ' ', + ts: '1700000000.000200', + channel: 'D123', + }, + }); + + expect(result).toEqual({ + request: null, + skipReason: 'Empty request text', + emptyRequest: { + slackChannelId: 'D123', + slackMessageTimestamp: '1700000000.000200', + parentMessageTimestamp: undefined, + isInExistingThread: false, + }, + }); + }); + + it('should skip an empty unmentioned thread follow-up without a hint reply', () => { + const result = parseSlackAssistantRequest({ + type: 'event_callback', + event_id: 'EvEmptyFollowUp', + event: { + type: 'message', + channel_type: 'channel', + user: 'U123', + text: '', + ts: '1700000000.000500', + thread_ts: '1699999999.000001', + channel: 'C123', + }, + }); + + expect(result).toEqual({ + request: null, + skipReason: 'Empty request text', + }); }); it('should skip non event_callback bodies', () => { diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-empty-request-reply.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-empty-request-reply.ts new file mode 100644 index 0000000000..d185ffd804 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-empty-request-reply.ts @@ -0,0 +1,33 @@ +import { kv } from 'twenty-sdk/logic-function'; +import { isDefined } from 'twenty-sdk/utils'; + +import { type SlackMessageReference } from 'src/logic-functions/types/slack-message-reference.type'; +import { getSlackEmptyRequestReplyKvKey } from 'src/logic-functions/utils/get-slack-empty-request-reply-kv-key'; +import { hasKvEntryExpired } from 'src/logic-functions/utils/has-kv-entry-expired'; + +const SLACK_EMPTY_REQUEST_REPLY_TTL_MS = 60 * 60 * 1000; + +type SlackEmptyRequestReplyClaim = { + expiresAt: number; +}; + +export const claimSlackEmptyRequestReply = async ({ + slackChannelId, + slackMessageTimestamp, +}: SlackMessageReference): Promise => { + const key = getSlackEmptyRequestReplyKvKey({ + slackChannelId, + slackMessageTimestamp, + }); + const existingClaim = await kv.get(key); + + if (isDefined(existingClaim) && !hasKvEntryExpired(existingClaim)) { + return false; + } + + await kv.set(key, { + expiresAt: Date.now() + SLACK_EMPTY_REQUEST_REPLY_TTL_MS, + }); + + return true; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/is-slack-thread-active.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/clear-lapsed-slack-thread-subscription.ts similarity index 60% rename from packages/twenty-apps/public/slack/src/logic-functions/utils/is-slack-thread-active.ts rename to packages/twenty-apps/public/slack/src/logic-functions/utils/clear-lapsed-slack-thread-subscription.ts index 185c61dfae..8283ef4feb 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/utils/is-slack-thread-active.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/clear-lapsed-slack-thread-subscription.ts @@ -1,31 +1,21 @@ -import { isNonEmptyString } from '@sniptt/guards'; import { kv } from 'twenty-sdk/logic-function'; +import { isDefined } from 'twenty-sdk/utils'; import { type SlackThreadReference } from 'src/logic-functions/types/slack-thread-reference.type'; import { type SlackThreadSubscription } from 'src/logic-functions/types/slack-thread-subscription.type'; import { getSlackThreadKvKey } from 'src/logic-functions/utils/get-slack-thread-kv-key'; import { hasKvEntryExpired } from 'src/logic-functions/utils/has-kv-entry-expired'; -export const isSlackThreadActive = async ({ +export const clearLapsedSlackThreadSubscription = async ({ channelId, threadTimestamp, -}: SlackThreadReference): Promise => { - if (!isNonEmptyString(channelId) || !isNonEmptyString(threadTimestamp)) { - return false; - } - +}: SlackThreadReference): Promise => { const key = getSlackThreadKvKey({ channelId, threadTimestamp }); const subscription = await kv.get(key); - if (subscription === null) { - return false; + if (isDefined(subscription) && !hasKvEntryExpired(subscription)) { + return; } - if (hasKvEntryExpired(subscription)) { - await kv.delete(key); - - return false; - } - - return true; + await kv.delete(key); }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request-record.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request-record.ts new file mode 100644 index 0000000000..d1532bc01a --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request-record.ts @@ -0,0 +1,40 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'twenty-sdk/utils'; + +import { createSlackAssistantRequest } from 'src/logic-functions/data/create-slack-assistant-request'; +import { findSlackAssistantRequestBySlackMessage } from 'src/logic-functions/data/find-slack-assistant-request-by-slack-message'; +import { type SlackAssistantRequestDraft } from 'src/logic-functions/types/slack-assistant-request-draft.type'; +import { type SlackEventsEnqueueResult } from 'src/logic-functions/types/slack-events-enqueue-result.type'; +import { isDuplicateRecordError } from 'src/logic-functions/utils/is-duplicate-record-error'; + +const ALREADY_QUEUED_SKIP_REASON = 'Slack message is already queued'; + +export const enqueueSlackAssistantRequestRecord = async ( + request: SlackAssistantRequestDraft, +): Promise => { + const client = new CoreApiClient(); + + const existingRequestId = await findSlackAssistantRequestBySlackMessage( + client, + { + slackChannelId: request.slackChannelId, + slackMessageTimestamp: request.slackMessageTimestamp, + }, + ); + + if (isDefined(existingRequestId)) { + return { ok: true, skipped: ALREADY_QUEUED_SKIP_REASON }; + } + + try { + await createSlackAssistantRequest(client, request); + } catch (error) { + if (isDuplicateRecordError(error)) { + return { ok: true, skipped: ALREADY_QUEUED_SKIP_REASON }; + } + + throw error; + } + + return { ok: true }; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request.ts index 94269b92e9..c46d7528a0 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/enqueue-slack-assistant-request.ts @@ -1,15 +1,11 @@ -import { CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'twenty-sdk/utils'; -import { createSlackAssistantRequest } from 'src/logic-functions/data/create-slack-assistant-request'; -import { findSlackAssistantRequestBySlackMessage } from 'src/logic-functions/data/find-slack-assistant-request-by-slack-message'; +import { type SlackEventsEnqueueResult } from 'src/logic-functions/types/slack-events-enqueue-result.type'; import { type SlackEventsRequestBody } from 'src/logic-functions/types/slack-events-request-body.type'; -import { isDuplicateRecordError } from 'src/logic-functions/utils/is-duplicate-record-error'; -import { isSlackThreadActive } from 'src/logic-functions/utils/is-slack-thread-active'; +import { enqueueSlackAssistantRequestRecord } from 'src/logic-functions/utils/enqueue-slack-assistant-request-record'; +import { gateSlackThreadFollowUp } from 'src/logic-functions/utils/gate-slack-thread-follow-up'; import { parseSlackAssistantRequest } from 'src/logic-functions/utils/parse-slack-assistant-request'; - -const ALREADY_QUEUED_SKIP_REASON = 'Slack message is already queued'; - -type SlackEventsEnqueueResult = { ok: boolean; skipped?: string }; +import { replyToEmptySlackAssistantRequest } from 'src/logic-functions/utils/reply-to-empty-slack-assistant-request'; export const enqueueSlackAssistantRequest = async ( body: SlackEventsRequestBody, @@ -17,46 +13,20 @@ export const enqueueSlackAssistantRequest = async ( const parsed = parseSlackAssistantRequest(body); if (parsed.request === null) { + if (isDefined(parsed.emptyRequest)) { + return await replyToEmptySlackAssistantRequest(parsed.emptyRequest); + } + return { ok: true, skipped: parsed.skipReason }; } if (parsed.requiresActiveThreadSubscription) { - const isActive = await isSlackThreadActive({ - channelId: parsed.request.slackChannelId, - threadTimestamp: parsed.request.slackThreadTimestamp, - }); + const followUpGateResult = await gateSlackThreadFollowUp(parsed.request); - if (!isActive) { - return { - ok: true, - skipped: 'Thread is not subscribed for unmentioned follow-ups', - }; + if (isDefined(followUpGateResult)) { + return followUpGateResult; } } - const client = new CoreApiClient(); - - const existingRequestId = await findSlackAssistantRequestBySlackMessage( - client, - { - slackChannelId: parsed.request.slackChannelId, - slackMessageTimestamp: parsed.request.slackMessageTimestamp, - }, - ); - - if (existingRequestId !== undefined) { - return { ok: true, skipped: ALREADY_QUEUED_SKIP_REASON }; - } - - try { - await createSlackAssistantRequest(client, parsed.request); - } catch (error) { - if (isDuplicateRecordError(error)) { - return { ok: true, skipped: ALREADY_QUEUED_SKIP_REASON }; - } - - throw error; - } - - return { ok: true }; + return await enqueueSlackAssistantRequestRecord(parsed.request); }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/gate-slack-thread-follow-up.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/gate-slack-thread-follow-up.ts new file mode 100644 index 0000000000..b27ce83326 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/gate-slack-thread-follow-up.ts @@ -0,0 +1,32 @@ +import { type SlackAssistantRequestDraft } from 'src/logic-functions/types/slack-assistant-request-draft.type'; +import { type SlackEventsEnqueueResult } from 'src/logic-functions/types/slack-events-enqueue-result.type'; +import { getSlackThreadSubscriptionState } from 'src/logic-functions/utils/get-slack-thread-subscription-state'; +import { handleExpiredSlackThreadFollowUp } from 'src/logic-functions/utils/handle-expired-slack-thread-follow-up'; + +export const gateSlackThreadFollowUp = async ( + request: SlackAssistantRequestDraft, +): Promise => { + const threadReference = { + channelId: request.slackChannelId, + threadTimestamp: request.slackThreadTimestamp, + }; + + const subscriptionState = + await getSlackThreadSubscriptionState(threadReference); + + if (subscriptionState === 'expired') { + return await handleExpiredSlackThreadFollowUp({ + ...threadReference, + slackUserId: request.slackUserId, + }); + } + + if (subscriptionState === 'none') { + return { + ok: true, + skipped: 'Thread is not subscribed for unmentioned follow-ups', + }; + } + + return undefined; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-empty-request-reply-kv-key.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-empty-request-reply-kv-key.ts new file mode 100644 index 0000000000..cd8a61feef --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-empty-request-reply-kv-key.ts @@ -0,0 +1,7 @@ +import { type SlackMessageReference } from 'src/logic-functions/types/slack-message-reference.type'; + +export const getSlackEmptyRequestReplyKvKey = ({ + slackChannelId, + slackMessageTimestamp, +}: SlackMessageReference): string => + `slack-empty-request-reply:${slackChannelId}:${slackMessageTimestamp}`; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-thread-subscription-state.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-thread-subscription-state.ts new file mode 100644 index 0000000000..10c9fe684a --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/get-slack-thread-subscription-state.ts @@ -0,0 +1,27 @@ +import { isNonEmptyString, isNumber } from '@sniptt/guards'; +import { kv } from 'twenty-sdk/logic-function'; +import { isDefined } from 'twenty-sdk/utils'; + +import { type SlackThreadReference } from 'src/logic-functions/types/slack-thread-reference.type'; +import { type SlackThreadSubscriptionState } from 'src/logic-functions/types/slack-thread-subscription-state.type'; +import { type SlackThreadSubscription } from 'src/logic-functions/types/slack-thread-subscription.type'; +import { getSlackThreadKvKey } from 'src/logic-functions/utils/get-slack-thread-kv-key'; +import { hasKvEntryExpired } from 'src/logic-functions/utils/has-kv-entry-expired'; + +export const getSlackThreadSubscriptionState = async ({ + channelId, + threadTimestamp, +}: SlackThreadReference): Promise => { + if (!isNonEmptyString(channelId) || !isNonEmptyString(threadTimestamp)) { + return 'none'; + } + + const key = getSlackThreadKvKey({ channelId, threadTimestamp }); + const subscription = await kv.get(key); + + if (!isDefined(subscription) || !isNumber(subscription.expiresAt)) { + return 'none'; + } + + return hasKvEntryExpired(subscription) ? 'expired' : 'active'; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/handle-expired-slack-thread-follow-up.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/handle-expired-slack-thread-follow-up.ts new file mode 100644 index 0000000000..27c6df1219 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/handle-expired-slack-thread-follow-up.ts @@ -0,0 +1,31 @@ +import { type SlackEventsEnqueueResult } from 'src/logic-functions/types/slack-events-enqueue-result.type'; +import { type SlackThreadReference } from 'src/logic-functions/types/slack-thread-reference.type'; +import { clearLapsedSlackThreadSubscription } from 'src/logic-functions/utils/clear-lapsed-slack-thread-subscription'; +import { nudgeExpiredSlackThread } from 'src/logic-functions/utils/nudge-expired-slack-thread'; + +export const handleExpiredSlackThreadFollowUp = async ({ + channelId, + threadTimestamp, + slackUserId, +}: SlackThreadReference & { + slackUserId: string; +}): Promise => { + const nudgeResult = await nudgeExpiredSlackThread({ + channelId, + threadTimestamp, + slackUserId, + }); + + if (!nudgeResult.success) { + throw new Error( + `Failed to post the Slack expired thread nudge in channel ${channelId}: ${nudgeResult.error ?? nudgeResult.message}`, + ); + } + + await clearLapsedSlackThreadSubscription({ channelId, threadTimestamp }); + + return { + ok: true, + skipped: 'Thread subscription expired; nudged the requester', + }; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/normalize-slack-parent-message-timestamp.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/normalize-slack-parent-message-timestamp.ts new file mode 100644 index 0000000000..9d10c2a6d6 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/normalize-slack-parent-message-timestamp.ts @@ -0,0 +1,15 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +export const normalizeSlackParentMessageTimestamp = ( + parentMessageTimestamp: string | undefined, +): string | undefined => { + if (!isNonEmptyString(parentMessageTimestamp)) { + return undefined; + } + + const trimmedParentMessageTimestamp = parentMessageTimestamp.trim(); + + return isNonEmptyString(trimmedParentMessageTimestamp) + ? trimmedParentMessageTimestamp + : undefined; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/nudge-expired-slack-thread.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/nudge-expired-slack-thread.ts new file mode 100644 index 0000000000..06cbe6ec90 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/nudge-expired-slack-thread.ts @@ -0,0 +1,18 @@ +import { SLACK_ASSISTANT_EXPIRED_THREAD_TEXT } from 'src/logic-functions/constants/slack-assistant-expired-thread-text'; +import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler'; +import { type SlackThreadReference } from 'src/logic-functions/types/slack-thread-reference.type'; +import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type'; + +export const nudgeExpiredSlackThread = async ({ + channelId, + threadTimestamp, + slackUserId, +}: SlackThreadReference & { + slackUserId: string; +}): Promise => + await slackPostEphemeralMessageHandler({ + slackChannelId: channelId, + recipientSlackUserId: slackUserId, + messageText: SLACK_ASSISTANT_EXPIRED_THREAD_TEXT, + parentMessageTimestamp: threadTimestamp, + }); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-assistant-request.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-assistant-request.ts index 0b418f13eb..43477ae9c4 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-assistant-request.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/parse-slack-assistant-request.ts @@ -1,20 +1,66 @@ import { isNonEmptyString } from '@sniptt/guards'; +import { type SlackAssistantEmptyRequest } from 'src/logic-functions/types/slack-assistant-empty-request.type'; import { type SlackAssistantRequestDraft } from 'src/logic-functions/types/slack-assistant-request-draft.type'; import { type SlackEventsRequestBody } from 'src/logic-functions/types/slack-events-request-body.type'; +import { getSlackAssistantParentMessageTimestamp } from 'src/logic-functions/utils/get-slack-assistant-parent-message-timestamp'; const LEADING_BOT_MENTION_PATTERN = /^<@[A-Z0-9]+(\|[^>]*)?>\s*/; +type SlackInboundEvent = NonNullable; + +type SlackAssistantEventKind = 'mention' | 'directMessage' | 'threadFollowUp'; + type ParsedSlackAssistantRequest = | { request: SlackAssistantRequestDraft; requiresActiveThreadSubscription: boolean; } - | { request: null; skipReason: string }; + | { + request: null; + skipReason: string; + emptyRequest?: SlackAssistantEmptyRequest; + }; + +const classifySlackAssistantEvent = ( + event: SlackInboundEvent, +): SlackAssistantEventKind | null => { + if (event.type === 'app_mention') { + return 'mention'; + } + + if (event.type !== 'message') { + return null; + } + + if (event.channel_type === 'im') { + return 'directMessage'; + } + + const isChannelOrGroupMessage = + event.channel_type === 'channel' || event.channel_type === 'group'; + + if (isChannelOrGroupMessage && isNonEmptyString(event.thread_ts)) { + return 'threadFollowUp'; + } + + return null; +}; const stripLeadingBotMention = (text: string): string => text.replace(LEADING_BOT_MENTION_PATTERN, '').replace(/\s+/g, ' ').trim(); +const normalizeSlackRequestText = ({ + text, + kind, +}: { + text: string; + kind: SlackAssistantEventKind; +}): string => + kind === 'mention' + ? stripLeadingBotMention(text) + : text.replace(/\s+/g, ' ').trim(); + export const parseSlackAssistantRequest = ( body: SlackEventsRequestBody, ): ParsedSlackAssistantRequest => { @@ -28,16 +74,9 @@ export const parseSlackAssistantRequest = ( return { request: null, skipReason: 'Missing event payload' }; } - const isMention = event.type === 'app_mention'; - const isDirectMessage = - event.type === 'message' && event.channel_type === 'im'; - const isChannelOrGroupMessage = - event.type === 'message' && - (event.channel_type === 'channel' || event.channel_type === 'group'); - const isThreadFollowUp = - isChannelOrGroupMessage && isNonEmptyString(event.thread_ts); + const kind = classifySlackAssistantEvent(event); - if (!isMention && !isDirectMessage && !isThreadFollowUp) { + if (kind === null) { return { request: null, skipReason: `Unhandled event type: ${event.type}` }; } @@ -54,29 +93,42 @@ export const parseSlackAssistantRequest = ( return { request: null, skipReason: 'Event is missing required fields' }; } - const rawText = event.text ?? ''; - const requestText = isMention - ? stripLeadingBotMention(rawText) - : rawText.replace(/\s+/g, ' ').trim(); + const requestText = normalizeSlackRequestText({ + text: event.text ?? '', + kind, + }); if (!isNonEmptyString(requestText)) { - return { request: null, skipReason: 'Empty request text' }; - } + if (kind === 'threadFollowUp') { + return { request: null, skipReason: 'Empty request text' }; + } - const slackChannelType = - event.channel_type ?? - (isMention ? 'channel' : isDirectMessage ? 'im' : 'channel'); + return { + request: null, + skipReason: 'Empty request text', + emptyRequest: { + slackChannelId: event.channel, + slackMessageTimestamp: event.ts, + parentMessageTimestamp: getSlackAssistantParentMessageTimestamp({ + slackThreadTimestamp: event.thread_ts, + slackMessageTimestamp: event.ts, + isDirectMessage: kind === 'directMessage', + }), + isInExistingThread: isNonEmptyString(event.thread_ts), + }, + }; + } return { request: { slackEventId: body.event_id, slackChannelId: event.channel, - slackChannelType, + slackChannelType: event.channel_type ?? 'channel', slackThreadTimestamp: event.thread_ts ?? '', slackMessageTimestamp: event.ts, slackUserId: event.user, requestText, }, - requiresActiveThreadSubscription: isThreadFollowUp && !isMention, + requiresActiveThreadSubscription: kind === 'threadFollowUp', }; }; 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 index 3543f72e4a..04101e3a4a 100644 --- 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 @@ -1,17 +1,17 @@ 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 { normalizeSlackParentMessageTimestamp } from 'src/logic-functions/utils/normalize-slack-parent-message-timestamp'; import { sendSlackMessageWithBodyFallbacks } from 'src/logic-functions/utils/send-slack-message-with-body-fallbacks'; export const postSlackMessage = async ( client: WebClient, parameters: SlackPostMessageInput, ): Promise => { - const parentTimestamp = isNonEmptyString(parameters.parentMessageTimestamp) - ? parameters.parentMessageTimestamp.trim() || undefined - : undefined; + const parentTimestamp = normalizeSlackParentMessageTimestamp( + parameters.parentMessageTimestamp, + ); return await sendSlackMessageWithBodyFallbacks({ messageText: parameters.messageText, diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-empty-request-reply.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-empty-request-reply.ts new file mode 100644 index 0000000000..00af7a593e --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/release-slack-empty-request-reply.ts @@ -0,0 +1,13 @@ +import { kv } from 'twenty-sdk/logic-function'; + +import { type SlackMessageReference } from 'src/logic-functions/types/slack-message-reference.type'; +import { getSlackEmptyRequestReplyKvKey } from 'src/logic-functions/utils/get-slack-empty-request-reply-kv-key'; + +export const releaseSlackEmptyRequestReply = async ({ + slackChannelId, + slackMessageTimestamp, +}: SlackMessageReference): Promise => { + await kv.delete( + getSlackEmptyRequestReplyKvKey({ slackChannelId, slackMessageTimestamp }), + ); +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/reply-to-empty-slack-assistant-request.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/reply-to-empty-slack-assistant-request.ts new file mode 100644 index 0000000000..10fca80464 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/reply-to-empty-slack-assistant-request.ts @@ -0,0 +1,59 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { SLACK_ASSISTANT_EMPTY_REQUEST_TEXT } from 'src/logic-functions/constants/slack-assistant-empty-request-text'; +import { SLACK_ASSISTANT_EMPTY_THREAD_REQUEST_TEXT } from 'src/logic-functions/constants/slack-assistant-empty-thread-request-text'; +import { type SlackAssistantEmptyRequest } from 'src/logic-functions/types/slack-assistant-empty-request.type'; +import { type SlackEventsEnqueueResult } from 'src/logic-functions/types/slack-events-enqueue-result.type'; +import { claimSlackEmptyRequestReply } from 'src/logic-functions/utils/claim-slack-empty-request-reply'; +import { getSlackClient } from 'src/logic-functions/utils/get-slack-client'; +import { postSlackMessage } from 'src/logic-functions/utils/post-slack-message'; +import { releaseSlackEmptyRequestReply } from 'src/logic-functions/utils/release-slack-empty-request-reply'; +import { subscribeSlackThread } from 'src/logic-functions/utils/subscribe-slack-thread'; + +export const replyToEmptySlackAssistantRequest = async ( + emptyRequest: SlackAssistantEmptyRequest, +): Promise => { + const claimReference = { + slackChannelId: emptyRequest.slackChannelId, + slackMessageTimestamp: emptyRequest.slackMessageTimestamp, + }; + const isFirstReply = await claimSlackEmptyRequestReply(claimReference); + + if (!isFirstReply) { + return { ok: true, skipped: 'Empty request was already answered' }; + } + + const slackClientResult = await getSlackClient(); + + if (!slackClientResult.success) { + await releaseSlackEmptyRequestReply(claimReference); + + throw new Error(slackClientResult.error); + } + + const replyResult = await postSlackMessage(slackClientResult.client, { + slackChannelId: emptyRequest.slackChannelId, + messageText: emptyRequest.isInExistingThread + ? SLACK_ASSISTANT_EMPTY_THREAD_REQUEST_TEXT + : SLACK_ASSISTANT_EMPTY_REQUEST_TEXT, + parentMessageTimestamp: emptyRequest.parentMessageTimestamp, + messageFormat: 'markdown', + }); + + if (!replyResult.success) { + await releaseSlackEmptyRequestReply(claimReference); + + throw new Error( + `Failed to post the Slack empty request hint in channel ${emptyRequest.slackChannelId}: ${replyResult.error ?? replyResult.message}`, + ); + } + + if (isNonEmptyString(emptyRequest.parentMessageTimestamp)) { + await subscribeSlackThread({ + channelId: emptyRequest.slackChannelId, + threadTimestamp: emptyRequest.parentMessageTimestamp, + }).catch(() => undefined); + } + + return { ok: true }; +};