+ {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 ? (
+
+
+
+ Open in Slack
+
+
+ ) : null}
+ {canCreate ? (
+
setConfirmOpen(true)}>
+ {status.state === 'not_started' ? 'Create channel' : 'Retry'}
+
+ ) : 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.
+
+
+
+ setConfirmOpen(false)}
+ disabled={createChannel.isPending}
+ >
+ Cancel
+
+ createChannel.mutate()}
+ disabled={createChannel.isPending}
+ >
+ {createChannel.isPending ? : null}
+ {createChannel.isPending ? 'Creating...' : 'Create and invite'}
+
+
+
+
+
+ );
+}
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',