From 2242f6c997a3f2bdfa2b33aceb5aa264a9be8053 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 5 Jul 2026 08:32:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(skills):=20port=20Slack=20Socket=20Mode=20?= =?UTF-8?q?into=20add-slack=20=E2=80=94=20when:-guarded=20delivery=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream added Socket Mode to the guided setup (cf8478ff) in the bespoke setup/channels/slack.ts this branch deleted. The port expresses it the way the engine was built for: a `connection` prompt (socket|webhook) with when:-guarded branches — - socket: app-level token walkthrough (connections:write scope, Socket Mode toggle), `nc:prompt app_token` (xapp-, reuse:SLACK_APP_TOKEN), SLACK_APP_TOKEN env-set. The adapter enables Socket Mode purely from the token's presence, so no public URL and no signing secret. - webhook: the existing signing-secret + Event Subscriptions path, now guard-scoped to its branch. Gate-policy parity holds: all three operator blocks remain prompt-barriered (no confirms), matching the §5.1 rules; the placeholder-URL negative fixture moves to operator index 2. Structure-tracking tests updated (directive order, operator/prompt/env-set inventories, Option-A inputs gain connection:webhook). Suite 827 passed | 1 skipped; add-slack lint exit 0. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/add-slack/SKILL.md | 62 +++++++++++++++++++----- scripts/skill-directives.test.ts | 56 ++++++++++++++------- scripts/skill-policy.test.ts | 11 +++-- setup/channels/run-channel-skill.test.ts | 4 +- 4 files changed, 97 insertions(+), 36 deletions(-) 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;