From a0d34de6234578db63efb0aeee28c039286d08ce Mon Sep 17 00:00:00 2001 From: "b.b" <76818625+breaking-brake@users.noreply.github.com> Date: Mon, 24 Nov 2025 01:13:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20Slack=20Workflow=20Sharing=20?= =?UTF-8?q?(=CE=B2)=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Slack Workflow Sharing (β) - Manual Slack Bot Token connection - Share workflows to Slack channels with Block Kit messages - Import workflows from Slack via deep links - Workflow file upload/download via Slack API - Slack channel browsing and selection - Import prerequisite note for workspace opening 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: bundle nano-spawn correctly in extension - Changed from CJS require to ES import for nano-spawn - Fixes "Cannot find module 'nano-spawn'" error in VSIX - nano-spawn is now properly bundled into dist/extension.js 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .vscodeignore | 4 +- CLAUDE.md | 3 +- README.md | 2 + package-lock.json | 269 +++++- package.json | 1 + .../checklists/requirements.md | 46 + .../contracts/extension-host-api-contracts.md | 700 +++++++++++++++ .../contracts/slack-api-contracts.md | 565 ++++++++++++ .../001-slack-workflow-sharing/data-model.md | 475 ++++++++++ specs/001-slack-workflow-sharing/plan.md | 195 ++++ .../001-slack-workflow-sharing/quickstart.md | 410 +++++++++ specs/001-slack-workflow-sharing/research.md | 537 +++++++++++ specs/001-slack-workflow-sharing/spec.md | 169 ++++ specs/001-slack-workflow-sharing/tasks.md | 624 +++++++++++++ src/extension/commands/open-editor.ts | 839 +++++++++++------- .../commands/slack-connect-manual.ts | 137 +++ .../commands/slack-import-workflow.ts | 257 ++++++ .../commands/slack-share-workflow.ts | 348 ++++++++ src/extension/extension.ts | 101 ++- src/extension/services/claude-code-service.ts | 4 +- src/extension/services/mcp-cli-service.ts | 4 +- src/extension/services/slack-api-service.ts | 545 ++++++++++++ .../types/slack-integration-types.ts | 226 +++++ src/extension/types/slack-messages.ts | 358 ++++++++ .../utils/sensitive-data-detector.ts | 207 +++++ src/extension/utils/slack-error-handler.ts | 240 +++++ src/extension/utils/slack-message-builder.ts | 103 +++ src/extension/utils/slack-token-manager.ts | 404 +++++++++ src/extension/utils/workflow-validator.ts | 101 +++ src/shared/types/messages.ts | 301 ++++++- src/webview/src/App.tsx | 137 ++- src/webview/src/components/Toolbar.tsx | 46 +- .../dialogs/SlackManualTokenDialog.tsx | 472 ++++++++++ .../components/dialogs/SlackShareDialog.tsx | 738 +++++++++++++++ src/webview/src/i18n/translation-keys.ts | 96 ++ src/webview/src/i18n/translations/en.ts | 102 +++ src/webview/src/i18n/translations/ja.ts | 101 +++ src/webview/src/i18n/translations/ko.ts | 101 +++ src/webview/src/i18n/translations/zh-CN.ts | 99 +++ src/webview/src/i18n/translations/zh-TW.ts | 99 +++ .../src/services/slack-integration-service.ts | 547 ++++++++++++ src/webview/src/stores/workflow-store.ts | 5 + vite.extension.config.ts | 33 +- 43 files changed, 10407 insertions(+), 344 deletions(-) create mode 100644 specs/001-slack-workflow-sharing/checklists/requirements.md create mode 100644 specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md create mode 100644 specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md create mode 100644 specs/001-slack-workflow-sharing/data-model.md create mode 100644 specs/001-slack-workflow-sharing/plan.md create mode 100644 specs/001-slack-workflow-sharing/quickstart.md create mode 100644 specs/001-slack-workflow-sharing/research.md create mode 100644 specs/001-slack-workflow-sharing/spec.md create mode 100644 specs/001-slack-workflow-sharing/tasks.md create mode 100644 src/extension/commands/slack-connect-manual.ts create mode 100644 src/extension/commands/slack-import-workflow.ts create mode 100644 src/extension/commands/slack-share-workflow.ts create mode 100644 src/extension/services/slack-api-service.ts create mode 100644 src/extension/types/slack-integration-types.ts create mode 100644 src/extension/types/slack-messages.ts create mode 100644 src/extension/utils/sensitive-data-detector.ts create mode 100644 src/extension/utils/slack-error-handler.ts create mode 100644 src/extension/utils/slack-message-builder.ts create mode 100644 src/extension/utils/slack-token-manager.ts create mode 100644 src/extension/utils/workflow-validator.ts create mode 100644 src/webview/src/components/dialogs/SlackManualTokenDialog.tsx create mode 100644 src/webview/src/components/dialogs/SlackShareDialog.tsx create mode 100644 src/webview/src/services/slack-integration-service.ts diff --git a/.vscodeignore b/.vscodeignore index 07a3458b..a1a2561d 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -3,6 +3,8 @@ .vscode-test/** .gitignore .git/** +.env +.env.* # Source files (use built files in dist/ instead) src/extension/**/*.ts @@ -37,8 +39,6 @@ specs/** # Build tools node_modules/** -# Include nano-spawn for cross-platform process spawning (Issue #79) -!node_modules/nano-spawn/** *.vsix # Documentation diff --git a/CLAUDE.md b/CLAUDE.md index e0d332d6..0f69deec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ Auto-generated from all feature plans. Last updated: 2025-11-01 - Workflow JSON files in `.vscode/workflows/` directory, Claude Code MCP configuration (user/project/enterprise scopes) (001-mcp-node) - TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), existing MCP SDK client services (001-mcp-natural-language-mode) - Workflow JSON files in `.vscode/workflows/` directory (extends existing McpNodeData structure) (001-mcp-natural-language-mode) +- TypeScript 5.3 (VSCode Extension Host), React 18.2 (Webview UI), @slack/web-api 7.x, Node.js http (OAuth callback server), VSCode Secret Storage (001-slack-workflow-sharing) +- Workflow JSON files in `.vscode/workflows/` directory, Slack message attachments (workflow storage), VSCode Secret Storage (OAuth tokens) (001-slack-workflow-sharing) - TypeScript 5.x (VSCode Extension Host), React 18.x (Webview UI) (001-cc-wf-studio) @@ -196,7 +198,6 @@ TypeScript 5.x (VSCode Extension Host), React 18.x (Webview UI): Follow standard ## Recent Changes - 001-mcp-natural-language-mode: Added TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), existing MCP SDK client services - 001-mcp-node: Added TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), child_process (Claude Code CLI execution) -- 001-ai-workflow-refinement: Added TypeScript 5.3 (VSCode Extension Host), React 18.2 (Webview UI) diff --git a/README.md b/README.md index c870f9c7..0200de09 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ All operations run locally within VSCode. **Note:** MCP Tool nodes may require n 🔌 **MCP Tool Nodes** - Integrate Model Context Protocol (MCP) tools with automatic server discovery, tool browsing, dynamic parameter forms, and real-time validation +💬 **Slack Workflow Sharing (β)** - Share workflows directly to Slack channels with rich preview cards and one-click import links for seamless team collaboration + ## AI-Assisted Workflow Refinement ### Overview diff --git a/package-lock.json b/package-lock.json index ff885332..9201f4ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "cc-wf-studio", - "version": "2.7.2", + "version": "2.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-wf-studio", - "version": "2.7.2", + "version": "2.11.2", "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", + "@slack/web-api": "^7.12.0", "nano-spawn": "^2.0.0" }, "devDependencies": { @@ -2086,6 +2087,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@slack/logger": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", + "integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18.0.0" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.19.0.tgz", + "integrity": "sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.12.0.tgz", + "integrity": "sha512-LrDxjYyqjeYYQGVdVZ6EYHunFmzveOr2pFpShr6TzW4KNFpdNNnpKekjtMg0PJlOsMibSySLGQqiBZQDasmRCA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/types": "^2.18.0", + "@types/node": ">=18.0.0", + "@types/retry": "0.12.0", + "axios": "^1.11.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2111,7 +2159,6 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2124,6 +2171,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.105.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.105.0.tgz", @@ -2329,6 +2382,23 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2897,6 +2967,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -3199,6 +3281,15 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3513,6 +3604,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3593,6 +3699,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -3883,6 +3995,26 @@ "flat": "cli.js" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3900,6 +4032,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4166,6 +4335,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4457,6 +4641,12 @@ "node": ">=8" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4553,7 +4743,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8182,6 +8371,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-is-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", @@ -8237,6 +8435,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-queue/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-reduce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", @@ -8247,6 +8479,19 @@ "node": ">=8" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-timeout": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", @@ -8606,6 +8851,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -8841,6 +9092,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", @@ -10324,7 +10584,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index b3f6e0d4..544910f7 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", + "@slack/web-api": "^7.12.0", "nano-spawn": "^2.0.0" } } diff --git a/specs/001-slack-workflow-sharing/checklists/requirements.md b/specs/001-slack-workflow-sharing/checklists/requirements.md new file mode 100644 index 00000000..e2890a06 --- /dev/null +++ b/specs/001-slack-workflow-sharing/checklists/requirements.md @@ -0,0 +1,46 @@ +# Specification Quality Checklist: Slack統合型ワークフロー共有 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-11-22 +**Updated**: 2025-11-22 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All validation items have passed. The specification is ready for `/speckit.plan`. + +### Clarifications Resolved +1. **Q1: インポート時の既存ファイル競合処理** → 上書き確認ダイアログを表示 +2. **Q2: インポート前のプレビュー機能** → プレビューなしで直接インポート +3. **Q3: Slack App配布方法** → Slack App Directory公開、最小限の実装(サーバーインフラ不要) + +### Assumptions +- Slack App DirectoryでのApp公開を想定 +- ワークフロー保存はSlackメッセージの添付ファイルのみを使用 +- OAuth認証はVS Code拡張機能内のローカルHTTPサーバーで処理 +- 外部ストレージ(S3等)への依存を最小化 diff --git a/specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md b/specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md new file mode 100644 index 00000000..699f9cae --- /dev/null +++ b/specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md @@ -0,0 +1,700 @@ +# Extension Host API Contracts + +**Feature**: 001-slack-workflow-sharing +**Date**: 2025-11-22 + +## 概要 + +このドキュメントは、VS Code Extension HostとWebview UI間のメッセージパッシングAPIを定義します。 + +--- + +## 1. Webview → Extension Host (Commands) + +### 1.1 SLACK_CONNECT + +Slackワークスペースへの接続を開始します(OAuth認証フロー)。 + +**Message Type**: `SLACK_CONNECT` + +**Payload**: +```typescript +interface SlackConnectCommand { + type: 'SLACK_CONNECT'; + payload: Record; // 空オブジェクト +} +``` + +**Example**: +```typescript +vscode.postMessage({ + type: 'SLACK_CONNECT', + payload: {} +}); +``` + +**Extension Host Response**: +- Success: `SLACK_CONNECT_SUCCESS` +- Failure: `SLACK_CONNECT_FAILED` + +--- + +### 1.2 SLACK_DISCONNECT + +Slackワークスペースとの接続を切断します(トークン削除)。 + +**Message Type**: `SLACK_DISCONNECT` + +**Payload**: +```typescript +interface SlackDisconnectCommand { + type: 'SLACK_DISCONNECT'; + payload: Record; +} +``` + +**Example**: +```typescript +vscode.postMessage({ + type: 'SLACK_DISCONNECT', + payload: {} +}); +``` + +**Extension Host Response**: +- Success: `SLACK_DISCONNECT_SUCCESS` +- Failure: `SLACK_DISCONNECT_FAILED` + +--- + +### 1.3 GET_SLACK_CHANNELS + +Slackチャンネル一覧を取得します。 + +**Message Type**: `GET_SLACK_CHANNELS` + +**Payload**: +```typescript +interface GetSlackChannelsCommand { + type: 'GET_SLACK_CHANNELS'; + payload: { + includePrivate?: boolean; // プライベートチャンネルを含めるか (default: true) + onlyMember?: boolean; // メンバーとして参加しているチャンネルのみ (default: true) + }; +} +``` + +**Example**: +```typescript +vscode.postMessage({ + type: 'GET_SLACK_CHANNELS', + payload: { + includePrivate: true, + onlyMember: true + } +}); +``` + +**Extension Host Response**: +- Success: `GET_SLACK_CHANNELS_SUCCESS` +- Failure: `GET_SLACK_CHANNELS_FAILED` + +--- + +### 1.4 SHARE_WORKFLOW_TO_SLACK + +ワークフローをSlackチャンネルに共有します。 + +**Message Type**: `SHARE_WORKFLOW_TO_SLACK` + +**Payload**: +```typescript +interface ShareWorkflowToSlackCommand { + type: 'SHARE_WORKFLOW_TO_SLACK'; + payload: { + workflowId: string; // 共有するワークフローのID + workflowName: string; // ワークフロー名 + channelId: string; // 共有先チャンネルID + description?: string; // ワークフローの説明 (optional) + overrideSensitiveWarning?: boolean; // 機密情報警告を無視 (default: false) + }; +} +``` + +**Example**: +```typescript +vscode.postMessage({ + type: 'SHARE_WORKFLOW_TO_SLACK', + payload: { + workflowId: 'uuid-1234-5678', + workflowName: 'My Data Processing Workflow', + channelId: 'C01234ABCD', + description: 'Workflow for daily data processing tasks', + overrideSensitiveWarning: false + } +}); +``` + +**Extension Host Response**: +- Success: `SHARE_WORKFLOW_SUCCESS` +- Warning (sensitive data detected): `SENSITIVE_DATA_WARNING` +- Failure: `SHARE_WORKFLOW_FAILED` + +--- + +### 1.5 IMPORT_WORKFLOW_FROM_SLACK + +SlackからワークフローをインポートするFします。 + +**Message Type**: `IMPORT_WORKFLOW_FROM_SLACK` + +**Payload**: +```typescript +interface ImportWorkflowFromSlackCommand { + type: 'IMPORT_WORKFLOW_FROM_SLACK'; + payload: { + workflowId: string; // インポートするワークフローのID + fileId: string; // Slack File ID + messageTs: string; // Slackメッセージのタイムスタンプ + channelId: string; // チャンネルID + overwriteExisting?: boolean; // 既存ファイルを上書き (default: false) + }; +} +``` + +**Example**: +```typescript +vscode.postMessage({ + type: 'IMPORT_WORKFLOW_FROM_SLACK', + payload: { + workflowId: 'uuid-1234-5678', + fileId: 'F01234ABCD', + messageTs: '1234567890.123456', + channelId: 'C01234ABCD', + overwriteExisting: false + } +}); +``` + +**Extension Host Response**: +- Success: `IMPORT_WORKFLOW_SUCCESS` +- Confirmation required: `IMPORT_WORKFLOW_CONFIRM_OVERWRITE` +- Failure: `IMPORT_WORKFLOW_FAILED` + +--- + +### 1.6 SEARCH_SLACK_WORKFLOWS + +Slackで過去に共有されたワークフローを検索します。 + +**Message Type**: `SEARCH_SLACK_WORKFLOWS` + +**Payload**: +```typescript +interface SearchSlackWorkflowsCommand { + type: 'SEARCH_SLACK_WORKFLOWS'; + payload: { + query?: string; // 検索キーワード (optional) + channelId?: string; // 特定チャンネル内を検索 (optional) + authorName?: string; // 作成者名で絞り込み (optional) + fromDate?: string; // 開始日 (ISO 8601) (optional) + toDate?: string; // 終了日 (ISO 8601) (optional) + limit?: number; // 取得件数 (default: 20, max: 100) + }; +} +``` + +**Example**: +```typescript +vscode.postMessage({ + type: 'SEARCH_SLACK_WORKFLOWS', + payload: { + query: 'data processing', + channelId: 'C01234ABCD', + fromDate: '2025-11-01T00:00:00Z', + limit: 20 + } +}); +``` + +**Extension Host Response**: +- Success: `SEARCH_SLACK_WORKFLOWS_SUCCESS` +- Failure: `SEARCH_SLACK_WORKFLOWS_FAILED` + +--- + +## 2. Extension Host → Webview (Events) + +### 2.1 SLACK_CONNECT_SUCCESS + +Slack接続成功時に送信されます。 + +**Message Type**: `SLACK_CONNECT_SUCCESS` + +**Payload**: +```typescript +interface SlackConnectSuccessEvent { + type: 'SLACK_CONNECT_SUCCESS'; + payload: { + workspaceId: string; + workspaceName: string; + userId: string; + authorizedAt: string; // ISO 8601 + }; +} +``` + +**Example**: +```json +{ + "type": "SLACK_CONNECT_SUCCESS", + "payload": { + "workspaceId": "T01234IJKL", + "workspaceName": "My Team Workspace", + "userId": "U01234ABCD", + "authorizedAt": "2025-11-22T10:00:00Z" + } +} +``` + +--- + +### 2.2 SLACK_CONNECT_FAILED + +Slack接続失敗時に送信されます。 + +**Message Type**: `SLACK_CONNECT_FAILED` + +**Payload**: +```typescript +interface SlackConnectFailedEvent { + type: 'SLACK_CONNECT_FAILED'; + payload: { + errorCode: string; + errorMessage: string; + }; +} +``` + +**Error Codes**: +- `USER_CANCELLED`: ユーザーが認証をキャンセル +- `OAUTH_FAILED`: OAuth認証失敗 +- `NETWORK_ERROR`: ネットワークエラー +- `UNKNOWN_ERROR`: その他のエラー + +**Example**: +```json +{ + "type": "SLACK_CONNECT_FAILED", + "payload": { + "errorCode": "USER_CANCELLED", + "errorMessage": "ユーザーが認証をキャンセルしました。" + } +} +``` + +--- + +### 2.3 GET_SLACK_CHANNELS_SUCCESS + +チャンネル一覧取得成功時に送信されます。 + +**Message Type**: `GET_SLACK_CHANNELS_SUCCESS` + +**Payload**: +```typescript +interface GetSlackChannelsSuccessEvent { + type: 'GET_SLACK_CHANNELS_SUCCESS'; + payload: { + channels: SlackChannel[]; + }; +} + +interface SlackChannel { + id: string; + name: string; + isPrivate: boolean; + isMember: boolean; + memberCount?: number; + purpose?: string; + topic?: string; +} +``` + +**Example**: +```json +{ + "type": "GET_SLACK_CHANNELS_SUCCESS", + "payload": { + "channels": [ + { + "id": "C01234ABCD", + "name": "general", + "isPrivate": false, + "isMember": true, + "memberCount": 25, + "purpose": "General discussions", + "topic": "Team announcements" + }, + { + "id": "C56789EFGH", + "name": "project-alpha", + "isPrivate": true, + "isMember": true, + "memberCount": 5 + } + ] + } +} +``` + +--- + +### 2.4 SENSITIVE_DATA_WARNING + +機密情報が検出された場合に送信されます(ユーザーに確認を求める)。 + +**Message Type**: `SENSITIVE_DATA_WARNING` + +**Payload**: +```typescript +interface SensitiveDataWarningEvent { + type: 'SENSITIVE_DATA_WARNING'; + payload: { + workflowId: string; + findings: SensitiveDataFinding[]; + }; +} + +interface SensitiveDataFinding { + type: string; // 例: 'AWS_ACCESS_KEY', 'API_KEY' + maskedValue: string; // 例: 'AKIA...X7Z9' + position: number; + context?: string; + severity: 'low' | 'medium' | 'high'; +} +``` + +**Example**: +```json +{ + "type": "SENSITIVE_DATA_WARNING", + "payload": { + "workflowId": "uuid-1234-5678", + "findings": [ + { + "type": "AWS_ACCESS_KEY", + "maskedValue": "AKIA...X7Z9", + "position": 1234, + "context": "...\"aws_access_key\": \"AKIA1234...\"...", + "severity": "high" + }, + { + "type": "API_KEY", + "maskedValue": "sk-p...def9", + "position": 5678, + "severity": "medium" + } + ] + } +} +``` + +**User Action Required**: ユーザーは「続行」または「キャンセル」を選択 + +--- + +### 2.5 SHARE_WORKFLOW_SUCCESS + +ワークフロー共有成功時に送信されます。 + +**Message Type**: `SHARE_WORKFLOW_SUCCESS` + +**Payload**: +```typescript +interface ShareWorkflowSuccessEvent { + type: 'SHARE_WORKFLOW_SUCCESS'; + payload: { + workflowId: string; + channelId: string; + channelName: string; + messageTs: string; + fileId: string; + permalink: string; // Slackメッセージへの直リンク + }; +} +``` + +**Example**: +```json +{ + "type": "SHARE_WORKFLOW_SUCCESS", + "payload": { + "workflowId": "uuid-1234-5678", + "channelId": "C01234ABCD", + "channelName": "general", + "messageTs": "1234567890.123456", + "fileId": "F01234ABCD", + "permalink": "https://myteam.slack.com/archives/C01234ABCD/p1234567890123456" + } +} +``` + +--- + +### 2.6 SHARE_WORKFLOW_FAILED + +ワークフロー共有失敗時に送信されます。 + +**Message Type**: `SHARE_WORKFLOW_FAILED` + +**Payload**: +```typescript +interface ShareWorkflowFailedEvent { + type: 'SHARE_WORKFLOW_FAILED'; + payload: { + workflowId: string; + errorCode: string; + errorMessage: string; + }; +} +``` + +**Error Codes**: +- `NOT_AUTHENTICATED`: Slack未接続 +- `CHANNEL_NOT_FOUND`: チャンネルが存在しない +- `NOT_IN_CHANNEL`: Botがチャンネルメンバーではない +- `FILE_TOO_LARGE`: ファイルサイズ超過 (1MB) +- `RATE_LIMITED`: Slack API Rate Limit超過 +- `NETWORK_ERROR`: ネットワークエラー +- `UNKNOWN_ERROR`: その他のエラー + +**Example**: +```json +{ + "type": "SHARE_WORKFLOW_FAILED", + "payload": { + "workflowId": "uuid-1234-5678", + "errorCode": "NOT_IN_CHANNEL", + "errorMessage": "Botがチャンネルに参加していません。チャンネルに招待してください。" + } +} +``` + +--- + +### 2.7 IMPORT_WORKFLOW_CONFIRM_OVERWRITE + +既存ファイルが存在する場合に確認を求めます。 + +**Message Type**: `IMPORT_WORKFLOW_CONFIRM_OVERWRITE` + +**Payload**: +```typescript +interface ImportWorkflowConfirmOverwriteEvent { + type: 'IMPORT_WORKFLOW_CONFIRM_OVERWRITE'; + payload: { + workflowId: string; + existingFilePath: string; + }; +} +``` + +**Example**: +```json +{ + "type": "IMPORT_WORKFLOW_CONFIRM_OVERWRITE", + "payload": { + "workflowId": "uuid-1234-5678", + "existingFilePath": "/Users/.../workflows/my-workflow.json" + } +} +``` + +**User Action Required**: ユーザーは「上書き」または「キャンセル」を選択 + +--- + +### 2.8 IMPORT_WORKFLOW_SUCCESS + +ワークフローインポート成功時に送信されます。 + +**Message Type**: `IMPORT_WORKFLOW_SUCCESS` + +**Payload**: +```typescript +interface ImportWorkflowSuccessEvent { + type: 'IMPORT_WORKFLOW_SUCCESS'; + payload: { + workflowId: string; + filePath: string; + workflowName: string; + }; +} +``` + +**Example**: +```json +{ + "type": "IMPORT_WORKFLOW_SUCCESS", + "payload": { + "workflowId": "uuid-1234-5678", + "filePath": "/Users/.../workflows/my-workflow.json", + "workflowName": "My Data Processing Workflow" + } +} +``` + +--- + +### 2.9 SEARCH_SLACK_WORKFLOWS_SUCCESS + +ワークフロー検索成功時に送信されます。 + +**Message Type**: `SEARCH_SLACK_WORKFLOWS_SUCCESS` + +**Payload**: +```typescript +interface SearchSlackWorkflowsSuccessEvent { + type: 'SEARCH_SLACK_WORKFLOWS_SUCCESS'; + payload: { + workflows: SharedWorkflowMetadata[]; + total: number; + }; +} + +interface SharedWorkflowMetadata { + id: string; + name: string; + description?: string; + version: string; + authorName: string; + sharedAt: string; // ISO 8601 + channelId: string; + channelName: string; + messageTs: string; + fileId: string; + fileUrl: string; + nodeCount: number; + tags?: string[]; + permalink: string; +} +``` + +**Example**: +```json +{ + "type": "SEARCH_SLACK_WORKFLOWS_SUCCESS", + "payload": { + "total": 5, + "workflows": [ + { + "id": "uuid-1234-5678", + "name": "Data Processing Workflow", + "description": "Daily data processing tasks", + "version": "1.0.0", + "authorName": "John Doe", + "sharedAt": "2025-11-22T10:00:00Z", + "channelId": "C01234ABCD", + "channelName": "general", + "messageTs": "1234567890.123456", + "fileId": "F01234ABCD", + "fileUrl": "https://files.slack.com/files-pri/...", + "nodeCount": 5, + "tags": ["data", "processing"], + "permalink": "https://myteam.slack.com/archives/C01234ABCD/p1234567890123456" + } + ] + } +} +``` + +--- + +## 3. Message Flow Examples + +### 3.1 Slack接続フロー + +``` +[Webview] SLACK_CONNECT + ↓ +[Extension Host] OAuth認証開始 + ↓ +[Browser] ユーザー認証 + ↓ +[Extension Host] トークン取得・保存 + ↓ +[Webview] SLACK_CONNECT_SUCCESS +``` + +### 3.2 ワークフロー共有フロー (機密情報あり) + +``` +[Webview] SHARE_WORKFLOW_TO_SLACK + ↓ +[Extension Host] 機密情報検出 + ↓ +[Webview] SENSITIVE_DATA_WARNING + ↓ +[User] 「続行」選択 + ↓ +[Webview] SHARE_WORKFLOW_TO_SLACK (overrideSensitiveWarning: true) + ↓ +[Extension Host] Slack API呼び出し + ↓ +[Webview] SHARE_WORKFLOW_SUCCESS +``` + +### 3.3 ワークフローインポートフロー (上書き確認) + +``` +[Webview] IMPORT_WORKFLOW_FROM_SLACK + ↓ +[Extension Host] ファイル存在チェック + ↓ +[Webview] IMPORT_WORKFLOW_CONFIRM_OVERWRITE + ↓ +[User] 「上書き」選択 + ↓ +[Webview] IMPORT_WORKFLOW_FROM_SLACK (overwriteExisting: true) + ↓ +[Extension Host] ファイルダウンロード・保存 + ↓ +[Webview] IMPORT_WORKFLOW_SUCCESS +``` + +--- + +## 4. TypeScript型定義 + +```typescript +// Commands (Webview → Extension Host) +export type WebviewToExtensionCommand = + | SlackConnectCommand + | SlackDisconnectCommand + | GetSlackChannelsCommand + | ShareWorkflowToSlackCommand + | ImportWorkflowFromSlackCommand + | SearchSlackWorkflowsCommand; + +// Events (Extension Host → Webview) +export type ExtensionToWebviewEvent = + | SlackConnectSuccessEvent + | SlackConnectFailedEvent + | GetSlackChannelsSuccessEvent + | GetSlackChannelsFailedEvent + | SensitiveDataWarningEvent + | ShareWorkflowSuccessEvent + | ShareWorkflowFailedEvent + | ImportWorkflowConfirmOverwriteEvent + | ImportWorkflowSuccessEvent + | ImportWorkflowFailedEvent + | SearchSlackWorkflowsSuccessEvent + | SearchSlackWorkflowsFailedEvent; + +// Message format +export interface WebviewMessage { + type: string; + payload: T; +} +``` diff --git a/specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md b/specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md new file mode 100644 index 00000000..caa1251d --- /dev/null +++ b/specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md @@ -0,0 +1,565 @@ +# Slack API Contracts + +**Feature**: 001-slack-workflow-sharing +**Date**: 2025-11-22 +**API Version**: Slack Web API v2 + +## 概要 + +このドキュメントは、Slack統合機能で使用するSlack Web APIのコントラクト仕様を定義します。 + +--- + +## 1. Bot User Token要件 + +本機能では、SlackワークスペースにインストールされたSlack Appから取得した**Bot User Token**を使用します。 + +**必要なBot Token Scopes**: +- `chat:write` - メッセージ投稿 +- `files:write` - ファイルアップロード +- `channels:read` - チャンネル一覧取得 +- `groups:read` - メッセージ検索 + +**トークン形式**: +- Bot User Tokenは `xoxb-` で始まる文字列 +- 例: `xoxb-YOUR-WORKSPACE-ID-YOUR-APP-ID-YOUR-TOKEN-STRING` + +**トークン取得方法**: +1. Slack APIでAppを作成 +2. 上記のBot Token Scopesを追加 +3. AppをワークスペースにInstall +4. OAuth & PermissionsページでBot User OAuth Tokenを取得 + +--- + +## 2. Workspace情報の取得 + +### 2.1 Token検証 + +**Method**: POST +**Endpoint**: `https://slack.com/api/auth.test` + +**Headers**: +``` +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Example Request**: +```http +POST https://slack.com/api/auth.test +Authorization: Bearer xoxb-YOUR-TOKEN-HERE +``` + +**Success Response** (200 OK): +```json +{ + "ok": true, + "url": "https://myteam.slack.com/", + "team": "My Team Workspace", + "user": "bot_user", + "team_id": "T01234IJKL", + "user_id": "U01234ABCD", + "bot_id": "B01234EFGH" +} +``` + +**Error Response**: +```json +{ + "ok": false, + "error": "invalid_auth" +} +``` + +**エラーコード**: +- `invalid_auth`: トークンが無効または失効 +- `account_inactive`: アカウントが無効化されている + +--- + +## 3. チャンネル管理 + +### 3.1 チャンネル一覧取得 + +**Method**: POST +**Endpoint**: `https://slack.com/api/conversations.list` + +**Headers**: +``` +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Body Parameters**: + +| Parameter | Type | Required | Description | Default | +|-----------|------|----------|-------------|---------| +| `types` | `string` | - | チャンネルタイプ (カンマ区切り) | `public_channel` | +| `limit` | `number` | - | 取得件数 | `100` | +| `cursor` | `string` | - | ページネーション用カーソル | - | +| `exclude_archived` | `boolean` | - | アーカイブ済みチャンネルを除外 | `false` | + +**Example Request**: +```http +POST https://slack.com/api/conversations.list +Authorization: Bearer xoxb-YOUR-TOKEN-HERE +Content-Type: application/json + +{ + "types": "public_channel,private_channel", + "limit": 100, + "exclude_archived": true +} +``` + +**Success Response** (200 OK): +```json +{ + "ok": true, + "channels": [ + { + "id": "C01234ABCD", + "name": "general", + "is_channel": true, + "is_group": false, + "is_im": false, + "is_mpim": false, + "is_private": false, + "is_archived": false, + "is_member": true, + "num_members": 25, + "purpose": { + "value": "This is the general channel", + "creator": "U01234EFGH", + "last_set": 1234567890 + }, + "topic": { + "value": "General discussions", + "creator": "U01234EFGH", + "last_set": 1234567890 + } + } + ], + "response_metadata": { + "next_cursor": "dGVhbTpDMDYxRkE1UEI=" + } +} +``` + +**Error Response**: +```json +{ + "ok": false, + "error": "invalid_auth" +} +``` + +**エラーコード**: +- `invalid_auth`: トークンが無効 +- `missing_scope`: 必要なスコープ (`channels:read`) がない + +--- + +## 4. メッセージ投稿 + +### 4.1 リッチメッセージカードの投稿 + +**Method**: POST +**Endpoint**: `https://slack.com/api/chat.postMessage` + +**Headers**: +``` +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Body Parameters**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `channel` | `string` | ✓ | チャンネルID (例: `C01234ABCD`) | +| `text` | `string` | - | フォールバックテキスト | +| `blocks` | `array` | - | Block Kit形式のメッセージブロック | +| `attachments` | `array` | - | 添付ファイル情報 | +| `thread_ts` | `string` | - | スレッドのタイムスタンプ (スレッド返信時) | + +**Example Request**: +```http +POST https://slack.com/api/chat.postMessage +Authorization: Bearer xoxb-YOUR-TOKEN-HERE +Content-Type: application/json + +{ + "channel": "C01234ABCD", + "text": "New workflow shared: My Workflow", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🔧 Workflow: My Workflow" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Author:*\nJohn Doe" + }, + { + "type": "mrkdwn", + "text": "*Version:*\n1.0.0" + }, + { + "type": "mrkdwn", + "text": "*Nodes:*\n5" + }, + { + "type": "mrkdwn", + "text": "*Created:*\n2025-11-22T10:00:00Z" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a sample workflow for data processing." + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "📥 Import to VS Code" + }, + "style": "primary", + "value": "workflow-uuid-1234", + "action_id": "import_workflow" + } + ] + } + ] +} +``` + +**Success Response** (200 OK): +```json +{ + "ok": true, + "channel": "C01234ABCD", + "ts": "1234567890.123456", + "message": { + "type": "message", + "subtype": null, + "text": "New workflow shared: My Workflow", + "ts": "1234567890.123456", + "username": "Claude Code Workflow Studio", + "bot_id": "B01234EFGH", + "blocks": [...] + } +} +``` + +**Error Response**: +```json +{ + "ok": false, + "error": "channel_not_found" +} +``` + +**エラーコード**: +- `channel_not_found`: チャンネルが存在しない +- `not_in_channel`: Botがチャンネルメンバーではない +- `missing_scope`: 必要なスコープ (`chat:write`) がない +- `msg_too_long`: メッセージが長すぎる (40,000文字制限) + +--- + +## 5. ファイルアップロード + +### 5.1 ワークフローJSONのアップロード + +**Method**: POST +**Endpoint**: `https://slack.com/api/files.uploadV2` + +**Headers**: +``` +Authorization: Bearer {access_token} +Content-Type: multipart/form-data +``` + +**Body Parameters**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `channel_id` | `string` | ✓ | チャンネルID | +| `file` | `file` | ✓ | アップロードするファイル | +| `filename` | `string` | ✓ | ファイル名 | +| `title` | `string` | - | ファイルのタイトル | +| `initial_comment` | `string` | - | ファイルアップロード時のコメント | +| `thread_ts` | `string` | - | スレッドのタイムスタンプ | + +**Example Request**: +```http +POST https://slack.com/api/files.uploadV2 +Authorization: Bearer xoxb-YOUR-TOKEN-HERE +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary + +------WebKitFormBoundary +Content-Disposition: form-data; name="channel_id" + +C01234ABCD +------WebKitFormBoundary +Content-Disposition: form-data; name="filename" + +my-workflow.json +------WebKitFormBoundary +Content-Disposition: form-data; name="title" + +My Workflow Definition +------WebKitFormBoundary +Content-Disposition: form-data; name="file"; filename="my-workflow.json" +Content-Type: application/json + +{ + "id": "workflow-uuid-1234", + "name": "My Workflow", + "version": "1.0.0", + "nodes": [...] +} +------WebKitFormBoundary-- +``` + +**Success Response** (200 OK): +```json +{ + "ok": true, + "file": { + "id": "F01234ABCD", + "created": 1234567890, + "timestamp": 1234567890, + "name": "my-workflow.json", + "title": "My Workflow Definition", + "mimetype": "application/json", + "filetype": "json", + "size": 1234, + "url_private": "https://files.slack.com/files-pri/T01234IJKL-F01234ABCD/my-workflow.json", + "url_private_download": "https://files.slack.com/files-pri/T01234IJKL-F01234ABCD/download/my-workflow.json", + "permalink": "https://myteam.slack.com/files/U01234EFGH/F01234ABCD/my-workflow.json", + "permalink_public": "https://slack-files.com/T01234IJKL-F01234ABCD-abc123def456" + } +} +``` + +**Error Response**: +```json +{ + "ok": false, + "error": "file_too_large" +} +``` + +**エラーコード**: +- `file_too_large`: ファイルサイズが1GBを超過 +- `invalid_file_type`: サポートされていないファイルタイプ +- `missing_scope`: 必要なスコープ (`files:write`) がない + +--- + +## 6. メッセージ検索 + +### 6.1 ワークフロー検索 + +**Method**: POST +**Endpoint**: `https://slack.com/api/search.messages` + +**Headers**: +``` +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Body Parameters**: + +| Parameter | Type | Required | Description | Default | +|-----------|------|----------|-------------|---------| +| `query` | `string` | ✓ | 検索クエリ | - | +| `count` | `number` | - | 取得件数 | `20` | +| `page` | `number` | - | ページ番号 | `1` | +| `sort` | `string` | - | ソート順 (`score`, `timestamp`) | `score` | + +**Example Request**: +```http +POST https://slack.com/api/search.messages +Authorization: Bearer xoxb-YOUR-TOKEN-HERE +Content-Type: application/json + +{ + "query": "workflow filename:*.json in:#general", + "count": 20, + "sort": "timestamp" +} +``` + +**Success Response** (200 OK): +```json +{ + "ok": true, + "query": "workflow filename:*.json in:#general", + "messages": { + "total": 5, + "matches": [ + { + "type": "message", + "ts": "1234567890.123456", + "channel": { + "id": "C01234ABCD", + "name": "general" + }, + "user": "U01234EFGH", + "username": "Claude Code Workflow Studio", + "text": "New workflow shared: My Workflow", + "permalink": "https://myteam.slack.com/archives/C01234ABCD/p1234567890123456", + "files": [ + { + "id": "F01234ABCD", + "name": "my-workflow.json", + "title": "My Workflow Definition", + "url_private": "https://files.slack.com/files-pri/T01234IJKL-F01234ABCD/my-workflow.json" + } + ] + } + ] + } +} +``` + +**Error Response**: +```json +{ + "ok": false, + "error": "missing_scope" +} +``` + +**エラーコード**: +- `missing_scope`: 必要なスコープ (`groups:read`) がない +- `invalid_query`: クエリが無効 + +--- + +## Rate Limits + +Slack Web APIには以下のRate Limitsが適用されます: + +| Tier | Requests per minute | Methods | +|------|---------------------|---------| +| Tier 1 | 1+ | `chat.postMessage`, `files.upload` | +| Tier 2 | 20+ | `conversations.list`, `search.messages` | +| Tier 3 | 50+ | `auth.test` | +| Tier 4 | 100+ | その他のメソッド | + +**Rate Limit対策**: +- `@slack/web-api` は自動リトライ機能を提供 +- `Retry-After` ヘッダーを尊重 +- Rate Limit超過時は指数バックオフでリトライ + +**Rate Limit Response**: +```json +{ + "ok": false, + "error": "rate_limited" +} +``` + +**Headers**: +``` +X-Rate-Limit-Limit: 20 +X-Rate-Limit-Remaining: 0 +X-Rate-Limit-Reset: 1234567890 +Retry-After: 60 +``` + +--- + +## エラーハンドリング + +### 共通エラーコード + +| Error Code | Description | 対処方法 | +|------------|-------------|---------| +| `invalid_auth` | トークンが無効または失効 | 再認証を促す | +| `missing_scope` | 必要なスコープがない | スコープを追加して再認証 | +| `rate_limited` | Rate Limit超過 | `Retry-After` 後にリトライ | +| `internal_error` | Slack内部エラー | 指数バックオフでリトライ | +| `not_authed` | トークンが提供されていない | トークンを確認 | + +### エラーハンドリング実装例 + +```typescript +import { WebClient, WebAPICallError } from '@slack/web-api'; + +async function handleSlackApiCall( + apiCall: () => Promise +): Promise { + try { + return await apiCall(); + } catch (error) { + if (error instanceof WebAPICallError) { + switch (error.data.error) { + case 'invalid_auth': + vscode.window.showErrorMessage( + 'Slackトークンが無効です。再認証してください。' + ); + // 再認証フロー開始 + break; + + case 'missing_scope': + vscode.window.showErrorMessage( + '必要な権限がありません。アプリを再インストールしてください。' + ); + break; + + case 'rate_limited': + const retryAfter = error.data.retryAfter ?? 60; + vscode.window.showWarningMessage( + `Slack API Rate Limitに達しました。${retryAfter}秒後に再試行してください。` + ); + break; + + default: + vscode.window.showErrorMessage( + `Slack APIエラー: ${error.data.error}` + ); + } + } + throw error; + } +} +``` + +--- + +## セキュリティ要件 + +### Token Management + +1. **暗号化保存**: Bot User TokenはVSCode Secret Storageに安全に保存 +2. **平文保存禁止**: トークンをコードにハードコードしたり、設定ファイルに平文で保存しない +3. **スコープ最小化**: 必要最小限のBot Token Scopesのみ要求 +4. **トークン検証**: トークン保存前に `auth.test` APIでトークンの有効性を確認 +5. **トークン再検証**: アプリ起動時およびAPI呼び出しエラー時にトークンの有効性を再確認 + +### Data Privacy + +1. **機密情報検出**: ワークフローJSONをアップロード前にスキャン +2. **ログ出力禁止**: Bot User Tokenやワークフロー内容をログに出力しない +3. **トークンマスキング**: UI表示時はトークンの一部のみ表示 (例: xoxb-****...****1234) +4. **ユーザー同意**: 共有前に確認ダイアログを表示 diff --git a/specs/001-slack-workflow-sharing/data-model.md b/specs/001-slack-workflow-sharing/data-model.md new file mode 100644 index 00000000..7e07c454 --- /dev/null +++ b/specs/001-slack-workflow-sharing/data-model.md @@ -0,0 +1,475 @@ +# データモデル: Slack統合型ワークフロー共有 + +**Feature**: 001-slack-workflow-sharing +**Date**: 2025-11-22 +**Status**: Complete + +## 概要 + +このドキュメントは、Slack統合機能で使用されるデータモデルを定義します。主に以下のエンティティを含みます: + +1. **SlackWorkspaceConnection** - Slackワークスペース接続情報 +2. **SharedWorkflowMetadata** - 共有ワークフローのメタデータ +3. **SensitiveDataFinding** - 機密情報検出結果 +4. **SlackChannel** - Slackチャンネル情報 +5. **WorkflowImportRequest** - ワークフローインポートリクエスト + +--- + +## 1. SlackWorkspaceConnection + +Slackワークスペースとの接続情報を管理するエンティティ。 + +### フィールド + +| フィールド名 | 型 | 必須 | 説明 | 制約 | +|------------|-----|------|------|------| +| `workspaceId` | `string` | ✓ | Slack WorkspaceのID | Slack APIから取得、例: `T01234ABCD` | +| `workspaceName` | `string` | ✓ | ワークスペース名 | 表示用、例: `My Team Workspace` | +| `teamId` | `string` | ✓ | Slack Team ID | Slack APIから取得 | +| `accessToken` | `string` | ✓ | OAuth Access Token | VSCode Secret Storageに暗号化保存 | +| `tokenScope` | `string[]` | ✓ | トークンのスコープ | 例: `['chat:write', 'files:write', 'channels:read']` | +| `userId` | `string` | ✓ | 認証したユーザーのSlack User ID | 例: `U01234EFGH` | +| `authorizedAt` | `Date` | ✓ | 認証日時 | ISO 8601形式 | +| `lastValidatedAt` | `Date` | - | 最終検証日時 | トークン有効性チェック時に更新 | + +### バリデーションルール + +- `accessToken`: + - パターン: `xoxb-` または `xoxp-` で始まる + - 長さ: 最低40文字 + - 保存: VSCode Secret Storageのみ (平文保存禁止) +- `tokenScope`: + - 必須スコープ: `channels:read`, `chat:write`, `files:write`, `groups:read` + - 不足スコープがある場合はエラー +- `workspaceId`, `teamId`, `userId`: + - Slack ID形式: 大文字英字1文字 + 8-11桁の英数字 + +### TypeScript型定義 + +```typescript +export interface SlackWorkspaceConnection { + workspaceId: string; + workspaceName: string; + teamId: string; + accessToken: string; // Secret Storage経由でのみアクセス + tokenScope: string[]; + userId: string; + authorizedAt: Date; + lastValidatedAt?: Date; +} +``` + +### 状態遷移 + +``` +[未接続] → OAuth認証 → [接続済み] +[接続済み] → トークン失効検出 → [未接続] +[接続済み] → ユーザーによる切断 → [未接続] +``` + +--- + +## 2. SharedWorkflowMetadata + +Slackに共有されたワークフローのメタデータ。 + +### フィールド + +| フィールド名 | 型 | 必須 | 説明 | 制約 | +|------------|-----|------|------|------| +| `id` | `string` | ✓ | ワークフローの一意ID | UUID v4形式 | +| `name` | `string` | ✓ | ワークフロー名 | 1-100文字 | +| `description` | `string` | - | ワークフローの説明 | 最大500文字 | +| `version` | `string` | ✓ | ワークフローバージョン | Semantic Versioning形式 | +| `authorName` | `string` | ✓ | 共有者の名前 | VS Code設定から取得 | +| `authorEmail` | `string` | - | 共有者のメールアドレス | オプション | +| `sharedAt` | `Date` | ✓ | 共有日時 | ISO 8601形式 | +| `channelId` | `string` | ✓ | 共有先チャンネルID | 例: `C01234ABCD` | +| `channelName` | `string` | ✓ | 共有先チャンネル名 | 表示用 | +| `messageTs` | `string` | ✓ | Slackメッセージのタイムスタンプ | Slack API形式: `1234567890.123456` | +| `fileId` | `string` | ✓ | Slackに添付されたファイルID | 例: `F01234ABCD` | +| `fileUrl` | `string` | ✓ | ファイルのダウンロードURL | Slack private URL | +| `nodeCount` | `number` | ✓ | ノード数 | >= 0 | +| `tags` | `string[]` | - | タグ(検索用) | 最大10個、各タグ最大30文字 | +| `hasSensitiveData` | `boolean` | ✓ | 機密情報検出フラグ | `true` = 検出された | +| `sensitiveDataOverride` | `boolean` | - | 機密情報警告を無視して共有 | デフォルト: `false` | + +### バリデーションルール + +- `name`: + - 空文字禁止 + - 1-100文字 + - 特殊文字制限: `<>:"/\|?*` 使用不可 +- `version`: + - Semantic Versioning形式: `MAJOR.MINOR.PATCH` + - 例: `1.0.0`, `2.3.1-beta` +- `messageTs`: + - Slack固有形式: `{unix_timestamp}.{microseconds}` + - 例: `1234567890.123456` +- `nodeCount`: + - 最小値: 0 + - 最大値: 1000 (仕様上の上限) + +### TypeScript型定義 + +```typescript +export interface SharedWorkflowMetadata { + id: string; + name: string; + description?: string; + version: string; + authorName: string; + authorEmail?: string; + sharedAt: Date; + channelId: string; + channelName: string; + messageTs: string; + fileId: string; + fileUrl: string; + nodeCount: number; + tags?: string[]; + hasSensitiveData: boolean; + sensitiveDataOverride?: boolean; +} +``` + +### リレーション + +- `channelId` → `SlackChannel.id` +- `fileId` → Slack Files API + +--- + +## 3. SensitiveDataFinding + +機密情報検出結果を表すエンティティ。 + +### フィールド + +| フィールド名 | 型 | 必須 | 説明 | 制約 | +|------------|-----|------|------|------| +| `type` | `SensitiveDataType` | ✓ | 検出された機密情報の種類 | 列挙型 (下記参照) | +| `maskedValue` | `string` | ✓ | マスク済みの値 | 最初と最後の4文字のみ表示 | +| `position` | `number` | ✓ | ファイル内の位置 (文字オフセット) | >= 0 | +| `context` | `string` | - | 検出箇所の前後のコンテキスト | 最大100文字 | +| `severity` | `'low' \| 'medium' \| 'high'` | ✓ | 重要度 | デフォルト: `'high'` | + +### SensitiveDataType 列挙型 + +```typescript +export enum SensitiveDataType { + AWS_ACCESS_KEY = 'AWS_ACCESS_KEY', + AWS_SECRET_KEY = 'AWS_SECRET_KEY', + API_KEY = 'API_KEY', + TOKEN = 'TOKEN', + SLACK_TOKEN = 'SLACK_TOKEN', + GITHUB_TOKEN = 'GITHUB_TOKEN', + PRIVATE_KEY = 'PRIVATE_KEY', + PASSWORD = 'PASSWORD', + CUSTOM = 'CUSTOM' // ユーザー定義パターン +} +``` + +### バリデーションルール + +- `maskedValue`: + - 元の値を完全に復元不可能であること + - 形式: `{first4chars}...{last4chars}` + - 例: `AKIA...X7Z9` +- `severity`: + - `'high'`: 即座に共有を停止すべき (AWS keys, private keys) + - `'medium'`: 警告を表示 (API keys, tokens) + - `'low'`: 情報提供のみ (パスワードなど) + +### TypeScript型定義 + +```typescript +export interface SensitiveDataFinding { + type: SensitiveDataType; + maskedValue: string; + position: number; + context?: string; + severity: 'low' | 'medium' | 'high'; +} + +export enum SensitiveDataType { + AWS_ACCESS_KEY = 'AWS_ACCESS_KEY', + AWS_SECRET_KEY = 'AWS_SECRET_KEY', + API_KEY = 'API_KEY', + TOKEN = 'TOKEN', + SLACK_TOKEN = 'SLACK_TOKEN', + GITHUB_TOKEN = 'GITHUB_TOKEN', + PRIVATE_KEY = 'PRIVATE_KEY', + PASSWORD = 'PASSWORD', + CUSTOM = 'CUSTOM' +} +``` + +--- + +## 4. SlackChannel + +Slackチャンネル情報を表すエンティティ。 + +### フィールド + +| フィールド名 | 型 | 必須 | 説明 | 制約 | +|------------|-----|------|------|------| +| `id` | `string` | ✓ | チャンネルID | 例: `C01234ABCD` | +| `name` | `string` | ✓ | チャンネル名 | 例: `general`, `random` | +| `isPrivate` | `boolean` | ✓ | プライベートチャンネルか | `true` = private, `false` = public | +| `isMember` | `boolean` | ✓ | ユーザーがメンバーか | `true` = joined | +| `memberCount` | `number` | - | メンバー数 | >= 0 | +| `purpose` | `string` | - | チャンネルの目的 | 最大250文字 | +| `topic` | `string` | - | チャンネルのトピック | 最大250文字 | + +### バリデーションルール + +- `id`: + - Slack Channel ID形式: `C` で始まる大文字英数字8-11桁 + - 例: `C01234ABCD`, `C9876ZYXW` +- `name`: + - 小文字英数字とハイフン・アンダースコアのみ + - 1-80文字 + - 例: `general`, `team-announcements`, `project_alpha` +- `isMember`: + - ワークフロー共有先として選択可能なのは `isMember === true` のチャンネルのみ + +### TypeScript型定義 + +```typescript +export interface SlackChannel { + id: string; + name: string; + isPrivate: boolean; + isMember: boolean; + memberCount?: number; + purpose?: string; + topic?: string; +} +``` + +--- + +## 5. WorkflowImportRequest + +Slackからワークフローをインポートするリクエスト情報。 + +### フィールド + +| フィールド名 | 型 | 必須 | 説明 | 制約 | +|------------|-----|------|------|------| +| `workflowId` | `string` | ✓ | インポートするワークフローのID | UUID v4形式 | +| `sourceMessageTs` | `string` | ✓ | インポート元メッセージのタイムスタンプ | Slack API形式 | +| `sourceChannelId` | `string` | ✓ | インポート元チャンネルID | 例: `C01234ABCD` | +| `fileId` | `string` | ✓ | ダウンロードするファイルID | 例: `F01234ABCD` | +| `targetDirectory` | `string` | ✓ | インポート先ディレクトリ | 絶対パス、例: `/Users/.../workflows/` | +| `overwriteExisting` | `boolean` | ✓ | 既存ファイルを上書きするか | デフォルト: `false` | +| `requestedAt` | `Date` | ✓ | リクエスト日時 | ISO 8601形式 | +| `status` | `ImportStatus` | ✓ | インポート状態 | 列挙型 (下記参照) | +| `errorMessage` | `string` | - | エラーメッセージ | `status === 'failed'` の場合に設定 | + +### ImportStatus 列挙型 + +```typescript +export enum ImportStatus { + PENDING = 'pending', // インポート待機中 + DOWNLOADING = 'downloading', // ファイルダウンロード中 + VALIDATING = 'validating', // ファイル検証中 + WRITING = 'writing', // ファイル書き込み中 + COMPLETED = 'completed', // インポート完了 + FAILED = 'failed' // インポート失敗 +} +``` + +### バリデーションルール + +- `targetDirectory`: + - 絶対パスであること + - `.vscode/workflows/` ディレクトリ内であること + - 存在しない場合は自動作成 +- `fileId`: + - Slack File ID形式: `F` で始まる大文字英数字8-11桁 + - 例: `F01234ABCD` +- `overwriteExisting`: + - `false`: 同名ファイルが存在する場合は確認ダイアログを表示 + - `true`: 確認なしで上書き + +### TypeScript型定義 + +```typescript +export interface WorkflowImportRequest { + workflowId: string; + sourceMessageTs: string; + sourceChannelId: string; + fileId: string; + targetDirectory: string; + overwriteExisting: boolean; + requestedAt: Date; + status: ImportStatus; + errorMessage?: string; +} + +export enum ImportStatus { + PENDING = 'pending', + DOWNLOADING = 'downloading', + VALIDATING = 'validating', + WRITING = 'writing', + COMPLETED = 'completed', + FAILED = 'failed' +} +``` + +### 状態遷移 + +``` +[PENDING] → ダウンロード開始 → [DOWNLOADING] +[DOWNLOADING] → ダウンロード完了 → [VALIDATING] +[VALIDATING] → 検証成功 → [WRITING] +[WRITING] → 書き込み完了 → [COMPLETED] + +[DOWNLOADING/VALIDATING/WRITING] → エラー発生 → [FAILED] +``` + +--- + +## エンティティ関係図 (ER図) + +``` +┌────────────────────────────┐ +│ SlackWorkspaceConnection │ +├────────────────────────────┤ +│ workspaceId (PK) │ +│ workspaceName │ +│ teamId │ +│ accessToken (Secret) │ +│ tokenScope[] │ +│ userId │ +│ authorizedAt │ +│ lastValidatedAt │ +└────────────────────────────┘ + │ + │ 1:N + ▼ +┌────────────────────────────┐ ┌──────────────────────┐ +│ SharedWorkflowMetadata │ N:1 │ SlackChannel │ +├────────────────────────────┤◄──────┤──────────────────────┤ +│ id (PK) │ │ id (PK) │ +│ name │ │ name │ +│ description │ │ isPrivate │ +│ version │ │ isMember │ +│ authorName │ │ memberCount │ +│ authorEmail │ │ purpose │ +│ sharedAt │ │ topic │ +│ channelId (FK) │ └──────────────────────┘ +│ channelName │ +│ messageTs │ +│ fileId │ +│ fileUrl │ +│ nodeCount │ +│ tags[] │ +│ hasSensitiveData │ +│ sensitiveDataOverride │ +└────────────────────────────┘ + │ + │ 1:N + ▼ +┌────────────────────────────┐ +│ SensitiveDataFinding │ +├────────────────────────────┤ +│ type │ +│ maskedValue │ +│ position │ +│ context │ +│ severity │ +└────────────────────────────┘ + + +┌────────────────────────────┐ +│ WorkflowImportRequest │ +├────────────────────────────┤ +│ workflowId (FK) │──────┐ +│ sourceMessageTs │ │ +│ sourceChannelId (FK) │ │ References +│ fileId │ ▼ +│ targetDirectory │ SharedWorkflowMetadata.id +│ overwriteExisting │ SlackChannel.id +│ requestedAt │ +│ status │ +│ errorMessage │ +└────────────────────────────┘ +``` + +--- + +## ストレージ設計 + +### VSCode Secret Storage + +**保存データ**: +- Slack OAuth Access Token +- Slack Workspace ID + +**キー形式**: +```typescript +const SECRET_KEYS = { + ACCESS_TOKEN: 'slack-oauth-access-token', + WORKSPACE_ID: 'slack-workspace-id' +} as const; +``` + +**セキュリティ要件**: +- 平文保存禁止 +- OS標準のKeychain/Credential Managerに保存 +- 拡張機能アンインストール時に自動削除 + +### ローカルファイルシステム + +**保存データ**: +- ワークフロー定義ファイル (`.vscode/workflows/*.json`) +- 共有履歴キャッシュ (`.vscode/workflow-sharing-history.json` - オプション) + +**ファイル形式**: +```json +// .vscode/workflow-sharing-history.json (例) +{ + "version": "1.0.0", + "lastSyncedAt": "2025-11-22T10:00:00Z", + "sharedWorkflows": [ + { + "id": "uuid-1234", + "name": "My Workflow", + "sharedAt": "2025-11-22T09:00:00Z", + "channelName": "general", + "messageTs": "1234567890.123456" + } + ] +} +``` + +--- + +## まとめ + +### 定義されたエンティティ + +1. **SlackWorkspaceConnection** - Slack接続管理 +2. **SharedWorkflowMetadata** - 共有ワークフローのメタデータ +3. **SensitiveDataFinding** - 機密情報検出結果 +4. **SlackChannel** - Slackチャンネル情報 +5. **WorkflowImportRequest** - インポートリクエスト + +### データフロー + +``` +[ワークフロー作成] → [機密情報検出] → [Slack共有] → [メタデータ保存] + ↓ +[Slackメッセージ] → [インポートリクエスト] → [ファイルダウンロード] → [ローカル保存] +``` + +### セキュリティ考慮事項 + +- OAuth Token: VSCode Secret Storageで暗号化保存 +- 機密情報: 検出後にマスク表示、元の値は保存しない +- ファイルアクセス: `.vscode/workflows/` ディレクトリ内のみに制限 diff --git a/specs/001-slack-workflow-sharing/plan.md b/specs/001-slack-workflow-sharing/plan.md new file mode 100644 index 00000000..55e11c96 --- /dev/null +++ b/specs/001-slack-workflow-sharing/plan.md @@ -0,0 +1,195 @@ +# 実装計画: Slack統合型ワークフロー共有 + +**ブランチ**: `001-slack-workflow-sharing` | **日付**: 2025-11-22 | **仕様**: [spec.md](./spec.md) +**入力**: `/specs/001-slack-workflow-sharing/spec.md` の機能仕様書 + +**注意**: このテンプレートは `/speckit.plan` コマンドによって記入されます。実行ワークフローについては `.specify/templates/commands/plan.md` を参照してください。 + +## 概要 + +Claude Code Workflow StudioにSlack統合機能を追加し、開発者がワークフロー定義ファイル(JSON)をSlackチャンネルに直接共有し、チームメンバーがSlackから1クリックでワークフローをインポートできる機能を提供します。 + +**主要要件**: +- VS CodeからSlackチャンネルへのワークフロー共有(リッチメッセージカード表示) +- Slackメッセージからワークフローの1クリックインポート +- 機密情報検出・警告機能(APIキー、トークンなど) +- 過去共有ワークフローの検索・フィルタリング機能(名前、作成者、日付、チャンネル) +- Slack App Directory公開による簡易インストール +- OAuth認証(VS Code拡張機能内のローカルHTTPサーバー処理) +- ワークフローファイルはSlackメッセージの添付ファイルとして保存(外部ストレージ不要) + +## 技術コンテキスト + + + +**言語/バージョン**: TypeScript 5.3 (VSCode Extension Host & Webview UI) +**主要な依存関係**: +- VSCode Extension API 1.80.0+ (Extension Host) +- React 18.2 (Webview UI) +- Slack Web API (@slack/web-api) - NEEDS CLARIFICATION: 最新バージョンの選定 +- Slack OAuth (@slack/oauth) - NEEDS CLARIFICATION: 認証フロー実装の詳細 +- Express.js - NEEDS CLARIFICATION: OAuthコールバック用ローカルHTTPサーバーのバージョン選定 +- Zustand (既存の状態管理ライブラリ) +**ストレージ**: +- ローカルファイルシステム (`.vscode/workflows/*.json` - 既存) +- VSCode Secret Storage (Slack OAuth トークン保存用) - NEEDS CLARIFICATION: 実装詳細 +- Slackメッセージの添付ファイル (ワークフロー共有データの永続化) +**テスト**: Manual E2E testing (既存方針に従う) +**ターゲットプラットフォーム**: VSCode Extension (Windows, macOS, Linux) +**プロジェクトタイプ**: VSCode Extension (既存プロジェクト拡張) +**パフォーマンス目標**: +- ワークフロー共有処理 < 3秒 (Slack API呼び出し含む) +- ワークフローインポート処理 < 2秒 +- 機密情報検出処理 < 500ms (ファイルサイズ100KB未満想定) +**制約**: +- Slack API rate limits (Tier 3: 20+ requests/minute) +- ワークフローファイルサイズ上限: 1MB (Slack添付ファイル制限を考慮) +- OAuth認証用ローカルHTTPサーバーはエフェメラルポート使用(ポート競合回避) +- 外部ストレージサービス (S3等) への依存を最小化 +**規模/スコープ**: +- 新規機能追加: Extension Host側 5-8ファイル、Webview UI側 3-5コンポーネント +- 既存コードベースへの影響: 最小限 (新規サービス追加が中心) +- Slack App Directory公開対応: アプリマニフェスト、OAuth設定、配布用ドキュメント + +## Constitution Check + +*ゲート: Phase 0 調査の前に合格する必要があります。Phase 1 設計後に再確認してください。* + +**参照**: `.specify/memory/constitution.md` の5つの原則に基づいて以下を確認する + +### I. コード品質原則 +- [x] 可読性とドキュメント化の要件が満たされているか + - すべてのSlack API連携サービス、OAuth処理、機密情報検出ロジックは適切にドキュメント化 + - 命名規則: `SlackWorkflowSharingService`, `SensitiveDataDetector`, `OAuthCallbackHandler` +- [x] 命名規則が明確に定義されているか + - Extension Host: `slack-*-service.ts` (例: `slack-api-service.ts`, `slack-oauth-service.ts`) + - Webview UI: React コンポーネント `Slack*.tsx` (例: `SlackShareDialog.tsx`, `SlackImportButton.tsx`) +- [x] コードの複雑度が妥当な範囲に収まっているか + - Slack API呼び出しは専用サービスに分離 + - OAuth フローは段階的に実装(認証 → トークン取得 → トークン保存) + - 機密情報検出は正規表現ベースの単純なパターンマッチング + +### II. テスト駆動開発 +- [ ] テストファースト開発プロセスが計画されているか + - **理由**: 既存プロジェクト方針がManual E2E testingのため、自動化されたTDDプロセスは適用しない + - Phase 2 (tasks.md) でManual E2Eテストシナリオを詳細に定義 +- [ ] 契約テスト・統合テスト・ユニットテストの計画があるか + - **理由**: 同上。既存プロジェクト方針に従い、Manual E2E testingのみ実施 +- [ ] テストカバレッジ目標(80%以上)が設定されているか + - **理由**: 自動テストを実施しないため、カバレッジ目標は設定しない + +### III. UX一貫性 +- [x] 一貫したUIパターンが定義されているか + - 既存ダイアログパターンに準拠 (`AiGenerationDialog.tsx` を参考) + - Slackチャンネル選択: ドロップダウン形式 + - 機密情報警告: 既存のエラーダイアログパターンを再利用 +- [x] エラーメッセージの明確性が確保されているか + - Slack API エラー: 具体的なエラー内容 + ユーザーが取るべきアクション + - OAuth エラー: 認証失敗理由 + 再試行手順 + - 機密情報検出警告: 検出された情報の種類 + 削除推奨メッセージ +- [x] アクセシビリティが考慮されているか + - キーボードショートカット: Ctrl/Cmd+Shift+S (Share to Slack) + - スクリーンリーダー対応: ARIA labels追加 + - 既存i18n対応に統合 (5言語: en, ja, ko, zh-CN, zh-TW) + +### IV. パフォーマンス基準 +- [x] API応答時間目標(p95 < 200ms)が検討されているか + - Slack API呼び出しは外部要因のため、この基準からは除外 + - ワークフロー共有処理全体: < 3秒 (Slack API呼び出し含む) + - ワークフローインポート処理: < 2秒 + - 機密情報検出処理: < 500ms (ファイルサイズ100KB未満想定) +- [x] データベース最適化が計画されているか + - **N/A**: データベース使用なし(ファイルシステム + VSCode Secret Storage) +- [x] フロントエンドロード時間目標が設定されているか(該当する場合) + - Webview UI: 既存のReactコンポーネントパターンに準拠 + - 新規ダイアログのロード時間: < 300ms + +### V. 保守性と拡張性 +- [x] モジュール化・疎結合設計が採用されているか + - Slack API連携: 専用サービスクラスに分離 (`SlackApiService`) + - OAuth処理: 独立したサービス (`SlackOAuthService`) + - 機密情報検出: 独立したユーティリティ (`SensitiveDataDetector`) + - Webview UI: React コンポーネントベースの設計 +- [x] 設定管理の方針が明確か + - Slack App credentials: 環境変数 (`SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`) + - OAuth tokens: VSCode Secret Storage (暗号化保存) + - 機密情報検出パターン: 設定ファイル (`.vscode/settings.json` で拡張可能) +- [x] バージョニング戦略が定義されているか + - Semantic Versioning (既存プロジェクト方針に従う) + - Slack App マニフェスト: バージョン番号を含む + - 破壊的変更時は移行ガイド提供 + +**違反の正当化**: +- テスト駆動開発 (II): 既存プロジェクト方針がManual E2E testingのため、自動テストは実施しない。複雑度追跡テーブルには記録不要(プロジェクト標準方針のため) + +## プロジェクト構造 + +### ドキュメント (この機能) + +```text +specs/[###-feature]/ +├── plan.md # このファイル (/speckit.plan コマンドの出力) +├── research.md # Phase 0 の出力 (/speckit.plan コマンド) +├── data-model.md # Phase 1 の出力 (/speckit.plan コマンド) +├── quickstart.md # Phase 1 の出力 (/speckit.plan コマンド) +├── contracts/ # Phase 1 の出力 (/speckit.plan コマンド) +└── tasks.md # Phase 2 の出力 (/speckit.tasks コマンド - /speckit.plan では作成されない) +``` + +### ソースコード (リポジトリルート) + + +```text +# VSCode Extension構造 (既存プロジェクトに新規機能追加) + +src/extension/ # Extension Host (Node.js) +├── services/ +│ ├── slack-api-service.ts # Slack Web API連携 (新規) +│ ├── slack-oauth-service.ts # OAuth認証フロー処理 (新規) +│ └── sensitive-data-detector.ts # 機密情報検出ユーティリティ (新規) +├── commands/ +│ ├── slack-share-workflow.ts # ワークフロー共有コマンド (新規) +│ └── slack-import-workflow.ts # ワークフローインポートコマンド (新規) +└── utils/ + └── oauth-callback-server.ts # ローカルHTTPサーバー (OAuth callback用) (新規) + +src/webview/ # Webview UI (React) +├── src/ +│ ├── components/ +│ │ ├── dialogs/ +│ │ │ └── SlackShareDialog.tsx # Slack共有ダイアログ (新規) +│ │ └── buttons/ +│ │ └── SlackImportButton.tsx # Slackインポートボタン (新規) +│ ├── services/ +│ │ └── slack-integration-service.ts # Extension Hostとの通信 (新規) +│ └── i18n/ +│ └── translations/ +│ ├── en.ts # 英語翻訳 (拡張) +│ ├── ja.ts # 日本語翻訳 (拡張) +│ ├── ko.ts # 韓国語翻訳 (拡張) +│ ├── zh-CN.ts # 中国語簡体字翻訳 (拡張) +│ └── zh-TW.ts # 中国語繁体字翻訳 (拡張) + +.vscode/ +└── workflows/ # ワークフロー定義ファイル (既存) + └── *.json + +resources/ # Slack App関連リソース (新規) +├── slack-app-manifest.json # Slack App マニフェスト +└── oauth-redirect.html # OAuth認証リダイレクトページ +``` + +**構造の決定**: VSCode Extension構造(既存プロジェクト拡張)を採用。Extension Host側に3つの新規サービス、2つの新規コマンド、1つのユーティリティを追加。Webview UI側に2つの新規コンポーネント、1つの新規サービスを追加。既存のi18n対応に5言語の翻訳を拡張。 + +## 複雑度追跡 + +> **Constitution Checkで正当化が必要な違反がある場合のみ記入** + +該当なし。すべてのConstitution Check項目が合格しており、正当化が必要な違反はありません。 diff --git a/specs/001-slack-workflow-sharing/quickstart.md b/specs/001-slack-workflow-sharing/quickstart.md new file mode 100644 index 00000000..f67eaa65 --- /dev/null +++ b/specs/001-slack-workflow-sharing/quickstart.md @@ -0,0 +1,410 @@ +# Quickstart Guide: Slack統合型ワークフロー共有 + +**Feature**: 001-slack-workflow-sharing +**Audience**: 開発者 (実装担当者) +**Date**: 2025-11-22 + +## 目次 + +1. [開発環境セットアップ](#1-開発環境セットアップ) +2. [Slack App設定](#2-slack-app設定) +3. [ローカル開発](#3-ローカル開発) +4. [実装ガイド](#4-実装ガイド) +5. [テスト](#5-テスト) +6. [トラブルシューティング](#6-トラブルシューティング) + +--- + +## 1. 開発環境セットアップ + +### 前提条件 + +- Node.js 18.x 以上 +- VS Code 1.80.0 以上 +- npm または yarn + +### 依存関係のインストール + +```bash +# プロジェクトルートで実行 +npm install + +# 新規依存関係の追加 (Slack SDK) +npm install @slack/web-api + +# TypeScript型定義 +npm install --save-dev @types/node +``` + +--- + +## 2. Slack App設定 + +### 2.1 Slack Appの作成 + +1. [Slack API](https://api.slack.com/apps) にアクセス +2. 「Create New App」→ 「From scratch」を選択 +3. App Name: `Claude Code Workflow Studio` (例) +4. Workspace: 開発用ワークスペースを選択 + +### 2.2 Bot Token Scopesの設定 + +1. 「OAuth & Permissions」を開く +2. 「Scopes」→ 「Bot Token Scopes」に以下を追加: + - `chat:write` - メッセージ投稿 + - `files:write` - ファイルアップロード + - `channels:read` - チャンネル一覧取得 + - `groups:read` - メッセージ検索 + +### 2.3 Appのワークスペースへのインストール + +1. 「OAuth & Permissions」ページの上部にある「Install to Workspace」ボタンをクリック +2. アクセス許可を確認して「Allow」をクリック +3. インストール完了後、「Bot User OAuth Token」が表示される + +### 2.4 Bot User Tokenの取得 + +1. 「OAuth & Permissions」ページで「Bot User OAuth Token」を確認 +2. トークンは `xoxb-` で始まる文字列 (例: `xoxb-YOUR-TOKEN-HERE`) +3. このトークンをVS Code拡張機能で使用する (初回共有時に入力を求められる) + +**重要**: Bot User Tokenは秘密情報です。コードにハードコードしたり、公開リポジトリにコミットしないでください。 + +--- + +## 3. ローカル開発 + +### 3.1 拡張機能のビルド + +```bash +# TypeScriptコンパイル +npm run build + +# またはwatchモードで開発 +npm run watch +``` + +### 3.2 拡張機能のデバッグ + +1. VS Codeで `F5` を押す +2. Extension Development Hostウィンドウが開く +3. Workflow Studioを開き、ツールバーの「Share to Slack」ボタンをクリック +4. Bot User Tokenが未設定の場合、トークン入力ダイアログが表示される +5. Slack App設定で取得したBot User Token (xoxb-で始まる文字列) を入力 + +### 3.3 ホットリロード + +TypeScriptファイルを編集後: +1. `npm run build` (または watch mode) +2. Extension Development Hostで `Ctrl/Cmd+R` (Reload Window) + +--- + +## 4. 実装ガイド + +### 4.1 プロジェクト構造 + +``` +src/extension/ +├── services/ +│ ├── slack-api-service.ts # 実装する +│ └── sensitive-data-detector.ts # 実装する +├── commands/ +│ ├── slack-share-workflow.ts # 実装する +│ └── slack-import-workflow.ts # 実装する + +src/webview/src/ +├── components/ +│ ├── dialogs/ +│ │ ├── SlackShareDialog.tsx # 実装済み +│ │ └── SlackManualTokenDialog.tsx # 実装済み +│ └── buttons/ +│ └── SlackImportButton.tsx # 実装する +└── services/ + └── slack-integration-service.ts # 実装する +``` + +### 4.2 実装の優先順位 + +**Phase 1** (トークン管理・基本機能): +1. `SlackManualTokenDialog.tsx` - トークン入力ダイアログUI ✓ (実装済み) +2. `slack-api-service.ts` - Slack API連携とトークン検証 + +**Phase 2** (共有機能): +3. `sensitive-data-detector.ts` - 機密情報検出 +4. `slack-share-workflow.ts` - ワークフロー共有コマンド +5. `SlackShareDialog.tsx` - 共有ダイアログUI ✓ (実装済み) + +**Phase 3** (インポート機能): +6. `slack-import-workflow.ts` - ワークフローインポートコマンド +7. `SlackImportButton.tsx` - インポートボタンUI + +**Phase 4** (検索機能): +8. `slack-api-service.ts` - 検索API実装 (拡張) +9. Webview UI - 検索UI追加 + +### 4.3 コード例 + +#### slack-api-service.ts (骨格) - トークン検証 + +```typescript +import * as vscode from 'vscode'; +import { WebClient } from '@slack/web-api'; + +export class SlackApiService { + private client: WebClient | null = null; + + constructor(private context: vscode.ExtensionContext) {} + + /** + * Bot User Tokenを検証し、有効な場合はクライアントを初期化 + */ + async validateAndStoreToken(token: string): Promise<{ valid: boolean; workspaceName?: string; error?: string }> { + try { + const client = new WebClient(token); + const authTest = await client.auth.test(); + + if (!authTest.ok) { + return { valid: false, error: 'Token validation failed' }; + } + + // トークンを安全に保存 + await this.context.secrets.store('slack-bot-token', token); + this.client = client; + + return { + valid: true, + workspaceName: authTest.team as string + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * 保存されたトークンを取得 + */ + async getStoredToken(): Promise { + return await this.context.secrets.get('slack-bot-token'); + } + + /** + * Slack APIクライアントを取得 (トークンが保存されている場合) + */ + async getClient(): Promise { + if (this.client) { + return this.client; + } + + const token = await this.getStoredToken(); + if (!token) { + return null; + } + + this.client = new WebClient(token); + return this.client; + } +} +``` + +#### sensitive-data-detector.ts (骨格) + +```typescript +export const SENSITIVE_PATTERNS = { + AWS_ACCESS_KEY: /AKIA[0-9A-Z]{16}/g, + SLACK_TOKEN: /xox[baprs]-[0-9a-zA-Z-]{10,}/g, + API_KEY: /api[_-]?key["\s:=]+["']?([0-9a-zA-Z-_]{20,})/gi, + // ... 他のパターン +}; + +export class SensitiveDataDetector { + detect(content: string): SensitiveDataFinding[] { + const findings: SensitiveDataFinding[] = []; + + for (const [type, pattern] of Object.entries(SENSITIVE_PATTERNS)) { + const matches = content.matchAll(pattern); + for (const match of matches) { + findings.push({ + type, + maskedValue: this.maskValue(match[0]), + position: match.index!, + severity: this.getSeverity(type) + }); + } + } + + return findings; + } + + private maskValue(value: string): string { + if (value.length <= 8) return '***'; + return `${value.substring(0, 4)}...${value.substring(value.length - 4)}`; + } + + private getSeverity(type: string): 'low' | 'medium' | 'high' { + const highSeverity = ['AWS_ACCESS_KEY', 'PRIVATE_KEY']; + const mediumSeverity = ['API_KEY', 'TOKEN', 'SLACK_TOKEN']; + + if (highSeverity.includes(type)) return 'high'; + if (mediumSeverity.includes(type)) return 'medium'; + return 'low'; + } +} +``` + +--- + +## 5. テスト + +### 5.1 Manual E2E Testing + +**T001: Bot User Token設定テスト** +1. Workflow Studioを開く +2. ツールバーの「Share to Slack」ボタンをクリック +3. Bot User Tokenが未設定の場合、トークン入力ダイアログが表示される +4. Slack App設定で取得したBot User Token (xoxb-で始まる文字列) を入力 +5. トークン検証が成功し、ワークスペース名が表示される +6. 保存後、チャンネル選択ダイアログが表示される + +**T002: ワークフロー共有テスト** +1. Workflow Studioを開く +2. ツールバー(上部)の「Share to Slack」ボタンをクリック +3. チャンネル選択ダイアログで共有先を選択 +4. 機密情報警告が表示されないことを確認 (機密情報がない場合) +5. Slackチャンネルでリッチメッセージカードを確認 + +**T003: 機密情報検出テスト** +1. ワークフローファイルにAWSキー (`AKIA1234567890ABCDEF`) を含める +2. ツールバー(上部)の「Share to Slack」ボタンをクリック +3. 機密情報警告ダイアログが表示されることを確認 +4. マスク済みの値 (`AKIA...CDEF`) が表示されることを確認 +5. 「続行」を選択して共有完了 + +**T004: ワークフローインポートテスト** +⚠️ **注意**: ワークフローインポート機能はUser Story 2の機能です。User Story 1のみ実装している状態では、この機能は未実装です。 +1. Slackメッセージの「Import to VS Code」ボタンをクリック +2. VS Codeに戻り、インポート成功通知を確認 +3. `.vscode/workflows/` にファイルが保存されていることを確認 + +**T005: 上書き確認テスト** +⚠️ **注意**: ワークフローインポート機能はUser Story 2の機能です。User Story 1のみ実装している状態では、この機能は未実装です。 +1. 既存ワークフローと同名のファイルをインポート +2. 上書き確認ダイアログが表示されることを確認 +3. 「上書き」を選択してインポート完了 + +**T006: 検索テスト** +⚠️ **注意**: `Slack: Search Workflows`コマンドはUser Story 3の機能です。User Story 1のみ実装している状態では、この機能は未実装です。 +1. コマンドパレットで `Slack: Search Workflows` を実行 +2. 検索クエリを入力 (例: `data processing`) +3. 過去に共有されたワークフローがリスト表示されることを確認 +4. ワークフローを選択してインポート + +### 5.2 エラーケースのテスト + +**E001: トークン未設定エラー** +1. Bot User Token未設定の状態でツールバーの「Share to Slack」ボタンをクリック +2. トークン入力ダイアログが表示されることを確認 + +**E002: チャンネルアクセスエラー** +1. Botが参加していないチャンネルに共有を試行 +2. 「チャンネルに招待してください」エラーが表示されることを確認 + +**E003: ネットワークエラー** +1. ネットワークを切断 +2. ワークフロー共有を試行 +3. 「ネットワークエラー」が表示されることを確認 + +### 5.3 パフォーマンステスト + +**P001: 共有処理時間** +- 目標: < 3秒 (Slack API呼び出し含む) +- 測定方法: `console.time()` / `console.timeEnd()` でログ出力 + +**P002: インポート処理時間** +- 目標: < 2秒 +- 測定方法: 同上 + +**P003: 機密情報検出時間** +- 目標: < 500ms (100KB未満のファイル) +- 測定方法: 同上 + +--- + +## 6. トラブルシューティング + +### 問題: Bot User Tokenの検証が失敗する + +**原因**: +- トークンが無効または期限切れ +- トークンの形式が正しくない (xoxb-で始まっていない) +- 必要なスコープが不足している + +**解決方法**: +1. Slack App設定の「OAuth & Permissions」ページで新しいトークンを取得 +2. トークンが `xoxb-` で始まることを確認 +3. 以下のBot Token Scopesが追加されているか確認: + - `chat:write` + - `files:write` + - `channels:read` + - `groups:read` +4. スコープ追加後、AppをワークスペースにReinstallして新しいトークンを取得 + +--- + +### 問題: トークンが保存されない + +**原因**: +- VSCode Secret Storageへのアクセス権限がない + +**解決方法**: +1. macOS: Keychainアクセス許可を確認 +2. Windows: Credential Managerへのアクセスを確認 +3. Linux: libsecretがインストールされているか確認 + +--- + +### 問題: ワークフローがSlackに表示されない + +**原因**: +- チャンネルにBotが参加していない +- メッセージ投稿権限がない + +**解決方法**: +1. Slackチャンネルで `/invite @Claude Code Workflows` を実行 +2. Botをチャンネルメンバーに追加 + +--- + +### 問題: Rate Limit超過エラー + +**原因**: +- Slack API Rate Limitに達した + +**解決方法**: +1. エラーメッセージの `Retry-After` 時間を待つ +2. `@slack/web-api` の自動リトライ機能が動作しているか確認 +3. 連続リクエストを避ける(バッチ処理の検討) + +--- + +## 次のステップ + +1. `tasks.md` を生成して実装タスクを詳細化 (`/speckit.tasks`) +2. 優先度P1のユーザーストーリーから実装開始 +3. Manual E2Eテストを実施しながら段階的に機能追加 +4. Bot User Token管理機能の拡張 (トークン再設定、ワークスペース情報表示など) + +--- + +## 参考リンク + +- [Slack API Documentation](https://api.slack.com/docs) +- [@slack/web-api SDK](https://slack.dev/node-slack-sdk/web-api) +- [Slack Block Kit Builder](https://app.slack.com/block-kit-builder) +- [VS Code Extension API](https://code.visualstudio.com/api) +- [Feature Specification](./spec.md) +- [API Contracts](./contracts/) +- [Data Model](./data-model.md) diff --git a/specs/001-slack-workflow-sharing/research.md b/specs/001-slack-workflow-sharing/research.md new file mode 100644 index 00000000..66407e35 --- /dev/null +++ b/specs/001-slack-workflow-sharing/research.md @@ -0,0 +1,537 @@ +# Phase 0 調査: Slack統合型ワークフロー共有 + +**Feature**: 001-slack-workflow-sharing +**Date**: 2025-11-22 +**Status**: Complete + +## 概要 + +このドキュメントは、Slack統合機能の実装に必要な技術選定と設計判断を文書化します。主に以下の4つの「NEEDS CLARIFICATION」項目を解決します: + +1. Slack Web API ライブラリのバージョン選定 +2. Slack OAuth 認証フローの実装詳細 +3. OAuthコールバック用ローカルHTTPサーバーの技術選定 +4. VSCode Secret Storageの実装詳細 + +--- + +## 1. Slack Web API ライブラリ (@slack/web-api) + +### Decision +`@slack/web-api` バージョン **7.x** (latest stable) を採用 + +### Rationale + +**調査結果**: +- `@slack/web-api` は Slack公式のNode.js SDKであり、TypeScriptサポートが充実 +- バージョン 7.x は以下の機能を提供: + - `chat.postMessage` - リッチメッセージカード投稿 + - `conversations.list` - チャンネル一覧取得 + - `files.upload` - ファイルアップロード(ワークフローJSON添付) + - `search.messages` - 過去メッセージ検索 + - Rate limit handling - 自動リトライ機能 + - TypeScript型定義完備 + +**必要な主要API**: +```typescript +import { WebClient } from '@slack/web-api'; + +const client = new WebClient(token); + +// 1. ワークフロー共有 (リッチメッセージ投稿) +await client.chat.postMessage({ + channel: channelId, + blocks: [...], // Slack Block Kit形式 + attachments: [{ + fallback: 'Workflow file', + // ワークフローJSONファイル添付 + }] +}); + +// 2. ファイルアップロード (ワークフローJSON) +await client.files.uploadV2({ + channel_id: channelId, + file: workflowJsonBuffer, + filename: 'workflow.json' +}); + +// 3. チャンネル一覧取得 +await client.conversations.list({ + types: 'public_channel,private_channel' +}); + +// 4. 過去共有ワークフロー検索 +await client.search.messages({ + query: 'workflow filename:*.json' +}); +``` + +### Alternatives Considered + +| Alternative | Pros | Cons | 却下理由 | +|------------|------|------|---------| +| REST API直接呼び出し | 依存関係なし | 型安全性なし、エラーハンドリング自前実装 | TypeScript型定義が重要 | +| `@slack/bolt` | フレームワーク機能豊富 | 過剰な依存関係、VS Code拡張に不適 | 軽量なWeb APIのみで十分 | + +### Implementation Notes + +- **Rate Limit対応**: `@slack/web-api` は自動リトライ機能を提供(Tier 3: 20+ req/min) +- **Error Handling**: `WebAPICallError` を適切にハンドリング +- **Token管理**: VSCode Secret Storageに保存されたトークンを使用 + +--- + +## 2. Slack OAuth 認証フロー (@slack/oauth) + +### Decision +`@slack/oauth` **v2.7.x** を使用せず、**手動でOAuth 2.0フローを実装** + +### Rationale + +**`@slack/oauth` の問題点**: +- Express.jsベースのWebサーバー起動を前提としており、VS Code拡張機能に組み込むには過剰 +- VS Code拡張機能は軽量なローカルHTTPサーバーのみで十分 +- 依存関係の肥大化を避けたい + +**手動実装のメリット**: +- 必要最小限のコード(< 100行) +- VS Code拡張機能の制約に最適化 +- デバッグが容易 + +**OAuth 2.0 フロー設計**: + +``` +[VS Code Extension] → [ブラウザ] → [Slack OAuth] → [ローカルサーバー] → [VS Code Extension] +``` + +**実装ステップ**: + +1. **Authorization URL生成**: +```typescript +const authUrl = `https://slack.com/oauth/v2/authorize?` + + `client_id=${SLACK_CLIENT_ID}&` + + `scope=channels:read,chat:write,files:write,groups:read&` + + `redirect_uri=http://localhost:${EPHEMERAL_PORT}/oauth/callback`; + +vscode.env.openExternal(vscode.Uri.parse(authUrl)); +``` + +2. **ローカルHTTPサーバー起動** (エフェメラルポート): +```typescript +import * as http from 'http'; + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url!, `http://localhost:${port}`); + const code = url.searchParams.get('code'); + + if (code) { + // Step 3: トークン交換 + const tokenResponse = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: SLACK_CLIENT_ID, + client_secret: SLACK_CLIENT_SECRET, + code: code, + redirect_uri: `http://localhost:${port}/oauth/callback` + }) + }); + + const { access_token } = await tokenResponse.json(); + + // Step 4: トークン保存 (VSCode Secret Storage) + await context.secrets.store('slack-access-token', access_token); + + // Step 5: 成功レスポンス + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

認証成功!

このウィンドウを閉じてVS Codeに戻ってください。

'); + + server.close(); + } +}); + +server.listen(0); // エフェメラルポート +const port = (server.address() as AddressInfo).port; +``` + +3. **トークンリフレッシュ対応**: + - Slack OAuth v2 は `refresh_token` を提供しない(長期間有効な `access_token` のみ) + - トークン失効時は再認証をユーザーに促す + +### Alternatives Considered + +| Alternative | Pros | Cons | 却下理由 | +|------------|------|------|---------| +| `@slack/oauth` | 公式ライブラリ | Express.js依存、過剰な機能 | VS Code拡張に不適 | +| Webviewベース認証 | UIが統合される | Slack OAuthがiframe禁止、CORSエラー | 技術的に実現不可 | + +--- + +## 3. OAuthコールバック用ローカルHTTPサーバー + +### Decision +Node.js標準ライブラリ `http` モジュールを使用(**Express.js不使用**) + +### Rationale + +**Express.js不使用の理由**: +- OAuthコールバックは1回限りの単純なHTTPリクエスト処理 +- Express.jsの機能(ルーティング、ミドルウェア)は不要 +- 依存関係の追加を避け、バンドルサイズを最小化 + +**Node.js `http` モジュールの利点**: +- 標準ライブラリのため追加依存なし +- 軽量(< 50行で実装可能) +- VS Code Extension Hostで動作保証 + +**実装例**: +```typescript +import * as http from 'http'; +import * as vscode from 'vscode'; + +export class OAuthCallbackServer { + private server: http.Server | null = null; + private port: number = 0; + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer(this.handleRequest.bind(this)); + + this.server.listen(0, () => { // エフェメラルポート (OS割り当て) + const address = this.server!.address() as AddressInfo; + this.port = address.port; + console.log(`OAuth callback server listening on port ${this.port}`); + resolve(this.port); + }); + + this.server.on('error', reject); + }); + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { + const url = new URL(req.url!, `http://localhost:${this.port}`); + + if (url.pathname === '/oauth/callback') { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('

認証エラー

Slack認証に失敗しました。

'); + this.close(); + return; + } + + if (code) { + // トークン交換処理(省略) + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('

認証成功!

VS Codeに戻ってください。

'); + this.close(); + } + } else { + res.writeHead(404); + res.end(); + } + } + + close() { + if (this.server) { + this.server.close(); + this.server = null; + } + } +} +``` + +**セキュリティ考慮事項**: +- **CSRF対策**: `state` パラメータを使用してリクエスト検証 +- **タイムアウト**: サーバーは30秒後に自動クローズ +- **localhost限定**: `127.0.0.1` にバインドし外部アクセスを防止 + +### Alternatives Considered + +| Alternative | Pros | Cons | 却下理由 | +|------------|------|------|---------| +| Express.js | 開発者に馴染みがある | 依存関係追加、過剰な機能 | 単純なコールバックに不要 | +| `fastify` | 軽量、高速 | 依存関係追加 | 標準ライブラリで十分 | + +--- + +## 4. VSCode Secret Storage 実装詳細 + +### Decision +VSCode Extension API の `context.secrets` を使用してSlack OAuth トークンを暗号化保存 + +### Rationale + +**VSCode Secret Storage の特徴**: +- OS標準のキーチェーン/資格情報マネージャーに保存(暗号化) + - macOS: Keychain + - Windows: Credential Manager + - Linux: libsecret +- VS Code 1.53.0+ で利用可能 +- 同期設定が有効な場合、デバイス間で暗号化同期される +- 機密情報の平文保存を回避 + +**実装例**: + +```typescript +import * as vscode from 'vscode'; + +export class SlackTokenManager { + private static readonly TOKEN_KEY = 'slack-oauth-access-token'; + private static readonly WORKSPACE_KEY = 'slack-workspace-id'; + + constructor(private context: vscode.ExtensionContext) {} + + // トークン保存 + async saveToken(accessToken: string, workspaceId: string): Promise { + await this.context.secrets.store(SlackTokenManager.TOKEN_KEY, accessToken); + await this.context.secrets.store(SlackTokenManager.WORKSPACE_KEY, workspaceId); + } + + // トークン取得 + async getToken(): Promise { + return await this.context.secrets.get(SlackTokenManager.TOKEN_KEY); + } + + // トークン削除(ログアウト時) + async deleteToken(): Promise { + await this.context.secrets.delete(SlackTokenManager.TOKEN_KEY); + await this.context.secrets.delete(SlackTokenManager.WORKSPACE_KEY); + } + + // トークン有効性チェック + async validateToken(): Promise { + const token = await this.getToken(); + if (!token) return false; + + try { + const client = new WebClient(token); + await client.auth.test(); // Slack API: トークン検証 + return true; + } catch (error) { + // トークン無効 → 削除 + await this.deleteToken(); + return false; + } + } +} +``` + +**セキュリティベストプラクティス**: +- トークンは決してログに出力しない +- トークンはメモリ上で最小限の時間のみ保持 +- 拡張機能アンインストール時は自動的に削除される + +**エラーハンドリング**: +```typescript +try { + await tokenManager.saveToken(accessToken, workspaceId); +} catch (error) { + vscode.window.showErrorMessage( + 'Slackトークンの保存に失敗しました。OSのキーチェーンへのアクセスを確認してください。' + ); + console.error('Secret storage error:', error); +} +``` + +### Alternatives Considered + +| Alternative | Pros | Cons | 却下理由 | +|------------|------|------|---------| +| `.vscode/settings.json` | 実装簡単 | 平文保存、セキュリティリスク | 機密情報の平文保存は不可 | +| 環境変数 | 開発時便利 | ユーザーが手動設定必要 | UX悪い | +| 独自暗号化 + ファイル保存 | 完全制御可能 | セキュリティリスク、車輪の再発明 | OS標準機能を信頼すべき | + +--- + +## 5. 機密情報検出パターン + +### Decision +正規表現ベースのパターンマッチングを使用(拡張可能設計) + +### Rationale + +**検出対象パターン**: + +```typescript +export const SENSITIVE_PATTERNS = { + // AWS認証情報 + AWS_ACCESS_KEY: /AKIA[0-9A-Z]{16}/g, + AWS_SECRET_KEY: /[0-9a-zA-Z/+=]{40}/g, + + // APIキー (一般) + API_KEY: /api[_-]?key["\s:=]+["']?([0-9a-zA-Z-_]{20,})/gi, + + // トークン (一般) + TOKEN: /token["\s:=]+["']?([0-9a-zA-Z-_\.]{20,})/gi, + + // Slack Token + SLACK_TOKEN: /xox[baprs]-[0-9a-zA-Z-]{10,}/g, + + // GitHub Personal Access Token + GITHUB_TOKEN: /ghp_[0-9a-zA-Z]{36}/g, + + // 秘密鍵 + PRIVATE_KEY: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/g, + + // パスワード (シンプルな検出) + PASSWORD: /password["\s:=]+["']?([^\s"']{8,})/gi, +}; + +export class SensitiveDataDetector { + detect(workflowJson: string): Array<{ type: string; value: string; position: number }> { + const findings: Array<{ type: string; value: string; position: number }> = []; + + for (const [type, pattern] of Object.entries(SENSITIVE_PATTERNS)) { + const matches = workflowJson.matchAll(pattern); + for (const match of matches) { + findings.push({ + type, + value: this.maskValue(match[0]), + position: match.index! + }); + } + } + + return findings; + } + + private maskValue(value: string): string { + // 最初の4文字と最後の4文字のみ表示 + if (value.length <= 8) return '***'; + return `${value.substring(0, 4)}...${value.substring(value.length - 4)}`; + } +} +``` + +**拡張可能性**: +- `.vscode/settings.json` でカスタムパターン追加可能: +```json +{ + "claudeCodeWorkflowStudio.slackIntegration.customSensitivePatterns": [ + { + "name": "Custom API Key", + "pattern": "my-custom-pattern-[0-9a-f]{32}" + } + ] +} +``` + +**ユーザー警告UI**: +```typescript +const findings = detector.detect(workflowJson); +if (findings.length > 0) { + const message = `機密情報が検出されました:\n${findings.map(f => `- ${f.type}: ${f.value}`).join('\n')}`; + const action = await vscode.window.showWarningMessage( + message, + { modal: true }, + '続行', + 'キャンセル' + ); + + if (action !== '続行') { + return; // 共有中止 + } +} +``` + +### Alternatives Considered + +| Alternative | Pros | Cons | 却下理由 | +|------------|------|------|---------| +| ML/AIベース検出 | 高精度 | 実装複雑、依存関係大 | 過剰 | +| 外部API (e.g., GitGuardian) | 高精度、メンテナンス不要 | ネットワーク依存、コスト | プライバシー懸念 | + +--- + +## 6. Slack Block Kit メッセージ設計 + +### Decision +Slack Block Kit を使用してリッチメッセージカードを作成 + +### Block Kit構造 + +```typescript +export function buildWorkflowMessageBlocks(workflow: Workflow, author: string): any[] { + return [ + { + type: 'header', + text: { + type: 'plain_text', + text: `🔧 Workflow: ${workflow.name}` + } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Author:*\n${author}` }, + { type: 'mrkdwn', text: `*Version:*\n${workflow.version}` }, + { type: 'mrkdwn', text: `*Nodes:*\n${workflow.nodes.length}` }, + { type: 'mrkdwn', text: `*Created:*\n${new Date().toISOString()}` } + ] + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: workflow.description || '_No description_' + } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: '📥 Import to VS Code' }, + style: 'primary', + value: workflow.id, + action_id: 'import_workflow' + } + ] + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Shared from Claude Code Workflow Studio` + } + ] + } + ]; +} +``` + +**インタラクティブ機能**: +- 「Import to VS Code」ボタン → Slack App がdeep linkを生成 → VS Code拡張機能がハンドリング + +--- + +## まとめ + +### 最終的な技術スタック + +| コンポーネント | 技術選定 | バージョン | +|--------------|---------|----------| +| Slack API連携 | `@slack/web-api` | 7.x (latest) | +| OAuth認証 | 手動実装 (Node.js `http`) | - | +| ローカルHTTPサーバー | Node.js `http` 標準ライブラリ | - | +| トークン保存 | VSCode Secret Storage | VSCode 1.53.0+ | +| 機密情報検出 | 正規表現パターンマッチング | - | +| メッセージUI | Slack Block Kit | v2 | + +### すべての NEEDS CLARIFICATION 解決 + +✅ **Slack Web API**: `@slack/web-api` v7.x 採用 +✅ **Slack OAuth**: 手動実装(`@slack/oauth` 不使用) +✅ **HTTPサーバー**: Node.js `http` 標準ライブラリ(Express.js 不使用) +✅ **Secret Storage**: VSCode `context.secrets` API 使用 + +### 次のステップ + +Phase 1 (Design & Contracts) に進み、以下を生成: +- `data-model.md`: ワークフロー共有のデータモデル +- `contracts/`: Slack API呼び出し仕様 +- `quickstart.md`: 開発者向けクイックスタートガイド diff --git a/specs/001-slack-workflow-sharing/spec.md b/specs/001-slack-workflow-sharing/spec.md new file mode 100644 index 00000000..263d7ff2 --- /dev/null +++ b/specs/001-slack-workflow-sharing/spec.md @@ -0,0 +1,169 @@ +# 機能仕様書: Slack統合型ワークフロー共有 + +**機能ブランチ**: `001-slack-workflow-sharing` +**作成日**: 2025-11-22 +**ステータス**: ドラフト +**入力**: ユーザー説明: "Claude Code Workflow StudioにSlack統合型ワークフロー共有機能を追加する。開発者がVS Code上で作成したワークフロー定義ファイル(JSON)を、チームのSlackチャンネルに直接共有できるようにする。共有されたワークフローは、Slack上でリッチなメッセージカードとして表示され、ワークフロー名、作成者、説明などのメタデータが一目で分かる。他のチームメンバーは、Slackのメッセージからワンクリックで自分のVS Codeにワークフローをインポートできる。手動でのファイルダウンロード、ディレクトリ配置、エディタでの開く操作が不要になる。これにより、プロトタイピング段階でのワークフロー共有が迅速化され、チーム内コラボレーションが促進される。Gitのような重いバージョン管理プロセスを経ずに、アドホックな共有が可能になる。セキュリティ面では、認証情報を安全に管理し、ワークフローに含まれるAPIキーなどの機密情報が誤って共有されないように警告する仕組みを含む。また、VS Code内からSlackに過去に共有されたワークフローを検索し、再利用できる機能も提供する。" + +## ユーザーシナリオとテスト + +### ユーザーストーリー 1 - VS CodeからSlackへのワークフロー共有 (優先度: P1) + +開発者がVS Code上で作成したワークフロー定義ファイルを、チームのSlackチャンネルに直接共有します。共有時には、機密情報の検出と警告が行われ、安全な共有が保証されます。Slack上では、ワークフロー名、作成者、説明などのメタデータを含むリッチメッセージカードとして表示されます。 + +**この優先度の理由**: ワークフロー共有のコア機能であり、この機能がなければ他の機能が成立しません。チーム内でのワークフロー配布の基盤となる最も重要な機能です。 + +**独立したテスト**: VS Code上でワークフローを選択し、Slackチャンネルに共有することで完全にテストでき、チームメンバーがSlack上でワークフローの存在を確認できる価値を提供します。 + +**受け入れシナリオ**: + +1. **前提** 開発者がVS Code上でワークフロー定義ファイルを開いている、**実行** 「Slackに共有」コマンドを実行する、**結果** 共有先チャンネル選択ダイアログが表示される +2. **前提** 共有先チャンネルを選択済み、**実行** 共有を実行する、**結果** ワークフローがSlackチャンネルにリッチメッセージカードとして投稿される +3. **前提** ワークフローにAPIキーまたは認証トークンが含まれている、**実行** 共有を実行する、**結果** 機密情報検出の警告ダイアログが表示され、続行または中止を選択できる +4. **前提** 機密情報検出警告で「続行」を選択した、**実行** 共有を完了する、**結果** Slackメッセージに機密情報が含まれる可能性がある旨の注意書きが追加される +5. **前提** Bot User Tokenが未設定、**実行** 「Slackに共有」コマンドを実行する、**結果** トークン入力ダイアログが表示され、Bot User Tokenを入力できる +6. **前提** ワークフロー定義ファイルが不正な形式である、**実行** 共有を実行する、**結果** エラーメッセージが表示され、共有が中止される + +--- + +### ユーザーストーリー 2 - Slackからのワンクリックインポート (優先度: P1) + +チームメンバーがSlack上で共有されたワークフローメッセージから、ワンクリックで自分のVS Codeにワークフローをインポートします。手動でのファイルダウンロード、ディレクトリ配置、エディタでの開く操作は不要です。 + +**この優先度の理由**: ワークフロー共有機能の価値を最大化するために必須です。簡単にインポートできなければ、共有機能の利便性が大きく損なわれます。 + +**独立したテスト**: Slackメッセージの「VS Codeで開く」ボタンをクリックすることで完全にテストでき、VS Code上でワークフローが自動的に開かれる価値を提供します。 + +**受け入れシナリオ**: + +1. **前提** チームメンバーがSlackでワークフロー共有メッセージを閲覧している、**実行** 「VS Codeで開く」ボタンをクリックする、**結果** VS Codeが起動し、ワークフローが自動的にインポートされて開かれる +2. **前提** 同名のワークフローファイルが既に存在する、**実行** インポートを実行する、**結果** 上書き確認ダイアログが表示され、ユーザーが上書きまたはキャンセルを選択できる +3. **前提** VS Codeが起動していない状態、**実行** 「VS Codeで開く」ボタンをクリックする、**結果** VS Codeが自動的に起動し、ワークフローがインポートされる +4. **前提** ワークフローファイルが破損している、**実行** インポートを実行する、**結果** エラーメッセージが表示され、インポートが失敗する + +--- + +### ユーザーストーリー 3 - VS Code内からの過去ワークフロー検索・再利用 (優先度: P2) + +開発者がVS Code内から、Slackに過去に共有されたワークフローを検索し、再利用できます。チャンネル名、ワークフロー名、作成者、共有日時などで絞り込み検索が可能です。 + +**この優先度の理由**: 共有されたワークフローの再利用性を高め、ナレッジベースとしての価値を提供します。ただし、基本的な共有・インポート機能がなければ意味がないため、P2としています。 + +**独立したテスト**: VS Code上で「Slackワークフロー検索」コマンドを実行し、過去に共有されたワークフローを検索・インポートすることで完全にテストでき、過去の資産を活用できる価値を提供します。 + +**受け入れシナリオ**: + +1. **前提** 開発者がVS Code上で作業している、**実行** 「Slackワークフロー検索」コマンドを実行する、**結果** 検索ダイアログが表示され、過去に共有されたワークフローの一覧が表示される +2. **前提** ワークフロー一覧が表示されている、**実行** ワークフロー名で検索フィルタを適用する、**結果** 該当するワークフローのみが絞り込まれて表示される +3. **前提** 検索結果からワークフローを選択した、**実行** 「インポート」ボタンをクリックする、**結果** 選択したワークフローがVS Codeにインポートされて開かれる +4. **前提** 検索結果が多数存在する、**実行** 作成者名で絞り込む、**結果** 該当する作成者が共有したワークフローのみが表示される +5. **前提** 検索結果が表示されている、**実行** 共有日時の降順でソートする、**結果** 最新の共有ワークフローが上位に表示される + +--- + +### ユーザーストーリー 4 - Bot User Token管理 (優先度: P3) + +開発者が初回利用時にSlack Bot User Tokenを入力し、安全に保存・管理できます。トークンの有効性は自動検証され、無効な場合は再入力が促されます。 + +**この優先度の理由**: トークン管理は重要ですが、初回設定後は頻繁に操作しないため、P3としています。基本的な共有・インポート機能の実装が優先されます。 + +**独立したテスト**: 初回利用時にBot User Tokenを入力し、トークンの検証・保存・再設定を行うことで完全にテストでき、安全なトークン管理の価値を提供します。 + +**受け入れシナリオ**: + +1. **前提** 開発者が初めてSlack統合機能を使用する、**実行** 「Slackに共有」コマンドを実行する、**結果** トークン入力ダイアログが表示される +2. **前提** トークン入力ダイアログが表示されている、**実行** Bot User Token (xoxb-で始まる文字列) を入力して保存する、**結果** トークンが検証され、有効な場合は保存完了メッセージが表示される +3. **前提** 無効なBot User Tokenを入力した、**実行** 保存を実行する、**結果** トークン検証エラーが表示され、再入力を促される +4. **前提** Bot User Tokenが既に保存されている、**実行** 「Slack: Manage Token」コマンドを実行する、**結果** 現在のトークン情報(マスク済み)と再設定オプションが表示される +5. **前提** Bot User Tokenが保存されている、**実行** VS Codeを再起動する、**結果** トークン再入力なしで引き続きSlack統合機能が利用できる + +--- + +### エッジケース + +- ネットワーク接続が切断された状態で共有またはインポートを実行した場合、どのように処理されるか? + - エラーメッセージを表示し、ネットワーク接続を確認するよう促す + - オフライン時には共有・インポート機能を無効化し、グレーアウト表示する + +- Slackのレート制限に達した場合、どのように対応するか? + - レート制限エラーを検出し、ユーザーに待機時間を通知する + - 自動リトライ機能を実装し、一定時間後に再試行する + +- ワークフロー定義ファイルのサイズが極端に大きい(例: 10MB以上)場合、どのように処理するか? + - 事前にファイルサイズをチェックし、制限を超える場合は警告を表示する + - Slackのファイル添付制限(通常100MB)を考慮し、適切なサイズ上限を設定する + +- Slackチャンネルへのアクセス権限がない場合、どのように処理するか? + - アクセス可能なチャンネルのみを共有先候補として表示する + - アクセス権限エラーが発生した場合、明確なエラーメッセージを表示する + +- 共有されたワークフローファイルが削除またはアクセス不可になった場合、どのように処理するか? + - Slackメッセージの添付ファイルとしてワークフローを保存する(別途ストレージは使用しない) + - メッセージまたは添付ファイルが削除された場合は、エラーメッセージを表示してインポートを中止する + - 検索結果では、削除されたワークフローを検出し、適切な警告を表示する + +- 同じワークフローが複数回共有された場合、どのように識別・管理するか? + - 共有時刻とメッセージIDを記録し、同一ワークフローの複数バージョンを区別する + - 検索結果では最新バージョンを優先的に表示する + +## 要件 + +### 機能要件 + +**共有機能** + +- **FR-001**: システムは、開発者がVS Code上でワークフロー定義ファイルを選択し、Slackチャンネルに共有できるようにしなければならない +- **FR-002**: システムは、共有前にワークフロー定義ファイルの妥当性を検証し、不正な形式の場合はエラーメッセージを表示しなければならない +- **FR-003**: システムは、共有時に開発者が共有先のSlackチャンネルを選択できるようにしなければならない +- **FR-004**: システムは、ワークフロー共有時に以下のメタデータを含むリッチメッセージカードをSlackに投稿しなければならない: ワークフロー名、作成者名、説明、共有日時、VS Codeで開くボタン +- **FR-005**: システムは、ワークフロー定義ファイル内にAPIキー、認証トークン、パスワード、秘密鍵などの機密情報が含まれていないかを検出し、含まれている場合は警告を表示しなければならない +- **FR-006**: システムは、機密情報検出警告後も開発者が明示的に共有を続行できるようにしなければならない(警告のみ、強制的なブロックはしない) +- **FR-007**: システムは、機密情報が含まれる可能性があるワークフローを共有した場合、Slackメッセージに注意書きを追加しなければならない + +**インポート機能** + +- **FR-008**: システムは、Slack上のワークフロー共有メッセージに「VS Codeで開く」ボタンを表示しなければならない +- **FR-009**: システムは、ユーザーが「VS Codeで開く」ボタンをクリックした際、VS Codeを起動してワークフローを自動的にインポートしなければならない +- **FR-010**: システムは、インポートしたワークフローを適切なディレクトリ(`.vscode/workflows/`)に配置しなければならない +- **FR-011**: システムは、インポート後に自動的にワークフローファイルをVS Codeエディタで開かなければならない +- **FR-012**: システムは、インポート時にワークフロー定義ファイルの妥当性を検証し、不正な形式の場合はエラーメッセージを表示しなければならない +- **FR-013**: システムは、インポート時に同名のワークフローファイルが既に存在する場合、上書き確認ダイアログを表示しなければならない +- **FR-014**: システムは、上書き確認ダイアログでユーザーが上書きを選択した場合は既存ファイルを置き換え、キャンセルを選択した場合はインポートを中止しなければならない + +**検索・再利用機能** + +- **FR-015**: システムは、VS Code内から過去にSlackに共有されたワークフローを検索できる機能を提供しなければならない +- **FR-016**: システムは、ワークフロー検索時に以下の条件で絞り込みができるようにしなければならない: ワークフロー名、作成者名、共有日時範囲、Slackチャンネル名 +- **FR-017**: システムは、検索結果を共有日時の降順で表示しなければならない +- **FR-018**: システムは、検索結果からワークフローを選択してインポートできるようにしなければならない + +**認証・トークン管理** + +- **FR-019**: システムは、初回利用時にBot User Tokenの入力を促し、トークンの有効性を検証しなければならない +- **FR-020**: システムは、ワークフローファイルをSlackメッセージの添付ファイルとして保存しなければならない(外部ストレージへの依存を最小化する) +- **FR-021**: システムは、入力されたBot User TokenをVSCode Secret Storageに安全に保存しなければならない(平文での保存は禁止) +- **FR-022**: システムは、Bot User Tokenが無効化された場合を検出し、トークン再入力が必要な場合はユーザーに通知しなければならない +- **FR-023**: システムは、現在設定されているSlackワークスペース名をVS Code UIに表示しなければならない + +### 主要エンティティ + +- **ワークフロー定義ファイル**: JSON形式のワークフロー設定ファイル。ノード構成、接続、パラメータなどを含む。ファイルパス、ファイル名、ファイルサイズ、最終更新日時の属性を持つ。 +- **ワークフローメタデータ**: ワークフロー名、作成者名、説明、作成日時、共有日時の情報。Slackメッセージカードに表示される。 +- **Slackメッセージ**: ワークフロー共有時にSlackチャンネルに投稿されるメッセージ。メッセージID、チャンネルID、投稿日時、リッチカードレイアウト、添付ファイル参照を含む。 +- **Slack認証情報**: Bot User Token (xoxb-で始まる文字列)、ワークスペースID、ワークスペース名。VSCode Secret Storageに安全に暗号化して保存される。 +- **機密情報パターン**: APIキー、認証トークン、パスワード、秘密鍵などを検出するための正規表現パターン集。検出対象文字列パターン、重要度レベル、説明を含む。 + +## 成功基準 + +### 測定可能な成果 + +- **SC-001**: 開発者がワークフローをVS CodeからSlackに共有するまでの操作時間が30秒以内である +- **SC-002**: チームメンバーがSlackメッセージからワークフローをVS Codeにインポートするまでの操作時間が10秒以内である +- **SC-003**: 機密情報検出機能が、一般的なAPIキーやトークンパターン(AWS、GitHub、Slack、OpenAI等)の90%以上を正確に検出できる +- **SC-004**: ワークフロー共有からインポートまでの一連の操作で、手動ダウンロード・配置・開く操作が完全に不要である +- **SC-005**: VS Code内からの過去ワークフロー検索結果が2秒以内に表示される +- **SC-006**: 過去30日間に共有されたワークフローを100件検索した場合でも、検索結果の表示が3秒以内に完了する +- **SC-007**: Bot User Tokenの入力・検証が30秒以内に完了する +- **SC-008**: 認証情報が暗号化された状態で保存され、平文での保存がゼロ件である +- **SC-009**: 90%のユーザーが最初の試行でワークフローの共有とインポートを正常に完了する +- **SC-010**: ワークフロー共有機能の導入により、チーム内でのワークフロー再利用率が50%以上増加する diff --git a/specs/001-slack-workflow-sharing/tasks.md b/specs/001-slack-workflow-sharing/tasks.md new file mode 100644 index 00000000..bc00ddbe --- /dev/null +++ b/specs/001-slack-workflow-sharing/tasks.md @@ -0,0 +1,624 @@ +# Tasks: Slack統合型ワークフロー共有 + +**Input**: `/specs/001-slack-workflow-sharing/`の設計ドキュメント +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**テスト方針**: 手動E2Eテストのみ実施 (自動テストは含まれません) + +**組織方針**: タスクはユーザーストーリー別に整理され、各ストーリーを独立して実装・テスト可能にします。 + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: 並列実行可能 (異なるファイル、依存関係なし) +- **[Story]**: このタスクが属するユーザーストーリー (US1, US2, US3など) +- タスク説明には正確なファイルパスを含める + +## 進捗管理 + +**重要**: タスク完了時は、`- [ ]` を `- [x]` に変更してマークしてください。 + +例: +```markdown +- [ ] T001 未完了のタスク +- [x] T002 完了したタスク +``` + +これにより、実装の進捗を可視化できます。 + +--- + +## Phase 1: Setup (共通インフラストラクチャ) + +**目的**: プロジェクト初期化と基本構造の構築 + +- [x] T001 プロジェクト構造の確認とディレクトリ準備 +- [x] T002 @slack/web-api 7.x 依存関係のインストール +- [x] T003 [P] TypeScript型定義ファイルの準備 (@types/node) +- [x] T004 [P] i18n翻訳ファイルにSlack統合用キーのスケルトン追加 (5言語: en, ja, ko, zh-CN, zh-TW) + +--- + +## Phase 2: Foundational (ブロック前提条件) + +**目的**: すべてのユーザーストーリー実装前に完了必須のコアインフラストラクチャ + +**⚠️ 重要**: このフェーズが完了するまで、ユーザーストーリーの作業は開始できません + +- [x] T005 OAuth認証用ローカルHTTPサーバーの実装 in src/extension/utils/oauth-callback-server.ts +- [x] T006 VSCode Secret Storage連携のトークン管理実装 in src/extension/utils/slack-token-manager.ts +- [x] T007 Slack OAuth認証フローサービスの実装 in src/extension/services/slack-oauth-service.ts +- [x] T008 Slack Web API基本クライアント実装 in src/extension/services/slack-api-service.ts +- [x] T009 [P] 機密情報検出ユーティリティの実装 in src/extension/utils/sensitive-data-detector.ts +- [x] T010 [P] データモデル型定義の作成 in src/extension/types/slack-integration-types.ts +- [x] T011 [P] Webview ↔ Extension Host メッセージング型定義の作成 in src/extension/types/slack-messages.ts +- [x] T012 エラーハンドリングユーティリティの実装 in src/extension/utils/slack-error-handler.ts + +**Checkpoint**: 基盤準備完了 - ユーザーストーリー実装を並列開始可能 + +--- + +## Phase 3: User Story 1 - VS CodeからSlackへのワークフロー共有 (優先度: P1) 🎯 MVP + +**ゴール**: 開発者がVS Code上で作成したワークフロー定義ファイルを、チームのSlackチャンネルに直接共有できるようにする。共有時には機密情報の検出と警告が行われ、Slack上ではリッチメッセージカードとして表示される。 + +**独立テスト**: VS Code上でワークフローを選択し、Slackチャンネルに共有し、Slack上でリッチメッセージカードを確認できる。 + +### Manual E2E Tests for User Story 1 + +以下のテストシナリオは、実装完了後に手動で実施します: + +**T001: 基本的なワークフロー共有** +1. Workflow Studioを開く +2. ツールバーの「Share to Slack」ボタンをクリック +3. チャンネル選択ダイアログで共有先を選択 +4. 機密情報警告が表示されないことを確認 (機密情報がない場合) +5. Slackチャンネルでリッチメッセージカードを確認 + +**T002: 機密情報検出警告** +1. ワークフローファイルにAWSキー (`AKIA1234567890ABCDEF`) を含める +2. ツールバーの「Share to Slack」ボタンをクリック +3. 機密情報警告ダイアログが表示されることを確認 +4. マスク済みの値 (`AKIA...CDEF`) が表示されることを確認 +5. 「続行」を選択して共有完了 + +**T003: 未認証エラー** +1. Slack未接続の状態でツールバーの「Share to Slack」ボタンをクリック +2. 「Slackに接続してください」エラーが表示されることを確認 + +### Implementation for User Story 1 + +- [x] T013 [P] [US1] Slackチャンネル一覧取得APIの実装 in src/extension/services/slack-api-service.ts +- [x] T014 [P] [US1] ワークフローファイルアップロードAPIの実装 in src/extension/services/slack-api-service.ts +- [x] T015 [P] [US1] リッチメッセージカード投稿APIの実装 in src/extension/services/slack-api-service.ts +- [x] T016 [P] [US1] Block Kit メッセージビルダー実装 in src/extension/utils/slack-message-builder.ts +- [x] T017 [US1] ワークフロー共有コマンドの実装 in src/extension/commands/slack-share-workflow.ts +- [x] T018 [US1] 機密情報検出とユーザー警告フローの統合 in src/extension/commands/slack-share-workflow.ts +- [x] T019 [P] [US1] Slackチャンネル選択ダイアログコンポーネントの実装 in src/webview/src/components/dialogs/SlackShareDialog.tsx +- [x] T020 [P] [US1] Webview側Slack統合サービスの実装 in src/webview/src/services/slack-integration-service.ts +- [x] T021 [US1] Extension HostとWebview間のメッセージハンドリング実装 in src/extension/commands/open-editor.ts +- [x] T022 [US1] VS Code コマンド登録 (`Slack: Share Workflow`) in src/extension/commands/open-editor.ts +- [x] T023 [P] [US1] i18n翻訳の追加 (ワークフロー共有関連メッセージ) in src/webview/src/i18n/translations/*.ts + +**Checkpoint**: User Story 1 バックエンド実装完了 - Webview経由でテスト可能 + +--- + +## Phase 3.1: User Story 1 - ツールバーUI統合 (優先度: P1) + +**ゴール**: Workflow Studio(Webview UI)内のツールバーに「Share to Slack」ボタンを表示し、クリックするとSlack共有ダイアログが表示される。 + +**独立テスト**: Workflow Studioを開き、ツールバー(上部)の「Share to Slack」ボタンをクリックして、Slack共有ダイアログが開くことを確認できる。 + +### Implementation for Phase 3.1 + +- [x] T023-1 [P] [US1] Toolbar.tsxにonShareToSlackプロパティとボタンを追加 in src/webview/src/components/Toolbar.tsx +- [x] T023-2 [P] [US1] App.tsxでhandleShareToSlackコールバック実装 in src/webview/src/App.tsx +- [x] T023-3 [P] [US1] SlackShareDialogの状態管理とレンダリング in src/webview/src/App.tsx + +**Checkpoint**: User Story 1完全実装完了 - Workflow Studioツールバーから直接実行可能 + +--- + +## Phase 3.2: Multi-workspace Support (優先度: P1) + +**ゴール**: 複数のSlackワークスペースに対応し、ワークスペース選択UIを追加する。 + +**独立テスト**: 複数のワークスペースに接続し、Share to Slackダイアログでワークスペースを切り替えてチャンネル一覧が更新されることを確認できる。 + +### Implementation for Phase 3.2 + +- [x] T023-7 [P] [US1] SlackTokenManagerをマルチワークスペース対応に拡張 in src/extension/utils/slack-token-manager.ts +- [x] T023-8 [P] [US1] SlackApiServiceをワークスペースID指定対応に拡張 in src/extension/services/slack-api-service.ts +- [x] T023-12 [US1] LIST_SLACK_WORKSPACESメッセージハンドラー実装 in src/extension/commands/open-editor.ts +- [x] T023-13 [P] [US1] listSlackWorkspaces() Webviewサービス実装 in src/webview/src/services/slack-integration-service.ts +- [x] T023-14 [P] [US1] SlackShareDialogにワークスペース選択UI追加 in src/webview/src/components/dialogs/SlackShareDialog.tsx +- [x] T023-15 [P] [US1] i18n翻訳追加(ワークスペース選択関連) in src/webview/src/i18n/translations/*.ts + +**Checkpoint**: Multi-workspace対応完了 - 複数ワークスペース間でワークフロー共有可能 + +--- + +## Phase 3.3: OAuth Authentication UI Integration (優先度: P1) + +**ゴール**: SlackShareDialog内にOAuth認証開始ボタンを追加し、未接続状態からワンクリックで認証を開始できるようにする。 + +**独立テスト**: Slack未接続の状態でShare to Slackダイアログを開き、「Connect to Slack」ボタンをクリックしてOAuth認証フローが開始されることを確認できる。 + +### Implementation for Phase 3.3 + +- [x] T024-1 [P] [US1] SlackShareDialogに「Connect to Slack」ボタン追加 in src/webview/src/components/dialogs/SlackShareDialog.tsx +- [x] T024-2 [P] [US1] OAuth認証成功後のワークスペース一覧再取得処理 in src/webview/src/components/dialogs/SlackShareDialog.tsx +- [x] T024-3 [P] [US1] i18n翻訳追加(Connect to Slackボタン関連) in src/webview/src/i18n/translations/*.ts +- [x] T024-4 [P] [US1] GET_OAUTH_REDIRECT_URI message type追加 in src/shared/types/messages.ts +- [x] T024-5 [P] [US1] Extension HostでのRedirect URI取得ハンドラー実装 in src/extension/commands/open-editor.ts +- [x] T024-6 [P] [US1] WebviewでのRedirect URI表示UI実装 in src/webview/src/components/dialogs/SlackShareDialog.tsx +- [x] T024-7 [US1] ngrok統合によるHTTPS URL取得 (以下のサブタスク) + - [x] T024-7-1 [P] [US1] ngrok依存関係の追加 in package.json (devDependencies) + - [x] T024-7-2 [P] [US1] ngrokサービスクラスの実装 in src/extension/utils/ngrok-service.ts + - [x] T024-7-3 [US1] Extension HostでngrokトンネルURL取得 in src/extension/commands/open-editor.ts + - [x] T024-7-4 [P] [US1] エラーハンドリング実装(ngrok未インストール等) in src/extension/utils/ngrok-service.ts + - [x] T024-7-5 [P] [US1] Vite設定でngrokを外部依存関係に追加 in vite.extension.config.ts + +**Checkpoint**: OAuth認証UI統合完了 - ユーザーはダイアログ内から直接Slack認証を開始可能、開発時にHTTPS Redirect URIを取得可能 + +--- + +## Phase 3.4: Share未保存ワークフロー対応 (優先度: P1) + +**ゴール**: 現在のキャンバス状態(未保存または編集中)を直接Slackに共有できるようにし、Save操作を強制しない自然なUXを実現する。 + +**独立テスト**: 未保存のワークフローまたは保存後に編集したワークフローをShare to Slackして、現在の画面状態が正しく共有されることを確認できる。 + +### 背景 + +**問題点:** +- workflowIdを送信してExtension側でファイルから読み込むため、未保存ワークフローではファイルが存在せず失敗する +- Save後に編集した場合、古いファイル内容が共有される +- ユーザーは「今画面に見えているワークフロー」を共有したいが、それができない + +**解決方法:** +- Webview側で現在のノード/エッジからWorkflowオブジェクトを動的に生成 +- payloadにworkflow全体を含めてExtensionに送信 +- Extension側でファイル読み込みをスキップし、受け取ったworkflowをそのまま使用 + +### Implementation for Phase 3.4 + +- [x] T024-8 [P] [US1] ShareWorkflowToSlackPayload型定義の拡張 in src/shared/types/messages.ts +- [x] T024-9 [P] [US1] SlackShareDialogで動的Workflow生成 in src/webview/src/components/dialogs/SlackShareDialog.tsx +- [x] T024-10 [P] [US1] ShareWorkflowOptions型定義の更新 in src/webview/src/services/slack-integration-service.ts +- [x] T024-11 [US1] Extension側ハンドラーの修正(ファイル読み込み削除) in src/extension/commands/slack-share-workflow.ts +- [x] T024-12 [US1] VSCodeネイティブ通知の追加(成功時に"View in Slack"ボタン付き) in src/extension/commands/slack-share-workflow.ts + +**Checkpoint**: Share未保存ワークフロー対応完了 - 未保存/編集中のワークフローを直接Slackに共有可能 + +--- + +## Phase 4: User Story 2 - Slackからのワンクリックインポート (優先度: P1) + +**ゴール**: チームメンバーがSlack上で共有されたワークフローメッセージから、ワンクリックで自分のVS Codeにワークフローをインポートできるようにする。手動でのファイルダウンロード、ディレクトリ配置、エディタでの開く操作は不要。 + +**独立テスト**: Slackメッセージの「Import to VS Code」ボタンをクリックし、VS Code上でワークフローが自動的に開かれることを確認できる。 + +### Manual E2E Tests for User Story 2 + +以下のテストシナリオは、実装完了後に手動で実施します: + +**T004: 基本的なワークフローインポート** +1. Slackメッセージの「Import to VS Code」ボタンをクリック +2. VS Codeに戻り、インポート成功通知を確認 +3. `.vscode/workflows/` にファイルが保存されていることを確認 + +**T005: 上書き確認** +1. 既存ワークフローと同名のファイルをインポート +2. 上書き確認ダイアログが表示されることを確認 +3. 「上書き」を選択してインポート完了 + +**T006: ファイル破損エラー** +1. ワークフローファイルが破損している状態でインポート実行 +2. エラーメッセージが表示され、インポートが失敗することを確認 + +### Implementation for User Story 2 + +- [x] T024 [P] [US2] Slackファイルダウンロード実装 in src/extension/services/slack-api-service.ts +- [x] T025 [P] [US2] ワークフロー定義ファイルバリデーション実装 in src/extension/utils/workflow-validator.ts +- [x] T026 [US2] ワークフローインポートコマンドの実装 in src/extension/commands/slack-import-workflow.ts +- [x] T027 [US2] ファイル上書き確認ダイアログの実装 in src/extension/commands/slack-import-workflow.ts +- [x] T028 [US2] インポート後のファイル自動オープン機能 in src/extension/commands/slack-import-workflow.ts +- [x] T029 [P] [US2] Slackインポートボタンコンポーネントの実装 in src/webview/src/components/buttons/SlackImportButton.tsx +- [x] T030 [US2] deep link ハンドリング実装 (VS Code URI handler) in src/extension/extension.ts +- [x] T031 [US2] VS Code コマンド登録 (`Slack: Import Workflow`) in src/extension/extension.ts +- [x] T032 [P] [US2] i18n翻訳の追加 (ワークフローインポート関連メッセージ) in src/webview/src/i18n/translations/*.ts + +**Checkpoint**: User Story 1とUser Story 2が独立して動作することを確認 + +--- + +## Phase 5: User Story 3 - VS Code内からの過去ワークフロー検索・再利用 (優先度: P2) + +**ゴール**: 開発者がVS Code内から、Slackに過去に共有されたワークフローを検索し、再利用できるようにする。チャンネル名、ワークフロー名、作成者、共有日時などで絞り込み検索が可能。 + +**独立テスト**: VS Code上で「Slackワークフロー検索」コマンドを実行し、過去に共有されたワークフローを検索・インポートできることを確認できる。 + +### Manual E2E Tests for User Story 3 + +以下のテストシナリオは、実装完了後に手動で実施します: + +**T007: ワークフロー検索** +1. コマンドパレットで `Slack: Search Workflows` を実行 +2. 検索クエリを入力 (例: `data processing`) +3. 過去に共有されたワークフローがリスト表示されることを確認 +4. ワークフローを選択してインポート + +**T008: 検索フィルタリング** +1. ワークフロー一覧が表示されている状態でフィルタを適用 +2. 作成者名で絞り込み +3. 該当する作成者が共有したワークフローのみが表示されることを確認 + +**T009: 検索結果ソート** +1. 検索結果が表示されている状態で共有日時降順でソート +2. 最新の共有ワークフローが上位に表示されることを確認 + +### Implementation for User Story 3 + +- [ ] T033 [P] [US3] Slackメッセージ検索API実装 in src/extension/services/slack-api-service.ts +- [ ] T034 [P] [US3] 検索結果フィルタリング・ソート実装 in src/extension/services/slack-search-service.ts +- [ ] T035 [US3] ワークフロー検索コマンドの実装 in src/extension/commands/slack-search-workflows.ts +- [ ] T036 [P] [US3] ワークフロー検索ダイアログコンポーネントの実装 in src/webview/src/components/dialogs/SlackSearchDialog.tsx +- [ ] T037 [P] [US3] 検索結果リストコンポーネントの実装 in src/webview/src/components/lists/WorkflowSearchResults.tsx +- [ ] T038 [US3] 検索結果からのインポート機能統合 in src/webview/src/components/dialogs/SlackSearchDialog.tsx +- [ ] T039 [US3] VS Code コマンド登録 (`Slack: Search Workflows`) in src/extension/extension.ts +- [ ] T040 [P] [US3] i18n翻訳の追加 (ワークフロー検索関連メッセージ) in src/webview/src/i18n/translations/*.ts + +**Checkpoint**: User Story 1, 2, 3がすべて独立して動作することを確認 + +--- + +## Phase 6: User Story 4 - Slack認証とワークスペース管理 (優先度: P3) + +**ゴール**: 開発者が初回利用時にSlackワークスペースとの連携を設定し、複数のワークスペースを切り替えながら利用できるようにする。認証情報は安全に管理され、再認証が必要な場合には自動的に通知される。 + +**独立テスト**: 初回利用時にSlack認証フローを完了し、複数のワークスペースを切り替えることで、柔軟なワークスペース管理が可能であることを確認できる。 + +### Manual E2E Tests for User Story 4 + +以下のテストシナリオは、実装完了後に手動で実施します: + +**T010: 初回Slack接続** +1. コマンドパレットで `Slack: Connect Workspace` を実行 +2. ブラウザでSlack OAuth認証ページが開く +3. ワークスペースを選択し、アクセスを許可 +4. VS Codeに戻り、接続成功通知を確認 + +**T011: ワークスペース切り替え** +1. コマンドパレットで `Slack: Switch Workspace` を実行 +2. 利用可能なワークスペース一覧が表示される +3. ワークスペースを選択 +4. 切り替え成功通知を確認 + +**T012: 再認証フロー** +1. Slackアクセストークンの有効期限を切らす (またはトークンを削除) +2. 共有またはインポートを実行 +3. 再認証が必要である旨の通知が表示される +4. 再認証フローが開始されることを確認 + +### Implementation for User Story 4 + +- [ ] T041 [P] [US4] ワークスペース情報取得API実装 in src/extension/services/slack-api-service.ts +- [ ] T042 [P] [US4] トークン有効性検証実装 in src/extension/services/slack-oauth-service.ts +- [ ] T043 [P] [US4] ワークスペース管理サービスの実装 in src/extension/services/slack-workspace-manager.ts +- [ ] T044 [US4] Slack接続コマンドの実装 in src/extension/commands/slack-connect.ts +- [ ] T045 [US4] Slack切断コマンドの実装 in src/extension/commands/slack-disconnect.ts +- [ ] T046 [US4] ワークスペース切り替えコマンドの実装 in src/extension/commands/slack-switch-workspace.ts +- [ ] T047 [P] [US4] ワークスペース接続状態表示UI実装 in src/webview/src/components/status/SlackConnectionStatus.tsx +- [ ] T048 [P] [US4] ワークスペース切り替えダイアログ実装 in src/webview/src/components/dialogs/SlackWorkspaceSwitchDialog.tsx +- [ ] T049 [US4] VS Code コマンド登録 (`Slack: Connect Workspace`, `Slack: Disconnect`, `Slack: Switch Workspace`) in src/extension/extension.ts +- [ ] T050 [P] [US4] i18n翻訳の追加 (認証・ワークスペース管理関連メッセージ) in src/webview/src/i18n/translations/*.ts + +**Checkpoint**: すべてのユーザーストーリーが独立して機能することを確認 + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**目的**: 複数のユーザーストーリーにまたがる改善 + +- [ ] T051 [P] エラーメッセージとログ出力の一貫性確認 in src/extension/utils/slack-error-handler.ts +- [ ] T052 [P] パフォーマンス最適化 (Slack API呼び出しのキャッシング検討) +- [ ] T053 [P] セキュリティハードニング (トークン漏洩チェック、ログ出力制限) +- [ ] T054 [P] i18n翻訳の完全性確認 (すべての5言語で一貫性チェック) +- [ ] T055 コード品質チェック (npm run format, npm run lint, npm run check, npm run build) +- [ ] T056 quickstart.mdの検証 (すべての手順が正確に動作することを確認) +- [ ] T057 [P] VSCode Extension マニフェスト更新 (package.json: コマンド、スコープ、依存関係) +- [ ] T058 [P] Slack App マニフェスト作成 in resources/slack-app-manifest.json + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: 依存関係なし - 即座に開始可能 +- **Foundational (Phase 2)**: Setupに依存 - すべてのユーザーストーリーをブロック +- **User Stories (Phase 3-6)**: すべてFoundational完了に依存 + - ユーザーストーリーは並列実行可能 (複数人で作業する場合) + - または優先順位順に順次実行 (P1 → P2 → P3) +- **Polish (Phase 7)**: すべての実装したいユーザーストーリー完了に依存 + +### User Story Dependencies + +- **User Story 1 (P1)**: Foundational (Phase 2) 完了後に開始可能 - 他ストーリーへの依存なし +- **User Story 2 (P1)**: Foundational (Phase 2) 完了後に開始可能 - US1と統合するが独立してテスト可能 +- **User Story 3 (P2)**: Foundational (Phase 2) 完了後に開始可能 - US1/US2と統合するが独立してテスト可能 +- **User Story 4 (P3)**: Foundational (Phase 2) 完了後に開始可能 - すべてのストーリーに基盤を提供するが独立してテスト可能 + +### Within Each User Story + +- モデル/型定義 → サービス → コマンド → UI コンポーネント +- Extension Host実装 → Webview実装 → 統合 +- コア機能実装 → エラーハンドリング → i18n + +### Parallel Opportunities + +- Setupタスク ([P]マーク) は並列実行可能 +- Foundationalタスク ([P]マーク) は並列実行可能 (Phase 2内) +- Foundational完了後、すべてのユーザーストーリーを並列開始可能 (チーム規模による) +- ユーザーストーリー内の [P] タスクは並列実行可能 +- 異なるユーザーストーリーは異なるチームメンバーが並列作業可能 + +--- + +## Parallel Example: User Story 1 + +```bash +# User Story 1のAPI実装タスクを並列実行: +Task: "Slackチャンネル一覧取得APIの実装 in src/extension/services/slack-api-service.ts" +Task: "ワークフローファイルアップロードAPIの実装 in src/extension/services/slack-api-service.ts" +Task: "リッチメッセージカード投稿APIの実装 in src/extension/services/slack-api-service.ts" + +# User Story 1のUIコンポーネント実装を並列実行: +Task: "Slackチャンネル選択ダイアログコンポーネントの実装 in src/webview/src/components/dialogs/SlackShareDialog.tsx" +Task: "Webview側Slack統合サービスの実装 in src/webview/src/services/slack-integration-service.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Phase 1完了: Setup +2. Phase 2完了: Foundational (重要 - すべてのストーリーをブロック) +3. Phase 3完了: User Story 1 +4. **停止して検証**: User Story 1を独立してテスト +5. デモ/デプロイ可能な状態 + +### Incremental Delivery + +1. Setup + Foundational完了 → 基盤準備完了 +2. User Story 1追加 → 独立テスト → デプロイ/デモ (MVP!) +3. User Story 2追加 → 独立テスト → デプロイ/デモ +4. User Story 3追加 → 独立テスト → デプロイ/デモ +5. User Story 4追加 → 独立テスト → デプロイ/デモ +6. 各ストーリーが前ストーリーを壊さずに価値を追加 + +### Parallel Team Strategy + +複数の開発者がいる場合: + +1. チーム全体でSetup + Foundationalを完了 +2. Foundational完了後: + - 開発者A: User Story 1 + - 開発者B: User Story 2 + - 開発者C: User Story 3 + - 開発者D: User Story 4 +3. ストーリーが独立して完了・統合される + +--- + +## Manual E2E Testing Plan + +以下の手動E2Eテストシナリオは、各ユーザーストーリー実装完了後に実施します: + +### User Story 1 - ワークフロー共有 + +- **T001**: 基本的なワークフロー共有 +- **T002**: 機密情報検出警告 +- **T003**: 未認証エラー + +### User Story 2 - ワークフローインポート + +- **T004**: 基本的なワークフローインポート +- **T005**: 上書き確認 +- **T006**: ファイル破損エラー + +### User Story 3 - ワークフロー検索 + +- **T007**: ワークフロー検索 +- **T008**: 検索フィルタリング +- **T009**: 検索結果ソート + +### User Story 4 - 認証・ワークスペース管理 + +- **T010**: 初回Slack接続 +- **T011**: ワークスペース切り替え +- **T012**: 再認証フロー + +### Additional Error Cases + +quickstart.md (セクション 5.2) に記載された追加のエラーケーステストも実施: + +- **E001**: 未認証エラー +- **E002**: チャンネルアクセスエラー +- **E003**: ネットワークエラー + +### Performance Tests + +quickstart.md (セクション 5.3) に記載されたパフォーマンステストも実施: + +- **P001**: 共有処理時間 (< 3秒目標) +- **P002**: インポート処理時間 (< 2秒目標) +- **P003**: 機密情報検出時間 (< 500ms目標、100KB未満ファイル) + +測定方法: `console.time()` / `console.timeEnd()` でログ出力 + +--- + +## Phase 8: Manual Token Input方式への移行 (優先度: P0 - ベータ版必須) 🔧 + +**目的**: OAuth認証フロー(ngrok使用)を削除し、ユーザーが手動でSlack Bot Tokenを入力する方式に簡素化 + +**背景**: +- ベータ版では、OAuth認証フロー(ngrok経由)が複雑すぎる +- 個人開発・小規模チームではManual Token Inputの方が理解しやすい +- GitHub CopilotなどもManual Token方式をサポート + +**ゴール**: ユーザーがhttps://api.slack.com/appsで自分でSlack Appを作成し、Bot Tokenを手動入力してVSCodeと接続する + +### Manual E2E Tests for Phase 8 + +**T010-M: Manual Token入力で初回接続** +1. コマンドパレットで `Slack: Connect Workspace (Manual Token)` を実行 +2. Bot Token (xoxb-...) を入力 +3. 接続成功通知を確認(Workspace名が表示される) +4. Share to Slackでワークスペースが選択可能であることを確認 + +**T011-M: 複数ワークスペースの手動接続** +1. 2つ目のワークスペースに対してManual Token入力 +2. 両方のワークスペースが選択可能であることを確認 + +**T012-M: 無効なToken formatでのエラー表示** +1. `xoxp-` (User Token) を入力 +2. エラーメッセージ「Bot Tokenが必要です (xoxb-で始まる)」が表示されることを確認 + +**T013-M: Token再入力で接続情報を上書き** +1. 既存の接続があるワークスペースに対して、新しいBot Tokenで再接続 +2. 古い接続情報が新しい情報で上書きされることを確認 + +### Implementation for Phase 8 + +**削除するファイル**: +- src/extension/services/slack-oauth-service.ts (OAuth認証フロー全体) +- src/extension/utils/oauth-callback-server.ts (ローカルコールバックサーバー) +- src/extension/utils/ngrok-service.ts (ngrokトンネル管理) + +**Backend (Extension Host)** + +- [x] T100 [P] SlackTokenManagerの簡素化 in src/extension/utils/slack-token-manager.ts + - Manual入力用の新メソッド追加: `storeManualConnection(workspaceId, workspaceName, teamId, accessToken, userId)` + - Token validation強化: Bot Token (`xoxb-`) のみ許可 + +- [x] T101 Manual Token入力コマンドハンドラー実装 in src/extension/commands/slack-connect-manual.ts + - VSCode Input Box UIでBot Token (xoxb-...) のみ入力 + - Token format validation (`xoxb-` prefix確認) + - `auth.test` APIでToken検証 & ワークスペース情報自動取得: + - Workspace ID (team_id) + - Workspace Name (team) + - Author名はgit configから取得(Slack User IDは使用しない) + - VSCode Secret Storageに保存 + +- [x] T102 Extension Host message handler追加 in src/extension/commands/open-editor.ts + - `CONNECT_SLACK_MANUAL` メッセージハンドラー追加 + - Input validation & error handling + - Success/Failedレスポンス送信 + +- [x] T103 VSCode コマンド登録 in src/extension/extension.ts + - `Slack: Connect Workspace (Manual Token)` コマンド追加 + - `package.json` の `contributes.commands` に追加 + +**Frontend (Webview UI)** + +- [x] T104 [P] Manual Token入力ダイアログコンポーネント in src/webview/src/components/dialogs/SlackManualTokenDialog.tsx + - Workspace Name入力フィールド + - Workspace ID (Team ID)入力フィールド + - Bot Token入力フィールド(type="password") + - User ID入力フィールド + - セットアップ手順へのリンク(docs/slack-manual-token-setup.md) + - Validation UI(token format check) + +- [x] T105 [P] Webview service追加 in src/webview/src/services/slack-integration-service.ts + - `connectSlackManual(workspaceName, workspaceId, teamId, token, userId)` 実装 + - Extension Hostへのメッセージ送信 + +- [x] T106 SlackShareDialogの更新 in src/webview/src/components/dialogs/SlackShareDialog.tsx + - 「Connect to Slack (Manual Token)」ボタンに変更 + - OAuth関連UI削除(ngrok URL表示、Redirect URI表示など) + - Manual Token Dialog呼び出し + +**Message Types** + +- [x] T107 [P] Message type定義追加 in src/shared/types/messages.ts + - `CONNECT_SLACK_MANUAL` request payload定義 + - `CONNECT_SLACK_MANUAL_SUCCESS` response定義 + - `CONNECT_SLACK_MANUAL_FAILED` response定義 + - OAuth関連message types削除(GET_OAUTH_REDIRECT_URI等) + +**Dependencies & Configuration** + +- [x] T108 [P] package.json更新 + - `@ngrok/ngrok` 依存関係削除(devDependencies) + - 他のngrok関連パッケージ削除確認 + - OAuth関連VSCode設定項目削除 + +- [x] T109 [P] Vite設定更新 in vite.extension.config.ts + - ngrok外部依存関係設定削除(存在する場合) + +**Documentation** + +- [x] T110 [P] セットアップ手順の簡易説明追加 in src/webview/src/components/dialogs/SlackManualTokenDialog.tsx + - ダイアログ内に「How to Get Bot Token」セクションを追加(5ステップ) + - 必須スコープの明示(channels:read, chat:write, files:write, groups:read) + - セキュリティ・プライバシー情報の表示 + - 詳細なドキュメントは作成せず、ダイアログ内の簡潔な説明で対応 + +- [x] T111 README.md更新 + - Key Featuresに「Slack Workflow Sharing (β)」を追加 + - README肥大化を避けるため詳細セクションは作成せず簡潔な説明のみ + +- [x] T112 quickstart.md更新 in specs/001-slack-workflow-sharing/quickstart.md + - OAuth認証セクションは既に存在せず削除不要 + - Manual Token入力セクションは既に記載済み(2.4 Bot User Tokenの取得) + +**i18n** + +- [x] T113 [P] 翻訳追加 in src/webview/src/i18n/translations/*.ts (5言語: en, ja, ko, zh-CN, zh-TW) + - Manual Token入力ダイアログ関連テキスト + - セットアップガイドへのリンクテキスト + - OAuth関連翻訳キー削除(使用されなくなったキー) + +**Cleanup** + +- [x] T114 削除ファイルのimport参照削除 + - SlackOAuthService importを削除 + - NgrokService importを削除 + - OAuthCallbackServer importを削除 + - OAuth関連サービスファイル削除(slack-oauth-service.ts, ngrok-service.ts, oauth-callback-server.ts) + - GET_OAUTH_REDIRECT_URIメッセージハンドラー削除 + +- [x] T115 型定義クリーンアップ in src/extension/types/slack-integration-types.ts + - OAuth関連フィールド削除またはOptionalに変更 + - SlackWorkspaceConnection.tokenScopeをOptionalに変更 + +**Testing & QA** + +- [ ] T116 Manual E2E Testing + - T010-M: Manual Token入力で初回接続 + - T011-M: 複数ワークスペースの手動接続 + - T012-M: 無効なToken formatでのエラー表示確認 + +- [x] T117 Code quality checks + - `npm run format && npm run lint && npm run check && npm run build` + +**Checkpoint**: Manual Token方式への移行完了 - OAuth依存なし、ユーザーは手動接続可能 + +--- + +## Notes + +- **[P] タスク** = 異なるファイル、依存関係なし +- **[Story] ラベル** = タスクを特定のユーザーストーリーにマッピング (トレーサビリティ確保) +- 各ユーザーストーリーは独立して完了・テスト可能であるべき +- 各タスクまたは論理的グループ後にコミット +- 任意のチェックポイントで停止し、ストーリーを独立して検証可能 +- 避けるべき: 曖昧なタスク、同一ファイルでの競合、ストーリー独立性を壊すクロス依存関係 +- 手動E2Eテストはすべての実装完了後に実施 +- Code quality checksはコミット前に必ず実行 (npm run format && npm run lint && npm run check && npm run build) diff --git a/src/extension/commands/open-editor.ts b/src/extension/commands/open-editor.ts index b67412c3..a6d4a684 100644 --- a/src/extension/commands/open-editor.ts +++ b/src/extension/commands/open-editor.ts @@ -9,6 +9,8 @@ import * as vscode from 'vscode'; import type { WebviewMessage } from '../../shared/types/messages'; import { cancelGeneration } from '../services/claude-code-service'; import { FileService } from '../services/file-service'; +import { SlackApiService } from '../services/slack-api-service'; +import { SlackTokenManager } from '../utils/slack-token-manager'; import { getWebviewContent } from '../webview-content'; import { handleGenerateWorkflow } from './ai-generation'; import { handleExportWorkflow } from './export-workflow'; @@ -17,12 +19,36 @@ import { loadWorkflowList } from './load-workflow-list'; import { handleGetMcpToolSchema, handleGetMcpTools, handleListMcpServers } from './mcp-handlers'; import { saveWorkflow } from './save-workflow'; import { handleBrowseSkills, handleCreateSkill, handleValidateSkillFile } from './skill-operations'; +import { handleConnectSlackManual } from './slack-connect-manual'; +import { handleImportWorkflowFromSlack } from './slack-import-workflow'; +import { + handleGetSlackChannels, + handleListSlackWorkspaces, + handleShareWorkflowToSlack, +} from './slack-share-workflow'; import { handleCancelRefinement, handleClearConversation, handleRefineWorkflow, } from './workflow-refinement'; +// Module-level variables to share state between commands +let currentPanel: vscode.WebviewPanel | undefined; +let fileService: FileService; +let slackTokenManager: SlackTokenManager; +let slackApiService: SlackApiService; + +/** + * Import parameters for workflow import from Slack + */ +export interface ImportParameters { + fileId: string; + channelId: string; + messageTs: string; + workspaceId: string; + workflowId: string; +} + /** * Register the open editor command * @@ -31,347 +57,528 @@ import { export function registerOpenEditorCommand( context: vscode.ExtensionContext ): vscode.WebviewPanel | null { - let currentPanel: vscode.WebviewPanel | undefined; - let fileService: FileService; - - const openEditorCommand = vscode.commands.registerCommand('cc-wf-studio.openEditor', () => { - // Initialize file service - try { - fileService = new FileService(); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to initialize File Service: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - return; - } - const columnToShowIn = vscode.window.activeTextEditor - ? vscode.window.activeTextEditor.viewColumn - : undefined; - - // If panel already exists, reveal it - if (currentPanel) { - currentPanel.reveal(columnToShowIn); - return; - } - - // Create new webview panel - currentPanel = vscode.window.createWebviewPanel( - 'ccWorkflowStudio', - 'Claude Code Workflow Studio', - columnToShowIn || vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'src', 'webview', 'dist')], + const openEditorCommand = vscode.commands.registerCommand( + 'cc-wf-studio.openEditor', + (importParams?: ImportParameters | vscode.Uri) => { + // Filter out vscode.Uri objects (file paths) - only process ImportParameters + // This prevents unintended import when .json files are opened in VSCode + let actualImportParams: ImportParameters | undefined; + if (importParams !== undefined) { + if (importParams instanceof vscode.Uri) { + // Ignore Uri objects - this is just a file being opened + actualImportParams = undefined; + } else { + // This is a proper ImportParameters object + actualImportParams = importParams as ImportParameters; + } } - ); - // Set custom icon for the tab - currentPanel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources', 'icon.png'); - - // Set webview HTML content - currentPanel.webview.html = getWebviewContent(currentPanel.webview, context.extensionUri); + // Initialize file service + try { + fileService = new FileService(); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to initialize File Service: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + return; + } - // Check if this is the first launch and send initial state - const hasLaunchedBefore = context.globalState.get('hasLaunchedBefore', false); - if (!hasLaunchedBefore) { - // Mark as launched - context.globalState.update('hasLaunchedBefore', true); - } + // Initialize Slack services + slackTokenManager = new SlackTokenManager(context); + slackApiService = new SlackApiService(slackTokenManager); + const columnToShowIn = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; - // Send initial state to webview after a short delay to ensure webview is ready - setTimeout(() => { + // If panel already exists, reveal it if (currentPanel) { - currentPanel.webview.postMessage({ - type: 'INITIAL_STATE', - payload: { - isFirstLaunch: !hasLaunchedBefore, - }, - }); - } - }, 500); - - // Handle messages from webview - currentPanel.webview.onDidReceiveMessage( - async (message: WebviewMessage) => { - // Ensure panel still exists - if (!currentPanel) { - return; - } - const webview = currentPanel.webview; - - switch (message.type) { - case 'SAVE_WORKFLOW': - // Save workflow - if (message.payload?.workflow) { - await saveWorkflow(fileService, webview, message.payload.workflow, message.requestId); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Workflow is required', - }, - }); - } - break; - - case 'EXPORT_WORKFLOW': - // Export workflow to .claude format - if (message.payload) { - await handleExportWorkflow(fileService, webview, message.payload, message.requestId); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Export payload is required', - }, - }); - } - break; - - case 'LOAD_WORKFLOW_LIST': - // Load workflow list - await loadWorkflowList(fileService, webview, message.requestId); - break; - - case 'LOAD_WORKFLOW': - // Load specific workflow - if (message.payload?.workflowId) { - await loadWorkflow( - fileService, - webview, - message.payload.workflowId, - message.requestId - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Workflow ID is required', - }, - }); - } - break; - - case 'STATE_UPDATE': - // State update from webview (for persistence) - console.log('STATE_UPDATE:', message.payload); - break; - - case 'GENERATE_WORKFLOW': - // AI-assisted workflow generation - if (message.payload) { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - await handleGenerateWorkflow( - message.payload, - webview, - context.extensionPath, - message.requestId || '', - workspaceRoot - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Generation payload is required', - }, + currentPanel.reveal(columnToShowIn); + + // If import parameters are provided, trigger import + if (actualImportParams) { + setTimeout(() => { + if (currentPanel) { + currentPanel.webview.postMessage({ + type: 'IMPORT_WORKFLOW_FROM_SLACK', + payload: actualImportParams, }); } - break; + }, 500); + } + + return; + } + + // Create new webview panel + currentPanel = vscode.window.createWebviewPanel( + 'ccWorkflowStudio', + 'Claude Code Workflow Studio', + columnToShowIn || vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'src', 'webview', 'dist')], + } + ); - case 'CANCEL_GENERATION': - // Cancel AI generation - if (message.payload?.requestId) { - const result = cancelGeneration(message.payload.requestId); + // Set custom icon for the tab + currentPanel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources', 'icon.png'); - if (result.cancelled) { + // Set webview HTML content + currentPanel.webview.html = getWebviewContent(currentPanel.webview, context.extensionUri); + + // Check if this is the first launch and send initial state + const hasLaunchedBefore = context.globalState.get('hasLaunchedBefore', false); + if (!hasLaunchedBefore) { + // Mark as launched + context.globalState.update('hasLaunchedBefore', true); + } + + // Send initial state to webview after a short delay to ensure webview is ready + setTimeout(() => { + if (currentPanel) { + currentPanel.webview.postMessage({ + type: 'INITIAL_STATE', + payload: { + isFirstLaunch: !hasLaunchedBefore, + }, + }); + + // If import parameters are provided, trigger import after initial state + if (actualImportParams) { + setTimeout(() => { + if (currentPanel) { + currentPanel.webview.postMessage({ + type: 'IMPORT_WORKFLOW_FROM_SLACK', + payload: actualImportParams, + }); + } + }, 500); + } + } + }, 500); + + // Handle messages from webview + currentPanel.webview.onDidReceiveMessage( + async (message: WebviewMessage) => { + // Ensure panel still exists + if (!currentPanel) { + return; + } + const webview = currentPanel.webview; + + switch (message.type) { + case 'SAVE_WORKFLOW': + // Save workflow + if (message.payload?.workflow) { + await saveWorkflow( + fileService, + webview, + message.payload.workflow, + message.requestId + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Workflow is required', + }, + }); + } + break; + + case 'EXPORT_WORKFLOW': + // Export workflow to .claude format + if (message.payload) { + await handleExportWorkflow( + fileService, + webview, + message.payload, + message.requestId + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Export payload is required', + }, + }); + } + break; + + case 'LOAD_WORKFLOW_LIST': + // Load workflow list + await loadWorkflowList(fileService, webview, message.requestId); + break; + + case 'LOAD_WORKFLOW': + // Load specific workflow + if (message.payload?.workflowId) { + await loadWorkflow( + fileService, + webview, + message.payload.workflowId, + message.requestId + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Workflow ID is required', + }, + }); + } + break; + + case 'STATE_UPDATE': + // State update from webview (for persistence) + console.log('STATE_UPDATE:', message.payload); + break; + + case 'GENERATE_WORKFLOW': + // AI-assisted workflow generation + if (message.payload) { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + await handleGenerateWorkflow( + message.payload, + webview, + context.extensionPath, + message.requestId || '', + workspaceRoot + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Generation payload is required', + }, + }); + } + break; + + case 'CANCEL_GENERATION': + // Cancel AI generation + if (message.payload?.requestId) { + const result = cancelGeneration(message.payload.requestId); + + if (result.cancelled) { + webview.postMessage({ + type: 'GENERATION_CANCELLED', + requestId: message.payload.requestId, + payload: { + executionTimeMs: result.executionTimeMs || 0, + timestamp: new Date().toISOString(), + }, + }); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.payload.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'No active generation found to cancel', + }, + }); + } + } + break; + + case 'CONFIRM_OVERWRITE': + // TODO: Will be implemented in Phase 4 + console.log('CONFIRM_OVERWRITE:', message.payload); + break; + + case 'BROWSE_SKILLS': + // Browse available Claude Code Skills + await handleBrowseSkills(webview, message.requestId || ''); + break; + + case 'CREATE_SKILL': + // Create new Skill (Phase 5) + if (message.payload) { + await handleCreateSkill(message.payload, webview, message.requestId || ''); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Skill creation payload is required', + }, + }); + } + break; + + case 'VALIDATE_SKILL_FILE': + // Validate Skill file + if (message.payload) { + await handleValidateSkillFile(message.payload, webview, message.requestId || ''); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Skill file path is required', + }, + }); + } + break; + + case 'REFINE_WORKFLOW': + // AI-assisted workflow refinement + if (message.payload) { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + await handleRefineWorkflow( + message.payload, + webview, + message.requestId || '', + context.extensionPath, + workspaceRoot + ); + } else { webview.postMessage({ - type: 'GENERATION_CANCELLED', - requestId: message.payload.requestId, + type: 'REFINEMENT_FAILED', + requestId: message.requestId, payload: { - executionTimeMs: result.executionTimeMs || 0, + error: { + code: 'VALIDATION_ERROR', + message: 'Refinement payload is required', + }, + executionTimeMs: 0, timestamp: new Date().toISOString(), }, }); + } + break; + + case 'CANCEL_REFINEMENT': + // Cancel workflow refinement + if (message.payload) { + await handleCancelRefinement(message.payload, webview, message.requestId || ''); } else { webview.postMessage({ type: 'ERROR', - requestId: message.payload.requestId, + requestId: message.requestId, payload: { code: 'VALIDATION_ERROR', - message: 'No active generation found to cancel', + message: 'Cancel refinement payload is required', }, }); } - } - break; - - case 'CONFIRM_OVERWRITE': - // TODO: Will be implemented in Phase 4 - console.log('CONFIRM_OVERWRITE:', message.payload); - break; - - case 'BROWSE_SKILLS': - // Browse available Claude Code Skills - await handleBrowseSkills(webview, message.requestId || ''); - break; - - case 'CREATE_SKILL': - // Create new Skill (Phase 5) - if (message.payload) { - await handleCreateSkill(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Skill creation payload is required', - }, - }); - } - break; - - case 'VALIDATE_SKILL_FILE': - // Validate Skill file - if (message.payload) { - await handleValidateSkillFile(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Skill file path is required', - }, - }); - } - break; - - case 'REFINE_WORKFLOW': - // AI-assisted workflow refinement - if (message.payload) { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - await handleRefineWorkflow( - message.payload, - webview, - message.requestId || '', - context.extensionPath, - workspaceRoot - ); - } else { - webview.postMessage({ - type: 'REFINEMENT_FAILED', - requestId: message.requestId, - payload: { - error: { + break; + + case 'CLEAR_CONVERSATION': + // Clear conversation history + if (message.payload) { + await handleClearConversation(message.payload, webview, message.requestId || ''); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { code: 'VALIDATION_ERROR', - message: 'Refinement payload is required', + message: 'Clear conversation payload is required', }, - executionTimeMs: 0, - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'CANCEL_REFINEMENT': - // Cancel workflow refinement - if (message.payload) { - await handleCancelRefinement(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Cancel refinement payload is required', - }, - }); - } - break; - - case 'CLEAR_CONVERSATION': - // Clear conversation history - if (message.payload) { - await handleClearConversation(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Clear conversation payload is required', - }, - }); - } - break; - - case 'LIST_MCP_SERVERS': - // List all configured MCP servers (T018) - await handleListMcpServers(message.payload || {}, webview, message.requestId || ''); - break; - - case 'GET_MCP_TOOLS': - // Get tools from a specific MCP server (T019) - if (message.payload?.serverId) { - await handleGetMcpTools(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Server ID is required', - }, - }); - } - break; - - case 'GET_MCP_TOOL_SCHEMA': - // Get detailed schema for a specific tool (T028) - if (message.payload?.serverId && message.payload?.toolName) { - await handleGetMcpToolSchema(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Server ID and Tool Name are required', - }, - }); - } - break; + }); + } + break; - default: - console.warn('Unknown message type:', message); - } - }, - undefined, - context.subscriptions - ); - - // Handle panel disposal - currentPanel.onDidDispose( - () => { - currentPanel = undefined; - }, - undefined, - context.subscriptions - ); - - // Show information message - vscode.window.showInformationMessage('Claude Code Workflow Studio: Editor opened!'); - }); + case 'LIST_MCP_SERVERS': + // List all configured MCP servers (T018) + await handleListMcpServers(message.payload || {}, webview, message.requestId || ''); + break; + + case 'GET_MCP_TOOLS': + // Get tools from a specific MCP server (T019) + if (message.payload?.serverId) { + await handleGetMcpTools(message.payload, webview, message.requestId || ''); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Server ID is required', + }, + }); + } + break; + + case 'GET_MCP_TOOL_SCHEMA': + // Get detailed schema for a specific tool (T028) + if (message.payload?.serverId && message.payload?.toolName) { + await handleGetMcpToolSchema(message.payload, webview, message.requestId || ''); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Server ID and Tool Name are required', + }, + }); + } + break; + + case 'LIST_SLACK_WORKSPACES': + // List connected Slack workspaces + await handleListSlackWorkspaces(webview, message.requestId || '', slackApiService); + break; + + case 'GET_SLACK_CHANNELS': + // Get Slack channels for specific workspace + if (message.payload?.workspaceId) { + await handleGetSlackChannels( + message.payload, + webview, + message.requestId || '', + slackApiService + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Workspace ID is required', + }, + }); + } + break; + + case 'SHARE_WORKFLOW_TO_SLACK': + // Share workflow to Slack channel (T021) + if (message.payload) { + await handleShareWorkflowToSlack( + message.payload, + webview, + message.requestId || '', + fileService, + slackApiService + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Share workflow payload is required', + }, + }); + } + break; + + case 'IMPORT_WORKFLOW_FROM_SLACK': + // Import workflow from Slack (T026) + if (message.payload) { + await handleImportWorkflowFromSlack( + message.payload, + webview, + message.requestId || '', + fileService, + slackApiService + ); + } else { + webview.postMessage({ + type: 'ERROR', + requestId: message.requestId, + payload: { + code: 'VALIDATION_ERROR', + message: 'Import workflow payload is required', + }, + }); + } + break; + + case 'CONNECT_SLACK_MANUAL': + // Manual Slack connection (Bot Token input) + try { + if (!message.payload?.botToken) { + throw new Error('Bot Token is required'); + } + + const result = await handleConnectSlackManual( + slackTokenManager, + slackApiService, + message.payload.botToken + ); + + if (result) { + webview.postMessage({ + type: 'CONNECT_SLACK_MANUAL_SUCCESS', + requestId: message.requestId, + payload: { + workspaceId: result.workspaceId, + workspaceName: result.workspaceName, + }, + }); + } else { + throw new Error('Failed to connect to Slack'); + } + } catch (error) { + webview.postMessage({ + type: 'CONNECT_SLACK_MANUAL_FAILED', + requestId: message.requestId, + payload: { + code: 'SLACK_CONNECTION_FAILED', + message: error instanceof Error ? error.message : 'Failed to connect to Slack', + }, + }); + } + break; + + case 'SLACK_DISCONNECT': + // Disconnect from Slack workspace + try { + await slackTokenManager.clearConnection(); + vscode.window.showInformationMessage('Slack Bot Token deleted successfully'); + webview.postMessage({ + type: 'SLACK_DISCONNECT_SUCCESS', + requestId: message.requestId, + payload: {}, + }); + } catch (error) { + webview.postMessage({ + type: 'SLACK_DISCONNECT_FAILED', + requestId: message.requestId, + payload: { + message: + error instanceof Error ? error.message : 'Failed to disconnect from Slack', + }, + }); + } + break; + + default: + console.warn('Unknown message type:', message); + } + }, + undefined, + context.subscriptions + ); + + // Handle panel disposal + currentPanel.onDidDispose( + () => { + currentPanel = undefined; + }, + undefined, + context.subscriptions + ); + + // Show information message + vscode.window.showInformationMessage('Claude Code Workflow Studio: Editor opened!'); + } + ); context.subscriptions.push(openEditorCommand); diff --git a/src/extension/commands/slack-connect-manual.ts b/src/extension/commands/slack-connect-manual.ts new file mode 100644 index 00000000..8473bc3e --- /dev/null +++ b/src/extension/commands/slack-connect-manual.ts @@ -0,0 +1,137 @@ +/** + * Slack Manual Token Input Command Handler + * + * Handles manual Slack Bot Token input from users. + * Users manually create Slack App and provide Bot Token only. + * Workspace ID and Workspace Name are automatically retrieved via auth.test API. + * Author name comes from git config (not Slack user). + * + * Based on specs/001-slack-workflow-sharing/tasks.md Phase 8 + */ + +import { WebClient } from '@slack/web-api'; +import * as vscode from 'vscode'; +import { log } from '../extension'; +import type { SlackApiService } from '../services/slack-api-service'; +import { handleSlackError } from '../utils/slack-error-handler'; +import type { SlackTokenManager } from '../utils/slack-token-manager'; + +/** + * Handle manual Slack connection command + * + * Prompts user for workspace information and Bot Token, + * validates the token, and stores it in VSCode Secret Storage. + * + * @param tokenManager - Token manager instance + * @param slackApiService - Slack API service instance + * @param botToken - Optional Bot Token (if provided, skip Input Box prompt) + * @returns Workspace info if successful + */ +export async function handleConnectSlackManual( + tokenManager: SlackTokenManager, + slackApiService: SlackApiService, + botToken?: string +): Promise<{ workspaceId: string; workspaceName: string } | undefined> { + try { + log('INFO', 'Manual Slack connection started'); + + // Step 1: Get Bot Token (from parameter or Input Box) + let accessToken = botToken; + + if (!accessToken) { + // Prompt for Bot Token via Input Box (VSCode command path) + accessToken = await vscode.window.showInputBox({ + prompt: 'Enter Bot User OAuth Token (starts with "xoxb-")', + placeHolder: 'xoxb-...', + password: true, // Hide input + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return 'Bot Token is required'; + } + if (!value.startsWith('xoxb-')) { + return 'Bot Token must start with "xoxb-" (User Tokens "xoxp-" are not supported)'; + } + return null; + }, + }); + + if (!accessToken) { + log('INFO', 'Manual connection cancelled: No token provided'); + return; // User cancelled + } + } + + // Validate token format (for Webview path) + if (!accessToken.startsWith('xoxb-')) { + throw new Error('Bot Token must start with "xoxb-" (User Tokens "xoxp-" are not supported)'); + } + + // Step 2: Validate token and retrieve workspace info from Slack API (auth.test) + log('INFO', 'Validating token with Slack API'); + + const client = new WebClient(accessToken); + const authResponse = await client.auth.test(); + + if (!authResponse.ok) { + throw new Error('Token validation failed: Invalid token'); + } + + // Extract workspace information from auth.test response + const workspaceId = authResponse.team_id as string; + const workspaceName = authResponse.team as string; + + log('INFO', 'Token validation successful', { + workspaceId, + workspaceName, + }); + + // Step 3: Store connection in VSCode Secret Storage + await tokenManager.storeManualConnection( + workspaceId, + workspaceName, + workspaceId, // teamId is same as workspaceId + accessToken, + '' // userId is no longer used (author name comes from git config) + ); + + log('INFO', 'Manual Slack connection stored successfully', { + workspaceId, + workspaceName, + }); + + // Step 7: Show success message (only when called from VSCode command) + if (!botToken) { + const viewDocumentation = 'View Documentation'; + const result = await vscode.window.showInformationMessage( + `Successfully connected to Slack workspace "${workspaceName}"!`, + viewDocumentation + ); + + if (result === viewDocumentation) { + await vscode.env.openExternal( + vscode.Uri.parse('https://github.com/your-repo/docs/slack-manual-token-setup.md') + ); + } + } + + // Invalidate SlackApiService client cache to force re-initialization + slackApiService.invalidateClient(workspaceId); + + log('INFO', 'Manual Slack connection completed successfully'); + + // Return workspace info for Webview callers + return { + workspaceId, + workspaceName, + }; + } catch (error) { + const errorInfo = handleSlackError(error); + + log('ERROR', 'Manual Slack connection failed', { + errorCode: errorInfo.code, + errorMessage: errorInfo.message, + }); + + await vscode.window.showErrorMessage(`Failed to connect to Slack: ${errorInfo.message}`, 'OK'); + } +} diff --git a/src/extension/commands/slack-import-workflow.ts b/src/extension/commands/slack-import-workflow.ts new file mode 100644 index 00000000..d3063a95 --- /dev/null +++ b/src/extension/commands/slack-import-workflow.ts @@ -0,0 +1,257 @@ +/** + * Slack Import Workflow Command Handler + * + * Handles IMPORT_WORKFLOW_FROM_SLACK messages from Webview. + * Downloads workflow file from Slack, validates, and saves to local filesystem. + * + * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md + */ + +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { ImportWorkflowFromSlackPayload } from '../../shared/types/messages'; +import { log } from '../extension'; +import type { FileService } from '../services/file-service'; +import type { SlackApiService } from '../services/slack-api-service'; +import type { + ImportWorkflowFailedEvent, + ImportWorkflowSuccessEvent, +} from '../types/slack-messages'; +import { handleSlackError } from '../utils/slack-error-handler'; +import { validateWorkflowFile } from '../utils/workflow-validator'; + +/** + * Handle workflow import from Slack + * + * @param payload - Import workflow request + * @param webview - Webview to send response to + * @param requestId - Request ID for correlation + * @param fileService - File service instance + * @param slackApiService - Slack API service instance + */ +export async function handleImportWorkflowFromSlack( + payload: ImportWorkflowFromSlackPayload, + webview: vscode.Webview, + requestId: string, + fileService: FileService, + slackApiService: SlackApiService +): Promise { + const startTime = Date.now(); + + log('INFO', 'Slack workflow import started', { + requestId, + workflowId: payload.workflowId, + fileId: payload.fileId, + workspaceId: payload.workspaceId, + }); + + try { + // Step 1: Download workflow file from Slack + log('INFO', 'Downloading workflow file from Slack', { requestId }); + const content = await slackApiService.downloadWorkflowFile(payload.workspaceId, payload.fileId); + + log('INFO', 'Workflow file downloaded successfully', { + requestId, + contentLength: content.length, + }); + + // Step 2: Validate workflow file + log('INFO', 'Validating workflow file', { requestId }); + const validationResult = validateWorkflowFile(content); + + if (!validationResult.valid) { + log('ERROR', 'Workflow validation failed', { + requestId, + errors: validationResult.errors, + }); + + sendImportFailed( + webview, + requestId, + payload.workflowId, + 'INVALID_WORKFLOW_FILE', + `Invalid workflow file: ${validationResult.errors?.join(', ')}` + ); + return; + } + + const workflow = validationResult.workflow; + if (!workflow) { + throw new Error('Workflow validation succeeded but workflow object is missing'); + } + + log('INFO', 'Workflow validation passed', { + requestId, + workflowName: workflow.name, + }); + + // Step 3: Select workspace for saving + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + log('ERROR', 'No workspace folder is open', { requestId }); + sendImportFailed( + webview, + requestId, + payload.workflowId, + 'FILE_WRITE_ERROR', + 'No workspace folder is open. Please open a folder or workspace first.' + ); + return; + } + + let selectedWorkspace: vscode.WorkspaceFolder; + + if (workspaceFolders.length === 1) { + // Only one workspace, use it directly + selectedWorkspace = workspaceFolders[0]; + log('INFO', 'Single workspace detected, using it automatically', { + requestId, + workspaceName: selectedWorkspace.name, + }); + } else { + // Multiple workspaces, ask user to select + const workspaceItems = workspaceFolders.map((folder) => ({ + label: folder.name, + description: folder.uri.fsPath, + folder, + })); + + const selected = await vscode.window.showQuickPick(workspaceItems, { + placeHolder: `Select workspace to save workflow "${workflow.name}"`, + ignoreFocusOut: true, + }); + + if (!selected) { + log('INFO', 'User cancelled workspace selection', { requestId }); + // User cancelled, don't send error just stop + return; + } + + selectedWorkspace = selected.folder; + log('INFO', 'User selected workspace', { + requestId, + workspaceName: selectedWorkspace.name, + }); + } + + // Step 4: Determine file path + const workflowsDir = path.join(selectedWorkspace.uri.fsPath, '.vscode', 'workflows'); + const fileName = `${workflow.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.json`; + const filePath = path.join(workflowsDir, fileName); + + // Ensure .vscode/workflows directory exists + const workflowsDirUri = vscode.Uri.file(workflowsDir); + try { + await vscode.workspace.fs.stat(workflowsDirUri); + } catch { + await vscode.workspace.fs.createDirectory(workflowsDirUri); + log('INFO', 'Created workflows directory', { requestId, workflowsDir }); + } + + // Step 4: Check if file exists (unless overwriting) + if (!payload.overwriteExisting) { + const fileExists = await fileService.fileExists(filePath); + + if (fileExists) { + log('WARN', 'Workflow file already exists', { + requestId, + filePath, + }); + + // Show VSCode native confirmation dialog + const userChoice = await vscode.window.showWarningMessage( + `Workflow "${workflow.name}" already exists. Do you want to overwrite it?`, + { modal: true }, + 'Overwrite', + 'Cancel' + ); + + if (userChoice !== 'Overwrite') { + log('INFO', 'User cancelled overwrite', { requestId }); + // User cancelled - hide loading overlay in Webview + webview.postMessage({ + type: 'IMPORT_WORKFLOW_CANCELLED', + requestId, + }); + return; + } + + log('INFO', 'User confirmed overwrite', { requestId }); + // Continue to save (fall through to Step 5) + } + } + + log('INFO', 'Saving workflow file to disk', { requestId, filePath }); + + // Step 5: Save workflow file + await fileService.writeFile(filePath, content); + + log('INFO', 'Workflow file saved successfully', { requestId }); + + // Step 6: Send success response with workflow data + const successEvent: ImportWorkflowSuccessEvent = { + type: 'IMPORT_WORKFLOW_SUCCESS', + payload: { + workflowId: payload.workflowId, + filePath, + workflowName: workflow.name, + workflow, + }, + }; + + webview.postMessage({ + ...successEvent, + requestId, + }); + + // Show native notification with workspace name + vscode.window.showInformationMessage( + `Workflow "${workflow.name}" imported to ${selectedWorkspace.name}/.vscode/workflows/` + ); + + log('INFO', 'Workflow import completed successfully', { + requestId, + executionTimeMs: Date.now() - startTime, + }); + } catch (error) { + const errorInfo = handleSlackError(error); + + // Log detailed error for debugging + log('ERROR', 'Workflow import failed - detailed error', { + requestId, + errorCode: errorInfo.code, + errorMessage: errorInfo.message, + originalError: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + executionTimeMs: Date.now() - startTime, + }); + + sendImportFailed(webview, requestId, payload.workflowId, errorInfo.code, errorInfo.message); + } +} + +/** + * Send import workflow failed event to Webview + */ +function sendImportFailed( + webview: vscode.Webview, + requestId: string, + workflowId: string, + errorCode: string, + errorMessage: string +): void { + const failedEvent: ImportWorkflowFailedEvent = { + type: 'IMPORT_WORKFLOW_FAILED', + payload: { + workflowId, + errorCode: errorCode as ImportWorkflowFailedEvent['payload']['errorCode'], + errorMessage, + }, + }; + + webview.postMessage({ + ...failedEvent, + requestId, + }); +} diff --git a/src/extension/commands/slack-share-workflow.ts b/src/extension/commands/slack-share-workflow.ts new file mode 100644 index 00000000..e54aa04d --- /dev/null +++ b/src/extension/commands/slack-share-workflow.ts @@ -0,0 +1,348 @@ +/** + * Slack Share Workflow Command Handler + * + * Handles SHARE_WORKFLOW_TO_SLACK messages from Webview. + * Implements workflow sharing with sensitive data detection and warning flow. + * + * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md + */ + +import * as vscode from 'vscode'; +import type { ShareWorkflowToSlackPayload } from '../../shared/types/messages'; +import { log } from '../extension'; +import type { FileService } from '../services/file-service'; +import type { SlackApiService } from '../services/slack-api-service'; +import type { + SensitiveDataWarningEvent, + ShareWorkflowFailedEvent, + ShareWorkflowSuccessEvent, +} from '../types/slack-messages'; +import { detectSensitiveData } from '../utils/sensitive-data-detector'; +import { handleSlackError } from '../utils/slack-error-handler'; +import type { WorkflowMessageBlock } from '../utils/slack-message-builder'; + +/** + * Handle workflow sharing to Slack + * + * @param payload - Share workflow request + * @param webview - Webview to send response to + * @param requestId - Request ID for correlation + * @param fileService - File service instance + * @param slackApiService - Slack API service instance + */ +export async function handleShareWorkflowToSlack( + payload: ShareWorkflowToSlackPayload, + webview: vscode.Webview, + requestId: string, + _fileService: FileService, + slackApiService: SlackApiService +): Promise { + const startTime = Date.now(); + + log('INFO', 'Slack workflow sharing started', { + requestId, + workflowId: payload.workflowId, + channelId: payload.channelId, + }); + + try { + // Use workflow object directly from payload (current canvas state) + const workflow = payload.workflow; + const workflowContent = JSON.stringify(workflow, null, 2); + + // Step 1: Detect sensitive data (if not overriding warning) + if (!payload.overrideSensitiveWarning) { + const findings = detectSensitiveData(workflowContent); + + if (findings.length > 0) { + log('WARN', 'Sensitive data detected in workflow', { + requestId, + findingsCount: findings.length, + types: findings.map((f) => f.type), + }); + + // Send warning to user + const warningEvent: SensitiveDataWarningEvent = { + type: 'SENSITIVE_DATA_WARNING', + payload: { + workflowId: payload.workflowId, + findings, + }, + }; + + webview.postMessage({ + ...warningEvent, + requestId, + }); + + log('INFO', 'Sensitive data warning sent to user', { requestId }); + return; // Stop here, wait for user confirmation + } + } + + log('INFO', 'No sensitive data detected or warning overridden', { requestId }); + + // Step 2: Get Slack user information for author + log('INFO', 'Getting Slack user information', { requestId }); + const userInfo = await slackApiService.getUserInfo(payload.workspaceId); + const authorName = userInfo.userName; + + // Step 3: Extract workflow metadata + const nodeCount = workflow.nodes.length; + const createdAt = + typeof workflow.createdAt === 'string' + ? workflow.createdAt + : new Date(workflow.createdAt).toISOString(); + + // Step 4: Post rich message card to channel (main message) + log('INFO', 'Posting workflow message card to Slack', { requestId }); + + const messageBlock: WorkflowMessageBlock = { + workflowId: workflow.id, + name: workflow.name, + description: payload.description || workflow.description, + version: workflow.version, + authorName, + nodeCount, + createdAt, + fileId: '', // Will be updated after file upload + workspaceId: payload.workspaceId, + channelId: payload.channelId, + }; + + const messageResult = await slackApiService.postWorkflowMessage( + payload.workspaceId, + payload.channelId, + messageBlock + ); + + log('INFO', 'Workflow message card posted successfully', { + requestId, + messageTs: messageResult.messageTs, + permalink: messageResult.permalink, + }); + + // Step 5: Upload workflow file to thread as reply + log('INFO', 'Uploading workflow file to thread', { requestId }); + + const filename = `${payload.workflowName.replace(/[^a-zA-Z0-9-_]/g, '_')}.json`; + const uploadResult = await slackApiService.uploadWorkflowFile({ + workspaceId: payload.workspaceId, + content: workflowContent, + filename, + title: payload.workflowName, + channelId: payload.channelId, + threadTs: messageResult.messageTs, + }); + + log('INFO', 'Workflow file uploaded to thread successfully', { + requestId, + fileId: uploadResult.fileId, + }); + + // Step 6: Update message with complete deep link + log('INFO', 'Updating message with complete deep link', { requestId }); + + const updatedMessageBlock: WorkflowMessageBlock = { + ...messageBlock, + fileId: uploadResult.fileId, + messageTs: messageResult.messageTs, + }; + + await slackApiService.updateWorkflowMessage( + payload.workspaceId, + payload.channelId, + messageResult.messageTs, + updatedMessageBlock + ); + + log('INFO', 'Message updated with complete deep link', { requestId }); + + // Step 6: Send success response + const successEvent: ShareWorkflowSuccessEvent = { + type: 'SHARE_WORKFLOW_SUCCESS', + payload: { + workflowId: payload.workflowId, + channelId: payload.channelId, + channelName: '', // TODO: Resolve channel name from channelId + messageTs: messageResult.messageTs, + fileId: uploadResult.fileId, + permalink: messageResult.permalink, + }, + }; + + log('INFO', 'Sending success message to webview', { requestId }); + + webview.postMessage({ + ...successEvent, + requestId, + }); + + // Show native notification + const viewInSlackButton = 'View in Slack'; + const result = await vscode.window.showInformationMessage( + `Workflow "${payload.workflowName}" shared to Slack successfully!`, + viewInSlackButton + ); + + // Open Slack permalink if user clicks the button + if (result === viewInSlackButton) { + await vscode.env.openExternal(vscode.Uri.parse(messageResult.permalink)); + } + + log('INFO', 'Workflow sharing completed successfully', { + requestId, + executionTimeMs: Date.now() - startTime, + }); + } catch (error) { + const errorInfo = handleSlackError(error); + + log('ERROR', 'Workflow sharing failed', { + requestId, + errorCode: errorInfo.code, + errorMessage: errorInfo.message, + executionTimeMs: Date.now() - startTime, + }); + + sendShareFailed(webview, requestId, payload.workflowId, errorInfo.code, errorInfo.message); + } +} + +/** + * Handle list Slack workspaces request + * + * @param webview - Webview to send response to + * @param requestId - Request ID for correlation + * @param slackApiService - Slack API service instance + */ +export async function handleListSlackWorkspaces( + webview: vscode.Webview, + requestId: string, + slackApiService: SlackApiService +): Promise { + try { + log('INFO', 'Listing Slack workspaces', { requestId }); + + const workspaces = await slackApiService.getWorkspaces(); + + // Convert to message payload format + const workspaceList = workspaces.map((ws) => ({ + workspaceId: ws.workspaceId, + workspaceName: ws.workspaceName, + teamId: ws.teamId, + authorizedAt: ws.authorizedAt.toISOString(), + lastValidatedAt: ws.lastValidatedAt?.toISOString(), + })); + + webview.postMessage({ + type: 'LIST_SLACK_WORKSPACES_SUCCESS', + requestId, + payload: { + workspaces: workspaceList, + }, + }); + + log('INFO', 'Workspace list retrieved successfully', { + requestId, + count: workspaceList.length, + }); + } catch (error) { + const errorInfo = handleSlackError(error); + + log('ERROR', 'Failed to list workspaces', { + requestId, + errorCode: errorInfo.code, + errorMessage: errorInfo.message, + }); + + webview.postMessage({ + type: 'LIST_SLACK_WORKSPACES_FAILED', + requestId, + payload: { + message: errorInfo.message, + }, + }); + } +} + +/** + * Handle get Slack channels request + * + * @param payload - Get channels request payload + * @param webview - Webview to send response to + * @param requestId - Request ID for correlation + * @param slackApiService - Slack API service instance + */ +export async function handleGetSlackChannels( + payload: { workspaceId: string; includePrivate?: boolean; onlyMember?: boolean }, + webview: vscode.Webview, + requestId: string, + slackApiService: SlackApiService +): Promise { + try { + log('INFO', 'Getting Slack channels', { + requestId, + workspaceId: payload.workspaceId, + }); + + const channels = await slackApiService.getChannels( + payload.workspaceId, + payload.includePrivate ?? true, + payload.onlyMember ?? true + ); + + webview.postMessage({ + type: 'GET_SLACK_CHANNELS_SUCCESS', + requestId, + payload: { + channels, + }, + }); + + log('INFO', 'Channel list retrieved successfully', { + requestId, + count: channels.length, + }); + } catch (error) { + const errorInfo = handleSlackError(error); + + log('ERROR', 'Failed to get channels', { + requestId, + errorCode: errorInfo.code, + errorMessage: errorInfo.message, + }); + + webview.postMessage({ + type: 'GET_SLACK_CHANNELS_FAILED', + requestId, + payload: { + message: errorInfo.message, + }, + }); + } +} + +/** + * Send share workflow failed event to Webview + */ +function sendShareFailed( + webview: vscode.Webview, + requestId: string, + workflowId: string, + errorCode: string, + errorMessage: string +): void { + const failedEvent: ShareWorkflowFailedEvent = { + type: 'SHARE_WORKFLOW_FAILED', + payload: { + workflowId, + errorCode: errorCode as ShareWorkflowFailedEvent['payload']['errorCode'], + errorMessage, + }, + }; + + webview.postMessage({ + ...failedEvent, + requestId, + }); +} diff --git a/src/extension/extension.ts b/src/extension/extension.ts index d4c38b53..75ffab82 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -6,6 +6,9 @@ import * as vscode from 'vscode'; import { registerOpenEditorCommand } from './commands/open-editor'; +import { handleConnectSlackManual } from './commands/slack-connect-manual'; +import { SlackApiService } from './services/slack-api-service'; +import { SlackTokenManager } from './utils/slack-token-manager'; /** * Global Output Channel for logging @@ -58,7 +61,103 @@ export function activate(context: vscode.ExtensionContext): void { // Register commands registerOpenEditorCommand(context); - log('INFO', 'Claude Code Workflow Studio: All commands registered'); + // Register Slack import command (T031) + context.subscriptions.push( + vscode.commands.registerCommand('claudeCodeWorkflowStudio.slack.importWorkflow', async () => { + log('INFO', 'Slack: Import Workflow command invoked'); + + // Show input box for Slack file URL or ID + const input = await vscode.window.showInputBox({ + prompt: 'Enter Slack file URL or file ID', + placeHolder: 'https://files.slack.com/... or F0123456789', + }); + + if (!input) { + log('INFO', 'User cancelled Slack import'); + return; + } + + log('INFO', 'Slack import input received', { input }); + + // TODO: Parse URL and extract file ID, then trigger import + // For now, show error message + vscode.window.showErrorMessage( + 'Slack import via command is not fully implemented yet. Use the "Import to VS Code" button in Slack messages.' + ); + }) + ); + + // Register Slack manual token connection command (T103) + context.subscriptions.push( + vscode.commands.registerCommand('claudeCodeWorkflowStudio.slack.connectManual', async () => { + log('INFO', 'Slack: Connect Workspace (Manual Token) command invoked'); + + const tokenManager = new SlackTokenManager(context); + const slackApiService = new SlackApiService(tokenManager); + + await handleConnectSlackManual(tokenManager, slackApiService); + }) + ); + + // Register URI handler for deep links (vscode://cc-wf-studio/import?...) + context.subscriptions.push( + vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri): void { + log('INFO', 'URI handler invoked', { uri: uri.toString() }); + + // Parse URI path and query parameters + const path = uri.path; + const query = new URLSearchParams(uri.query); + + if (path === '/import') { + // Extract import parameters + const fileId = query.get('fileId'); + const channelId = query.get('channelId'); + const messageTs = query.get('messageTs'); + const workspaceId = query.get('workspaceId'); + const workflowId = query.get('workflowId'); + + if (!fileId || !channelId || !messageTs || !workspaceId || !workflowId) { + log('ERROR', 'Missing required import parameters', { + fileId, + channelId, + messageTs, + workspaceId, + workflowId, + }); + vscode.window.showErrorMessage('Invalid import URL: Missing required parameters'); + return; + } + + log('INFO', 'Importing workflow from Slack via deep link', { + fileId, + channelId, + messageTs, + workspaceId, + workflowId, + }); + + // Open editor with import parameters + vscode.commands + .executeCommand('cc-wf-studio.openEditor', { + fileId, + channelId, + messageTs, + workspaceId, + workflowId, + }) + .then(() => { + log('INFO', 'Editor opened with import parameters', { workflowId }); + }); + } else { + log('WARN', 'Unknown URI path', { path }); + vscode.window.showErrorMessage(`Unknown deep link path: ${path}`); + } + }, + }) + ); + + log('INFO', 'Claude Code Workflow Studio: All commands and handlers registered'); } /** diff --git a/src/extension/services/claude-code-service.ts b/src/extension/services/claude-code-service.ts index 6f5ca283..1dfcea5c 100644 --- a/src/extension/services/claude-code-service.ts +++ b/src/extension/services/claude-code-service.ts @@ -8,10 +8,8 @@ * See: Issue #79 - Windows environment compatibility */ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const nanoSpawn = require('nano-spawn'); - import type { ChildProcess } from 'node:child_process'; +import nanoSpawn from 'nano-spawn'; import { log } from '../extension'; /** diff --git a/src/extension/services/mcp-cli-service.ts b/src/extension/services/mcp-cli-service.ts index 768835f3..7a26c2c7 100644 --- a/src/extension/services/mcp-cli-service.ts +++ b/src/extension/services/mcp-cli-service.ts @@ -11,9 +11,7 @@ * See: Issue #79 - Windows environment compatibility */ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const nanoSpawn = require('nano-spawn'); - +import nanoSpawn from 'nano-spawn'; import type { McpServerReference, McpToolReference } from '../../shared/types/mcp-node'; import { log } from '../extension'; import { getCachedTools, setCachedTools } from './mcp-cache-service'; diff --git a/src/extension/services/slack-api-service.ts b/src/extension/services/slack-api-service.ts new file mode 100644 index 00000000..ced80a37 --- /dev/null +++ b/src/extension/services/slack-api-service.ts @@ -0,0 +1,545 @@ +/** + * Slack API Service + * + * Provides high-level interface to Slack Web API. + * Handles authentication, error handling, and response parsing. + * + * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md + */ + +import { WebClient } from '@slack/web-api'; +import type { SlackChannel } from '../types/slack-integration-types'; +import { handleSlackError } from '../utils/slack-error-handler'; +import { + buildWorkflowMessageBlocks, + type WorkflowMessageBlock, +} from '../utils/slack-message-builder'; +import type { SlackTokenManager } from '../utils/slack-token-manager'; + +/** + * Workflow file upload options + */ +export interface WorkflowUploadOptions { + /** Target workspace ID */ + workspaceId: string; + /** Workflow JSON content */ + content: string; + /** Filename */ + filename: string; + /** File title */ + title: string; + /** Target channel ID */ + channelId: string; + /** Initial comment (optional) */ + initialComment?: string; + /** Thread timestamp to upload file as reply (optional) */ + threadTs?: string; +} + +/** + * Message post result + */ +export interface MessagePostResult { + /** Channel ID */ + channelId: string; + /** Message timestamp */ + messageTs: string; + /** Permalink to message */ + permalink: string; +} + +/** + * File upload result + */ +export interface FileUploadResult { + /** File ID */ + fileId: string; + /** File download URL (private) */ + fileUrl: string; + /** File permalink */ + permalink: string; +} + +/** + * Workflow search options + */ +export interface WorkflowSearchOptions { + /** Target workspace ID */ + workspaceId: string; + /** Search query */ + query?: string; + /** Filter by channel ID */ + channelId?: string; + /** Number of results (max: 100) */ + count?: number; + /** Sort order (score | timestamp) */ + sort?: 'score' | 'timestamp'; +} + +/** + * Search result + */ +export interface SearchResult { + /** Message timestamp */ + messageTs: string; + /** Channel ID */ + channelId: string; + /** Channel name */ + channelName: string; + /** Message text */ + text: string; + /** User ID */ + userId: string; + /** Permalink to message */ + permalink: string; + /** Attached file ID (if exists) */ + fileId?: string; + /** File name (if exists) */ + fileName?: string; + /** File download URL (if exists) */ + fileUrl?: string; +} + +/** + * Slack API Service + * + * Wraps Slack Web API with authentication and error handling. + * Supports multiple workspace connections. + */ +export class SlackApiService { + /** Workspace-specific WebClient cache */ + private clients: Map = new Map(); + + constructor(private readonly tokenManager: SlackTokenManager) {} + + /** + * Initializes Slack client for specific workspace with access token + * + * @param workspaceId - Target workspace ID + * @throws Error if workspace not authenticated + */ + private async ensureClient(workspaceId: string): Promise { + // Return cached client if exists + let client = this.clients.get(workspaceId); + if (client) { + return client; + } + + // Get access token for this workspace + const accessToken = await this.tokenManager.getAccessTokenByWorkspaceId(workspaceId); + + if (!accessToken) { + throw new Error(`ワークスペース ${workspaceId} に接続されていません`); + } + + // Create and cache new client + client = new WebClient(accessToken); + this.clients.set(workspaceId, client); + + return client; + } + + /** + * Invalidates cached client (forces re-authentication) + * + * @param workspaceId - Optional workspace ID. If not provided, clears all cached clients. + */ + invalidateClient(workspaceId?: string): void { + if (workspaceId) { + this.clients.delete(workspaceId); + } else { + this.clients.clear(); + } + } + + /** + * Gets list of Slack channels + * + * @param workspaceId - Target workspace ID + * @param includePrivate - Include private channels (default: true) + * @param onlyMember - Only channels user is a member of (default: true) + * @returns Array of channels + */ + async getChannels( + workspaceId: string, + includePrivate = true, + onlyMember = true + ): Promise { + try { + const client = await this.ensureClient(workspaceId); + + // Build channel types filter + const types: string[] = ['public_channel']; + if (includePrivate) { + types.push('private_channel'); + } + + // Fetch channels (with pagination) + const channels: SlackChannel[] = []; + let cursor: string | undefined; + + do { + const response = await client.conversations.list({ + types: types.join(','), + exclude_archived: true, + limit: 100, + cursor, + }); + + if (!response.ok || !response.channels) { + throw new Error('チャンネル一覧の取得に失敗しました'); + } + + // Map to SlackChannel type + for (const channel of response.channels) { + const isMember = channel.is_member ?? false; + + // Filter by membership if requested + if (onlyMember && !isMember) { + continue; + } + + channels.push({ + id: channel.id as string, + name: channel.name as string, + isPrivate: channel.is_private ?? false, + isMember, + memberCount: channel.num_members, + purpose: channel.purpose?.value, + topic: channel.topic?.value, + }); + } + + cursor = response.response_metadata?.next_cursor; + } while (cursor); + + return channels; + } catch (error) { + const errorInfo = handleSlackError(error); + throw new Error(errorInfo.message); + } + } + + /** + * Uploads workflow file to Slack + * + * @param options - Upload options + * @returns File upload result + */ + async uploadWorkflowFile(options: WorkflowUploadOptions): Promise { + try { + const client = await this.ensureClient(options.workspaceId); + + // Upload file using files.uploadV2 + const response = await client.files.uploadV2({ + channel_id: options.channelId, + file: Buffer.from(options.content, 'utf-8'), + filename: options.filename, + title: options.title, + initial_comment: options.initialComment, + thread_ts: options.threadTs, + }); + + if (!response.ok) { + throw new Error('ファイルのアップロードに失敗しました'); + } + + const responseObj = response as unknown as Record; + + // files.uploadV2 returns nested structure: response.files[0].files[0] + const file = responseObj.file as Record | undefined; + const filesWrapper = responseObj.files as Array> | undefined; + + let fileData: Record | undefined = file; + + // If no direct file object, try to get from nested structure + if (!fileData && filesWrapper && filesWrapper.length > 0) { + const innerWrapper = filesWrapper[0]; + const innerFiles = innerWrapper.files as Array> | undefined; + + if (innerFiles && innerFiles.length > 0) { + fileData = innerFiles[0]; + } + } + + if (!fileData) { + throw new Error('ファイルのアップロードに失敗しました'); + } + + return { + fileId: fileData.id as string, + fileUrl: fileData.url_private as string, + permalink: fileData.permalink as string, + }; + } catch (error) { + const errorInfo = handleSlackError(error); + throw new Error(errorInfo.message); + } + } + + /** + * Posts rich message card to channel + * + * @param workspaceId - Target workspace ID + * @param channelId - Target channel ID + * @param block - Workflow message block + * @returns Message post result + */ + async postWorkflowMessage( + workspaceId: string, + channelId: string, + block: WorkflowMessageBlock + ): Promise { + try { + const client = await this.ensureClient(workspaceId); + + // Build Block Kit blocks + const blocks = buildWorkflowMessageBlocks(block); + + // Post message + const response = await client.chat.postMessage({ + channel: channelId, + text: `New workflow shared: ${block.name}`, + // biome-ignore lint/suspicious/noExplicitAny: Slack Web API type definitions are incomplete + blocks: blocks as any, + }); + + if (!response.ok) { + throw new Error('メッセージの投稿に失敗しました'); + } + + // Get permalink + const permalinkResponse = await client.chat.getPermalink({ + channel: channelId, + message_ts: response.ts as string, + }); + + return { + channelId, + messageTs: response.ts as string, + permalink: (permalinkResponse.permalink as string) || '', + }; + } catch (error) { + const errorInfo = handleSlackError(error); + throw new Error(errorInfo.message); + } + } + + /** + * Searches for workflow messages + * + * @param options - Search options + * @returns Array of search results + */ + async searchWorkflows(options: WorkflowSearchOptions): Promise { + try { + const client = await this.ensureClient(options.workspaceId); + + // Build search query + let query = 'workflow filename:*.json'; + if (options.query) { + query = `${options.query} ${query}`; + } + if (options.channelId) { + query = `${query} in:<#${options.channelId}>`; + } + + // Search messages + const response = await client.search.messages({ + query, + count: Math.min(options.count || 20, 100), + sort: options.sort || 'timestamp', + }); + + if (!response.ok || !response.messages) { + throw new Error('ワークフロー検索に失敗しました'); + } + + const matches = response.messages.matches || []; + const results: SearchResult[] = []; + + for (const match of matches) { + const file = match.files?.[0]; + + results.push({ + messageTs: match.ts as string, + channelId: match.channel?.id as string, + channelName: match.channel?.name as string, + text: match.text as string, + userId: match.user as string, + permalink: match.permalink as string, + fileId: file?.id, + fileName: file?.name, + fileUrl: file?.url_private, + }); + } + + return results; + } catch (error) { + const errorInfo = handleSlackError(error); + throw new Error(errorInfo.message); + } + } + + /** + * Validates token for specific workspace + * + * @param workspaceId - Target workspace ID + * @returns True if token is valid + */ + async validateToken(workspaceId: string): Promise { + try { + const client = await this.ensureClient(workspaceId); + const response = await client.auth.test(); + return response.ok === true; + } catch (_error) { + return false; + } + } + + /** + * Gets list of connected workspaces + * + * @returns Array of workspace connections + */ + async getWorkspaces() { + return this.tokenManager.getWorkspaces(); + } + + /** + * Gets current user information (Git username, not Slack user) + * + * @param _workspaceId - Target workspace ID (not used, kept for compatibility) + * @returns User information (user_id as empty string, Git username) + */ + async getUserInfo(_workspaceId: string): Promise<{ + userId: string; + userName: string; + }> { + try { + // Get Git user name from git config + const { exec } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execAsync = promisify(exec); + + try { + const { stdout } = await execAsync('git config user.name'); + const userName = stdout.trim(); + + if (userName) { + return { + userId: '', // No longer needed + userName, + }; + } + } catch (_error) { + // Git command failed, use fallback + } + + // Fallback: Use environment username + const userName = process.env.USER || process.env.USERNAME || 'Unknown User'; + + return { + userId: '', // No longer needed + userName, + }; + } catch (_error) { + return { + userId: '', + userName: 'Unknown User', + }; + } + } + + /** + * Updates existing workflow message with new content + * + * @param workspaceId - Target workspace ID + * @param channelId - Target channel ID + * @param messageTs - Message timestamp to update + * @param block - Updated workflow message block + */ + async updateWorkflowMessage( + workspaceId: string, + channelId: string, + messageTs: string, + block: WorkflowMessageBlock + ): Promise { + try { + const client = await this.ensureClient(workspaceId); + + // Build Block Kit blocks + const blocks = buildWorkflowMessageBlocks(block); + + // Update message + const response = await client.chat.update({ + channel: channelId, + ts: messageTs, + text: `Workflow shared: ${block.name}`, + // biome-ignore lint/suspicious/noExplicitAny: Slack Web API type definitions are incomplete + blocks: blocks as any, + }); + + if (!response.ok) { + throw new Error('メッセージの更新に失敗しました'); + } + } catch (error) { + const errorInfo = handleSlackError(error); + throw new Error(errorInfo.message); + } + } + + /** + * Downloads workflow file from Slack + * + * @param workspaceId - Target workspace ID + * @param fileId - Slack file ID to download + * @returns Workflow JSON content as string + */ + async downloadWorkflowFile(workspaceId: string, fileId: string): Promise { + try { + const client = await this.ensureClient(workspaceId); + + // Get file info using files.info API + const response = await client.files.info({ + file: fileId, + }); + + if (!response.ok || !response.file) { + throw new Error('ファイル情報の取得に失敗しました'); + } + + const file = response.file as Record; + const urlPrivate = file.url_private as string | undefined; + + if (!urlPrivate) { + throw new Error('ファイルのダウンロードURLが見つかりません'); + } + + // Download file content from url_private + const accessToken = await this.tokenManager.getAccessTokenByWorkspaceId(workspaceId); + if (!accessToken) { + throw new Error(`ワークスペース ${workspaceId} に接続されていません`); + } + + // Fetch file content with Authorization header + const fileResponse = await fetch(urlPrivate, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!fileResponse.ok) { + throw new Error(`ファイルのダウンロードに失敗しました (HTTP ${fileResponse.status})`); + } + + const content = await fileResponse.text(); + + return content; + } catch (error) { + console.error('[SlackApiService] downloadWorkflowFile error:', error); + const errorInfo = handleSlackError(error); + throw new Error(errorInfo.message); + } + } +} diff --git a/src/extension/types/slack-integration-types.ts b/src/extension/types/slack-integration-types.ts new file mode 100644 index 00000000..b5cd14fe --- /dev/null +++ b/src/extension/types/slack-integration-types.ts @@ -0,0 +1,226 @@ +/** + * Slack Integration Data Models + * + * This file defines TypeScript types for Slack integration feature. + * Based on specs/001-slack-workflow-sharing/data-model.md + */ + +// ============================================================================ +// 1. SlackWorkspaceConnection +// ============================================================================ + +/** + * Slack workspace connection information + * + * Manages workspace connections using Bot User OAuth Tokens. + * Access tokens are stored in VSCode Secret Storage (encrypted). + */ +export interface SlackWorkspaceConnection { + /** Slack Workspace ID (e.g., T01234ABCD) */ + workspaceId: string; + + /** Workspace display name */ + workspaceName: string; + + /** Slack Team ID */ + teamId: string; + + /** Bot User OAuth Token (stored in VSCode Secret Storage only) */ + accessToken: string; + + /** Token scopes (e.g., ['chat:write', 'files:write']) - Optional for manual token input */ + tokenScope?: string[]; + + /** Authenticated user's Slack User ID (e.g., U01234EFGH) */ + userId: string; + + /** Authorization timestamp (ISO 8601) */ + authorizedAt: Date; + + /** Last token validation timestamp (ISO 8601) */ + lastValidatedAt?: Date; +} + +// ============================================================================ +// 2. SensitiveDataFinding & SensitiveDataType +// ============================================================================ + +/** + * Types of sensitive data that can be detected + */ +export enum SensitiveDataType { + AWS_ACCESS_KEY = 'AWS_ACCESS_KEY', + AWS_SECRET_KEY = 'AWS_SECRET_KEY', + API_KEY = 'API_KEY', + TOKEN = 'TOKEN', + SLACK_TOKEN = 'SLACK_TOKEN', + GITHUB_TOKEN = 'GITHUB_TOKEN', + PRIVATE_KEY = 'PRIVATE_KEY', + PASSWORD = 'PASSWORD', + CUSTOM = 'CUSTOM', // User-defined pattern +} + +/** + * Sensitive data detection result + * + * Contains masked values and severity information. + * Original values are never stored. + */ +export interface SensitiveDataFinding { + /** Type of sensitive data detected */ + type: SensitiveDataType; + + /** Masked value (first 4 + last 4 chars only, e.g., 'AKIA...X7Z9') */ + maskedValue: string; + + /** Character offset in file */ + position: number; + + /** Surrounding context (max 100 chars) */ + context?: string; + + /** Severity level (high = AWS keys, medium = API keys, low = passwords) */ + severity: 'low' | 'medium' | 'high'; +} + +// ============================================================================ +// 3. SlackChannel +// ============================================================================ + +/** + * Slack channel information + * + * Retrieved from Slack API conversations.list + */ +export interface SlackChannel { + /** Channel ID (e.g., C01234ABCD) */ + id: string; + + /** Channel name (e.g., 'general', 'team-announcements') */ + name: string; + + /** Whether channel is private */ + isPrivate: boolean; + + /** Whether user is a member of the channel */ + isMember: boolean; + + /** Number of members in the channel */ + memberCount?: number; + + /** Channel purpose (max 250 chars) */ + purpose?: string; + + /** Channel topic (max 250 chars) */ + topic?: string; +} + +// ============================================================================ +// 4. SharedWorkflowMetadata +// ============================================================================ + +/** + * Metadata for workflows shared to Slack + * + * Embedded in Slack message block kit as metadata. + * Used for workflow search and import. + */ +export interface SharedWorkflowMetadata { + /** Workflow unique ID (UUID v4) */ + id: string; + + /** Workflow name (1-100 chars) */ + name: string; + + /** Workflow description (max 500 chars) */ + description?: string; + + /** Semantic versioning (e.g., '1.0.0') */ + version: string; + + /** Author's name (from VS Code settings) */ + authorName: string; + + /** Author's email address (optional) */ + authorEmail?: string; + + /** Timestamp when shared to Slack (ISO 8601) */ + sharedAt: Date; + + /** Slack channel ID where shared */ + channelId: string; + + /** Slack channel name (for display) */ + channelName: string; + + /** Slack message timestamp (e.g., '1234567890.123456') */ + messageTs: string; + + /** Slack file ID (e.g., F01234ABCD) */ + fileId: string; + + /** Slack file download URL (private URL) */ + fileUrl: string; + + /** Number of nodes in workflow */ + nodeCount: number; + + /** Tags for search (max 10 tags, each max 30 chars) */ + tags?: string[]; + + /** Whether sensitive data was detected */ + hasSensitiveData: boolean; + + /** Whether user overrode sensitive data warning */ + sensitiveDataOverride?: boolean; +} + +// ============================================================================ +// 5. WorkflowImportRequest & ImportStatus +// ============================================================================ + +/** + * Workflow import status + */ +export enum ImportStatus { + PENDING = 'pending', // Import queued + DOWNLOADING = 'downloading', // Downloading file from Slack + VALIDATING = 'validating', // Validating file format + WRITING = 'writing', // Writing file to disk + COMPLETED = 'completed', // Import completed + FAILED = 'failed', // Import failed +} + +/** + * Workflow import request + * + * Tracks the state of importing a workflow from Slack. + */ +export interface WorkflowImportRequest { + /** Workflow ID to import (UUID v4) */ + workflowId: string; + + /** Source Slack message timestamp */ + sourceMessageTs: string; + + /** Source Slack channel ID */ + sourceChannelId: string; + + /** Slack file ID to download */ + fileId: string; + + /** Target directory (absolute path, e.g., '/Users/.../workflows/') */ + targetDirectory: string; + + /** Whether to overwrite existing file */ + overwriteExisting: boolean; + + /** Request timestamp (ISO 8601) */ + requestedAt: Date; + + /** Current import status */ + status: ImportStatus; + + /** Error message (only when status === 'failed') */ + errorMessage?: string; +} diff --git a/src/extension/types/slack-messages.ts b/src/extension/types/slack-messages.ts new file mode 100644 index 00000000..a41730e2 --- /dev/null +++ b/src/extension/types/slack-messages.ts @@ -0,0 +1,358 @@ +/** + * Slack Integration Message Passing Types + * + * Defines message contracts between Webview UI and Extension Host. + * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md + */ + +import type { + SensitiveDataFinding, + SharedWorkflowMetadata, + SlackChannel, +} from './slack-integration-types'; + +// ============================================================================ +// Common Message Format +// ============================================================================ + +/** + * Generic message wrapper for Webview ↔ Extension Host communication + */ +export interface WebviewMessage { + type: string; + payload: T; +} + +// ============================================================================ +// 1. Webview → Extension Host (Commands) +// ============================================================================ + +/** + * SLACK_CONNECT + * + * Initiates Slack workspace connection (OAuth flow). + */ +export interface SlackConnectCommand { + type: 'SLACK_CONNECT'; + payload: Record; // Empty object +} + +/** + * SLACK_DISCONNECT + * + * Disconnects from Slack workspace (removes token). + */ +export interface SlackDisconnectCommand { + type: 'SLACK_DISCONNECT'; + payload: Record; +} + +/** + * GET_SLACK_CHANNELS + * + * Retrieves list of Slack channels. + */ +export interface GetSlackChannelsCommand { + type: 'GET_SLACK_CHANNELS'; + payload: { + /** Include private channels (default: true) */ + includePrivate?: boolean; + /** Only channels user is a member of (default: true) */ + onlyMember?: boolean; + }; +} + +/** + * SHARE_WORKFLOW_TO_SLACK + * + * Shares workflow to Slack channel. + */ +export interface ShareWorkflowToSlackCommand { + type: 'SHARE_WORKFLOW_TO_SLACK'; + payload: { + /** Workflow ID to share */ + workflowId: string; + /** Workflow name */ + workflowName: string; + /** Target Slack channel ID */ + channelId: string; + /** Workflow description (optional) */ + description?: string; + /** Override sensitive data warning (default: false) */ + overrideSensitiveWarning?: boolean; + }; +} + +/** + * IMPORT_WORKFLOW_FROM_SLACK + * + * Imports workflow from Slack message. + */ +export interface ImportWorkflowFromSlackCommand { + type: 'IMPORT_WORKFLOW_FROM_SLACK'; + payload: { + /** Workflow ID to import */ + workflowId: string; + /** Slack file ID */ + fileId: string; + /** Slack message timestamp */ + messageTs: string; + /** Slack channel ID */ + channelId: string; + /** Overwrite existing file (default: false) */ + overwriteExisting?: boolean; + }; +} + +/** + * SEARCH_SLACK_WORKFLOWS + * + * Searches for workflows previously shared to Slack. + */ +export interface SearchSlackWorkflowsCommand { + type: 'SEARCH_SLACK_WORKFLOWS'; + payload: { + /** Search keyword (optional) */ + query?: string; + /** Filter by channel ID (optional) */ + channelId?: string; + /** Filter by author name (optional) */ + authorName?: string; + /** Filter by start date (ISO 8601) (optional) */ + fromDate?: string; + /** Filter by end date (ISO 8601) (optional) */ + toDate?: string; + /** Result limit (default: 20, max: 100) */ + limit?: number; + }; +} + +/** + * Union type of all Webview → Extension Host commands + */ +export type WebviewToExtensionCommand = + | SlackConnectCommand + | SlackDisconnectCommand + | GetSlackChannelsCommand + | ShareWorkflowToSlackCommand + | ImportWorkflowFromSlackCommand + | SearchSlackWorkflowsCommand; + +// ============================================================================ +// 2. Extension Host → Webview (Events) +// ============================================================================ + +/** + * SLACK_CONNECT_SUCCESS + * + * Sent when Slack connection succeeds. + */ +export interface SlackConnectSuccessEvent { + type: 'SLACK_CONNECT_SUCCESS'; + payload: { + workspaceId: string; + workspaceName: string; + userId: string; + authorizedAt: string; // ISO 8601 + }; +} + +/** + * SLACK_CONNECT_FAILED + * + * Sent when Slack connection fails. + */ +export interface SlackConnectFailedEvent { + type: 'SLACK_CONNECT_FAILED'; + payload: { + errorCode: 'USER_CANCELLED' | 'OAUTH_FAILED' | 'NETWORK_ERROR' | 'UNKNOWN_ERROR'; + errorMessage: string; + }; +} + +/** + * SLACK_DISCONNECT_SUCCESS + * + * Sent when Slack disconnection succeeds. + */ +export interface SlackDisconnectSuccessEvent { + type: 'SLACK_DISCONNECT_SUCCESS'; + payload: Record; +} + +/** + * SLACK_DISCONNECT_FAILED + * + * Sent when Slack disconnection fails. + */ +export interface SlackDisconnectFailedEvent { + type: 'SLACK_DISCONNECT_FAILED'; + payload: { + errorCode: string; + errorMessage: string; + }; +} + +/** + * GET_SLACK_CHANNELS_SUCCESS + * + * Sent when channel list retrieval succeeds. + */ +export interface GetSlackChannelsSuccessEvent { + type: 'GET_SLACK_CHANNELS_SUCCESS'; + payload: { + channels: SlackChannel[]; + }; +} + +/** + * GET_SLACK_CHANNELS_FAILED + * + * Sent when channel list retrieval fails. + */ +export interface GetSlackChannelsFailedEvent { + type: 'GET_SLACK_CHANNELS_FAILED'; + payload: { + errorCode: string; + errorMessage: string; + }; +} + +/** + * SENSITIVE_DATA_WARNING + * + * Sent when sensitive data is detected (requires user confirmation). + */ +export interface SensitiveDataWarningEvent { + type: 'SENSITIVE_DATA_WARNING'; + payload: { + workflowId: string; + findings: SensitiveDataFinding[]; + }; +} + +/** + * SHARE_WORKFLOW_SUCCESS + * + * Sent when workflow sharing succeeds. + */ +export interface ShareWorkflowSuccessEvent { + type: 'SHARE_WORKFLOW_SUCCESS'; + payload: { + workflowId: string; + channelId: string; + channelName: string; + messageTs: string; + fileId: string; + permalink: string; // Direct link to Slack message + }; +} + +/** + * SHARE_WORKFLOW_FAILED + * + * Sent when workflow sharing fails. + */ +export interface ShareWorkflowFailedEvent { + type: 'SHARE_WORKFLOW_FAILED'; + payload: { + workflowId: string; + errorCode: + | 'NOT_AUTHENTICATED' + | 'CHANNEL_NOT_FOUND' + | 'NOT_IN_CHANNEL' + | 'FILE_TOO_LARGE' + | 'RATE_LIMITED' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; + errorMessage: string; + }; +} + +/** + * IMPORT_WORKFLOW_CONFIRM_OVERWRITE + * + * Sent when existing file is found (requires user confirmation). + */ +export interface ImportWorkflowConfirmOverwriteEvent { + type: 'IMPORT_WORKFLOW_CONFIRM_OVERWRITE'; + payload: { + workflowId: string; + existingFilePath: string; + }; +} + +/** + * IMPORT_WORKFLOW_SUCCESS + * + * Sent when workflow import succeeds. + */ +export interface ImportWorkflowSuccessEvent { + type: 'IMPORT_WORKFLOW_SUCCESS'; + payload: { + workflowId: string; + filePath: string; + workflowName: string; + /** Workflow data for loading into canvas */ + workflow: import('../../shared/types/workflow-definition').Workflow; + }; +} + +/** + * IMPORT_WORKFLOW_FAILED + * + * Sent when workflow import fails. + */ +export interface ImportWorkflowFailedEvent { + type: 'IMPORT_WORKFLOW_FAILED'; + payload: { + workflowId: string; + errorCode: string; + errorMessage: string; + }; +} + +/** + * SEARCH_SLACK_WORKFLOWS_SUCCESS + * + * Sent when workflow search succeeds. + */ +export interface SearchSlackWorkflowsSuccessEvent { + type: 'SEARCH_SLACK_WORKFLOWS_SUCCESS'; + payload: { + workflows: SharedWorkflowMetadata[]; + total: number; + }; +} + +/** + * SEARCH_SLACK_WORKFLOWS_FAILED + * + * Sent when workflow search fails. + */ +export interface SearchSlackWorkflowsFailedEvent { + type: 'SEARCH_SLACK_WORKFLOWS_FAILED'; + payload: { + errorCode: string; + errorMessage: string; + }; +} + +/** + * Union type of all Extension Host → Webview events + */ +export type ExtensionToWebviewEvent = + | SlackConnectSuccessEvent + | SlackConnectFailedEvent + | SlackDisconnectSuccessEvent + | SlackDisconnectFailedEvent + | GetSlackChannelsSuccessEvent + | GetSlackChannelsFailedEvent + | SensitiveDataWarningEvent + | ShareWorkflowSuccessEvent + | ShareWorkflowFailedEvent + | ImportWorkflowConfirmOverwriteEvent + | ImportWorkflowSuccessEvent + | ImportWorkflowFailedEvent + | SearchSlackWorkflowsSuccessEvent + | SearchSlackWorkflowsFailedEvent; diff --git a/src/extension/utils/sensitive-data-detector.ts b/src/extension/utils/sensitive-data-detector.ts new file mode 100644 index 00000000..31e1d4c9 --- /dev/null +++ b/src/extension/utils/sensitive-data-detector.ts @@ -0,0 +1,207 @@ +/** + * Sensitive Data Detector Utility + * + * Detects and masks sensitive information (API keys, tokens, passwords, etc.) + * in workflow JSON files before sharing to Slack. + * + * Based on specs/001-slack-workflow-sharing/data-model.md + */ + +import { type SensitiveDataFinding, SensitiveDataType } from '../types/slack-integration-types'; + +/** + * Detection pattern definition + */ +interface DetectionPattern { + /** Pattern type */ + type: SensitiveDataType; + /** Regular expression for detection */ + pattern: RegExp; + /** Severity level */ + severity: 'low' | 'medium' | 'high'; + /** Minimum length for valid matches */ + minLength?: number; +} + +/** + * Built-in detection patterns + * + * Patterns are based on common secret formats and best practices. + */ +const DETECTION_PATTERNS: DetectionPattern[] = [ + // AWS Access Key (AKIA followed by 16 alphanumeric chars) + { + type: SensitiveDataType.AWS_ACCESS_KEY, + pattern: /AKIA[0-9A-Z]{16}/g, + severity: 'high', + }, + + // AWS Secret Key (40 chars base64-like string) + { + type: SensitiveDataType.AWS_SECRET_KEY, + pattern: /(?:aws_secret_access_key|aws[_-]?secret)["\s]*[:=]["\s]*([A-Za-z0-9/+=]{40})/gi, + severity: 'high', + minLength: 40, + }, + + // Slack Token (xoxb-, xoxp-, xoxa-, xoxo- prefixes) + { + type: SensitiveDataType.SLACK_TOKEN, + pattern: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, + severity: 'high', + }, + + // GitHub Personal Access Token (ghp_ prefix, 36 chars) + { + type: SensitiveDataType.GITHUB_TOKEN, + pattern: /ghp_[A-Za-z0-9]{36}/g, + severity: 'high', + }, + + // Generic API Key patterns + { + type: SensitiveDataType.API_KEY, + pattern: /(?:api[_-]?key|apikey)["\s]*[:=]["\s]*["']?([A-Za-z0-9_-]{20,})["']?/gi, + severity: 'medium', + minLength: 20, + }, + + // Generic Token patterns + { + type: SensitiveDataType.TOKEN, + pattern: + /(?:token|auth[_-]?token|access[_-]?token)["\s]*[:=]["\s]*["']?([A-Za-z0-9_\-.]{20,})["']?/gi, + severity: 'medium', + minLength: 20, + }, + + // Private Key markers + { + type: SensitiveDataType.PRIVATE_KEY, + pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----/g, + severity: 'high', + }, + + // Password patterns + { + type: SensitiveDataType.PASSWORD, + pattern: /(?:password|passwd|pwd)["\s]*[:=]["\s]*["']?([^\s"']{8,})["']?/gi, + severity: 'low', + minLength: 8, + }, +]; + +/** + * Masks a sensitive value + * + * Shows only first 4 and last 4 characters. + * Example: "AKIAIOSFODNN7EXAMPLE" → "AKIA...MPLE" + * + * @param value - Original value to mask + * @returns Masked value + */ +function maskValue(value: string): string { + if (value.length <= 8) { + // Too short to mask meaningfully, mask completely + return '****'; + } + + const first4 = value.substring(0, 4); + const last4 = value.substring(value.length - 4); + return `${first4}...${last4}`; +} + +/** + * Extracts context around a match + * + * @param content - Full content + * @param position - Match position + * @param contextLength - Context length (default: 50 chars on each side) + * @returns Context string + */ +function extractContext(content: string, position: number, contextLength = 50): string { + const start = Math.max(0, position - contextLength); + const end = Math.min(content.length, position + contextLength); + + const contextBefore = content.substring(start, position); + const contextAfter = content.substring(position, end); + + return `...${contextBefore}[REDACTED]${contextAfter}...`; +} + +/** + * Detects sensitive data in content + * + * @param content - Content to scan (workflow JSON as string) + * @returns Array of sensitive data findings + */ +export function detectSensitiveData(content: string): SensitiveDataFinding[] { + const findings: SensitiveDataFinding[] = []; + + for (const patternDef of DETECTION_PATTERNS) { + // Reset regex state + patternDef.pattern.lastIndex = 0; + + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex exec loop pattern + while ((match = patternDef.pattern.exec(content)) !== null) { + const matchedValue = match[1] || match[0]; // Use capture group if available + const position = match.index; + + // Validate minimum length if specified + if (patternDef.minLength && matchedValue.length < patternDef.minLength) { + continue; + } + + findings.push({ + type: patternDef.type, + maskedValue: maskValue(matchedValue), + position, + context: extractContext(content, position), + severity: patternDef.severity, + }); + } + } + + return findings; +} + +/** + * Checks if workflow content contains sensitive data + * + * @param workflowContent - Workflow JSON as string + * @returns True if sensitive data detected, false otherwise + */ +export function hasSensitiveData(workflowContent: string): boolean { + return detectSensitiveData(workflowContent).length > 0; +} + +/** + * Gets high severity findings only + * + * @param findings - All findings + * @returns High severity findings + */ +export function getHighSeverityFindings(findings: SensitiveDataFinding[]): SensitiveDataFinding[] { + return findings.filter((finding) => finding.severity === 'high'); +} + +/** + * Groups findings by type + * + * @param findings - All findings + * @returns Findings grouped by type + */ +export function groupFindingsByType( + findings: SensitiveDataFinding[] +): Map { + const grouped = new Map(); + + for (const finding of findings) { + const existing = grouped.get(finding.type) || []; + existing.push(finding); + grouped.set(finding.type, existing); + } + + return grouped; +} diff --git a/src/extension/utils/slack-error-handler.ts b/src/extension/utils/slack-error-handler.ts new file mode 100644 index 00000000..64ae01d8 --- /dev/null +++ b/src/extension/utils/slack-error-handler.ts @@ -0,0 +1,240 @@ +/** + * Slack Error Handler Utility + * + * Provides unified error handling for Slack API operations. + * Maps Slack API errors to user-friendly messages. + * + * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md + */ + +/** + * Slack error information + */ +export interface SlackErrorInfo { + /** Error code (for programmatic handling) */ + code: string; + /** User-friendly error message */ + message: string; + /** Whether error is recoverable */ + recoverable: boolean; + /** Suggested action for user */ + suggestedAction?: string; + /** Retry after seconds (for rate limiting) */ + retryAfter?: number; +} + +/** + * Error code mappings + */ +const ERROR_MAPPINGS: Record> = { + invalid_auth: { + message: 'Slackトークンが無効です', + recoverable: true, + suggestedAction: '再度Slackに接続してください', + }, + missing_scope: { + message: '必要な権限がありません', + recoverable: true, + suggestedAction: 'Slackアプリに必要な権限を追加し、再度接続してください', + }, + rate_limited: { + message: 'Slack APIのレート制限に達しました', + recoverable: true, + suggestedAction: 'しばらく待ってから再試行してください', + }, + channel_not_found: { + message: 'チャンネルが見つかりません', + recoverable: false, + suggestedAction: 'チャンネルIDを確認してください', + }, + not_in_channel: { + message: 'Botがチャンネルに参加していません', + recoverable: true, + suggestedAction: 'Slackアプリをチャンネルに招待してください', + }, + file_too_large: { + message: 'ファイルサイズが大きすぎます', + recoverable: false, + suggestedAction: 'ワークフローファイルのサイズを1MB未満に削減してください', + }, + invalid_file_type: { + message: 'サポートされていないファイルタイプです', + recoverable: false, + suggestedAction: 'JSON形式のワークフローファイルのみサポートされています', + }, + internal_error: { + message: 'Slack内部エラーが発生しました', + recoverable: true, + suggestedAction: 'しばらく待ってから再試行してください', + }, + not_authed: { + message: '認証情報が提供されていません', + recoverable: true, + suggestedAction: 'Slackに接続してください', + }, + invalid_code: { + message: '認証コードが無効または期限切れです', + recoverable: true, + suggestedAction: '再度認証を開始してください', + }, + bad_client_secret: { + message: 'クライアントシークレットが無効です', + recoverable: false, + suggestedAction: 'Slackアプリの設定を確認してください', + }, + invalid_grant_type: { + message: '無効な認証タイプです', + recoverable: false, + suggestedAction: 'Slackアプリの設定を確認してください', + }, + account_inactive: { + message: 'アカウントが無効化されています', + recoverable: false, + suggestedAction: 'Slackアカウントの状態を確認してください', + }, + invalid_query: { + message: '無効な検索クエリです', + recoverable: false, + suggestedAction: '検索キーワードを確認してください', + }, + msg_too_long: { + message: 'メッセージが長すぎます', + recoverable: false, + suggestedAction: 'ワークフローの説明を短くするか、ファイルサイズを削減してください', + }, +}; + +/** + * Handles Slack API errors + * + * @param error - Error from Slack API call + * @returns Structured error information + */ +export function handleSlackError(error: unknown): SlackErrorInfo { + // Check if it's a Slack Web API error (property-based check instead of instanceof) + // This works even when @slack/web-api is an external dependency + if ( + error && + typeof error === 'object' && + 'data' in error && + error.data && + typeof error.data === 'object' + ) { + // Type assertion for Slack Web API error structure + const slackError = error as { data: { error?: string; retryAfter?: number } }; + const errorCode = slackError.data.error || 'unknown_error'; + + // Get error mapping + const mapping = ERROR_MAPPINGS[errorCode] || { + message: `Slack APIエラー: ${errorCode}`, + recoverable: false, + suggestedAction: 'エラーが継続する場合は、サポートにお問い合わせください', + }; + + // Extract retry-after for rate limiting + const retryAfter = slackError.data.retryAfter ? Number(slackError.data.retryAfter) : undefined; + + return { + code: errorCode, + ...mapping, + retryAfter, + }; + } + + // Network or other errors + if (error instanceof Error) { + return { + code: 'NETWORK_ERROR', + message: 'ネットワークエラーが発生しました', + recoverable: true, + suggestedAction: 'インターネット接続を確認してください', + }; + } + + // Unknown error + return { + code: 'UNKNOWN_ERROR', + message: '不明なエラーが発生しました', + recoverable: false, + suggestedAction: 'エラーが継続する場合は、サポートにお問い合わせください', + }; +} + +/** + * Formats error for user display + * + * @param errorInfo - Error information + * @returns Formatted error message + */ +export function formatErrorMessage(errorInfo: SlackErrorInfo): string { + let message = errorInfo.message; + + if (errorInfo.suggestedAction) { + message += `\n\n${errorInfo.suggestedAction}`; + } + + if (errorInfo.retryAfter) { + message += `\n\n${errorInfo.retryAfter}秒後に再試行してください。`; + } + + return message; +} + +/** + * Checks if error is recoverable + * + * @param error - Error from Slack API call + * @returns True if error is recoverable + */ +export function isRecoverableError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return errorInfo.recoverable; +} + +/** + * Checks if error is authentication-related + * + * @param error - Error from Slack API call + * @returns True if authentication error + */ +export function isAuthenticationError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return ['invalid_auth', 'not_authed', 'account_inactive'].includes(errorInfo.code); +} + +/** + * Checks if error is permission-related + * + * @param error - Error from Slack API call + * @returns True if permission error + */ +export function isPermissionError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return ['missing_scope', 'not_in_channel'].includes(errorInfo.code); +} + +/** + * Checks if error is rate limiting + * + * @param error - Error from Slack API call + * @returns True if rate limiting error + */ +export function isRateLimitError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return errorInfo.code === 'rate_limited'; +} + +/** + * Gets retry delay for exponential backoff + * + * @param attempt - Retry attempt number (1-indexed) + * @param maxDelay - Maximum delay in seconds (default: 60) + * @returns Delay in seconds + */ +export function getRetryDelay(attempt: number, maxDelay = 60): number { + // Exponential backoff: 2^attempt seconds, capped at maxDelay + const delay = Math.min(2 ** attempt, maxDelay); + // Add jitter (random 0-20%) + const jitter = delay * 0.2 * Math.random(); + return delay + jitter; +} diff --git a/src/extension/utils/slack-message-builder.ts b/src/extension/utils/slack-message-builder.ts new file mode 100644 index 00000000..2a16f62b --- /dev/null +++ b/src/extension/utils/slack-message-builder.ts @@ -0,0 +1,103 @@ +/** + * Slack Block Kit Message Builder + * + * Builds rich message blocks for Slack using Block Kit format. + * Used for displaying workflow metadata in Slack channels. + * + * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md + */ + +/** + * Workflow message block (Block Kit format) + */ +export interface WorkflowMessageBlock { + /** Workflow ID */ + workflowId: string; + /** Workflow name */ + name: string; + /** Workflow description */ + description?: string; + /** Workflow version */ + version: string; + /** Author name */ + authorName: string; + /** Node count */ + nodeCount: number; + /** Created timestamp (ISO 8601) */ + createdAt: string; + /** File ID (after upload) */ + fileId: string; + /** Workspace ID (for deep link) */ + workspaceId?: string; + /** Channel ID (for deep link) */ + channelId?: string; + /** Message timestamp (for deep link) */ + messageTs?: string; +} + +/** + * Builds Block Kit blocks for workflow message + * + * Creates a rich message card with: + * - Header with workflow name + * - Description section (if provided) + * - Metadata fields (Author, Date) + * - Import link with deep link to VS Code + * + * @param block - Workflow message block + * @returns Block Kit blocks array + */ +export function buildWorkflowMessageBlocks( + block: WorkflowMessageBlock +): Array> { + return [ + // Header + { + type: 'header', + text: { + type: 'plain_text', + text: block.name, + }, + }, + // Description (if provided) + ...(block.description + ? [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: block.description, + }, + }, + { type: 'divider' }, + ] + : [{ type: 'divider' }]), + // Metadata fields + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `*Author:* ${block.authorName}`, + }, + { + type: 'mrkdwn', + text: `*Date:* ${new Date(block.createdAt).toLocaleDateString()}`, + }, + ], + }, + // Import link footer + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: + block.workspaceId && block.channelId && block.messageTs && block.fileId + ? `📥 \n_Note: Please open your target VS Code workspace before clicking the import link_` + : '_Import link will be available after file upload_', + }, + ], + }, + ]; +} diff --git a/src/extension/utils/slack-token-manager.ts b/src/extension/utils/slack-token-manager.ts new file mode 100644 index 00000000..410b4dd7 --- /dev/null +++ b/src/extension/utils/slack-token-manager.ts @@ -0,0 +1,404 @@ +/** + * Slack Token Manager + * + * Manages Slack OAuth tokens using VSCode Secret Storage. + * Provides encrypted storage for access tokens and workspace information. + * + * Based on specs/001-slack-workflow-sharing/data-model.md + */ + +import type { ExtensionContext } from 'vscode'; +import type { SlackWorkspaceConnection } from '../types/slack-integration-types'; + +/** + * Secret storage keys + */ +const SECRET_KEYS = { + /** OAuth access token key (legacy - single workspace) */ + ACCESS_TOKEN: 'slack-oauth-access-token', + /** Workspace connection data key (legacy - single workspace) */ + WORKSPACE_DATA: 'slack-workspace-connection', + /** Workspace list key (stores array of workspace IDs) */ + WORKSPACE_LIST: 'slack-workspace-list', +} as const; + +/** + * Generates workspace-specific secret key + */ +function getWorkspaceSecretKey(workspaceId: string, type: 'token' | 'data'): string { + return type === 'token' ? `slack-oauth-${workspaceId}` : `slack-workspace-${workspaceId}`; +} + +/** + * Slack Token Manager + * + * Handles secure storage and retrieval of Slack authentication tokens. + */ +export class SlackTokenManager { + constructor(private readonly context: ExtensionContext) {} + + /** + * Stores Slack workspace connection + * + * @param connection - Workspace connection details + */ + async storeConnection(connection: SlackWorkspaceConnection): Promise { + const { workspaceId } = connection; + + // Store access token for this workspace + const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); + await this.context.secrets.store(tokenKey, connection.accessToken); + + // Store workspace metadata (without token) + const workspaceData = { + workspaceId: connection.workspaceId, + workspaceName: connection.workspaceName, + teamId: connection.teamId, + tokenScope: connection.tokenScope, + userId: connection.userId, + authorizedAt: connection.authorizedAt.toISOString(), + lastValidatedAt: connection.lastValidatedAt?.toISOString(), + }; + + const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); + await this.context.secrets.store(dataKey, JSON.stringify(workspaceData)); + + // Add to workspace list + await this.addToWorkspaceList(workspaceId); + } + + /** + * Stores Slack workspace connection from manual token input + * + * This is a simplified version of storeConnection() for manual token input. + * It does not require full OAuth flow metadata. + * + * @param workspaceId - Workspace ID (Team ID) + * @param workspaceName - Workspace name + * @param teamId - Team ID (same as workspaceId) + * @param accessToken - Bot User OAuth Token (xoxb-...) + * @param userId - User ID who authorized this connection + */ + async storeManualConnection( + workspaceId: string, + workspaceName: string, + teamId: string, + accessToken: string, + userId: string + ): Promise { + // Validate token format (Bot Token only) + if (!SlackTokenManager.validateTokenFormat(accessToken)) { + throw new Error('Invalid token format. Bot Token (xoxb-...) is required.'); + } + + // Store access token for this workspace + const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); + await this.context.secrets.store(tokenKey, accessToken); + + // Store workspace metadata (without token) + const workspaceData = { + workspaceId, + workspaceName, + teamId, + tokenScope: [], // No scope information from manual input + userId, + authorizedAt: new Date().toISOString(), + lastValidatedAt: undefined, // Will be set after first validation + }; + + const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); + await this.context.secrets.store(dataKey, JSON.stringify(workspaceData)); + + // Add to workspace list + await this.addToWorkspaceList(workspaceId); + } + + /** + * Adds workspace ID to the workspace list + * + * @param workspaceId - Workspace ID to add + */ + private async addToWorkspaceList(workspaceId: string): Promise { + const listJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_LIST); + let workspaceIds: string[] = []; + + if (listJson) { + try { + workspaceIds = JSON.parse(listJson); + } catch (_error) { + // Invalid JSON, start fresh + workspaceIds = []; + } + } + + // Add if not already in list + if (!workspaceIds.includes(workspaceId)) { + workspaceIds.push(workspaceId); + await this.context.secrets.store(SECRET_KEYS.WORKSPACE_LIST, JSON.stringify(workspaceIds)); + } + } + + /** + * Removes workspace ID from the workspace list + * + * @param workspaceId - Workspace ID to remove + */ + private async removeFromWorkspaceList(workspaceId: string): Promise { + const listJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_LIST); + + if (!listJson) { + return; + } + + try { + let workspaceIds: string[] = JSON.parse(listJson); + workspaceIds = workspaceIds.filter((id) => id !== workspaceId); + await this.context.secrets.store(SECRET_KEYS.WORKSPACE_LIST, JSON.stringify(workspaceIds)); + } catch (_error) { + // Invalid JSON, ignore + } + } + + /** + * Retrieves all connected Slack workspaces + * + * @returns Array of workspace connections + */ + async getWorkspaces(): Promise { + const listJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_LIST); + + if (!listJson) { + return []; + } + + try { + const workspaceIds: string[] = JSON.parse(listJson); + const connections: SlackWorkspaceConnection[] = []; + + for (const workspaceId of workspaceIds) { + const connection = await this.getConnectionByWorkspaceId(workspaceId); + if (connection) { + connections.push(connection); + } + } + + return connections; + } catch (_error) { + return []; + } + } + + /** + * Retrieves Slack workspace connection by workspace ID + * + * @param workspaceId - Workspace ID + * @returns Workspace connection if exists, null otherwise + */ + async getConnectionByWorkspaceId(workspaceId: string): Promise { + const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); + const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); + + const accessToken = await this.context.secrets.get(tokenKey); + const workspaceDataJson = await this.context.secrets.get(dataKey); + + if (!accessToken || !workspaceDataJson) { + return null; + } + + try { + const workspaceData = JSON.parse(workspaceDataJson); + + return { + workspaceId: workspaceData.workspaceId, + workspaceName: workspaceData.workspaceName, + teamId: workspaceData.teamId, + accessToken, + tokenScope: workspaceData.tokenScope, + userId: workspaceData.userId, + authorizedAt: new Date(workspaceData.authorizedAt), + lastValidatedAt: workspaceData.lastValidatedAt + ? new Date(workspaceData.lastValidatedAt) + : undefined, + }; + } catch (_error) { + // Invalid JSON, clear corrupted data + await this.clearConnectionByWorkspaceId(workspaceId); + return null; + } + } + + /** + * Retrieves Slack workspace connection (legacy - returns first workspace) + * + * @deprecated Use getWorkspaces() or getConnectionByWorkspaceId() instead + * @returns Workspace connection if exists, null otherwise + */ + async getConnection(): Promise { + const workspaces = await this.getWorkspaces(); + return workspaces.length > 0 ? workspaces[0] : null; + } + + /** + * Gets access token for specific workspace + * + * @param workspaceId - Workspace ID + * @returns Access token if exists, null otherwise + */ + async getAccessTokenByWorkspaceId(workspaceId: string): Promise { + const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); + return (await this.context.secrets.get(tokenKey)) || null; + } + + /** + * Gets access token only (legacy - returns token for first workspace) + * + * @deprecated Use getAccessTokenByWorkspaceId() instead + * @returns Access token if exists, null otherwise + */ + async getAccessToken(): Promise { + const connection = await this.getConnection(); + return connection?.accessToken || null; + } + + /** + * Updates last validated timestamp + * + * @param timestamp - Validation timestamp (default: now) + */ + async updateLastValidated(timestamp: Date = new Date()): Promise { + const workspaceDataJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_DATA); + + if (!workspaceDataJson) { + return; + } + + try { + const workspaceData = JSON.parse(workspaceDataJson); + workspaceData.lastValidatedAt = timestamp.toISOString(); + + await this.context.secrets.store(SECRET_KEYS.WORKSPACE_DATA, JSON.stringify(workspaceData)); + } catch (_error) { + // Invalid JSON, ignore update + } + } + + /** + * Checks if workspace is connected + * + * @returns True if connected, false otherwise + */ + async isConnected(): Promise { + const accessToken = await this.context.secrets.get(SECRET_KEYS.ACCESS_TOKEN); + return !!accessToken; + } + + /** + * Gets workspace ID only + * + * @returns Workspace ID if exists, null otherwise + */ + async getWorkspaceId(): Promise { + const workspaceDataJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_DATA); + + if (!workspaceDataJson) { + return null; + } + + try { + const workspaceData = JSON.parse(workspaceDataJson); + return workspaceData.workspaceId || null; + } catch (_error) { + return null; + } + } + + /** + * Gets workspace name only + * + * @returns Workspace name if exists, null otherwise + */ + async getWorkspaceName(): Promise { + const workspaceDataJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_DATA); + + if (!workspaceDataJson) { + return null; + } + + try { + const workspaceData = JSON.parse(workspaceDataJson); + return workspaceData.workspaceName || null; + } catch (_error) { + return null; + } + } + + /** + * Clears specific workspace connection + * + * @param workspaceId - Workspace ID to clear + */ + async clearConnectionByWorkspaceId(workspaceId: string): Promise { + const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); + const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); + + await this.context.secrets.delete(tokenKey); + await this.context.secrets.delete(dataKey); + + // Remove from workspace list + await this.removeFromWorkspaceList(workspaceId); + } + + /** + * Clears all workspace connections (logout from all workspaces) + */ + async clearConnection(): Promise { + const workspaces = await this.getWorkspaces(); + + for (const workspace of workspaces) { + await this.clearConnectionByWorkspaceId(workspace.workspaceId); + } + + // Clear workspace list + await this.context.secrets.delete(SECRET_KEYS.WORKSPACE_LIST); + } + + /** + * Validates token format + * + * Checks if token follows Slack token format (xoxb- or xoxp- prefix). + * + * @param token - Token to validate + * @returns True if valid format, false otherwise + */ + static validateTokenFormat(token: string): boolean { + // Slack tokens start with xoxb- (bot) or xoxp- (user) + // Minimum length: 40 characters + return /^xox[bp]-[A-Za-z0-9-]{36,}$/.test(token); + } + + /** + * Validates token scopes + * + * Checks if token has required scopes for Slack integration. + * + * @param scopes - Token scopes + * @returns True if all required scopes present, false otherwise + */ + static validateTokenScopes(scopes: string[]): boolean { + const requiredScopes = ['channels:read', 'chat:write', 'files:write', 'groups:read']; + + return requiredScopes.every((required) => scopes.includes(required)); + } + + /** + * Gets missing scopes + * + * @param scopes - Current token scopes + * @returns Array of missing required scopes + */ + static getMissingScopes(scopes: string[]): string[] { + const requiredScopes = ['channels:read', 'chat:write', 'files:write', 'groups:read']; + + return requiredScopes.filter((required) => !scopes.includes(required)); + } +} diff --git a/src/extension/utils/workflow-validator.ts b/src/extension/utils/workflow-validator.ts new file mode 100644 index 00000000..eb98230e --- /dev/null +++ b/src/extension/utils/workflow-validator.ts @@ -0,0 +1,101 @@ +/** + * Workflow Validator + * + * Validates workflow JSON files downloaded from Slack. + * Ensures required fields exist and structure is valid before import. + * + * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md + */ + +import type { Workflow } from '../../shared/types/workflow'; + +/** + * Validation result + */ +export interface ValidationResult { + /** Whether the workflow is valid */ + valid: boolean; + /** Validation error messages (if invalid) */ + errors?: string[]; + /** Parsed workflow object (if valid) */ + workflow?: Workflow; +} + +/** + * Validates workflow JSON content + * + * Checks: + * 1. Valid JSON format + * 2. Required fields exist (id, name, version, nodes, connections) + * 3. Basic structure validation + * + * @param content - Workflow JSON string + * @returns Validation result with errors or parsed workflow + */ +export function validateWorkflowFile(content: string): ValidationResult { + const errors: string[] = []; + + // Step 1: Parse JSON + let parsedData: unknown; + try { + parsedData = JSON.parse(content); + } catch (error) { + return { + valid: false, + errors: [`Invalid JSON format: ${error instanceof Error ? error.message : String(error)}`], + }; + } + + // Step 2: Type check + if (typeof parsedData !== 'object' || parsedData === null) { + return { + valid: false, + errors: ['Workflow must be a JSON object'], + }; + } + + const workflow = parsedData as Record; + + // Step 3: Required field validation + const requiredFields: Array = ['id', 'name', 'version', 'nodes', 'connections']; + + for (const field of requiredFields) { + if (!(field in workflow)) { + errors.push(`Missing required field: ${field}`); + } + } + + // Step 4: Field type validation + if ('id' in workflow && typeof workflow.id !== 'string') { + errors.push('Field "id" must be a string'); + } + + if ('name' in workflow && typeof workflow.name !== 'string') { + errors.push('Field "name" must be a string'); + } + + if ('version' in workflow && typeof workflow.version !== 'string') { + errors.push('Field "version" must be a string'); + } + + if ('nodes' in workflow && !Array.isArray(workflow.nodes)) { + errors.push('Field "nodes" must be an array'); + } + + if ('connections' in workflow && !Array.isArray(workflow.connections)) { + errors.push('Field "connections" must be an array'); + } + + // Step 5: Return validation result + if (errors.length > 0) { + return { + valid: false, + errors, + }; + } + + return { + valid: true, + workflow: workflow as Workflow, + }; +} diff --git a/src/shared/types/messages.ts b/src/shared/types/messages.ts index 96516745..731c4b56 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -533,7 +533,296 @@ export type ExtensionMessage = | Message | Message | Message - | Message; + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message; + +// ============================================================================ +// Slack Integration Payloads (001-slack-workflow-sharing) +// ============================================================================ + +/** + * Slack channel information + */ +export interface SlackChannel { + id: string; + name: string; + isPrivate: boolean; + isMember: boolean; + memberCount?: number; + purpose?: string; + topic?: string; +} + +/** + * Workflow search result + */ +export interface SearchResult { + messageTs: string; + channelId: string; + channelName: string; + text: string; + userId: string; + permalink: string; + fileId?: string; + fileName?: string; + timestamp: string; +} + +/** + * Slack connection request payload + */ +export interface SlackConnectPayload { + /** Force reconnection (delete existing token and reconnect) */ + forceReconnect?: boolean; +} + +/** + * Slack connection success payload + */ +export interface SlackConnectSuccessPayload { + workspaceName: string; +} + +/** + * Get OAuth redirect URI success payload + * @deprecated OAuth flow will be removed in favor of manual token input + */ +export interface GetOAuthRedirectUriSuccessPayload { + redirectUri: string; +} + +/** + * Manual Slack connection request payload + */ +export interface ConnectSlackManualPayload { + /** Workspace name (e.g., "My Team") */ + workspaceName: string; + /** Workspace ID / Team ID (e.g., "T1234567890") */ + workspaceId: string; + /** Team ID (same as workspaceId) */ + teamId: string; + /** Slack Bot User OAuth Token (xoxb-...) */ + accessToken: string; + /** User ID who authorized this connection (e.g., "U1234567890") */ + userId: string; +} + +/** + * Manual Slack connection success payload + */ +export interface ConnectSlackManualSuccessPayload { + /** Workspace ID that was connected */ + workspaceId: string; + /** Workspace name */ + workspaceName: string; +} + +/** + * Slack error payload (for FAILED messages) + */ +export interface SlackErrorPayload { + message: string; +} + +/** + * Get Slack channels request payload + */ +export interface GetSlackChannelsPayload { + /** Target workspace ID */ + workspaceId: string; + /** Include private channels (default: true) */ + includePrivate?: boolean; + /** Only show channels user is a member of (default: true) */ + onlyMember?: boolean; +} + +/** + * Get Slack channels success payload + */ +export interface GetSlackChannelsSuccessPayload { + channels: SlackChannel[]; +} + +/** + * Slack workspace information (for workspace selection) + */ +export interface SlackWorkspace { + /** Workspace ID (Team ID) */ + workspaceId: string; + /** Workspace name */ + workspaceName: string; + /** Team ID */ + teamId: string; + /** When the workspace was authorized */ + authorizedAt: string; + /** Last validation timestamp (optional) */ + lastValidatedAt?: string; +} + +/** + * List Slack workspaces success payload + */ +export interface ListSlackWorkspacesSuccessPayload { + workspaces: SlackWorkspace[]; +} + +/** + * Import workflow from Slack request payload + */ +export interface ImportWorkflowFromSlackPayload { + /** Workflow ID to import */ + workflowId: string; + /** Slack file ID */ + fileId: string; + /** Slack message timestamp */ + messageTs: string; + /** Slack channel ID */ + channelId: string; + /** Target workspace ID */ + workspaceId: string; + /** Override existing file without confirmation (default: false) */ + overwriteExisting?: boolean; +} + +/** + * Import workflow success payload + */ +export interface ImportWorkflowSuccessPayload { + /** Workflow ID that was imported */ + workflowId: string; + /** Local file path where workflow was saved */ + filePath: string; + /** Workflow name */ + workflowName: string; +} + +/** + * Import workflow confirm overwrite payload + */ +export interface ImportWorkflowConfirmOverwritePayload { + /** Workflow ID to import */ + workflowId: string; + /** Existing file path that will be overwritten */ + existingFilePath: string; +} + +/** + * Import workflow failed payload + */ +export interface ImportWorkflowFailedPayload { + /** Workflow ID that failed to import */ + workflowId: string; + /** Error code */ + errorCode: + | 'NOT_AUTHENTICATED' + | 'FILE_DOWNLOAD_FAILED' + | 'INVALID_WORKFLOW_FILE' + | 'FILE_WRITE_ERROR' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; +} + +/** + * Search Slack workflows success payload + */ +export interface SearchSlackWorkflowsSuccessPayload { + results: SearchResult[]; +} + +/** + * Share workflow to Slack channel payload + */ +export interface ShareWorkflowToSlackPayload { + /** Target workspace ID */ + workspaceId: string; + /** Workflow ID to share (for identification purposes) */ + workflowId: string; + /** Workflow name (for display purposes) */ + workflowName: string; + /** Complete workflow object (current canvas state) */ + workflow: Workflow; + /** Target Slack channel ID */ + channelId: string; + /** Workflow description (optional) */ + description?: string; + /** Override sensitive data warning (default: false) */ + overrideSensitiveWarning?: boolean; +} + +/** + * Sensitive data finding + */ +export interface SensitiveDataFinding { + type: string; + maskedValue: string; + position: number; + context?: string; + severity: 'low' | 'medium' | 'high'; +} + +/** + * Slack channel information + */ +export interface SlackChannelInfo { + id: string; + name: string; +} + +/** + * Share workflow success payload + */ +export interface ShareWorkflowSuccessPayload { + workflowId: string; + channelId: string; + channelName: string; + messageTs: string; + fileId: string; + permalink: string; +} + +/** + * Sensitive data warning payload + */ +export interface SensitiveDataWarningPayload { + workflowId: string; + findings: SensitiveDataFinding[]; +} + +/** + * Share workflow failed payload + */ +export interface ShareWorkflowFailedPayload { + workflowId: string; + errorCode: + | 'NOT_AUTHENTICATED' + | 'CHANNEL_NOT_FOUND' + | 'FILE_UPLOAD_FAILED' + | 'MESSAGE_POST_FAILED' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; + errorMessage: string; +} // ============================================================================ // Webview → Extension Messages @@ -558,7 +847,15 @@ export type WebviewMessage = | Message | Message | Message - | Message; + | Message + | Message + | Message + | Message // @deprecated Will be removed in favor of CONNECT_SLACK_MANUAL + | Message + | Message + | Message + | Message + | Message; // ============================================================================ // Error Codes diff --git a/src/webview/src/App.tsx b/src/webview/src/App.tsx index ea7a4280..517d31c2 100644 --- a/src/webview/src/App.tsx +++ b/src/webview/src/App.tsx @@ -5,13 +5,19 @@ * Based on: /specs/001-cc-wf-studio/plan.md */ -import type { ErrorPayload, InitialStatePayload } from '@shared/types/messages'; +import type { + ErrorPayload, + ImportWorkflowFromSlackPayload, + InitialStatePayload, + Workflow, +} from '@shared/types/messages'; import type React from 'react'; import { useEffect, useState } from 'react'; import { ProcessingOverlay } from './components/common/ProcessingOverlay'; import { SimpleOverlay } from './components/common/SimpleOverlay'; import { ConfirmDialog } from './components/dialogs/ConfirmDialog'; import { RefinementChatPanel } from './components/dialogs/RefinementChatPanel'; +import { SlackShareDialog } from './components/dialogs/SlackShareDialog'; import { ErrorNotification } from './components/ErrorNotification'; import { NodePalette } from './components/NodePalette'; import { PropertyPanel } from './components/PropertyPanel'; @@ -19,16 +25,29 @@ import { Toolbar } from './components/Toolbar'; import { Tour } from './components/Tour'; import { WorkflowEditor } from './components/WorkflowEditor'; import { useTranslation } from './i18n/i18n-context'; +import { vscode } from './main'; +import { deserializeWorkflow } from './services/workflow-service'; import { useRefinementStore } from './stores/refinement-store'; import { useWorkflowStore } from './stores/workflow-store'; const App: React.FC = () => { const { t } = useTranslation(); - const { pendingDeleteNodeIds, confirmDeleteNodes, cancelDeleteNodes } = useWorkflowStore(); + const { + pendingDeleteNodeIds, + confirmDeleteNodes, + cancelDeleteNodes, + activeWorkflow, + setNodes, + setEdges, + setWorkflowName, + setActiveWorkflow, + } = useWorkflowStore(); const { isOpen: isRefinementPanelOpen, isProcessing } = useRefinementStore(); const [error, setError] = useState(null); const [runTour, setRunTour] = useState(false); const [tourKey, setTourKey] = useState(0); // Used to force Tour component remount + const [isSlackShareDialogOpen, setIsSlackShareDialogOpen] = useState(false); + const [isLoadingImportedWorkflow, setIsLoadingImportedWorkflow] = useState(false); const handleError = (errorData: ErrorPayload) => { setError(errorData); @@ -47,6 +66,10 @@ const App: React.FC = () => { setTourKey((prev) => prev + 1); // Increment key to force remount and reset tour state }; + const handleShareToSlack = () => { + setIsSlackShareDialogOpen(true); + }; + // Listen for messages from Extension useEffect(() => { const messageHandler = (event: MessageEvent) => { @@ -58,6 +81,48 @@ const App: React.FC = () => { // Start tour automatically on first launch setRunTour(true); } + } else if (message.type === 'IMPORT_WORKFLOW_FROM_SLACK') { + // Handle import workflow request from Extension Host + // Simply forward the message back to Extension Host to trigger the import process + const payload = message.payload as ImportWorkflowFromSlackPayload; + + console.log('Forwarding import request to Extension Host:', payload); + + // Show loading overlay + setIsLoadingImportedWorkflow(true); + + // Send the import request back to Extension Host with a new requestId + const requestId = `req-${Date.now()}-${Math.random()}`; + vscode.postMessage({ + type: 'IMPORT_WORKFLOW_FROM_SLACK', + requestId, + payload, + }); + + // The import process will be handled by Extension Host + // Success/failure notifications will be shown by Extension Host + } else if (message.type === 'IMPORT_WORKFLOW_SUCCESS') { + // Load imported workflow into canvas + const workflow = message.payload?.workflow as Workflow; + if (workflow) { + const { nodes: loadedNodes, edges: loadedEdges } = deserializeWorkflow(workflow); + setNodes(loadedNodes); + setEdges(loadedEdges); + setWorkflowName(workflow.name); + // Set as active workflow to preserve conversation history + setActiveWorkflow(workflow); + + // TODO: Select imported workflow in dropdown after fixing selection logic + } + + // Hide loading overlay + setIsLoadingImportedWorkflow(false); + } else if (message.type === 'IMPORT_WORKFLOW_FAILED') { + // Hide loading overlay on failure + setIsLoadingImportedWorkflow(false); + } else if (message.type === 'IMPORT_WORKFLOW_CANCELLED') { + // Hide loading overlay when user cancels + setIsLoadingImportedWorkflow(false); } }; @@ -66,7 +131,7 @@ const App: React.FC = () => { return () => { window.removeEventListener('message', messageHandler); }; - }, []); + }, [setNodes, setEdges, setWorkflowName, setActiveWorkflow]); return (
{ }} > {/* Top: Toolbar */} - + {/* Main Content: 3-column layout */}
{ onConfirm={confirmDeleteNodes} onCancel={cancelDeleteNodes} /> + + {/* Slack Share Dialog */} + setIsSlackShareDialogOpen(false)} + workflowId={activeWorkflow?.id || ''} + /> + + {/* Import Workflow Loading Overlay */} + {isLoadingImportedWorkflow && ( +
+
+
+ + {t('loading.importWorkflow')} + +
+
+ )} + +
); }; diff --git a/src/webview/src/components/Toolbar.tsx b/src/webview/src/components/Toolbar.tsx index 7c5c4d91..94115ae8 100644 --- a/src/webview/src/components/Toolbar.tsx +++ b/src/webview/src/components/Toolbar.tsx @@ -22,6 +22,7 @@ import { ProcessingOverlay } from './common/ProcessingOverlay'; interface ToolbarProps { onError: (error: { code: string; message: string; details?: unknown }) => void; onStartTour: () => void; + onShareToSlack: () => void; } interface WorkflowListItem { @@ -31,13 +32,20 @@ interface WorkflowListItem { updatedAt: string; } -export const Toolbar: React.FC = ({ onError, onStartTour }) => { +export const Toolbar: React.FC = ({ onError, onStartTour, onShareToSlack }) => { const { t } = useTranslation(); - const { nodes, edges, setNodes, setEdges, activeWorkflow, setActiveWorkflow } = - useWorkflowStore(); + const { + nodes, + edges, + setNodes, + setEdges, + activeWorkflow, + setActiveWorkflow, + workflowName, + setWorkflowName, + } = useWorkflowStore(); const { openChat, initConversation, loadConversationHistory, isProcessing } = useRefinementStore(); - const [workflowName, setWorkflowName] = useState('my-workflow'); const [isSaving, setIsSaving] = useState(false); const [isExporting, setIsExporting] = useState(false); const [workflows, setWorkflows] = useState([]); @@ -121,7 +129,7 @@ export const Toolbar: React.FC = ({ onError, onStartTour }) => { window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); - }, [setNodes, setEdges, setActiveWorkflow]); + }, [setNodes, setEdges, setActiveWorkflow, setWorkflowName]); // Load workflow list on mount useEffect(() => { @@ -378,6 +386,34 @@ export const Toolbar: React.FC = ({ onError, onStartTour }) => { }} /> + {/* Share to Slack Button - Phase 3.1 (Beta feature, placed before help button) */} + + + {/* Divider */} +
+ {/* Help Button */} + )} + + {/* Right side buttons */} +
+ + +
+
+ + {/* Delete Confirmation Dialog */} + {showDeleteConfirm && ( +
setShowDeleteConfirm(false)} + role="presentation" + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: onClick is only used to stop event propagation */} +
e.stopPropagation()} + > +
+ {t('slack.manualToken.deleteConfirm.title')} +
+
+ {t('slack.manualToken.deleteConfirm.message')} +
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/src/webview/src/components/dialogs/SlackShareDialog.tsx b/src/webview/src/components/dialogs/SlackShareDialog.tsx new file mode 100644 index 00000000..c2b88a07 --- /dev/null +++ b/src/webview/src/components/dialogs/SlackShareDialog.tsx @@ -0,0 +1,738 @@ +/** + * Slack Share Dialog Component + * + * Dialog for sharing workflow to Slack channels. + * Includes channel selection, description input, and sensitive data warning handling. + * + * Based on specs/001-slack-workflow-sharing/plan.md + */ + +import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslation } from '../../i18n/i18n-context'; +import type { + SensitiveDataFinding, + SlackChannel, + SlackWorkspace, +} from '../../services/slack-integration-service'; +import { + getSlackChannels, + listSlackWorkspaces, + shareWorkflowToSlack, +} from '../../services/slack-integration-service'; +import { serializeWorkflow } from '../../services/workflow-service'; +import { useWorkflowStore } from '../../stores/workflow-store'; +import { IndeterminateProgressBar } from '../common/IndeterminateProgressBar'; +import { SlackManualTokenDialog } from './SlackManualTokenDialog'; + +interface SlackShareDialogProps { + isOpen: boolean; + onClose: () => void; + workflowId: string; +} + +export function SlackShareDialog({ isOpen, onClose, workflowId }: SlackShareDialogProps) { + const { t } = useTranslation(); + const dialogRef = useRef(null); + const titleId = useId(); + + // Get current canvas state for workflow generation + const { nodes, edges, activeWorkflow, workflowName } = useWorkflowStore(); + + // State management + const [loading, setLoading] = useState(false); + const [loadingWorkspace, setLoadingWorkspace] = useState(false); + const [loadingChannels, setLoadingChannels] = useState(false); + const [error, setError] = useState(null); + const [workspace, setWorkspace] = useState(null); + const [channels, setChannels] = useState([]); + const [selectedChannelId, setSelectedChannelId] = useState(''); + const [description, setDescription] = useState(''); + const [sensitiveDataWarning, setSensitiveDataWarning] = useState( + null + ); + const [isManualTokenDialogOpen, setIsManualTokenDialogOpen] = useState(false); + + // Load workspace when dialog opens (single workspace only) + useEffect(() => { + if (!isOpen) { + return; + } + + const loadWorkspace = async () => { + setLoadingWorkspace(true); + setError(null); + + try { + const workspaceList = await listSlackWorkspaces(); + + // Only use the first workspace (single workspace support) + if (workspaceList.length > 0) { + setWorkspace(workspaceList[0]); + } else { + setWorkspace(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('slack.error.networkError')); + } finally { + setLoadingWorkspace(false); + } + }; + + loadWorkspace(); + }, [isOpen, t]); + + // Load channels when workspace is loaded + useEffect(() => { + if (!workspace) { + setChannels([]); + setSelectedChannelId(''); + return; + } + + const loadChannels = async () => { + setLoadingChannels(true); + setError(null); + + try { + const channelList = await getSlackChannels(workspace.workspaceId); + setChannels(channelList); + + // Auto-select first channel if available + if (channelList.length > 0) { + setSelectedChannelId(channelList[0].id); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('slack.error.networkError')); + } finally { + setLoadingChannels(false); + } + }; + + loadChannels(); + }, [workspace, t]); + + // Auto-focus dialog when opened + useEffect(() => { + if (isOpen && dialogRef.current) { + dialogRef.current.focus(); + } + }, [isOpen]); + + const handleOpenManualTokenDialog = () => { + setIsManualTokenDialogOpen(true); + }; + + const handleManualTokenSuccess = async () => { + setIsManualTokenDialogOpen(false); + setError(null); + + // Reload workspace after successful connection (single workspace only) + setLoadingWorkspace(true); + try { + const workspaceList = await listSlackWorkspaces(); + + // Only use the first workspace + if (workspaceList.length > 0) { + setWorkspace(workspaceList[0]); + } else { + setWorkspace(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('slack.error.networkError')); + } finally { + setLoadingWorkspace(false); + } + }; + + const handleManualTokenClose = () => { + setIsManualTokenDialogOpen(false); + }; + + const handleShare = async () => { + if (!workspace) { + setError(t('slack.error.noWorkspaces')); + return; + } + + if (!selectedChannelId) { + setError(t('slack.share.selectChannelPlaceholder')); + return; + } + + setLoading(true); + setError(null); + setSensitiveDataWarning(null); + + try { + // Generate workflow from current canvas state + const workflow = serializeWorkflow( + nodes, + edges, + workflowName, + 'Created with Workflow Studio', + activeWorkflow?.conversationHistory + ); + + const result = await shareWorkflowToSlack({ + workspaceId: workspace.workspaceId, + workflowId, + workflowName, + workflow, + channelId: selectedChannelId, + description: description || undefined, + overrideSensitiveWarning: false, + }); + + if (result.success) { + // Success - close dialog + handleClose(); + // TODO: Show success notification + } else if (result.sensitiveDataWarning) { + // Show sensitive data warning + setSensitiveDataWarning(result.sensitiveDataWarning); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('slack.share.failed')); + } finally { + setLoading(false); + } + }; + + const handleShareOverride = async () => { + if (!workspace || !selectedChannelId) { + return; + } + + setLoading(true); + setError(null); + setSensitiveDataWarning(null); + + try { + // Generate workflow from current canvas state + const workflow = serializeWorkflow( + nodes, + edges, + workflowName, + 'Created with Workflow Studio', + activeWorkflow?.conversationHistory + ); + + const result = await shareWorkflowToSlack({ + workspaceId: workspace.workspaceId, + workflowId, + workflowName, + workflow, + channelId: selectedChannelId, + description: description || undefined, + overrideSensitiveWarning: true, + }); + + if (result.success) { + handleClose(); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('slack.share.failed')); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setSelectedChannelId(''); + setDescription(''); + setError(null); + setSensitiveDataWarning(null); + setLoading(false); + onClose(); + }; + + if (!isOpen) { + return null; + } + + // Sensitive data warning dialog + if (sensitiveDataWarning) { + return ( +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: onClick is only used to stop event propagation, not for click actions */} +
e.stopPropagation()} + > + {/* Warning Title */} +
+ {t('slack.sensitiveData.warning.title')} +
+ + {/* Warning Message */} +
+ {t('slack.sensitiveData.warning.message')} +
+ + {/* Findings List */} +
+ {sensitiveDataWarning.map((finding, index) => ( +
+
+ {finding.type} ({finding.severity}) +
+
+ {finding.maskedValue} +
+
+ ))} +
+ + {/* Warning Buttons */} +
+ + +
+
+
+ ); + } + + // Main share dialog + return ( +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: onClick is only used to stop event propagation, not for click actions */} +
e.stopPropagation()} + > + {/* Title */} +
+ {t('slack.share.title')} +
+ + {/* Workflow Name */} +
+ {workflowName} +
+ + {/* Connection Status Section */} + {!loadingWorkspace && !workspace && ( +
+
+ {t('slack.connect.description')} +
+ +
+ )} + + {!loadingWorkspace && workspace && ( +
+
+ + + Connected to{' '} + + {workspace.workspaceName} + + +
+ +
+ )} + + {/* Channel Selection */} +
+ + + + {/* Help message when no channels available */} + {!loadingChannels && channels.length === 0 && workspace && ( +
+ 💡 {t('slack.error.noChannelsHelp')} +
+ )} +
+ + {/* Description Input */} +
+ +