Skip to content
Merged
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
24 changes: 23 additions & 1 deletion .agents/skills/mock-slack-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: mock-slack-testing
description: Run Roomote Slack integration flows through the existing mock Slack harness instead of a real Slack workspace. Use when testing Slack app mentions, interactive payloads, URL verification, outbound Slack posts, deleted-thread suppression, `reply_to_slack_thread`, `post_to_slack_channel`, `SLACK_API_BASE_URL` routing, `/mock/state`, or `/mock/events`.
description: Run Roomote Slack integration flows through the existing mock Slack harness instead of a real Slack workspace. Use when testing Slack app mentions, interactive payloads, URL verification, outbound Slack posts, deleted-thread suppression, `reply_to_slack_thread`, `post_to_slack_channel`, Slack app creation via `apps.manifest.create`, `SLACK_API_BASE_URL` routing, `/mock/state`, or `/mock/events`.
---

# Mock Slack Testing
Expand Down Expand Up @@ -131,6 +131,28 @@ curl -s http://127.0.0.1:3012/mock/state | jq '.messages'
curl -s http://127.0.0.1:3012/mock/state | jq '.messages[] | select(.thread_ts != null)'
```

## Testing Slack app creation (`apps.manifest.create`)

The harness also mocks Slack's `apps.manifest.create` endpoint, which backs
the setup flow where an admin pastes an app configuration token and Roomote
creates the Slack app. Point the web server at the harness
(`SLACK_API_BASE_URL=http://127.0.0.1:3012/api/`), then drive the setup UI or
the `slack.createAppFromManifest` tRPC mutation.

Optional state knobs:

- `acceptedConfigTokens` — allowlist for config tokens; unknown tokens get
`{ "ok": false, "error": "invalid_auth" }`. Config tokens are a separate
token space from `acceptedBotTokens`.
- `manifestCredentials` — `{ appId, clientId, clientSecret, signingSecret,
verificationToken }` overrides for the returned credentials.

Created apps are recorded in mock state:

```bash
curl -s http://127.0.0.1:3012/mock/state | jq '.createdManifests'
```

## Scenario Selection

See `references/scenarios.md` for the full catalog. Common picks:
Expand Down
11 changes: 11 additions & 0 deletions .changeset/slack-config-token-app-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@roomote/web': minor
---

Slack setup can now create the Slack app for you: paste an app configuration
token and Roomote creates the app through Slack's `apps.manifest.create` API,
saves the client ID, client secret, and signing secret automatically, and
advances straight to the Connect to Slack install step. Entering values
manually and the prefilled-manifest path remain available as fallbacks, and
the mock Slack harness now covers `apps.manifest.create` so the flow is
testable without a real workspace.
22 changes: 18 additions & 4 deletions apps/docs/providers/communications/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@ or configured for your self-hosted deployment.

## Fast path

In `/setup`, choose **Create Slack app from manifest**. Roomote opens Slack's
create-app flow with redirect URLs, webhook URLs, interactivity, scopes, and
events prefilled for the current public URL.
In `/setup`, paste a Slack **app configuration token** and Roomote creates the
Slack app for you through Slack's `apps.manifest.create` API:

After Slack creates the app:
1. open [api.slack.com/apps](https://api.slack.com/apps) and click
**Generate Token** under **Your App Configuration Tokens**, picking the
workspace where Roomote should live
2. paste the access token into Roomote setup and click **Create Slack app**

Roomote creates the app with redirect URLs, webhook URLs, interactivity,
scopes, and events preconfigured for the current public URL, then saves the
**Client ID**, **Client Secret**, and **Signing Secret** automatically. The
configuration token is used once to create the app and never stored. Finish
the Slack install step so Roomote can receive events and post replies.

### Alternative: prefilled manifest

If you prefer not to use a configuration token, choose the **prefilled
manifest** link instead. Roomote opens Slack's create-app flow with the same
manifest prefilled. After Slack creates the app:

1. copy **Client ID**, **Client Secret**, and **Signing Secret** from
**Basic Information > App Credentials**
Expand Down
158 changes: 137 additions & 21 deletions apps/web/src/app/(onboarding)/setup/ProviderSetupExperience.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useState } from 'react';
import Link from 'next/link';
import {
MICROSOFT_SINGLE_APP_TEAMS_BOT_FIELD_SOURCES,
Expand All @@ -19,6 +20,7 @@ import {
Button,
Pencil,
Sparkles,
Spinner,
} from '@/components/system';

import { StepTitle } from './StepTitle';
Expand Down Expand Up @@ -293,9 +295,12 @@ type ProviderSetupExperienceProps = {
clearedSavedValues: Record<string, boolean>;
teamsAppPackageHref: string | null;
showManualSlackValues: boolean;
createdSlackAppSettingsUrl?: string | null;
createSlackAppPending?: boolean;
showMicrosoftAdvancedConfig?: boolean;
surface?: 'setup' | 'settings';
envVarsInfoNote?: React.ReactNode;
onCreateSlackApp?: (configToken: string) => void;
onShowManualSlackValues: () => void;
onToggleMicrosoftAdvancedConfig?: () => void;
onValueChange: (envVarName: string, value: string) => void;
Expand Down Expand Up @@ -329,55 +334,143 @@ function ProviderSetupTitle({
}

function SlackSetupExperience(props: ProviderSetupExperienceProps) {
const slackManifestPrefillUrl = buildSlackManifestPrefillUrl({
publicOrigin: props.publicOrigin,
});
const [configToken, setConfigToken] = useState('');
const createdSlackAppSettingsUrl = props.createdSlackAppSettingsUrl ?? null;

if (createdSlackAppSettingsUrl) {
return (
<div className="relative w-full max-w-2xl space-y-4 py-2 md:py-0">
<ProviderSetupTitle surface={props.surface} text="Finish Slack app" />

<div className="space-y-4 max-w-xl">
<p>
Your Slack app is ready. Adding a logo is optional: open{' '}
<a
href={createdSlackAppSettingsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline font-semibold"
>
the app
</a>
, scroll down to App icon, and upload{' '}
<a
href="/api/setup/roomote-logo"
download
className="text-foreground underline font-semibold"
>
the Roomote logo
</a>
.
</p>
</div>
</div>
);
}

if (
!props.showManualSlackValues &&
!props.provider.runtimeSatisfied &&
!props.provider.savedSatisfied
) {
const createSlackAppPending = props.createSlackAppPending === true;
const createSlackAppDisabled =
!props.onCreateSlackApp || props.disabled || createSlackAppPending;
const submitConfigToken = () => {
const normalizedConfigToken = configToken.trim();

if (createSlackAppDisabled || !normalizedConfigToken) {
return;
}

props.onCreateSlackApp?.(normalizedConfigToken);
};

return (
<div className="relative w-full max-w-2xl space-y-4 py-2 md:py-0">
<ProviderSetupTitle surface={props.surface} text="Create Slack app" />

<div className="space-y-3 max-w-xl">
<div className="space-y-4 max-w-xl">
<p>
Because Roomote is self-hosted, we can&apos;t offer you an
out-of-the-box Slack app – you need to create your own.
</p>
<p>
Roomote can create it for you automatically, and then you can enter
the config values manually.
out-of-the-box Slack app – you need your own. But with just an app
configuration token, we&apos;ll create it for you.
</p>
<NumberedStep number={1}>
<p className="font-semibold">Get an app token.</p>
<div className="space-y-3 max-w-xl">
<p className="text-sm text-muted-foreground">
Go to the{' '}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline font-semibold"
>
Slack Apps portal
<ExternalLink className="inline size-3 -mt-1 ml-1" />
</a>{' '}
→ Your App Configuration Tokens →{' '}
<span className="font-semibold">Generate Token</span>, picking
the workspace you want to connect.
</p>
</div>
</NumberedStep>
<NumberedStep number={2}>
<p className="font-semibold">Copy it....</p>
<p className="text-sm text-muted-foreground">
...and paste it here:
</p>
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)] md:items-center max-w-xl">
<div className="text-sm font-medium">Access token</div>
<Input
secret
className="font-mono"
aria-label="App configuration token"
value={configToken}
onChange={(event) => setConfigToken(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitConfigToken();
}
}}
placeholder="xoxe.xoxp-..."
disabled={props.disabled || createSlackAppPending}
data-1p-ignore
/>
</div>
</NumberedStep>
</div>

<div className="flex flex-col gap-2 sm:flex-row sm:items-center mt-8">
{props.onBack ? (
<Button type="button" variant="outline" onClick={props.onBack}>
<Button
type="button"
variant="outline"
onClick={props.onBack}
disabled={createSlackAppPending}
>
<ArrowLeft />
Back
</Button>
) : null}
<Button asChild>
<a
href={slackManifestPrefillUrl}
target="_blank"
rel="noopener noreferrer"
onClick={props.onShowManualSlackValues}
>
<Sparkles />
Create Slack app
</a>
<Button
type="button"
onClick={submitConfigToken}
disabled={createSlackAppDisabled || configToken.trim().length === 0}
>
{createSlackAppPending ? <Spinner /> : <Sparkles />}
Create Slack app
</Button>
<Button
type="button"
variant="outline"
onClick={props.onShowManualSlackValues}
disabled={createSlackAppPending}
>
<Pencil />
Enter values manually
Create app manually
</Button>
</div>
</div>
Expand Down Expand Up @@ -558,6 +651,12 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) {
const providerSetupLabel =
providerSetupCopy?.setupLabel ?? `${props.provider.label} app`;
const fields = getSetupVisibleFields(props.provider);
const slackManifestPrefillUrl =
props.provider.id === 'slack'
? buildSlackManifestPrefillUrl({
publicOrigin: props.publicOrigin,
})
: null;

return (
<div className="relative w-full max-w-2xl space-y-5 py-2 md:py-0">
Expand All @@ -566,6 +665,23 @@ function GenericSetupExperience(props: ProviderSetupExperienceProps) {
text={`Configure ${providerSetupLabel}`}
/>

{slackManifestPrefillUrl ? (
<p className="text-sm text-muted-foreground max-w-xl">
Prefer not to use a configuration token? Create the app in
Slack&apos;s UI from a{' '}
<a
href={slackManifestPrefillUrl}
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline"
onClick={props.onShowManualSlackValues}
>
prefilled manifest
</a>{' '}
and enter its credentials manually.
</p>
) : null}

<NumberedStep number={1} className="mt-6">
<p className="font-semibold">
Create a new {providerSetupCopy.setupLabel}.
Expand Down
Loading
Loading