feat(setup): add Slack and iMessage channel flows (experimental)

Slack: interactive driver walks through app creation, validates the
bot token via auth.test, installs the adapter, and prints a
post-install checklist for the webhook URL + Event Subscriptions
config. No welcome DM since Slack needs a public URL before inbound
events work — the driver's own "finish in Slack" note replaces the
outro "check your DMs" banner.

iMessage: picks local (macOS) vs remote (Photon) mode. Local mode
opens the node binary's directory in Finder so the user can drag it
into Full Disk Access. Remote mode prompts for Photon URL + API key.
Asks for the operator's phone/email, then wires the first agent
including a welcome iMessage.

Both marked "(experimental)" in the askChannelChoice picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-23 11:09:30 +03:00
parent d8b1f52f2b
commit 3101f65a72
6 changed files with 891 additions and 11 deletions

314
setup/channels/imessage.ts Normal file
View File

@@ -0,0 +1,314 @@
/**
* iMessage channel flow for setup:auto.
*
* `runIMessageChannel(displayName)` covers both deployment modes:
*
* Local (macOS): the bot runs on this Mac and talks via the signed-in
* iMessage account. Reading chat.db needs Full Disk Access granted to
* the Node binary — we open the directory for them so they can drag
* the `node` file into System Settings.
*
* Remote (Photon API): the bot talks to a separate server (Photon)
* that owns an iMessage account on another Mac. Used when this host
* is Linux, or when the operator wants to keep their daily-driver
* Mac's chat history out of the loop.
*
* Flow:
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
* Remote: prompt for Photon server URL + API key
* 3. Ask for the phone or email the operator messages from — this is
* the platform-id for first-agent wiring
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
* iMessage goes out through the normal delivery path
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
type Mode = 'local' | 'remote';
interface RemoteCreds {
serverUrl: string;
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<void> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
if (!isMac) {
await fail(
'imessage',
"Local iMessage mode only works on macOS.",
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
);
}
await walkThroughFullDiskAccess();
} else {
remoteCreds = await collectRemoteCreds();
}
const handle = await askOperatorHandle();
const install = await runQuietChild(
'imessage-install',
'bash',
['setup/add-imessage.sh'],
{
running:
mode === 'local'
? "Connecting the iMessage adapter to this Mac…"
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
done: 'iMessage adapter installed.',
},
{
env:
mode === 'local'
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
: {
IMESSAGE_LOCAL: 'false',
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
IMESSAGE_API_KEY: remoteCreds!.apiKey,
},
extraFields: { MODE: mode },
},
);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const role = await askOperatorRole('iMessage');
setupLog.userInput('imessage_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'imessage',
'--user-id', handle,
'--platform-id', handle,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to iMessage…`,
done: `${agentName} is ready. Check iMessage for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'imessage',
AGENT_NAME: agentName,
PLATFORM_ID: handle,
MODE: mode,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
);
}
}
async function askMode(isMac: boolean): Promise<Mode> {
const choice = ensureAnswer(
await brightSelect<Mode>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
options: isMac
? [
{
value: 'local',
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
],
}),
);
setupLog.userInput('imessage_mode', String(choice));
return choice;
}
/**
* Grant Full Disk Access to the Node binary the host runs under — without
* it, the adapter can't read chat.db and inbound messages never arrive.
* Opening the containing directory in Finder makes the drag-and-drop
* target obvious; falling back to printing the path keeps us working in
* SSH/headless contexts where `open` is a no-op.
*/
async function walkThroughFullDiskAccess(): Promise<void> {
let nodePath = process.execPath;
try {
// `which node` picks up the user's shell-resolved node, which may differ
// from process.execPath (e.g. they launched setup under a different
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
const which = execSync('which node', { encoding: 'utf-8' }).trim();
if (which) nodePath = which;
} catch {
// fall back to process.execPath
}
const nodeDir = path.dirname(nodePath);
p.note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
'',
` ${nodePath}`,
'',
' 1. System Settings → Privacy & Security → Full Disk Access',
` 2. Click +, then drag the "node" file from the Finder window`,
' we just opened for you',
' 3. Toggle it on, then come back here',
].join('\n'),
6,
),
'Grant Full Disk Access',
);
try {
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
} catch {
// No Finder (SSH/headless) — user sees the path in the note above.
}
ensureAnswer(
await p.confirm({
message: "Granted Full Disk Access?",
initialValue: true,
}),
);
setupLog.userInput('imessage_fda_confirmed', 'true');
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
p.note(
[
"Photon is a separate service that owns an iMessage account and",
"exposes it over HTTP. NanoClaw will talk to it via its API.",
'',
' 1. Set up a Photon server: https://photon.im',
' 2. Copy the server URL and API key from your Photon dashboard',
].join('\n'),
'Remote iMessage via Photon',
);
const urlAnswer = ensureAnswer(
await p.text({
message: 'Photon server URL',
placeholder: 'https://photon.example.com',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'URL is required';
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
return undefined;
},
}),
);
const serverUrl = (urlAnswer as string).trim();
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
const apiKey = (keyAnswer as string).trim();
setupLog.userInput('imessage_server_url', serverUrl);
setupLog.userInput(
'imessage_api_key',
`${apiKey.slice(0, 4)}${apiKey.slice(-4)}`,
);
return { serverUrl, apiKey };
}
async function askOperatorHandle(): Promise<string> {
p.note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: full E.164, e.g. +15551234567'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number or email',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
const isPhone = /^\+\d{8,15}$/.test(t);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
if (!isPhone && !isEmail) {
return "Use a +E.164 phone number or an email address";
}
return undefined;
},
}),
);
const handle = (answer as string).trim();
setupLog.userInput('imessage_handle', handle);
return handle;
}
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;
}

