Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
22 changes: 22 additions & 0 deletions apps/docs/providers/communications/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions apps/web/src/components/settings/CommsProviderSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -599,6 +600,9 @@ export function CommsProviderSection({
/>

<div className="space-y-2 text-sm text-muted-foreground">
{provider.id === 'slack' && hasConfiguredValues ? (
<SlackSupportChannelPanel />
) : null}
{provider.id === 'telegram' && provider.telegramWebhook && (
<div className="flex items-start gap-2 mt-4">
{TELEGRAM_WEBHOOK_STATUS_COPY[provider.telegramWebhook.status]
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 151 additions & 0 deletions apps/web/src/components/settings/SlackSupportChannelPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<MessagesSquare className="mt-0.5 size-5 shrink-0" />
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">Shared support channel</p>
<Badge variant={badgeVariant}>{STATE_LABELS[status.state]}</Badge>
</div>
<p className="text-sm text-muted-foreground">{status.message}</p>
{status.channelName ? (
<p className="text-xs text-muted-foreground">
#{status.channelName}
</p>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
{status.openUrl ? (
<Button asChild variant="outline" size="sm">
<a
href={status.openUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-4" />
Open in Slack
</a>
</Button>
) : null}
{canCreate ? (
<Button size="sm" onClick={() => setConfirmOpen(true)}>
{status.state === 'not_started' ? 'Create channel' : 'Retry'}
</Button>
) : null}
</div>
</div>

{status.state === 'needs_permissions' ? (
<p className="text-xs text-muted-foreground">
Add <code>groups:write</code> and{' '}
<code>conversations.connect:write</code> to the Slack app, then use
Re-auth above.
</p>
) : null}

<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Create a shared support channel?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmOpen(false)}
disabled={createChannel.isPending}
>
Cancel
</Button>
<Button
onClick={() => createChannel.mutate()}
disabled={createChannel.isPending}
>
{createChannel.isPending ? <Spinner size="sm" /> : null}
{createChannel.isPending ? 'Creating...' : 'Create and invite'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
21 changes: 21 additions & 0 deletions apps/web/src/lib/slack-app-manifest.client.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion apps/web/src/lib/slack-app-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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`;
Expand Down Expand Up @@ -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,
},
Expand Down
Loading
Loading