Просмотр исходного кода

Merge branch 'feat/password-rsettings-by-users' into fix/gw7248-form-err

kaori 4 лет назад
Родитель
Сommit
0374bfc0c0
100 измененных файлов с 2453 добавлено и 1534 удалено
  1. 2 0
      .devcontainer/Dockerfile
  2. 4 0
      .devcontainer/docker-compose.yml
  3. 1 1
      .github/workflows/release-rc.yml
  4. 2 2
      .github/workflows/release.yml
  5. 23 3
      CHANGES.md
  6. 1 1
      package.json
  7. 1 0
      packages/app/.env.development
  8. 18 13
      packages/app/docker/Dockerfile
  9. 17 16
      packages/app/package.json
  10. BIN
      packages/app/public/images/slack-integration/growi-register-sentence.png
  11. 15 2
      packages/app/resource/locales/en_US/admin/admin.json
  12. 22 22
      packages/app/resource/locales/en_US/sandbox.md
  13. 0 1
      packages/app/resource/locales/en_US/translation.json
  14. 53 16
      packages/app/resource/locales/en_US/welcome.md
  15. 15 2
      packages/app/resource/locales/ja_JP/admin/admin.json
  16. 0 1
      packages/app/resource/locales/ja_JP/translation.json
  17. 43 10
      packages/app/resource/locales/ja_JP/welcome.md
  18. 15 2
      packages/app/resource/locales/zh_CN/admin/admin.json
  19. 0 1
      packages/app/resource/locales/zh_CN/translation.json
  20. 53 16
      packages/app/resource/locales/zh_CN/welcome.md
  21. 5 3
      packages/app/src/client/admin.jsx
  22. 9 37
      packages/app/src/client/services/AdminNotificationContainer.js
  23. 91 0
      packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  24. 5 1
      packages/app/src/client/util/apiNotification.js
  25. 71 0
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  26. 21 21
      packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  27. 111 20
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  28. 0 80
      packages/app/src/components/Admin/Notification/SlackIntegrationNotificationSetting.jsx
  29. 39 17
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  30. 7 5
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  31. 36 15
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  32. 53 0
      packages/app/src/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx
  33. 3 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  34. 41 31
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  35. 2 2
      packages/app/src/components/PageComment/CommentEditor.jsx
  36. 10 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  37. 7 1
      packages/app/src/components/PageEditor/Editor.jsx
  38. 7 9
      packages/app/src/components/PageEditor/EditorIcon.jsx
  39. 3 3
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  40. 6 1
      packages/app/src/server/crowi/express-init.js
  41. 10 45
      packages/app/src/server/crowi/index.js
  42. 1 1
      packages/app/src/server/models/config.ts
  43. 0 1
      packages/app/src/server/models/index.js
  44. 5 0
      packages/app/src/server/models/page.js
  45. 1 0
      packages/app/src/server/models/slack-app-integration.js
  46. 122 0
      packages/app/src/server/models/update-post.ts
  47. 0 153
      packages/app/src/server/models/updatePost.js
  48. 2 2
      packages/app/src/server/routes/admin.js
  49. 2 2
      packages/app/src/server/routes/apiv3/healthcheck.js
  50. 1 0
      packages/app/src/server/routes/apiv3/index.js
  51. 9 71
      packages/app/src/server/routes/apiv3/notification-setting.js
  52. 14 11
      packages/app/src/server/routes/apiv3/page.js
  53. 2 2
      packages/app/src/server/routes/apiv3/search.js
  54. 128 0
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  55. 193 160
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  56. 22 59
      packages/app/src/server/routes/apiv3/slack-integration.js
  57. 2 2
      packages/app/src/server/routes/apiv3/statistics.js
  58. 2 1
      packages/app/src/server/routes/page.js
  59. 1 3
      packages/app/src/server/service/app.ts
  60. 11 7
      packages/app/src/server/service/global-notification/global-notification-slack.js
  61. 37 52
      packages/app/src/server/service/page.js
  62. 1 1
      packages/app/src/server/service/slack-command-handler/help.js
  63. 279 0
      packages/app/src/server/service/slack-integration.ts
  64. 0 22
      packages/app/src/server/service/slack-notification.js
  65. 0 136
      packages/app/src/server/service/slackbot.ts
  66. 0 61
      packages/app/src/server/service/user-notification/index.js
  67. 82 0
      packages/app/src/server/service/user-notification/index.ts
  68. 15 43
      packages/app/src/server/util/slack-legacy.js
  69. 123 182
      packages/app/src/server/util/slack.js
  70. 1 1
      packages/app/src/server/views/admin/slack-integration-legacy.html
  71. 2 0
      packages/app/src/server/views/admin/slack-integration.html
  72. 98 1
      packages/app/src/test/service/page.test.js
  73. 4 7
      packages/app/src/test/utils/slack-legacy.test.js
  74. 9 0
      packages/slack/src/index.ts
  75. 11 1
      packages/slack/src/utils/block-kit-builder.ts
  76. 9 4
      packages/slack/src/utils/check-communicable.ts
  77. 31 0
      packages/slack/src/utils/publish-initial-home-view.ts
  78. 11 0
      packages/slack/src/utils/required-scopes.ts
  79. 4 0
      packages/slack/src/utils/slash-command-parser.ts
  80. 21 6
      packages/slack/src/utils/webclient-factory.ts
  81. 21 0
      packages/slack/src/utils/welcome-message.ts
  82. 15 0
      packages/slackbot-proxy/CHANGES.md
  83. 4 1
      packages/slackbot-proxy/docker/Dockerfile
  84. 2 1
      packages/slackbot-proxy/package.json
  85. 9 1
      packages/slackbot-proxy/src/Server.ts
  86. 1 0
      packages/slackbot-proxy/src/config/logger/config.dev.ts
  87. 23 10
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  88. 144 88
      packages/slackbot-proxy/src/controllers/slack.ts
  89. 2 9
      packages/slackbot-proxy/src/controllers/top.ts
  90. 30 0
      packages/slackbot-proxy/src/middlewares/growi-to-slack/add-webclient-response-to-res.ts
  91. 0 23
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts
  92. 32 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts
  93. BIN
      packages/slackbot-proxy/src/public/images/growi-bot.png
  94. 23 8
      packages/slackbot-proxy/src/services/RegisterService.ts
  95. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  96. 21 0
      packages/slackbot-proxy/src/views/install-failed.ejs
  97. 18 0
      packages/slackbot-proxy/src/views/install-succeeded-but-has-problem.ejs
  98. 21 0
      packages/slackbot-proxy/src/views/install-succeeded.ejs
  99. 1 0
      packages/slackbot-proxy/src/views/top.ejs
  100. 4 0
      packages/slackbot-proxy/tsconfig.base.json

+ 2 - 0
.devcontainer/Dockerfile

@@ -14,6 +14,8 @@ ARG USER_UID=1000
 ARG USER_GID=$USER_UID
 ARG USER_GID=$USER_UID
 
 
 RUN mkdir -p /workspace/growi/node_modules
 RUN mkdir -p /workspace/growi/node_modules
+RUN mkdir -p /workspace/growi/packages/app/node_modules
+RUN mkdir -p /workspace/growi/packages/slackbot-proxy/node_modules
 
 
 # [Optional] Update UID/GID if needed
 # [Optional] Update UID/GID if needed
 RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
 RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \

+ 4 - 0
.devcontainer/docker-compose.yml

@@ -24,6 +24,8 @@ services:
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
       - node_modules:/workspace/growi/node_modules
       - node_modules:/workspace/growi/node_modules
+      - node_modules_app:/workspace/growi/packages/app/node_modules
+      - node_modules_slackbot-proxy:/workspace/growi/packages/slackbot-proxy/node_modules
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
 
 
     tty: true
     tty: true
@@ -80,3 +82,5 @@ services:
       - /files/sqlite
       - /files/sqlite
 volumes:
 volumes:
   node_modules:
   node_modules:
+  node_modules_app:
+  node_modules_slackbot-proxy:

+ 1 - 1
.github/workflows/release-rc.yml

@@ -55,7 +55,7 @@ jobs:
       uses: docker/build-push-action@v2
       uses: docker/build-push-action@v2
       with:
       with:
         context: .
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         platforms: linux/amd64
         push: true
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache
         cache-from: type=local,src=/tmp/.buildx-cache

+ 2 - 2
.github/workflows/release.yml

@@ -113,7 +113,7 @@ jobs:
       uses: docker/build-push-action@v2
       uses: docker/build-push-action@v2
       with:
       with:
         context: .
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         platforms: linux/amd64
         push: true
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache
         cache-from: type=local,src=/tmp/.buildx-cache
@@ -131,7 +131,7 @@ jobs:
         username: wsmoogle
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         repository: weseek/growi
         repository: weseek/growi
-        readme-filepath: ./docker/README.md
+        readme-filepath: ./packages/app/docker/README.md
 
 
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master
       uses: weseek/ghaction-release-slack-notification@master

+ 23 - 3
CHANGES.md

@@ -1,17 +1,37 @@
 # CHANGES
 # CHANGES
 
 
