diff --git a/packages/twenty-apps/public/slack/src/connection-providers/slack-connection.ts b/packages/twenty-apps/public/slack/src/connection-providers/slack-connection.ts index 99d666f331..5feb306634 100644 --- a/packages/twenty-apps/public/slack/src/connection-providers/slack-connection.ts +++ b/packages/twenty-apps/public/slack/src/connection-providers/slack-connection.ts @@ -2,7 +2,7 @@ import { defineConnectionProvider } from 'twenty-sdk/define'; import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER, - SLACK_TEAM_CLAIM_UNIVERSAL_IDENTIFIER, + SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; export default defineConnectionProvider({ @@ -11,7 +11,7 @@ export default defineConnectionProvider({ displayName: 'Slack', type: 'oauth', onConnectLogicFunction: { - universalIdentifier: SLACK_TEAM_CLAIM_UNIVERSAL_IDENTIFIER, + universalIdentifier: SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER, }, oauth: { authorizationEndpoint: 'https://slack.com/oauth/v2/authorize', 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 b05c4559fb..72857a6624 100644 --- a/packages/twenty-apps/public/slack/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/public/slack/src/constants/universal-identifiers.ts @@ -55,7 +55,7 @@ export const SLACK_CHANNEL_WELCOME_UNIVERSAL_IDENTIFIER = export const SLACK_ASSISTANT_WORKER_UNIVERSAL_IDENTIFIER = '4b92a49f-d674-46ea-a3d9-e8d658ae3a17'; -export const SLACK_TEAM_CLAIM_UNIVERSAL_IDENTIFIER = +export const SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER = 'a29ae15d-dd16-4b99-bb6c-079842da55ab'; export const SLACK_ASSISTANT_REQUEST_OBJECT_UNIVERSAL_IDENTIFIER = diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-bot-user-id-kv-key.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-bot-user-id-kv-key.ts new file mode 100644 index 0000000000..5aef54e180 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-bot-user-id-kv-key.ts @@ -0,0 +1 @@ +export const SLACK_BOT_USER_ID_KV_KEY = 'slack-bot-user-id'; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-bot-user-id-ttl-ms.ts b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-bot-user-id-ttl-ms.ts new file mode 100644 index 0000000000..a42b3b7127 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/constants/slack-bot-user-id-ttl-ms.ts @@ -0,0 +1 @@ +export const SLACK_BOT_USER_ID_TTL_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-register-connection-handler.ts b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-register-connection-handler.ts new file mode 100644 index 0000000000..0455d9ceaa --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-register-connection-handler.ts @@ -0,0 +1,9 @@ +import { type SlackRegisterConnectionPayload } from 'src/logic-functions/types/slack-register-connection-payload.type'; +import { registerSlackConnection } from 'src/logic-functions/utils/register-slack-connection'; + +export const slackRegisterConnectionHandler = ( + payload: SlackRegisterConnectionPayload, +) => + registerSlackConnection({ + connectedAccountId: payload.connectedAccountId, + }); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-team-claim-handler.ts b/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-team-claim-handler.ts deleted file mode 100644 index 6d5d6d90bb..0000000000 --- a/packages/twenty-apps/public/slack/src/logic-functions/handlers/slack-team-claim-handler.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type SlackTeamClaimPayload } from 'src/logic-functions/types/slack-team-claim-payload.type'; -import { claimSlackTeam } from 'src/logic-functions/utils/claim-slack-team'; - -export const slackTeamClaimHandler = (payload: SlackTeamClaimPayload) => - claimSlackTeam({ - connectedAccountId: payload.connectedAccountId, - }); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/slack-register-connection.ts b/packages/twenty-apps/public/slack/src/logic-functions/slack-register-connection.ts new file mode 100644 index 0000000000..a48db58de6 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/slack-register-connection.ts @@ -0,0 +1,13 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { slackRegisterConnectionHandler } from 'src/logic-functions/handlers/slack-register-connection-handler'; + +export default defineLogicFunction({ + universalIdentifier: SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER, + name: 'slack-register-connection', + description: + 'Runs when a Slack connection is established (via the connection provider onConnect hook). Resolves the Slack team_id for the just-created connection via auth.test and stores this workspace id under the server-scoped slack-team: key so inbound Slack events route here. Caches the bot user id from the same auth.test under slack-bot-user-id so the channel welcome can recognise the bot without calling Slack on every join event.', + timeoutSeconds: 30, + handler: slackRegisterConnectionHandler, +}); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/slack-team-claim.ts b/packages/twenty-apps/public/slack/src/logic-functions/slack-team-claim.ts deleted file mode 100644 index d2eb4c9249..0000000000 --- a/packages/twenty-apps/public/slack/src/logic-functions/slack-team-claim.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineLogicFunction } from 'twenty-sdk/define'; - -import { SLACK_TEAM_CLAIM_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { slackTeamClaimHandler } from 'src/logic-functions/handlers/slack-team-claim-handler'; - -export default defineLogicFunction({ - universalIdentifier: SLACK_TEAM_CLAIM_UNIVERSAL_IDENTIFIER, - name: 'slack-team-claim', - description: - 'Runs when a Slack connection is established (via the connection provider onConnect hook). Resolves the Slack team_id for the just-created connection via auth.test and stores this workspace id under the server-scoped slack-team: key so inbound Slack events route here.', - timeoutSeconds: 30, - handler: slackTeamClaimHandler, -}); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-bot-user-id-cache-entry.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-bot-user-id-cache-entry.type.ts new file mode 100644 index 0000000000..2d5906bfc3 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-bot-user-id-cache-entry.type.ts @@ -0,0 +1,4 @@ +export type SlackBotUserIdCacheEntry = { + botUserId: string; + expiresAt: number; +}; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-team-claim-payload.type.ts b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-register-connection-payload.type.ts similarity index 67% rename from packages/twenty-apps/public/slack/src/logic-functions/types/slack-team-claim-payload.type.ts rename to packages/twenty-apps/public/slack/src/logic-functions/types/slack-register-connection-payload.type.ts index b51266bc71..1b19fe4c51 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/types/slack-team-claim-payload.type.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/types/slack-register-connection-payload.type.ts @@ -1,4 +1,4 @@ -export type SlackTeamClaimPayload = { +export type SlackRegisterConnectionPayload = { connectionProviderId: string; connectionProviderName: string; connectedAccountId: string; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/post-slack-channel-welcome.test.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/post-slack-channel-welcome.test.ts new file mode 100644 index 0000000000..44d4e0402c --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/post-slack-channel-welcome.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type SlackEventsRequestBody } from 'src/logic-functions/types/slack-events-request-body.type'; +import { postSlackChannelWelcome } from 'src/logic-functions/utils/post-slack-channel-welcome'; + +const { + claimSlackChannelWelcomeMock, + getSlackClientMock, + postSlackMessageMock, + releaseSlackChannelWelcomeMock, + resolveSlackBotUserIdOrThrowMock, +} = vi.hoisted(() => ({ + claimSlackChannelWelcomeMock: vi.fn(), + getSlackClientMock: vi.fn(), + postSlackMessageMock: vi.fn(), + releaseSlackChannelWelcomeMock: vi.fn(), + resolveSlackBotUserIdOrThrowMock: vi.fn(), +})); + +vi.mock('src/logic-functions/utils/claim-slack-channel-welcome', () => ({ + claimSlackChannelWelcome: claimSlackChannelWelcomeMock, +})); + +vi.mock('src/logic-functions/utils/get-slack-client', () => ({ + getSlackClient: getSlackClientMock, +})); + +vi.mock('src/logic-functions/utils/post-slack-message', () => ({ + postSlackMessage: postSlackMessageMock, +})); + +vi.mock('src/logic-functions/utils/release-slack-channel-welcome', () => ({ + releaseSlackChannelWelcome: releaseSlackChannelWelcomeMock, +})); + +vi.mock('src/logic-functions/utils/resolve-slack-bot-user-id-or-throw', () => ({ + resolveSlackBotUserIdOrThrow: resolveSlackBotUserIdOrThrowMock, +})); + +const BOT_USER_ID = 'UBOT'; +const CHANNEL_ID = 'C123'; + +const buildJoinEvent = (slackUserId: string): SlackEventsRequestBody => ({ + type: 'event_callback', + team_id: 'T123', + event: { + type: 'member_joined_channel', + channel: CHANNEL_ID, + user: slackUserId, + }, +}); + +describe('postSlackChannelWelcome', () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveSlackBotUserIdOrThrowMock.mockResolvedValue(BOT_USER_ID); + claimSlackChannelWelcomeMock.mockResolvedValue(true); + releaseSlackChannelWelcomeMock.mockResolvedValue(undefined); + getSlackClientMock.mockResolvedValue({ success: true, client: {} }); + postSlackMessageMock.mockResolvedValue({ success: true, slackTs: '1.1' }); + }); + + it('should skip without touching the Slack connection when someone else joined', async () => { + const result = await postSlackChannelWelcome(buildJoinEvent('UHUMAN')); + + expect(result).toEqual({ + ok: true, + skipped: 'Someone other than the bot joined', + }); + expect(getSlackClientMock).not.toHaveBeenCalled(); + expect(claimSlackChannelWelcomeMock).not.toHaveBeenCalled(); + }); + + it('should skip when the channel was already welcomed', async () => { + claimSlackChannelWelcomeMock.mockResolvedValue(false); + + const result = await postSlackChannelWelcome(buildJoinEvent(BOT_USER_ID)); + + expect(result).toEqual({ + ok: true, + skipped: 'Channel was already welcomed', + }); + expect(getSlackClientMock).not.toHaveBeenCalled(); + }); + + it('should post the channel message and its thread reply on the bot join', async () => { + const result = await postSlackChannelWelcome(buildJoinEvent(BOT_USER_ID)); + + expect(result).toEqual({ ok: true }); + expect(postSlackMessageMock).toHaveBeenCalledTimes(2); + expect(postSlackMessageMock.mock.calls[1][1]).toMatchObject({ + slackChannelId: CHANNEL_ID, + parentMessageTimestamp: '1.1', + }); + expect(releaseSlackChannelWelcomeMock).not.toHaveBeenCalled(); + }); + + it('should release the claim when the Slack client cannot be built', async () => { + getSlackClientMock.mockResolvedValue({ + success: false, + error: 'Slack is not connected.', + }); + + await expect( + postSlackChannelWelcome(buildJoinEvent(BOT_USER_ID)), + ).rejects.toThrow('Slack is not connected.'); + expect(releaseSlackChannelWelcomeMock).toHaveBeenCalledWith(CHANNEL_ID); + }); + + it('should release the claim when the channel message fails to post', async () => { + postSlackMessageMock.mockResolvedValue({ + success: false, + error: 'channel_not_found', + }); + + await expect( + postSlackChannelWelcome(buildJoinEvent(BOT_USER_ID)), + ).rejects.toThrow('channel_not_found'); + expect(releaseSlackChannelWelcomeMock).toHaveBeenCalledWith(CHANNEL_ID); + }); + + it('should keep the claim when only the thread reply fails, so a retry cannot repost the channel message', async () => { + postSlackMessageMock + .mockResolvedValueOnce({ success: true, slackTs: '1.1' }) + .mockResolvedValueOnce({ success: false, error: 'thread_not_found' }); + + await expect( + postSlackChannelWelcome(buildJoinEvent(BOT_USER_ID)), + ).rejects.toThrow('thread_not_found'); + expect(releaseSlackChannelWelcomeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/resolve-slack-bot-user-id-or-throw.test.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/resolve-slack-bot-user-id-or-throw.test.ts new file mode 100644 index 0000000000..78ac078bee --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/__tests__/resolve-slack-bot-user-id-or-throw.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SLACK_BOT_USER_ID_KV_KEY } from 'src/logic-functions/constants/slack-bot-user-id-kv-key'; +import { SLACK_BOT_USER_ID_TTL_MS } from 'src/logic-functions/constants/slack-bot-user-id-ttl-ms'; +import { resolveSlackBotUserIdOrThrow } from 'src/logic-functions/utils/resolve-slack-bot-user-id-or-throw'; + +const { authTestMock, getSlackClientMock, kvGetMock, kvSetMock, kvDeleteMock } = + vi.hoisted(() => ({ + authTestMock: vi.fn(), + getSlackClientMock: vi.fn(), + kvGetMock: vi.fn(), + kvSetMock: vi.fn(), + kvDeleteMock: vi.fn(), + })); + +vi.mock('twenty-sdk/logic-function', () => ({ + kv: { get: kvGetMock, set: kvSetMock, delete: kvDeleteMock }, +})); + +vi.mock('src/logic-functions/utils/get-slack-client', () => ({ + getSlackClient: getSlackClientMock, +})); + +const BOT_USER_ID = 'UBOT'; + +const freshCacheEntry = () => ({ + botUserId: BOT_USER_ID, + expiresAt: Date.now() + SLACK_BOT_USER_ID_TTL_MS, +}); + +describe('resolveSlackBotUserIdOrThrow', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + kvGetMock.mockResolvedValue(null); + kvSetMock.mockResolvedValue(undefined); + kvDeleteMock.mockResolvedValue(true); + authTestMock.mockResolvedValue({ user_id: BOT_USER_ID }); + getSlackClientMock.mockResolvedValue({ + success: true, + client: { auth: { test: authTestMock } }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return the cached id without calling Slack', async () => { + kvGetMock.mockResolvedValue(freshCacheEntry()); + + const botUserId = await resolveSlackBotUserIdOrThrow(); + + expect(botUserId).toBe(BOT_USER_ID); + expect(getSlackClientMock).not.toHaveBeenCalled(); + expect(authTestMock).not.toHaveBeenCalled(); + }); + + it('should fall back to auth.test when nothing is cached', async () => { + const botUserId = await resolveSlackBotUserIdOrThrow(); + + expect(botUserId).toBe(BOT_USER_ID); + expect(authTestMock).toHaveBeenCalled(); + }); + + it('should refetch rather than trust an expired entry', async () => { + kvGetMock.mockResolvedValue({ + botUserId: 'USTALE', + expiresAt: Date.now() - 1, + }); + + const botUserId = await resolveSlackBotUserIdOrThrow(); + + expect(botUserId).toBe(BOT_USER_ID); + expect(authTestMock).toHaveBeenCalled(); + }); + + it('should refetch when the cached entry has no expiry', async () => { + kvGetMock.mockResolvedValue({ botUserId: 'USTALE' }); + + await expect(resolveSlackBotUserIdOrThrow()).resolves.toBe(BOT_USER_ID); + }); + + it('should fall back to auth.test when the cache read fails', async () => { + kvGetMock.mockRejectedValue(new Error('kv unavailable')); + + const botUserId = await resolveSlackBotUserIdOrThrow(); + + expect(botUserId).toBe(BOT_USER_ID); + expect(authTestMock).toHaveBeenCalled(); + }); + + it('should backfill the cache with an expiry after falling back', async () => { + await resolveSlackBotUserIdOrThrow(); + + expect(kvSetMock).toHaveBeenCalledWith(SLACK_BOT_USER_ID_KV_KEY, { + botUserId: BOT_USER_ID, + expiresAt: Date.now() + SLACK_BOT_USER_ID_TTL_MS, + }); + }); + + it('should drop the key when the cache write fails, rather than leave a stale id', async () => { + kvSetMock.mockRejectedValue(new Error('kv unavailable')); + + await expect(resolveSlackBotUserIdOrThrow()).resolves.toBe(BOT_USER_ID); + expect(kvDeleteMock).toHaveBeenCalledWith(SLACK_BOT_USER_ID_KV_KEY); + }); + + it('should throw when Slack is not connected and nothing is cached', async () => { + getSlackClientMock.mockResolvedValue({ + success: false, + error: 'Slack is not connected.', + }); + + await expect(resolveSlackBotUserIdOrThrow()).rejects.toThrow( + 'Slack is not connected.', + ); + }); + + it('should throw when auth.test returns no user id', async () => { + authTestMock.mockResolvedValue({}); + + await expect(resolveSlackBotUserIdOrThrow()).rejects.toThrow( + 'Slack auth.test returned no user_id for the bot', + ); + }); +}); diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/cache-slack-bot-user-id.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/cache-slack-bot-user-id.ts new file mode 100644 index 0000000000..a23e5179fd --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/cache-slack-bot-user-id.ts @@ -0,0 +1,16 @@ +import { kv } from 'twenty-sdk/logic-function'; + +import { SLACK_BOT_USER_ID_KV_KEY } from 'src/logic-functions/constants/slack-bot-user-id-kv-key'; +import { SLACK_BOT_USER_ID_TTL_MS } from 'src/logic-functions/constants/slack-bot-user-id-ttl-ms'; +import { type SlackBotUserIdCacheEntry } from 'src/logic-functions/types/slack-bot-user-id-cache-entry.type'; + +export const cacheSlackBotUserId = async (botUserId: string): Promise => { + await kv + .set(SLACK_BOT_USER_ID_KV_KEY, { + botUserId, + expiresAt: Date.now() + SLACK_BOT_USER_ID_TTL_MS, + } satisfies SlackBotUserIdCacheEntry) + .catch(async () => { + await kv.delete(SLACK_BOT_USER_ID_KV_KEY).catch(() => undefined); + }); +}; 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 index b841264114..b6cdace39f 100644 --- 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 @@ -1,8 +1,8 @@ -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'; +import { hasKvEntryExpired } from 'src/logic-functions/utils/has-kv-entry-expired'; const SLACK_CHANNEL_WELCOME_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -12,11 +12,7 @@ export const claimSlackChannelWelcome = async ( const key = getSlackChannelWelcomeKvKey(channelId); const existingWelcome = await kv.get(key); - if ( - existingWelcome !== null && - isNumber(existingWelcome.expiresAt) && - existingWelcome.expiresAt > Date.now() - ) { + if (existingWelcome !== null && !hasKvEntryExpired(existingWelcome)) { return false; } diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/has-kv-entry-expired.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/has-kv-entry-expired.ts new file mode 100644 index 0000000000..845f37ca78 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/has-kv-entry-expired.ts @@ -0,0 +1,4 @@ +import { isNumber } from '@sniptt/guards'; + +export const hasKvEntryExpired = (entry: { expiresAt?: number }): boolean => + !isNumber(entry.expiresAt) || entry.expiresAt <= Date.now(); 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/is-slack-thread-active.ts index 148e65a51e..185c61dfae 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/is-slack-thread-active.ts @@ -1,9 +1,10 @@ -import { isNonEmptyString, isNumber } from '@sniptt/guards'; +import { isNonEmptyString } from '@sniptt/guards'; import { kv } from 'twenty-sdk/logic-function'; 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 ({ channelId, @@ -16,11 +17,11 @@ export const isSlackThreadActive = async ({ const key = getSlackThreadKvKey({ channelId, threadTimestamp }); const subscription = await kv.get(key); - if (subscription === null || !isNumber(subscription.expiresAt)) { + if (subscription === null) { return false; } - if (subscription.expiresAt <= Date.now()) { + if (hasKvEntryExpired(subscription)) { await kv.delete(key); return false; 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 index 41035dd17e..3a15b33ad3 100644 --- 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 @@ -8,6 +8,7 @@ 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'; +import { resolveSlackBotUserIdOrThrow } from 'src/logic-functions/utils/resolve-slack-bot-user-id-or-throw'; type SlackChannelWelcomeResult = { ok: boolean; skipped?: string }; @@ -22,17 +23,9 @@ export const postSlackChannelWelcome = async ( const { slackChannelId, slackUserId } = parsed.channelJoin; - const slackClientResult = await getSlackClient(); + const botUserId = await resolveSlackBotUserIdOrThrow(); - if (!slackClientResult.success) { - throw new Error(slackClientResult.error); - } - - const { client } = slackClientResult; - - const authResult = await client.auth.test(); - - if (authResult.user_id !== slackUserId) { + if (botUserId !== slackUserId) { return { ok: true, skipped: 'Someone other than the bot joined' }; } @@ -42,6 +35,16 @@ export const postSlackChannelWelcome = async ( return { ok: true, skipped: 'Channel was already welcomed' }; } + const slackClientResult = await getSlackClient(); + + if (!slackClientResult.success) { + await releaseSlackChannelWelcome(slackChannelId); + + throw new Error(slackClientResult.error); + } + + const { client } = slackClientResult; + const channelMessageResult = await postSlackMessage(client, { slackChannelId, messageText: SLACK_CHANNEL_WELCOME_TEXT, diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-team.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/register-slack-connection.ts similarity index 57% rename from packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-team.ts rename to packages/twenty-apps/public/slack/src/logic-functions/utils/register-slack-connection.ts index e534c01b3b..193706d139 100644 --- a/packages/twenty-apps/public/slack/src/logic-functions/utils/claim-slack-team.ts +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/register-slack-connection.ts @@ -2,37 +2,43 @@ import { WebClient } from '@slack/web-api'; import { isNonEmptyString } from '@sniptt/guards'; import { getConnection, kv } from 'twenty-sdk/logic-function'; +import { cacheSlackBotUserId } from 'src/logic-functions/utils/cache-slack-bot-user-id'; import { getSlackTeamKvKey } from 'src/logic-functions/utils/get-slack-team-kv-key'; -type ClaimSlackTeamArgs = { +type RegisterSlackConnectionArgs = { connectedAccountId: string; }; -type ClaimSlackTeamResult = { +type RegisterSlackConnectionResult = { ok: true; teamId: string; }; -export const claimSlackTeam = async ({ +export const registerSlackConnection = async ({ connectedAccountId, -}: ClaimSlackTeamArgs): Promise => { +}: RegisterSlackConnectionArgs): Promise => { if (!isNonEmptyString(connectedAccountId)) { throw new Error( - 'Slack team claim failed: onConnect payload is missing connectedAccountId', + 'Slack connection registration failed: onConnect payload is missing connectedAccountId', ); } const connection = await getConnection(connectedAccountId); const client = new WebClient(connection.accessToken); - const authResult = await client.auth.test(); - const teamId = authResult.team_id; + const { team_id: teamId, user_id: botUserId } = await client.auth.test(); if (!isNonEmptyString(teamId)) { throw new Error('Slack auth.test returned no team_id to claim'); } + if (!isNonEmptyString(botUserId)) { + throw new Error('Slack auth.test returned no user_id for the bot'); + } + // TODO: release the claim on disconnect once connection providers expose an onDisconnect hook. await kv.set(getSlackTeamKvKey(teamId), null, { scope: 'SERVER' }); + await cacheSlackBotUserId(botUserId); + return { ok: true, teamId }; }; diff --git a/packages/twenty-apps/public/slack/src/logic-functions/utils/resolve-slack-bot-user-id-or-throw.ts b/packages/twenty-apps/public/slack/src/logic-functions/utils/resolve-slack-bot-user-id-or-throw.ts new file mode 100644 index 0000000000..3872abe923 --- /dev/null +++ b/packages/twenty-apps/public/slack/src/logic-functions/utils/resolve-slack-bot-user-id-or-throw.ts @@ -0,0 +1,48 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { kv } from 'twenty-sdk/logic-function'; + +import { SLACK_BOT_USER_ID_KV_KEY } from 'src/logic-functions/constants/slack-bot-user-id-kv-key'; +import { type SlackBotUserIdCacheEntry } from 'src/logic-functions/types/slack-bot-user-id-cache-entry.type'; +import { cacheSlackBotUserId } from 'src/logic-functions/utils/cache-slack-bot-user-id'; +import { getSlackClient } from 'src/logic-functions/utils/get-slack-client'; +import { hasKvEntryExpired } from 'src/logic-functions/utils/has-kv-entry-expired'; + +const readCachedBotUserId = async (): Promise => { + const cacheEntry = await kv + .get(SLACK_BOT_USER_ID_KV_KEY) + .catch(() => null); + + if ( + cacheEntry === null || + !isNonEmptyString(cacheEntry.botUserId) || + hasKvEntryExpired(cacheEntry) + ) { + return undefined; + } + + return cacheEntry.botUserId; +}; + +export const resolveSlackBotUserIdOrThrow = async (): Promise => { + const cachedBotUserId = await readCachedBotUserId(); + + if (isNonEmptyString(cachedBotUserId)) { + return cachedBotUserId; + } + + const slackClientResult = await getSlackClient(); + + if (!slackClientResult.success) { + throw new Error(slackClientResult.error); + } + + const authResult = await slackClientResult.client.auth.test(); + + if (!isNonEmptyString(authResult.user_id)) { + throw new Error('Slack auth.test returned no user_id for the bot'); + } + + await cacheSlackBotUserId(authResult.user_id); + + return authResult.user_id; +};