diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 6ee6f6445..45b5eaa18 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -317,6 +317,7 @@ as per-task auth tokens or workspace paths. | `SLACK_REDIRECT_URI` | Optional | Slack redirect URI override. | | `SLACK_AUTH_URI` | Optional | Slack auth URI override. | | `R_SLACK_SIGNING_SECRET` | Slack app/auth | Slack signing secret for webhook verification. | +| `R_SLACK_CONNECT_SUPPORT_EMAIL` | Roomote Cloud | Server-managed Slack Connect invite target for the shared support channel. | | `SLACK_API_BASE_URL` | Optional | Slack API base URL. Defaults to `https://slack.com/api/`. | | `SLACK_UNFURL_ALLOWED_DOMAINS` | Optional | Domains Slack unfurl handling may allow. | | `SLACK_API_TIMEOUT_MS` | Optional | Slack API timeout. Defaults to `API_EXTERNAL_REQUEST_TIMEOUT_MS` or 10 seconds. | diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 622eedfb6..1bb769c2b 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -111,6 +111,28 @@ After adding or changing scopes, reinstall the app from **OAuth & Permissions > Install to Workspace**. Slack does not apply new scopes to an existing installation until you reinstall. +### Roomote Cloud support channel + +Roomote Cloud deployments can create a dedicated private Slack Connect channel +with Roomote support from **Settings → Communications**. Cloud-generated Slack +apps also request these bot scopes: + +```text +groups:write +conversations.connect:write +``` + +`groups:write` lets the app create the private support channel; +`conversations.connect:write` lets it send the external invitation. Self-hosted +deployments do not request either scope. Existing Cloud Slack apps must add both +scopes and reinstall the app before the support-channel action becomes +available. + +Creating the channel sends the invitation, but Slack may still require invite +acceptance and approval from one or both organizations' admins. The support +channel is separate from the Manager Channel and does not receive manager +automations or ordinary task output automatically. + ## Events and interactivity Turn on **Event Subscriptions** and set **Request URL** to: diff --git a/apps/web/src/components/settings/CommsProviderSection.test.tsx b/apps/web/src/components/settings/CommsProviderSection.test.tsx index 13e46b8ad..09caccdd1 100644 --- a/apps/web/src/components/settings/CommsProviderSection.test.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.test.tsx @@ -201,6 +201,10 @@ vi.mock('./TelegramLinkAccountStep', () => ({ TelegramLinkAccountStep: () =>
Telegram link step
, })); +vi.mock('./SlackSupportChannelPanel', () => ({ + SlackSupportChannelPanel: () =>
Slack support channel
, +})); + vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ slack: { diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index d47fb924f..48a6fb862 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -86,6 +86,7 @@ import { import { Section } from './Section'; import { TelegramLinkAccountStep } from './TelegramLinkAccountStep'; import { DiscordSetupStatus } from './DiscordSetupStatus'; +import { SlackSupportChannelPanel } from './SlackSupportChannelPanel'; function getProviderIconId(providerId: CommsProviderId): string { return providerId === 'microsoft' ? 'teams' : providerId; @@ -599,6 +600,9 @@ export function CommsProviderSection({ />
+ {provider.id === 'slack' && hasConfiguredValues ? ( + + ) : null} {provider.id === 'telegram' && provider.telegramWebhook && (
{TELEGRAM_WEBHOOK_STATUS_COPY[provider.telegramWebhook.status] diff --git a/apps/web/src/components/settings/SlackSupportChannelPanel.test.tsx b/apps/web/src/components/settings/SlackSupportChannelPanel.test.tsx new file mode 100644 index 000000000..3e3f9e0a6 --- /dev/null +++ b/apps/web/src/components/settings/SlackSupportChannelPanel.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { SlackSupportChannelPanel } from './SlackSupportChannelPanel'; + +const state = vi.hoisted(() => ({ + status: { + eligible: true, + configured: true, + state: 'not_started' as + | 'not_started' + | 'needs_permissions' + | 'invitation_pending', + channelId: null as string | null, + channelName: null as string | null, + openUrl: null as string | null, + message: 'Create a private Slack Connect channel with Roomote support.', + }, +})); + +const mocks = vi.hoisted(() => ({ + mutate: vi.fn(), + invalidateQueries: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: state.status, isPending: false }), + useMutation: () => ({ mutate: mocks.mutate, isPending: false }), + useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + slack: { + supportChannel: { + queryOptions: () => ({}), + queryKey: () => ['slack', 'supportChannel'], + }, + createSupportChannel: { + mutationOptions: (options: unknown) => options, + }, + }, + }), +})); + +describe('SlackSupportChannelPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.status = { + eligible: true, + configured: true, + state: 'not_started', + channelId: null, + channelName: null, + openUrl: null, + message: 'Create a private Slack Connect channel with Roomote support.', + }; + }); + + it('requires confirmation before creating the external channel', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Create channel' })); + expect( + screen.getByRole('heading', { + name: 'Create a shared support channel?', + }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Create and invite' })); + expect(mocks.mutate).toHaveBeenCalledTimes(1); + }); + + it('shows the narrow permission upgrade when re-auth is required', () => { + state.status = { + ...state.status, + state: 'needs_permissions', + message: 'Update the Slack app permissions and re-authenticate.', + }; + + render(); + + expect(screen.getByText('groups:write')).toBeInTheDocument(); + expect(screen.getByText('conversations.connect:write')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Create channel' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/settings/SlackSupportChannelPanel.tsx b/apps/web/src/components/settings/SlackSupportChannelPanel.tsx new file mode 100644 index 000000000..efa6e5206 --- /dev/null +++ b/apps/web/src/components/settings/SlackSupportChannelPanel.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + ExternalLink, + MessagesSquare, + Spinner, +} from '@/components/system'; + +const STATE_LABELS = { + unavailable: 'Unavailable', + not_connected: 'Connect Slack', + needs_permissions: 'Permissions needed', + not_started: 'Not started', + invitation_pending: 'Invitation pending', + connected: 'Connected', + action_needed: 'Action needed', +} as const; + +export function SlackSupportChannelPanel() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); + const statusQuery = useQuery(trpc.slack.supportChannel.queryOptions()); + const createChannel = useMutation( + trpc.slack.createSupportChannel.mutationOptions({ + onSuccess: async (result) => { + setConfirmOpen(false); + await queryClient.invalidateQueries({ + queryKey: trpc.slack.supportChannel.queryKey(), + }); + if (result.state === 'invitation_pending') { + toast.success('Slack Connect invitation sent.'); + } else if (result.state === 'connected') { + toast.success('Shared support channel is connected.'); + } else { + toast.error(result.message); + } + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (statusQuery.isPending || !statusQuery.data?.eligible) { + return null; + } + + const status = statusQuery.data; + const canCreate = + status.configured && + (status.state === 'not_started' || status.state === 'action_needed'); + const badgeVariant = + status.state === 'connected' + ? 'success' + : status.state === 'invitation_pending' + ? 'warning' + : status.state === 'action_needed' + ? 'destructive' + : 'secondary'; + + return ( +
+
+
+ +
+
+

Shared support channel

+ {STATE_LABELS[status.state]} +
+

{status.message}

+ {status.channelName ? ( +

+ #{status.channelName} +

+ ) : null} +
+
+
+ {status.openUrl ? ( + + ) : null} + {canCreate ? ( + + ) : null} +
+
+ + {status.state === 'needs_permissions' ? ( +

+ Add groups:write and{' '} + conversations.connect:write to the Slack app, then use + Re-auth above. +

+ ) : null} + + + + + Create a shared support channel? + + Roomote will create a private channel and invite Roomote support + through Slack Connect. People outside your organization will be + able to read messages posted there, and both organizations may + apply their own retention policies. + + + + + + + + +
+ ); +} diff --git a/apps/web/src/lib/slack-app-manifest.client.test.ts b/apps/web/src/lib/slack-app-manifest.client.test.ts index be18f9281..6a7795083 100644 --- a/apps/web/src/lib/slack-app-manifest.client.test.ts +++ b/apps/web/src/lib/slack-app-manifest.client.test.ts @@ -5,6 +5,7 @@ import { SLACK_MANIFEST_BOT_EVENTS, SLACK_MANIFEST_BOT_SCOPES, SLACK_MANIFEST_DESCRIPTION, + SLACK_SUPPORT_CHANNEL_BOT_SCOPES, } from './slack-app-manifest'; function relativeLuminance(hex: string): number { @@ -131,6 +132,26 @@ describe('Slack app manifest builder', () => { ); }); + it('adds support-channel scopes only when enabled', () => { + const standardManifest = buildSlackAppManifest({ + publicOrigin: 'https://roomote.example.com', + }); + const cloudManifest = buildSlackAppManifest({ + publicOrigin: 'https://roomote.example.com', + supportChannelEnabled: true, + }); + + expect(standardManifest.oauth_config.scopes.bot).not.toEqual( + expect.arrayContaining([...SLACK_SUPPORT_CHANNEL_BOT_SCOPES]), + ); + expect(cloudManifest.oauth_config.scopes.bot).toEqual( + expect.arrayContaining([...SLACK_SUPPORT_CHANNEL_BOT_SCOPES]), + ); + expect(cloudManifest.oauth_config.scopes.bot).not.toContain( + 'channels:manage', + ); + }); + it('builds Slack manifest-prefill URLs with the manifest encoded', () => { const url = new URL( buildSlackManifestPrefillUrl({ diff --git a/apps/web/src/lib/slack-app-manifest.ts b/apps/web/src/lib/slack-app-manifest.ts index 6eea48f08..7c2a3605f 100644 --- a/apps/web/src/lib/slack-app-manifest.ts +++ b/apps/web/src/lib/slack-app-manifest.ts @@ -24,6 +24,17 @@ export const SLACK_MANIFEST_BOT_SCOPES = [ 'users:read', ] as const; +export const SLACK_SUPPORT_CHANNEL_BOT_SCOPES = [ + 'groups:write', + 'conversations.connect:write', +] as const; + +export function getSlackManifestBotScopes(supportChannelEnabled = false) { + return supportChannelEnabled + ? [...SLACK_MANIFEST_BOT_SCOPES, ...SLACK_SUPPORT_CHANNEL_BOT_SCOPES] + : [...SLACK_MANIFEST_BOT_SCOPES]; +} + export const SLACK_MANIFEST_BOT_EVENTS = [ 'app_mention', 'entity_details_requested', @@ -43,11 +54,13 @@ export const SLACK_MANIFEST_BACKGROUND_COLOR = '#000000'; type SlackAppManifestInput = { publicOrigin: string; appName?: string; + supportChannelEnabled?: boolean; }; export function buildSlackAppManifest({ publicOrigin, appName = 'Roomote', + supportChannelEnabled = false, }: SlackAppManifestInput) { const origin = publicOrigin.replace(/\/+$/, ''); const webhookUrl = `${origin}/api/webhooks/slack`; @@ -75,7 +88,7 @@ export function buildSlackAppManifest({ `${origin}${SLACK_APP_INSTALL_CALLBACK_PATH}`, ], scopes: { - bot: [...SLACK_MANIFEST_BOT_SCOPES], + bot: getSlackManifestBotScopes(supportChannelEnabled), }, pkce_enabled: false, }, diff --git a/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts b/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts index 04ceafef7..aff941199 100644 --- a/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts +++ b/apps/web/src/trpc/commands/slack/create-app-from-manifest.test.ts @@ -3,11 +3,13 @@ import type { FeatureFlag } from '@roomote/feature-flags'; import type { UserAuthSuccess } from '@/types'; const { + cloudState, mockDbTransaction, mockFetch, mockReadFile, mockUpsertDeploymentEnvironmentVariables, } = vi.hoisted(() => ({ + cloudState: { enabled: false }, mockDbTransaction: vi.fn(), mockFetch: vi.fn(), mockReadFile: vi.fn(), @@ -31,6 +33,10 @@ vi.mock('@/lib/server', () => ({ }, })); +vi.mock('@/lib/server/env', () => ({ + isRoomoteCloudEnabled: () => cloudState.enabled, +})); + vi.mock('../environment-variables', () => ({ upsertDeploymentEnvironmentVariables: mockUpsertDeploymentEnvironmentVariables, @@ -94,6 +100,7 @@ function mockSuccessfulCreateResponse() { describe('createSlackAppFromManifestCommand', () => { beforeEach(() => { vi.clearAllMocks(); + cloudState.enabled = false; mockDbTransaction.mockImplementation(async (callback) => callback({ kind: 'tx' }), ); @@ -193,6 +200,35 @@ describe('createSlackAppFromManifestCommand', () => { ); }); + it('adds support-channel scopes to Cloud-created Slack apps', async () => { + cloudState.enabled = true; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockSuccessfulCreateResponse(), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + }); + + await createSlackAppFromManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-token', + }); + + const createInit = mockFetch.mock.calls[0]?.[1] as RequestInit; + const body = JSON.parse(String(createInit.body)) as { manifest: string }; + const manifest = JSON.parse(body.manifest) as { + oauth_config: { scopes: { bot: string[] } }; + }; + expect(manifest.oauth_config.scopes.bot).toEqual( + expect.arrayContaining(['groups:write', 'conversations.connect:write']), + ); + expect(manifest.oauth_config.scopes.bot).not.toContain('channels:manage'); + }); + it('still succeeds when the app icon cannot be set', async () => { mockFetch .mockResolvedValueOnce({ diff --git a/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts b/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts index c022b41f4..e7246a360 100644 --- a/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts +++ b/apps/web/src/trpc/commands/slack/create-app-from-manifest.ts @@ -6,6 +6,7 @@ import { db } from '@roomote/db/server'; import type { UserAuthSuccess } from '@/types'; import { Env } from '@/lib/server'; +import { isRoomoteCloudEnabled } from '@/lib/server/env'; import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; import { buildSlackAppManifest } from '@/lib/slack-app-manifest'; import { upsertDeploymentEnvironmentVariables } from '../environment-variables'; @@ -212,7 +213,10 @@ export async function createSlackAppFromManifest({ }; } - const manifest = buildSlackAppManifest({ publicOrigin }); + const manifest = buildSlackAppManifest({ + publicOrigin, + supportChannelEnabled: isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), + }); const response = await fetch(buildSlackApiUrl('apps.manifest.create'), { method: 'POST', diff --git a/apps/web/src/trpc/commands/slack/index.ts b/apps/web/src/trpc/commands/slack/index.ts index e4c9a62cb..3261a7473 100644 --- a/apps/web/src/trpc/commands/slack/index.ts +++ b/apps/web/src/trpc/commands/slack/index.ts @@ -27,6 +27,8 @@ import { import type { UserAuthSuccess } from '@/types'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; import { getSlackRedirectUri } from '@/lib/server/slack-redirect-uri'; +import { Env, isRoomoteCloudEnabled } from '@/lib/server/env'; +import { getSlackManifestBotScopes } from '@/lib/slack-app-manifest'; import { syncUser } from '@/lib/server/sync-internal'; import { createSignedSlackInstallState, @@ -35,6 +37,10 @@ import { } from '@/lib/server/slack-oauth-state'; export { createSlackAppFromManifestCommand } from './create-app-from-manifest'; +export { + createSlackSupportChannelCommand, + getSlackSupportChannelStatusCommand, +} from './support-channel'; interface SlackOAuthResponse { ok: boolean; @@ -546,26 +552,9 @@ export async function connectSlackAppCommand( } const slackOAuthConfig = await resolveSlackOAuthConfig(); - const permissions = [ - 'app_mentions:read', - 'channels:read', - 'channels:history', - 'chat:write', - 'files:read', - 'groups:read', - 'groups:history', - 'im:read', - 'im:history', - 'im:write', - 'links:read', - 'links:write', - 'mpim:read', - 'mpim:history', - 'reactions:read', - 'reactions:write', - 'team:read', - 'users:read', - ]; + const permissions = getSlackManifestBotScopes( + isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), + ); const redirectPath = input.redirectPath ?? '/settings'; const state = await createSignedSlackInstallState({ redirectPath }); diff --git a/apps/web/src/trpc/commands/slack/support-channel.test.ts b/apps/web/src/trpc/commands/slack/support-channel.test.ts new file mode 100644 index 000000000..e2dcb1653 --- /dev/null +++ b/apps/web/src/trpc/commands/slack/support-channel.test.ts @@ -0,0 +1,294 @@ +import type { FeatureFlag } from '@roomote/feature-flags'; + +import type { UserAuthSuccess } from '@/types'; + +const state = vi.hoisted(() => ({ + cloudEnabled: true, + supportEmail: 'support@roomote.example', + installation: null as null | { + teamId: string; + botAccessToken: string; + scopes: { bot: string[] }; + }, + metadata: {} as Record, +})); + +const mocks = vi.hoisted(() => ({ + createPrivateChannel: vi.fn(), + inviteSharedChannel: vi.fn(), + getSlackConnectChannelStatus: vi.fn(), + resolveChannelId: vi.fn(), + insert: vi.fn(), + updateMetadata: vi.fn(), + releaseLock: vi.fn(), + renewLock: vi.fn(), +})); + +vi.mock('@roomote/slack', () => ({ + SlackNotifier: vi.fn().mockImplementation(function () { + return { + createPrivateChannel: mocks.createPrivateChannel, + inviteSharedChannel: mocks.inviteSharedChannel, + getSlackConnectChannelStatus: mocks.getSlackConnectChannelStatus, + resolveChannelId: mocks.resolveChannelId, + }; + }), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + slackInstallations: { + findFirst: vi.fn(() => Promise.resolve(state.installation)), + }, + deploymentSettings: { + findFirst: vi.fn(() => Promise.resolve({ metadata: state.metadata })), + }, + }, + insert: mocks.insert, + }, + deploymentSettings: { id: 'id', metadata: 'metadata' }, + slackInstallations: { isActive: 'isActive', updatedAt: 'updatedAt' }, + desc: vi.fn((value) => value), + eq: vi.fn(() => ({})), + sql: vi.fn(), +})); + +vi.mock('@/lib/server/env', () => ({ + Env: { + get R_CLOUD_ENABLED() { + return state.cloudEnabled; + }, + get R_SLACK_CONNECT_SUPPORT_EMAIL() { + return state.supportEmail; + }, + }, + isRoomoteCloudEnabled: () => state.cloudEnabled, +})); + +vi.mock('@/lib/slack-app-manifest', () => ({ + SLACK_SUPPORT_CHANNEL_BOT_SCOPES: [ + 'groups:write', + 'conversations.connect:write', + ], +})); + +vi.mock('@roomote/redis', () => ({ + REDIS_KEYS: { SLACK_SUPPORT_CHANNEL_CREATE: 'slack:support-channel:create' }, + acquireRedisLock: vi.fn(async () => + Object.assign(mocks.releaseLock, { renew: mocks.renewLock }), + ), +})); + +import { + createSlackSupportChannelCommand, + getSlackSupportChannelStatusCommand, +} from './support-channel'; + +function buildAuth(overrides: Partial = {}): UserAuthSuccess { + return { + success: true, + userType: 'user', + userId: 'admin-1', + isAdmin: true, + name: 'Admin', + primaryEmail: 'admin@example.com', + featureFlags: {} as Record, + resource: {}, + ...overrides, + } as UserAuthSuccess; +} + +describe('Slack support channel commands', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.cloudEnabled = true; + state.supportEmail = 'support@roomote.example'; + state.metadata = {}; + state.installation = { + teamId: 'T123456789', + botAccessToken: 'xoxb-test', + scopes: { + bot: ['groups:write', 'conversations.connect:write'], + }, + }; + mocks.insert.mockImplementation(() => ({ + values: () => ({ + onConflictDoUpdate: mocks.updateMetadata.mockResolvedValue(undefined), + }), + })); + mocks.resolveChannelId.mockResolvedValue(null); + mocks.releaseLock.mockResolvedValue(undefined); + mocks.renewLock.mockResolvedValue(true); + }); + + it('stays unavailable outside Roomote Cloud', async () => { + state.cloudEnabled = false; + + await expect( + getSlackSupportChannelStatusCommand(buildAuth()), + ).resolves.toMatchObject({ eligible: false, state: 'unavailable' }); + }); + + it('requires both narrow Slack scopes', async () => { + state.installation!.scopes.bot = ['groups:write']; + + await expect( + getSlackSupportChannelStatusCommand(buildAuth()), + ).resolves.toMatchObject({ eligible: true, state: 'needs_permissions' }); + expect(mocks.createPrivateChannel).not.toHaveBeenCalled(); + }); + + it('creates, persists, and invites a dedicated private channel', async () => { + mocks.createPrivateChannel.mockResolvedValue({ + success: true, + data: { id: 'C123SUPPORT', name: 'roomote-support' }, + }); + mocks.getSlackConnectChannelStatus.mockResolvedValue({ + success: true, + data: 'not_shared', + }); + mocks.inviteSharedChannel.mockResolvedValue({ + success: true, + data: { inviteId: 'I123' }, + }); + + const result = await createSlackSupportChannelCommand(buildAuth()); + + expect(mocks.createPrivateChannel).toHaveBeenCalledWith( + 'roomote-support-456789', + ); + expect(mocks.inviteSharedChannel).toHaveBeenCalledWith({ + channelId: 'C123SUPPORT', + email: 'support@roomote.example', + }); + expect(mocks.insert).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + state: 'invitation_pending', + channelId: 'C123SUPPORT', + channelName: 'roomote-support-456789', + }); + expect(mocks.releaseLock).toHaveBeenCalledTimes(1); + }); + + it('resumes a pending invitation without creating a duplicate channel', async () => { + state.metadata = { + slackSupportChannel: { + teamId: 'T123456789', + channelId: 'C123SUPPORT', + channelName: 'roomote-support', + inviteId: 'I123', + createdAt: '2026-08-02T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + }; + mocks.getSlackConnectChannelStatus.mockResolvedValue({ + success: true, + data: 'pending', + }); + + const result = await createSlackSupportChannelCommand(buildAuth()); + + expect(mocks.createPrivateChannel).not.toHaveBeenCalled(); + expect(mocks.inviteSharedChannel).not.toHaveBeenCalled(); + expect(result.state).toBe('invitation_pending'); + }); + + it('recreates a support channel that was deleted from Slack', async () => { + state.metadata = { + slackSupportChannel: { + teamId: 'T123456789', + channelId: 'C123DELETED', + channelName: 'roomote-support', + createdAt: '2026-08-02T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + }; + mocks.getSlackConnectChannelStatus.mockResolvedValue({ + success: true, + data: 'not_found', + }); + mocks.createPrivateChannel.mockResolvedValue({ + success: true, + data: { id: 'C123REPLACEMENT', name: 'roomote-support-456789' }, + }); + mocks.inviteSharedChannel.mockResolvedValue({ + success: true, + data: { inviteId: 'I456' }, + }); + + const result = await createSlackSupportChannelCommand(buildAuth()); + + expect(mocks.createPrivateChannel).toHaveBeenCalled(); + expect(mocks.inviteSharedChannel).toHaveBeenCalledWith({ + channelId: 'C123REPLACEMENT', + email: 'support@roomote.example', + }); + expect(result.channelId).toBe('C123REPLACEMENT'); + }); + + it('does not resend an invitation when channel status is unknown', async () => { + state.metadata = { + slackSupportChannel: { + teamId: 'T123456789', + channelId: 'C123SUPPORT', + channelName: 'roomote-support-456789', + inviteSentAt: '2026-08-02T00:00:00.000Z', + createdAt: '2026-08-02T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + }; + mocks.getSlackConnectChannelStatus.mockResolvedValue({ + success: false, + error: 'request_timeout', + }); + + const result = await createSlackSupportChannelCommand(buildAuth()); + + expect(result.state).toBe('action_needed'); + expect(mocks.inviteSharedChannel).not.toHaveBeenCalled(); + }); + + it('recovers the deterministic channel after a concurrent create', async () => { + mocks.resolveChannelId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('C123RECOVERED'); + mocks.createPrivateChannel.mockResolvedValue({ + success: false, + error: 'name_taken', + }); + mocks.inviteSharedChannel.mockResolvedValue({ + success: true, + data: { inviteId: null }, + }); + + const result = await createSlackSupportChannelCommand(buildAuth()); + + expect(mocks.createPrivateChannel).toHaveBeenCalledTimes(1); + expect(mocks.inviteSharedChannel).toHaveBeenCalledWith({ + channelId: 'C123RECOVERED', + email: 'support@roomote.example', + }); + expect(result).toMatchObject({ + state: 'invitation_pending', + channelId: 'C123RECOVERED', + }); + }); + + it('stops before inviting when the renewable lock is lost', async () => { + mocks.createPrivateChannel.mockResolvedValue({ + success: true, + data: { id: 'C123SUPPORT', name: 'roomote-support-456789' }, + }); + mocks.renewLock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + const result = await createSlackSupportChannelCommand(buildAuth()); + + expect(result).toMatchObject({ + state: 'action_needed', + message: 'Support channel setup lost its lock. Try again.', + }); + expect(mocks.inviteSharedChannel).not.toHaveBeenCalled(); + expect(mocks.releaseLock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/trpc/commands/slack/support-channel.ts b/apps/web/src/trpc/commands/slack/support-channel.ts new file mode 100644 index 000000000..95bfc3068 --- /dev/null +++ b/apps/web/src/trpc/commands/slack/support-channel.ts @@ -0,0 +1,475 @@ +import { SlackNotifier } from '@roomote/slack'; +import { + db, + deploymentSettings, + desc, + eq, + slackInstallations, + sql, +} from '@roomote/db/server'; +import { acquireRedisLock, REDIS_KEYS } from '@roomote/redis'; + +import type { UserAuthSuccess } from '@/types'; +import { Env, isRoomoteCloudEnabled } from '@/lib/server/env'; +import { SLACK_SUPPORT_CHANNEL_BOT_SCOPES } from '@/lib/slack-app-manifest'; + +const SUPPORT_CHANNEL_METADATA_KEY = 'slackSupportChannel'; + +type SupportChannelRecord = { + teamId: string; + channelId: string; + channelName: string; + inviteId?: string; + inviteSentAt?: string; + lastError?: string; + createdAt: string; + updatedAt: string; +}; + +type SlackSupportChannelStatus = { + eligible: boolean; + configured: boolean; + state: + | 'unavailable' + | 'not_connected' + | 'needs_permissions' + | 'not_started' + | 'invitation_pending' + | 'connected' + | 'action_needed'; + channelId: string | null; + channelName: string | null; + openUrl: string | null; + message: string; +}; + +function readSupportChannelRecord( + metadata: unknown, +): SupportChannelRecord | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return null; + } + const value = (metadata as Record)[ + SUPPORT_CHANNEL_METADATA_KEY + ]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const record = value as Record; + if ( + typeof record.channelId !== 'string' || + typeof record.teamId !== 'string' || + typeof record.channelName !== 'string' || + typeof record.createdAt !== 'string' || + typeof record.updatedAt !== 'string' + ) { + return null; + } + + return { + teamId: record.teamId, + channelId: record.channelId, + channelName: record.channelName, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...(typeof record.inviteId === 'string' + ? { inviteId: record.inviteId } + : {}), + ...(typeof record.inviteSentAt === 'string' + ? { inviteSentAt: record.inviteSentAt } + : {}), + ...(typeof record.lastError === 'string' + ? { lastError: record.lastError } + : {}), + }; +} + +async function getSupportChannelRecord(): Promise { + const settings = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { metadata: true }, + }); + return readSupportChannelRecord(settings?.metadata); +} + +async function saveSupportChannelRecord(record: SupportChannelRecord) { + const metadata = { [SUPPORT_CHANNEL_METADATA_KEY]: record }; + await db + .insert(deploymentSettings) + .values({ id: 'default', metadata }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { + metadata: sql`${deploymentSettings.metadata} || ${JSON.stringify(metadata)}::jsonb`, + updatedAt: new Date(), + }, + }); +} + +function getBotScopes(scopes: unknown): Set { + if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) { + return new Set(); + } + const botScopes = (scopes as { bot?: unknown }).bot; + return new Set( + Array.isArray(botScopes) + ? botScopes.filter((scope): scope is string => typeof scope === 'string') + : [], + ); +} + +function hasSupportChannelScopes(scopes: unknown) { + const granted = getBotScopes(scopes); + return SLACK_SUPPORT_CHANNEL_BOT_SCOPES.every((scope) => granted.has(scope)); +} + +function mapSlackError( + error: string, +): Pick { + switch (error) { + case 'missing_scope': + return { + state: 'needs_permissions', + message: 'Update the Slack app permissions and re-authenticate.', + }; + case 'not_paid': + case 'not_allowed_for_grid_workspace': + return { + state: 'action_needed', + message: 'This Slack workspace does not support Slack Connect.', + }; + case 'restricted_action': + case 'no_external_invite_permission': + case 'no_permission': + return { + state: 'action_needed', + message: 'A Slack admin must allow external channel invitations.', + }; + default: + return { + state: 'action_needed', + message: 'Slack could not finish the invitation. Try again.', + }; + } +} + +function withChannel( + record: SupportChannelRecord | null, + status: Omit< + SlackSupportChannelStatus, + 'channelId' | 'channelName' | 'openUrl' + >, +): SlackSupportChannelStatus { + return { + ...status, + channelId: record?.channelId ?? null, + channelName: record?.channelName ?? null, + openUrl: record?.channelId + ? `https://slack.com/app_redirect?channel=${encodeURIComponent(record.channelId)}` + : null, + }; +} + +async function getActiveSlackInstallation() { + return db.query.slackInstallations.findFirst({ + where: eq(slackInstallations.isActive, true), + orderBy: [desc(slackInstallations.updatedAt)], + }); +} + +export async function getSlackSupportChannelStatusCommand( + auth: UserAuthSuccess, +): Promise { + if (!auth.isAdmin || !isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED)) { + return withChannel(null, { + eligible: false, + configured: false, + state: 'unavailable', + message: 'Shared support channels are available on Roomote Cloud.', + }); + } + + const supportEmail = Env.R_SLACK_CONNECT_SUPPORT_EMAIL?.trim(); + if (!supportEmail) { + return withChannel(null, { + eligible: true, + configured: false, + state: 'unavailable', + message: 'Shared support channel setup is not configured.', + }); + } + + const installation = await getActiveSlackInstallation(); + if (!installation) { + return withChannel(null, { + eligible: true, + configured: true, + state: 'not_connected', + message: 'Connect a Slack workspace first.', + }); + } + if (!hasSupportChannelScopes(installation.scopes)) { + return withChannel(null, { + eligible: true, + configured: true, + state: 'needs_permissions', + message: 'Update the Slack app permissions and re-authenticate.', + }); + } + + const record = await getSupportChannelRecord(); + if (!record || record.teamId !== installation.teamId) { + return withChannel(null, { + eligible: true, + configured: true, + state: 'not_started', + message: 'Create a private Slack Connect channel with Roomote support.', + }); + } + + const slack = new SlackNotifier(installation.botAccessToken); + const connectStatus = await slack.getSlackConnectChannelStatus( + record.channelId, + ); + if (!connectStatus.success) { + const mapped = mapSlackError(connectStatus.error); + return withChannel(record, { + eligible: true, + configured: true, + ...mapped, + }); + } + if (connectStatus.data === 'connected') { + return withChannel(record, { + eligible: true, + configured: true, + state: 'connected', + message: 'Connected with Roomote support.', + }); + } + if (connectStatus.data === 'pending') { + return withChannel(record, { + eligible: true, + configured: true, + state: 'invitation_pending', + message: 'Waiting for invite acceptance or Slack admin approval.', + }); + } + + return withChannel(record, { + eligible: true, + configured: true, + state: 'action_needed', + message: + connectStatus.data === 'not_found' + ? 'The support channel no longer exists. Contact Roomote support.' + : record.inviteSentAt + ? 'The previous Slack Connect invitation is no longer pending. Send it again.' + : record.lastError + ? mapSlackError(record.lastError).message + : 'The Slack Connect invitation needs to be sent again.', + }); +} + +async function createSlackSupportChannel( + supportEmail: string, + ensureLock: () => Promise, +): Promise { + const installation = await getActiveSlackInstallation(); + if (!installation) { + throw new Error('Connect a Slack workspace first.'); + } + if (!hasSupportChannelScopes(installation.scopes)) { + return withChannel(null, { + eligible: true, + configured: true, + state: 'needs_permissions', + message: 'Update the Slack app permissions and re-authenticate.', + }); + } + + const slack = new SlackNotifier(installation.botAccessToken); + let record = await getSupportChannelRecord(); + if (record?.teamId !== installation.teamId) { + record = null; + } + + if (record) { + const currentStatus = await slack.getSlackConnectChannelStatus( + record.channelId, + ); + if (currentStatus.success && currentStatus.data === 'connected') { + return withChannel(record, { + eligible: true, + configured: true, + state: 'connected', + message: 'Connected with Roomote support.', + }); + } + if (currentStatus.success && currentStatus.data === 'pending') { + return withChannel(record, { + eligible: true, + configured: true, + state: 'invitation_pending', + message: 'Waiting for invite acceptance or Slack admin approval.', + }); + } + if (currentStatus.success && currentStatus.data === 'not_found') { + record = null; + } + if (!currentStatus.success) { + const mapped = mapSlackError(currentStatus.error); + return withChannel(record, { + eligible: true, + configured: true, + ...mapped, + }); + } + } + + if (!record) { + const channelName = `roomote-support-${installation.teamId + .slice(-6) + .toLowerCase()}`; + let channelId = await slack.resolveChannelId(`#${channelName}`); + if (!channelId) { + if (!(await ensureLock())) { + return withChannel(null, { + eligible: true, + configured: true, + state: 'action_needed', + message: 'Support channel setup lost its lock. Try again.', + }); + } + const created = await slack.createPrivateChannel(channelName); + if (!created.success && created.error === 'name_taken') { + channelId = await slack.resolveChannelId(`#${channelName}`); + } else if (!created.success) { + const mapped = mapSlackError(created.error); + return withChannel(null, { + eligible: true, + configured: true, + ...mapped, + }); + } else { + channelId = created.data.id; + } + } + if (!channelId) { + return withChannel(null, { + eligible: true, + configured: true, + state: 'action_needed', + message: + 'Slack created the channel, but Roomote could not recover it. Try again shortly.', + }); + } + + const now = new Date().toISOString(); + record = { + teamId: installation.teamId, + channelId, + channelName, + createdAt: now, + updatedAt: now, + }; + await saveSupportChannelRecord(record); + } + + if (!(await ensureLock())) { + return withChannel(record, { + eligible: true, + configured: true, + state: 'action_needed', + message: 'Support channel setup lost its lock. Try again.', + }); + } + const invited = await slack.inviteSharedChannel({ + channelId: record.channelId, + email: supportEmail, + }); + if (!invited.success) { + if (invited.error === 'connection_limit_exceeded_pending') { + return withChannel(record, { + eligible: true, + configured: true, + state: 'invitation_pending', + message: 'Waiting for invite acceptance or Slack admin approval.', + }); + } + const updatedRecord = { + ...record, + lastError: invited.error, + updatedAt: new Date().toISOString(), + }; + await saveSupportChannelRecord(updatedRecord); + const mapped = mapSlackError(invited.error); + return withChannel(updatedRecord, { + eligible: true, + configured: true, + ...mapped, + }); + } + + const updatedRecord = { + ...record, + ...(invited.data.inviteId ? { inviteId: invited.data.inviteId } : {}), + inviteSentAt: new Date().toISOString(), + lastError: undefined, + updatedAt: new Date().toISOString(), + }; + await saveSupportChannelRecord(updatedRecord); + return withChannel(updatedRecord, { + eligible: true, + configured: true, + state: 'invitation_pending', + message: 'Waiting for invite acceptance or Slack admin approval.', + }); +} + +export async function createSlackSupportChannelCommand( + auth: UserAuthSuccess, +): Promise { + if (!auth.isAdmin) { + throw new Error('Unauthorized'); + } + if (!isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED)) { + throw new Error('Shared support channels are available on Roomote Cloud.'); + } + const supportEmail = Env.R_SLACK_CONNECT_SUPPORT_EMAIL?.trim(); + if (!supportEmail) { + throw new Error('Shared support channel setup is not configured.'); + } + + const lock = await acquireRedisLock(REDIS_KEYS.SLACK_SUPPORT_CHANNEL_CREATE, { + ttlSeconds: 30, + }); + if (!lock) { + return withChannel(await getSupportChannelRecord(), { + eligible: true, + configured: true, + state: 'action_needed', + message: 'Support channel setup is already in progress. Refresh shortly.', + }); + } + + let lockLost = false; + const renewTimer = setInterval(() => { + void lock.renew().then((renewed) => { + if (!renewed) lockLost = true; + }); + }, 10_000); + renewTimer.unref?.(); + + try { + return await createSlackSupportChannel(supportEmail, async () => { + if (lockLost) return false; + const renewed = await lock.renew(); + if (!renewed) lockLost = true; + return renewed; + }); + } finally { + clearInterval(renewTimer); + await lock(); + } +} diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ed79a5ce9..0646a98f0 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -110,6 +110,8 @@ import { startAuthenticateSlackAccountCommand, finishAuthenticateSlackAccountCommand, completePendingSlackAuthenticationCommand, + createSlackSupportChannelCommand, + getSlackSupportChannelStatusCommand, } from '../commands/slack'; import { getLinearInstallationCommand, @@ -1149,6 +1151,14 @@ export const appRouter = createRouter({ getSlackInstallationCommand(auth), ), + supportChannel: protectedProcedure.query(({ ctx: { auth } }) => + getSlackSupportChannelStatusCommand(auth), + ), + + createSupportChannel: protectedProcedure.mutation(({ ctx: { auth } }) => + createSlackSupportChannelCommand(auth), + ), + connectApp: protectedProcedure .input(z.object({ redirectPath: z.string().optional() }).optional()) .mutation(({ ctx: { auth }, input }) => diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 8e9519db8..98e47b26c 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -171,6 +171,7 @@ const serverSchema = { SLACK_REDIRECT_URI: emptyStringDefault(), SLACK_AUTH_URI: emptyStringDefault(), R_SLACK_SIGNING_SECRET: z.string().min(1).optional(), + R_SLACK_CONNECT_SUPPORT_EMAIL: z.string().email().optional(), SLACK_API_BASE_URL: z.string().url().default('https://slack.com/api/'), SLACK_UNFURL_ALLOWED_DOMAINS: z.string().optional(), ROUTER_DEBUG_CHANNEL_ID: z.string().optional(), @@ -448,6 +449,7 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_SLACK_CLIENT_ID', 'R_SLACK_CLIENT_SECRET', 'R_SLACK_SIGNING_SECRET', + 'R_SLACK_CONNECT_SUPPORT_EMAIL', 'R_MICROSOFT_CLIENT_ID', 'R_MICROSOFT_CLIENT_SECRET', 'R_MICROSOFT_TENANT_ID', diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index b0a0433a9..42c23a9e0 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -8,6 +8,7 @@ export const REDIS_KEYS = { MENTIONED_THREADS: 'slack:mentioned_threads', PENDING_WORKSPACE_SELECTIONS: 'slack:pending_workspace_selections', SLACK_AUTO_START_CHANNEL: 'slack:auto-start-channel', + SLACK_SUPPORT_CHANNEL_CREATE: 'slack:support-channel:create', DISCORD_AUTO_START_CHANNEL: 'discord:auto-start-channel', CONTROLLER_HEARTBEAT: 'controller:heartbeat', /** Cached GitHub release notes payload keyed as `${prefix}:${version}`. */ diff --git a/packages/slack/src/__tests__/mock-slack-server.test.ts b/packages/slack/src/__tests__/mock-slack-server.test.ts index 93d749576..11309681b 100644 --- a/packages/slack/src/__tests__/mock-slack-server.test.ts +++ b/packages/slack/src/__tests__/mock-slack-server.test.ts @@ -749,4 +749,61 @@ describe('MockSlackServer', () => { await server.stop(); } }); + + it('creates a private channel and records its Slack Connect invitation', async () => { + const server = new MockSlackServer({ + state: { + team: { id: 'T1', domain: 'mock-roomote' }, + acceptedBotTokens: ['xoxb-mock-token'], + channels: [], + users: [], + }, + }); + + try { + await server.start(); + const createResponse = await fetch( + `${server.baseUrl}/api/conversations.create`, + { + method: 'POST', + headers: { + authorization: 'Bearer xoxb-mock-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ name: 'roomote-support', is_private: true }), + }, + ); + const created = (await createResponse.json()) as { + channel: { id: string }; + }; + + const inviteResponse = await fetch( + `${server.baseUrl}/api/conversations.inviteShared`, + { + method: 'POST', + headers: { + authorization: 'Bearer xoxb-mock-token', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + channel: created.channel.id, + emails: ['support@roomote.example'], + external_limited: false, + }), + }, + ); + + await expect(inviteResponse.json()).resolves.toMatchObject({ ok: true }); + expect(server.getState().channels).toEqual([ + expect.objectContaining({ + id: created.channel.id, + name: 'roomote-support', + type: 'private_channel', + isPendingExtShared: true, + }), + ]); + } finally { + await server.stop(); + } + }); }); diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index 780b0d3cf..273722d6c 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -2876,4 +2876,100 @@ describe('SlackNotifier', () => { expect('metadata' in args).toBe(false); }); }); + + describe('Slack Connect support channels', () => { + it('creates a private channel with the bot token', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + channel: { id: 'C123SUPPORT', name: 'roomote-support' }, + }), + }); + + await expect( + notifier.createPrivateChannel('roomote-support'), + ).resolves.toEqual({ + success: true, + data: { id: 'C123SUPPORT', name: 'roomote-support' }, + }); + expect(getGlobalWithFetch().fetch).toHaveBeenCalledWith( + 'https://slack.com/api/conversations.create', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'roomote-support', is_private: true }), + }), + ); + }); + + it('sends a full-member Slack Connect invitation', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true, invite_id: 'I123' }), + }); + + await expect( + notifier.inviteSharedChannel({ + channelId: 'C123SUPPORT', + email: 'support@example.com', + }), + ).resolves.toEqual({ success: true, data: { inviteId: 'I123' } }); + expect(getGlobalWithFetch().fetch).toHaveBeenCalledWith( + 'https://slack.com/api/conversations.inviteShared', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + channel: 'C123SUPPORT', + emails: ['support@example.com'], + external_limited: false, + }), + }), + ); + }); + + it('accepts a successful invitation response without an invite id', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + }); + + await expect( + notifier.inviteSharedChannel({ + channelId: 'C123SUPPORT', + email: 'support@example.com', + }), + ).resolves.toEqual({ success: true, data: { inviteId: null } }); + }); + + it('distinguishes pending and connected channels', async () => { + getGlobalWithFetch().fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + channel: { is_pending_ext_shared: true }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + channel: { is_ext_shared: true }, + }), + }); + + await expect( + notifier.getSlackConnectChannelStatus('C123SUPPORT'), + ).resolves.toEqual({ success: true, data: 'pending' }); + await expect( + notifier.getSlackConnectChannelStatus('C123SUPPORT'), + ).resolves.toEqual({ success: true, data: 'connected' }); + }); + }); }); diff --git a/packages/slack/src/mock-slack-server.ts b/packages/slack/src/mock-slack-server.ts index 777be5e23..975724634 100644 --- a/packages/slack/src/mock-slack-server.ts +++ b/packages/slack/src/mock-slack-server.ts @@ -48,6 +48,9 @@ export type MockSlackChannel = { type?: 'public_channel' | 'private_channel' | 'im'; isMember?: boolean; members?: string[]; + isExtShared?: boolean; + isPendingExtShared?: boolean; + pendingShared?: string[]; }; export type MockSlackTeam = { @@ -592,11 +595,66 @@ export class MockSlackServer { id: channel.id, name: channel.name, is_member: channel.isMember ?? true, + is_private: channel.type === 'private_channel', + is_ext_shared: channel.isExtShared ?? false, + is_pending_ext_shared: channel.isPendingExtShared ?? false, + pending_shared: channel.pendingShared ?? [], }, }); return; } + case 'POST conversations.create': { + const name = typeof jsonBody.name === 'string' ? jsonBody.name : ''; + if (!name) { + json(response, 200, { ok: false, error: 'invalid_name_required' }); + return; + } + if (this.state.channels.some((channel) => channel.name === name)) { + json(response, 200, { ok: false, error: 'name_taken' }); + return; + } + + const channel: MockSlackChannel = { + id: `C${String(this.state.channels.length + 1).padStart(10, '0')}`, + name, + type: jsonBody.is_private ? 'private_channel' : 'public_channel', + isMember: true, + members: [this.state.botUserId ?? 'UMOCKBOT'], + }; + this.state.channels.push(channel); + json(response, 200, { + ok: true, + channel: { + id: channel.id, + name: channel.name, + is_private: channel.type === 'private_channel', + }, + }); + return; + } + + case 'POST conversations.inviteShared': { + const channelId = + typeof jsonBody.channel === 'string' ? jsonBody.channel : ''; + const channel = this.state.channels.find( + (entry) => entry.id === channelId, + ); + if (!channel) { + json(response, 200, { ok: false, error: 'channel_not_found' }); + return; + } + + channel.isPendingExtShared = true; + channel.pendingShared = ['TEXTERNAL']; + json(response, 200, { + ok: true, + invite_id: `I${channel.id.slice(1)}`, + is_legacy_shared_channel: false, + }); + return; + } + case 'GET conversations.members': { const channel = this.state.channels.find( (entry) => entry.id === url.searchParams.get('channel'), diff --git a/packages/slack/src/slack-notifier.ts b/packages/slack/src/slack-notifier.ts index 3771e43ba..0a942d01f 100644 --- a/packages/slack/src/slack-notifier.ts +++ b/packages/slack/src/slack-notifier.ts @@ -168,6 +168,16 @@ type SlackChannelInfoContext = { transportFailureLabel: string; }; +export type SlackSupportChannelApiResult = + | { success: true; data: T } + | { success: false; error: string }; + +export type SlackConnectChannelStatus = + | 'connected' + | 'pending' + | 'not_shared' + | 'not_found'; + export class SlackNotifier { private readonly token: string; private readonly channelInfoCache: SlackChannelInfoCache | null; @@ -376,6 +386,144 @@ export class SlackNotifier { return this.getChannelDiscovery().resolveChannelId(input); } + public async createPrivateChannel( + name: string, + ): Promise> { + try { + const response = await slackFetch( + buildSlackApiUrl('conversations.create'), + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ name, is_private: true }), + }, + ); + const result = (await response.json()) as { + ok?: boolean; + error?: string; + channel?: { id?: string; name?: string }; + }; + + if (!response.ok || !result.ok || !result.channel?.id) { + return { + success: false, + error: result.error ?? `http_${response.status}`, + }; + } + + return { + success: true, + data: { + id: result.channel.id, + name: result.channel.name?.trim() || name, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + public async inviteSharedChannel(params: { + channelId: string; + email: string; + }): Promise> { + try { + const response = await slackFetch( + buildSlackApiUrl('conversations.inviteShared'), + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ + channel: params.channelId, + emails: [params.email], + external_limited: false, + }), + }, + ); + const result = (await response.json()) as { + ok?: boolean; + error?: string; + invite_id?: string; + }; + + if (!response.ok || !result.ok) { + return { + success: false, + error: result.error ?? `http_${response.status}`, + }; + } + + return { + success: true, + data: { inviteId: result.invite_id?.trim() || null }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + public async getSlackConnectChannelStatus( + channelId: string, + ): Promise> { + try { + const params = new URLSearchParams({ channel: channelId }); + const response = await slackFetch( + `${buildSlackApiUrl('conversations.info')}?${params.toString()}`, + { + method: 'GET', + headers: { Authorization: `Bearer ${this.token}` }, + }, + ); + const result = (await response.json()) as { + ok?: boolean; + error?: string; + channel?: { + is_ext_shared?: boolean; + is_pending_ext_shared?: boolean; + pending_shared?: string[]; + }; + }; + + if (!result.ok && result.error === 'channel_not_found') { + return { success: true, data: 'not_found' }; + } + if (!response.ok || !result.ok) { + return { + success: false, + error: result.error ?? `http_${response.status}`, + }; + } + if (result.channel?.is_ext_shared) { + return { success: true, data: 'connected' }; + } + if ( + result.channel?.is_pending_ext_shared || + (result.channel?.pending_shared?.length ?? 0) > 0 + ) { + return { success: true, data: 'pending' }; + } + + return { success: true, data: 'not_shared' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + /** * Fetches the `conversations.info` projection for a channel once, going * through the caller-supplied cache when there is one. diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index 65fd1b405..411c0fa38 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -65,6 +65,7 @@ export const PROVIDER_IDENTIFIER_ENV_VAR_NAMES: ReadonlySet = new Set([ 'GITLAB_CLIENT_ID', 'GITEA_CLIENT_ID', 'SLACK_APP_ID', + 'R_SLACK_CONNECT_SUPPORT_EMAIL', 'ADO_CLIENT_ID', 'ADO_TENANT_ID', 'ADO_AUTH_MODE',