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

Merge branch 'master' into feat/7152-enable-togetter

Yuki Takei 4 лет назад
Родитель
Сommit
2df29e07f8
50 измененных файлов с 1183 добавлено и 1028 удалено
  1. 9 9
      packages/app/package.json
  2. BIN
      packages/app/public/images/slack-integration/growi-register-sentence.png
  3. 5 2
      packages/app/resource/locales/en_US/admin/admin.json
  4. 0 1
      packages/app/resource/locales/en_US/translation.json
  5. 5 2
      packages/app/resource/locales/ja_JP/admin/admin.json
  6. 0 1
      packages/app/resource/locales/ja_JP/translation.json
  7. 5 2
      packages/app/resource/locales/zh_CN/admin/admin.json
  8. 0 1
      packages/app/resource/locales/zh_CN/translation.json
  9. 5 1
      packages/app/src/client/util/apiNotification.js
  10. 39 17
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  11. 7 5
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  12. 36 15
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  13. 53 0
      packages/app/src/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx
  14. 3 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  15. 41 31
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  16. 2 2
      packages/app/src/components/PageComment/CommentEditor.jsx
  17. 3 3
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  18. 10 45
      packages/app/src/server/crowi/index.js
  19. 1 1
      packages/app/src/server/models/config.ts
  20. 0 1
      packages/app/src/server/models/index.js
  21. 1 0
      packages/app/src/server/models/slack-app-integration.js
  22. 122 0
      packages/app/src/server/models/update-post.ts
  23. 0 153
      packages/app/src/server/models/updatePost.js
  24. 2 2
      packages/app/src/server/routes/admin.js
  25. 0 1
      packages/app/src/server/routes/apiv3/notification-setting.js
  26. 14 11
      packages/app/src/server/routes/apiv3/page.js
  27. 193 160
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  28. 18 59
      packages/app/src/server/routes/apiv3/slack-integration.js
  29. 1 3
      packages/app/src/server/service/app.ts
  30. 11 7
      packages/app/src/server/service/global-notification/global-notification-slack.js
  31. 279 0
      packages/app/src/server/service/slack-integration.ts
  32. 0 22
      packages/app/src/server/service/slack-notification.js
  33. 0 136
      packages/app/src/server/service/slackbot.ts
  34. 0 61
      packages/app/src/server/service/user-notification/index.js
  35. 82 0
      packages/app/src/server/service/user-notification/index.ts
  36. 15 43
      packages/app/src/server/util/slack-legacy.js
  37. 123 182
      packages/app/src/server/util/slack.js
  38. 2 0
      packages/app/src/server/views/admin/slack-integration.html
  39. 4 7
      packages/app/src/test/utils/slack-legacy.test.js
  40. 4 0
      packages/slack/src/index.ts
  41. 11 1
      packages/slack/src/utils/block-kit-builder.ts
  42. 6 1
      packages/slack/src/utils/check-communicable.ts
  43. 1 1
      packages/slack/src/utils/webclient-factory.ts
  44. 1 0
      packages/slackbot-proxy/src/config/logger/config.dev.ts
  45. 7 4
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  46. 35 26
      packages/slackbot-proxy/src/controllers/slack.ts
  47. 0 0
      packages/slackbot-proxy/src/middlewares/growi-to-slack/add-webclient-response-to-res.ts
  48. BIN
      packages/slackbot-proxy/src/public/images/growi-bot.png
  49. 23 8
      packages/slackbot-proxy/src/services/RegisterService.ts
  50. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts

+ 9 - 9
packages/app/package.json

@@ -6,19 +6,19 @@
     "//// for production": "",
     "start": "yarn build && yarn server",
     "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",
     "prebuild": "run-p clean resources:*",
     "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",
     "preserver": "yarn migrate",
     "//// for development": "",
     "dev": "run-p dev:client dev:server",
-    "dev:client": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
+    "dev:client": "yarn 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",
+    "dev:server": "yarn cross-env NODE_ENV=development yarn ts-node-dev src/server/app.ts --expose_gc",
     "predev:client": "run-p resources:*",
     "predev:server": "yarn migrate",
     "//// for CI": "",