-## v4.3.3-RC
+## v4.4.0-RC
+
+### BREAKING CHANGES
 
 
+* Official plugins are now preinstalled
+
+### Updates
+
+* Improvement: Add attachment button in editor navbar
+* Fix: Recursive rename operation from `/parent` to `/parent/child` ([#4101](https://github.com/weseek/growi/pull/4101))
 * Fix: Encode spaces in page path in LinkEditModal
 * Fix: Encode spaces in page path in LinkEditModal
-* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
 * Support: Create @growi/core package
 * Support: Create @growi/core package
 * Support: Create @growi/ui package
 * Support: Create @growi/ui package
 * Support: Improve error handling for @growi/slackbot-proxy
 * Support: Improve error handling for @growi/slackbot-proxy
 * Support: Include official plugins as sub packages
 * Support: Include official plugins as sub packages
 * Support: Upgrade libs
 * Support: Upgrade libs
     * @slack/web-api
     * @slack/web-api
-    * escape-string-regexp
+    * date-fns
+    * helmet
     * morgan
     * morgan
+    * socket.io
+
+## v4.3.3-RC
+
+* Improvement: Welcome page markdown
+* Fix: Some recursive operation exclude descendant pages that are restricted for groups
+    * Rename / Delete / Delete completely / Put back / Duplicate
+* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
+* Support: Upgrade libs
+    * @slack/web-api
+    * date-fns
+    * escape-string-regexp
 
 
 ## v4.3.2
 ## v4.3.2
 
 

+ 1 - 1
package.json

@@ -31,7 +31,7 @@
   "scripts": {
   "scripts": {
     "start": "yarn app:server",
     "start": "yarn app:server",
     "prestart": "yarn app:build",
     "prestart": "yarn app:build",
-    "app:build": "yarn lerna run build --scope @growi/app --scope @growi/slack --scope @growi/plugin-pukiwiki-like-linker",
+    "app:build": "yarn lerna run build --scope @growi/app --scope @growi/slack --scope @growi/plugin-*",
     "app:server": "yarn lerna run server --scope @growi/app",
     "app:server": "yarn lerna run server --scope @growi/app",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",

+ 1 - 0
packages/app/.env.development

@@ -10,6 +10,7 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
+ELASTICSEARCH_REQUEST_TIMEOUT=15000
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"

+ 18 - 13
packages/app/docker/Dockerfile

@@ -17,18 +17,21 @@ COPY ./package.json .
 COPY ./yarn.lock .
 COPY ./yarn.lock .
 COPY ./lerna.json .
 COPY ./lerna.json .
 COPY ./packages/app/package.json packages/app/
 COPY ./packages/app/package.json packages/app/
-COPY ./packages/slack/package.json packages/slack/
+COPY ./packages/core/package.json packages/core/
+COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
+COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
 COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
 COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
+COPY ./packages/slack/package.json packages/slack/
+COPY ./packages/ui/package.json packages/ui/
 
 
 # setup
 # setup
 RUN yarn config set network-timeout 300000
 RUN yarn config set network-timeout 300000
 RUN npx lerna bootstrap
 RUN npx lerna bootstrap
 
 
 # make artifacts
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules \
-  packages/plugin-pukiwiki-like-linker/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
 
 
 
@@ -41,10 +44,9 @@ FROM deps-resolver AS deps-resolver-prod
 RUN yarn install --production
 RUN yarn install --production
 
 
 # make artifacts
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules \
-  packages/plugin-pukiwiki-like-linker/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
 
 
 
@@ -92,9 +94,13 @@ COPY ./yarn.lock ./
 COPY ./lerna.json ./
 COPY ./lerna.json ./
 COPY ./tsconfig.base.json ./
 COPY ./tsconfig.base.json ./
 # copy all related packages
 # copy all related packages
-COPY packages/slack packages/slack
 COPY packages/app packages/app
 COPY packages/app packages/app
+COPY packages/core packages/core
+COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
+COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
 COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
+COPY packages/slack packages/slack
+COPY packages/ui packages/ui
 
 
 # build
 # build
 RUN yarn lerna run build
 RUN yarn lerna run build
@@ -113,9 +119,8 @@ RUN tar cf packages.tar \
   packages/app/.env.production \
   packages/app/.env.production \
   packages/app/tsconfig.base.json \
   packages/app/tsconfig.base.json \
   packages/app/tsconfig.json \
   packages/app/tsconfig.json \
-  packages/slack/package.json \
-  packages/slack/dist
-
+  packages/*/package.json \
+  packages/*/dist
 
 
 
 
 
 

+ 17 - 16
packages/app/package.json

@@ -6,21 +6,21 @@
     "//// for production": "",
     "//// for production": "",
     "start": "yarn build && yarn server",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
     "build": "run-p build:*",
-    "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
-    "build:server": "cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "clean": "npx shx rm -rf dist transpiled",
-    "prebuild": "run-p clean resources:*",
+    "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "postbuild": "npx shx mv transpiled/src dist && npx shx rm -rf transpiled",
     "postbuild": "npx shx mv transpiled/src dist && npx shx rm -rf transpiled",
-    "server": "cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
+    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "server:ci": "yarn server --ci",
-    "preserver": "yarn migrate",
+    "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "//// for development": "",
     "//// for development": "",
     "dev": "run-p dev:client dev:server",
     "dev": "run-p dev:client dev:server",
-    "dev:client": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
-    "dev:client:nowatch": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
-    "dev:server": "cross-env NODE_ENV=development yarn ts-node-dev src/server/app.ts --expose_gc --inspect",
-    "predev:client": "run-p resources:*",
-    "predev:server": "yarn migrate",
+    "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
+    "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
+    "dev:server": "yarn cross-env NODE_ENV=development yarn ts-node-dev src/server/app.ts --expose_gc",
+    "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
+    "predev:server": "yarn cross-env NODE_ENV=development yarn migrate",
     "//// for CI": "",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
     "predev:ci": "run-p resources:*",
@@ -33,10 +33,10 @@
     "prelint:eslint": "yarn resources:plugin",
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "//// misc": "",
     "//// misc": "",
-    "console": "cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
+    "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
-    "openapi:v3": "cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
-    "openapi:v1": "cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
+    "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
+    "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "migrate": "yarn migrate:up",
     "migrate": "yarn migrate:up",
@@ -45,7 +45,7 @@
     "migrate:up": "yarn ts-node node_modules/.bin/migrate-mongo up -f config/migrate.js",
     "migrate:up": "yarn ts-node node_modules/.bin/migrate-mongo up -f config/migrate.js",
     "migrate:down": "yarn ts-node node_modules/.bin/migrate-mongo down -f config/migrate.js",
     "migrate:down": "yarn ts-node node_modules/.bin/migrate-mongo down -f config/migrate.js",
     "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only",
     "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --transpile-only"
+    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --inspect --transpile-only"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
@@ -80,7 +80,7 @@
     "connect-redis": "^4.0.4",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
     "csrf": "^3.1.0",
     "csrf": "^3.1.0",
-    "date-fns": "^2.0.0",
+    "date-fns": "^2.23.0",
     "detect-indent": "^6.0.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
     "elasticsearch": "^16.0.0",
     "elasticsearch": "^16.0.0",
@@ -97,7 +97,8 @@
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
     "growi-commons": "^5.0.4",
-    "helmet": "^3.13.0",
+    "helmet": "^4.6.0",
+    "nocache": "^3.0.1",
     "http-errors": "~1.6.2",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-express-middleware": "^2.0.0",

BIN
packages/app/public/images/slack-integration/growi-register-sentence.png


+ 15 - 2
packages/app/resource/locales/en_US/admin/admin.json

@@ -252,6 +252,12 @@
     "download": "Download",
     "download": "Download",
     "delete": "Delete"
     "delete": "Delete"
   },
   },
+  "external_notification": {
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "header_status": "Slack Integration Status",
+    "caution_enabled": "CAUTION: Currently, notifications that are configured in this page will send only to the Slack Workspace set as primary."
+  },
   "slack_integration": {
   "slack_integration": {
     "selecting_bot_types": {
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "slack_bot": "Slack bot",
@@ -282,6 +288,9 @@
       "cancel": "Cancel",
       "cancel": "Cancel",
       "change": "Change"
       "change": "Change"
     },
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "Succeeded to delete the slack integration procedure"
+    },
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
     "access_token_settings": {
     "access_token_settings": {
       "regenerate": "Regenerate"
       "regenerate": "Regenerate"
@@ -309,8 +318,8 @@
       "paste_growi_url": "Since a modal is displayed, enter the following URL in <b>GROWI URL</b>.",
       "paste_growi_url": "Since a modal is displayed, enter the following URL in <b>GROWI URL</b>.",
       "enter_access_token_for_growi_and_proxy": "Enter <b>Access Token Proxy to GROWI</b> and <b>Access Token GROWI to Proxy</b>",
       "enter_access_token_for_growi_and_proxy": "Enter <b>Access Token Proxy to GROWI</b> and <b>Access Token GROWI to Proxy</b>",
       "set_proxy_url_on_growi": "Set Proxy URL on GROWI",
       "set_proxy_url_on_growi": "Set Proxy URL on GROWI",
-      "copy_proxy_url": "1. When the above step are completed successfully, the Proxy URL will be displayed in the Slack Channel you selected in the modal, so copy it.",
-      "enter_proxy_url_and_update": "2. Enter and update the Proxy URL that you copied in step in the <b>Proxy URL</b>  of the <b>Custom bot with proxy integration</b> on this page.",
+      "copy_proxy_url": "When the above step are completed successfully, the Proxy URL will be displayed in the Slack Channel you selected in the modal, so copy it.",
+      "enter_proxy_url_and_update": "Enter and update the Proxy URL that you copied in the above step in the <b>Proxy URL</b> of the <b>Custom bot with proxy integration</b> on this page.",
       "dont_need_update": "※If the value is already in there, there is no need to update it.",
       "dont_need_update": "※If the value is already in there, there is no need to update it.",
       "select_install_your_app": "Select \"Install your app\".",
       "select_install_your_app": "Select \"Install your app\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
@@ -346,6 +355,10 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
     }
   },
   },
+  "slack_integration_legacy": {
+    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
+  },
   "user_management": {
   "user_management": {
     "invite_users": "Temporarily issue a new user",
     "invite_users": "Temporarily issue a new user",
     "click_twice_same_checkbox": "You should check at least one checkbox.",
     "click_twice_same_checkbox": "You should check at least one checkbox.",

+ 22 - 22
packages/app/resource/locales/en_US/sandbox.md

@@ -37,7 +37,7 @@ Add one `#` per level at the start of the line
 
 
 ## Block paragraph
 ## Block paragraph
 
 
-Pararaphs are created by inserting a newline character
+Paragraphs are created by inserting a newline character
 A paragraph can be created by pressing Enter at the end of the previous paragraph.
 A paragraph can be created by pressing Enter at the end of the previous paragraph.
 
 
 ```
 ```
@@ -53,17 +53,17 @@ paragraph2
 ## Br new line
 ## Br new line
 
 
 Add two spaces before break.
 Add two spaces before break.
-***This behaviour can be modified in the options menu.***
+***This behavior can be modified in the options menu.***
 
 
 ```
 ```
-hoge
-fuga(two spaces)
-piyo
+foo
+bar(two spaces)
+baz
 ```
 ```
 
 
-hoge
-fuga
-piyo
+foo
+bar
+baz
 
 
 ## Blockquotes
 ## Blockquotes
 
 
@@ -84,7 +84,7 @@ Add one `>` per level at the start of the line
 Wrap code with three back quotes or tildes.
 Wrap code with three back quotes or tildes.
 
 
 ```
 ```
-print 'hoge'
+print 'foo'
 ```
 ```
 
 
 ### Syntax highlight and file name
 ### Syntax highlight and file name
@@ -131,16 +131,16 @@ This is  `Inline Code`.
 Code blocks should be preceded by four spaces or one tab.
 Code blocks should be preceded by four spaces or one tab.
 
 
 ```
 ```
-    class Hoge
-        def hoge
-            print 'hoge'
+    class Foo
+        def foo
+            print 'foo'
         end
         end
     end
     end
 ```
 ```
 
 
-    class Hoge
-        def hoge
-            print 'hoge'
+    class Foo
+        def foo
+            print 'foo'
         end
         end
     end
     end
 
 
@@ -166,7 +166,7 @@ ___
 
 
 ### Italic
 ### Italic
 
 
-To italicize text, add One asterisk or underscores before and after a word or phrase.
+To italicize text, add one asterisk or underscores before and after a word or phrase.
 
 
 ```
 ```
 This is *Italic* .
 This is *Italic* .
@@ -178,7 +178,7 @@ This is _Italic_ .
 
 
 ### Bold
 ### Bold
 
 
-To bold text, add two asterisks or underscores before and after a word or phrase.
+To make text bold, add two asterisks or underscores before and after a word or phrase.
 
 
 ```
 ```
 This is **bold**.
 This is **bold**.
@@ -263,7 +263,7 @@ Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 
 ## Ul Bulleted list
 ## Ul Bulleted list
 
 
-To create an unordered list, add dashes (-), asterisks (*), or plus signs (+) in front of line items. 
+To create an unordered list, add dashes (-), asterisks (*), or plus signs (+) in front of line items.
 Items can be nested using indentation.
 Items can be nested using indentation.
 
 
 ```
 ```
@@ -286,7 +286,7 @@ Items can be nested using indentation.
 
 
 ## Ol Numbered List
 ## Ol Numbered List
 
 
-To create an ordered list, add line items with numbers followed by periods. 
+To create an ordered list, add line items with numbers followed by periods.
 The numbers don’t have to be in numerical order, but the list should start with the number one.
 The numbers don’t have to be in numerical order, but the list should start with the number one.
 
 
 ```
 ```
@@ -449,9 +449,9 @@ See [emojione](https://www.emojione.com/)
 
 
 # :heavy_plus_sign: More..
 # :heavy_plus_sign: More..
 
 
-- Try to attach Bootstrap4 Tags?
+- Want to attach Bootstrap4 Tags?
     - :arrow_right: [/Sandbox/Bootstrap4]
     - :arrow_right: [/Sandbox/Bootstrap4]
-- Try to draw Diagrams?
+- Want to draw Diagrams?
     - :arrow_right: [/Sandbox/Diagrams]
     - :arrow_right: [/Sandbox/Diagrams]
-- Try to write Math Formulas?
+- Want to write Math Formulas?
     - :arrow_right: [/Sandbox/Math]
     - :arrow_right: [/Sandbox/Math]

+ 0 - 1
packages/app/resource/locales/en_US/translation.json

@@ -438,7 +438,6 @@
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "delete_slack_integration_procedure": "Succeeded to delete the slack integration procedure",
     "activate_user_success": "Succeeded to activating {{username}}",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",

+ 53 - 16
packages/app/resource/locales/en_US/welcome.md

@@ -1,27 +1,64 @@
-# Welcome to GROWI :anchor:
+# :tada: Welcome to GROWI
 
 
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 
-<div class="card border-primary">
-  <div class="card-header bg-primary text-light">Tips</div>
-  <div class="card-body"><ul>
-    <li>Ctrl(⌘) + "/" to show quick help</li>
-    <li>You can write HTML with <a href="https://getbootstrap.com/docs/4.5/components/">Bootstrap 4</a>.</li>
-  </ul></div>
+GROWI is a Wiki for Individuals and Corporations | A knowledge base tool.
+Knowledge in companies, university laboratories, and clubs can be easily shared and anyone can edit the page.
+
+We can easily write what we know and edit it together, we can **simplify the tacit knowledge (knowledge which is hard to explain with words) in our team**.  
+Let's increase the information exchange everyday.
+
+### :beginner: How to create a page easily 
+
+- Start from "**Create**" button on the upper right, or the **Pencil Icon** on the lower right.
+    - The page title can be edited again later, don't worry about the title.
+        - On title input field, it's possible to create the page's hierarchy with half-width `/` (slash).
+        - (Example)Try entering `/category1/category2/page-title-we-want-to-create`.
+- We can create a bullet point by adding `-`  at the beginning of the line.
+- We can also copy and paste, drag and drop attachments such as images, PDF, Word/Excel/PowerPoint, etc.
+- Once we finished, press the "**Update**" button to publish the page.
+    - We can also save it by `Ctrl(⌘) +S`.
+
+For more information: [Tutorial#Create New Page](https://docs.growi.org/en/guide/tutorial/create_page.html#create-new-page)
+
+<div class="mt-4 card border-primary">
+  <div class="card-header bg-primary text-light">
+    Tips
+  </div>
+  <div class="card-body">
+    <ul>
+      <li>Ctrl(⌘) + "/" to show quick help.</li>
+      <li>We can write HTML with <a href="https://getbootstrap.com/docs/4.5/components/">Bootstrap 4</a>.</li>
+    </ul>
+  </div>
 </div>
 </div>
 
 
-Contents
-=========
+# :anchor: For administrator <small>〜After you construct the site〜</small>
+
+### :arrow_right: Do you will use a Wiki with more than one person?
+- :heavy_check_mark: Let's invite some members.
+    - [Add/invite new members to the Wiki](https://docs.growi.org/en/admin-guide/management-cookbook/user-management.html#temporary-issuance-of-a-new-user)
+### :arrow_right: Work with Slack to receive page and comment notifications.
+- :heavy_check_mark:  [Slack integration](https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#overview)
+### :arrow_right: Are you switching from another system?
+- :heavy_check_mark: It's possible to import data from other GROWI, esa.io, Qiita:Team.
+    -  [Import Data](https://docs.growi.org/en/admin-guide/management-cookbook/import.html)
+
+For more information: [Admin Guide](https://docs.growi.org/en/admin-guide/)
+
+
+# Content List Example
+
+We can display the content list using a table and `$lsx`.
 
 
-|All Pages|[/Sandbox]|
-| --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Go to Sandbox](/Sandbox)</span></div> $lsx(/Sandbox)|
+| All page list (First 15 pages)      | [/Sandbox] List of subordinate pages |
+| ----------------------------------- | ------------------------------------ |
+| $lsx(/,num=15)                      | $lsx(/Sandbox)                       |
 
 
-Slack
-=====
+# Slack
 
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
 
-Let's join our Slack channel for all to help make GROWI better.
-In addition to discussing development, we also accept questions at the time of introduction.
+We welcome newcomers joining our slack channel to help improve Growi.
+In addition to discussing development, we are also happy to answer your questions when you join.

+ 15 - 2
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -252,6 +252,12 @@
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
   },
+  "external_notification": {
+    "enabled": "有効",
+    "disabled": "無効",
+    "header_status": "Slack 連携の状態",
+    "caution_enabled": "CAUTION: このページで設定される通知は、Primary として設定された Slack ワークスペースにのみ送信されます。 "
+  },
   "slack_integration": {
   "slack_integration": {
     "selecting_bot_types": {
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "slack_bot": "Slack bot",
@@ -282,6 +288,9 @@
       "cancel": "取消",
       "cancel": "取消",
       "change": "変更する"
       "change": "変更する"
     },
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "Slack 連携手順を削除しました"
+    },
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
     "access_token_settings": {
     "access_token_settings": {
       "regenerate": "再発行"
       "regenerate": "再発行"
@@ -308,8 +317,8 @@
       "paste_growi_url": "モーダルが表示されるので、<b>GROWI URL</b> には下記のURLを入力します。",
       "paste_growi_url": "モーダルが表示されるので、<b>GROWI URL</b> には下記のURLを入力します。",
       "enter_access_token_for_growi_and_proxy": "上記で発行した<b>Access Token Proxy to GROWI</b> と <b>Access Token GROWI to Proxy</b>を入れる",
       "enter_access_token_for_growi_and_proxy": "上記で発行した<b>Access Token Proxy to GROWI</b> と <b>Access Token GROWI to Proxy</b>を入れる",
       "set_proxy_url_on_growi": "ProxyのURLをGROWIに登録する",
       "set_proxy_url_on_growi": "ProxyのURLをGROWIに登録する",
-      "copy_proxy_url": "1. ②が正常に完了すると、モーダル内で選択したSlack ChannelにProxy URLが表示されるので、コピーします。",
-      "enter_proxy_url_and_update": "2. 連携手順③でコピーしたProxy URLを、このページの<b>Custom bot with proxy 連携</b>の<b>Proxy URL</b>に入力、更新します。",
+      "copy_proxy_url": "上の手順が正常に完了すると、モーダル内で選択したSlack ChannelにProxy URLが表示されるので、コピーします。",
+      "enter_proxy_url_and_update": "コピーしたProxy URLを、このページの<b>Custom bot with proxy 連携</b>の<b>Proxy URL</b>に入力、更新します。",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "select_install_your_app": "Install your app をクリックします。",
       "select_install_your_app": "Install your app をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
@@ -345,6 +354,10 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
       "custom_bot_with_proxy_setting": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
     }
   },
   },
+  "slack_integration_legacy": {
+    "alert_disabled": "<a href='/admin/slack-integration'>新しい設定</a>が有効になっているため、この 'Slack連携 (レガシー)' は現在無効になっています。",
+    "alert_deplicated": "この 'Slack連携 (レガシー)' は将来廃止されます。代わりに<a href='/admin/slack-integration'>新しいSlack連携機能</a>を利用してください。"
+  },
   "user_management": {
   "user_management": {
     "invite_users": "新規ユーザーの仮発行",
     "invite_users": "新規ユーザーの仮発行",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",

+ 0 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -440,7 +440,6 @@
     "initialize_successed": "{{target}}を初期化しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
-    "delete_slack_integration_procedure": "Slack 連携手順を削除しました",
     "activate_user_success": "{{username}}を有効化しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
     "remove_user_success": "{{username}}を削除しました",

+ 43 - 10
packages/app/resource/locales/ja_JP/welcome.md

@@ -1,9 +1,27 @@
-# Welcome to GROWI :anchor:
-
+# :tada: GROWI へようこそ
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 
-<div class="card border-primary">
+GROWI は個人・法人向けの Wiki | ナレッジベースツールです。  
+会社や大学の研究室、サークルでのナレッジ情報を簡単に共有でき、作られたページは誰でも編集が可能です。
+
+知っている情報をカジュアルに書き出しみんなで編集することで、**チーム内での暗黙知を減らす**ことができます。  
+当たり前に共有される情報を日々増やしていきましょう。
+
+### :beginner: 簡単なページの作り方
+
+- 右上の "**作成**"ボタンまたは右下の**鉛筆アイコン**のボタンからページを書き始めることができます
+    - ページタイトルは後から変更できますので、適当に入力しても大丈夫です
+        - タイトル入力欄では、半角の `/` (スラッシュ) でページ階層を作れます
+        - (例)`/カテゴリ1/カテゴリ2/作りたいページタイトル` のように入力してみてください
+- `- ` を行頭につけると、この文章のような箇条書きを書くことができます
+- 画像やPDF、Word/Excel/PowerPointなどの添付ファイルも、コピー&ペースト、ドラッグ&ドロップで貼ることができます
+- 書けたら "**更新**" ボタンを押してページを公開しましょう
+    - `Ctrl(⌘) +S` でも保存できます
+
+さらに詳しくはこちら: [チュートリアル#新規ページ作成](https://docs.growi.org/ja/guide/tutorial/create_page.html#新規ページ作成)
+
+<div class="mt-4 card border-primary">
   <div class="card-header bg-primary text-light">Tips</div>
   <div class="card-header bg-primary text-light">Tips</div>
   <div class="card-body"><ul>
   <div class="card-body"><ul>
     <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
     <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
@@ -11,15 +29,30 @@
   </ul></div>
   </ul></div>
 </div>
 </div>
 
 
-Contents
-=========
 
 
-|All Pages|[/Sandbox]|
-| --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Sandboxをチェック](/Sandbox)</span></div> $lsx(/Sandbox)|
+# :anchor: 管理者の方へ <small>〜Wikiを作ったら〜</small>
+
+### :arrow_right: 複数人でWikiを使いますか?
+- :heavy_check_mark: メンバーを招待しましょう
+    - [Wikiに新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
+### :arrow_right: Slackと連携してページやコメントの通知を受け取りましょう
+- :heavy_check_mark:  [Slack連携](https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/#%E6%A6%82%E8%A6%81)
+### :arrow_right: 他のシステムからの乗り換えですか?
+- :heavy_check_mark: 他の GROWI、esa. io、Qiita:Team のデータをインポートすることが出来ます
+    -  [データのインポート](https://docs.growi.org/ja/admin-guide/management-cookbook/import.html)
+
+さらに詳しくはこちら: [管理者ガイド](https://docs.growi.org/ja/admin-guide/)
+
+
+# コンテンツリストアップ例
+
+テーブルと `$lsx` を使ってコンテンツリストを表示できます。
+
+| 全てのページリスト (First 15 pages) | [/Sandbox] 配下ページ一覧 |
+| ----------------------------------- | ------------------------- |
+| $lsx(/,num=15)                      | $lsx(/Sandbox)            |
 
 
-Slack
-=====
+# Slack
 
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
 

+ 15 - 2
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -262,6 +262,12 @@
     "download": "下载",
     "download": "下载",
     "delete": "删除"
     "delete": "删除"
   },
   },
+  "external_notification": {
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "header_status": "Slack整合状态",
+    "caution_enabled": "CAUTION: 目前,在此页面中配置的通知只会通知设置为主要的 Slack 工作区。 "
+  },
   "slack_integration": {
   "slack_integration": {
     "selecting_bot_types": {
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "slack_bot": "Slack bot",
@@ -292,6 +298,9 @@
       "cancel": "取消",
       "cancel": "取消",
       "change": "改变"
       "change": "改变"
     },
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "删除了 Slack 集成程序"
+    },
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <code>{{variable}}</code> 启用。",
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <code>{{variable}}</code> 启用。",
     "access_token_settings": {
     "access_token_settings": {
       "regenerate": "再生"
       "regenerate": "再生"
@@ -318,8 +327,8 @@
       "paste_growi_url": "由于显示了模式,请在 <b>GROWI URL</b> 中输入以下URL",
       "paste_growi_url": "由于显示了模式,请在 <b>GROWI URL</b> 中输入以下URL",
       "enter_access_token_for_growi_and_proxy": "插入上面发出的 <b>Access Token Proxy to GROWI</b> 和 <b>Access Token GROWI to Proxy</b>。",
       "enter_access_token_for_growi_and_proxy": "插入上面发出的 <b>Access Token Proxy to GROWI</b> 和 <b>Access Token GROWI to Proxy</b>。",
       "set_proxy_url_on_growi": "向GROWI注册Proxy的URL",
       "set_proxy_url_on_growi": "向GROWI注册Proxy的URL",
-      "copy_proxy_url": "1. 当上述步骤成功完成后,Proxy URL将显示在你在模版中选择的Slack频道中,所以请复制它。",
-      "enter_proxy_url_and_update": "2. 输入并更新你在步骤③中复制的ProxyURL到本页的<b>Custom bot with proxy 一体化</b>的<b>ProxyURL</b>。",
+      "copy_proxy_url": "当上述步骤成功完成后,Proxy URL将显示在你在模版中选择的Slack频道中,所以请复制它。",
+      "enter_proxy_url_and_update": "上述过程中复制的ProxyURL到本页的<b>Custom bot with proxy 一体化</b>的<b>ProxyURL</b>。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
@@ -355,6 +364,10 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
     }
   },
   },
+  "slack_integration_legacy": {
+    "alert_disabled": "由于<a href='/admin/slack-integration'>新设置</a>已启用,因此该'旧版Slack一体化'目前已被禁用。",
+    "alert_deplicated": "这个 '旧版Slack一体化' 已经过时了,将来会停止使用。使用<a href='/admin/slack-integration'>新的设置</a>来代替。"
+  },
   "user_management": {
   "user_management": {
     "invite_users": "临时发布新用户",
     "invite_users": "临时发布新用户",
     "click_twice_same_checkbox": "您应该至少选中一个复选框。",
     "click_twice_same_checkbox": "您应该至少选中一个复选框。",

+ 0 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -418,7 +418,6 @@
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
-    "delete_slack_integration_procedure": "删除了 Slack 集成程序",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",
 		"remove_user_success": "Succeeded to removing {{username}} ",

+ 53 - 16
packages/app/resource/locales/zh_CN/welcome.md

@@ -1,27 +1,64 @@
-# 欢迎来到GROWI :anchor:
+# :tada: 欢迎来到GROWI
 
 
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 
-<div class="card border-primary">
-  <div class="card-header bg-primary text-light">提示</div>
-  <div class="card-body"><ul>
-    <li>(按Ctrl>)+“/”to show quick help</li>
-    <li>>你可以写HTML与</a href=”https://getbootstrap.com docs/4.5 components/“Bootstrap 4</a></li>
-  </ul></div>
+GROWI是一个针对个人和公司的Wiki - 一个知识库工具。
+公司、大学实验室和俱乐部的知识可以轻松共享,任何人都可以编辑页面。
+
+我们可以很容易地写下我们知道的东西,并一起编辑,我们可以**简化我们团队中的隐性知识(难以用语言解释的知识)**。 
+让我们每天增加信息交流。
+
+### :beginner: 如何轻松制作一个页面 
+
+- 从右上方的 "**创建**"按钮,或右下方的**铅笔图标开始。
+    - 页面标题以后可以再编辑,不用担心标题的问题。
+        - 在标题输入栏,可以用半宽的`/`(斜线)创建页面的层次。
+        - 例子)尝试输入`/category1/category2/page-title-we-want-to-create`。
+- 我们可以通过在行首添加`-`来创建一个要点。
+- 我们还可以复制和粘贴,拖放附件,如图片、PDF、Word/Excel/PowerPoint等。
+- 一旦我们完成了,按 "**更新**"按钮来发布页面。
+    - 我们也可以通过`Ctrl(⌘) +S`来保存。
+
+了解更多信息: [Tutorial#Create New Page](https://docs.growi.org/en/guide/tutorial/create_page.html#create-new-page)
+
+<div class="mt-4 card border-primary">
+  <div class="card-header bg-primary text-light">
+    Tips
+  </div>
+  <div class="card-body">
+    <ul>
+      <li>Ctrl(⌘) + "/" 显示快速帮助。</li>
+      <li>你可以用 <a href="https://getbootstrap.com/docs/4.5/components/">Bootstrap 4编写HTML</a>.</li>
+    </ul>
+  </div>
 </div>
 </div>
 
 
-Contents
-=========
+# :anchor: 对于管理员来说 <small>〜如果你创建了一个Wiki〜</small>
+
+### :arrow_right: 你会和多个人一起使用Wiki吗?
+- :heavy_check_mark: 让我们邀请一些成员。
+    - [Add/invite new members to the Wiki](https://docs.growi.org/en/admin-guide/management-cookbook/user-management.html#temporary-issuance-of-a-new-user)
+### :arrow_right: 与Slack合作,接收页面和评论通知。
+- :heavy_check_mark:  [Slack integration](https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#overview)
+### :arrow_right: 你是否从另一个系统转换?
+- :heavy_check_mark: 可以从其他GROWI, esa.io, Qiita:Team导入数据。
+    -  [Import Data](https://docs.growi.org/en/admin-guide/management-cookbook/import.html)
+
+了解更多信息: [Admin Guide](https://docs.growi.org/en/admin-guide/)
+
+
+# 内容列表示例
+
+你可以用一个表格和`$lsx`来显示内容列表。
 
 
-|All Pages|[/Sandbox]|
-| --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Go to Sandbox](/Sandbox)</span></div> $lsx(/Sandbox)|
+| 所有页面列表(前15页)      | [/Sandbox] 下级页面列表 |
+| ---------------------------| ------------------------|
+| $lsx(/,num=15)             | $lsx(/Sandbox)          |
 
 
-Slack 
-=====
+# Slack
 
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
 
-让我们加入我们所有人的休闲渠道,帮助成长。
-除了讨论发展,我们在介绍时也接受提问。
+我们欢迎新人加入我们的slack频道,帮助改善Growi
+除了讨论发展问题,我们也很乐意在你加入时回答你的问题

+ 5 - 3
packages/app/src/client/admin.jsx

@@ -10,7 +10,7 @@ import ErrorBoundary from '../components/ErrorBoudary';
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
 import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
 import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
 import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import SlackIntegrationNotificationSetting from '../components/Admin/Notification/SlackIntegrationNotificationSetting';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
 import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
 import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
 import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
 import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
 import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
 import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
@@ -46,6 +46,7 @@ import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityC
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
@@ -65,6 +66,7 @@ const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
 const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
 const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
+const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
@@ -78,7 +80,7 @@ const injectableContainers = [
   adminUsersContainer,
   adminUsersContainer,
   adminExternalAccountsContainer,
   adminExternalAccountsContainer,
   adminNotificationContainer,
   adminNotificationContainer,
-  adminNotificationContainer,
+  adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
   adminMarkDownContainer,
   adminUserGroupDetailContainer,
   adminUserGroupDetailContainer,
 ];
 ];
@@ -99,7 +101,7 @@ Object.assign(componentMappings, {
   'admin-export-page': <ExportArchiveDataPage />,
   'admin-export-page': <ExportArchiveDataPage />,
   'admin-notification-setting': <NotificationSetting />,
   'admin-notification-setting': <NotificationSetting />,
   'admin-slack-integration': <SlackIntegration />,
   'admin-slack-integration': <SlackIntegration />,
-  'admin-slack-integration-notification-setting': <SlackIntegrationNotificationSetting />,
+  'admin-slack-integration-legacy': <LegacySlackIntegration />,
   'admin-global-notification-setting': <ManageGlobalNotification />,
   'admin-global-notification-setting': <ManageGlobalNotification />,
   'admin-user-page': <UserManagement />,
   'admin-user-page': <UserManagement />,
   'admin-external-account-setting': <ManageExternalAccount />,
   'admin-external-account-setting': <ManageExternalAccount />,

+ 9 - 37
packages/app/src/client/services/AdminNotificationContainer.js

@@ -10,15 +10,14 @@ export default class AdminNotificationContainer extends Container {
     super();
     super();
 
 
     this.appContainer = appContainer;
     this.appContainer = appContainer;
-    this.dummyWebhookUrl = 0;
-    this.dummyWebhookUrlForError = 1;
 
 
     this.state = {
     this.state = {
       retrieveError: null,
       retrieveError: null,
-      selectSlackOption: 'Incoming Webhooks',
-      webhookUrl: this.dummyWebhookUrl,
-      isIncomingWebhookPrioritized: false,
-      slackToken: '',
+
+      isSlackbotConfigured: null,
+      isSlackLegacyConfigured: null,
+      currentBotType: null,
+
       userNotifications: [],
       userNotifications: [],
       isNotificationForOwnerPageEnabled: false,
       isNotificationForOwnerPageEnabled: false,
       isNotificationForGroupPageEnabled: false,
       isNotificationForGroupPageEnabled: false,
@@ -42,9 +41,10 @@ export default class AdminNotificationContainer extends Container {
     const { notificationParams } = response.data;
     const { notificationParams } = response.data;
 
 
     this.setState({
     this.setState({
-      webhookUrl: notificationParams.webhookUrl,
-      isIncomingWebhookPrioritized: notificationParams.isIncomingWebhookPrioritized,
-      slackToken: notificationParams.slackToken,
+      isSlackbotConfigured: notificationParams.isSlackbotConfigured,
+      isSlackLegacyConfigured: notificationParams.isSlackLegacyConfigured,
+      currentBotType: notificationParams.currentBotType,
+
       userNotifications: notificationParams.userNotifications,
       userNotifications: notificationParams.userNotifications,
       isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
       isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
       isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
       isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
@@ -52,34 +52,6 @@ export default class AdminNotificationContainer extends Container {
     });
     });
   }
   }
 
 
-  /**
-   * Switch slackOption
-   */
-  switchSlackOption(slackOption) {
-    this.setState({ selectSlackOption: slackOption });
-  }
-
-  /**
-   * Change webhookUrl
-   */
-  changeWebhookUrl(webhookUrl) {
-    this.setState({ webhookUrl });
-  }
-
-  /**
-   * Switch incomingWebhookPrioritized
-   */
-  switchIsIncomingWebhookPrioritized() {
-    this.setState({ isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized });
-  }
-
-  /**
-   * Change slackToken
-   */
-  changeSlackToken(slackToken) {
-    this.setState({ slackToken });
-  }
-
   /**
   /**
    * Update slackAppConfiguration
    * Update slackAppConfiguration
    * @memberOf SlackAppConfiguration
    * @memberOf SlackAppConfiguration

+ 91 - 0
packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -0,0 +1,91 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container for admin LegacySlackIntegration setting page (LegacySlackIntegration.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminSlackIntegrationLegacyContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.dummyWebhookUrl = 0;
+    this.dummyWebhookUrlForError = 1;
+
+    this.state = {
+      isSlackbotConfigured: false,
+      retrieveError: null,
+      selectSlackOption: 'Incoming Webhooks',
+      webhookUrl: this.dummyWebhookUrl,
+      isIncomingWebhookPrioritized: false,
+      slackToken: '',
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminSlackIntegrationLegacyContainer';
+  }
+
+  /**
+   * Retrieve notificationData
+   */
+  async retrieveData() {
+    const response = await this.appContainer.apiv3.get('/slack-integration-legacy-settings/');
+    const { slackIntegrationParams } = response.data;
+
+    this.setState({
+      isSlackbotConfigured: slackIntegrationParams.isSlackbotConfigured,
+      webhookUrl: slackIntegrationParams.webhookUrl,
+      isIncomingWebhookPrioritized: slackIntegrationParams.isIncomingWebhookPrioritized,
+      slackToken: slackIntegrationParams.slackToken,
+    });
+  }
+
+  /**
+   * Switch slackOption
+   */
+  switchSlackOption(slackOption) {
+    this.setState({ selectSlackOption: slackOption });
+  }
+
+  /**
+   * Change webhookUrl
+   */
+  changeWebhookUrl(webhookUrl) {
+    this.setState({ webhookUrl });
+  }
+
+  /**
+   * Switch incomingWebhookPrioritized
+   */
+  switchIsIncomingWebhookPrioritized() {
+    this.setState({ isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized });
+  }
+
+  /**
+   * Change slackToken
+   */
+  changeSlackToken(slackToken) {
+    this.setState({ slackToken });
+  }
+
+  /**
+   * Update slackAppConfiguration
+   * @memberOf SlackAppConfiguration
+   */
+  async updateSlackAppConfiguration() {
+    const response = await this.appContainer.apiv3.put('/slack-integration-legacy-settings/', {
+      webhookUrl: this.state.webhookUrl,
+      isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
+      slackToken: this.state.slackToken,
+    });
+
+    return response;
+  }
+
+}

+ 5 - 1
packages/app/src/client/util/apiNotification.js

@@ -34,8 +34,12 @@ const toastrOption = {
 export const toastError = (err, header = 'Error', option = toastrOption.error) => {
 export const toastError = (err, header = 'Error', option = toastrOption.error) => {
   const errs = toArrayIfNot(err);
   const errs = toArrayIfNot(err);
 
 
+  if (err.length === 0) {
+    toastr.error('', header);
+  }
+
   for (const err of errs) {
   for (const err of errs) {
-    toastr.error(err.message, header, option);
+    toastr.error(err.message || err, header, option);
   }
   }
 };
 };
 
 

+ 71 - 0
packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx

@@ -0,0 +1,71 @@
+import React, { useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastError } from '~/client/util/apiNotification';
+import { toArrayIfNot } from '~/utils/array-utils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
+
+import SlackConfiguration from './SlackConfiguration';
+
+const logger = loggerFactory('growi:NotificationSetting');
+
+let retrieveErrors = null;
+function LegacySlackIntegration(props) {
+  const { t } = useTranslation();
+  const { adminSlackIntegrationLegacyContainer } = props;
+
+  if (adminSlackIntegrationLegacyContainer.state.webhookUrl === adminSlackIntegrationLegacyContainer.dummyWebhookUrl) {
+    throw (async() => {
+      try {
+        await adminSlackIntegrationLegacyContainer.retrieveData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        retrieveErrors = errs;
+        adminSlackIntegrationLegacyContainer.setState({ webhookUrl: adminSlackIntegrationLegacyContainer.dummyWebhookUrlForError });
+      }
+    })();
+  }
+
+  if (adminSlackIntegrationLegacyContainer.state.webhookUrl === adminSlackIntegrationLegacyContainer.dummyWebhookUrlForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
+  }
+
+  const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
+
+  return (
+    <>
+      { isDisabled && (
+        <div className="alert alert-danger">
+          <i className="icon-minus icon-fw"></i>
+          {/* eslint-disable-next-line react/no-danger */}
+          <span dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_disabled') }}></span>
+        </div>
+      ) }
+
+      <div className="alert alert-warning">
+        <i className="icon-info icon-fw"></i>
+        {/* eslint-disable-next-line react/no-danger */}
+        <span dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_deplicated') }}></span>
+      </div>
+
+      <SlackConfiguration />
+    </>
+  );
+}
+
+const LegacySlackIntegrationWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(LegacySlackIntegration), [AdminSlackIntegrationLegacyContainer]);
+
+LegacySlackIntegration.propTypes = {
+  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
+};
+
+export default LegacySlackIntegrationWithUnstatedContainer;

+ 21 - 21
packages/app/src/components/Admin/Notification/SlackAppConfiguration.jsx → packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx

@@ -8,12 +8,12 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 const logger = loggerFactory('growi:slackAppConfiguration');
 const logger = loggerFactory('growi:slackAppConfiguration');
 
 
-class SlackAppConfiguration extends React.Component {
+class SlackConfiguration extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -22,10 +22,10 @@ class SlackAppConfiguration extends React.Component {
   }
   }
 
 
   async onClickSubmit() {
   async onClickSubmit() {
-    const { t, adminNotificationContainer } = this.props;
+    const { t, adminSlackIntegrationLegacyContainer } = this.props;
 
 
     try {
     try {
-      await adminNotificationContainer.updateSlackAppConfiguration();
+      await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
       toastSuccess(t('notification_setting.updated_slackApp'));
       toastSuccess(t('notification_setting.updated_slackApp'));
     }
     }
     catch (err) {
     catch (err) {
@@ -35,7 +35,7 @@ class SlackAppConfiguration extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, adminNotificationContainer } = this.props;
+    const { t, adminSlackIntegrationLegacyContainer } = this.props;
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
@@ -50,18 +50,18 @@ class SlackAppConfiguration extends React.Component {
                 aria-haspopup="true"
                 aria-haspopup="true"
                 aria-expanded="true"
                 aria-expanded="true"
               >
               >
-                {`Slack ${adminNotificationContainer.state.selectSlackOption}`}
+                {`Slack ${adminSlackIntegrationLegacyContainer.state.selectSlackOption}`}
               </button>
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => adminNotificationContainer.switchSlackOption('Incoming Webhooks')}>
+                <button className="dropdown-item" type="button" onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}>
                   Slack Incoming Webhooks
                   Slack Incoming Webhooks
                 </button>
                 </button>
-                <button className="dropdown-item" type="button" onClick={() => adminNotificationContainer.switchSlackOption('App')}>Slack App</button>
+                <button className="dropdown-item" type="button" onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('App')}>Slack App</button>
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
-        {adminNotificationContainer.state.selectSlackOption === 'Incoming Webhooks' ? (
+        {adminSlackIntegrationLegacyContainer.state.selectSlackOption === 'Incoming Webhooks' ? (
           <React.Fragment>
           <React.Fragment>
             <h2 className="border-bottom mb-5">{t('notification_setting.slack_incoming_configuration')}</h2>
             <h2 className="border-bottom mb-5">{t('notification_setting.slack_incoming_configuration')}</h2>
 
 
@@ -71,8 +71,8 @@ class SlackAppConfiguration extends React.Component {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  defaultValue={adminNotificationContainer.state.webhookUrl || ''}
-                  onChange={e => adminNotificationContainer.changeWebhookUrl(e.target.value)}
+                  defaultValue={adminSlackIntegrationLegacyContainer.state.webhookUrl || ''}
+                  onChange={e => adminSlackIntegrationLegacyContainer.changeWebhookUrl(e.target.value)}
                 />
                 />
               </div>
               </div>
             </div>
             </div>
@@ -84,8 +84,8 @@ class SlackAppConfiguration extends React.Component {
                     type="checkbox"
                     type="checkbox"
                     className="custom-control-input"
                     className="custom-control-input"
                     id="cbPrioritizeIWH"
                     id="cbPrioritizeIWH"
-                    checked={adminNotificationContainer.state.isIncomingWebhookPrioritized || false}
-                    onChange={() => { adminNotificationContainer.switchIsIncomingWebhookPrioritized() }}
+                    checked={adminSlackIntegrationLegacyContainer.state.isIncomingWebhookPrioritized || false}
+                    onChange={() => { adminSlackIntegrationLegacyContainer.switchIsIncomingWebhookPrioritized() }}
                   />
                   />
                   <label className="custom-control-label" htmlFor="cbPrioritizeIWH">
                   <label className="custom-control-label" htmlFor="cbPrioritizeIWH">
                     {t('notification_setting.prioritize_webhook')}
                     {t('notification_setting.prioritize_webhook')}
@@ -111,7 +111,7 @@ class SlackAppConfiguration extends React.Component {
                 <a
                 <a
                   href="#slack-incoming-webhooks"
                   href="#slack-incoming-webhooks"
                   data-toggle="tab"
                   data-toggle="tab"
-                  onClick={() => adminNotificationContainer.switchSlackOption('Incoming Webhooks')}
+                  onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}
                 >
                 >
                   {t('notification_setting.use_instead')}
                   {t('notification_setting.use_instead')}
                 </a>
                 </a>
@@ -123,8 +123,8 @@ class SlackAppConfiguration extends React.Component {
                   <input
                   <input
                     className="form-control"
                     className="form-control"
                     type="text"
                     type="text"
-                    defaultValue={adminNotificationContainer.state.slackToken || ''}
-                    onChange={e => adminNotificationContainer.changeSlackToken(e.target.value)}
+                    defaultValue={adminSlackIntegrationLegacyContainer.state.slackToken || ''}
+                    onChange={e => adminSlackIntegrationLegacyContainer.changeSlackToken(e.target.value)}
                   />
                   />
                 </div>
                 </div>
               </div>
               </div>
@@ -135,7 +135,7 @@ class SlackAppConfiguration extends React.Component {
 
 
         <AdminUpdateButtonRow
         <AdminUpdateButtonRow
           onClick={this.onClickSubmit}
           onClick={this.onClickSubmit}
-          disabled={adminNotificationContainer.state.retrieveError != null}
+          disabled={adminSlackIntegrationLegacyContainer.state.retrieveError != null}
         />
         />
 
 
         <hr />
         <hr />
@@ -170,13 +170,13 @@ class SlackAppConfiguration extends React.Component {
 
 
 }
 }
 
 
-const SlackAppConfigurationWrapper = withUnstatedContainers(SlackAppConfiguration, [AppContainer, AdminNotificationContainer]);
+const SlackConfigurationWrapper = withUnstatedContainers(SlackConfiguration, [AppContainer, AdminSlackIntegrationLegacyContainer]);
 
 
-SlackAppConfiguration.propTypes = {
+SlackConfiguration.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
+  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
 
 
 };
 };
 
 
-export default withTranslation()(SlackAppConfigurationWrapper);
+export default withTranslation()(SlackConfigurationWrapper);

+ 111 - 20
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -1,7 +1,12 @@
-import React, { useMemo, useState } from 'react';
+import React, {
+  useCallback, useEffect, useMemo, useState,
+} from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { TabContent, TabPane } from 'reactstrap';
+import {
+  Card, CardBody, TabContent, TabPane,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -19,9 +24,76 @@ import GlobalNotification from './GlobalNotification';
 const logger = loggerFactory('growi:NotificationSetting');
 const logger = loggerFactory('growi:NotificationSetting');
 
 
 let retrieveErrors = null;
 let retrieveErrors = null;
+
+
+// eslint-disable-next-line react/prop-types
+const Badge = ({ isEnabled }) => {
+  const { t } = useTranslation();
+
+  return isEnabled
+    ? <span className="badge badge-success">{t('admin:external_notification.enabled')}</span>
+    : <span className="badge badge-secondary">{t('admin:external_notification.disabled')}</span>;
+};
+
+const SkeltonListItem = () => (
+  <li className="list-group-item">
+    <h4 className="mb-2">
+      <span className="badge badge-secondary">――</span>
+      <span className="ml-2">...</span>
+    </h4>
+  </li>
+);
+
+// eslint-disable-next-line react/prop-types
+const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
+  const { t } = useTranslation();
+
+  const isCautionVisible = currentBotType === 'officialBot' || currentBotType === 'customBotWithProxy';
+
+  return (
+    <li className="list-group-item">
+      <h4>
+        <Badge isEnabled={isEnabled} />
+        <a href="/admin/slack-integration" className="ml-2">{t('slack_integration')}</a>
+      </h4>
+      { isCautionVisible && (
+        <ul className="mt-2 pl-4">
+          {/* eslint-disable-next-line react/no-danger */}
+          <li dangerouslySetInnerHTML={{ __html: t('admin:external_notification.caution_enabled') }} />
+        </ul>
+      ) }
+    </li>
+  );
+};
+
+// eslint-disable-next-line react/prop-types
+const LegacySlackIntegrationListItem = ({ isEnabled }) => {
+  const { t } = useTranslation();
+
+  return (
+    <li className="list-group-item">
+      <h4>
+        <Badge isEnabled={isEnabled} />
+        <a href="/admin/slack-integration-legacy" className="ml-2">{t('legacy_slack_integration')}</a>
+      </h4>
+      { isEnabled && (
+        <ul className="mt-2 pl-4">
+          <li>
+            {/* eslint-disable-next-line react/no-danger */}
+            <span className="text-danger" dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_deplicated') }}></span>
+          </li>
+        </ul>
+      ) }
+    </li>
+  );
+};
+
 function NotificationSetting(props) {
 function NotificationSetting(props) {
   const { adminNotificationContainer } = props;
   const { adminNotificationContainer } = props;
 
 
+  const { t } = useTranslation();
+
+  const [isMounted, setMounted] = useState(false);
   const [activeTab, setActiveTab] = useState('user_trigger_notification');
   const [activeTab, setActiveTab] = useState('user_trigger_notification');
   const [activeComponents, setActiveComponents] = useState(new Set(['user_trigger_notification']));
   const [activeComponents, setActiveComponents] = useState(new Set(['user_trigger_notification']));
 
 
@@ -30,24 +102,24 @@ function NotificationSetting(props) {
     setActiveComponents(activeComponents.add(selectedTab));
     setActiveComponents(activeComponents.add(selectedTab));
   };
   };
 
 
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrl) {
-    throw (async() => {
-      try {
-        await adminNotificationContainer.retrieveNotificationData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        logger.error(errs);
-        retrieveErrors = errs;
-        adminNotificationContainer.setState({ webhookUrl: adminNotificationContainer.dummyWebhookUrlForError });
-      }
-    })();
-  }
-
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrlForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+  const fetchData = useCallback(async() => {
+    try {
+      await adminNotificationContainer.retrieveNotificationData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+      logger.error(errs);
+      retrieveErrors = errs;
+    }
+    finally {
+      setMounted(true);
+    }
+  }, [adminNotificationContainer]);
+
+  useEffect(() => {
+    fetchData();
+  }, [fetchData]);
 
 
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
@@ -64,8 +136,27 @@ function NotificationSetting(props) {
     };
     };
   }, []);
   }, []);
 
 
+  const { isSlackbotConfigured, isSlackLegacyConfigured, currentBotType } = adminNotificationContainer.state;
+  const isSlackEnabled = isSlackbotConfigured;
+  const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
+
   return (
   return (
     <>
     <>
+      <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
+      <ul className="list-group">
+        { !isMounted && <SkeltonListItem />}
+        { isMounted && (
+          <>
+            <SlackIntegrationListItem isEnabled={isSlackEnabled} currentBotType={currentBotType} />
+            {/* Legacy Slack Integration become visible only when new Slack Integration is disabled */}
+            { !isSlackEnabled && <LegacySlackIntegrationListItem isEnabled={isSlackLegacyEnabled} /> }
+          </>
+        ) }
+      </ul>
+
+
+      <h2 className="admin-setting-header mt-5">{t('Notification Settings')}</h2>
+
       <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
       <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
 
 
       <TabContent activeTab={activeTab} className="p-5">
       <TabContent activeTab={activeTab} className="p-5">

+ 0 - 80
packages/app/src/components/Admin/Notification/SlackIntegrationNotificationSetting.jsx

@@ -1,80 +0,0 @@
-import React, { useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import { TabContent, TabPane } from 'reactstrap';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
-import { toArrayIfNot } from '~/utils/array-utils';
-import { withLoadingSppiner } from '../../SuspenseUtils';
-
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-
-import { CustomNavTab } from '../../CustomNavigation/CustomNav';
-
-import SlackAppConfiguration from './SlackAppConfiguration';
-
-const logger = loggerFactory('growi:NotificationSetting');
-
-let retrieveErrors = null;
-function NotificationSetting(props) {
-  const { adminNotificationContainer } = props;
-
-  const [activeTab, setActiveTab] = useState('slack_configuration');
-  const [activeComponents, setActiveComponents] = useState(new Set(['slack_configuration']));
-
-  const switchActiveTab = (selectedTab) => {
-    setActiveTab(selectedTab);
-    setActiveComponents(activeComponents.add(selectedTab));
-  };
-
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrl) {
-    throw (async() => {
-      try {
-        await adminNotificationContainer.retrieveNotificationData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        logger.error(errs);
-        retrieveErrors = errs;
-        adminNotificationContainer.setState({ webhookUrl: adminNotificationContainer.dummyWebhookUrlForError });
-      }
-    })();
-  }
-
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrlForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
-
-  const navTabMapping = useMemo(() => {
-    return {
-      slack_configuration: {
-        Icon: () => <i className="icon-settings" />,
-        i18n: 'Slack configuration',
-        index: 0,
-      },
-    };
-  }, []);
-
-  return (
-    <>
-      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
-
-      <TabContent activeTab={activeTab} className="p-5">
-        <TabPane tabId="slack_configuration">
-          {activeComponents.has('slack_configuration') && <SlackAppConfiguration />}
-        </TabPane>
-      </TabContent>
-    </>
-  );
-}
-
-const NotificationSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(NotificationSetting), [AdminNotificationContainer]);
-
-NotificationSetting.propTypes = {
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-};
-
-export default NotificationSettingWithUnstatedContainer;

+ 39 - 17
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -1,6 +1,7 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -8,12 +9,15 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 
 
-const logger = loggerFactory('growi:SlackBotSettings');
+const logger = loggerFactory('growi:cli:SlackIntegration:CustomBotWithProxySettings');
 
 
 const CustomBotWithProxySettings = (props) => {
 const CustomBotWithProxySettings = (props) => {
   const {
   const {
-    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations, proxyServerUri,
+    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
+    connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   } = props;
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
@@ -31,17 +35,36 @@ const CustomBotWithProxySettings = (props) => {
     }
     }
   };
   };
 
 
+  const isPrimaryChangedHandler = useCallback(async(slackIntegrationToChange, newValue) => {
+    // do nothing when turning off
+    if (!newValue) {
+      return;
+    }
+
+    try {
+      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
+      if (onPrimaryUpdated != null) {
+        onPrimaryUpdated();
+      }
+      toastSuccess(t('toaster.update_successed', { target: 'Primary' }));
+    }
+    catch (err) {
+      toastError(err, 'Failed to change isPrimary');
+      logger.error('Failed to change isPrimary', err);
+    }
+  }, [appContainer.apiv3, t, onPrimaryUpdated]);
+
   const deleteSlackAppIntegrationHandler = async() => {
   const deleteSlackAppIntegrationHandler = async() => {
     try {
     try {
-      await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
+      await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
       if (props.onDeleteSlackAppIntegration != null) {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
         props.onDeleteSlackAppIntegration();
       }
       }
-      toastSuccess(t('toaster.delete_slack_integration_procedure'));
+      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
     }
     }
     catch (err) {
     catch (err) {
-      toastError(err);
-      logger.error(err);
+      toastError(err, 'Failed to delete');
+      logger.error('Failed to delete', err);
     }
     }
   };
   };
 
 
@@ -53,8 +76,8 @@ const CustomBotWithProxySettings = (props) => {
       toastSuccess(t('toaster.update_successed', { target: 'Proxy URL' }));
       toastSuccess(t('toaster.update_successed', { target: 'Proxy URL' }));
     }
     }
     catch (err) {
     catch (err) {
-      toastError(err);
-      logger.error(err);
+      toastError(err, 'Failed to update');
+      logger.error('Failed to update', err);
     }
     }
   };
   };
 
 
@@ -113,14 +136,12 @@ const CustomBotWithProxySettings = (props) => {
                 <h2 id={_id || `settings-accordions-${i}`}>
                 <h2 id={_id || `settings-accordions-${i}`}>
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                 </h2>
                 </h2>
-                <button
-                  className="btn btn-outline-danger"
-                  type="button"
-                  onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
-                >
-                  <i className="icon-trash mr-1" />
-                  {t('admin:slack_integration.delete')}
-                </button>
+                <SlackAppIntegrationControl
+                  slackAppIntegration={slackAppIntegration}
+                  onIsPrimaryChanged={isPrimaryChangedHandler}
+                  // set state to open DeleteSlackBotSettingsModal
+                  onDeleteButtonClicked={saiToDelete => setIntegrationIdToDelete(saiToDelete._id)}
+                />
               </div>
               </div>
               <WithProxyAccordions
               <WithProxyAccordions
                 botType="customBotWithProxy"
                 botType="customBotWithProxy"
@@ -168,6 +189,7 @@ CustomBotWithProxySettings.propTypes = {
   slackAppIntegrations: PropTypes.array,
   slackAppIntegrations: PropTypes.array,
   proxyServerUri: PropTypes.string,
   proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onPrimaryUpdated: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   onSubmitForm: PropTypes.func,
   onSubmitForm: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
   connectionStatuses: PropTypes.object.isRequired,

+ 7 - 5
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -51,7 +51,7 @@ const ManageCommandsProcess = ({
 
 
   const updateCommandsHandler = async() => {
   const updateCommandsHandler = async() => {
     try {
     try {
-      await apiv3Put(`/slack-integration-settings/${slackAppIntegrationId}/supported-commands`, {
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
         supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
         supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
         supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
         supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
       });
       });
@@ -75,18 +75,19 @@ const ManageCommandsProcess = ({
           <div className="custom-control custom-checkbox">
           <div className="custom-control custom-checkbox">
             <div className="row mb-5">
             <div className="row mb-5">
               {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
               {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
+                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
                 return (
                 return (
                   <div className="col-sm-6 my-1" key={commandName}>
                   <div className="col-sm-6 my-1" key={commandName}>
                     <input
                     <input
                       type="checkbox"
                       type="checkbox"
                       className="custom-control-input"
                       className="custom-control-input"
-                      id={commandName}
+                      id={checkboxId}
                       name={commandName}
                       name={commandName}
                       value={commandName}
                       value={commandName}
                       checked={selectedCommandsForBroadcastUse.has(commandName)}
                       checked={selectedCommandsForBroadcastUse.has(commandName)}
                       onChange={toggleCheckboxForBroadcast}
                       onChange={toggleCheckboxForBroadcast}
                     />
                     />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
                       {commandName}
                       {commandName}
                     </label>
                     </label>
                   </div>
                   </div>
@@ -100,18 +101,19 @@ const ManageCommandsProcess = ({
           <div className="custom-control custom-checkbox">
           <div className="custom-control custom-checkbox">
             <div className="row mb-5">
             <div className="row mb-5">
               {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
               {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
+                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
                 return (
                 return (
                   <div className="col-sm-6 my-1" key={commandName}>
                   <div className="col-sm-6 my-1" key={commandName}>
                     <input
                     <input
                       type="checkbox"
                       type="checkbox"
                       className="custom-control-input"
                       className="custom-control-input"
-                      id={commandName}
+                      id={checkboxId}
                       name={commandName}
                       name={commandName}
                       value={commandName}
                       value={commandName}
                       checked={selectedCommandsForSingleUse.has(commandName)}
                       checked={selectedCommandsForSingleUse.has(commandName)}
                       onChange={toggleCheckboxForSingleUse}
                       onChange={toggleCheckboxForSingleUse}
                     />
                     />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
                       {commandName}
                       {commandName}
                     </label>
                     </label>
                   </div>
                   </div>

+ 36 - 15
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -8,12 +8,15 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 
 
-const logger = loggerFactory('growi:SlackBotSettings');
+const logger = loggerFactory('growi:cli:SlackIntegration:OfficialBotSettings');
 
 
 const OfficialBotSettings = (props) => {
 const OfficialBotSettings = (props) => {
   const {
   const {
-    appContainer, slackAppIntegrations, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations,
+    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
+    connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   } = props;
   const [siteName, setSiteName] = useState('');
   const [siteName, setSiteName] = useState('');
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
@@ -25,17 +28,36 @@ const OfficialBotSettings = (props) => {
     }
     }
   };
   };
 
 
+  const isPrimaryChangedHandler = useCallback(async(slackIntegrationToChange, newValue) => {
+    // do nothing when turning off
+    if (!newValue) {
+      return;
+    }
+
+    try {
+      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
+      if (onPrimaryUpdated != null) {
+        onPrimaryUpdated();
+      }
+      toastSuccess(t('toaster.update_successed', { target: 'Primary' }));
+    }
+    catch (err) {
+      toastError(err, 'Failed to change isPrimary');
+      logger.error('Failed to change isPrimary', err);
+    }
+  }, [appContainer.apiv3, t, onPrimaryUpdated]);
+
   const deleteSlackAppIntegrationHandler = async() => {
   const deleteSlackAppIntegrationHandler = async() => {
-    await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
+    await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
     try {
     try {
       if (props.onDeleteSlackAppIntegration != null) {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
         props.onDeleteSlackAppIntegration();
       }
       }
-      toastSuccess(t('toaster.delete_slack_integration_procedure'));
+      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
     }
     }
     catch (err) {
     catch (err) {
-      toastError(err);
-      logger.error(err);
+      toastError('Failed to delete');
+      logger.error('Failed to delete', err);
     }
     }
   };
   };
 
 
@@ -79,14 +101,12 @@ const OfficialBotSettings = (props) => {
                 <h2 id={_id || `settings-accordions-${i}`}>
                 <h2 id={_id || `settings-accordions-${i}`}>
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                 </h2>
                 </h2>
-                <button
-                  className="btn btn-outline-danger"
-                  type="button"
-                  onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
-                >
-                  <i className="icon-trash mr-1" />
-                  {t('admin:slack_integration.delete')}
-                </button>
+                <SlackAppIntegrationControl
+                  slackAppIntegration={slackAppIntegration}
+                  onIsPrimaryChanged={isPrimaryChangedHandler}
+                  // set state to open DeleteSlackBotSettingsModal
+                  onDeleteButtonClicked={saiToDelete => setIntegrationIdToDelete(saiToDelete._id)}
+                />
               </div>
               </div>
               <WithProxyAccordions
               <WithProxyAccordions
                 botType="officialBot"
                 botType="officialBot"
@@ -133,6 +153,7 @@ OfficialBotSettings.propTypes = {
 
 
   slackAppIntegrations: PropTypes.array,
   slackAppIntegrations: PropTypes.array,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onPrimaryUpdated: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
   onUpdateTokens: PropTypes.func,
   onUpdateTokens: PropTypes.func,

+ 53 - 0
packages/app/src/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx

@@ -0,0 +1,53 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  slackAppIntegration: {
+    _id: string,
+    isPrimary?: boolean,
+  },
+  onIsPrimaryChanged?: (slackAppIntegration: unknown, newValue: boolean) => void,
+  onDeleteButtonClicked?: (slackAppIntegration: unknown) => void,
+}
+
+export const SlackAppIntegrationControl: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const { slackAppIntegration, onIsPrimaryChanged, onDeleteButtonClicked } = props;
+  const inputId = `cb-primary-${slackAppIntegration._id}`;
+  const isPrimary = slackAppIntegration.isPrimary === true;
+
+  return (
+    <div className="d-flex align-items-center">
+      <div className="my-1 custom-control custom-switch">
+        <input
+          className="custom-control-input"
+          id={inputId}
+          type="checkbox"
+          checked={isPrimary}
+          disabled={isPrimary}
+          onChange={(e) => {
+            if (onIsPrimaryChanged != null) {
+              onIsPrimaryChanged(slackAppIntegration, e.target.checked);
+            }
+          }}
+        />
+        <label className="custom-control-label" htmlFor={inputId}>
+          Primary
+        </label>
+      </div>
+      <button
+        className="btn btn-outline-danger ml-3"
+        type="button"
+        onClick={() => {
+          if (onDeleteButtonClicked != null) {
+            onDeleteButtonClicked(slackAppIntegration);
+          }
+        }}
+      >
+        <i className="icon-trash mr-1" />
+        {t('admin:slack_integration.delete')}
+      </button>
+    </div>
+  );
+};

+ 3 - 1
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -72,7 +72,7 @@ const SlackIntegration = (props) => {
 
 
   const createSlackIntegrationData = async() => {
   const createSlackIntegrationData = async() => {
     try {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/slack-app-integrations');
+      await appContainer.apiv3.post('/slack-integration-settings/slack-app-integrations');
       fetchSlackIntegrationData();
       fetchSlackIntegrationData();
       toastSuccess(t('admin:slack_integration.adding_slack_ws_integration_settings_successful'));
       toastSuccess(t('admin:slack_integration.adding_slack_ws_integration_settings_successful'));
     }
     }
@@ -130,6 +130,7 @@ const SlackIntegration = (props) => {
         <OfficialBotSettings
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onPrimaryUpdated={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           connectionStatuses={connectionStatuses}
           onUpdateTokens={fetchSlackIntegrationData}
           onUpdateTokens={fetchSlackIntegrationData}
@@ -156,6 +157,7 @@ const SlackIntegration = (props) => {
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}
           proxyServerUri={proxyServerUri}
           proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onPrimaryUpdated={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           connectionStatuses={connectionStatuses}
           onUpdateTokens={fetchSlackIntegrationData}
           onUpdateTokens={fetchSlackIntegrationData}

+ 41 - 31
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -86,17 +86,27 @@ const RegisteringProxyUrlProcess = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
     <div className="container w-75 py-5">
     <div className="container w-75 py-5">
-      <p
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.copy_proxy_url') }}
-      />
-      <img className="mb-5 border border-light img-fluid" src="/images/slack-integration/growi-register-sentence.png" />
-      <span
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_proxy_url_and_update') }}
-      />
-      <p className="text-danger">{t('admin:slack_integration.accordion.dont_need_update')}</p>
-      <img className="mb-3 border border-light img-fluid" src="/images/slack-integration/growi-set-proxy-url.png" />
+      <ol>
+        <li>
+          <p
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.copy_proxy_url') }}
+          />
+          <p>
+            <img className="border border-light img-fluid" src="/images/slack-integration/growi-register-sentence.png" />
+          </p>
+        </li>
+        <li>
+          <p
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_proxy_url_and_update') }}
+          />
+          <p>
+            <img className="border border-light img-fluid" src="/images/slack-integration/growi-set-proxy-url.png" />
+          </p>
+          <p className="text-danger">{t('admin:slack_integration.accordion.dont_need_update')}</p>
+        </li>
+      </ol>
     </div>
     </div>
   );
   );
 };
 };
@@ -107,7 +117,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 
 
   const regenerateTokensHandler = async() => {
   const regenerateTokensHandler = async() => {
     try {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/regenerate-tokens', { slackAppIntegrationId });
+      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/regenerate-tokens`);
       if (props.onUpdateTokens != null) {
       if (props.onUpdateTokens != null) {
         props.onUpdateTokens();
         props.onUpdateTokens();
       }
       }
@@ -215,7 +225,7 @@ const TestProcess = ({
   const submitForm = async(e) => {
   const submitForm = async(e) => {
     e.preventDefault();
     e.preventDefault();
     try {
     try {
-      await apiv3Post('/slack-integration-settings/with-proxy/relation-test', { slackAppIntegrationId, channel: testChannel });
+      await apiv3Post(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/relation-test`, { channel: testChannel });
       const newLogs = addLogs(logsValue, successMessage, null);
       const newLogs = addLogs(logsValue, successMessage, null);
       setLogsValue(newLogs);
       setLogsValue(newLogs);
 
 
@@ -309,15 +319,6 @@ const WithProxyAccordions = (props) => {
       />,
       />,
     },
     },
     '③': {
     '③': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
-    '④': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -327,6 +328,15 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
       />,
     },
     },
+    '④': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
   };
   };
 
 
   const CustomBotIntegrationProcedure = {
   const CustomBotIntegrationProcedure = {
@@ -353,15 +363,6 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
       content: <RegisteringProxyUrlProcess />,
     },
     },
     '⑤': {
     '⑤': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
-    '⑥': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -371,6 +372,15 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
       />,
     },
     },
+    '⑥': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
   };
   };
 
 
   const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
   const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;

+ 2 - 2
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -62,7 +62,7 @@ class CommentEditor extends React.Component {
       isUploadable,
       isUploadable,
       isUploadableFile,
       isUploadableFile,
       errorMessage: undefined,
       errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
+      isSlackConfigured: config.isSlackConfigured,
     };
     };
 
 
     this.updateState = this.updateState.bind(this);
     this.updateState = this.updateState.bind(this);
@@ -354,7 +354,7 @@ class CommentEditor extends React.Component {
             <span className="flex-grow-1" />
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
             <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
 
 
-            { this.state.hasSlackConfig
+            { this.state.isSlackConfigured
               && (
               && (
                 <div className="form-inline align-self-center mr-md-2">
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
                   <SlackNotification

+ 10 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -801,6 +801,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
       >
         <EditorIcon icon="CheckList" />
         <EditorIcon icon="CheckList" />
       </Button>,
       </Button>,
+      <Button
+        key="nav-item-attachment"
+        color={null}
+        size="sm"
+        title="Attachment"
+        onClick={this.props.onAddAttachmentButtonClicked}
+      >
+        <EditorIcon icon="Attachment" />
+      </Button>,
       <Button
       <Button
         key="nav-item-link"
         key="nav-item-link"
         color={null}
         color={null}
@@ -947,6 +956,7 @@ CodeMirrorEditor.propTypes = Object.assign({
   emojiStrategy: PropTypes.object,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
   lineNumbers: PropTypes.bool,
   onMarkdownHelpButtonClicked: PropTypes.func,
   onMarkdownHelpButtonClicked: PropTypes.func,
+  onAddAttachmentButtonClicked: PropTypes.func,
 }, AbstractEditor.propTypes);
 }, AbstractEditor.propTypes);
 CodeMirrorEditor.defaultProps = {
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
   lineNumbers: true,

+ 7 - 1
packages/app/src/components/PageEditor/Editor.jsx

@@ -39,6 +39,7 @@ export default class Editor extends AbstractEditor {
     this.dropHandler = this.dropHandler.bind(this);
     this.dropHandler = this.dropHandler.bind(this);
 
 
     this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
     this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
+    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
 
 
     this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
@@ -187,6 +188,10 @@ export default class Editor extends AbstractEditor {
     this.setState({ isCheatsheetModalShown: true });
     this.setState({ isCheatsheetModalShown: true });
   }
   }
 
 
+  addAttachmentHandler() {
+    this.dropzone.open();
+  }
+
   getDropzoneClassName(isDragAccept, isDragReject) {
   getDropzoneClassName(isDragAccept, isDragReject) {
     let className = 'dropzone';
     let className = 'dropzone';
     if (!this.props.isUploadable) {
     if (!this.props.isUploadable) {
@@ -314,6 +319,7 @@ export default class Editor extends AbstractEditor {
                         onPasteFiles={this.pasteFilesHandler}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
                         onDragEnter={this.dragEnterHandler}
                         onMarkdownHelpButtonClicked={this.showMarkdownHelp}
                         onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
                         {...this.props}
                         {...this.props}
                       />
                       />
                     )}
                     )}
@@ -341,7 +347,7 @@ export default class Editor extends AbstractEditor {
             <button
             <button
               type="button"
               type="button"
               className="btn btn-outline-secondary btn-block btn-open-dropzone"
               className="btn btn-outline-secondary btn-block btn-open-dropzone"
-              onClick={() => { this.dropzone.open() }}
+              onClick={this.addAttachmentHandler}
             >
             >
               <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
               <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
               Attach files
               Attach files

+ 7 - 9
packages/app/src/components/PageEditor/EditorIcon.jsx

@@ -118,15 +118,13 @@ const EditorIcon = (props) => {
           <path d="M22.12,17H19.75l-3.12-4H18a1,1,0,0,0,1-1V8a1,1,0,0,0-1-1H12a1,1,0,0,0-1,1v4a1,1,0,0,0,1,1h1.38l-2.92,4H7.88A.94.94,0,0,0,7,18v4a.94.94,0,0,0,.88,1h5.24A.94.94,0,0,0,14,22V18a.94.94,0,0,0-.88-1H11.63l3.13-4h.47l3.13,4H16.88A.94.94,0,0,0,16,18v4a.94.94,0,0,0,.88,1h5.24A.94.94,0,0,0,23,22V18A.94.94,0,0,0,22.12,17ZM13,22H8V18h5ZM12,8h6v4H12ZM22,22H17V18h5Z" />
           <path d="M22.12,17H19.75l-3.12-4H18a1,1,0,0,0,1-1V8a1,1,0,0,0-1-1H12a1,1,0,0,0-1,1v4a1,1,0,0,0,1,1h1.38l-2.92,4H7.88A.94.94,0,0,0,7,18v4a.94.94,0,0,0,.88,1h5.24A.94.94,0,0,0,14,22V18a.94.94,0,0,0-.88-1H11.63l3.13-4h.47l3.13,4H16.88A.94.94,0,0,0,16,18v4a.94.94,0,0,0,.88,1h5.24A.94.94,0,0,0,23,22V18A.94.94,0,0,0,22.12,17ZM13,22H8V18h5ZM12,8h6v4H12ZM22,22H17V18h5Z" />
         </svg>
         </svg>
       );
       );
-    // Unused icon
-    // case 'attachment':
-    //   return (
-    //     <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">
-    //       <rect fillOpacity="0" width="30" height="30" />
-    //       <path d="M9.71,22.5a2.57,2.57,0,0,1-1.85-.79,2.79,2.79,0,0,1,0-4l9-9.23a3.21,3.21,0,0,1,1.59-.87,3.39,3.39,0,0,1,1.81.1,4.38,4.38,0,0,1,1.7,1.05,4.15,4.15,0,0,1,.46.56,3.73,3.73,0,0,1,.35.65,4.25,4.25,0,0,1,.2.72,3.91,3.91,0,0,1,.07.76,3.71,3.71,0,0,1-1.12,2.67l-6.79,7a.48.48,0,0,1-.34.16.51.51,0,0,1-.35-.13.48.48,0,0,1,0-.7l6.78-7a2.8,2.8,0,0,0,.84-2,2.58,2.58,0,0,0-.79-2,3.63,3.63,0,0,0-1.11-.75,2.41,2.41,0,0,0-1.31-.17,2.19,2.19,0,0,0-1.25.62l-9,9.22A1.8,1.8,0,0,0,8,19.69,1.78,1.78,0,0,0,8.58,21a1.81,1.81,0,0,0,.57.39,1.48,1.48,0,0,0,.66.1,2,2,0,0,0,1.28-.62l7.12-7.35.15-.16a1.15,1.15,0,0,0,.15-.2.9.9,0,0,0,.12-.24,1.17,1.17,0,0,0,.07-.25.52.52,0,0,0-.05-.27.75.75,0,0,0-.19-.26.73.73,0,0,0-.58-.27,1.29,1.29,0,0,0-.67.38l-5.36,5.53a.5.5,0,0,1-.22.13.46.46,0,0,1-.26,0,.48.48,0,0,1-.22-.12A.41.41,0,0,1,11,17.5a.5.5,0,0,1,.14-.35L16.5,11.6a2.19,2.19,0,0,1,1.29-.67,1.69,1.69,0,0,1,1.37.55,1.54,1.54,0,0,1,.53,1.31,2.26,2.26,0,0,1-.76,1.42L11.8,21.58a3.06,3.06,0,0,1-2,.91H9.71Z" />
-    //     </svg>
-    // );
-
+    case 'Attachment':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">
+          <rect fillOpacity="0" width="30" height="30" />
+          <path d="M9.71,22.5a2.57,2.57,0,0,1-1.85-.79,2.79,2.79,0,0,1,0-4l9-9.23a3.21,3.21,0,0,1,1.59-.87,3.39,3.39,0,0,1,1.81.1,4.38,4.38,0,0,1,1.7,1.05,4.15,4.15,0,0,1,.46.56,3.73,3.73,0,0,1,.35.65,4.25,4.25,0,0,1,.2.72,3.91,3.91,0,0,1,.07.76,3.71,3.71,0,0,1-1.12,2.67l-6.79,7a.48.48,0,0,1-.34.16.51.51,0,0,1-.35-.13.48.48,0,0,1,0-.7l6.78-7a2.8,2.8,0,0,0,.84-2,2.58,2.58,0,0,0-.79-2,3.63,3.63,0,0,0-1.11-.75,2.41,2.41,0,0,0-1.31-.17,2.19,2.19,0,0,0-1.25.62l-9,9.22A1.8,1.8,0,0,0,8,19.69,1.78,1.78,0,0,0,8.58,21a1.81,1.81,0,0,0,.57.39,1.48,1.48,0,0,0,.66.1,2,2,0,0,0,1.28-.62l7.12-7.35.15-.16a1.15,1.15,0,0,0,.15-.2.9.9,0,0,0,.12-.24,1.17,1.17,0,0,0,.07-.25.52.52,0,0,0-.05-.27.75.75,0,0,0-.19-.26.73.73,0,0,0-.58-.27,1.29,1.29,0,0,0-.67.38l-5.36,5.53a.5.5,0,0,1-.22.13.46.46,0,0,1-.26,0,.48.48,0,0,1-.22-.12A.41.41,0,0,1,11,17.5a.5.5,0,0,1,.14-.35L16.5,11.6a2.19,2.19,0,0,1,1.29-.67,1.69,1.69,0,0,1,1.37.55,1.54,1.54,0,0,1,.53,1.31,2.26,2.26,0,0,1-.76,1.42L11.8,21.58a3.06,3.06,0,0,1-2,.91H9.71Z" />
+        </svg>
+      );
   }
   }
 
 
 
 

+ 3 - 3
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx

@@ -19,7 +19,7 @@ const EditorNavbarBottom = (props) => {
   const [isExpanded, setExpanded] = useState(false);
   const [isExpanded, setExpanded] = useState(false);
 
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const [isSlackExpanded, setSlackExpanded] = useState(false);
-  const hasSlackConfig = props.appContainer.getConfig().hasSlackConfig;
+  const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
 
   const {
   const {
     navigationContainer,
     navigationContainer,
@@ -61,7 +61,7 @@ const EditorNavbarBottom = (props) => {
   return (
   return (
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
       {/* Collapsed SlackNotification */}
       {/* Collapsed SlackNotification */}
-      {hasSlackConfig && (
+      {isSlackConfigured && (
         <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
         <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
             <SlackNotification
             <SlackNotification
@@ -84,7 +84,7 @@ const EditorNavbarBottom = (props) => {
         <form className="form-inline flex-nowrap ml-auto">
         <form className="form-inline flex-nowrap ml-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
           {/* Button or the normal Slack banner */}
-          {hasSlackConfig && (isDeviceSmallerThanMd ? (
+          {isSlackConfigured && (isDeviceSmallerThanMd ? (
             <Button
             <Button
               className="grw-btn-slack border mr-2"
               className="grw-btn-slack border mr-2"
               onClick={() => (setSlackExpanded(!isSlackExpanded))}
               onClick={() => (setSlackExpanded(!isSlackExpanded))}

+ 6 - 1
packages/app/src/server/crowi/express-init.js

@@ -53,7 +53,12 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
       nsSeparator: '::',
     });
     });
 
 
-  app.use(helmet());
+  app.use(helmet({
+    contentSecurityPolicy: false,
+    expectCt: false,
+    referrerPolicy: false,
+    permittedCrossDomainPolicies: false,
+  }));
 
 
   app.use((req, res, next) => {
   app.use((req, res, next) => {
     const now = new Date();
     const now = new Date();

+ 10 - 45
packages/app/src/server/crowi/index.js

@@ -13,8 +13,11 @@ import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import ConfigManager from '../service/config-manager';
 import ConfigManager from '../service/config-manager';
+import AppService from '../service/app';
 import AclService from '../service/acl';
 import AclService from '../service/acl';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
+import { SlackIntegrationService } from '../service/slack-integration';
+import { UserNotificationService } from '../service/user-notification';
 
 
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -45,7 +48,6 @@ function Crowi() {
   this.passportService = null;
   this.passportService = null;
   this.globalNotificationService = null;
   this.globalNotificationService = null;
   this.userNotificationService = null;
   this.userNotificationService = null;
-  this.slackNotificationService = null;
   this.xssService = null;
   this.xssService = null;
   this.aclService = null;
   this.aclService = null;
   this.appService = null;
   this.appService = null;
@@ -60,7 +62,7 @@ function Crowi() {
   this.syncPageStatusService = null;
   this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.interceptorManager = new InterceptorManager();
-  this.slackBotService = null;
+  this.slackIntegrationService = null;
   this.xss = new Xss();
   this.xss = new Xss();
 
 
   this.tokens = null;
   this.tokens = null;
@@ -93,12 +95,10 @@ Crowi.prototype.init = async function() {
 
 
   // customizeService depends on AppService and XssService
   // customizeService depends on AppService and XssService
   // passportService depends on appService
   // passportService depends on appService
-  // slack depends on setUpSlacklNotification
   // export and import depends on setUpGrowiBridge
   // export and import depends on setUpGrowiBridge
   await Promise.all([
   await Promise.all([
     this.setUpApp(),
     this.setUpApp(),
     this.setUpXss(),
     this.setUpXss(),
-    this.setUpSlacklNotification(),
     this.setUpGrowiBridge(),
     this.setUpGrowiBridge(),
   ]);
   ]);
 
 
@@ -107,8 +107,7 @@ Crowi.prototype.init = async function() {
     this.setupPassport(),
     this.setupPassport(),
     this.setupSearcher(),
     this.setupSearcher(),
     this.setupMailer(),
     this.setupMailer(),
-    this.setupSlack(),
-    this.setupSlackLegacy(),
+    this.setupSlackIntegrationService(),
     this.setupCsrf(),
     this.setupCsrf(),
     this.setUpFileUpload(),
     this.setUpFileUpload(),
     this.setUpFileUploaderSwitchService(),
     this.setUpFileUploaderSwitchService(),
@@ -121,7 +120,6 @@ Crowi.prototype.init = async function() {
     this.setupImport(),
     this.setupImport(),
     this.setupPageService(),
     this.setupPageService(),
     this.setupSyncPageStatusService(),
     this.setupSyncPageStatusService(),
-    this.setupSlackBotService(),
   ]);
   ]);
 
 
   // globalNotification depends on slack and mailer
   // globalNotification depends on slack and mailer
@@ -137,11 +135,9 @@ Crowi.prototype.initForTest = async function() {
 
 
   // // customizeService depends on AppService and XssService
   // // customizeService depends on AppService and XssService
   // // passportService depends on appService
   // // passportService depends on appService
-  // // slack depends on setUpSlacklNotification
   await Promise.all([
   await Promise.all([
     this.setUpApp(),
     this.setUpApp(),
     this.setUpXss(),
     this.setUpXss(),
-    // this.setUpSlacklNotification(),
     // this.setUpGrowiBridge(),
     // this.setUpGrowiBridge(),
   ]);
   ]);
 
 
@@ -150,7 +146,7 @@ Crowi.prototype.initForTest = async function() {
     this.setupPassport(),
     this.setupPassport(),
     // this.setupSearcher(),
     // this.setupSearcher(),
     // this.setupMailer(),
     // this.setupMailer(),
-    // this.setupSlack(),
+    // this.setupSlackIntegrationService(),
     // this.setupCsrf(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
     // this.setUpFileUpload(),
     this.setupAttachmentService(),
     this.setupAttachmentService(),
@@ -383,24 +379,6 @@ Crowi.prototype.setupMailer = async function() {
   }
   }
 };
 };
 
 
-Crowi.prototype.setupSlack = async function() {
-  const self = this;
-
-  return new Promise(((resolve, reject) => {
-    self.slack = require('../util/slack')(self);
-    resolve();
-  }));
-};
-
-Crowi.prototype.setupSlackLegacy = async function() {
-  const self = this;
-
-  return new Promise(((resolve, reject) => {
-    self.slackLegacy = require('../util/slack-legacy')(self);
-    resolve();
-  }));
-};
-
 Crowi.prototype.setupCsrf = async function() {
 Crowi.prototype.setupCsrf = async function() {
   const Tokens = require('csrf');
   const Tokens = require('csrf');
   this.tokens = new Tokens();
   this.tokens = new Tokens();
@@ -525,22 +503,11 @@ Crowi.prototype.setUpGlobalNotification = async function() {
  * setup UserNotificationService
  * setup UserNotificationService
  */
  */
 Crowi.prototype.setUpUserNotification = async function() {
 Crowi.prototype.setUpUserNotification = async function() {
-  const UserNotificationService = require('../service/user-notification');
   if (this.userNotificationService == null) {
   if (this.userNotificationService == null) {
     this.userNotificationService = new UserNotificationService(this);
     this.userNotificationService = new UserNotificationService(this);
   }
   }
 };
 };
 
 
-/**
- * setup SlackNotificationService
- */
-Crowi.prototype.setUpSlacklNotification = async function() {
-  const SlackNotificationService = require('../service/slack-notification');
-  if (this.slackNotificationService == null) {
-    this.slackNotificationService = new SlackNotificationService(this.configManager);
-  }
-};
-
 /**
 /**
  * setup XssService
  * setup XssService
  */
  */
@@ -581,7 +548,6 @@ Crowi.prototype.setUpCustomize = async function() {
  * setup AppService
  * setup AppService
  */
  */
 Crowi.prototype.setUpApp = async function() {
 Crowi.prototype.setUpApp = async function() {
-  const AppService = require('../service/app');
   if (this.appService == null) {
   if (this.appService == null) {
     this.appService = new AppService(this);
     this.appService = new AppService(this);
 
 
@@ -681,15 +647,14 @@ Crowi.prototype.setupSyncPageStatusService = async function() {
   }
   }
 };
 };
 
 
-Crowi.prototype.setupSlackBotService = async function() {
-  const SlackBotService = require('../service/slackbot');
-  if (this.slackBotService == null) {
-    this.slackBotService = new SlackBotService(this);
+Crowi.prototype.setupSlackIntegrationService = async function() {
+  if (this.slackIntegrationService == null) {
+    this.slackIntegrationService = new SlackIntegrationService(this);
   }
   }
 
 
   // add as a message handler
   // add as a message handler
   if (this.s2sMessagingService != null) {
   if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.slackBotService);
+    this.s2sMessagingService.addMessageHandler(this.slackIntegrationService);
   }
   }
 };
 };
 
 

+ 1 - 1
packages/app/src/server/models/config.ts

@@ -216,7 +216,7 @@ schema.statics.getLocalconfig = function(crowi) {
     isSavedStatesOfTabChanges: crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
     isSavedStatesOfTabChanges: crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
     isEnabledAttachTitleHeader: crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
     isEnabledAttachTitleHeader: crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
     customizeScript: crowi.configManager.getConfig('crowi', 'customize:script'),
     customizeScript: crowi.configManager.getConfig('crowi', 'customize:script'),
-    hasSlackConfig: crowi.slackNotificationService.hasSlackConfig(),
+    isSlackConfigured: crowi.slackIntegrationService.isSlackConfigured,
     env: {
     env: {
       PLANTUML_URI: env.PLANTUML_URI || null,
       PLANTUML_URI: env.PLANTUML_URI || null,
       BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
       BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,

+ 0 - 1
packages/app/src/server/models/index.js

@@ -12,7 +12,6 @@ module.exports = {
   Bookmark: require('./bookmark'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),
   Attachment: require('./attachment'),
-  UpdatePost: require('./updatePost'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),

+ 5 - 0
packages/app/src/server/models/page.js

@@ -831,6 +831,11 @@ module.exports = function(crowi) {
    */
    */
   pageSchema.statics.addConditionToFilteringByViewerForList = addConditionToFilteringByViewerForList;
   pageSchema.statics.addConditionToFilteringByViewerForList = addConditionToFilteringByViewerForList;
 
 
+  /**
+   * export addConditionToFilteringByViewerToEdit as static method
+   */
+  pageSchema.statics.addConditionToFilteringByViewerToEdit = addConditionToFilteringByViewerToEdit;
+
   /**
   /**
    * Throw error for growi-lsx-plugin (v1.x)
    * Throw error for growi-lsx-plugin (v1.x)
    */
    */

+ 1 - 0
packages/app/src/server/models/slack-app-integration.js

@@ -4,6 +4,7 @@ const mongoose = require('mongoose');
 const schema = new mongoose.Schema({
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  isPrimary: { type: Boolean, unique: true, sparse: true },
   supportedCommandsForBroadcastUse: { type: [String], default: [] },
   supportedCommandsForBroadcastUse: { type: [String], default: [] },
   supportedCommandsForSingleUse: { type: [String], default: [] },
   supportedCommandsForSingleUse: { type: [String], default: [] },
 });
 });

+ 122 - 0
packages/app/src/server/models/update-post.ts

@@ -0,0 +1,122 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import {
+  Types, Schema, Model, Document,
+} from 'mongoose';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+export interface IUpdatePost {
+  pathPattern: string
+  patternPrefix: string
+  patternPrefix2: string
+  channel: string
+  provider: string
+  creator: Schema.Types.ObjectId
+  createdAt: Date
+}
+
+export interface UpdatePostDocument extends IUpdatePost, Document {}
+
+export interface UpdatePostModel extends Model<UpdatePostDocument> {
+  normalizeChannelName(channel): any
+  createPrefixesByPathPattern(pathPattern): any
+  getRegExpByPattern(pattern): any
+  findSettingsByPath(path): Promise<UpdatePostDocument[]>
+  findAll(offset?: number): Promise<UpdatePostDocument[]>
+  createUpdatePost(pathPattern: string, channel: string, creator: Types.ObjectId): Promise<UpdatePostDocument>
+}
+
+/**
+ * This is the setting for notify to 3rd party tool (like Slack).
+ */
+const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>({
+  pathPattern: { type: String, required: true },
+  patternPrefix: { type: String, required: true },
+  patternPrefix2: { type: String, required: true },
+  channel: { type: String, required: true },
+  provider: { type: String, required: true },
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  createdAt: { type: Date, default: Date.now },
+});
+
+updatePostSchema.statics.normalizeChannelName = function(channel) {
+  return channel.replace(/(#|,)/g, '');
+};
+
+updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern) {
+  const patternPrefix = ['*', '*'];
+
+  // not begin with slash
+  if (!pathPattern.match(/^\/.+/)) {
+    return patternPrefix;
+  }
+
+  const pattern = pathPattern.split('/');
+  pattern.shift();
+  if (pattern[0] && pattern[0] !== '*') {
+    patternPrefix[0] = pattern[0];
+  }
+
+  if (pattern[1] && pattern[1] !== '*') {
+    patternPrefix[1] = pattern[1];
+  }
+  return patternPrefix;
+};
+
+updatePostSchema.statics.getRegExpByPattern = function(pattern) {
+  let reg = pattern;
+  if (!reg.match(/^\/.*/)) {
+    reg = `/*${reg}*`;
+  }
+  reg = `^${reg}`;
+  reg = reg.replace(/\//g, '\\/');
+  reg = reg.replace(/(\*)/g, '.*');
+
+  return new RegExp(reg);
+};
+
+updatePostSchema.statics.findSettingsByPath = async function(path) {
+  const prefixes = this.createPrefixesByPathPattern(path);
+
+  const settings = await this.find({
+    $or: [
+      { patternPrefix: prefixes[0], patternPrefix2: prefixes[1] },
+      { patternPrefix: '*', patternPrefix2: '*' },
+      { patternPrefix: prefixes[0], patternPrefix2: '*' },
+      { patternPrefix: '*', patternPrefix2: prefixes[1] },
+    ],
+  });
+  if (settings.length <= 0) {
+    return settings;
+  }
+
+  const validSettings = settings.filter((setting) => {
+    const patternRegex = this.getRegExpByPattern(setting.pathPattern);
+    return patternRegex.test(path);
+  });
+
+  return validSettings;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+updatePostSchema.statics.findAll = function(offset = 0) {
+  return this.find().sort({ createdAt: 1 }).populate('creator').exec();
+};
+
+updatePostSchema.statics.createUpdatePost = async function(pathPattern, channel, creator) {
+  const provider = 'slack'; // now slack only
+
+  const prefixes = this.createPrefixesByPathPattern(pathPattern);
+
+  return this.create({
+    pathPattern,
+    patternPrefix: prefixes[0],
+    patternPrefix2: prefixes[1],
+    channel: this.normalizeChannelName(channel),
+    provider,
+    creator,
+    createdAt: Date.now(),
+  });
+};
+
+export default getOrCreateModel<UpdatePostDocument, UpdatePostModel>('UpdatePost', updatePostSchema);

+ 0 - 153
packages/app/src/server/models/updatePost.js

@@ -1,153 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-/**
- * This is the setting for notify to 3rd party tool (like Slack).
- */
-module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:updatePost');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
-
-  // TODO: slack 以外の対応
-  const updatePostSchema = new mongoose.Schema({
-    pathPattern: { type: String, required: true },
-    patternPrefix:  { type: String, required: true },
-    patternPrefix2: { type: String, required: true },
-    channel: { type: String, required: true },
-    provider: { type: String, required: true },
-    creator: { type: ObjectId, ref: 'User', index: true },
-    createdAt: { type: Date, default: Date.now },
-  });
-
-  updatePostSchema.statics.normalizeChannelName = function(channel) {
-    return channel.replace(/(#|,)/g, '');
-  };
-
-  updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern) {
-    const patternPrefix = ['*', '*'];
-
-    // not begin with slash
-    if (!pathPattern.match(/^\/.+/)) {
-      return patternPrefix;
-    }
-
-    const pattern = pathPattern.split('/');
-    pattern.shift();
-    if (pattern[0] && pattern[0] !== '*') {
-      patternPrefix[0] = pattern[0];
-    }
-
-    if (pattern[1] && pattern[1] !== '*') {
-      patternPrefix[1] = pattern[1];
-    }
-    return patternPrefix;
-  };
-
-  updatePostSchema.statics.getRegExpByPattern = function(pattern) {
-    let reg = pattern;
-    if (!reg.match(/^\/.*/)) {
-      reg = `/*${reg}*`;
-    }
-    reg = `^${reg}`;
-    reg = reg.replace(/\//g, '\\/');
-    reg = reg.replace(/(\*)/g, '.*');
-
-    return new RegExp(reg);
-  };
-
-  updatePostSchema.statics.findSettingsByPath = function(path) {
-    const UpdatePost = this;
-    const prefixes = UpdatePost.createPrefixesByPathPattern(path);
-
-    return new Promise(((resolve, reject) => {
-      UpdatePost.find({
-        $or: [
-          { patternPrefix: prefixes[0], patternPrefix2: prefixes[1] },
-          { patternPrefix: '*', patternPrefix2: '*' },
-          { patternPrefix: prefixes[0], patternPrefix2: '*' },
-          { patternPrefix: '*', patternPrefix2: prefixes[1] },
-        ],
-      })
-        .then((settings) => {
-          if (settings.length <= 0) {
-            return resolve(settings);
-          }
-
-          // eslint-disable-next-line no-param-reassign
-          settings = settings.filter((setting) => {
-            const patternRegex = UpdatePost.getRegExpByPattern(setting.pathPattern);
-            return patternRegex.test(path);
-          });
-
-          return resolve(settings);
-        });
-    }));
-  };
-
-  updatePostSchema.statics.findAll = function(offset) {
-    const UpdatePost = this;
-    // eslint-disable-next-line no-param-reassign
-    offset = offset || 0;
-
-    return new Promise(((resolve, reject) => {
-      UpdatePost
-        .find()
-        .sort({ createdAt: 1 })
-        .populate('creator')
-        .exec((err, data) => {
-          if (err) {
-            return reject(err);
-          }
-
-          if (data.length < 1) {
-            return resolve([]);
-          }
-
-          return resolve(data);
-        });
-    }));
-  };
-
-  updatePostSchema.statics.create = function(pathPattern, channel, user) {
-    const UpdatePost = this;
-    const provider = 'slack'; // now slack only
-
-    const prefixes = UpdatePost.createPrefixesByPathPattern(pathPattern);
-    const notif = new UpdatePost();
-    notif.pathPattern = pathPattern;
-    notif.patternPrefix = prefixes[0];
-    notif.patternPrefix2 = prefixes[1];
-    notif.channel = UpdatePost.normalizeChannelName(channel);
-    notif.provider = provider;
-    notif.creator = user;
-    notif.createdAt = Date.now();
-
-    return new Promise(((resolve, reject) => {
-      notif.save((err, doc) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(doc);
-      });
-    }));
-  };
-
-  updatePostSchema.statics.remove = function(id) {
-    const UpdatePost = this;
-
-    return new Promise(((resolve, reject) => {
-      UpdatePost.findOneAndRemove({ _id: id }, (err, data) => {
-        if (err) {
-          debug('UpdatePost.findOneAndRemove failed', err);
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    }));
-  };
-
-  return mongoose.model('UpdatePost', updatePostSchema);
-};

+ 2 - 2
packages/app/src/server/routes/admin.js

@@ -14,7 +14,7 @@ module.exports = function(crowi, app) {
   const {
   const {
     configManager,
     configManager,
     aclService,
     aclService,
-    slackNotificationService,
+    slackIntegrationService,
     exportService,
     exportService,
   } = crowi;
   } = crowi;
 
 
@@ -160,7 +160,7 @@ module.exports = function(crowi, app) {
     const code = req.query.code;
     const code = req.query.code;
     const { t } = req;
     const { t } = req;
 
 
-    if (!code || !slackNotificationService.hasSlackConfig()) {
+    if (!code || !slackIntegrationService.isSlackConfigured()) {
       return res.redirect('/admin/notification');
       return res.redirect('/admin/notification');
     }
     }
 
 

+ 2 - 2
packages/app/src/server/routes/apiv3/healthcheck.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 /**
 /**
@@ -122,7 +122,7 @@ module.exports = (crowi) => {
    *                  info:
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
    */
-  router.get('/', helmet.noCache(), async(req, res) => {
+  router.get('/', noCache(), async(req, res) => {
     let checkServices = req.query.checkServices || [];
     let checkServices = req.query.checkServices || [];
     let isStrictly = req.query.strictly != null;
     let isStrictly = req.query.strictly != null;
 
 

+ 1 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -48,6 +48,7 @@ module.exports = (crowi) => {
 
 
   router.use('/slack-integration', require('./slack-integration')(crowi));
   router.use('/slack-integration', require('./slack-integration')(crowi));
   router.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   router.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
+  router.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
 
 
   router.use('/forgot-password', require('./forgot-password')(crowi));
   router.use('/forgot-password', require('./forgot-password')(crowi));

+ 9 - 71
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 
+import UpdatePost from '../../models/update-post';
+
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 
 
@@ -13,11 +15,6 @@ const { body } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 const validator = {
 const validator = {
-  slackConfiguration: [
-    body('webhookUrl').if(value => value != null).isString().trim(),
-    body('isIncomingWebhookPrioritized').isBoolean(),
-    body('slackToken').if(value => value != null).isString().trim(),
-  ],
   userNotification: [
   userNotification: [
     body('pathPattern').isString().trim(),
     body('pathPattern').isString().trim(),
     body('channel').isString().trim(),
     body('channel').isString().trim(),
@@ -50,18 +47,6 @@ const validator = {
  *
  *
  *  components:
  *  components:
  *    schemas:
  *    schemas:
- *      SlackConfigurationParams:
- *        type: object
- *        properties:
- *          webhookUrl:
- *            type: string
- *            description: incoming webhooks url
- *          isIncomingWebhookPrioritized:
- *            type: boolean
- *            description: use incoming webhooks even if Slack App settings are enabled
- *          slackToken:
- *            type: string
- *            description: OAuth access token
  *      UserNotificationParams:
  *      UserNotificationParams:
  *        type: object
  *        type: object
  *        properties:
  *        properties:
@@ -107,7 +92,6 @@ module.exports = (crowi) => {
   const csrf = require('../../middlewares/csrf')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
-  const UpdatePost = crowi.model('UpdatePost');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
 
   const GlobalNotificationMailSetting = crowi.models.GlobalNotificationMailSetting;
   const GlobalNotificationMailSetting = crowi.models.GlobalNotificationMailSetting;
@@ -134,9 +118,11 @@ module.exports = (crowi) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
 
     const notificationParams = {
     const notificationParams = {
-      webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
-      isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
-      slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
+      // status of slack intagration
+      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
+      isSlackLegacyConfigured: crowi.slackIntegrationService.isSlackLegacyConfigured,
+      currentBotType: await crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
+
       userNotifications: await UpdatePost.findAll(),
       userNotifications: await UpdatePost.findAll(),
       isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification', 'notification:owner-page:isEnabled'),
       isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification', 'notification:owner-page:isEnabled'),
       isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification', 'notification:group-page:isEnabled'),
       isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification', 'notification:group-page:isEnabled'),
@@ -145,53 +131,6 @@ module.exports = (crowi) => {
     return res.apiv3({ notificationParams });
     return res.apiv3({ notificationParams });
   });
   });
 
 
-  /**
-   * @swagger
-   *
-   *    /notification-setting/slack-configuration:
-   *      put:
-   *        tags: [NotificationSetting]
-   *        description: Update slack configuration setting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/SlackConfigurationParams'
-   *        responses:
-   *          200:
-   *            description: Succeeded to update slack configuration setting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/SlackConfigurationParams'
-   */
-  router.put('/slack-configuration', loginRequiredStrictly, adminRequired, csrf, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
-
-    const requestParams = {
-      'slack:incomingWebhookUrl': req.body.webhookUrl,
-      'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
-      'slack:token': req.body.slackToken,
-    };
-
-    try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('notification', requestParams);
-      const responseParams = {
-        webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
-        isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
-        slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
-      };
-      await crowi.setupSlackLegacy();
-      return res.apiv3({ responseParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating slack configuration';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
-    }
-
-  });
-
   /**
   /**
   * @swagger
   * @swagger
   *
   *
@@ -221,12 +160,11 @@ module.exports = (crowi) => {
   */
   */
   router.post('/user-notification', loginRequiredStrictly, adminRequired, csrf, validator.userNotification, apiV3FormValidator, async(req, res) => {
   router.post('/user-notification', loginRequiredStrictly, adminRequired, csrf, validator.userNotification, apiV3FormValidator, async(req, res) => {
     const { pathPattern, channel } = req.body;
     const { pathPattern, channel } = req.body;
-    const UpdatePost = crowi.model('UpdatePost');
 
 
     try {
     try {
       logger.info('notification.add', pathPattern, channel);
       logger.info('notification.add', pathPattern, channel);
       const responseParams = {
       const responseParams = {
-        createdUser: await UpdatePost.create(pathPattern, channel, req.user),
+        createdUser: await UpdatePost.createUpdatePost(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
         userNotifications: await UpdatePost.findAll(),
       };
       };
       return res.apiv3({ responseParams }, 201);
       return res.apiv3({ responseParams }, 201);
@@ -268,7 +206,7 @@ module.exports = (crowi) => {
     const { id } = req.params;
     const { id } = req.params;
 
 
     try {
     try {
-      const deletedNotificaton = await UpdatePost.remove(id);
+      const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
       return res.apiv3(deletedNotificaton);
       return res.apiv3(deletedNotificaton);
     }
     }
     catch (err) {
     catch (err) {

+ 14 - 11
packages/app/src/server/routes/apiv3/page.js

@@ -185,7 +185,7 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/Page'
    *                  $ref: '#/components/schemas/Page'
    */
    */
   router.put('/likes', accessTokenParser, loginRequiredStrictly, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
   router.put('/likes', accessTokenParser, loginRequiredStrictly, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
-    const { pageId, bool } = req.body;
+    const { pageId, bool: isLiked } = req.body;
 
 
     let page;
     let page;
     try {
     try {
@@ -193,7 +193,8 @@ module.exports = (crowi) => {
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
       }
-      if (bool) {
+
+      if (isLiked) {
         page = await page.like(req.user);
         page = await page.like(req.user);
       }
       }
       else {
       else {
@@ -205,17 +206,19 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
       return res.apiv3Err(err, 500);
     }
     }
 
 
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
-    }
-    catch (err) {
-      logger.error('Like notification failed', err);
-    }
-
     const result = { page };
     const result = { page };
     result.seenUser = page.seenUsers;
     result.seenUser = page.seenUsers;
-    return res.apiv3({ result });
+    res.apiv3({ result });
+
+    if (isLiked) {
+      try {
+        // global notification
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
+      }
+      catch (err) {
+        logger.error('Like notification failed', err);
+      }
+    }
   });
   });
 
 
   /**
   /**

+ 2 - 2
packages/app/src/server/routes/apiv3/search.js

@@ -7,7 +7,7 @@ const { body } = require('express-validator');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -41,7 +41,7 @@ module.exports = (crowi) => {
    *                  info:
    *                  info:
    *                    type: object
    *                    type: object
    */
    */
-  router.get('/indices', helmet.noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/indices', noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
     const { searchService } = crowi;
     const { searchService } = crowi;
 
 
     if (!searchService.isConfigured) {
     if (!searchService.isConfigured) {

+ 128 - 0
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -0,0 +1,128 @@
+import loggerFactory from '~/utils/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator');
+
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const validator = {
+  slackConfiguration: [
+    body('webhookUrl').if(value => value != null).isString().trim(),
+    body('isIncomingWebhookPrioritized').isBoolean(),
+    body('slackToken').if(value => value != null).isString().trim(),
+  ],
+};
+
+/**
+ * @swagger
+ *  tags:
+ *    name: SlackIntegrationLegacySetting
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SlackConfigurationParams:
+ *        type: object
+ *        properties:
+ *          webhookUrl:
+ *            type: string
+ *            description: incoming webhooks url
+ *          isIncomingWebhookPrioritized:
+ *            type: boolean
+ *            description: use incoming webhooks even if Slack App settings are enabled
+ *          slackToken:
+ *            type: string
+ *            description: OAuth access token
+ */
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-legacy-setting/:
+   *      get:
+   *        tags: [SlackIntegrationLegacySetting]
+   *        description: Get slack configuration setting
+   *        responses:
+   *          200:
+   *            description: params of slack configuration setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    notificationParams:
+   *                      type: object
+   *                      description: slack configuration setting params
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    const slackIntegrationParams = {
+      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
+      webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
+      isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
+      slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
+    };
+    return res.apiv3({ slackIntegrationParams });
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-legacy-setting/:
+   *      put:
+   *        tags: [SlackIntegrationLegacySetting]
+   *        description: Update slack configuration setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/SlackConfigurationParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update slack configuration setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/SlackConfigurationParams'
+   */
+  router.put('/', loginRequiredStrictly, adminRequired, csrf, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
+
+    const requestParams = {
+      'slack:incomingWebhookUrl': req.body.webhookUrl,
+      'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
+      'slack:token': req.body.slackToken,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('notification', requestParams);
+      const responseParams = {
+        webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
+        isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
+        slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
+      };
+      return res.apiv3({ responseParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating slack configuration';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
+    }
+
+  });
+
+  return router;
+};

+ 193 - 160
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -7,7 +7,10 @@ const axios = require('axios');
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
 const {
 const {
-  getConnectionStatus, getConnectionStatuses, sendSuccessMessage, defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+  getConnectionStatus, getConnectionStatuses,
+  sendSuccessMessage,
+  defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+  REQUEST_TIMEOUT_FOR_GTOP,
 } = require('@growi/slack');
 } = require('@growi/slack');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
@@ -16,6 +19,8 @@ const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
+const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
+
 /**
 /**
  * @swagger
  * @swagger
  *  tags:
  *  tags:
@@ -51,10 +56,10 @@ module.exports = (crowi) => {
   const SlackAppIntegration = mongoose.model('SlackAppIntegration');
   const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
 
   const validator = {
   const validator = {
-    BotType: [
+    botType: [
       body('currentBotType').isString(),
       body('currentBotType').isString(),
     ],
     ],
-    SlackIntegration: [
+    slackIntegration: [
       body('currentBotType')
       body('currentBotType')
         .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
         .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
     ],
     ],
@@ -62,42 +67,52 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
         .isURL({ require_tld: false }),
     ],
     ],
+    makePrimary: [
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     updateSupportedCommands: [
     updateSupportedCommands: [
       body('supportedCommandsForSingleUse').toArray(),
       body('supportedCommandsForSingleUse').toArray(),
       body('supportedCommandsForBroadcastUse').toArray(),
       body('supportedCommandsForBroadcastUse').toArray(),
       param('id').isMongoId().withMessage('id is required'),
       param('id').isMongoId().withMessage('id is required'),
     ],
     ],
-    RelationTest: [
-      body('slackAppIntegrationId').isMongoId(),
+    relationTest: [
+      param('id').isMongoId(),
       body('channel').trim().isString(),
       body('channel').trim().isString(),
     ],
     ],
+    regenerateTokens: [
+      param('id').isMongoId(),
+    ],
     deleteIntegration: [
     deleteIntegration: [
-      query('integrationIdToDelete').isMongoId(),
+      param('id').isMongoId(),
     ],
     ],
-    SlackChannel: [
+    slackChannel: [
       body('channel').trim().not().isEmpty()
       body('channel').trim().not().isEmpty()
         .isString(),
         .isString(),
     ],
     ],
   };
   };
 
 
-  async function resetAllBotSettings() {
+  async function updateSlackBotSettings(params) {
+    const { configManager } = crowi;
+    // update config without publishing S2sMessage
+    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+  }
+
+  async function resetAllBotSettings(initializedType) {
     await SlackAppIntegration.deleteMany();
     await SlackAppIntegration.deleteMany();
 
 
     const params = {
     const params = {
-      'slackbot:currentBotType': null,
+      'slackbot:currentBotType': initializedType,
       'slackbot:signingSecret': null,
       'slackbot:signingSecret': null,
       'slackbot:token': null,
       'slackbot:token': null,
       'slackbot:proxyServerUri': null,
       'slackbot:proxyServerUri': null,
     };
     };
-    const { configManager } = crowi;
-    // update config without publishing S2sMessage
-    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
-  }
 
 
-  async function updateSlackBotSettings(params) {
-    const { configManager } = crowi;
-    // update config without publishing S2sMessage
-    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+    // set url if officialBot is specified
+    if (initializedType === 'officialBot') {
+      params['slackbot:proxyServerUri'] = OFFICIAL_SLACKBOT_PROXY_URI;
+    }
+
+    return updateSlackBotSettings(params);
   }
   }
 
 
   async function getConnectionStatusesFromProxy(tokens) {
   async function getConnectionStatusesFromProxy(tokens) {
@@ -107,6 +122,7 @@ module.exports = (crowi) => {
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
       headers: {
       headers: {
         'x-growi-gtop-tokens': csv,
         'x-growi-gtop-tokens': csv,
+        timeout: REQUEST_TIMEOUT_FOR_GTOP,
       },
       },
     });
     });
 
 
@@ -119,13 +135,22 @@ module.exports = (crowi) => {
       throw new Error('Proxy URL is not registered');
       throw new Error('Proxy URL is not registered');
     }
     }
 
 
-    const headers = {
-      'x-growi-gtop-tokens': token,
-    };
-
-    const result = await axios[method](urljoin(proxyUri, endpoint), body, { headers });
+    try {
+      const result = await axios[method](
+        urljoin(proxyUri, endpoint),
+        body, {
+          headers: {
+            'x-growi-gtop-tokens': token,
+          },
+          timeout: REQUEST_TIMEOUT_FOR_GTOP,
+        },
+      );
 
 
-    return result.data;
+      return result.data;
+    }
+    catch (err) {
+      throw new Error(`Requesting to proxy server failed: ${err.message}`);
+    }
   }
   }
 
 
   /**
   /**
@@ -206,7 +231,7 @@ module.exports = (crowi) => {
           });
           });
         }
         }
         catch (e) {
         catch (e) {
-          errorMsg = 'Incorrect Proxy URL';
+          errorMsg = 'Something went wrong when retrieving information from Proxy Server.';
           errorCode = 'test-connection-failed';
           errorCode = 'test-connection-failed';
           logger.error(errorMsg, e);
           logger.error(errorMsg, e);
         }
         }
@@ -218,48 +243,15 @@ module.exports = (crowi) => {
     });
     });
   });
   });
 
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration-settings/:
-   *      put:
-   *        tags: [SlackIntegration]
-   *        operationId: putSlackIntegration
-   *        summary: put /slack-integration
-   *        description: Put SlackIntegration setting.
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/SlackIntegration'
-   *        responses:
-   *           200:
-   *             description: Succeeded to put Slack Integration setting.
-   */
-  router.put('/', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.SlackIntegration, apiV3FormValidator, async(req, res) => {
-    const { currentBotType } = req.body;
-
-    const requestParams = {
-      'slackbot:currentBotType': currentBotType,
-    };
-
-    try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
 
 
-      const slackIntegrationSettingsParams = {
-        currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
-      };
-      return res.apiv3({ slackIntegrationSettingsParams });
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Slack bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-SlackIntegrationSetting-failed'), 500);
-    }
-  });
+  const handleBotTypeChanging = async(req, res, initializedBotType) => {
+    await resetAllBotSettings(initializedBotType);
+    crowi.slackIntegrationService.publishUpdatedMessage();
 
 
+    // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
+    const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
+    return res.apiv3({ slackBotTypeParam });
+  };
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -280,23 +272,15 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to put botType setting.
    *             description: Succeeded to put botType setting.
    */
    */
-  router.put('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.BotType, apiV3FormValidator, async(req, res) => {
+  router.put('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.botType, apiV3FormValidator, async(req, res) => {
     const { currentBotType } = req.body;
     const { currentBotType } = req.body;
 
 
-    await resetAllBotSettings();
-    const requestParams = { 'slackbot:currentBotType': currentBotType };
-
-    if (currentBotType === 'officialBot') {
-      requestParams['slackbot:proxyServerUri'] = 'https://slackbot-proxy.growi.org';
+    if (currentBotType == null) {
+      return res.apiv3Err(new ErrorV3('The param \'currentBotType\' must be specified.', 'update-CustomBotSetting-failed'), 400);
     }
     }
 
 
     try {
     try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
-
-      // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
-      const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
-      return res.apiv3({ slackBotTypeParam });
+      await handleBotTypeChanging(req, res, currentBotType);
     }
     }
     catch (error) {
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
       const msg = 'Error occured in updating Custom bot setting';
@@ -324,22 +308,13 @@ module.exports = (crowi) => {
    *             description: Succeeded to delete botType setting.
    *             description: Succeeded to delete botType setting.
    */
    */
   router.delete('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
   router.delete('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
-
-    await resetAllBotSettings();
-    const params = { 'slackbot:currentBotType': null };
-
     try {
     try {
-      await updateSlackBotSettings(params);
-      crowi.slackBotService.publishUpdatedMessage();
-
-      // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
-      const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
-      return res.apiv3({ slackBotTypeParam });
+      await handleBotTypeChanging(req, res, null);
     }
     }
     catch (error) {
     catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
+      const msg = 'Error occured in resetting all';
       logger.error('Error', error);
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'resetting-all-failed'), 500);
     }
     }
   });
   });
 
 
@@ -370,7 +345,7 @@ module.exports = (crowi) => {
     };
     };
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
+      crowi.slackIntegrationService.publishUpdatedMessage();
 
 
       const customBotWithoutProxySettingParams = {
       const customBotWithoutProxySettingParams = {
         slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
         slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
@@ -390,7 +365,7 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *    /slack-integration-settings/slack-app-integrations:
    *    /slack-integration-settings/slack-app-integrations:
-   *      put:
+   *      post:
    *        tags: [SlackIntegration]
    *        tags: [SlackIntegration]
    *        operationId: putSlackAppIntegrations
    *        operationId: putSlackAppIntegrations
    *        summary: /slack-integration
    *        summary: /slack-integration
@@ -399,19 +374,20 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to create slack app integration
    *            description: Succeeded to create slack app integration
    */
    */
-  router.put('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
-    if (SlackAppIntegrationRecordsNum >= 10) {
-      const msg = 'Not be able to create more than 10 slack workspace integration settings';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
-    }
-
+  router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
     try {
+      const count = await SlackAppIntegration.countDocuments();
+      if (count >= 10) {
+        const msg = 'Not be able to create more than 10 slack workspace integration settings';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+      }
+
       const slackAppTokens = await SlackAppIntegration.create({
       const slackAppTokens = await SlackAppIntegration.create({
         tokenGtoP,
         tokenGtoP,
         tokenPtoG,
         tokenPtoG,
+        isPrimary: count === 0 ? true : undefined,
         supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
         supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
         supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
         supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
       });
       });
@@ -427,37 +403,7 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /slack-integration-settings/regenerate-tokens:
-   *      put:
-   *        tags: [SlackIntegration]
-   *        operationId: putRegenerateTokens
-   *        summary: /slack-integration
-   *        description: Regenerate SlackAppTokens
-   *        responses:
-   *          200:
-   *            description: Succeeded to regenerate slack app tokens
-   */
-  router.put('/regenerate-tokens', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-
-    const { slackAppIntegrationId } = req.body;
-
-    try {
-      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-      const slackAppTokens = await SlackAppIntegration.findByIdAndUpdate(slackAppIntegrationId, { tokenGtoP, tokenPtoG });
-
-      return res.apiv3(slackAppTokens, 200);
-    }
-    catch (error) {
-      const msg = 'Error occurred during regenerating slack app tokens';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
-    }
-  });
-
-  /**
-   * @swagger
-   *
-   *    /slack-integration-settings/slack-app-integration:
+   *    /slack-integration-settings/slack-app-integrations/:id:
    *      delete:
    *      delete:
    *        tags: [SlackIntegration]
    *        tags: [SlackIntegration]
    *        operationId: deleteAccessTokens
    *        operationId: deleteAccessTokens
@@ -467,11 +413,19 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to delete access tokens for slack
    *            description: Succeeded to delete access tokens for slack
    */
    */
-  router.delete('/slack-app-integration', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
+  router.delete('/slack-app-integrations/:id', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-    const { integrationIdToDelete } = req.query;
+    const { id } = req.params;
+
     try {
     try {
-      const response = await SlackAppIntegration.findOneAndDelete({ _id: integrationIdToDelete });
+      const response = await SlackAppIntegration.findOneAndDelete({ _id: id });
+
+      // update primary
+      const countOfPrimary = await SlackAppIntegration.countDocuments({ isPrimary: true });
+      if (countOfPrimary === 0) {
+        await SlackAppIntegration.updateOne({}, { isPrimary: true });
+      }
+
       return res.apiv3({ response });
       return res.apiv3({ response });
     }
     }
     catch (error) {
     catch (error) {
@@ -488,13 +442,13 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
+      crowi.slackIntegrationService.publishUpdatedMessage();
       return res.apiv3({});
       return res.apiv3({});
     }
     }
     catch (error) {
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
       const msg = 'Error occured in updating Custom bot setting';
       logger.error('Error', error);
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-SlackAppIntegration-failed'), 500);
     }
     }
 
 
   });
   });
@@ -502,7 +456,83 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /slack-integration-settings/:id/supported-commands:
+   *    /slack-integration-settings/slack-app-integrations/:id/makeprimary:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: makePrimary
+   *        summary: /slack-integration
+   *        description: Make SlackAppTokens primary
+   *        responses:
+   *          200:
+   *            description: Succeeded to make it primary
+   */
+  // eslint-disable-next-line max-len
+  router.put('/slack-app-integrations/:id/make-primary', loginRequiredStrictly, adminRequired, csrf, validator.makePrimary, apiV3FormValidator, async(req, res) => {
+
+    const { id } = req.params;
+
+    try {
+      await SlackAppIntegration.bulkWrite([
+        // unset isPrimary for others
+        {
+          updateMany: {
+            filter: { _id: { $ne: id } },
+            update: { $unset: { isPrimary: '' } },
+          },
+        },
+        // set primary
+        {
+          updateOne: {
+            filter: { _id: id },
+            update: { isPrimary: true },
+          },
+        },
+      ]);
+
+      return res.apiv3();
+    }
+    catch (error) {
+      const msg = 'Error occurred during making SlackAppIntegration primary';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'making-primary-failed'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putRegenerateTokens
+   *        summary: /slack-integration
+   *        description: Regenerate SlackAppTokens
+   *        responses:
+   *          200:
+   *            description: Succeeded to regenerate slack app tokens
+   */
+  // eslint-disable-next-line max-len
+  router.put('/slack-app-integrations/:id/regenerate-tokens', loginRequiredStrictly, adminRequired, csrf, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
+
+    const { id } = req.params;
+
+    try {
+      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+      const slackAppTokens = await SlackAppIntegration.findByIdAndUpdate(id, { tokenGtoP, tokenPtoG });
+
+      return res.apiv3(slackAppTokens, 200);
+    }
+    catch (error) {
+      const msg = 'Error occurred during regenerating slack app tokens';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/slack-app-integrations/:id/supported-commands:
    *      put:
    *      put:
    *        tags: [SlackIntegration]
    *        tags: [SlackIntegration]
    *        operationId: putSupportedCommands
    *        operationId: putSupportedCommands
@@ -512,7 +542,8 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to update supported commands
    *            description: Succeeded to update supported commands
    */
    */
-  router.put('/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/slack-app-integrations/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
     const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
     const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
     const { id } = req.params;
     const { id } = req.params;
 
 
@@ -523,56 +554,58 @@ module.exports = (crowi) => {
         { new: true },
         { new: true },
       );
       );
 
 
-      await requestToProxyServer(
-        slackAppIntegration.tokenGtoP,
-        'put',
-        '/g2s/supported-commands',
-        {
-          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
-          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
-        },
-      );
+      const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+      if (proxyUri != null) {
+        await requestToProxyServer(
+          slackAppIntegration.tokenGtoP,
+          'put',
+          '/g2s/supported-commands',
+          {
+            supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+            supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+          },
+        );
+      }
 
 
       return res.apiv3({ slackAppIntegration });
       return res.apiv3({ slackAppIntegration });
     }
     }
     catch (error) {
     catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
+      const msg = `Error occured in updating settings. Cause: ${error.message}`;
       logger.error('Error', error);
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'update-supported-commands-failed'), 500);
     }
     }
   });
   });
 
 
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /slack-integration-settings/with-proxy/relation-test:
+   *    /slack-integration-settings/slack-app-integrations/:id/relation-test:
    *      post:
    *      post:
    *        tags: [botType]
    *        tags: [botType]
    *        operationId: postRelationTest
    *        operationId: postRelationTest
-   *        summary: /slack-integration/bot-type
+   *        summary: Test relation
    *        description: Delete botType setting.
    *        description: Delete botType setting.
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  slackAppIntegrationId:
-   *                    type: string
    *        responses:
    *        responses:
    *           200:
    *           200:
    *             description: Succeeded to delete botType setting.
    *             description: Succeeded to delete botType setting.
    */
    */
-  router.post('/with-proxy/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.RelationTest, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.relationTest, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType === 'customBotWithoutProxy') {
     if (currentBotType === 'customBotWithoutProxy') {
       const msg = 'Not Proxy Type';
       const msg = 'Not Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
     }
     }
 
 
-    const { slackAppIntegrationId } = req.body;
+    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    if (proxyUri == null) {
+      return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
+    }
+
+    const { id } = req.params;
     let slackBotToken;
     let slackBotToken;
     try {
     try {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: slackAppIntegrationId });
+      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: id });
       if (slackAppIntegration == null) {
       if (slackAppIntegration == null) {
         const msg = 'Could not find SlackAppIntegration by id';
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
@@ -631,7 +664,7 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to connect to slack work space.
    *             description: Succeeded to connect to slack work space.
    */
    */
-  router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.SlackChannel, apiV3FormValidator, async(req, res) => {
+  router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType !== 'customBotWithoutProxy') {
     if (currentBotType !== 'customBotWithoutProxy') {
       const msg = 'Select Without Proxy Type';
       const msg = 'Select Without Proxy Type';

+ 22 - 59
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -14,7 +14,7 @@ const { respondIfSlackbotError } = require('../../service/slack-command-handler/
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   this.app = crowi.express;
   this.app = crowi.express;
 
 
-  const { configManager } = crowi;
+  const { configManager, slackIntegrationService } = crowi;
 
 
   // Check if the access token is correct
   // Check if the access token is correct
   async function verifyAccessTokenFromProxy(req, res, next) {
   async function verifyAccessTokenFromProxy(req, res, next) {
@@ -98,32 +98,7 @@ module.exports = (crowi) => {
     return next();
     return next();
   };
   };
 
 
-  const generateClientForResponse = (tokenGtoP) => {
-    const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-
-    if (currentBotType == null) {
-      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
-    }
-
-    let token;
-
-    // connect directly
-    if (tokenGtoP == null) {
-      token = crowi.configManager.getConfig('crowi', 'slackbot:token');
-      return generateWebClient(token);
-    }
-
-    // connect to proxy
-    const proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
-    const serverUri = urljoin(proxyServerUri, '/g2s');
-    const headers = {
-      'x-growi-gtop-tokens': tokenGtoP,
-    };
-
-    return generateWebClient(token, serverUri, headers);
-  };
-
-  async function handleCommands(req, res) {
+  async function handleCommands(req, res, client) {
     const { body } = req;
     const { body } = req;
 
 
     if (body.text == null) {
     if (body.text == null) {
@@ -136,25 +111,17 @@ module.exports = (crowi) => {
 
 
     // Send response immediately to avoid opelation_timeout error
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Processing your request ...',
+    });
 
 
-    // generate client
-    let client;
-    if (tokenPtoG == null) {
-      client = generateClientForResponse();
-    }
-    else {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
-      client = generateClientForResponse(slackAppIntegration.tokenGtoP);
-    }
 
 
     const args = body.text.split(' ');
     const args = body.text.split(' ');
     const command = args[0];
     const command = args[0];
 
 
     try {
     try {
-      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
+      await crowi.slackIntegrationService.handleCommandRequest(command, client, body, args);
     }
     }
     catch (err) {
     catch (err) {
       await respondIfSlackbotError(client, body, err);
       await respondIfSlackbotError(client, body, err);
@@ -163,7 +130,8 @@ module.exports = (crowi) => {
   }
   }
 
 
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
-    return handleCommands(req, res);
+    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+    return handleCommands(req, res, client);
   });
   });
 
 
   router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
   router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
@@ -175,27 +143,18 @@ module.exports = (crowi) => {
       return res.send({ challenge: body.challenge });
       return res.send({ challenge: body.challenge });
     }
     }
 
 
-    return handleCommands(req, res);
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+
+    return handleCommands(req, res, client);
   });
   });
 
 
-  async function handleInteractions(req, res) {
+  async function handleInteractions(req, res, client) {
 
 
     // Send response immediately to avoid opelation_timeout error
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
     res.send();
 
 
-
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    // generate client
-    let client;
-    if (tokenPtoG == null) {
-      client = generateClientForResponse();
-    }
-    else {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
-      client = generateClientForResponse(slackAppIntegration.tokenGtoP);
-    }
-
     const payload = JSON.parse(req.body.payload);
     const payload = JSON.parse(req.body.payload);
     const { type } = payload;
     const { type } = payload;
 
 
@@ -203,7 +162,7 @@ module.exports = (crowi) => {
       switch (type) {
       switch (type) {
         case 'block_actions':
         case 'block_actions':
           try {
           try {
-            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+            await crowi.slackIntegrationService.handleBlockActionsRequest(client, payload);
           }
           }
           catch (err) {
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
             await respondIfSlackbotError(client, req.body, err);
@@ -211,7 +170,7 @@ module.exports = (crowi) => {
           break;
           break;
         case 'view_submission':
         case 'view_submission':
           try {
           try {
-            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, payload);
           }
           }
           catch (err) {
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
             await respondIfSlackbotError(client, req.body, err);
@@ -228,11 +187,15 @@ module.exports = (crowi) => {
   }
   }
 
 
   router.post('/interactions', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
   router.post('/interactions', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
-    return handleInteractions(req, res);
+    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+    return handleInteractions(req, res, client);
   });
   });
 
 
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
-    return handleInteractions(req, res);
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+
+    return handleInteractions(req, res, client);
   });
   });
 
 
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {

+ 2 - 2
packages/app/src/server/routes/apiv3/statistics.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 
 const USER_STATUS_MASTER = {
 const USER_STATUS_MASTER = {
   1: 'registered',
   1: 'registered',
@@ -97,7 +97,7 @@ module.exports = (crowi) => {
    *                    type: object
    *                    type: object
    *                    description: Statistics for all user
    *                    description: Statistics for all user
    */
    */
-  router.get('/user', helmet.noCache(), async(req, res) => {
+  router.get('/user', noCache(), async(req, res) => {
     const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
     const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
     res.status(200).send({ data });
     res.status(200).send({ data });
   });
   });

+ 2 - 1
packages/app/src/server/routes/page.js

@@ -1,6 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import UpdatePost from '../models/update-post';
+
 const { isCreatablePage } = pagePathUtils;
 const { isCreatablePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
@@ -1118,7 +1120,6 @@ module.exports = function(crowi, app) {
    */
    */
   api.getUpdatePost = function(req, res) {
   api.getUpdatePost = function(req, res) {
     const path = req.query.path;
     const path = req.query.path;
-    const UpdatePost = crowi.model('UpdatePost');
 
 
     if (!path) {
     if (!path) {
       return res.json(ApiResponse.error({}));
       return res.json(ApiResponse.error({}));

+ 1 - 3
packages/app/src/server/service/app.ts

@@ -12,7 +12,7 @@ const logger = loggerFactory('growi:service:AppService');
 /**
 /**
  * the service class of AppService
  * the service class of AppService
  */
  */
-class AppService implements S2sMessageHandlable {
+export default class AppService implements S2sMessageHandlable {
 
 
   crowi!: any;
   crowi!: any;
 
 
@@ -131,5 +131,3 @@ class AppService implements S2sMessageHandlable {
   }
   }
 
 
 }
 }
-
-module.exports = AppService;

+ 11 - 7
packages/app/src/server/service/global-notification/global-notification-slack.js

@@ -2,6 +2,10 @@ import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
+import {
+  prepareSlackMessageForGlobalNotification,
+} from '../../util/slack';
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 const urljoin = require('url-join');
 
 
@@ -14,8 +18,7 @@ class GlobalNotificationSlackService {
 
 
   constructor(crowi) {
   constructor(crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
-    this.slack = crowi.getSlack();
-    this.slackLegacy = crowi.getSlackLegacy();
+
     this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
     this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
   }
@@ -33,20 +36,21 @@ class GlobalNotificationSlackService {
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
    */
   async fire(event, id, path, triggeredBy, vars) {
   async fire(event, id, path, triggeredBy, vars) {
+    const { appService, slackIntegrationService } = this.crowi;
+
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
 
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
 
 
+    const appTitle = appService.getAppTitle();
+
     await Promise.all(notifications.map((notification) => {
     await Promise.all(notifications.map((notification) => {
-      return [
-        this.slack.sendGlobalNotification(messageBody, attachmentBody, notification.slackChannels),
-        this.slackLegacy.sendGlobalNotification(messageBody, attachmentBody, notification.slackChannels),
-      ];
+      const messageObj = prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, appTitle, notification.slackChannels);
+      return slackIntegrationService.postMessage(messageObj);
     }));
     }));
 
 
-
   }
   }
 
 
   /**
   /**

+ 37 - 52
packages/app/src/server/service/page.js

@@ -3,6 +3,7 @@ import loggerFactory from '~/utils/logger';
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
+const streamToPromise = require('stream-to-promise');
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
 const debug = require('debug')('growi:models:page');
@@ -49,6 +50,26 @@ class PageService {
     return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
     return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
   }
   }
 
 
+  /**
+   * Generate read stream to operate descendants of the specified page path
+   * @param {string} targetPagePath
+   * @param {User} viewer
+   */
+  async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPagePath);
+
+    await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
+
+    return builder
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+  }
 
 
   async renamePage(page, newPagePath, user, options, isRecursively = false) {
   async renamePage(page, newPagePath, user, options, isRecursively = false) {
 
 
@@ -62,6 +83,11 @@ class PageService {
     // sanitize path
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
+    // create descendants first
+    if (isRecursively) {
+      await this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
+
     const update = {};
     const update = {};
     // update Page
     // update Page
     update.path = newPagePath;
     update.path = newPagePath;
@@ -79,10 +105,6 @@ class PageService {
       await Page.create(path, body, user, { redirectTo: newPagePath });
       await Page.create(path, body, user, { redirectTo: newPagePath });
     }
     }
 
 
-    if (isRecursively) {
-      this.renameDescendantsWithStream(page, newPagePath, user, options);
-    }
-
     this.pageEvent.emit('delete', page, user, socketClientId);
     this.pageEvent.emit('delete', page, user, socketClientId);
     this.pageEvent.emit('create', renamedPage, user, socketClientId);
     this.pageEvent.emit('create', renamedPage, user, socketClientId);
 
 
@@ -147,19 +169,12 @@ class PageService {
    * Create rename stream
    * Create rename stream
    */
    */
   async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
   async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
-    const Page = this.crowi.model('Page');
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
     const newPagePathPrefix = newPagePath;
     const newPagePathPrefix = newPagePath;
-    const { PageQueryBuilder } = Page;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
     const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
 
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
-
     const renameDescendants = this.renameDescendants.bind(this);
     const renameDescendants = this.renameDescendants.bind(this);
     const pageEvent = this.pageEvent;
     const pageEvent = this.pageEvent;
     let count = 0;
     let count = 0;
@@ -189,6 +204,8 @@ class PageService {
     readStream
     readStream
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
       .pipe(writeStream);
+
+    await streamToPromise(readStream);
   }
   }
 
 
 
 
@@ -348,19 +365,11 @@ class PageService {
   }
   }
 
 
   async duplicateDescendantsWithStream(page, newPagePath, user) {
   async duplicateDescendantsWithStream(page, newPagePath, user) {
-    const Page = this.crowi.model('Page');
-    const newPagePathPrefix = newPagePath;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
 
-    const { PageQueryBuilder } = Page;
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
 
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(page.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
 
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const pageEvent = this.pageEvent;
     const pageEvent = this.pageEvent;
@@ -486,16 +495,8 @@ class PageService {
    * Create delete stream
    * Create delete stream
    */
    */
   async deleteDescendantsWithStream(targetPage, user, options = {}) {
   async deleteDescendantsWithStream(targetPage, user, options = {}) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
 
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
     const deleteDescendants = this.deleteDescendants.bind(this);
     const deleteDescendants = this.deleteDescendants.bind(this);
     let count = 0;
     let count = 0;
@@ -562,16 +563,8 @@ class PageService {
    * Create delete completely stream
    * Create delete completely stream
    */
    */
   async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
   async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
 
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     let count = 0;
     let count = 0;
@@ -688,16 +681,8 @@ class PageService {
    * Create revert stream
    * Create revert stream
    */
    */
   async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
   async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
 
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
     const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
     const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
     let count = 0;
     let count = 0;

+ 1 - 1
packages/app/src/server/service/slack-command-handler/help.js

@@ -11,7 +11,7 @@ module.exports = () => {
     message += 'Commands:\n\n';
     message += 'Commands:\n\n';
     message += '`/growi create`                          Create new page\n\n';
     message += '`/growi create`                          Create new page\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
-    message += '`/growi togetter`                      Create new page with existing slack conversations\n\n';
+    message += '`/growi togetter`                      Create new page with existing slack conversations (Alpha)\n\n';
     client.chat.postEphemeral({
     client.chat.postEphemeral({
       channel: body.channel_id,
       channel: body.channel_id,
       user: body.user_id,
       user: body.user_id,

+ 279 - 0
packages/app/src/server/service/slack-integration.ts

@@ -0,0 +1,279 @@
+import mongoose from 'mongoose';
+
+import { IncomingWebhookSendArguments } from '@slack/webhook';
+import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
+import { generateWebClient, markdownSectionBlock } from '@growi/slack';
+
+
+import loggerFactory from '~/utils/logger';
+import S2sMessage from '../models/vo/s2s-message';
+
+import ConfigManager from './config-manager';
+import { S2sMessagingService } from './s2s-messaging/base';
+import { S2sMessageHandlable } from './s2s-messaging/handlable';
+
+
+const logger = loggerFactory('growi:service:SlackBotService');
+
+
+type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
+
+export class SlackIntegrationService implements S2sMessageHandlable {
+
+  crowi!: any;
+
+  configManager!: ConfigManager;
+
+  s2sMessagingService!: S2sMessagingService;
+
+  lastLoadedAt?: Date;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+    this.s2sMessagingService = crowi.s2sMessagingService;
+
+    this.initialize();
+  }
+
+  initialize() {
+    this.lastLoadedAt = new Date();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage: S2sMessageForSlackIntegration): boolean {
+    const { eventName, updatedAt } = s2sMessage;
+    if (eventName !== 'slackIntegrationServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+  }
+
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(): Promise<void> {
+    const { configManager } = this.crowi;
+
+    logger.info('Reset slack bot by pubsub notification');
+    await configManager.loadConfigs();
+    this.initialize();
+  }
+
+  async publishUpdatedMessage(): Promise<void> {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+  get isSlackConfigured(): boolean {
+    return this.isSlackbotConfigured || this.isSlackLegacyConfigured;
+  }
+
+  get isSlackbotConfigured(): boolean {
+    const hasSlackbotType = !!this.configManager.getConfig('crowi', 'slackbot:currentBotType');
+    return hasSlackbotType;
+  }
+
+  get isSlackLegacyConfigured(): boolean {
+    // for legacy util
+    const hasSlackToken = !!this.configManager.getConfig('notification', 'slack:token');
+    const hasSlackIwhUrl = !!this.configManager.getConfig('notification', 'slack:incomingWebhookUrl');
+
+    return hasSlackToken || hasSlackIwhUrl;
+  }
+
+  private isCheckTypeValid(): boolean {
+    const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
+    if (currentBotType == null) {
+      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+    }
+
+    return true;
+  }
+
+  /**
+   * generate WebClient instance for 'customBotWithoutProxy' type
+   */
+  async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    const token = this.configManager.getConfig('crowi', 'slackbot:token');
+
+    if (token == null) {
+      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:token\') must be set.');
+    }
+
+    return generateWebClient(token);
+  }
+
+  /**
+   * generate WebClient instance by tokenPtoG
+   * @param tokenPtoG
+   */
+  async generateClientByTokenPtoG(tokenPtoG: string): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+    if (slackAppIntegration == null) {
+      throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+    }
+
+    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+  }
+
+  /**
+   * generate WebClient instance by tokenPtoG
+   * @param tokenPtoG
+   */
+  async generateClientForPrimaryWorkspace(): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
+
+    if (currentBotType === 'customBotWithoutProxy') {
+      return this.generateClientForCustomBotWithoutProxy();
+    }
+
+    // retrieve primary SlackAppIntegration
+    const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+    const slackAppIntegration = await SlackAppIntegration.findOne({ isPrimary: true });
+
+    if (slackAppIntegration == null) {
+      throw new Error('None of the primary SlackAppIntegration exists.');
+    }
+
+    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+  }
+
+  /**
+   * generate WebClient instance by SlackAppIntegration
+   * @param slackAppIntegration
+   */
+  async generateClientBySlackAppIntegration(slackAppIntegration: { tokenGtoP: string }): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    // connect to proxy
+    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const serverUri = new URL('/g2s', proxyServerUri);
+    const headers = {
+      'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
+    };
+
+    return generateWebClient(undefined, serverUri.toString(), headers);
+  }
+
+  async postMessage(messageArgs: ChatPostMessageArguments, slackAppIntegration?: { tokenGtoP: string; }): Promise<void> {
+    // use legacy slack configuration
+    if (this.isSlackLegacyConfigured && !this.isSlackbotConfigured) {
+      return this.postMessageWithLegacyUtil(messageArgs);
+    }
+
+    const client = slackAppIntegration == null
+      ? await this.generateClientForPrimaryWorkspace()
+      : await this.generateClientBySlackAppIntegration(slackAppIntegration);
+
+    try {
+      await client.chat.postMessage(messageArgs);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageArgs);
+      throw error;
+    }
+  }
+
+  private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
+    const slackLegacyUtil = require('../util/slack-legacy')(this.crowi);
+
+    try {
+      await slackLegacyUtil.postMessage(messageArgs);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageArgs);
+      throw error;
+    }
+  }
+
+  /**
+   * Handle /commands endpoint
+   */
+  async handleCommandRequest(command, client, body, ...opt) {
+    let module;
+    try {
+      module = `./slack-command-handler/${command}`;
+    }
+    catch (err) {
+      await this.notCommand(client, body);
+    }
+
+    try {
+      const handler = require(module)(this.crowi);
+      await handler.handleCommand(client, body, ...opt);
+    }
+    catch (err) {
+      throw err;
+    }
+  }
+
+  async handleBlockActionsRequest(client, payload) {
+    const { action_id: actionId } = payload.actions[0];
+    const commandName = actionId.split(':')[0];
+    const handlerMethodName = actionId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
+    try {
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
+    }
+    catch (err) {
+      throw err;
+    }
+    return;
+  }
+
+  async handleViewSubmissionRequest(client, payload) {
+    const { callback_id: callbackId } = payload.view;
+    const commandName = callbackId.split(':')[0];
+    const handlerMethodName = callbackId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
+    try {
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
+    }
+    catch (err) {
+      throw err;
+    }
+    return;
+  }
+
+  async notCommand(client, body) {
+    logger.error('Invalid first argument');
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'No command',
+      blocks: [
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+      ],
+    });
+    return;
+  }
+
+}

+ 0 - 22
packages/app/src/server/service/slack-notification.js

@@ -1,22 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:SlackNotification'); // eslint-disable-line no-unused-vars
-/**
- * the service class of SlackNotificationService
- */
-class SlackNotificationService {
-
-  constructor(configManager) {
-    this.configManager = configManager;
-  }
-
-  hasSlackConfig() {
-    const hasSlackToken = !!this.configManager.getConfig('notification', 'slack:token');
-    const hasSlackIwhUrl = !!this.configManager.getConfig('notification', 'slack:incomingWebhookUrl');
-
-    return hasSlackToken || hasSlackIwhUrl;
-  }
-
-}
-
-module.exports = SlackNotificationService;

+ 0 - 136
packages/app/src/server/service/slackbot.ts

@@ -1,136 +0,0 @@
-import loggerFactory from '~/utils/logger';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
-
-const logger = loggerFactory('growi:service:SlackBotService');
-
-const { markdownSectionBlock } = require('@growi/slack');
-
-const S2sMessage = require('../models/vo/s2s-message');
-
-
-class SlackBotService implements S2sMessageHandlable {
-
-  crowi!: any;
-
-  s2sMessagingService!: S2sMessagingService;
-
-  lastLoadedAt?: Date;
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.s2sMessagingService = crowi.s2sMessagingService;
-
-    this.initialize();
-  }
-
-  initialize() {
-    this.lastLoadedAt = new Date();
-  }
-
-  /**
-   * @inheritdoc
-   */
-  shouldHandleS2sMessage(s2sMessage) {
-    const { eventName, updatedAt } = s2sMessage;
-    if (eventName !== 'slackBotServiceUpdated' || updatedAt == null) {
-      return false;
-    }
-
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
-  }
-
-
-  /**
-   * @inheritdoc
-   */
-  async handleS2sMessage() {
-    const { configManager } = this.crowi;
-
-    logger.info('Reset slack bot by pubsub notification');
-    await configManager.loadConfigs();
-    this.initialize();
-  }
-
-  async publishUpdatedMessage() {
-    const { s2sMessagingService } = this;
-
-    if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('slackBotServiceUpdated', { updatedAt: new Date() });
-
-      try {
-        await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
-      }
-    }
-  }
-
-  /**
-   * Handle /commands endpoint
-   */
-  async handleCommandRequest(command, client, body, ...opt) {
-    let module;
-    try {
-      module = `./slack-command-handler/${command}`;
-    }
-    catch (err) {
-      await this.notCommand(client, body);
-    }
-
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(client, body, ...opt);
-    }
-    catch (err) {
-      throw err;
-    }
-  }
-
-  async handleBlockActionsRequest(client, payload) {
-    const { action_id: actionId } = payload.actions[0];
-    const commandName = actionId.split(':')[0];
-    const handlerMethodName = actionId.split(':')[1];
-    const module = `./slack-command-handler/${commandName}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleBlockActions(client, payload, handlerMethodName);
-    }
-    catch (err) {
-      throw err;
-    }
-    return;
-  }
-
-  async handleViewSubmissionRequest(client, payload) {
-    const { callback_id: callbackId } = payload.view;
-    const commandName = callbackId.split(':')[0];
-    const handlerMethodName = callbackId.split(':')[1];
-    const module = `./slack-command-handler/${commandName}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleBlockActions(client, payload, handlerMethodName);
-    }
-    catch (err) {
-      throw err;
-    }
-    return;
-  }
-
-  async notCommand(client, body) {
-    logger.error('Invalid first argument');
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'No command',
-      blocks: [
-        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    return;
-  }
-
-}
-
-module.exports = SlackBotService;

+ 0 - 61
packages/app/src/server/service/user-notification/index.js

@@ -1,61 +0,0 @@
-const toArrayFromCsv = require('~/utils/to-array-from-csv');
-
-/**
- * service class of UserNotification
- */
-class UserNotificationService {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    this.Page = this.crowi.model('Page');
-  }
-
-  /**
-   * fire user notification
-   *
-   * @memberof UserNotificationService
-   *
-   * @param {Page} page
-   * @param {User} user
-   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
-   * @param {string} mode 'create' or 'update' or 'comment'
-   * @param {string} previousRevision
-   * @param {Comment} comment
-   */
-  async fire(page, user, slackChannelsStr, mode, option, comment = {}) {
-    const {
-      slackNotificationService, slackLegacy, slack,
-    } = this.crowi;
-
-    const opt = option || {};
-    const previousRevision = opt.previousRevision || '';
-
-    await page.updateSlackChannels(slackChannelsStr);
-
-    if (!slackNotificationService.hasSlackConfig()) {
-      throw new Error('slackNotificationService has not been set up');
-    }
-
-    // "dev,slacktest" => [dev,slacktest]
-    const slackChannels = toArrayFromCsv(slackChannelsStr);
-
-    const promises = slackChannels.map(async(chan) => {
-      let res;
-      if (mode === 'comment') {
-        res = await slack.postComment(comment, user, chan, page.path);
-        res = await slackLegacy.postComment(comment, user, chan, page.path);
-      }
-      else {
-        res = await slack.postPage(page, user, chan, mode, previousRevision);
-        res = await slackLegacy.postPage(page, user, chan, mode, previousRevision);
-      }
-      return res;
-    });
-
-    return Promise.allSettled(promises);
-  }
-
-}
-
-module.exports = UserNotificationService;

+ 82 - 0
packages/app/src/server/service/user-notification/index.ts

@@ -0,0 +1,82 @@
+import UpdatePost from '~/server/models/update-post';
+import { toArrayFromCsv } from '~/utils/to-array-from-csv';
+
+
+import {
+  prepareSlackMessageForPage,
+  prepareSlackMessageForComment,
+} from '../../util/slack';
+
+/**
+ * service class of UserNotification
+ */
+export class UserNotificationService {
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  crowi!: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * fire user notification
+   *
+   * @memberof UserNotificationService
+   *
+   * @param {Page} page
+   * @param {User} user
+   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
+   * @param {string} mode 'create' or 'update' or 'comment'
+   * @param {string} previousRevision
+   * @param {Comment} comment
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  async fire(page, user, slackChannelsStr, mode, option, comment = {}): Promise<PromiseSettledResult<any>[]> {
+    const {
+      appService, slackIntegrationService,
+    } = this.crowi;
+
+    if (!slackIntegrationService.isSlackConfigured) {
+      throw new Error('slackIntegrationService has not been set up');
+    }
+
+    // update slackChannels attribute asynchronously
+    page.updateSlackChannels(slackChannelsStr);
+
+    const opt = option || {};
+    const previousRevision = opt.previousRevision || '';
+
+    // "dev,slacktest" => [dev,slacktest]
+    const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);
+    await this.putDefaultChannelIfEmpty(page.path, slackChannels);
+
+    const appTitle = appService.getAppTitle();
+    const siteUrl = appService.getSiteUrl();
+
+    const promises = slackChannels.map(async(chan) => {
+      let messageObj;
+      if (mode === 'comment') {
+        messageObj = prepareSlackMessageForComment(comment, user, appTitle, siteUrl, chan, page.path);
+      }
+      else {
+        messageObj = prepareSlackMessageForPage(page, user, appTitle, siteUrl, chan, mode, previousRevision);
+      }
+
+      return slackIntegrationService.postMessage(messageObj);
+    });
+
+    return Promise.allSettled(promises);
+  }
+
+  private async putDefaultChannelIfEmpty(pagePath:string, slackChannels: (string|null)[]): Promise<void> {
+    const updatePosts = await UpdatePost.findSettingsByPath(pagePath);
+    slackChannels.push(...(updatePosts).map(up => up.channel));
+
+    // insert null if empty to notify once
+    if (slackChannels.length === 0) {
+      slackChannels.push(null);
+    }
+  }
+
+}

+ 15 - 43
packages/app/src/server/util/slack-legacy.js

@@ -1,20 +1,15 @@
-const debug = require('debug')('growi:util:slack');
-// const slack = require('./slack');
+import { IncomingWebhook } from '@slack/webhook';
+import { WebClient } from '@slack/web-api';
 
 
-/**
- * slack
- */
+import loggerFactory from '~/utils/logger';
 
 
-/* eslint-disable no-use-before-define */
+const logger = loggerFactory('growi:util:slack-legacy');
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
-  const { IncomingWebhook } = require('@slack/webhook');
-  const { WebClient } = require('@slack/web-api');
 
 
   const { configManager } = crowi;
   const { configManager } = crowi;
-  const slack = crowi.getSlack();
 
 
-  const slackLegacy = {};
+  const slackUtilLegacy = {};
 
 
   const postWithIwh = async(messageObj) => {
   const postWithIwh = async(messageObj) => {
     const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
     const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
@@ -22,71 +17,48 @@ module.exports = function(crowi) {
       await webhook.send(messageObj);
       await webhook.send(messageObj);
     }
     }
     catch (error) {
     catch (error) {
-      debug('Post error', error);
-      debug('Sent data to slack is:', messageObj);
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
       throw error;
       throw error;
     }
     }
   };
   };
 
 
   const postWithWebApi = async(messageObj) => {
   const postWithWebApi = async(messageObj) => {
     const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
     const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
-    // stringify attachments
-    if (messageObj.attachments != null) {
-      messageObj.attachments = JSON.stringify(messageObj.attachments);
-    }
     try {
     try {
       await client.chat.postMessage(messageObj);
       await client.chat.postMessage(messageObj);
     }
     }
     catch (error) {
     catch (error) {
-      debug('Post error', error);
-      debug('Sent data to slack is:', messageObj);
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
       throw error;
       throw error;
     }
     }
   };
   };
 
 
-  // slackLegacy.post = function (channel, message, opts) {
-  slackLegacy.postPage = (page, user, channel, updateType, previousRevision) => {
-    const messageObj = slack.prepareSlackMessageForPage(page, user, channel, updateType, previousRevision);
-
-    return slackPost(messageObj);
-  };
-
-  slackLegacy.postComment = (comment, user, channel, path) => {
-    const messageObj = slack.prepareSlackMessageForComment(comment, user, channel, path);
-
-    return slackPost(messageObj);
-  };
-
-  slackLegacy.sendGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
-    const messageObj = await slack.prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, slackChannel);
-
-    return slackPost(messageObj);
-  };
-
-  const slackPost = (messageObj) => {
+  slackUtilLegacy.postMessage = async(messageObj) => {
     // when incoming Webhooks is prioritized
     // when incoming Webhooks is prioritized
     if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
     if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        debug('posting message with IncomingWebhook');
+        logger.debug('posting message with IncomingWebhook');
         return postWithIwh(messageObj);
         return postWithIwh(messageObj);
       }
       }
       if (configManager.getConfig('notification', 'slack:token')) {
       if (configManager.getConfig('notification', 'slack:token')) {
-        debug('posting message with Web API');
+        logger.debug('posting message with Web API');
         return postWithWebApi(messageObj);
         return postWithWebApi(messageObj);
       }
       }
     }
     }
     // else
     // else
     else {
     else {
       if (configManager.getConfig('notification', 'slack:token')) {
       if (configManager.getConfig('notification', 'slack:token')) {
-        debug('posting message with Web API');
+        logger.debug('posting message with Web API');
         return postWithWebApi(messageObj);
         return postWithWebApi(messageObj);
       }
       }
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        debug('posting message with IncomingWebhook');
+        logger.debug('posting message with IncomingWebhook');
         return postWithIwh(messageObj);
         return postWithIwh(messageObj);
       }
       }
     }
     }
   };
   };
 
 
-  return slackLegacy;
+  return slackUtilLegacy;
 };
 };

+ 123 - 182
packages/app/src/server/util/slack.js

@@ -7,219 +7,160 @@ const urljoin = require('url-join');
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
-module.exports = function(crowi) {
-  const { WebClient } = require('@slack/web-api');
+const convertMarkdownToMarkdown = function(body, siteUrl) {
+  return body
+    .replace(/\n\*\s(.+)/g, '\n• $1')
+    .replace(/#{1,}\s?(.+)/g, '\n*$1*')
+    .replace(/(\[(.+)\]\((https?:\/\/.+)\))/g, '<$3|$2>')
+    .replace(/(\[(.+)\]\((\/.+)\))/g, `<${siteUrl}$3|$2>`);
+};
+
+const prepareAttachmentTextForCreate = function(page, siteUrl) {
+  let body = page.revision.body;
+  if (body.length > 2000) {
+    body = `${body.substr(0, 2000)}...`;
+  }
+
+  return convertMarkdownToMarkdown(body, siteUrl);
+};
 
 
-  const { configManager } = crowi;
-  const slack = {};
+const prepareAttachmentTextForUpdate = function(page, siteUrl, previousRevision) {
+  const diff = require('diff');
+  let diffText = '';
 
 
-  const postWithWebApi = async(messageObj) => {
-    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
-    // stringify attachments
-    if (messageObj.attachments != null) {
-      messageObj.attachments = JSON.stringify(messageObj.attachments);
+  diff.diffLines(previousRevision.body, page.revision.body).forEach((line) => {
+    debug('diff line', line);
+    const value = line.value.replace(/\r\n|\r/g, '\n'); // eslint-disable-line no-unused-vars
+    if (line.added) {
+      diffText += `${line.value} ... :lower_left_fountain_pen:`;
     }
     }
-    try {
-      await client.chat.postMessage(messageObj);
+    else if (line.removed) {
+      // diffText += '-' + line.value.replace(/(.+)?\n/g, '- $1\n');
+      // 1以下は無視
+      if (line.count > 1) {
+        diffText += `:wastebasket: ... ${line.count} lines\n`;
+      }
     }
     }
-    catch (error) {
-      debug('Post error', error);
-      debug('Sent data to slack is:', messageObj);
-      throw error;
+    else {
+      // diffText += '...\n';
     }
     }
-  };
+  });
 
 
-  const convertMarkdownToMarkdown = function(body) {
-    const url = crowi.appService.getSiteUrl();
+  debug('diff is', diffText);
 
 
-    return body
-      .replace(/\n\*\s(.+)/g, '\n• $1')
-      .replace(/#{1,}\s?(.+)/g, '\n*$1*')
-      .replace(/(\[(.+)\]\((https?:\/\/.+)\))/g, '<$3|$2>')
-      .replace(/(\[(.+)\]\((\/.+)\))/g, `<${url}$3|$2>`);
-  };
+  return diffText;
+};
 
 
-  const prepareAttachmentTextForCreate = function(page, user) {
-    let body = page.revision.body;
-    if (body.length > 2000) {
-      body = `${body.substr(0, 2000)}...`;
-    }
+const prepareAttachmentTextForComment = function(comment) {
+  let body = comment.comment;
+  if (body.length > 2000) {
+    body = `${body.substr(0, 2000)}...`;
+  }
 
 
+  if (comment.isMarkdown) {
     return convertMarkdownToMarkdown(body);
     return convertMarkdownToMarkdown(body);
-  };
+  }
 
 
-  const prepareAttachmentTextForUpdate = function(page, user, previousRevision) {
-    const diff = require('diff');
-    let diffText = '';
-
-    diff.diffLines(previousRevision.body, page.revision.body).forEach((line) => {
-      debug('diff line', line);
-      const value = line.value.replace(/\r\n|\r/g, '\n'); // eslint-disable-line no-unused-vars
-      if (line.added) {
-        diffText += `${line.value} ... :lower_left_fountain_pen:`;
-      }
-      else if (line.removed) {
-        // diffText += '-' + line.value.replace(/(.+)?\n/g, '- $1\n');
-        // 1以下は無視
-        if (line.count > 1) {
-          diffText += `:wastebasket: ... ${line.count} lines\n`;
-        }
-      }
-      else {
-        // diffText += '...\n';
-      }
-    });
+  return body;
+};
 
 
-    debug('diff is', diffText);
+const generateSlackMessageTextForPage = function(path, pageId, user, siteUrl, updateType) {
+  let text;
 
 
-    return diffText;
-  };
+  const pageUrl = `<${urljoin(siteUrl, pageId)}|${path}>`;
+  if (updateType === 'create') {
+    text = `:rocket: ${user.username} created a new page! ${pageUrl}`;
+  }
+  else {
+    text = `:heavy_check_mark: ${user.username} updated ${pageUrl}`;
+  }
 
 
-  const prepareAttachmentTextForComment = function(comment) {
-    let body = comment.comment;
-    if (body.length > 2000) {
-      body = `${body.substr(0, 2000)}...`;
-    }
-
-    if (comment.isMarkdown) {
-      return convertMarkdownToMarkdown(body);
-    }
+  return text;
+};
 
 
-    return body;
+export const prepareSlackMessageForPage = (page, user, appTitle, siteUrl, channel, updateType, previousRevision) => {
+  let body = page.revision.body;
+
+  if (updateType === 'create') {
+    body = prepareAttachmentTextForCreate(page, siteUrl);
+  }
+  else {
+    body = prepareAttachmentTextForUpdate(page, siteUrl, previousRevision);
+  }
+
+  const attachment = {
+    color: '#263a3c',
+    author_name: `@${user.username}`,
+    author_link: urljoin(siteUrl, 'user', user.username),
+    author_icon: user.image,
+    title: page.path,
+    title_link: urljoin(siteUrl, page.id),
+    text: body,
+    mrkdwn_in: ['text'],
   };
   };
-
-  slack.prepareSlackMessageForPage = (page, user, channel, updateType, previousRevision) => {
-    const appTitle = crowi.appService.getAppTitle();
-    const url = crowi.appService.getSiteUrl();
-    let body = page.revision.body;
-
-    if (updateType === 'create') {
-      body = prepareAttachmentTextForCreate(page, user);
-    }
-    else {
-      body = prepareAttachmentTextForUpdate(page, user, previousRevision);
-    }
-
-    const attachment = {
-      color: '#263a3c',
-      author_name: `@${user.username}`,
-      author_link: urljoin(url, 'user', user.username),
-      author_icon: user.image,
-      title: page.path,
-      title_link: urljoin(url, page.id),
-      text: body,
-      mrkdwn_in: ['text'],
-    };
-    if (user.image) {
-      attachment.author_icon = user.image;
-    }
-
-    const message = {
-      channel: (channel != null) ? `#${channel}` : undefined,
-      username: appTitle,
-      text: getSlackMessageTextForPage(page.path, page.id, user, updateType),
-      attachments: [attachment],
-    };
-
-    return message;
+  if (user.image) {
+    attachment.author_icon = user.image;
+  }
+
+  const message = {
+    channel: (channel != null) ? `#${channel}` : undefined,
+    username: appTitle,
+    text: generateSlackMessageTextForPage(page.path, page.id, user, siteUrl, updateType),
+    attachments: [attachment],
   };
   };
 
 
-  slack.prepareSlackMessageForComment = (comment, user, channel, path) => {
-    const appTitle = crowi.appService.getAppTitle();
-    const url = crowi.appService.getSiteUrl();
-    const body = prepareAttachmentTextForComment(comment);
-
-    const attachment = {
-      color: '#263a3c',
-      author_name: `@${user.username}`,
-      author_link: urljoin(url, 'user', user.username),
-      author_icon: user.image,
-      text: body,
-      mrkdwn_in: ['text'],
-    };
-    if (user.image) {
-      attachment.author_icon = user.image;
-    }
+  return message;
+};
 
 
-    const message = {
-      channel: (channel != null) ? `#${channel}` : undefined,
-      username: appTitle,
-      text: getSlackMessageTextForComment(path, String(comment.page), user),
-      attachments: [attachment],
-    };
+export const prepareSlackMessageForComment = (comment, user, appTitle, siteUrl, channel, path) => {
+  const body = prepareAttachmentTextForComment(comment);
 
 
-    return message;
+  const attachment = {
+    color: '#263a3c',
+    author_name: `@${user.username}`,
+    author_link: urljoin(siteUrl, 'user', user.username),
+    author_icon: user.image,
+    text: body,
+    mrkdwn_in: ['text'],
+  };
+  if (user.image) {
+    attachment.author_icon = user.image;
+  }
+
+  const pageUrl = `<${urljoin(siteUrl, String(comment.page))}|${path}>`;
+  const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
+
+  const message = {
+    channel: (channel != null) ? `#${channel}` : undefined,
+    username: appTitle,
+    text,
+    attachments: [attachment],
   };
   };
 
 
-  /**
+  return message;
+};
+
+/**
    * For GlobalNotification
    * For GlobalNotification
    *
    *
    * @param {string} messageBody
    * @param {string} messageBody
    * @param {string} attachmentBody
    * @param {string} attachmentBody
    * @param {string} slackChannel
    * @param {string} slackChannel
   */
   */
-  slack.prepareSlackMessageForGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
-    const appTitle = crowi.appService.getAppTitle();
-
-    const attachment = {
-      color: '#263a3c',
-      text: attachmentBody,
-      mrkdwn_in: ['text'],
-    };
-
-    const message = {
-      channel: (slackChannel != null) ? `#${slackChannel}` : undefined,
-      username: appTitle,
-      text: messageBody,
-      attachments: [attachment],
-    };
-
-    return message;
-  };
-
-  const getSlackMessageTextForPage = function(path, pageId, user, updateType) {
-    let text;
-    const url = crowi.appService.getSiteUrl();
-
-    const pageUrl = `<${urljoin(url, pageId)}|${path}>`;
-    if (updateType === 'create') {
-      text = `:rocket: ${user.username} created a new page! ${pageUrl}`;
-    }
-    else {
-      text = `:heavy_check_mark: ${user.username} updated ${pageUrl}`;
-    }
-
-    return text;
-  };
-
-  const getSlackMessageTextForComment = function(path, pageId, user) {
-    const url = crowi.appService.getSiteUrl();
-    const pageUrl = `<${urljoin(url, pageId)}|${path}>`;
-    const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
-
-    return text;
-  };
-
-  slack.postPage = (page, user, channel, updateType, previousRevision) => {
-    const messageObj = slack.prepareSlackMessageForPage(page, user, channel, updateType, previousRevision);
-
-    return slackPost(messageObj);
-  };
-
-  slack.postComment = (comment, user, channel, path) => {
-    const messageObj = slack.prepareSlackMessageForComment(comment, user, channel, path);
-
-    return slackPost(messageObj);
-  };
+export const prepareSlackMessageForGlobalNotification = (messageBody, attachmentBody, appTitle, slackChannel) => {
 
 
-  slack.sendGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
-    const messageObj = await slack.prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, slackChannel);
-    return slackPost(messageObj);
+  const attachment = {
+    color: '#263a3c',
+    text: attachmentBody,
+    mrkdwn_in: ['text'],
   };
   };
 
 
-  const slackPost = (messageObj) => {
-    return postWithWebApi(messageObj);
+  const message = {
+    channel: (slackChannel != null) ? `#${slackChannel}` : undefined,
+    username: appTitle,
+    text: messageBody,
+    attachments: JSON.stringify([attachment]),
   };
   };
 
 
-  return slack;
+  return message;
 };
 };

+ 1 - 1
packages/app/src/server/views/admin/slack-integration-legacy.html

@@ -7,6 +7,6 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block content_main %}
 {% block content_main %}
-<div id="admin-slack-integration-notification-setting" class="admin-slack-integration-notification-setting"></div>
+<div id="admin-slack-integration-legacy" class="admin-slack-integration-legacy"></div>
 {% endblock content_main %}
 {% endblock content_main %}
 
 

+ 2 - 0
packages/app/src/server/views/admin/slack-integration.html

@@ -1,5 +1,7 @@
 {% extends '../layout/admin.html' %}
 {% extends '../layout/admin.html' %}
 
 
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('slack_integration')) }}{% endblock %}
+
 {% block content_header %}
 {% block content_header %}
 <h1 class="title">{{ t('slack_integration') }}</h1>
 <h1 class="title">{{ t('slack_integration') }}</h1>
 {% endblock %}
 {% endblock %}

+ 98 - 1
packages/app/src/test/service/page.test.js

@@ -14,6 +14,14 @@ let parentForRename1;
 let parentForRename2;
 let parentForRename2;
 let parentForRename3;
 let parentForRename3;
 let parentForRename4;
 let parentForRename4;
+let parentForRename5;
+let parentForRename6;
+let parentForRename7;
+let parentForRename8;
+let parentForRename9;
+
+let irrelevantPage1;
+let irrelevantPage2;
 
 
 let childForRename1;
 let childForRename1;
 let childForRename2;
 let childForRename2;
@@ -94,6 +102,48 @@ describe('PageService', () => {
         creator: testUser1,
         creator: testUser1,
         lastUpdateUser: testUser1,
         lastUpdateUser: testUser1,
       },
       },
+      {
+        path: '/parentForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename6',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1/level2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1/level2/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1/level2/level2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename6-2021H1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1-2021H1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
       {
       {
         path: '/parentForRename1/child',
         path: '/parentForRename1/child',
         grant: Page.GRANT_PUBLIC,
         grant: Page.GRANT_PUBLIC,
@@ -183,6 +233,14 @@ describe('PageService', () => {
     parentForRename2 = await Page.findOne({ path: '/parentForRename2' });
     parentForRename2 = await Page.findOne({ path: '/parentForRename2' });
     parentForRename3 = await Page.findOne({ path: '/parentForRename3' });
     parentForRename3 = await Page.findOne({ path: '/parentForRename3' });
     parentForRename4 = await Page.findOne({ path: '/parentForRename4' });
     parentForRename4 = await Page.findOne({ path: '/parentForRename4' });
+    parentForRename5 = await Page.findOne({ path: '/parentForRename5' });
+    parentForRename6 = await Page.findOne({ path: '/parentForRename6' });
+    parentForRename7 = await Page.findOne({ path: '/level1/level2' });
+    parentForRename8 = await Page.findOne({ path: '/level1/level2/child' });
+    parentForRename9 = await Page.findOne({ path: '/level1/level2/level2' });
+
+    irrelevantPage1 = await Page.findOne({ path: '/parentForRename6-2021H1' });
+    irrelevantPage2 = await Page.findOne({ path: '/level1-2021H1' });
 
 
     parentForDuplicate = await Page.findOne({ path: '/parentForDuplicate' });
     parentForDuplicate = await Page.findOne({ path: '/parentForDuplicate' });
 
 
@@ -232,6 +290,36 @@ describe('PageService', () => {
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });
   });
 
 
+  describe('rename page without using renameDescendantsWithStreamSpy', () => {
+    test('rename page with different tree with isRecursively [deeper]', async() => {
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, {}, true);
+      const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
+      const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
+      const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
+
+      expect(resultPage.path).toEqual(expectPage1.path);
+      expect(expectPage2.path).not.toBeNull();
+
+      // Check that pages that are not to be renamed have not been renamed
+      expect(wrongPage).toBeNull();
+    });
+
+    test('rename page with different tree with isRecursively [shallower]', async() => {
+      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {}, true);
+      const expectPage1 = await Page.findOne({ path: '/level1' });
+      const expectPage2 = await Page.findOne({ path: '/level1/child' });
+      const expectPage3 = await Page.findOne({ path: '/level1/level2/level2' });
+      const expectPage4 = await Page.findOne({ path: '/level1-2021H1' });
+
+      expect(expectPage1).not.toBeNull();
+      expect(expectPage2).not.toBeNull();
+      expect(expectPage3).not.toBeNull();
+
+      // Check that pages that are not to be renamed have not been renamed
+      expect(expectPage4).not.toBeNull();
+    });
+  });
+
   describe('rename page', () => {
   describe('rename page', () => {
     let pageEventSpy;
     let pageEventSpy;
     let renameDescendantsWithStreamSpy;
     let renameDescendantsWithStreamSpy;
@@ -328,6 +416,16 @@ describe('PageService', () => {
         expect(redirectedFromPageRevision).toBeNull();
         expect(redirectedFromPageRevision).toBeNull();
       });
       });
 
 
+      test('rename page with different tree with isRecursively', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, {}, true);
+        const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
+        const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
+
+        expect(resultPage.path).toEqual(expectPage.path);
+        expect(wrongPage).toBeNull();
+      });
+
     });
     });
 
 
     test('renameDescendants without options', async() => {
     test('renameDescendants without options', async() => {
@@ -396,7 +494,6 @@ describe('PageService', () => {
     });
     });
   });
   });
 
 
-
   describe('duplicate page', () => {
   describe('duplicate page', () => {
     let duplicateDescendantsWithStreamSpy;
     let duplicateDescendantsWithStreamSpy;
 
 

+ 4 - 7
packages/app/src/test/utils/slack-legacy.test.js

@@ -3,18 +3,15 @@ const { getInstance } = require('../setup-crowi');
 describe('Slack Util', () => {
 describe('Slack Util', () => {
 
 
   let crowi;
   let crowi;
-  let slackLegacy;
+  let slackLegacyUtil;
 
 
   beforeEach(async() => {
   beforeEach(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
-    slackLegacy = require('~/server/util/slack-legacy')(crowi);
+    slackLegacyUtil = require('~/server/util/slack-legacy')(crowi);
   });
   });
 
 
-  test('post comment method exists', () => {
-    expect(slackLegacy.postComment).toBeInstanceOf(Function);
+  test('postMessage method exists', () => {
+    expect(slackLegacyUtil.postMessage).toBeInstanceOf(Function);
   });
   });
 
 
-  test('post page method exists', () => {
-    expect(slackLegacy.postPage).toBeInstanceOf(Function);
-  });
 });
 });

+ 9 - 0
packages/slack/src/index.ts

@@ -1,3 +1,7 @@
+export const REQUEST_TIMEOUT_FOR_GTOP = 10000;
+
+export const REQUEST_TIMEOUT_FOR_PTOG = 10000;
+
 export const supportedSlackCommands: string[] = [
 export const supportedSlackCommands: string[] = [
   '/growi',
   '/growi',
 ];
 ];
@@ -5,6 +9,7 @@ export const supportedSlackCommands: string[] = [
 export const supportedGrowiCommands: string[] = [
 export const supportedGrowiCommands: string[] = [
   'search',
   'search',
   'create',
   'create',
+  'togetter',
   'help',
   'help',
 ];
 ];
 
 
@@ -14,6 +19,7 @@ export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
 
 
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
   'create',
   'create',
+  'togetter',
 ];
 ];
 
 
 export * from './interfaces/growi-command';
 export * from './interfaces/growi-command';
@@ -26,6 +32,9 @@ export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
 export * from './utils/check-communicable';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/post-ephemeral-errors';
+export * from './utils/publish-initial-home-view';
 export * from './utils/reshape-contents-body';
 export * from './utils/reshape-contents-body';
 export * from './utils/slash-command-parser';
 export * from './utils/slash-command-parser';
 export * from './utils/webclient-factory';
 export * from './utils/webclient-factory';
+export * from './utils/welcome-message';
+export * from './utils/required-scopes';

+ 11 - 1
packages/slack/src/utils/block-kit-builder.ts

@@ -1,5 +1,5 @@
 import {
 import {
-  SectionBlock, InputBlock, DividerBlock, ActionsBlock,
+  SectionBlock, HeaderBlock, InputBlock, DividerBlock, ActionsBlock,
   Button, Overflow, Datepicker, Select, RadioButtons, Checkboxes, Action, MultiSelect, PlainTextInput, Option,
   Button, Overflow, Datepicker, Select, RadioButtons, Checkboxes, Action, MultiSelect, PlainTextInput, Option,
 } from '@slack/types';
 } from '@slack/types';
 
 
@@ -10,6 +10,16 @@ export function divider(): DividerBlock {
   };
   };
 }
 }
 
 
+export function markdownHeaderBlock(text: string): HeaderBlock {
+  return {
+    type: 'header',
+    text: {
+      type: 'plain_text',
+      text,
+    },
+  };
+}
+
 export function markdownSectionBlock(text: string): SectionBlock {
 export function markdownSectionBlock(text: string): SectionBlock {
   return {
   return {
     type: 'section',
     type: 'section',

+ 9 - 4
packages/slack/src/utils/check-communicable.ts

@@ -4,6 +4,8 @@ import { WebClient } from '@slack/web-api';
 
 
 import { generateWebClient } from './webclient-factory';
 import { generateWebClient } from './webclient-factory';
 import { ConnectionStatus } from '../interfaces/connection-status';
 import { ConnectionStatus } from '../interfaces/connection-status';
+import { requiredScopes } from './required-scopes';
+import { markdownSectionBlock } from './block-kit-builder';
 
 
 /**
 /**
  * Check whether the HTTP server responds or not.
  * Check whether the HTTP server responds or not.
@@ -45,11 +47,10 @@ const testSlackApiServer = async(client: WebClient): Promise<any> => {
 
 
 const checkSlackScopes = (resultTestSlackApiServer: any) => {
 const checkSlackScopes = (resultTestSlackApiServer: any) => {
   const slackScopes = resultTestSlackApiServer.response_metadata.scopes;
   const slackScopes = resultTestSlackApiServer.response_metadata.scopes;
-  const correctScopes = ['commands', 'team:read', 'chat:write'];
-  const isPassedScopeCheck = correctScopes.every(e => slackScopes.includes(e));
+  const isPassedScopeCheck = requiredScopes.every(e => slackScopes.includes(e));
 
 
   if (!isPassedScopeCheck) {
   if (!isPassedScopeCheck) {
-    throw new Error('The scopes is not appropriate. Required scopes is [\'commands\', \'team:read\', \'chat:write\']');
+    throw new Error(`The scopes you registered are not appropriate. Required scopes are ${requiredScopes}`);
   }
   }
 };
 };
 
 
@@ -121,6 +122,10 @@ export const sendSuccessMessage = async(token:string, channel:string, appSiteUrl
   const client = generateWebClient(token);
   const client = generateWebClient(token);
   await client.chat.postMessage({
   await client.chat.postMessage({
     channel,
     channel,
-    text: `Successfully tested with ${appSiteUrl}.`,
+    text: 'Success',
+    blocks: [
+      markdownSectionBlock(`:tada: Successfully tested with ${appSiteUrl}.`),
+      markdownSectionBlock('Now your GROWI and Slack integration is ready to use :+1:'),
+    ],
   });
   });
 };
 };

+ 31 - 0
packages/slack/src/utils/publish-initial-home-view.ts

@@ -0,0 +1,31 @@
+// Now Home tab is disabled
+// TODO Imple Home tab
+
+import { ViewsPublishResponse, WebClient } from '@slack/web-api';
+
+export const publishInitialHomeView = (client: WebClient, userId: string): Promise<ViewsPublishResponse> => {
+  return client.views.publish({
+    user_id: userId,
+    view: {
+      type: 'home',
+      blocks: [
+        {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: 'Welcome GROWI Official Bot Home',
+          },
+        },
+        {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: 'Learn how to use GROWI Official bot.'
+            // eslint-disable-next-line max-len
+              + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.',
+          },
+        },
+      ],
+    },
+  });
+};

+ 11 - 0
packages/slack/src/utils/required-scopes.ts

@@ -0,0 +1,11 @@
+export const requiredScopes: string[] = [
+  'commands',
+  'team:read',
+  'chat:write',
+  'chat:write.public',
+  'channels:join',
+  'channels:history',
+  'groups:history',
+  'im:history',
+  'mpim:history',
+];

+ 4 - 0
packages/slack/src/utils/slash-command-parser.ts

@@ -2,6 +2,10 @@ import { GrowiCommand } from '../interfaces/growi-command';
 import { InvalidGrowiCommandError } from '../models/errors';
 import { InvalidGrowiCommandError } from '../models/errors';
 
 
 export const parseSlashCommand = (slashCommand:{[key:string]:string}): GrowiCommand => {
 export const parseSlashCommand = (slashCommand:{[key:string]:string}): GrowiCommand => {
+  if (slashCommand.text == null) {
+    throw new InvalidGrowiCommandError('The SlashCommand.text is null');
+  }
+
   const trimmedText = slashCommand.text.trim();
   const trimmedText = slashCommand.text.trim();
   const splitted = trimmedText.split(' ');
   const splitted = trimmedText.split(' ');
 
 

+ 21 - 6
packages/slack/src/utils/webclient-factory.ts

@@ -1,12 +1,27 @@
-import { LogLevel, WebClient } from '@slack/web-api';
+import { LogLevel, WebClient, WebClientOptions } from '@slack/web-api';
 
 
 const isProduction = process.env.NODE_ENV === 'production';
 const isProduction = process.env.NODE_ENV === 'production';
+const logLevel: LogLevel = isProduction ? LogLevel.DEBUG : LogLevel.INFO;
 
 
 /**
 /**
  * Generate WebClilent instance
  * Generate WebClilent instance
- * @param token Slack Bot Token or Proxy Server URI
- * @returns
+ * @param token
+ * @param serverUri Slack Bot Token or Proxy Server URI
+ * @param headers
  */
  */
-export const generateWebClient = (token: string, serverUri?: string, headers?:{[key:string]:string}): WebClient => {
-  return new WebClient(token, { slackApiUrl: serverUri, logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO, headers });
-};
+export function generateWebClient(token?: string, serverUri?: string, headers?:{[key:string]:string}): WebClient;
+
+/**
+ * Generate WebClilent instance
+ * @param token
+ * @param opts
+ */
+export function generateWebClient(token?: string, opts?: WebClientOptions): WebClient;
+
+export function generateWebClient(token?: string, ...args: any[]): WebClient {
+  if (typeof args[0] === 'string') {
+    return new WebClient(token, { logLevel, slackApiUrl: args[0], headers: args[1] });
+  }
+
+  return new WebClient(token, { logLevel, ...args });
+}

+ 21 - 0
packages/slack/src/utils/welcome-message.ts

@@ -0,0 +1,21 @@
+import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
+
+export const postWelcomeMessage = (client: WebClient, userId: string): Promise<ChatPostMessageResponse> => {
+  return client.chat.postMessage({
+    channel: userId,
+    user: userId,
+    blocks: [
+      {
+        type: 'section',
+        text: {
+          type: 'mrkdwn',
+          text: ':tada: You have successfully installed GROWI Official bot on this Slack workspace.\n'
+            + 'At first you do `/growi register` in the channel that you want to use.\n'
+            + 'Looking for additional help?'
+            // eslint-disable-next-line max-len
+            + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.',
+        },
+      },
+    ],
+  });
+};

+ 15 - 0
packages/slackbot-proxy/CHANGES.md

@@ -0,0 +1,15 @@
+# CHANGES
+
+## v1.0.2
+
+* Fix: Label for the install succeeded screen
+
+## v1.0.1
+
+* Improvement: Automatically add GROWI bot user to the channel where the command was executed
+* Improvement: Post a welcome message when the installation is successful
+* Improvement: Post a warning message when none of GROWI permit the command
+* Improvement: Permission scopes
+* Improvement: Top page information
+* Improvement: Add a link to docs
+

+ 4 - 1
packages/slackbot-proxy/docker/Dockerfile

@@ -37,7 +37,10 @@ RUN tar cf node_modules.tar node_modules \
 FROM deps-resolver-base AS deps-resolver-prod
 FROM deps-resolver-base AS deps-resolver-prod
 RUN npx lerna bootstrap -- --production
 RUN npx lerna bootstrap -- --production
 # make artifacts
 # make artifacts
-RUN tar cf dependencies.tar node_modules packages/slackbot-proxy/node_modules
+RUN tar cf dependencies.tar \
+  node_modules \
+  packages/slack/node_modules \
+  packages/slackbot-proxy/node_modules
 
 
 
 
 ##
 ##

+ 2 - 1
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "1.0.0-RC",
+  "version": "1.0.2",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -37,6 +37,7 @@
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "compression": "^1.7.4",
     "compression": "^1.7.4",
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
+    "date-fns": "^2.23.0",
     "express-bunyan-logger": "^1.3.3",
     "express-bunyan-logger": "^1.3.3",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "helmet": "^4.6.0",
     "helmet": "^4.6.0",

+ 9 - 1
packages/slackbot-proxy/src/Server.ts

@@ -42,7 +42,12 @@ const connectionOptions: ConnectionOptions = {
 } as ConnectionOptions;
 } as ConnectionOptions;
 
 
 const swaggerSettings = isProduction ? swaggerSettingsForProd : swaggerSettingsForDev;
 const swaggerSettings = isProduction ? swaggerSettingsForProd : swaggerSettingsForDev;
-const helmetOptions = isProduction ? {} : {
+const helmetOptions = isProduction ? {
+  contentSecurityPolicy: false,
+  expectCt: false,
+  referrerPolicy: false,
+  permittedCrossDomainPolicies: false,
+} : {
   contentSecurityPolicy: {
   contentSecurityPolicy: {
     directives: {
     directives: {
       defaultSrc: ['\'self\''],
       defaultSrc: ['\'self\''],
@@ -51,6 +56,9 @@ const helmetOptions = isProduction ? {} : {
       scriptSrc: ['\'self\'', 'https: \'unsafe-inline\''],
       scriptSrc: ['\'self\'', 'https: \'unsafe-inline\''],
     },
     },
   },
   },
+  expectCt: false,
+  referrerPolicy: false,
+  permittedCrossDomainPolicies: false,
 };
 };
 
 
 @Configuration({
 @Configuration({

+ 1 - 0
packages/slackbot-proxy/src/config/logger/config.dev.ts

@@ -10,6 +10,7 @@ const config: UniversalBunyanConfig = {
    */
    */
   // 'express:*': 'debug',
   // 'express:*': 'debug',
   // 'slackbot-proxy:*': 'debug',
   // 'slackbot-proxy:*': 'debug',
+  'slackbot-proxy:controllers:growi-to-slack': 'debug',
 
 
 };
 };
 
 

+ 23 - 10
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -5,13 +5,13 @@ import axios from 'axios';
 import createError from 'http-errors';
 import createError from 'http-errors';
 import { addHours } from 'date-fns';
 import { addHours } from 'date-fns';
 
 
-import { WebAPICallResult } from '@slack/web-api';
+import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 
 
 import {
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
-import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/slack-to-growi/add-webclient-response-to-res';
+import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 
 
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { InstallationRepository } from '~/repositories/installation';
 import { InstallationRepository } from '~/repositories/installation';
@@ -61,6 +61,7 @@ export class GrowiToSlackCtrl {
       headers: {
       headers: {
         'x-growi-ptog-tokens': tokenPtoG,
         'x-growi-ptog-tokens': tokenPtoG,
       },
       },
+      timeout: REQUEST_TIMEOUT_FOR_PTOG,
     });
     });
   }
   }
 
 
@@ -245,13 +246,13 @@ export class GrowiToSlackCtrl {
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(
   async callSlackApi(
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
-  ): Promise<void|string|Res|WebAPICallResult> {
+  ): Promise<WebclientRes> {
     const { tokenGtoPs } = req;
     const { tokenGtoPs } = req;
 
 
     logger.debug('Slack API called: ', { method });
     logger.debug('Slack API called: ', { method });
 
 
     if (tokenGtoPs.length !== 1) {
     if (tokenGtoPs.length !== 1) {
-      return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
+      return res.simulateWebAPIPlatformError('tokenGtoPs is invalid', 'invalid_tokenGtoP');
     }
     }
 
 
     const tokenGtoP = tokenGtoPs[0];
     const tokenGtoP = tokenGtoPs[0];
@@ -263,15 +264,18 @@ export class GrowiToSlackCtrl {
       .getOne();
       .getOne();
 
 
     if (relation == null) {
     if (relation == null) {
-      return res.webClientErr('relation is invalid', 'invalid_relation');
+      return res.simulateWebAPIPlatformError('relation is invalid', 'invalid_relation');
     }
     }
 
 
     const token = relation.installation.data.bot?.token;
     const token = relation.installation.data.bot?.token;
     if (token == null) {
     if (token == null) {
-      return res.webClientErr('installation is invalid', 'invalid_installation');
+      return res.simulateWebAPIPlatformError('installation is invalid', 'invalid_installation');
     }
     }
 
 
-    const client = generateWebClient(token);
+    // generate WebClient with no retry because GROWI main side will do
+    const client = generateWebClient(token, {
+      retryConfig: { retries: 0 },
+    });
 
 
     try {
     try {
       this.injectGrowiUri(req, relation.growiUri);
       this.injectGrowiUri(req, relation.growiUri);
@@ -279,11 +283,20 @@ export class GrowiToSlackCtrl {
       const opt = req.body;
       const opt = req.body;
       opt.headers = req.headers;
       opt.headers = req.headers;
 
 
-      return client.apiCall(method, opt);
+      logger.debug({ method, opt });
+      // !! DO NOT REMOVE `await ` or it does not enter catch block even when axios error occured !! -- 2021.08.22 Yuki Takei
+      const result = await client.apiCall(method, opt);
+
+      return res.send(result);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
+
+      if (err.code === ErrorCode.PlatformError) {
+        return res.simulateWebAPIPlatformError(err.message, err.code);
+      }
+
+      return res.simulateWebAPIRequestError(err.message, err.response?.status);
     }
     }
   }
   }
 
 

+ 144 - 88
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,13 +1,16 @@
 import {
 import {
-  BodyParams, Controller, Get, Inject, Post, Req, Res, UseBefore,
+  BodyParams, Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
 } from '@tsed/common';
 } from '@tsed/common';
 
 
 import axios from 'axios';
 import axios from 'axios';
 
 
 import { WebAPICallResult } from '@slack/web-api';
 import { WebAPICallResult } from '@slack/web-api';
+import { Installation } from '@slack/oauth';
+
 
 
 import {
 import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
   markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
+  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import { Relation } from '~/entities/relation';
 import { Relation } from '~/entities/relation';
@@ -25,6 +28,7 @@ import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { InvalidUrlError } from '../models/errors';
 import { InvalidUrlError } from '../models/errors';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
 
 
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
@@ -80,6 +84,7 @@ export class SlackCtrl {
         headers: {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
         },
+        timeout: REQUEST_TIMEOUT_FOR_PTOG,
       });
       });
     });
     });
 
 
@@ -97,22 +102,30 @@ export class SlackCtrl {
   }
   }
 
 
   @Post('/commands')
   @Post('/commands')
-  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware)
+  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware, JoinToConversationMiddleware)
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     const { body, authorizeResult } = req;
     const { body, authorizeResult } = req;
 
 
-    if (body.text == null) {
-      return 'No text.';
-    }
+    let growiCommand;
 
 
-    const growiCommand = parseSlashCommand(body);
+    try {
+      growiCommand = parseSlashCommand(body);
+    }
+    catch (err) {
+      if (err instanceof InvalidGrowiCommandError) {
+        res.json({
+          blocks: [
+            markdownSectionBlock('*Command type is not specified.*'),
+            markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+          ],
+        });
+      }
+      logger.error(err.message);
+      return;
+    }
 
 
     // register
     // register
     if (growiCommand.growiCommandType === 'register') {
     if (growiCommand.growiCommandType === 'register') {
-      // Send response immediately to avoid opelation_timeout error
-      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-      res.send();
-
       return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
       return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
     }
     }
 
 
@@ -125,10 +138,6 @@ export class SlackCtrl {
         return 'GROWI Urls must be urls.';
         return 'GROWI Urls must be urls.';
       }
       }
 
 
-      // Send response immediately to avoid opelation_timeout error
-      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-      res.send();
-
       return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
       return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
     }
     }
 
 
@@ -161,57 +170,80 @@ export class SlackCtrl {
 
 
     // Send response immediately to avoid opelation_timeout error
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Processing your request ...',
+    });
 
 
     const baseDate = new Date();
     const baseDate = new Date();
 
 
-    const relationsForSingleUse:Relation[] = [];
+    const allowedRelationsForSingleUse:Relation[] = [];
+    const allowedRelationsForBroadcastUse:Relation[] = [];
+    const disallowedGrowiUrls: Set<string> = new Set();
+
+    // check permission
     await Promise.all(relations.map(async(relation) => {
     await Promise.all(relations.map(async(relation) => {
-      const isSupported = await this.relationsService.isSupportedGrowiCommandForSingleUse(relation, growiCommand.growiCommandType, baseDate);
-      if (isSupported) {
-        relationsForSingleUse.push(relation);
+      const isSupportedForSingleUse = await this.relationsService.isSupportedGrowiCommandForSingleUse(
+        relation, growiCommand.growiCommandType, baseDate,
+      );
+
+      let isSupportedForBroadcastUse = false;
+      if (!isSupportedForSingleUse) {
+        isSupportedForBroadcastUse = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(
+          relation, growiCommand.growiCommandType, baseDate,
+        );
       }
       }
-    }));
 
 
-    let isCommandPermitted = false;
-
-    if (relationsForSingleUse.length > 0) {
-      isCommandPermitted = true;
-      body.growiUrisForSingleUse = relationsForSingleUse.map(v => v.growiUri);
-      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
-    }
-
-    const relationsForBroadcastUse:Relation[] = [];
-    await Promise.all(relations.map(async(relation) => {
-      const isSupported = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(relation, growiCommand.growiCommandType, baseDate);
-      if (isSupported) {
-        relationsForBroadcastUse.push(relation);
+      if (isSupportedForSingleUse) {
+        allowedRelationsForSingleUse.push(relation);
+      }
+      else if (isSupportedForBroadcastUse) {
+        allowedRelationsForBroadcastUse.push(relation);
+      }
+      else {
+        disallowedGrowiUrls.add(relation.growiUri);
       }
       }
     }));
     }));
 
 
-    /*
-     * forward to GROWI server
-     */
-    if (relationsForBroadcastUse.length > 0) {
-      isCommandPermitted = true;
-      this.sendCommand(growiCommand, relationsForBroadcastUse, body);
-    }
+    // when all of GROWI disallowed
+    if (relations.length === disallowedGrowiUrls.size) {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const client = generateWebClient(authorizeResult.botToken!);
 
 
-    if (!isCommandPermitted) {
-      const botToken = relations[0].installation?.data.bot?.token;
+      const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
+        return '\n'
+          + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
+      });
 
 
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const client = generateWebClient(botToken!);
+      const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
 
 
       return client.chat.postEphemeral({
       return client.chat.postEphemeral({
         text: 'Error occured.',
         text: 'Error occured.',
         channel: body.channel_id,
         channel: body.channel_id,
         user: body.user_id,
         user: body.user_id,
         blocks: [
         blocks: [
-          markdownSectionBlock(`It is not allowed to run *'${growiCommand.growiCommandType}'* command to this GROWI.`),
+          markdownSectionBlock('*None of GROWI permitted the command.*'),
+          markdownSectionBlock(`*'${growiCommand.growiCommandType}'* command was not allowed.`),
+          markdownSectionBlock(
+            `To use this command, modify settings from following pages: ${linkUrlList}`,
+          ),
+          markdownSectionBlock(
+            `Or, if your GROWI version is 4.3.0 or below, upgrade GROWI to use commands and permission settings: ${growiDocsLink}`,
+          ),
         ],
         ],
       });
       });
     }
     }
+
+    // select GROWI
+    if (allowedRelationsForSingleUse.length > 0) {
+      body.growiUrisForSingleUse = allowedRelationsForSingleUse.map(v => v.growiUri);
+      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
+    }
+
+    // forward to GROWI server
+    if (allowedRelationsForBroadcastUse.length > 0) {
+      return this.sendCommand(growiCommand, allowedRelationsForBroadcastUse, body);
+    }
   }
   }
 
 
   @Post('/interactions')
   @Post('/interactions')
@@ -222,10 +254,6 @@ export class SlackCtrl {
 
 
     const { body, authorizeResult } = req;
     const { body, authorizeResult } = req;
 
 
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
     // pass
     // pass
     if (body.ssl_check != null) {
     if (body.ssl_check != null) {
       return;
       return;
@@ -263,10 +291,18 @@ export class SlackCtrl {
 
 
     // forward to GROWI server
     // forward to GROWI server
     if (callBackId === 'select_growi') {
     if (callBackId === 'select_growi') {
+      // Send response immediately to avoid opelation_timeout error
+      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+      res.send();
+
       const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
       const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
       return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
       return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
     }
     }
 
 
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    res.send();
+
     /*
     /*
     * forward to GROWI server
     * forward to GROWI server
     */
     */
@@ -286,6 +322,7 @@ export class SlackCtrl {
         headers: {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
         },
+        timeout: REQUEST_TIMEOUT_FOR_PTOG,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -307,50 +344,69 @@ export class SlackCtrl {
   }
   }
 
 
   @Get('/oauth_redirect')
   @Get('/oauth_redirect')
-  async handleOauthRedirect(@Req() req: Req, @Res() res: Res): Promise<void> {
-
-    if (req.query.state === '') {
-      res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
-      res.end('<html>'
-      + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
-      + '<body style="text-align:center; padding-top:20%;">'
-      + '<h1>Illegal state, try it again.</h1>'
-      + '<a href="/">'
-      + 'Go to install page'
-      + '</a>'
-      + '</body></html>');
+  async handleOauthRedirect(@Req() req: Req, @Res() serverRes: Res, @Res() platformRes: PlatformResponse): Promise<void|string> {
+
+    // create 'Add to Slack' url
+    const addToSlackUrl = await this.installerService.installer.generateInstallUrl({
+      scopes: requiredScopes,
+    });
+
+    const state = req.query.state;
+    if (state == null || state === '') {
+      return platformRes.status(400).render('install-failed.ejs', { url: addToSlackUrl });
     }
     }
 
 
-    await this.installerService.installer.handleCallback(req, res, {
-      success: (installation, metadata, req, res) => {
-        logger.info('Success to install', { installation, metadata });
+    // promisify
+    const installPromise = new Promise<Installation>((resolve, reject) => {
+      this.installerService.installer.handleCallback(req, serverRes, {
+        success: async(installation, metadata) => {
+          logger.info('Success to install', { installation, metadata });
+          resolve(installation);
+        },
+        failure: async(error) => {
+          reject(error); // go to catch block
+        },
+      });
+    });
+
+    let httpStatus = 200;
+    let httpBody;
+    try {
+      const installation = await installPromise;
 
 
+      // check whether bot is not null
+      if (installation.bot == null) {
+        logger.warn('Success to install but something wrong. `installation.bot` is null.');
+        httpStatus = 500;
+        httpBody = await platformRes.render('install-succeeded-but-has-problem.ejs', { reason: '`installation.bot` is null' });
+      }
+      // MAIN PATH: everything is fine
+      else {
         const appPageUrl = `https://slack.com/apps/${installation.appId}`;
         const appPageUrl = `https://slack.com/apps/${installation.appId}`;
+        httpBody = await platformRes.render('install-succeeded.ejs', { appPageUrl });
 
 
-        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
-        res.end('<html>'
-        + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
-        + '<body style="text-align:center; padding-top:20%;">'
-        + '<h1>Congratulations!</h1>'
-        + '<p>GROWI Bot installation has succeeded.</p>'
-        + `<a href="${appPageUrl}">`
-        + 'Access to Slack App detail page.'
-        + '</a>'
-        + '</body></html>');
-      },
-      failure: (error, installOptions, req, res) => {
-        res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
-        res.end('<html>'
-        + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
-        + '<body style="text-align:center; padding-top:20%;">'
-        + '<h1>GROWI Bot installation failed</h1>'
-        + '<p>Please contact administrators of your workspace</p>'
-        + 'Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">'
-        + 'Manage app installation settings for your workspace'
-        + '</a>'
-        + '</body></html>');
-      },
-    });
+        // generate client
+        const client = generateWebClient(installation.bot.token);
+
+        const userId = installation.user.id;
+
+        await Promise.all([
+          // post message
+          postWelcomeMessage(client, userId),
+          // publish home
+          // TODO When Home tab show off, use bellow.
+          // publishInitialHomeView(client, userId),
+        ]);
+      }
+    }
+    catch (error) {
+      logger.error(error);
+      httpStatus = 500;
+      httpBody = await platformRes.status(400).render('install-failed.ejs', { url: addToSlackUrl });
+    }
+
+    platformRes.status(httpStatus);
+    return httpBody;
   }
   }
 
 
 }
 }

+ 2 - 9
packages/slackbot-proxy/src/controllers/top.ts

@@ -2,6 +2,7 @@ import {
   Controller, Get, Inject, View,
   Controller, Get, Inject, View,
 } from '@tsed/common';
 } from '@tsed/common';
 
 
+import { requiredScopes } from '@growi/slack';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 
 
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
@@ -18,15 +19,7 @@ export class TopCtrl {
   async getTopPage(): Promise<any> {
   async getTopPage(): Promise<any> {
     const url = await this.installerService.installer.generateInstallUrl({
     const url = await this.installerService.installer.generateInstallUrl({
       // Add the scopes your app needs
       // Add the scopes your app needs
-      scopes: [
-        'channels:history',
-        'commands',
-        'groups:history',
-        'im:history',
-        'mpim:history',
-        'chat:write',
-        'team:read',
-      ],
+      scopes: requiredScopes,
     });
     });
 
 
     return { url, isOfficialMode };
     return { url, isOfficialMode };

+ 30 - 0
packages/slackbot-proxy/src/middlewares/growi-to-slack/add-webclient-response-to-res.ts

@@ -0,0 +1,30 @@
+import { ErrorCode } from '@slack/web-api';
+import {
+  IMiddleware, Middleware, Next, Req, Res,
+} from '@tsed/common';
+
+
+export type WebclientRes = Res & {
+  simulateWebAPIRequestError: (error: string, statusCode: number) => WebclientRes
+  simulateWebAPIPlatformError: (error: string, errorCode?:string) => WebclientRes
+};
+
+
+@Middleware()
+export class AddWebclientResponseToRes implements IMiddleware {
+
+  use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
+
+    // https://github.com/slackapi/node-slack-sdk/blob/7b95663a9ef31036367c066ccbf0021423278f40/packages/web-api/src/WebClient.ts#L356-L358
+    res.simulateWebAPIRequestError = (error: string, statusCode?: number) => {
+      return res.status(statusCode || 500).send({ error, errorCode: ErrorCode.RequestError });
+    };
+    // https://github.com/slackapi/node-slack-sdk/blob/7b95663a9ef31036367c066ccbf0021423278f40/packages/web-api/src/WebClient.ts#L197-L199
+    res.simulateWebAPIPlatformError = (error: string, errorCode?: string) => {
+      return res.send({ ok: false, error, errorCode });
+    };
+
+    next();
+  }
+
+}

+ 0 - 23
packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts

@@ -1,23 +0,0 @@
-import {
-  IMiddleware, Middleware, Next, Req, Res,
-} from '@tsed/common';
-
-
-export type WebclientRes = Res & {
-  webClientErr: (message?:string, errorCode?:string) => void
-};
-
-
-@Middleware()
-export class AddWebclientResponseToRes implements IMiddleware {
-
-  use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
-
-    res.webClientErr = (error?:string, errorCode?:string) => {
-      return res.send({ ok: false, error, errorCode });
-    };
-
-    next();
-  }
-
-}

+ 32 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts

@@ -0,0 +1,32 @@
+import { generateWebClient } from '@growi/slack';
+import {
+  IMiddleware, Middleware, Req,
+} from '@tsed/common';
+
+import Logger from 'bunyan';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+
+import loggerFactory from '~/utils/logger';
+
+const logger: Logger = loggerFactory('slackbot-proxy:middlewares:JoinToConversationsMiddleware');
+
+
+/**
+ * This middleware should be processed after AuthorizeCommandMiddleware or AuthorizeInteractionMiddleware
+ */
+@Middleware()
+export class JoinToConversationMiddleware implements IMiddleware {
+
+  async use(@Req() req: SlackOauthReq): Promise<void> {
+    const { body, authorizeResult } = req;
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const client = generateWebClient(authorizeResult.botToken!);
+
+    const joinResult = await client.conversations.join({ channel: body.channel_id });
+    if (!joinResult.ok) {
+      logger.error(joinResult.error, joinResult);
+    }
+  }
+
+}

BIN
packages/slackbot-proxy/src/public/images/growi-bot.png


+ 23 - 8
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,6 +1,8 @@
 import { Inject, Service } from '@tsed/di';
 import { Inject, Service } from '@tsed/di';
 import { WebClient, LogLevel, Block } from '@slack/web-api';
 import { WebClient, LogLevel, Block } from '@slack/web-api';
-import { markdownSectionBlock, inputSectionBlock, GrowiCommand } from '@growi/slack';
+import {
+  markdownSectionBlock, markdownHeaderBlock, inputSectionBlock, GrowiCommand,
+} from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 import { AuthorizeResult } from '@slack/oauth';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { OrderRepository } from '~/repositories/order';
 import { OrderRepository } from '~/repositories/order';
@@ -103,19 +105,32 @@ export class RegisterService implements GrowiCommandProcessor {
 
 
     const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
     const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
 
 
+    const blocks: Block[] = [];
+
     if (isOfficialMode) {
     if (isOfficialMode) {
-      const blocks = [
-        markdownSectionBlock('Successfully registered with the proxy! Please check test connection in your GROWI'),
-      ];
+      blocks.push(markdownHeaderBlock(':white_check_mark: 1. Install Official bot to Slack'));
+      blocks.push(markdownHeaderBlock(':white_check_mark: 2. Register for GROWI Official Bot Proxy Service'));
+      blocks.push(markdownSectionBlock('The request has been successfully accepted. However, registration has *NOT been completed* yet.'));
+      blocks.push(markdownHeaderBlock(':arrow_right: 3. Test Connection'));
+      blocks.push(markdownSectionBlock('*Test Connection* to complete the registration in your GROWI.'));
+      blocks.push(markdownHeaderBlock(':white_large_square: 4. (Opt) Manage GROWI commands'));
+      blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
       await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
       await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
       return;
       return;
 
 
     }
     }
 
 
-    const blocks = [
-      markdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
-      markdownSectionBlock(`Proxy URL: ${serverUri}`),
-    ];
+    blocks.push(markdownHeaderBlock(':white_check_mark: 1. Create Bot'));
+    blocks.push(markdownHeaderBlock(':white_check_mark: 2. Install bot to Slack'));
+    blocks.push(markdownHeaderBlock(':white_check_mark: 3. Register for your GROWI Custom Bot Proxy'));
+    blocks.push(markdownSectionBlock('The request has been successfully accepted. However, registration has *NOT been completed* yet.'));
+    blocks.push(markdownHeaderBlock(':arrow_right: 4. Set Proxy URL on GROWI'));
+    blocks.push(markdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'));
+    blocks.push(markdownSectionBlock(`Proxy URL: ${serverUri}`));
+    blocks.push(markdownHeaderBlock(':arrow_right: 5. Test Connection'));
+    blocks.push(markdownSectionBlock('And *Test Connection* to complete the registration in your GROWI.'));
+    blocks.push(markdownHeaderBlock(':white_large_square: 6. (Opt) Manage GROWI commands'));
+    blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
     await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
     await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
     return;
     return;
   }
   }

+ 4 - 0
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -1,7 +1,10 @@
 import { Inject, Service } from '@tsed/di';
 import { Inject, Service } from '@tsed/di';
+
 import axios from 'axios';
 import axios from 'axios';
 import { addHours } from 'date-fns';
 import { addHours } from 'date-fns';
 
 
+import { REQUEST_TIMEOUT_FOR_PTOG } from '@growi/slack';
+
 import { Relation } from '~/entities/relation';
 import { Relation } from '~/entities/relation';
 import { RelationRepository } from '~/repositories/relation';
 import { RelationRepository } from '~/repositories/relation';
 
 
@@ -22,6 +25,7 @@ export class RelationsService {
       headers: {
       headers: {
         'x-growi-ptog-tokens': relation.tokenPtoG,
         'x-growi-ptog-tokens': relation.tokenPtoG,
       },
       },
+      timeout: REQUEST_TIMEOUT_FOR_PTOG,
     });
     });
   }
   }
 
 

+ 21 - 0
packages/slackbot-proxy/src/views/install-failed.ejs

@@ -0,0 +1,21 @@
+<%- include('commons/head'); %>
+
+<body>
+  <div class="container">
+    <div class="row">
+      <div class="col text-center">
+        <h1 class="my-5">GROWI Bot installation failed..</h1>
+        <p>
+          Retry from
+          <a href=<%- url %>>
+            <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
+          </a>
+        </p>
+        <p>
+          Or, please contact administrators of your workspace<br>
+          Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">Manage app installation settings for your workspace</a>
+        </p>
+      </div>
+    </div>
+  </div>
+</body>

+ 18 - 0
packages/slackbot-proxy/src/views/install-succeeded-but-has-problem.ejs

@@ -0,0 +1,18 @@
+<%- include('commons/head'); %>
+
+<body>
+  <div class="container">
+    <div class="row">
+      <div class="col text-center">
+        <h1 class="my-5">GROWI Bot installation has succeeded, but something went wrong..</h1>
+        <p>
+          Reason: <%- reason%>
+        </p>
+        <p>
+          Please contact administrators of your workspace<br>
+          Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">Manage app installation settings for your workspace</a>
+        </p>
+      </div>
+    </div>
+  </div>
+</body>

+ 21 - 0
packages/slackbot-proxy/src/views/install-succeeded.ejs

@@ -0,0 +1,21 @@
+<%- include('commons/head'); %>
+
+<body>
+  <div class="container">
+    <div class="row">
+      <div class="col text-center">
+        <h1 class="my-5">Congratulations!</h1>
+        <h2 class="my-5">GROWI Bot installation has succeeded!</h2>
+        <p>
+          Access to
+          <a href=<%- appPageUrl %>>
+            Slack App detail page.
+          </a>
+        </p>
+        <p>
+          <a class="btn btn-outline-success" href="https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html">Getting started</a>
+        </p>
+      </div>
+    </div>
+  </div>
+</body>

+ 1 - 0
packages/slackbot-proxy/src/views/top.ejs

@@ -14,6 +14,7 @@
           <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
           <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
         </a>
         </a>
       </div>
       </div>
+      <a class="btn btn-outline-success" href="https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html">Getting started</a>
       <div class="d-flex justify-content-evenly my-3">
       <div class="d-flex justify-content-evenly my-3">
         <% if (isOfficialMode) { %>
         <% if (isOfficialMode) { %>
           <a href="/privacy">
           <a href="/privacy">

+ 4 - 0
packages/slackbot-proxy/tsconfig.base.json

@@ -2,10 +2,14 @@
   "extends": "../../tsconfig.base.json",
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
   "compilerOptions": {
   },
   },
+  "include": [
+    "src"
+  ],
   "exclude": [
   "exclude": [
     "node_modules",
     "node_modules",
     "config",
     "config",
     "dist",
     "dist",
+    "src/public/**",
     "**/*.test.ts"
     "**/*.test.ts"
   ]
   ]
 }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов