Merge pull request #2075 from qwibitai/fix/slack-setup-wiring
fix(setup): complete Slack setup wiring with welcome DM
This commit is contained in:
@@ -60,7 +60,7 @@ pnpm run build
|
|||||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||||
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||||
|
|
||||||
@@ -76,7 +76,13 @@ pnpm run build
|
|||||||
10. Under **Subscribe to bot events**, add:
|
10. Under **Subscribe to bot events**, add:
|
||||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||||
11. Click **Save Changes**
|
11. Click **Save Changes**
|
||||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
|
||||||
|
### Interactivity
|
||||||
|
|
||||||
|
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
|
||||||
|
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
|
||||||
|
14. Click **Save Changes**
|
||||||
|
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
|
||||||
|
|
||||||
### Configure environment
|
### Configure environment
|
||||||
|
|
||||||
|
|||||||
@@ -510,10 +510,7 @@ function channelDmLabel(choice: ChannelChoice): string | null {
|
|||||||
case 'imessage':
|
case 'imessage':
|
||||||
return 'iMessage';
|
return 'iMessage';
|
||||||
case 'slack':
|
case 'slack':
|
||||||
// Slack install doesn't wire an agent or send a welcome DM — the
|
return 'Slack DMs';
|
||||||
// driver prints its own "finish in your Slack app" note. Falling
|
|
||||||
// through to null avoids a misleading "check your Slack DMs" banner.
|
|
||||||
return null;
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Slack channel flow for setup:auto.
|
* Slack channel flow for setup:auto.
|
||||||
*
|
*
|
||||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||||
* workspace through a running bot, then stops before wiring an agent:
|
* Slack app through the welcome DM:
|
||||||
*
|
*
|
||||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||||
* event subscriptions, and signing secret
|
* event subscriptions, and signing secret
|
||||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||||
* 5. Print the post-install checklist: set the public webhook URL in
|
* 5. Ask for the operator's Slack user ID
|
||||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
* 6. conversations.open to get the DM channel ID
|
||||||
* then `/manage-channels` to wire an agent.
|
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||||
|
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||||
*
|
*
|
||||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
* works without Event Subscriptions being configured. The user sees the
|
||||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
* greeting in Slack immediately; inbound replies require webhooks, so the
|
||||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
* post-install note covers that.
|
||||||
* is better than a welcome DM the user won't receive until they
|
|
||||||
* configure the webhook anyway.
|
|
||||||
*
|
*
|
||||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||||
*/
|
*/
|
||||||
@@ -27,11 +26,13 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
import { wrapForGutter } from '../lib/theme.js';
|
import { wrapForGutter } from '../lib/theme.js';
|
||||||
|
|
||||||
const SLACK_API = 'https://slack.com/api';
|
const SLACK_API = 'https://slack.com/api';
|
||||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||||
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
|
|
||||||
interface WorkspaceInfo {
|
interface WorkspaceInfo {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
@@ -40,10 +41,7 @@ interface WorkspaceInfo {
|
|||||||
botUserId: string;
|
botUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// displayName is reserved for when we start wiring the first agent here.
|
export async function runSlackChannel(displayName: string): Promise<void> {
|
||||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
|
||||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
|
||||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
|
||||||
await walkThroughAppCreation();
|
await walkThroughAppCreation();
|
||||||
|
|
||||||
const token = await collectBotToken();
|
const token = await collectBotToken();
|
||||||
@@ -78,6 +76,47 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownerUserId = await collectSlackUserId();
|
||||||
|
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||||
|
const platformId = `slack:${dmChannelId}`;
|
||||||
|
|
||||||
|
const role = await askOperatorRole('Slack');
|
||||||
|
setupLog.userInput('slack_role', role);
|
||||||
|
|
||||||
|
const agentName = await resolveAgentName();
|
||||||
|
|
||||||
|
const init = await runQuietChild(
|
||||||
|
'init-first-agent',
|
||||||
|
'pnpm',
|
||||||
|
[
|
||||||
|
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||||
|
'--channel', 'slack',
|
||||||
|
'--user-id', `slack:${ownerUserId}`,
|
||||||
|
'--platform-id', platformId,
|
||||||
|
'--display-name', displayName,
|
||||||
|
'--agent-name', agentName,
|
||||||
|
'--role', role,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
running: `Wiring ${agentName} to your Slack DMs…`,
|
||||||
|
done: 'Agent wired.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraFields: {
|
||||||
|
CHANNEL: 'slack',
|
||||||
|
AGENT_NAME: agentName,
|
||||||
|
PLATFORM_ID: platformId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!init.ok) {
|
||||||
|
await fail(
|
||||||
|
'init-first-agent',
|
||||||
|
`Couldn't finish connecting ${agentName}.`,
|
||||||
|
'You can retry later with `/init-first-agent` in Claude Code.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showPostInstallChecklist(info);
|
showPostInstallChecklist(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +128,9 @@ async function walkThroughAppCreation(): Promise<void> {
|
|||||||
'',
|
'',
|
||||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||||
' chat:write, channels:history, groups:history, im:history,',
|
' chat:write, im:write, channels:history, groups:history,',
|
||||||
' channels:read, groups:read, users:read, reactions:write',
|
' im:history, channels:read, groups:read, users:read,',
|
||||||
|
' reactions:write',
|
||||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||||
' slash commands and messages from the messages tab"',
|
' slash commands and messages from the messages tab"',
|
||||||
' 4. Basic Information → copy the "Signing Secret"',
|
' 4. Basic Information → copy the "Signing Secret"',
|
||||||
@@ -221,26 +261,135 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectSlackUserId(): Promise<string> {
|
||||||
|
p.note(
|
||||||
|
[
|
||||||
|
"To get your Slack member ID:",
|
||||||
|
'',
|
||||||
|
' 1. In Slack, click your profile picture (top right)',
|
||||||
|
' 2. Click "Profile"',
|
||||||
|
' 3. Click the three dots (⋯) → "Copy member ID"',
|
||||||
|
].join('\n'),
|
||||||
|
'Find your Slack user ID',
|
||||||
|
);
|
||||||
|
const answer = ensureAnswer(
|
||||||
|
await p.text({
|
||||||
|
message: 'Paste your Slack member ID',
|
||||||
|
validate: (v) => {
|
||||||
|
const t = (v ?? '').trim();
|
||||||
|
if (!t) return 'Member ID is required';
|
||||||
|
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
|
||||||
|
return "That doesn't look like a Slack member ID (starts with U)";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const id = (answer as string).trim();
|
||||||
|
setupLog.userInput('slack_user_id', id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||||
|
const s = p.spinner();
|
||||||
|
const start = Date.now();
|
||||||
|
s.start('Opening a DM channel…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SLACK_API}/conversations.open`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ users: userId }),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
ok?: boolean;
|
||||||
|
channel?: { id?: string };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||||
|
if (data.ok && data.channel?.id) {
|
||||||
|
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
||||||
|
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
||||||
|
DM_CHANNEL_ID: data.channel.id,
|
||||||
|
});
|
||||||
|
return data.channel.id;
|
||||||
|
}
|
||||||
|
const reason = data.error ?? `HTTP ${res.status}`;
|
||||||
|
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||||
|
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||||
|
ERROR: reason,
|
||||||
|
});
|
||||||
|
if (reason === 'missing_scope') {
|
||||||
|
await fail(
|
||||||
|
'slack-open-dm',
|
||||||
|
"Your Slack app is missing the im:write scope.",
|
||||||
|
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fail(
|
||||||
|
'slack-open-dm',
|
||||||
|
"Couldn't open a DM channel with you.",
|
||||||
|
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||||
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||||
|
ERROR: message,
|
||||||
|
});
|
||||||
|
await fail(
|
||||||
|
'slack-open-dm',
|
||||||
|
"Couldn't reach Slack.",
|
||||||
|
'Check your internet connection and retry setup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAgentName(): Promise<string> {
|
||||||
|
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||||
|
if (preset) {
|
||||||
|
setupLog.userInput('agent_name', preset);
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
const answer = ensureAnswer(
|
||||||
|
await p.text({
|
||||||
|
message: 'What should your assistant be called?',
|
||||||
|
placeholder: DEFAULT_AGENT_NAME,
|
||||||
|
defaultValue: DEFAULT_AGENT_NAME,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||||
|
setupLog.userInput('agent_name', value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||||
p.note(
|
p.note(
|
||||||
wrapForGutter(
|
wrapForGutter(
|
||||||
[
|
[
|
||||||
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
|
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
||||||
|
`To receive replies, Slack needs a public URL for delivering events:`,
|
||||||
'',
|
'',
|
||||||
' 1. A public URL so Slack can deliver events.',
|
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
|
||||||
' NanoClaw serves a webhook on port 3000 by default — expose it',
|
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||||
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
|
||||||
'',
|
'',
|
||||||
' 2. In your Slack app → Event Subscriptions:',
|
' 2. In your Slack app → Event Subscriptions:',
|
||||||
' • Toggle "Enable Events" on',
|
' • Toggle "Enable Events" on',
|
||||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||||
' • Subscribe to bot events: message.channels, message.groups,',
|
' • Subscribe to bot events: message.channels, message.groups,',
|
||||||
' message.im, app_mention',
|
' message.im, app_mention',
|
||||||
' • Save, then reinstall the app when Slack prompts',
|
' • Save Changes',
|
||||||
'',
|
'',
|
||||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
' 3. In your Slack app → Interactivity & Shortcuts:',
|
||||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
' • Toggle "Interactivity" on',
|
||||||
' wire an agent to it.',
|
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||||
|
' • Save Changes',
|
||||||
|
'',
|
||||||
|
' 4. Slack will prompt you to reinstall the app — do it to apply',
|
||||||
|
' the new settings',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
6,
|
6,
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user