mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-03 18:45:07 +08:00
feat(setup): wire /setup slack through the SKILL.md driver — delete slack.ts
auto.ts now applies add-slack via the thin driver instead of the bespoke runSlackChannel. The whole connect+wire procedure lives in the SKILL.md; the host side is just the driver plus a small adapter that ensures the wire-target agent group. - runChannelSkill(channel, displayName): ensures the `dm-with-<name>` agent group exists over ncl (idempotent — a 2nd DM channel reuses it), then runs `.claude/skills/add-<channel>` with that folder pre-supplied as agent_folder. Reports the outcome via fullyApplied. All I/O injectable for tests. - `ncl groups create` → custom op: creates the agent group AND its container config (a working group needs it; generic create made only a bare row), idempotent on folder. The minimal wire-target slice of the deferred #3. - skill-driver: resolveRemote is now an option (so the adapter/tests can inject). - Deleted setup/channels/slack.ts — the first bespoke channel flow retired. provider-contract test now points at run-channel-skill.ts. Only slack is migrated; the other channels keep run<Channel>Channel until their SKILL.md wire sections are converted. Tests: groups-create scaffold+idempotent; the adapter drives the real add-slack with injected I/O (ensures group → wires to it). 610 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-2
@@ -33,7 +33,7 @@ import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runIMessageChannel } from './channels/imessage.js';
|
||||
import { runSignalChannel } from './channels/signal.js';
|
||||
import { runSlackChannel } from './channels/slack.js';
|
||||
import { runChannelSkill } from './channels/run-channel-skill.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
@@ -569,7 +569,10 @@ async function main(): Promise<void> {
|
||||
} else if (channelChoice === 'teams') {
|
||||
result = await runTeamsChannel(displayName!);
|
||||
} else if (channelChoice === 'slack') {
|
||||
result = await runSlackChannel(displayName!);
|
||||
// First channel migrated to the SKILL.md-driven flow (the whole
|
||||
// connect+wire procedure lives in add-slack/SKILL.md). The other
|
||||
// channels still use their bespoke run<Channel>Channel until converted.
|
||||
result = await runChannelSkill('slack', displayName!);
|
||||
} else if (channelChoice === 'imessage') {
|
||||
result = await runIMessageChannel(displayName!);
|
||||
} else if (channelChoice === 'other') {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runChannelSkill } from './run-channel-skill.js';
|
||||
import { normalizeName } from '../../src/modules/agent-to-agent/db/agent-destinations.js';
|
||||
|
||||
// Drives the real add-slack skill through the adapter with every side effect
|
||||
// injected (no real ncl/git/clack): confirms it ensures the wire-target group
|
||||
// for the display-name-derived folder, then applies the skill with that folder
|
||||
// pre-supplied so the wiring targets it.
|
||||
describe('runChannelSkill adapter', () => {
|
||||
it('ensures the group, then applies the skill with agent_folder pre-supplied', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-'));
|
||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
|
||||
const cmds: string[] = [];
|
||||
const exec = (c: string): string | void => {
|
||||
cmds.push(c);
|
||||
if (c.includes('conversations.open')) return 'D0SLACK\n';
|
||||
};
|
||||
|
||||
await runChannelSkill('slack', 'Bob Smith', {
|
||||
projectRoot: root,
|
||||
exec,
|
||||
resolveRemote: () => 'origin',
|
||||
// the secrets a human would paste; agent_folder is supplied by the adapter
|
||||
inputs: { bot_token: 'xoxb-x', signing_secret: 's', slack_user_id: 'U1' },
|
||||
});
|
||||
|
||||
const folder = `dm-with-${normalizeName('Bob Smith')}`;
|
||||
expect(cmds).toContain(`ncl groups create --folder '${folder}' --name 'Bob Smith'`); // wire-target ensured
|
||||
expect(cmds.some((c) => c.startsWith('ncl wirings create') && c.includes(`--agent-group ${folder}`))).toBe(true); // wired to it
|
||||
expect(cmds.some((c) => c.startsWith('ncl messaging-groups create') && c.includes('--channel-type slack'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Generic channel onboarding for setup:auto — the replacement for the bespoke
|
||||
* per-channel `run<Channel>Channel` flows. The entire connect+wire procedure
|
||||
* lives in the channel's SKILL.md (operator walkthroughs, credential prompts,
|
||||
* restart, ncl wiring), so this just:
|
||||
*
|
||||
* 1. ensures the wire-target agent group exists (the SKILL.md wires to it),
|
||||
* 2. runs the SKILL.md through the thin driver,
|
||||
* 3. reports the outcome.
|
||||
*
|
||||
* The agent group is created over `ncl` (the running service owns the DB; the
|
||||
* setup process has none), idempotent on the folder, so adding a second DM
|
||||
* channel reuses the same `dm-with-<name>` group.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { fullyApplied } from '../../scripts/skill-apply.js';
|
||||
import { normalizeName } from '../../src/modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { hostExec, runSkill, type RunSkillOptions } from '../lib/skill-driver.js';
|
||||
|
||||
/** Wrap a value as a single-quoted shell argument. */
|
||||
function shq(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export async function runChannelSkill(
|
||||
channel: string,
|
||||
displayName: string,
|
||||
overrides: Partial<RunSkillOptions> = {},
|
||||
): Promise<ChannelFlowResult> {
|
||||
const projectRoot = overrides.projectRoot ?? process.cwd();
|
||||
const exec = overrides.exec ?? hostExec(projectRoot);
|
||||
const folder = `dm-with-${normalizeName(displayName)}`;
|
||||
|
||||
// Ensure the wire-target group (+ its container config) exists. Idempotent.
|
||||
exec(`ncl groups create --folder ${shq(folder)} --name ${shq(displayName)}`);
|
||||
|
||||
const res = await runSkill(`.claude/skills/add-${channel}`, {
|
||||
projectRoot,
|
||||
inputs: { agent_folder: folder, ...overrides.inputs },
|
||||
exec,
|
||||
prompter: overrides.prompter,
|
||||
resolveRemote: overrides.resolveRemote,
|
||||
skipEffects: overrides.skipEffects,
|
||||
});
|
||||
|
||||
if (fullyApplied(res)) {
|
||||
p.log.success(`${channel} connected.`);
|
||||
} else {
|
||||
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
|
||||
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
|
||||
p.log.warn(`${channel} setup didn't fully complete — see above.`);
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||
* Slack app through the welcome DM:
|
||||
*
|
||||
* 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. Apply the /add-slack skill via the directive engine (the skill's
|
||||
* SKILL.md is the single source of truth) + restart the service
|
||||
* 5. Ask for the operator's Slack user ID
|
||||
* 6. conversations.open to get the DM channel ID
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||
* works without Event Subscriptions being configured. The user sees the
|
||||
* greeting in Slack immediately; inbound replies require webhooks, so the
|
||||
* post-install note covers that.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { applySkill, type Prompter } from '../../scripts/skill-apply.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { openUrl } from '../lib/browser.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
botName: string;
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
export async function runSlackChannel(displayName: string): Promise<ChannelFlowResult> {
|
||||
const intro = await walkThroughAppCreation();
|
||||
if (intro === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const install = await applySlackSkill(token, signingSecret, info);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
install.detail || 'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the Slack adapter and persist credentials by applying the `/add-slack`
|
||||
* skill through the structured-directive engine. The skill's SKILL.md is the
|
||||
* single source of truth — this replaces the hand-maintained setup/add-slack.sh,
|
||||
* which had already drifted on the pinned adapter version.
|
||||
*
|
||||
* The two secrets collected above are handed to the skill's `prompt` directives
|
||||
* through the in-process Prompter, so they never touch argv or disk. The engine
|
||||
* runs copy/append/dep/build + env-set/env-sync; we restart the service after
|
||||
* (the skill itself doesn't, by design). add-slack is fully deterministic and
|
||||
* both secrets are supplied, so a healthy apply leaves nothing for an agent and
|
||||
* nothing deferred — either bucket being non-empty means the install failed.
|
||||
*/
|
||||
async function applySlackSkill(
|
||||
token: string,
|
||||
signingSecret: string,
|
||||
info: WorkspaceInfo,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
const projectRoot = process.cwd();
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start(`Connecting Slack to @${info.botName} (${info.teamName})…`);
|
||||
|
||||
const prompter: Prompter = {
|
||||
async ask(name) {
|
||||
if (name === 'bot_token') return token;
|
||||
if (name === 'signing_secret') return signingSecret;
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await applySkill('.claude/skills/add-slack', projectRoot, {
|
||||
prompter,
|
||||
exec: (cmd) => {
|
||||
execSync(cmd, { cwd: projectRoot, stdio: 'pipe' });
|
||||
},
|
||||
// Fork-aware: reuse the existing resolver (handles upstream/fork remotes
|
||||
// and the auto-add-upstream fallback) instead of assuming `origin`.
|
||||
resolveRemote: () =>
|
||||
execSync('source setup/lib/channels-remote.sh; resolve_channels_remote', {
|
||||
cwd: projectRoot,
|
||||
shell: '/bin/bash',
|
||||
encoding: 'utf8',
|
||||
}).trim(),
|
||||
});
|
||||
|
||||
if (result.agentTasks.length || result.deferred.length) {
|
||||
const why = [...result.agentTasks.map((t) => t.reason), ...result.deferred].join('; ');
|
||||
s.stop("Couldn't finish installing Slack.", 1);
|
||||
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: why });
|
||||
return { ok: false, detail: why };
|
||||
}
|
||||
|
||||
restartService(projectRoot);
|
||||
s.stop('Slack adapter installed.');
|
||||
setupLog.step('slack-install', 'success', Date.now() - start, {
|
||||
APPLIED: String(result.applied.length),
|
||||
SKIPPED: String(result.skipped.length),
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
});
|
||||
return { ok: true, detail: '' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop("Couldn't install the Slack adapter.", 1);
|
||||
setupLog.step('slack-install', 'failed', Date.now() - start, { ERROR: message });
|
||||
return { ok: false, detail: 'See logs/setup-steps/ for details, then retry setup.' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort service restart so the new adapter + credentials take effect. */
|
||||
function restartService(projectRoot: string): void {
|
||||
const script = [
|
||||
`source "${projectRoot}/setup/lib/install-slug.sh"`,
|
||||
'case "$(uname -s)" in',
|
||||
' Darwin) launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" ;;',
|
||||
' Linux) systemctl --user restart "$(systemd_unit)" || sudo systemctl restart "$(systemd_unit)" ;;',
|
||||
'esac',
|
||||
].join('\n');
|
||||
try {
|
||||
execSync(script, { cwd: projectRoot, stdio: 'pipe', shell: '/bin/bash' });
|
||||
} catch {
|
||||
// The service may not be installed yet during a fresh setup — best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
|
||||
// per-line formatter so the URL stands out against the rest of the body.
|
||||
const linkBlock = isHeadless()
|
||||
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
|
||||
: [];
|
||||
|
||||
note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
...linkBlock,
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' • im:write, im:history',
|
||||
' • channels:read, channels:history',
|
||||
' • groups:read, groups:history',
|
||||
' • chat:write',
|
||||
' • users:read',
|
||||
' • reactions:write',
|
||||
' • files:read, files: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-…)',
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
|
||||
// Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open
|
||||
// Slack app settings" so users can bail out of Slack before we open the
|
||||
// browser or ask for tokens.
|
||||
const choice = ensureAnswer(await brightSelect<'open' | 'back'>({
|
||||
message: 'Open Slack app settings in your browser?',
|
||||
options: [
|
||||
{ value: 'open', label: 'Open Slack app settings' },
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'open',
|
||||
}));
|
||||
if (choice === 'back') return 'back';
|
||||
if (!isHeadless()) openUrl(SLACK_APPS_URL);
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_BOT_TOKEN');
|
||||
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_bot_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack bot token',
|
||||
clearOnError: true,
|
||||
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 existing = readEnvKey('SLACK_SIGNING_SECRET');
|
||||
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: 'Found an existing Slack signing secret. Use it?',
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_signing_secret', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack signing secret',
|
||||
clearOnError: true,
|
||||
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;
|
||||
};
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||
);
|
||||
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) {
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSlackUserId(): Promise<string> {
|
||||
note(
|
||||
[
|
||||
"To get your Slack member ID:",
|
||||
'',
|
||||
' 1. In Slack, click your profile picture (bottom left)',
|
||||
' 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;
|
||||
};
|
||||
if (data.ok && data.channel?.id) {
|
||||
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
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) {
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 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 ${accentGreen('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 {
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`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. Expose NanoClaw\'s webhook server (port 3000) 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 Changes',
|
||||
'',
|
||||
' 3. In your Slack app → Interactivity & Shortcuts:',
|
||||
' • Toggle "Interactivity" on',
|
||||
` • 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'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
}
|
||||
@@ -68,6 +68,8 @@ export interface RunSkillOptions {
|
||||
prompter?: Prompter;
|
||||
/** Defaults to `hostExec`. */
|
||||
exec?: (cmd: string) => string | void;
|
||||
/** Defaults to the fork-aware channels-branch resolver. */
|
||||
resolveRemote?: (branch: string) => string;
|
||||
/** Run effects the caller owns (e.g. `['restart']` when it restarts once). */
|
||||
skipEffects?: string[];
|
||||
}
|
||||
@@ -83,7 +85,7 @@ export function runSkill(skillDir: string, opts: RunSkillOptions = {}): Promise<
|
||||
inputs: opts.inputs,
|
||||
prompter: opts.prompter ?? clackPrompter(),
|
||||
exec: opts.exec ?? hostExec(projectRoot),
|
||||
resolveRemote: channelsRemote(projectRoot),
|
||||
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
|
||||
skipEffects: opts.skipEffects,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const CREATION_FILES = [
|
||||
'setup/cli-agent.ts',
|
||||
'setup/channels/telegram.ts',
|
||||
'setup/channels/discord.ts',
|
||||
'setup/channels/slack.ts',
|
||||
'setup/channels/run-channel-skill.ts', // slack (and future channels) now go through the SKILL.md driver
|
||||
'setup/channels/whatsapp.ts',
|
||||
'setup/channels/signal.ts',
|
||||
'setup/channels/imessage.ts',
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { AdditionalMountConfig, McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../../db/agent-groups.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
ensureContainerConfig,
|
||||
getContainerConfig,
|
||||
updateContainerConfigScalars,
|
||||
updateContainerConfigJson,
|
||||
@@ -58,11 +62,31 @@ registerResource({
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
// `delete` is intentionally not in `operations` — the generic single-table
|
||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
||||
// provided as `customOperations.delete` below.
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
// `create` and `delete` are custom (below): generic `create` inserts a bare
|
||||
// agent_groups row but never the container_config a working group needs, and
|
||||
// the generic single-table DELETE violates FK constraints (#2525).
|
||||
operations: { list: 'open', get: 'open', update: 'approval' },
|
||||
customOperations: {
|
||||
create: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Create (or return the existing) agent group with its container config. Idempotent on --folder. ' +
|
||||
'Use --folder <slug> and --name <display name>. Workspace files are scaffolded on first spawn.',
|
||||
handler: async (args) => {
|
||||
const folder = args.folder as string;
|
||||
if (!folder) throw new Error('--folder is required');
|
||||
const name = (args.name as string) ?? folder;
|
||||
const existing = getAgentGroupByFolder(folder);
|
||||
if (existing) {
|
||||
ensureContainerConfig(existing.id); // ensure a reused group is fully configured too
|
||||
return existing;
|
||||
}
|
||||
const id = `ag-${randomUUID()}`;
|
||||
createAgentGroup({ id, name, folder, agent_provider: null, created_at: new Date().toISOString() });
|
||||
ensureContainerConfig(id);
|
||||
return getAgentGroupByFolder(folder);
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
access: 'approval',
|
||||
description:
|
||||
|
||||
@@ -38,6 +38,7 @@ import { dispatch } from '../dispatch.js';
|
||||
import './messaging-groups.js';
|
||||
import './wirings.js';
|
||||
import './users.js';
|
||||
import './groups.js';
|
||||
|
||||
const HOST = { caller: 'host' as const };
|
||||
function now(): string {
|
||||
@@ -132,6 +133,19 @@ describe('programmatic wiring verbs', () => {
|
||||
expect((r as { error: { message: string } }).error.message).toMatch(/no messaging group/i);
|
||||
});
|
||||
|
||||
it('groups create scaffolds the container config and is idempotent on folder', async () => {
|
||||
const r1 = await send('groups-create', { folder: 'dm-with-bob', name: 'Bob' });
|
||||
expect(r1.ok).toBe(true);
|
||||
const ag = (r1 as { data: { id: string } }).data;
|
||||
expect(ag.id).toBeTruthy();
|
||||
// a working group needs a container_config row — generic create never made one
|
||||
expect(count('SELECT COUNT(*) c FROM container_configs WHERE agent_group_id = ?', ag.id)).toBe(1);
|
||||
// idempotent on folder
|
||||
const r2 = await send('groups-create', { folder: 'dm-with-bob', name: 'Bob' });
|
||||
expect((r2 as { data: { id: string } }).data.id).toBe(ag.id);
|
||||
expect(count('SELECT COUNT(*) c FROM agent_groups WHERE folder = ?', 'dm-with-bob')).toBe(1);
|
||||
});
|
||||
|
||||
it('messaging-groups send errors when no group exists (lookup before routeInbound)', async () => {
|
||||
const r = await send('messaging-groups-send', {
|
||||
channel_type: 'resend',
|
||||
|
||||
Reference in New Issue
Block a user