fix(setup): wire Slack agent during setup like Discord/Telegram
Slack setup previously stopped after installing the adapter, leaving users to manually discover /init-first-agent. When they DM'd the bot, the channel-approval flow silently failed because no owner existed. Now the Slack setup flow matches Discord/Telegram: - Collects the operator's Slack member ID - Opens a DM channel via conversations.open (requires im:write scope) - Runs init-first-agent to establish ownership, wiring, and welcome DM - Updates post-install note to focus on webhook URL (the only remaining step) The welcome DM is delivered via chat.postMessage (outbound), which works before Event Subscriptions are configured. The user sees the greeting immediately; inbound replies require webhooks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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**
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +261,120 @@ 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',
|
||||||
@@ -237,10 +382,6 @@ function showPostInstallChecklist(info: WorkspaceInfo): void {
|
|||||||
' • 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, then reinstall the app when Slack prompts',
|
||||||
'',
|
|
||||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
|
||||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
|
||||||
' wire an agent to it.',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
6,
|
6,
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user