feat(slack): cache the Slack bot user id at connect time (#23726)
## What Caches the Slack bot user id in workspace kv so the channel welcome stops calling `auth.test` on every channel-join event. ## Why Slack fires `member_joined_channel` for **every** person joining **any** channel the bot sits in, not just for the bot itself. The welcome path had to answer "was that our bot?", and did it by resolving the Slack connection and calling `auth.test` — a connection lookup plus an external API call, on every event, to conclude "no, that was a human, do nothing". The bot user id never changes for a given connection, so asking Slack repeatedly is the wrong shape. ## How `registerSlackConnection` already calls `auth.test` in the `onConnect` hook and had `user_id` in hand, so it now writes it to workspace kv. `resolveSlackBotUserId` reads it, falling back to `auth.test` (and backfilling) for connections created before this change. Reconnecting is the only thing that can change the bot user id, and reconnecting re-runs that hook — so the cache is self-correcting and needs no TTL. Because resolving the id no longer needs a Slack client, the bot check moved ahead of the connection lookup: | per join event | before | after | |---|---|---| | Twenty round trips | 1 | 1 | | Slack API calls | 1 | 0 | A secondary win: previously `getSlackClient()` ran before the bot check and threw on failure, so a revoked Slack connection made **every unrelated human join** fail its job and retry. Now a human join answers from kv and returns cleanly; the connection is only touched when there is genuinely something to post. ## Claim ordering Moving the client lookup after the claim opened a window where the claim is held but nothing was posted, so that path now releases the claim before throwing. The invariant the file follows is unchanged: release on any failure that produced no message, keep it once a message is out (a retry must not repost the channel message). ## Renames `claimSlackTeam` → `registerSlackConnection`, and the logic function `slack-team-claim` → `slack-register-connection`, since it now does more than claim the team and connect-time work will keep landing there. **The universal identifier value is unchanged** (`a29ae15d-…`) — it is the app's stable identity and what the connection provider binds `onConnectLogicFunction` to. Only the constant's name moved. Worth a second pair of eyes in review, since that is exactly the kind of thing a rename sweep regenerates by reflex. Note this changes `name` and `sourceHandlerPath`/`builtHandlerPath` in the manifest. Both are updates keyed on the unchanged identifier, not a delete-and-recreate, so installed apps re-sync cleanly — but the app needs rebuilding so the bundle path matches. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23722?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> ## Cache correctness A wrong cached id fails silently — the bot's own join reads as someone else's and the welcome never fires — so the entry is bounded and self-healing in three ways: - **Failed write drops the key.** Leaving the previous id in place would keep a superseded value authoritative. An absent cache is rebuilt from `auth.test`; a wrong one is believed. - **Entries expire after 7 days.** `registerSlackConnection` rewrites on every connect, so the expiry only matters when that write never lands. - **A kv outage falls through to `auth.test`** rather than throwing, which keeps the human joins that make up nearly all these events from failing their job.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SLACK_BOT_USER_ID_KV_KEY = 'slack-bot-user-id';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SLACK_BOT_USER_ID_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
+9
@@ -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,
|
||||
});
|
||||
-7
@@ -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,
|
||||
});
|
||||
@@ -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:<team_id> 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,
|
||||
});
|
||||
@@ -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:<team_id> key so inbound Slack events route here.',
|
||||
timeoutSeconds: 30,
|
||||
handler: slackTeamClaimHandler,
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SlackBotUserIdCacheEntry = {
|
||||
botUserId: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type SlackTeamClaimPayload = {
|
||||
export type SlackRegisterConnectionPayload = {
|
||||
connectionProviderId: string;
|
||||
connectionProviderName: string;
|
||||
connectedAccountId: string;
|
||||
+132
@@ -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();
|
||||
});
|
||||
});
|
||||
+127
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+16
@@ -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<void> => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
+2
-6
@@ -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<SlackChannelWelcome>(key);
|
||||
|
||||
if (
|
||||
existingWelcome !== null &&
|
||||
isNumber(existingWelcome.expiresAt) &&
|
||||
existingWelcome.expiresAt > Date.now()
|
||||
) {
|
||||
if (existingWelcome !== null && !hasKvEntryExpired(existingWelcome)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
|
||||
export const hasKvEntryExpired = (entry: { expiresAt?: number }): boolean =>
|
||||
!isNumber(entry.expiresAt) || entry.expiresAt <= Date.now();
|
||||
+4
-3
@@ -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<SlackThreadSubscription>(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;
|
||||
|
||||
+13
-10
@@ -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,
|
||||
|
||||
+13
-7
@@ -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<ClaimSlackTeamResult> => {
|
||||
}: RegisterSlackConnectionArgs): Promise<RegisterSlackConnectionResult> => {
|
||||
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 };
|
||||
};
|
||||
+48
@@ -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<string | undefined> => {
|
||||
const cacheEntry = await kv
|
||||
.get<SlackBotUserIdCacheEntry>(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<string> => {
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user