249
setup/channels/slack.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` walks the operator from a bare Slack
* workspace through a running bot, then stops before wiring an agent:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Print the post-install checklist: set the public webhook URL in
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
* then `/manage-channels` to wire an agent.
*
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
* Slack needs a public Event Subscriptions URL for inbound events, and
* opening an unsolicited DM would need `im:write` scope we don't force
* the SKILL.md to require. Shipping a honest "here's what's left" note
* 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.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
interface WorkspaceInfo {
teamName: string;
teamId: string;
botName: string;
botUserId: string;
}
// displayName is reserved for when we start wiring the first agent here.
// 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();
const token = await collectBotToken();
const signingSecret = await collectSigningSecret();
const info = await validateSlackToken(token);
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env: {
SLACK_BOT_TOKEN: token,
SLACK_SIGNING_SECRET: signingSecret,
},
extraFields: {
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
showPostInstallChecklist(info);
}
async function walkThroughAppCreation(): Promise<void> {
p.note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, channels:history, groups:history, im:history,',
' channels:read, groups:read, users:read, reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
'Create a Slack app',
);
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
ensureAnswer(
await p.confirm({
message: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
}
async function collectBotToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
if (t.length < 24) return "That's shorter than a real Slack bot token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_bot_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function collectSigningSecret(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
// Slack signing secrets are 32-char hex strings, but newer apps
// sometimes emit longer variants — leniently require hex only.
if (!/^[a-f0-9]{16,}$/i.test(t)) {
return 'Signing secrets are a string of hex characters';
}
return undefined;
},
}),
);
const secret = (answer as string).trim();
setupLog.userInput(
'slack_signing_secret',
`${secret.slice(0, 4)}${secret.slice(-4)}`,
);
return secret;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${SLACK_API}/auth.test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = (await res.json()) as {
ok?: boolean;
team?: string;
team_id?: string;
user?: string;
user_id?: string;
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
botName: data.user,
botUserId: data.user_id ?? '',
};
setupLog.step('slack-validate', 'success', Date.now() - start, {
BOT_NAME: info.botName,
BOT_USER_ID: info.botUserId,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return info;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Slack didn't accept that token: ${reason}`, 1);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'slack-validate',
"Slack didn't accept that token.",
reason === 'invalid_auth' || reason === 'token_revoked'
? 'Copy the token again from OAuth & Permissions and retry setup.'
: `Slack said "${reason}". Check the token scopes and workspace install, 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-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-validate',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
p.note(
wrapForGutter(
[
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
'',
' 1. A public URL so Slack can deliver events.',
' NanoClaw serves a webhook on port 3000 by default — expose it',
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • 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'),
6,
),
'Finish setting up Slack',
);
}