@@ -33,10 +33,10 @@
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "//// 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",
-    "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:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "migrate": "yarn migrate:up",
@@ -45,7 +45,7 @@
     "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",
     "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": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",

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


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

@@ -282,6 +282,9 @@
       "cancel": "Cancel",
       "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.",
     "access_token_settings": {
       "regenerate": "Regenerate"
@@ -309,8 +312,8 @@
       "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>",
       "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.",
       "select_install_your_app": "Select \"Install your app\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",

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

@@ -438,7 +438,6 @@
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{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}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",

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

@@ -282,6 +282,9 @@
       "cancel": "取消",
       "change": "変更する"
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "Slack 連携手順を削除しました"
+    },
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
     "access_token_settings": {
       "regenerate": "再発行"
@@ -308,8 +311,8 @@
       "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>を入れる",
       "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": "※既に値が入っている場合は更新する必要はありません",
       "select_install_your_app": "Install your app をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",

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

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

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

@@ -292,6 +292,9 @@
       "cancel": "取消",
       "change": "改变"
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "删除了 Slack 集成程序"
+    },
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <code>{{variable}}</code> 启用。",
     "access_token_settings": {
       "regenerate": "再生"
@@ -318,8 +321,8 @@
       "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>。",
       "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": "※如果值已经在里面了,就不需要再更新。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",

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

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

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

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

+ 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 PropTypes from 'prop-types';
+
 import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -8,12 +9,15 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 
-const logger = loggerFactory('growi:SlackBotSettings');
+const logger = loggerFactory('growi:cli:SlackIntegration:CustomBotWithProxySettings');
 
 const CustomBotWithProxySettings = (props) => {
   const {
-    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations, proxyServerUri,
+    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
+    connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   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() => {
     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) {
         props.onDeleteSlackAppIntegration();
       }
-      toastSuccess(t('toaster.delete_slack_integration_procedure'));
+      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
     }
     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' }));
     }
     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}`}>
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                 </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>
               <WithProxyAccordions
                 botType="customBotWithProxy"
@@ -168,6 +189,7 @@ CustomBotWithProxySettings.propTypes = {
   slackAppIntegrations: PropTypes.array,
   proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onPrimaryUpdated: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   onSubmitForm: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,

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

@@ -51,7 +51,7 @@ const ManageCommandsProcess = ({
 
   const updateCommandsHandler = async() => {
     try {
-      await apiv3Put(`/slack-integration-settings/${slackAppIntegrationId}/supported-commands`, {
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
         supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
         supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
       });
@@ -75,18 +75,19 @@ const ManageCommandsProcess = ({
           <div className="custom-control custom-checkbox">
             <div className="row mb-5">
               {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
+                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
                 return (
                   <div className="col-sm-6 my-1" key={commandName}>
                     <input
                       type="checkbox"
                       className="custom-control-input"
-                      id={commandName}
+                      id={checkboxId}
                       name={commandName}
                       value={commandName}
                       checked={selectedCommandsForBroadcastUse.has(commandName)}
                       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}
                     </label>
                   </div>
@@ -100,18 +101,19 @@ const ManageCommandsProcess = ({
           <div className="custom-control custom-checkbox">
             <div className="row mb-5">
               {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
+                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
                 return (
                   <div className="col-sm-6 my-1" key={commandName}>
                     <input
                       type="checkbox"
                       className="custom-control-input"
-                      id={commandName}
+                      id={checkboxId}
                       name={commandName}
                       value={commandName}
                       checked={selectedCommandsForSingleUse.has(commandName)}
                       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}
                     </label>
                   </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 { useTranslation } from 'react-i18next';
 import loggerFactory from '~/utils/logger';
@@ -8,12 +8,15 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 
-const logger = loggerFactory('growi:SlackBotSettings');
+const logger = loggerFactory('growi:cli:SlackIntegration:OfficialBotSettings');
 
 const OfficialBotSettings = (props) => {
   const {
-    appContainer, slackAppIntegrations, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations,
+    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
+    connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   const [siteName, setSiteName] = useState('');
   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() => {
-    await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
+    await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
     try {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
       }
-      toastSuccess(t('toaster.delete_slack_integration_procedure'));
+      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
     }
     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}`}>
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                 </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>
               <WithProxyAccordions
                 botType="officialBot"
@@ -133,6 +153,7 @@ OfficialBotSettings.propTypes = {
 
   slackAppIntegrations: PropTypes.array,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onPrimaryUpdated: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
   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() => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/slack-app-integrations');
+      await appContainer.apiv3.post('/slack-integration-settings/slack-app-integrations');
       fetchSlackIntegrationData();
       toastSuccess(t('admin:slack_integration.adding_slack_ws_integration_settings_successful'));
     }
@@ -130,6 +130,7 @@ const SlackIntegration = (props) => {
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onPrimaryUpdated={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           onUpdateTokens={fetchSlackIntegrationData}
@@ -156,6 +157,7 @@ const SlackIntegration = (props) => {
           slackAppIntegrations={slackAppIntegrations}
           proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onPrimaryUpdated={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           onUpdateTokens={fetchSlackIntegrationData}

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

@@ -86,17 +86,27 @@ const RegisteringProxyUrlProcess = () => {
   const { t } = useTranslation();
   return (
     <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>
   );
 };
@@ -107,7 +117,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 
   const regenerateTokensHandler = async() => {
     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) {
         props.onUpdateTokens();
       }
@@ -215,7 +225,7 @@ const TestProcess = ({
   const submitForm = async(e) => {
     e.preventDefault();
     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);
       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',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -327,6 +328,15 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
+    '④': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
   };
 
   const CustomBotIntegrationProcedure = {
@@ -353,15 +363,6 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
-    '⑥': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -371,6 +372,15 @@ const WithProxyAccordions = (props) => {
         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;

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

@@ -62,7 +62,7 @@ class CommentEditor extends React.Component {
       isUploadable,
       isUploadableFile,
       errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
+      isSlackConfigured: config.isSlackConfigured,
     };
 
     this.updateState = this.updateState.bind(this);
@@ -354,7 +354,7 @@ class CommentEditor extends React.Component {
             <span className="flex-grow-1" />
             <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">
                   <SlackNotification

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

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

+ 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 ConfigManager from '../service/config-manager';
+import AppService from '../service/app';
 import AclService from '../service/acl';
 import AttachmentService from '../service/attachment';
+import { SlackIntegrationService } from '../service/slack-integration';
+import { UserNotificationService } from '../service/user-notification';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -45,7 +48,6 @@ function Crowi() {
   this.passportService = null;
   this.globalNotificationService = null;
   this.userNotificationService = null;
-  this.slackNotificationService = null;
   this.xssService = null;
   this.aclService = null;
   this.appService = null;
@@ -60,7 +62,7 @@ function Crowi() {
   this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
-  this.slackBotService = null;
+  this.slackIntegrationService = null;
   this.xss = new Xss();
 
   this.tokens = null;
@@ -93,12 +95,10 @@ Crowi.prototype.init = async function() {
 
   // customizeService depends on AppService and XssService
   // passportService depends on appService
-  // slack depends on setUpSlacklNotification
   // export and import depends on setUpGrowiBridge
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
-    this.setUpSlacklNotification(),
     this.setUpGrowiBridge(),
   ]);
 
@@ -107,8 +107,7 @@ Crowi.prototype.init = async function() {
     this.setupPassport(),
     this.setupSearcher(),
     this.setupMailer(),
-    this.setupSlack(),
-    this.setupSlackLegacy(),
+    this.setupSlackIntegrationService(),
     this.setupCsrf(),
     this.setUpFileUpload(),
     this.setUpFileUploaderSwitchService(),
@@ -121,7 +120,6 @@ Crowi.prototype.init = async function() {
     this.setupImport(),
     this.setupPageService(),
     this.setupSyncPageStatusService(),
-    this.setupSlackBotService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -137,11 +135,9 @@ Crowi.prototype.initForTest = async function() {
 
   // // customizeService depends on AppService and XssService
   // // passportService depends on appService
-  // // slack depends on setUpSlacklNotification
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
-    // this.setUpSlacklNotification(),
     // this.setUpGrowiBridge(),
   ]);
 
@@ -150,7 +146,7 @@ Crowi.prototype.initForTest = async function() {
     this.setupPassport(),
     // this.setupSearcher(),
     // this.setupMailer(),
-    // this.setupSlack(),
+    // this.setupSlackIntegrationService(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
     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() {
   const Tokens = require('csrf');
   this.tokens = new Tokens();
@@ -525,22 +503,11 @@ Crowi.prototype.setUpGlobalNotification = async function() {
  * setup UserNotificationService
  */
 Crowi.prototype.setUpUserNotification = async function() {
-  const UserNotificationService = require('../service/user-notification');
   if (this.userNotificationService == null) {
     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
  */
@@ -581,7 +548,6 @@ Crowi.prototype.setUpCustomize = async function() {
  * setup AppService
  */
 Crowi.prototype.setUpApp = async function() {
-  const AppService = require('../service/app');
   if (this.appService == null) {
     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
   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'),
     isEnabledAttachTitleHeader: crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
     customizeScript: crowi.configManager.getConfig('crowi', 'customize:script'),
-    hasSlackConfig: crowi.slackNotificationService.hasSlackConfig(),
+    isSlackConfigured: crowi.slackIntegrationService.isSlackConfigured,
     env: {
       PLANTUML_URI: env.PLANTUML_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'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),
-  UpdatePost: require('./updatePost'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),

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

@@ -4,6 +4,7 @@ const mongoose = require('mongoose');
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  isPrimary: { type: Boolean, unique: true, sparse: true },
   supportedCommandsForBroadcastUse: { 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 {
     configManager,
     aclService,
-    slackNotificationService,
+    slackIntegrationService,
     exportService,
   } = crowi;
 
@@ -160,7 +160,7 @@ module.exports = function(crowi, app) {
     const code = req.query.code;
     const { t } = req;
 
-    if (!code || !slackNotificationService.hasSlackConfig()) {
+    if (!code || !slackIntegrationService.isSlackConfigured()) {
       return res.redirect('/admin/notification');
     }
 

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

@@ -181,7 +181,6 @@ module.exports = (crowi) => {
         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) {

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

@@ -185,7 +185,7 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/Page'
    */
   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;
     try {
@@ -193,7 +193,8 @@ module.exports = (crowi) => {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
-      if (bool) {
+
+      if (isLiked) {
         page = await page.like(req.user);
       }
       else {
@@ -205,17 +206,19 @@ module.exports = (crowi) => {
       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 };
     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);
+      }
+    }
   });
 
   /**

+ 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 {
-  getConnectionStatus, getConnectionStatuses, sendSuccessMessage, defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+  getConnectionStatus, getConnectionStatuses,
+  sendSuccessMessage,
+  defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+  REQUEST_TIMEOUT_FOR_GTOP,
 } = require('@growi/slack');
 
 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 OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
+
 /**
  * @swagger
  *  tags:
@@ -51,10 +56,10 @@ module.exports = (crowi) => {
   const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
   const validator = {
-    BotType: [
+    botType: [
       body('currentBotType').isString(),
     ],
-    SlackIntegration: [
+    slackIntegration: [
       body('currentBotType')
         .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
     ],
@@ -62,42 +67,52 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
     ],
+    makePrimary: [
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     updateSupportedCommands: [
       body('supportedCommandsForSingleUse').toArray(),
       body('supportedCommandsForBroadcastUse').toArray(),
       param('id').isMongoId().withMessage('id is required'),
     ],
-    RelationTest: [
-      body('slackAppIntegrationId').isMongoId(),
+    relationTest: [
+      param('id').isMongoId(),
       body('channel').trim().isString(),
     ],
+    regenerateTokens: [
+      param('id').isMongoId(),
+    ],
     deleteIntegration: [
-      query('integrationIdToDelete').isMongoId(),
+      param('id').isMongoId(),
     ],
-    SlackChannel: [
+    slackChannel: [
       body('channel').trim().not().isEmpty()
         .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();
 
     const params = {
-      'slackbot:currentBotType': null,
+      'slackbot:currentBotType': initializedType,
       'slackbot:signingSecret': null,
       'slackbot:token': 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) {
@@ -107,6 +122,7 @@ module.exports = (crowi) => {
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
       headers: {
         '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');
     }
 
-    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) {
-          errorMsg = 'Incorrect Proxy URL';
+          errorMsg = 'Something went wrong when retrieving information from Proxy Server.';
           errorCode = 'test-connection-failed';
           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
@@ -280,23 +272,15 @@ module.exports = (crowi) => {
    *           200:
    *             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;
 
-    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 {
-      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) {
       const msg = 'Error occured in updating Custom bot setting';
@@ -324,22 +308,13 @@ module.exports = (crowi) => {
    *             description: Succeeded to delete botType setting.
    */
   router.delete('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
-
-    await resetAllBotSettings();
-    const params = { 'slackbot:currentBotType': null };
-
     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) {
-      const msg = 'Error occured in updating Custom bot setting';
+      const msg = 'Error occured in resetting all';
       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 {
       await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
+      crowi.slackIntegrationService.publishUpdatedMessage();
 
       const customBotWithoutProxySettingParams = {
         slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
@@ -390,7 +365,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *    /slack-integration-settings/slack-app-integrations:
-   *      put:
+   *      post:
    *        tags: [SlackIntegration]
    *        operationId: putSlackAppIntegrations
    *        summary: /slack-integration
@@ -399,19 +374,20 @@ module.exports = (crowi) => {
    *          200:
    *            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();
     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({
         tokenGtoP,
         tokenPtoG,
+        isPrimary: count === 0 ? true : undefined,
         supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
         supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
       });
@@ -427,37 +403,7 @@ module.exports = (crowi) => {
   /**
    * @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:
    *        tags: [SlackIntegration]
    *        operationId: deleteAccessTokens
@@ -467,11 +413,19 @@ module.exports = (crowi) => {
    *          200:
    *            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 { integrationIdToDelete } = req.query;
+    const { id } = req.params;
+
     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 });
     }
     catch (error) {
@@ -488,13 +442,13 @@ module.exports = (crowi) => {
 
     try {
       await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
+      crowi.slackIntegrationService.publishUpdatedMessage();
       return res.apiv3({});
     }
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
       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
    *
-   *    /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:
    *        tags: [SlackIntegration]
    *        operationId: putSupportedCommands
@@ -512,7 +542,8 @@ module.exports = (crowi) => {
    *          200:
    *            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 { id } = req.params;
 
@@ -523,56 +554,58 @@ module.exports = (crowi) => {
         { 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 });
     }
     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);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'update-supported-commands-failed'), 500);
     }
   });
 
   /**
    * @swagger
    *
-   *    /slack-integration-settings/with-proxy/relation-test:
+   *    /slack-integration-settings/slack-app-integrations/:id/relation-test:
    *      post:
    *        tags: [botType]
    *        operationId: postRelationTest
-   *        summary: /slack-integration/bot-type
+   *        summary: Test relation
    *        description: Delete botType setting.
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  slackAppIntegrationId:
-   *                    type: string
    *        responses:
    *           200:
    *             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');
     if (currentBotType === 'customBotWithoutProxy') {
       const msg = 'Not Proxy Type';
       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;
     try {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: slackAppIntegrationId });
+      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: id });
       if (slackAppIntegration == null) {
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
@@ -631,7 +664,7 @@ module.exports = (crowi) => {
    *           200:
    *             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');
     if (currentBotType !== 'customBotWithoutProxy') {
       const msg = 'Select Without Proxy Type';

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

@@ -14,7 +14,7 @@ const { respondIfSlackbotError } = require('../../service/slack-command-handler/
 module.exports = (crowi) => {
   this.app = crowi.express;
 
-  const { configManager } = crowi;
+  const { configManager, slackIntegrationService } = crowi;
 
   // Check if the access token is correct
   async function verifyAccessTokenFromProxy(req, res, next) {
@@ -98,32 +98,7 @@ module.exports = (crowi) => {
     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;
 
     if (body.text == null) {
@@ -138,23 +113,11 @@ module.exports = (crowi) => {
     // 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'];
-
-    // 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 command = args[0];
 
     try {
-      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
+      await crowi.slackIntegrationService.handleCommandRequest(command, client, body, args);
     }
     catch (err) {
       await respondIfSlackbotError(client, body, err);
@@ -163,7 +126,8 @@ module.exports = (crowi) => {
   }
 
   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) => {
@@ -175,27 +139,18 @@ module.exports = (crowi) => {
       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
     // 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'];
-    // 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 { type } = payload;
 
@@ -203,7 +158,7 @@ module.exports = (crowi) => {
       switch (type) {
         case 'block_actions':
           try {
-            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+            await crowi.slackIntegrationService.handleBlockActionsRequest(client, payload);
           }
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
@@ -211,7 +166,7 @@ module.exports = (crowi) => {
           break;
         case 'view_submission':
           try {
-            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, payload);
           }
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
@@ -228,11 +183,15 @@ module.exports = (crowi) => {
   }
 
   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) => {
-    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) => {

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

@@ -12,7 +12,7 @@ const logger = loggerFactory('growi:service:AppService');
 /**
  * the service class of AppService
  */
-class AppService implements S2sMessageHandlable {
+export default class AppService implements S2sMessageHandlable {
 
   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 {
+  prepareSlackMessageForGlobalNotification,
+} from '../../util/slack';
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 
@@ -14,8 +18,7 @@ class GlobalNotificationSlackService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.slack = crowi.getSlack();
-    this.slackLegacy = crowi.getSlackLegacy();
+
     this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
@@ -33,20 +36,21 @@ class GlobalNotificationSlackService {
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
   async fire(event, id, path, triggeredBy, vars) {
+    const { appService, slackIntegrationService } = this.crowi;
+
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
 
+    const appTitle = appService.getAppTitle();
+
     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);
     }));
 
-
   }
 
   /**

+ 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) {
-  const { IncomingWebhook } = require('@slack/webhook');
-  const { WebClient } = require('@slack/web-api');
 
   const { configManager } = crowi;
-  const slack = crowi.getSlack();
 
-  const slackLegacy = {};
+  const slackUtilLegacy = {};
 
   const postWithIwh = async(messageObj) => {
     const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
@@ -22,71 +17,48 @@ module.exports = function(crowi) {
       await webhook.send(messageObj);
     }
     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;
     }
   };
 
   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);
-    }
     try {
       await client.chat.postMessage(messageObj);
     }
     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;
     }
   };
 
-  // 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
     if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        debug('posting message with IncomingWebhook');
+        logger.debug('posting message with IncomingWebhook');
         return postWithIwh(messageObj);
       }
       if (configManager.getConfig('notification', 'slack:token')) {
-        debug('posting message with Web API');
+        logger.debug('posting message with Web API');
         return postWithWebApi(messageObj);
       }
     }
     // else
     else {
       if (configManager.getConfig('notification', 'slack:token')) {
-        debug('posting message with Web API');
+        logger.debug('posting message with Web API');
         return postWithWebApi(messageObj);
       }
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        debug('posting message with IncomingWebhook');
+        logger.debug('posting message with IncomingWebhook');
         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 */
 
-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);
-  };
+  }
 
-  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
    *
    * @param {string} messageBody
    * @param {string} attachmentBody
    * @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;
 };

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

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

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

@@ -3,18 +3,15 @@ const { getInstance } = require('../setup-crowi');
 describe('Slack Util', () => {
 
   let crowi;
-  let slackLegacy;
+  let slackLegacyUtil;
 
   beforeEach(async() => {
     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);
-  });
 });

+ 4 - 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[] = [
   '/growi',
 ];

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

@@ -1,5 +1,5 @@
 import {
-  SectionBlock, InputBlock, DividerBlock, ActionsBlock,
+  SectionBlock, HeaderBlock, InputBlock, DividerBlock, ActionsBlock,
   Button, Overflow, Datepicker, Select, RadioButtons, Checkboxes, Action, MultiSelect, PlainTextInput, Option,
 } 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 {
   return {
     type: 'section',

+ 6 - 1
packages/slack/src/utils/check-communicable.ts

@@ -5,6 +5,7 @@ import { WebClient } from '@slack/web-api';
 import { generateWebClient } from './webclient-factory';
 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.
@@ -121,6 +122,10 @@ export const sendSuccessMessage = async(token:string, channel:string, appSiteUrl
   const client = generateWebClient(token);
   await client.chat.postMessage({
     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:'),
+    ],
   });
 };

+ 1 - 1
packages/slack/src/utils/webclient-factory.ts

@@ -7,6 +7,6 @@ const isProduction = process.env.NODE_ENV === 'production';
  * @param token Slack Bot Token or Proxy Server URI
  * @returns
  */
-export const generateWebClient = (token: string, serverUri?: string, headers?:{[key:string]:string}): WebClient => {
+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 });
 };

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

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

+ 7 - 4
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -8,10 +8,10 @@ import { addHours } from 'date-fns';
 import { WebAPICallResult } from '@slack/web-api';
 
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient, REQUEST_TIMEOUT_FOR_PTOG,
 } 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 { InstallationRepository } from '~/repositories/installation';
@@ -61,6 +61,7 @@ export class GrowiToSlackCtrl {
       headers: {
         'x-growi-ptog-tokens': tokenPtoG,
       },
+      timeout: REQUEST_TIMEOUT_FOR_PTOG,
     });
   }
 
@@ -245,7 +246,7 @@ export class GrowiToSlackCtrl {
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
-  ): Promise<void|string|Res|WebAPICallResult> {
+  ): Promise<void|WebAPICallResult> {
     const { tokenGtoPs } = req;
 
     logger.debug('Slack API called: ', { method });
@@ -279,7 +280,9 @@ export class GrowiToSlackCtrl {
       const opt = req.body;
       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 error occured !! -- 2021.08.22 Yuki Takei
+      return await client.apiCall(method, opt);
     }
     catch (err) {
       logger.error(err);

+ 35 - 26
packages/slackbot-proxy/src/controllers/slack.ts

@@ -10,7 +10,7 @@ import { Installation } from '@slack/oauth';
 
 import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
-  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, publishInitialHomeView,
+  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, publishInitialHomeView, REQUEST_TIMEOUT_FOR_PTOG,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -84,6 +84,7 @@ export class SlackCtrl {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
+        timeout: REQUEST_TIMEOUT_FOR_PTOG,
       });
     });
 
@@ -182,42 +183,33 @@ export class SlackCtrl {
     const baseDate = new Date();
 
     const allowedRelationsForSingleUse:Relation[] = [];
+    const allowedRelationsForBroadcastUse:Relation[] = [];
     const disallowedGrowiUrls: Set<string> = new Set();
 
-    // check permission for single use
+    // check permission
     await Promise.all(relations.map(async(relation) => {
-      const isSupported = await this.relationsService.isSupportedGrowiCommandForSingleUse(relation, growiCommand.growiCommandType, baseDate);
-      if (isSupported) {
-        allowedRelationsForSingleUse.push(relation);
-      }
-      else {
-        disallowedGrowiUrls.add(relation.growiUri);
+      const isSupportedForSingleUse = await this.relationsService.isSupportedGrowiCommandForSingleUse(
+        relation, growiCommand.growiCommandType, baseDate,
+      );
+
+      let isSupportedForBroadcastUse = false;
+      if (!isSupportedForSingleUse) {
+        isSupportedForBroadcastUse = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(
+          relation, growiCommand.growiCommandType, baseDate,
+        );
       }
-    }));
 
-    // select GROWI
-    if (allowedRelationsForSingleUse.length > 0) {
-      body.growiUrisForSingleUse = allowedRelationsForSingleUse.map(v => v.growiUri);
-      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
-    }
-
-    // check permission for broadcast use
-    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) {
-      return 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
@@ -228,6 +220,8 @@ export class SlackCtrl {
           + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
       });
 
+      const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
+
       return client.chat.postEphemeral({
         text: 'Error occured.',
         channel: body.channel_id,
@@ -238,9 +232,23 @@ export class SlackCtrl {
           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')
@@ -315,6 +323,7 @@ export class SlackCtrl {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
+        timeout: REQUEST_TIMEOUT_FOR_PTOG,
       });
     }
     catch (err) {

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


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 { 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 { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 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 blocks: Block[] = [];
+
     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);
       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);
     return;
   }

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

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