slack: answer empty requests and nudge lapsed threads (#23835)

## 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

<img width="406" height="531" alt="Screenshot 2026-08-06 at 6 34 57 AM"
src="https://github.com/user-attachments/assets/bc001057-8998-4d1f-ac7e-e1ed8b5f3770"
/>
This commit is contained in:
Abdul Rahman
2026-08-07 06:38:57 +05:30
committed by GitHub
parent 712e5ece7e
commit fd85e64fe3
28 changed files with 561 additions and 87 deletions
+1 -1
View File
@@ -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
@@ -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.
@@ -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');
@@ -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.";
@@ -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.";
@@ -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,
};
@@ -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 threads 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',
@@ -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;
};
@@ -0,0 +1,4 @@
export type SlackEventsEnqueueResult = {
ok: boolean;
skipped?: string;
};
@@ -0,0 +1,4 @@
export type SlackMessageReference = {
slackChannelId: string;
slackMessageTimestamp: string;
};
@@ -4,5 +4,6 @@ export type SlackPostEphemeralMessageInput = {
slackChannelId: string;
recipientSlackUserId: string;
messageText: string;
parentMessageTimestamp?: string;
messageFormat?: SlackMessageBodyFormat;
};
@@ -0,0 +1 @@
export type SlackThreadSubscriptionState = 'active' | 'expired' | 'none';
@@ -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();
});
});
@@ -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', () => {
@@ -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<boolean> => {
const key = getSlackEmptyRequestReplyKvKey({
slackChannelId,
slackMessageTimestamp,
});
const existingClaim = await kv.get<SlackEmptyRequestReplyClaim>(key);
if (isDefined(existingClaim) && !hasKvEntryExpired(existingClaim)) {
return false;
}
await kv.set(key, {
expiresAt: Date.now() + SLACK_EMPTY_REQUEST_REPLY_TTL_MS,
});
return true;
};
@@ -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<boolean> => {
if (!isNonEmptyString(channelId) || !isNonEmptyString(threadTimestamp)) {
return false;
}
}: SlackThreadReference): Promise<void> => {
const key = getSlackThreadKvKey({ channelId, threadTimestamp });
const subscription = await kv.get<SlackThreadSubscription>(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);
};
@@ -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<SlackEventsEnqueueResult> => {
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 };
};
@@ -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);
};
@@ -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<SlackEventsEnqueueResult | undefined> => {
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;
};
@@ -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}`;
@@ -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<SlackThreadSubscriptionState> => {
if (!isNonEmptyString(channelId) || !isNonEmptyString(threadTimestamp)) {
return 'none';
}
const key = getSlackThreadKvKey({ channelId, threadTimestamp });
const subscription = await kv.get<SlackThreadSubscription>(key);
if (!isDefined(subscription) || !isNumber(subscription.expiresAt)) {
return 'none';
}
return hasKvEntryExpired(subscription) ? 'expired' : 'active';
};
@@ -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<SlackEventsEnqueueResult> => {
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',
};
};
@@ -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;
};
@@ -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<SlackToolResult> =>
await slackPostEphemeralMessageHandler({
slackChannelId: channelId,
recipientSlackUserId: slackUserId,
messageText: SLACK_ASSISTANT_EXPIRED_THREAD_TEXT,
parentMessageTimestamp: threadTimestamp,
});
@@ -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<SlackEventsRequestBody['event']>;
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',
};
};
@@ -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<SlackToolResult> => {
const parentTimestamp = isNonEmptyString(parameters.parentMessageTimestamp)
? parameters.parentMessageTimestamp.trim() || undefined
: undefined;
const parentTimestamp = normalizeSlackParentMessageTimestamp(
parameters.parentMessageTimestamp,
);
return await sendSlackMessageWithBodyFallbacks({
messageText: parameters.messageText,
@@ -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<void> => {
await kv.delete(
getSlackEmptyRequestReplyKvKey({ slackChannelId, slackMessageTimestamp }),
);
};
@@ -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<SlackEventsEnqueueResult> => {
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 };
};