diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 77092f534..7db224e25 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -62,12 +62,34 @@ delivery against a real workspace is verified manually once the service runs. ## Credentials -Walk the operator through creating the Slack app, then collect the two secrets it -hands back. The adapter is already installed and registered — it just can't -receive a message until this is done. Tell the user: +Slack can deliver events two ways. Socket Mode holds an outbound WebSocket open +— no public URL, so it works on a laptop or behind NAT — and is the right +default. Webhook delivery needs a public HTTPS Request URL but avoids the +long-lived socket. The adapter picks Socket Mode automatically whenever +`SLACK_APP_TOKEN` is set; otherwise it serves the webhook. -```nc:operator -Create the Slack app: +```nc:prompt connection validate:^(socket|webhook)$ +How should Slack deliver events? `socket` (Socket Mode — no public URL, recommended for local or behind-NAT installs) or `webhook` (needs a public HTTPS Request URL). +``` + +Walk the operator through creating the Slack app, then collect the secrets it +hands back. The adapter is already installed and registered — it just can't +receive a message until this is done. For Socket Mode, tell the user: + +```nc:operator when:connection=socket +Create the Slack app (Socket Mode): +1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace. +2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write. +3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab." +4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-). +5. Socket Mode → toggle "Enable Socket Mode" on. +6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-). +``` + +For webhook delivery, tell the user: + +```nc:operator when:connection=webhook +Create the Slack app (webhook delivery): 1. Go to api.slack.com/apps → Create New App → From scratch. Name it (e.g. "NanoClaw") and pick your workspace. 2. OAuth & Permissions → add these Bot Token Scopes: chat:write, im:write, channels:history, groups:history, im:history, channels:read, groups:read, users:read, reactions:write, files:read, files:write. 3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab." @@ -75,23 +97,35 @@ Create the Slack app: 5. Basic Information → copy the Signing Secret. ``` -Collect the two secrets and store them (the bridge reads them from `.env`): +Collect the secrets and store them (the bridge reads them from `.env`; the +app-level token doubles as the Socket Mode switch, the signing secret +authenticates webhook requests — each mode needs only its own): ```nc:prompt bot_token secret validate:^xoxb- Paste the Bot User OAuth Token — OAuth & Permissions, starts with `xoxb-`. ``` -```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$ +```nc:prompt app_token secret validate:^xapp- reuse:SLACK_APP_TOKEN when:connection=socket +Paste the App-Level Token — Basic Information → App-Level Tokens, starts with `xapp-`. +``` +```nc:prompt signing_secret secret validate:^[a-fA-F0-9]{16,}$ when:connection=webhook Paste the Signing Secret — Basic Information. ``` ```nc:env-set SLACK_BOT_TOKEN={{bot_token}} +``` +```nc:env-set when:connection=socket +SLACK_APP_TOKEN={{app_token}} +``` +```nc:env-set when:connection=webhook SLACK_SIGNING_SECRET={{signing_secret}} ``` -The bridge serves the webhook on port 3000 at `/webhook/slack` automatically; to -receive replies, that port must be reachable from the internet and registered -with Slack. Tell the user: -```nc:operator +With webhook delivery, the bridge serves port 3000 at `/webhook/slack` +automatically; to receive replies, that port must be reachable from the internet +and registered with Slack (Socket Mode skips all of this — events arrive over +the socket as soon as the service restarts). Tell the user: + +```nc:operator when:connection=webhook Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS): 1. Event Subscriptions → Enable Events. Set the Request URL to https:///webhook/slack and wait for the challenge to pass. 2. Subscribe to bot events: message.channels, message.groups, message.im, app_mention. Save Changes. @@ -126,8 +160,10 @@ curl -s -X POST https://slack.com/api/conversations.open -H "Authorization: Bear ``` `owner_handle` and `platform_id` are what the owner-wiring step needs. The -greeting goes out over `chat.postMessage`, which works right away; to receive -replies, finish the Event Subscriptions and Interactivity steps above. +greeting goes out over `chat.postMessage`, which works right away. Receiving +replies needs the event path live: with Socket Mode that happens as soon as the +service restarts below; with webhook delivery, finish the Event Subscriptions +and Interactivity steps above first. ## Restart diff --git a/scripts/skill-directives.test.ts b/scripts/skill-directives.test.ts index c27480b6e..ca302d2c2 100644 --- a/scripts/skill-directives.test.ts +++ b/scripts/skill-directives.test.ts @@ -15,11 +15,16 @@ describe('skill-directives parser, on the converted add-slack', () => { 'dep', // step 3: pinned package 'run', // step 4: build 'run', // step 4: test - 'operator', // credentials: create-app walkthrough (addressed to the operator) + 'prompt', // credentials: socket vs webhook delivery mode + 'operator', // credentials: create-app walkthrough, Socket Mode variant + 'operator', // credentials: create-app walkthrough, webhook variant 'prompt', // credentials: capture bot token - 'prompt', // credentials: capture signing secret - 'env-set', // credentials: write captured values to .env - 'operator', // credentials: event-delivery walkthrough + 'prompt', // credentials: capture app-level token (socket only) + 'prompt', // credentials: capture signing secret (webhook only) + 'env-set', // credentials: bot token (both modes) + 'env-set', // credentials: app token — doubles as the Socket Mode switch + 'env-set', // credentials: signing secret (webhook only) + 'operator', // credentials: event-delivery walkthrough (webhook only) 'prompt', // resolve: owner member id (owner_handle) 'run', // resolve: validate token (auth.test) — fast-fail before the restart 'run', // resolve: DM channel (conversations.open → capture:platform_id) @@ -32,10 +37,16 @@ describe('skill-directives parser, on the converted add-slack', () => { it('delineates the human UI steps as nc:operator (not agent prose or a run)', () => { const ops = directives.filter((d) => d.kind === 'operator'); - expect(ops).toHaveLength(2); - expect(ops[0].body.join('\n')).toMatch(/Create the Slack app/); - expect(ops[0].body.join('\n')).toMatch(/Bot Token Scopes/); - expect(ops[1].body.join('\n')).toMatch(/Event Subscriptions/); + expect(ops).toHaveLength(3); + expect(ops[0].body.join('\n')).toMatch(/Create the Slack app \(Socket Mode\)/); + expect(ops[0].body.join('\n')).toMatch(/connections:write/); + expect(ops[1].body.join('\n')).toMatch(/Create the Slack app \(webhook delivery\)/); + expect(ops[1].body.join('\n')).toMatch(/Bot Token Scopes/); + expect(ops[2].body.join('\n')).toMatch(/Event Subscriptions/); + // the mode branches are guard-delineated, one per delivery mode + expect(ops[0].attrs.when).toBe('connection=socket'); + expect(ops[1].attrs.when).toBe('connection=webhook'); + expect(ops[2].attrs.when).toBe('connection=webhook'); }); it('reads copy as a branch fetch with both files', () => { @@ -65,14 +76,19 @@ describe('skill-directives parser, on the converted add-slack', () => { ]); }); - it('captures prompts into named vars — credentials secret, the handle not', () => { + it('captures prompts into named vars — credentials secret, the mode and handle not', () => { const prompts = directives.filter((d) => d.kind === 'prompt'); - expect(prompts.map(promptVar)).toEqual(['bot_token', 'signing_secret', 'owner_handle']); - expect(prompts[0].args).toContain('secret'); // bot_token - expect(prompts[1].args).toContain('secret'); // signing_secret - expect(prompts[2].args).not.toContain('secret'); // owner_handle — a plain id, not a secret + expect(prompts.map(promptVar)).toEqual(['connection', 'bot_token', 'app_token', 'signing_secret', 'owner_handle']); + expect(prompts[0].args).not.toContain('secret'); // connection — a mode choice, not a secret + expect(prompts[1].args).toContain('secret'); // bot_token + expect(prompts[2].args).toContain('secret'); // app_token + expect(prompts[3].args).toContain('secret'); // signing_secret + expect(prompts[4].args).not.toContain('secret'); // owner_handle — a plain id, not a secret + // Each mode's credential is guard-scoped to its branch. + expect(prompts[2].attrs.when).toBe('connection=socket'); + expect(prompts[3].attrs.when).toBe('connection=webhook'); // The prompt body is the question; it does not mention env at all. - expect(prompts[0].body.join(' ')).toMatch(/Bot User OAuth Token/); + expect(prompts[1].body.join(' ')).toMatch(/Bot User OAuth Token/); }); it('resolves the conversation address into capture:platform_id (the wire input)', () => { @@ -83,9 +99,15 @@ describe('skill-directives parser, on the converted add-slack', () => { expect(resolve.body.join(' ')).toMatch(/"slack:" \+ \.channel\.id/); // emits the slack: platform_id }); - it('wires the captured variables into env-set via {{var}} references', () => { - const envSet = directives.find((d) => d.kind === 'env-set')!; - expect(envSet.body).toEqual(['SLACK_BOT_TOKEN={{bot_token}}', 'SLACK_SIGNING_SECRET={{signing_secret}}']); + it('wires the captured variables into env-set via {{var}} references, one per mode', () => { + const envSets = directives.filter((d) => d.kind === 'env-set'); + expect(envSets.map((d) => d.body)).toEqual([ + ['SLACK_BOT_TOKEN={{bot_token}}'], + ['SLACK_APP_TOKEN={{app_token}}'], + ['SLACK_SIGNING_SECRET={{signing_secret}}'], + ]); + expect(envSets[1].attrs.when).toBe('connection=socket'); + expect(envSets[2].attrs.when).toBe('connection=webhook'); }); it('passes validation (well-formed, pinned, every {{var}} captured first)', () => { diff --git a/scripts/skill-policy.test.ts b/scripts/skill-policy.test.ts index 9e41b0a3c..a69e0ec37 100644 --- a/scripts/skill-policy.test.ts +++ b/scripts/skill-policy.test.ts @@ -85,8 +85,11 @@ describe('gatePolicy — §5.1 parity table (real skills)', () => { expect(d[1].flavor).toBe('completed'); }); - it('slack: both operators are prompt-followed — no confirm, unchanged', () => { - expect(decisions(loadSkill('slack')).map((g) => g.needsConfirm)).toEqual([false, false]); + it('slack: all three operators are prompt-followed — no confirm, unchanged', () => { + // socket-create (guard-skips its webhook twin, lands on the bot_token + // prompt), webhook-create (bot_token prompt), event-delivery (owner_handle + // prompt in Resolve) — every barrier is a natural prompt barrier. + expect(decisions(loadSkill('slack')).map((g) => g.needsConfirm)).toEqual([false, false, false]); }); }); @@ -143,9 +146,9 @@ describe('extractOfferUrl — §5.2 inventory', () => { expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer) }); - it('slack :97 — the placeholder is EXCLUDED (normative negative fixture)', () => { + it('slack — the placeholder is EXCLUDED (normative negative fixture)', () => { const md = loadSkill('slack'); - const body = operatorBody(md, 1); // event-delivery block + const body = operatorBody(md, 2); // event-delivery block (after the two create-app variants) expect(body).toContain('https:///webhook/slack'); // fixture still authored as a placeholder expect(extractOfferUrl(body)).toBeUndefined(); // slack stays offer-free }); diff --git a/setup/channels/run-channel-skill.test.ts b/setup/channels/run-channel-skill.test.ts index b24904cb0..d22c70349 100644 --- a/setup/channels/run-channel-skill.test.ts +++ b/setup/channels/run-channel-skill.test.ts @@ -47,7 +47,7 @@ describe('runChannelSkill adapter (Option A)', () => { // the secrets + handle a human would supply; the skill resolves platform_id. // Values are valid-shaped for the prompts' validate: regexes — validate-at-bind // now enforces them on `inputs` too (they used to bypass validation). - inputs: { bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' }, + inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' }, wire: (a) => { wired.push(a); return true; @@ -220,7 +220,7 @@ describe('backGate (first-prompt back-to-channel-selection)', () => { resolveRemote: () => 'origin', agentName: 'Nano', role: 'owner', - inputs: { bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' }, + inputs: { connection: 'webhook', bot_token: 'xoxb-x', signing_secret: '0123456789abcdef', owner_handle: 'U12345678' }, wire: (a) => { wired.push(a); return true;