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

Merge pull request #4468 from weseek/master

Release slackbot-proxy 4.4.8-slackbot-proxy.0
Yuki Takei 4 лет назад
Родитель
Сommit
f1e5a6facd
68 измененных файлов с 1646 добавлено и 747 удалено
  1. 6 0
      .github/dependabot.yml
  2. 1 1
      .github/workflows/release-slackbot-proxy.yml
  3. 27 1
      CHANGELOG.md
  4. 12 1
      SECURITY.md
  5. 1 1
      lerna.json
  6. 1 1
      package.json
  7. 2 2
      packages/app/docker/README.md
  8. 7 7
      packages/app/package.json
  9. 5 0
      packages/app/resource/locales/en_US/translation.json
  10. 5 0
      packages/app/resource/locales/ja_JP/translation.json
  11. 5 0
      packages/app/resource/locales/zh_CN/translation.json
  12. 1 3
      packages/app/src/client/services/CommentContainer.js
  13. 9 2
      packages/app/src/client/services/EditorContainer.js
  14. 2 2
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  15. 1 1
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  16. 1 5
      packages/app/src/components/PageComment/Comment.jsx
  17. 75 0
      packages/app/src/components/PageEditor/DownloadDictModal.tsx
  18. 41 8
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  19. 0 1
      packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  20. 1 1
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  21. 84 0
      packages/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  22. 97 0
      packages/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  23. 14 6
      packages/app/src/server/middlewares/http-error-handler.js
  24. 0 10
      packages/app/src/server/models/comment.js
  25. 37 0
      packages/app/src/server/models/vo/slack-command-handler-error.ts
  26. 0 22
      packages/app/src/server/models/vo/slackbot-error.js
  27. 6 3
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  28. 173 75
      packages/app/src/server/routes/apiv3/slack-integration.js
  29. 28 17
      packages/app/src/server/routes/comment.js
  30. 5 5
      packages/app/src/server/routes/login-passport.js
  31. 18 29
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  32. 69 0
      packages/app/src/server/service/slack-command-handler/error-handler.ts
  33. 11 5
      packages/app/src/server/service/slack-command-handler/help.js
  34. 229 0
      packages/app/src/server/service/slack-command-handler/keep.js
  35. 12 12
      packages/app/src/server/service/slack-command-handler/note.js
  36. 0 66
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  37. 161 258
      packages/app/src/server/service/slack-command-handler/search.js
  38. 83 88
      packages/app/src/server/service/slack-command-handler/togetter.js
  39. 40 26
      packages/app/src/server/service/slack-integration.ts
  40. 5 0
      packages/app/src/server/util/slack-integration.ts
  41. 2 2
      packages/app/src/styles/_wiki.scss
  42. 6 6
      packages/app/src/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts
  43. 1 1
      packages/codemirror-textlint/package.json
  44. 1 1
      packages/core/package.json
  45. 1 1
      packages/plugin-attachment-refs/package.json
  46. 1 1
      packages/plugin-lsx/package.json
  47. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  48. 3 2
      packages/slack/package.json
  49. 8 5
      packages/slack/src/index.ts
  50. 9 7
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  51. 8 0
      packages/slack/src/interfaces/respond-util.ts
  52. 26 4
      packages/slack/src/utils/interaction-payload-accessor.ts
  53. 6 6
      packages/slack/src/utils/reshape-contents-body.test.ts
  54. 2 2
      packages/slack/src/utils/reshape-contents-body.ts
  55. 71 0
      packages/slack/src/utils/respond-util-factory.ts
  56. 9 0
      packages/slack/src/utils/response-url.ts
  57. 0 21
      packages/slack/src/utils/welcome-message.ts
  58. 2 2
      packages/slackbot-proxy/package.json
  59. 41 8
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  60. 31 14
      packages/slackbot-proxy/src/controllers/slack.ts
  61. 24 0
      packages/slackbot-proxy/src/entities/system-information.ts
  62. 23 0
      packages/slackbot-proxy/src/repositories/system-information.ts
  63. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  64. 1 1
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  65. 47 0
      packages/slackbot-proxy/src/services/SystemInformationService.ts
  66. 4 2
      packages/slackbot-proxy/src/services/UnregisterService.ts
  67. 38 0
      packages/slackbot-proxy/src/utils/welcome-message.ts
  68. 1 1
      packages/ui/package.json

+ 6 - 0
.github/dependabot.yml

@@ -15,3 +15,9 @@ updates:
     commit-message:
     commit-message:
       prefix: ci
       prefix: ci
       include: scope
       include: scope
+    ignore:
+      - dependency-name: escape-string-regexp
+      - dependency-name: string-width
+      - dependency-name: "@handsontable/react"
+      - dependency-name: handsontable
+

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -118,7 +118,7 @@ jobs:
 
 
     - name: Bump versions for next RC
     - name: Bump versions for next RC
       run: |
       run: |
-        node ./bin/github-actions/bump-versions -i prerelease -d packages/slackbot-proxy --preid slackbot-proxy --update-dependencies false
+        yarn bump-versions:slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.1.0
       uses: myrotvorets/info-from-package-json-action@1.1.0

+ 27 - 1
CHANGELOG.md

@@ -1,9 +1,35 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.6...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.4.7...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v4.4.7](https://github.com/weseek/growi/compare/v4.4.6...v4.4.7) - 2021-09-29
+
+### 🚀 Improvement
+
+- imprv: Slackbot search (#4420) @yuki-takei
+- imprv: Omit textlint-rule-en-capitalization (#4403) @yuki-takei
+- imprv: Apply terminus for graceful shutdown (#4398) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: A problem that GROWI server doesn't retrieve connection status from Official bot proxy (#4416) @yuki-takei
+- fix: Dictionary path of kuromoji invalid when uploaded to server (#4381) @stevenfukase
+- fix: Copy correct dotenv file for NO_CDN docker image (#4397) @yuki-takei
+- fix: Stop using ts-node in production (#4411) @yuki-takei
+- fix: SAML setting says 'setup is not yet complete' even if setup properly (#4390) @nakashimaki
+- fix: SidebarSmall button does not keep selection on reload (#4389) @nakashimaki
+- fix: Migrations for updating data for slackbot (#4406) @yuki-takei
+- fix: Migrations do not run in production (#4395) @yuki-takei
+- fix: Migration file for mongodb 3.6 compatibility (#4413) @hakumizuki
+- fix(slackbot): Sync permission when data stored is not enough (#4417) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Install Git LFS when provisioning of devcontainer (#4405) @stevenfukase
+- chore: Add .dockerignore (#4396) @yuki-takei
+
 ## [v4.4.6](https://github.com/weseek/growi/compare/v4.4.5...v4.4.6) - 2021-09-24
 ## [v4.4.6](https://github.com/weseek/growi/compare/v4.4.5...v4.4.6) - 2021-09-24
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 12 - 1
SECURITY.md

@@ -14,9 +14,20 @@
 If you believe you have found a security vulnerability in any GROWI related repository, please report it to us using one of the methods described below.
 If you believe you have found a security vulnerability in any GROWI related repository, please report it to us using one of the methods described below.
 
 
   * [Join our Slack team](https://growi-slackin.weseek.co.jp/) and send DM to `@yuki` who is the lead developer
   * [Join our Slack team](https://growi-slackin.weseek.co.jp/) and send DM to `@yuki` who is the lead developer
-  * Report to JPCERT/CC ([en](https://www.jpcert.or.jp/english/ir/form.html)/[ja](https://www.jpcert.or.jp/form/))
+  * Report to JPCERT/CC[^jpcertcc]
+    * [[PDF] JPCERT/CC Vulnerability Coordination and Disclosure Policy](https://www.jpcert.or.jp/english/vh/vul-coordination-disclosure-policy_2019.pdf)
 
 
 ## Preferred Languages
 ## Preferred Languages
 
 
 Communication in English and Japanese is possible.  
 Communication in English and Japanese is possible.  
 In Japanese, we can reply more quickly. 
 In Japanese, we can reply more quickly. 
+
+
+
+
+Some long sentence. 
+
+[^jpcertcc]: JPCERT/CC is a National CSIRT in Japan and a coordination center
+who coordinates cyber security incidents and products vulnerabilities
+with network service providers, product vendors (software/IoT/ICS etc.),
+security vendors, government agencies, as well as the industry associations.

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`4.4.6`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.6/docker/Dockerfile)
-* [`4.4.6-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.6/docker/Dockerfile)
+* [`4.4.7`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.7/docker/Dockerfile)
+* [`4.4.7-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.7/docker/Dockerfile)
 * [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 
 

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -57,11 +57,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.4.7-RC.0",
-    "@growi/plugin-attachment-refs": "^4.4.7-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.7-RC.0",
-    "@growi/plugin-lsx": "^4.4.7-RC.0",
-    "@growi/slack": "^4.4.7-RC.0",
+    "@growi/codemirror-textlint": "^4.4.8-RC.0",
+    "@growi/plugin-attachment-refs": "^4.4.8-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.8-RC.0",
+    "@growi/plugin-lsx": "^4.4.8-RC.0",
+    "@growi/slack": "^4.4.8-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -157,7 +157,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.7-RC.0",
+    "@growi/ui": "^4.4.8-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

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

@@ -454,6 +454,11 @@
       "Post": "Post"
       "Post": "Post"
     }
     }
   },
   },
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "Are you sure you want to enable Textlint? This will download 20MB of dictionary file.",
+    "enable_textlint": "Enable Textlint",
+    "dont_ask_again": "Don't ask again"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",

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

@@ -455,6 +455,11 @@
       "Post": "投稿"
       "Post": "投稿"
     }
     }
   },
   },
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "Textlintを有効にしますか?20MBの辞書ファイルをダウンロードします。",
+    "enable_textlint": "Textlintを有効にする",
+    "dont_ask_again": "常に許可する"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "リンク編集",
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",
     "set_link_and_label": "リンク情報",

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

@@ -433,6 +433,11 @@
 			"Post": "提交"
 			"Post": "提交"
 		}
 		}
 	},
 	},
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "您确定要启用 Textlint 吗?这将下载 20MB 的字典文件。",
+    "enable_textlint": "启用Textlint",
+    "dont_ask_again": "不要再问"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",

+ 1 - 3
packages/app/src/client/services/CommentContainer.js

@@ -132,11 +132,9 @@ export default class CommentContainer extends Container {
     return this.appContainer.apiPost('/comments.update', {
     return this.appContainer.apiPost('/comments.update', {
       commentForm: {
       commentForm: {
         comment,
         comment,
-        page_id: pageId,
-        revision_id: revisionId,
         is_markdown: isMarkdown,
         is_markdown: isMarkdown,
+        revision_id: revisionId,
         comment_id: commentId,
         comment_id: commentId,
-        author,
       },
       },
     })
     })
       .then((res) => {
       .then((res) => {

+ 9 - 2
packages/app/src/client/services/EditorContainer.js

@@ -36,7 +36,9 @@ export default class EditorContainer extends Container {
 
 
       editorOptions: {},
       editorOptions: {},
       previewOptions: {},
       previewOptions: {},
-      isTextlintEnabled: false,
+
+      // Defaults to null to show modal when not in DB
+      isTextlintEnabled: null,
       textlintRules: [],
       textlintRules: [],
 
 
       indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
       indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
@@ -204,13 +206,18 @@ export default class EditorContainer extends Container {
    * Retrieve Editor Settings
    * Retrieve Editor Settings
    */
    */
   async retrieveEditorSettings() {
   async retrieveEditorSettings() {
+    if (this.appContainer.isGuestUser) {
+      return;
+    }
+
     const { data } = await this.appContainer.apiv3Get('/personal-setting/editor-settings');
     const { data } = await this.appContainer.apiv3Get('/personal-setting/editor-settings');
 
 
     if (data?.textlintSettings == null) {
     if (data?.textlintSettings == null) {
       return;
       return;
     }
     }
 
 
-    const { isTextlintEnabled = false, textlintRules = [] } = data.textlintSettings;
+    // Defaults to null to show modal when not in DB
+    const { isTextlintEnabled = null, textlintRules = [] } = data.textlintSettings;
 
 
     this.setState({
     this.setState({
       isTextlintEnabled,
       isTextlintEnabled,

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

@@ -72,8 +72,8 @@ const ManageCommandsProcess = ({
     search: permissionsForBroadcastUseCommands.search,
     search: permissionsForBroadcastUseCommands.search,
   });
   });
   const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
   const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
-    create: permissionsForSingleUseCommands.create,
-    togetter: permissionsForSingleUseCommands.togetter,
+    note: permissionsForSingleUseCommands.note,
+    keep: permissionsForSingleUseCommands.keep,
   });
   });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     const initialState = {};
     const initialState = {};

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

@@ -190,7 +190,7 @@ const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
       await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
         commandPermission: editingCommandPermission,
         commandPermission: editingCommandPermission,
       });
       });
-      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+      toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);

+ 1 - 5
packages/app/src/components/PageComment/Comment.jsx

@@ -73,10 +73,6 @@ class Comment extends React.PureComponent {
     interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
     interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
   }
   }
 
 
-  checkPermissionToControlComment() {
-    return this.props.appContainer.isAdmin || this.isCurrentUserEqualsToAuthor();
-  }
-
   isCurrentUserEqualsToAuthor() {
   isCurrentUserEqualsToAuthor() {
     const { creator } = this.props.comment;
     const { creator } = this.props.comment;
     if (creator == null) {
     if (creator == null) {
@@ -210,7 +206,7 @@ class Comment extends React.PureComponent {
                   </UncontrolledTooltip>
                   </UncontrolledTooltip>
                 </span>
                 </span>
               </div>
               </div>
-              {this.checkPermissionToControlComment() && (
+              {this.isCurrentUserEqualsToAuthor() && (
                 <CommentControl
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}
                   onClickEditBtn={() => this.setState({ isReEdit: true })}

+ 75 - 0
packages/app/src/components/PageEditor/DownloadDictModal.tsx

@@ -0,0 +1,75 @@
+import React, { useState, FC } from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+type DownloadDictModalProps = {
+  isModalOpen: boolean
+  onConfirmEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
+  onCancel?: () => void;
+};
+
+export const DownloadDictModal: FC<DownloadDictModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(true);
+
+  const onCancel = () => {
+    if (props.onCancel != null) {
+      props.onCancel();
+    }
+  };
+
+  const onConfirmEnableTextlint = () => {
+    if (props.onConfirmEnableTextlint != null) {
+      props.onConfirmEnableTextlint(isSkipAskingAgainChecked);
+    }
+  };
+
+  return (
+    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+        <i className="icon-fw icon-question" />
+        Warning
+      </ModalHeader>
+      <ModalBody>
+        {t('modal_enable_textlint.confirm_download_dict_and_enable_textlint')}
+      </ModalBody>
+      <ModalFooter>
+        <div className="mr-3 custom-control custom-checkbox custom-checkbox-info">
+          <input
+            type="checkbox"
+            className="custom-control-input"
+            id="dont-ask-again"
+            checked={isSkipAskingAgainChecked}
+            onChange={e => setIsSkipAskingAgainChecked(e.target.checked)}
+          />
+          <label className="custom-control-label align-center" htmlFor="dont-ask-again">
+            {t('modal_enable_textlint.dont_ask_again')}
+          </label>
+        </div>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-outline-primary ml-3"
+          onClick={onConfirmEnableTextlint}
+        >
+          {t('modal_enable_textlint.enable_textlint')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+DownloadDictModal.propTypes = {
+  isModalOpen: PropTypes.bool.isRequired,
+  onConfirmEnableTextlint: PropTypes.func,
+  onCancel: PropTypes.func,
+};

+ 41 - 8
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -11,6 +11,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import { DownloadDictModal } from './DownloadDictModal';
 
 
 
 
 export const defaultEditorOptions = {
 export const defaultEditorOptions = {
@@ -34,6 +35,8 @@ class OptionsSelector extends React.Component {
     this.state = {
     this.state = {
       isCddMenuOpened: false,
       isCddMenuOpened: false,
       isMathJaxEnabled,
       isMathJaxEnabled,
+      isDownloadDictModalShown: false,
+      isSkipAskingAgainChecked: false,
     };
     };
 
 
     this.availableThemes = [
     this.availableThemes = [
@@ -53,6 +56,8 @@ class OptionsSelector extends React.Component {
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
     this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
+    this.confirmEnableTextlintHandler = this.confirmEnableTextlintHandler.bind(this);
+    this.toggleTextlint = this.toggleTextlint.bind(this);
     this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
     this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
     this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
     this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
@@ -124,11 +129,29 @@ class OptionsSelector extends React.Component {
     }
     }
   }
   }
 
 
-  async switchTextlintEnabledHandler() {
+  toggleTextlint() {
     const { editorContainer } = this.props;
     const { editorContainer } = this.props;
     const newVal = !editorContainer.state.isTextlintEnabled;
     const newVal = !editorContainer.state.isTextlintEnabled;
     editorContainer.setState({ isTextlintEnabled: newVal });
     editorContainer.setState({ isTextlintEnabled: newVal });
-    this.updateIsTextlintEnabledToDB(newVal);
+    if (this.state.isSkipAskingAgainChecked) {
+      this.updateIsTextlintEnabledToDB(newVal);
+    }
+  }
+
+  switchTextlintEnabledHandler() {
+    const { editorContainer } = this.props;
+    if (editorContainer.state.isTextlintEnabled === null) {
+      this.setState({ isDownloadDictModalShown: true });
+      return;
+    }
+    this.toggleTextlint();
+  }
+
+  confirmEnableTextlintHandler(isSkipAskingAgainChecked) {
+    this.setState(
+      { isSkipAskingAgainChecked, isDownloadDictModalShown: false },
+      () => this.toggleTextlint(),
+    );
   }
   }
 
 
   onToggleConfigurationDropdown(newValue) {
   onToggleConfigurationDropdown(newValue) {
@@ -359,12 +382,22 @@ class OptionsSelector extends React.Component {
 
 
   render() {
   render() {
     return (
     return (
-      <div className="d-flex flex-row">
-        <span>{this.renderThemeSelector()}</span>
-        <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
-        <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
-        <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
-      </div>
+      <>
+        <div className="d-flex flex-row">
+          <span>{this.renderThemeSelector()}</span>
+          <span className="d-none d-sm-block ml-2 ml-sm-4">{this.renderKeymapModeSelector()}</span>
+          <span className="ml-2 ml-sm-4">{this.renderIndentSizeSelector()}</span>
+          <span className="ml-2 ml-sm-4">{this.renderConfigurationDropdown()}</span>
+        </div>
+
+        {!this.state.isSkipAskingAgainChecked && (
+          <DownloadDictModal
+            isModalOpen={this.state.isDownloadDictModalShown}
+            onConfirmEnableTextlint={this.confirmEnableTextlintHandler}
+            onCancel={() => this.setState({ isDownloadDictModalShown: false })}
+          />
+        )}
+      </>
     );
     );
   }
   }
 
 

+ 0 - 1
packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js

@@ -1,6 +1,5 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -5,7 +5,7 @@ import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
-const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+const logger = loggerFactory('growi:migrate:migrate-slack-app-integration-schema');
 
 
 // create default data
 // create default data
 const defaultDataForBroadcastUse = {};
 const defaultDataForBroadcastUse = {};

+ 84 - 0
packages/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js

@@ -0,0 +1,84 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:slack-app-integration-rename-keys');
+
+module.exports = {
+  async up(db) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    if (slackAppIntegrations.length === 0) return;
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const permissionsForSingleUseCommands = doc._doc.permissionsForSingleUseCommands;
+      const createValue = permissionsForSingleUseCommands.get('create', false);
+      const togetterValue = permissionsForSingleUseCommands.get('togetter', false);
+
+      const newPermissionsForSingleUseCommands = {
+        note: createValue,
+        keep: togetterValue,
+      };
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: {
+            $set: {
+              permissionsForSingleUseCommands: newPermissionsForSingleUseCommands,
+            },
+          },
+        },
+      };
+    });
+
+    await db.collection('slackappintegrations').bulkWrite(operations);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    if (slackAppIntegrations.length === 0) return next();
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const permissionsForSingleUseCommands = doc._doc.permissionsForSingleUseCommands;
+      const noteValue = permissionsForSingleUseCommands.get('note', false);
+      const keepValue = permissionsForSingleUseCommands.get('keep', false);
+
+      const newPermissionsForSingleUseCommands = {
+        create: noteValue,
+        togetter: keepValue,
+      };
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: {
+            $set: {
+              permissionsForSingleUseCommands: newPermissionsForSingleUseCommands,
+            },
+          },
+        },
+      };
+    });
+
+    await db.collection('slackappintegrations').bulkWrite(operations);
+
+    next();
+    logger.info('Migration rollback has successfully applied');
+  },
+};

+ 97 - 0
packages/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js

@@ -0,0 +1,97 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+import Config from '~/server/models/config';
+
+
+const logger = loggerFactory('growi:migrate:slack-app-integration-rename-keys');
+
+module.exports = {
+  async up(db) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const isExist = (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) > 0;
+    if (!isExist) return;
+
+    const commandPermissionValue = await Config.findOne({ key: 'slackbot:withoutProxy:commandPermission' });
+    // do nothing if data is 'null' or null
+    if (commandPermissionValue._doc.value === 'null' || commandPermissionValue._doc.value == null) return;
+
+    const commandPermission = JSON.parse(commandPermissionValue._doc.value);
+
+    const newCommandPermission = {
+      note: false,
+      keep: false,
+    };
+    Object.entries(commandPermission).forEach((entry) => {
+      const [key, value] = entry;
+      switch (key) {
+        case 'create':
+          newCommandPermission.note = value;
+          break;
+        case 'togetter':
+          newCommandPermission.keep = value;
+          break;
+        default:
+          newCommandPermission[key] = value;
+          break;
+      }
+    });
+
+    await Config.findOneAndUpdate(
+      { key: 'slackbot:withoutProxy:commandPermission' },
+      {
+        $set: {
+          value: JSON.stringify(newCommandPermission),
+        },
+      },
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const isExist = (await Config.count({ key: 'slackbot:withoutProxy:commandPermission' })) > 0;
+    if (!isExist) return next();
+
+    const commandPermissionValue = await Config.findOne({ key: 'slackbot:withoutProxy:commandPermission' });
+    // do nothing if data is 'null' or null
+    if (commandPermissionValue._doc.value === 'null' || commandPermissionValue._doc.value == null) return next();
+
+    const commandPermission = JSON.parse(commandPermissionValue._doc.value);
+
+    const newCommandPermission = {
+      create: false,
+      togetter: false,
+    };
+    Object.entries(commandPermission).forEach((entry) => {
+      const [key, value] = entry;
+      switch (key) {
+        case 'note':
+          newCommandPermission.create = value;
+          break;
+        case 'keep':
+          newCommandPermission.togetter = value;
+          break;
+        default:
+          newCommandPermission[key] = value;
+          break;
+      }
+    });
+
+    await Config.findOneAndUpdate(
+      { key: 'slackbot:withoutProxy:commandPermission' },
+      {
+        $set: {
+          value: JSON.stringify(newCommandPermission),
+        },
+      },
+    );
+
+    next();
+    logger.info('Migration rollback has successfully applied');
+  },
+};

+ 14 - 6
packages/app/src/server/middlewares/http-error-handler.js

@@ -1,4 +1,7 @@
 import { HttpError } from 'http-errors';
 import { HttpError } from 'http-errors';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middleware:htto-error-handler');
 
 
 const isHttpError = (val) => {
 const isHttpError = (val) => {
   if (!val || typeof val !== 'object') {
   if (!val || typeof val !== 'object') {
@@ -20,12 +23,17 @@ module.exports = async(err, req, res, next) => {
   if (isHttpError(err)) {
   if (isHttpError(err)) {
     const httpError = err;
     const httpError = err;
 
 
-    return res
-      .status(httpError.status)
-      .send({
-        status: httpError.status,
-        message: httpError.message,
-      });
+    try {
+      return res
+        .status(httpError.status)
+        .send({
+          status: httpError.status,
+          message: httpError.message,
+        });
+    }
+    catch (err) {
+      logger.error('Cannot call res.send() twice:', err);
+    }
   }
   }
 
 
   next(err);
   next(err);

+ 0 - 10
packages/app/src/server/models/comment.js

@@ -65,16 +65,6 @@ module.exports = function(crowi) {
     }));
     }));
   };
   };
 
 
-  commentSchema.statics.updateCommentsByPageId = function(comment, isMarkdown, commentId) {
-    const Comment = this;
-
-    return Comment.findOneAndUpdate(
-      { _id: commentId },
-      { $set: { comment, isMarkdown } },
-    );
-
-  };
-
   commentSchema.statics.removeCommentsByPageId = function(pageId) {
   commentSchema.statics.removeCommentsByPageId = function(pageId) {
     const Comment = this;
     const Comment = this;
 
 

+ 37 - 0
packages/app/src/server/models/vo/slack-command-handler-error.ts

@@ -0,0 +1,37 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { RespondBodyForResponseUrl, markdownSectionBlock } from '@growi/slack';
+
+export const generateDefaultRespondBodyForInternalServerError = (message: string): RespondBodyForResponseUrl => {
+  return {
+    text: message,
+    blocks: [
+      markdownSectionBlock(`*An error occured*\n ${message}`),
+    ],
+  };
+};
+
+type Opts = {
+  responseUrl?: string,
+  respondBody?: RespondBodyForResponseUrl,
+}
+
+/**
+ * Error class for slackbot service
+ */
+export class SlackCommandHandlerError extends ExtensibleCustomError {
+
+  responseUrl?: string;
+
+  respondBody: RespondBodyForResponseUrl;
+
+  constructor(
+      message: string,
+      opts: Opts = {},
+  ) {
+    super(message);
+    this.responseUrl = opts.responseUrl;
+    this.respondBody = opts.respondBody || generateDefaultRespondBodyForInternalServerError(message);
+  }
+
+}

+ 0 - 22
packages/app/src/server/models/vo/slackbot-error.js

@@ -1,22 +0,0 @@
-/**
- * Error class for slackbot service
- */
-class SlackbotError extends Error {
-
-  constructor({
-    method, to, popupMessage, mainMessage,
-  } = {}) {
-    super();
-    this.method = method;
-    this.to = to;
-    this.popupMessage = popupMessage;
-    this.mainMessage = mainMessage;
-  }
-
-  static isSlackbotError(obj) {
-    return obj instanceof this;
-  }
-
-}
-
-module.exports = SlackbotError;

+ 6 - 3
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -174,7 +174,7 @@ module.exports = (crowi) => {
       settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:botToken');
       settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:botToken');
       settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
       settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
       settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
       settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
-      settings.commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+      settings.commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
     }
     }
     else {
     else {
       settings.proxyServerUri = slackIntegrationService.proxyUriForCurrentType;
       settings.proxyServerUri = slackIntegrationService.proxyUriForCurrentType;
@@ -251,7 +251,7 @@ module.exports = (crowi) => {
         commandPermission[commandName] = true;
         commandPermission[commandName] = true;
       });
       });
 
 
-      const requestParams = { 'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission) };
+      const requestParams = { 'slackbot:withoutProxy:commandPermission': commandPermission };
       try {
       try {
         await updateSlackBotSettings(requestParams);
         await updateSlackBotSettings(requestParams);
         crowi.slackIntegrationService.publishUpdatedMessage();
         crowi.slackIntegrationService.publishUpdatedMessage();
@@ -398,7 +398,7 @@ module.exports = (crowi) => {
 
 
     const { commandPermission } = req.body;
     const { commandPermission } = req.body;
     const requestParams = {
     const requestParams = {
-      'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission),
+      'slackbot:withoutProxy:commandPermission': commandPermission,
     };
     };
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
@@ -434,6 +434,8 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
       return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
     }
     }
 
 
+    const count = await SlackAppIntegration.count();
+
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
     try {
       const initialSupportedCommandsForBroadcastUse = new Map();
       const initialSupportedCommandsForBroadcastUse = new Map();
@@ -451,6 +453,7 @@ module.exports = (crowi) => {
         tokenPtoG,
         tokenPtoG,
         permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
         permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
         permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
         permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
+        isPrimary: count === 0,
       });
       });
       return res.apiv3(slackAppTokens, 200);
       return res.apiv3(slackAppTokens, 200);
     }
     }

+ 173 - 75
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,5 +1,9 @@
-import { markdownSectionBlock, InvalidGrowiCommandError } from '@growi/slack';
+import {
+  markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
+} from '@growi/slack';
+import createError from 'http-errors';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 
 
 const express = require('express');
 const express = require('express');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
@@ -12,7 +16,7 @@ const {
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
+const { handleError } = require('../../service/slack-command-handler/error-handler');
 const { checkPermission } = require('../../util/slack-integration');
 const { checkPermission } = require('../../util/slack-integration');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
@@ -27,7 +31,7 @@ module.exports = (crowi) => {
     if (tokenPtoG == null) {
     if (tokenPtoG == null) {
       const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
       const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
       logger.warn(message, { body: req.body });
       logger.warn(message, { body: req.body });
-      return res.status(400).send({ message });
+      return next(createError(400, message));
     }
     }
 
 
     const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
     const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
@@ -57,12 +61,37 @@ module.exports = (crowi) => {
     return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
     return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
   }
   }
 
 
-  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  // TODO: move this middleware to each controller
+  // no res.send() is allowed after this middleware
   async function checkCommandsPermission(req, res, next) {
   async function checkCommandsPermission(req, res, next) {
-    let { growiCommand } = req.body;
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    // for without proxy
+    res.send();
+
+    let growiCommand;
+    try {
+      growiCommand = getGrowiCommand(req.body);
+    }
+    catch (err) {
+      logger.error(err.message);
+      return next(err);
+    }
 
 
-    // when /relation-test or from proxy
-    if (req.body.text == null && growiCommand == null) return next();
+    // not supported commands
+    if (!supportedGrowiCommands.includes(growiCommand.growiCommandType)) {
+      const options = {
+        respondBody: {
+          text: 'Command is not supported',
+          blocks: [
+            markdownSectionBlock('*Command is not supported*'),
+            // eslint-disable-next-line max-len
+            markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
+          ],
+        },
+      };
+      return next(new SlackCommandHandlerError('Command type is not specified', options));
+    }
 
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
@@ -75,29 +104,38 @@ module.exports = (crowi) => {
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
       const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
       const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
       if (isPermitted) return next();
       if (isPermitted) return next();
-      return res.status(403).send(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`);
+
+      return next(createError(403, `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`));
     }
     }
 
 
     // without proxy
     // without proxy
-    growiCommand = parseSlashCommand(req.body);
-    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
 
 
     const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
     const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
     if (isPermitted) {
     if (isPermitted) {
       return next();
       return next();
     }
     }
     // show ephemeral error message if not permitted
     // show ephemeral error message if not permitted
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Command forbidden',
-      blocks: [
-        markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
-      ],
-    });
+    const options = {
+      respondBody: {
+        text: 'Command forbidden',
+        blocks: [
+          markdownSectionBlock('*Command is not supported*'),
+          markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
+        ],
+      },
+    };
+    return next(new SlackCommandHandlerError('Command type is not specified', options));
   }
   }
 
 
-  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  // TODO: move this middleware to each controller
+  // no res.send() is allowed after this middleware
   async function checkInteractionsPermission(req, res, next) {
   async function checkInteractionsPermission(req, res, next) {
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    // for without proxy
+    res.send();
+
     const { interactionPayload, interactionPayloadAccessor } = req;
     const { interactionPayload, interactionPayloadAccessor } = req;
     const siteUrl = crowi.appService.getSiteUrl();
     const siteUrl = crowi.appService.getSiteUrl();
 
 
@@ -114,24 +152,27 @@ module.exports = (crowi) => {
       const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
       const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
       if (isPermitted) return next();
       if (isPermitted) return next();
 
 
-      return res.status(403).send(`This interaction is forbidden on this GROWI: ${siteUrl}`);
+      return next(createError(403, `This interaction is forbidden on this GROWI: ${siteUrl}`));
     }
     }
 
 
     // without proxy
     // without proxy
-    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
 
 
     const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
     const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
     if (isPermitted) {
     if (isPermitted) {
       return next();
       return next();
     }
     }
     // show ephemeral error message if not permitted
     // show ephemeral error message if not permitted
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Interaction forbidden',
-      blocks: [
-        markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
-      ],
-    });
+    const options = {
+      respondBody: {
+        text: 'Interaction forbidden',
+        blocks: [
+          markdownSectionBlock('*Interaction forbidden*'),
+          markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
+        ],
+      },
+    };
+    return next(new SlackCommandHandlerError('Interaction forbidden', options));
   }
   }
 
 
   const addSigningSecretToReq = (req, res, next) => {
   const addSigningSecretToReq = (req, res, next) => {
@@ -150,113 +191,159 @@ module.exports = (crowi) => {
     return next();
     return next();
   };
   };
 
 
-  async function handleCommands(req, res, client) {
-    const { body } = req;
-    let { growiCommand } = body;
+  function getRespondUtil(responseUrl) {
+    const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType; // can be null
+
+    const appSiteUrl = crowi.appService.getSiteUrl();
+    if (appSiteUrl == null || appSiteUrl === '') {
+      logger.error('App site url must exist.');
+      throw SlackCommandHandlerError('App site url must exist.');
+    }
 
 
+    return generateRespondUtil(responseUrl, proxyUri, appSiteUrl);
+  }
+
+  function getGrowiCommand(body) {
+    let { growiCommand } = body;
     if (growiCommand == null) {
     if (growiCommand == null) {
       try {
       try {
         growiCommand = parseSlashCommand(body);
         growiCommand = parseSlashCommand(body);
       }
       }
       catch (err) {
       catch (err) {
         if (err instanceof InvalidGrowiCommandError) {
         if (err instanceof InvalidGrowiCommandError) {
-          res.json({
-            blocks: [
-              markdownSectionBlock('*Command type is not specified.*'),
-              markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
-            ],
-          });
+          const options = {
+            respondBody: {
+              text: 'Command type is not specified',
+              blocks: [
+                markdownSectionBlock('*Command type is not specified.*'),
+                markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+              ],
+            },
+          };
+          throw new SlackCommandHandlerError('Command type is not specified', options);
         }
         }
-        logger.error(err.message);
-        return;
+        throw err;
       }
       }
     }
     }
+    return growiCommand;
+  }
+
+  async function handleCommands(body, res, client, responseUrl) {
+    let growiCommand;
+    let respondUtil;
+    try {
+      growiCommand = getGrowiCommand(body);
+      respondUtil = getRespondUtil(responseUrl);
+    }
+    catch (err) {
+      logger.error(err.message);
+      return handleError(err, responseUrl);
+    }
 
 
     const { text } = growiCommand;
     const { text } = growiCommand;
 
 
-
     if (text == null) {
     if (text == null) {
       return 'No text.';
       return 'No text.';
     }
     }
 
 
-    /*
-     * TODO: use parseSlashCommand
-     */
-
     // Send response immediately to avoid opelation_timeout error
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.json({
-      response_type: 'ephemeral',
+    const appSiteUrl = crowi.appService.getSiteUrl();
+    await respondUtil.respond({
       text: 'Processing your request ...',
       text: 'Processing your request ...',
+      blocks: [
+        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`),
+      ],
     });
     });
 
 
-
     try {
     try {
-      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body);
+      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
     }
     }
     catch (err) {
     catch (err) {
-      await respondIfSlackbotError(client, body, err);
+      return handleError(err, responseUrl);
     }
     }
 
 
   }
   }
 
 
-  // TODO: do investigation and fix if needed GW-7519
+  // TODO: this method will be a middleware when typescriptize in the future
+  function getResponseUrl(req) {
+    const { body } = req;
+    const responseUrl = body?.growiCommand?.responseUrl;
+    if (responseUrl == null) {
+      return body.response_url; // may be null
+    }
+    return responseUrl;
+  }
+
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
-    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    return handleCommands(req, res, client);
+    const { body } = req;
+    const responseUrl = getResponseUrl(req);
+
+    let client;
+    try {
+      client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+    }
+    catch (err) {
+      logger.error(err.message);
+      return handleError(err, responseUrl);
+    }
+
+    return handleCommands(body, res, client, responseUrl);
   });
   });
 
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
+  // when relation test
+  router.post('/proxied/verify', verifyAccessTokenFromProxy, async(req, res) => {
     const { body } = req;
     const { body } = req;
+
     // eslint-disable-next-line max-len
     // eslint-disable-next-line max-len
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
     if (body.type === 'url_verification') {
     if (body.type === 'url_verification') {
       return res.send({ challenge: body.challenge });
       return res.send({ challenge: body.challenge });
     }
     }
+  });
+
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
+    const { body } = req;
+    const responseUrl = getResponseUrl(req);
 
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-    return handleCommands(req, res, client);
+
+    let client;
+    try {
+      client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+    }
+    catch (err) {
+      return handleError(err, responseUrl);
+    }
+
+    return handleCommands(body, res, client, responseUrl);
   });
   });
 
 
   async function handleInteractionsRequest(req, res, client) {
   async function handleInteractionsRequest(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 { interactionPayload, interactionPayloadAccessor } = req;
     const { interactionPayload, interactionPayloadAccessor } = req;
     const { type } = interactionPayload;
     const { type } = interactionPayload;
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
 
     try {
     try {
+      const respondUtil = getRespondUtil(responseUrl);
       switch (type) {
       switch (type) {
         case 'block_actions':
         case 'block_actions':
-          try {
-            await crowi.slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor);
-          }
-          catch (err) {
-            await respondIfSlackbotError(client, req.body, err);
-          }
+          await crowi.slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
           break;
           break;
         case 'view_submission':
         case 'view_submission':
-          try {
-            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor);
-          }
-          catch (err) {
-            await respondIfSlackbotError(client, req.body, err);
-          }
+          await crowi.slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
           break;
           break;
         default:
         default:
           break;
           break;
       }
       }
     }
     }
-    catch (error) {
-      logger.error(error);
+    catch (err) {
+      logger.error(err);
+      return handleError(err, responseUrl);
     }
     }
-
   }
   }
 
 
-  // TODO: do investigation and fix if needed GW-7519
   router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
   router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleInteractionsRequest(req, res, client);
     return handleInteractionsRequest(req, res, client);
@@ -265,7 +352,6 @@ module.exports = (crowi) => {
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-
     return handleInteractionsRequest(req, res, client);
     return handleInteractionsRequest(req, res, client);
   });
   });
 
 
@@ -277,5 +363,17 @@ module.exports = (crowi) => {
     return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
     return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
   });
   });
 
 
+  // error handler
+  router.use(async(err, req, res, next) => {
+    const responseUrl = getResponseUrl(req);
+    if (responseUrl == null) {
+      // pass err to global error handler
+      return next(err);
+    }
+
+    await handleError(err, responseUrl);
+    return;
+  });
+
   return router;
   return router;
 };
 };

+ 28 - 17
packages/app/src/server/routes/comment.js

@@ -310,10 +310,10 @@ module.exports = function(crowi, app) {
    *                            $ref: '#/components/schemas/Page/properties/_id'
    *                            $ref: '#/components/schemas/Page/properties/_id'
    *                          revision_id:
    *                          revision_id:
    *                            $ref: '#/components/schemas/Revision/properties/_id'
    *                            $ref: '#/components/schemas/Revision/properties/_id'
+   *                          comment_id:
+   *                            $ref: '#/components/schemas/Comment/properties/_id'
    *                          comment:
    *                          comment:
    *                            $ref: '#/components/schemas/Comment/properties/comment'
    *                            $ref: '#/components/schemas/Comment/properties/comment'
-   *                          comment_position:
-   *                            $ref: '#/components/schemas/Comment/properties/commentPosition'
    *                required:
    *                required:
    *                  - form
    *                  - form
    *        responses:
    *        responses:
@@ -340,13 +340,12 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
   api.update = async function(req, res) {
     const { commentForm } = req.body;
     const { commentForm } = req.body;
 
 
-    const pageId = commentForm.page_id;
-    const comment = commentForm.comment;
+    const commentStr = commentForm.comment;
     const isMarkdown = commentForm.is_markdown;
     const isMarkdown = commentForm.is_markdown;
     const commentId = commentForm.comment_id;
     const commentId = commentForm.comment_id;
-    const author = commentForm.author;
+    const revision = commentForm.revision_id;
 
 
-    if (comment === '') {
+    if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
       return res.json(ApiResponse.error('Comment text is required'));
     }
     }
 
 
@@ -354,19 +353,28 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('\'comment_id\' is undefined'));
       return res.json(ApiResponse.error('\'comment_id\' is undefined'));
     }
     }
 
 
-    if (author !== req.user.username) {
-      return res.json(ApiResponse.error('Only the author can edit'));
-    }
-
-    // check whether accessible
-    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
-    if (!isAccessible) {
-      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
-    }
-
     let updatedComment;
     let updatedComment;
     try {
     try {
-      updatedComment = await Comment.updateCommentsByPageId(comment, isMarkdown, commentId);
+      const comment = await Comment.findById(commentId).exec();
+
+      if (comment == null) {
+        throw new Error('This comment does not exist.');
+      }
+
+      // check whether accessible
+      const pageId = comment.page;
+      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+      if (!isAccessible) {
+        throw new Error('Current user is not accessible to this page.');
+      }
+      if (req.user.id !== comment.creator.toString()) {
+        throw new Error('Current user is not operatable to this comment.');
+      }
+
+      updatedComment = await Comment.findOneAndUpdate(
+        { _id: commentId },
+        { $set: { comment: commentStr, isMarkdown, revision } },
+      );
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -438,6 +446,9 @@ module.exports = function(crowi, app) {
       if (!isAccessible) {
       if (!isAccessible) {
         throw new Error('Current user is not accessible to this page.');
         throw new Error('Current user is not accessible to this page.');
       }
       }
+      if (req.user.id !== comment.creator.toString()) {
+        throw new Error('Current user is not operatable to this comment.');
+      }
 
 
       await comment.removeWithReplies();
       await comment.removeWithReplies();
       await Page.updateCommentCount(comment.page);
       await Page.updateCommentCount(comment.page);

+ 5 - 5
packages/app/src/server/routes/login-passport.js

@@ -467,6 +467,11 @@ module.exports = function(crowi, app) {
       userInfo.name = `${response[attrMapFirstName]} ${response[attrMapLastName]}`.trim();
       userInfo.name = `${response[attrMapFirstName]} ${response[attrMapLastName]}`.trim();
     }
     }
 
 
+    // Attribute-based Login Control
+    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
+      return loginFailureHandler(req, res, 'Sign in failure due to insufficient privileges.');
+    }
+
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
       return loginFailureHandler(req, res);
       return loginFailureHandler(req, res);
@@ -474,11 +479,6 @@ module.exports = function(crowi, app) {
 
 
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
-    // Attribute-based Login Control
-    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
-      return loginFailureHandler(req, res, 'Sign in failure due to insufficient privileges.');
-    }
-
     // login
     // login
     req.logIn(user, (err) => {
     req.logIn(user, (err) => {
       if (err != null) {
       if (err != null) {

+ 18 - 29
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -4,7 +4,6 @@ const logger = loggerFactory('growi:service:CreatePageService');
 const { reshapeContentsBody, respond, markdownSectionBlock } = require('@growi/slack');
 const { reshapeContentsBody, respond, markdownSectionBlock } = require('@growi/slack');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const pathUtils = require('growi-commons').pathUtils;
 const pathUtils = require('growi-commons').pathUtils;
-const SlackbotError = require('../../models/vo/slackbot-error');
 
 
 class CreatePageService {
 class CreatePageService {
 
 
@@ -12,36 +11,26 @@ class CreatePageService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
-    try {
-      // sanitize path
-      const sanitizedPath = this.crowi.xss.process(path);
-      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
-
-      // generate a dummy id because Operation to create a page needs ObjectId
-      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
-      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
-
-      // Send a message when page creation is complete
-      const growiUri = this.crowi.appService.getSiteUrl();
-      await respond(interactionPayloadAccessor.getResponseUrl(), {
-        text: 'Page has been created',
-        blocks: [
-          markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
-        ],
-      });
-    }
-    catch (err) {
-      logger.error('Failed to create page in GROWI.', err);
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Cannot create new page to existed path.',
-        mainMessage: `Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`,
-      });
-    }
+
+    // sanitize path
+    const sanitizedPath = this.crowi.xss.process(path);
+    const normalizedPath = pathUtils.normalizePath(sanitizedPath);
+
+    // generate a dummy id because Operation to create a page needs ObjectId
+    const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+    const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+
+    // Send a message when page creation is complete
+    const growiUri = this.crowi.appService.getSiteUrl();
+    await respondUtil.respond({
+      text: 'Page has been created',
+      blocks: [
+        markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
+      ],
+    });
   }
   }
 
 
 }
 }

+ 69 - 0
packages/app/src/server/service/slack-command-handler/error-handler.ts

@@ -0,0 +1,69 @@
+import assert from 'assert';
+import { ChatPostEphemeralResponse, WebClient } from '@slack/web-api';
+
+import { respond, RespondBodyForResponseUrl, markdownSectionBlock } from '@growi/slack';
+
+
+import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error';
+
+function generateRespondBodyForInternalServerError(message): RespondBodyForResponseUrl {
+  return {
+    text: message,
+    blocks: [
+      markdownSectionBlock(`*GROWI Internal Server Error occured.*\n \`${message}\``),
+    ],
+  };
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function handleErrorWithWebClient(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse> {
+
+  const isInteraction = !body.channel_id;
+
+  // this method is expected to use when system couldn't response_url
+  assert(!(error instanceof SlackCommandHandlerError) || error.responseUrl == null);
+
+  const payload = JSON.parse(body.payload);
+
+  const channel = isInteraction ? payload.channel.id : body.channel_id;
+  const user = isInteraction ? payload.user.id : body.user_id;
+
+  return client.chat.postEphemeral({
+    channel,
+    user,
+    ...generateRespondBodyForInternalServerError(error.message),
+  });
+}
+
+
+export async function handleError(error: SlackCommandHandlerError | Error, responseUrl?: string): Promise<void>;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function handleError(error: Error, client: WebClient, body: any): Promise<ChatPostEphemeralResponse>;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function handleError(error: SlackCommandHandlerError | Error, ...args: any[]): Promise<void|ChatPostEphemeralResponse> {
+
+  // handle a SlackCommandHandlerError
+  if (error instanceof SlackCommandHandlerError) {
+    const responseUrl = args[0] || error.responseUrl;
+
+    assert(responseUrl != null, 'Specify responseUrl.');
+
+    return respond(responseUrl, error.respondBody);
+  }
+
+  const secondArg = args[0];
+  assert(secondArg != null, 'Couldn\'t handle Error without the second argument.');
+
+  // handle a normal Error with response_url
+  if (typeof secondArg === 'string') {
+    const respondBody = generateRespondBodyForInternalServerError(error.message);
+    return respond(secondArg, respondBody);
+  }
+
+  assert(args[0] instanceof WebClient);
+
+  // handle with WebClient
+  return handleErrorWithWebClient(error, args[0], args[1]);
+}

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

@@ -1,18 +1,24 @@
-const { markdownSectionBlock, respond } = require('@growi/slack');
+/*
+ * !!help command and its message text must exist only in growi app package!!
+ * the help message should vary depending on the growi version
+ */
+
+const { markdownSectionBlock } = require('@growi/slack');
 
 
 module.exports = () => {
 module.exports = () => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
 
 
-  handler.handleCommand = (growiCommand, client, body) => {
+  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
     // adjust spacing
     // adjust spacing
     let message = '*Help*\n\n';
     let message = '*Help*\n\n';
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Usage:     `/growi [command] [args]`\n\n';
     message += 'Commands:\n\n';
     message += 'Commands:\n\n';
-    message += '`/growi create`                          Create new page\n\n';
+    message += '`/growi note`                          Take a note on GROWI\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
-    message += '`/growi togetter`                      Create new page with existing slack conversations (Alpha)\n\n';
-    await respond(growiCommand.responseUrl, {
+    message += '`/growi keep`                          Create new page with existing slack conversations (Alpha)\n\n';
+
+    await respondUtil.respond({
       text: 'Help',
       text: 'Help',
       blocks: [
       blocks: [
         markdownSectionBlock(message),
         markdownSectionBlock(message),

+ 229 - 0
packages/app/src/server/service/slack-command-handler/keep.js

@@ -0,0 +1,229 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackBotService:keep');
+const {
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
+} = require('@growi/slack');
+const { parse, format } = require('date-fns');
+const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
+
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
+  const BaseSlackCommandHandler = require('./slack-command-handler');
+  const handler = new BaseSlackCommandHandler();
+
+  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+    await respondUtil.respond({
+      text: 'Select messages to use.',
+      blocks: this.keepMessageBlocks(body.channel_name),
+    });
+    return;
+  };
+
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
+  };
+
+  handler.cancel = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    await respondUtil.deleteOriginal();
+  };
+
+  handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    let result = [];
+    const channelId = payload.channel.id; // this must exist since the type is always block_actions
+    const userChannelId = payload.user.id;
+
+    // validate form
+    const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
+    // get messages
+    result = await this.keepGetMessages(client, channelId, newest, oldest);
+    // clean messages
+    const cleanedContents = await this.keepCleanMessages(result.messages);
+
+    const contentsBody = cleanedContents.join('');
+    // create and send url message
+    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil);
+  };
+
+  handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
+
+    if (oldest == null || newest == null || path == null) {
+      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
+    }
+
+    /**
+     * RegExp for datetime yyyy/MM/dd-HH:mm
+     * @see https://regex101.com/r/XbxdNo/1
+     */
+    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+
+    if (!regexpDatetime.test(oldest.trim())) {
+      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
+    }
+    if (!regexpDatetime.test(newest.trim())) {
+      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
+    }
+    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
+    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+
+    if (oldest > newest) {
+      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
+    }
+
+    return { path, oldest, newest };
+  };
+
+  async function retrieveHistory(client, channelId, newest, oldest) {
+    return client.conversations.history({
+      channel: channelId,
+      newest,
+      oldest,
+      limit: 100,
+      inclusive: true,
+    });
+  }
+
+  handler.keepGetMessages = async function(client, channelId, newest, oldest) {
+    let result;
+
+    // first attempt
+    try {
+      result = await retrieveHistory(client, channelId, newest, oldest);
+    }
+    catch (err) {
+      const errorCode = err.data?.errorCode;
+
+      if (errorCode === 'not_in_channel') {
+        // join and retry
+        await client.conversations.join({
+          channel: channelId,
+        });
+        result = await retrieveHistory(client, channelId, newest, oldest);
+      }
+      else if (errorCode === 'channel_not_found') {
+
+        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
+          + '\nPlease add GROWI bot to this channel.'
+          + '\n';
+        throw new SlackCommandHandlerError(message, {
+          respondBody: {
+            text: message,
+            blocks: [
+              markdownSectionBlock(message),
+              {
+                type: 'image',
+                image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
+                alt_text: 'Add app to this channel',
+              },
+            ],
+          },
+        });
+      }
+      else {
+        throw err;
+      }
+    }
+
+    // return if no message found
+    if (result.messages.length === 0) {
+      throw new SlackCommandHandlerError('No message found from keep command. Try different datetime.');
+    }
+    return result;
+  };
+
+  handler.keepCleanMessages = async function(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    messages
+      .sort((a, b) => {
+        return a.ts - b.ts;
+      })
+      .forEach((message) => {
+        // increment contentsBody while removing the same headers
+        // exclude header
+        const lastMessageTs = Math.floor(lastMessage.ts / 60);
+        const messageTs = Math.floor(message.ts / 60);
+        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
+          cleanedContents.push(`${message.text}\n`);
+        }
+        // include header
+        else {
+          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
+          const time = format(new Date(ts), 'h:mm a');
+          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
+          lastMessage = message;
+        }
+      });
+    return cleanedContents;
+  };
+
+  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil) {
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
+
+    // TODO: contentsBody text characters must be less than 3001
+    // send preview to dm
+    // await client.chat.postMessage({
+    //   channel: userChannelId,
+    //   text: 'Preview from keep command',
+    //   blocks: [
+    //     markdownSectionBlock('*Preview*'),
+    //     divider(),
+    //     markdownSectionBlock(contentsBody),
+    //     divider(),
+    //   ],
+    // });
+
+    // dismiss
+    await respondUtil.deleteOriginal();
+  };
+
+  handler.keepMessageBlocks = function(channelName) {
+    const tzDateSec = new Date().getTime();
+    const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
+
+    const now = tzDateSec - grwTzoffset;
+    const oldest = now - 60 * 60 * 1000;
+    const newest = now;
+
+    const initialOldest = format(oldest, 'yyyy/MM/dd-HH:mm');
+    const initialNewest = format(newest, 'yyyy/MM/dd-HH:mm');
+    const initialPagePath = `/slack/keep/${channelName}/${format(oldest, 'yyyyMMdd-HH:mm')} - ${format(newest, 'yyyyMMdd-HH:mm')}`;
+
+    return [
+      markdownSectionBlock('*The keep command is in alpha.*'),
+      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
+      inputBlock({
+        type: 'plain_text_input',
+        action_id: 'oldest',
+        initial_value: initialOldest,
+      }, 'oldest', 'Oldest datetime'),
+      inputBlock({
+        type: 'plain_text_input',
+        action_id: 'newest',
+        initial_value: initialNewest,
+      }, 'newest', 'Newest datetime'),
+      inputBlock({
+        type: 'plain_text_input',
+        placeholder: {
+          type: 'plain_text',
+          text: 'Input page path to create.',
+        },
+        initial_value: initialPagePath,
+        action_id: 'page_path',
+      }, 'page_path', 'Page path'),
+      actionsBlock(
+        buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
+        buttonElement({ text: 'Create page', actionId: 'keep:createPage', style: 'primary' }),
+      ),
+    ];
+  };
+
+  return handler;
+};

+ 12 - 12
packages/app/src/server/service/slack-command-handler/create.js → packages/app/src/server/service/slack-command-handler/note.js

@@ -1,10 +1,10 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const {
 const {
-  markdownSectionBlock, inputSectionBlock, respond, inputBlock,
+  markdownSectionBlock, inputSectionBlock, inputBlock,
 } = require('@growi/slack');
 } = require('@growi/slack');
 
 
-const logger = loggerFactory('growi:service:SlackCommandHandler:create');
+const logger = loggerFactory('growi:service:SlackCommandHandler:note');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const CreatePageService = require('./create-page-service');
   const CreatePageService = require('./create-page-service');
@@ -18,16 +18,16 @@ module.exports = (crowi) => {
     default_to_current_conversation: true,
     default_to_current_conversation: true,
   };
   };
 
 
-  handler.handleCommand = async(growiCommand, client, body) => {
+  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
     await client.views.open({
     await client.views.open({
       trigger_id: body.trigger_id,
       trigger_id: body.trigger_id,
 
 
       view: {
       view: {
         type: 'modal',
         type: 'modal',
-        callback_id: 'create:createPage',
+        callback_id: 'note:createPage',
         title: {
         title: {
           type: 'plain_text',
           type: 'plain_text',
-          text: 'Create Page',
+          text: 'Take a note',
         },
         },
         submit: {
         submit: {
           type: 'plain_text',
           type: 'plain_text',
@@ -38,9 +38,9 @@ module.exports = (crowi) => {
           text: 'Cancel',
           text: 'Cancel',
         },
         },
         blocks: [
         blocks: [
-          markdownSectionBlock('Create new page.'),
+          markdownSectionBlock('Take a note on GROWI'),
           inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
           inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
-          inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
+          inputSectionBlock('path', 'Page path', 'path_input', false, '/path'),
           inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
           inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
         ],
         private_metadata: JSON.stringify({ channelId: body.channel_id, channelName: body.channel_name }),
         private_metadata: JSON.stringify({ channelId: body.channel_id, channelName: body.channel_name }),
@@ -48,15 +48,15 @@ module.exports = (crowi) => {
     });
     });
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
   };
   };
 
 
-  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor) {
+  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) {
     const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
     const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
     const privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
     const privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
     if (privateMetadata == null) {
     if (privateMetadata == null) {
-      await respond(interactionPayloadAccessor.getResponseUrl(), {
+      await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
         blocks: [
         blocks: [
           markdownSectionBlock('Failed to create a page.'),
           markdownSectionBlock('Failed to create a page.'),
@@ -65,7 +65,7 @@ module.exports = (crowi) => {
       return;
       return;
     }
     }
     const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
     const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
   };
   };
 
 
   return handler;
   return handler;

+ 0 - 66
packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js

@@ -1,66 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:SlackCommandHandler:slack-bot-response');
-const { markdownSectionBlock } = require('@growi/slack');
-const SlackbotError = require('../../models/vo/slackbot-error');
-
-async function respondIfSlackbotError(client, body, err) {
-  // check if the request is to /commands OR /interactions
-  const isInteraction = !body.channel_id;
-
-  // throw non-SlackbotError
-  if (!SlackbotError.isSlackbotError(err)) {
-    logger.error(`A non-SlackbotError error occured.\n${err.toString()}`);
-    throw err;
-  }
-
-  // for both postMessage and postEphemeral
-  let toChannel;
-  // for only postEphemeral
-  let toUser;
-  // decide which channel to send to
-  switch (err.to) {
-    case 'dm':
-      toChannel = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
-      toUser = toChannel;
-      break;
-    case 'channel':
-      toChannel = isInteraction ? JSON.parse(body.payload).channel.id : body.channel_id;
-      toUser = isInteraction ? JSON.parse(body.payload).user.id : body.user_id;
-      break;
-    default:
-      logger.error('The "to" property of SlackbotError must be "dm" or "channel".');
-      break;
-  }
-
-  // argumentObj object to pass to postMessage OR postEphemeral
-  let argumentsObj = {};
-  switch (err.method) {
-    case 'postMessage':
-      argumentsObj = {
-        channel: toChannel,
-        text: err.popupMessage,
-        blocks: [
-          markdownSectionBlock(err.mainMessage),
-        ],
-      };
-      break;
-    case 'postEphemeral':
-      argumentsObj = {
-        channel: toChannel,
-        user: toUser,
-        text: err.popupMessage,
-        blocks: [
-          markdownSectionBlock(err.mainMessage),
-        ],
-      };
-      break;
-    default:
-      logger.error('The "method" property of SlackbotError must be "postMessage" or "postEphemeral".');
-      break;
-  }
-
-  await client.chat[err.method](argumentsObj);
-}
-
-module.exports = { respondIfSlackbotError };

+ 161 - 258
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,33 +3,67 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const {
 const {
-  markdownSectionBlock, divider, respond, deleteOriginal,
+  markdownSectionBlock, divider,
 } = require('@growi/slack');
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 const { formatDistanceStrict } = require('date-fns');
-const SlackbotError = require('../../models/vo/slackbot-error');
 
 
-const PAGINGLIMIT = 10;
+const PAGINGLIMIT = 7;
+
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler(crowi);
   const handler = new BaseSlackCommandHandler(crowi);
 
 
-  handler.handleCommand = async function(growiCommand, client, body) {
-    const { responseUrl, growiCommandArgs } = growiCommand;
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(responseUrl, client, body, growiCommandArgs);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      throw new SlackbotError({
-        method: 'postEphemeral',
-        to: 'channel',
-        popupMessage: 'Failed To Search',
-        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
-      });
+
+  function getKeywords(growiCommandArgs) {
+    const keywords = growiCommandArgs.join(' ');
+    return keywords;
+  }
+
+  function appendSpeechBaloon(mrkdwn, commentCount) {
+    return (commentCount != null && commentCount > 0)
+      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
+      : mrkdwn;
+  }
+
+  function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
+    const url = new URL('/_search', appUrl);
+    url.searchParams.append('q', growiCommandArgs.map(kwd => encodeURIComponent(kwd)).join('+'));
+    return `<${url.href} | Results page>`;
+  }
+
+  function generatePageLinkMrkdwn(pathname, href) {
+    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  }
+
+  function generateLastUpdateMrkdwn(updatedAt, baseDate) {
+    if (updatedAt != null) {
+      // cast to date
+      const date = new Date(updatedAt);
+      return formatDistanceStrict(date, baseDate);
     }
     }
+    return '';
+  }
+
+  async function retrieveSearchResults(growiCommandArgs, offset = 0) {
+    const keywords = getKeywords(growiCommandArgs);
+
+    const { searchService } = crowi;
+    const options = { limit: PAGINGLIMIT, offset };
+    const results = await searchService.searchKeyword(keywords, null, {}, options);
+    const resultsTotal = results.meta.total;
+
+    const pages = results.data.map((data) => {
+      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      return { path, updatedAt, commentCount };
+    });
+
+    return {
+      pages, offset, resultsTotal,
+    };
+  }
 
 
+  function buildRespondBodyForSearchResult(searchResult, growiCommandArgs) {
     const appUrl = crowi.appService.getSiteUrl();
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
 
 
@@ -37,22 +71,9 @@ module.exports = (crowi) => {
       pages, offset, resultsTotal,
       pages, offset, resultsTotal,
     } = searchResult;
     } = searchResult;
 
 
-    const keywords = this.getKeywords(growiCommandArgs);
-
+    const keywords = getKeywords(growiCommandArgs);
 
 
     let searchResultsDesc;
     let searchResultsDesc;
-
-    if (resultsTotal === 0 || resultsTotal == null) {
-      if (keywords === '') return;
-      await respond(responseUrl, {
-        text: 'No page found.',
-        blocks: [
-          markdownSectionBlock(`No page found. keyword(s): *"${keywords}"*`),
-          markdownSectionBlock('Please try other keywords.'),
-        ],
-      });
-      return;
-    }
     switch (resultsTotal) {
     switch (resultsTotal) {
       case 1:
       case 1:
         searchResultsDesc = `*${resultsTotal}* page is found.`;
         searchResultsDesc = `*${resultsTotal}* page is found.`;
@@ -62,13 +83,15 @@ module.exports = (crowi) => {
         break;
         break;
     }
     }
 
 
-
     const contextBlock = {
     const contextBlock = {
       type: 'context',
       type: 'context',
       elements: [
       elements: [
         {
         {
           type: 'mrkdwn',
           type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+          text: `keyword(s) : *"${keywords}"*`
+          + `  |  Total ${resultsTotal} pages`
+          + `  |  Current: ${offset + 1} - ${offset + pages.length}`
+          + `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
         },
         },
       ],
       ],
     };
     };
@@ -89,8 +112,8 @@ module.exports = (crowi) => {
           type: 'section',
           type: 'section',
           text: {
           text: {
             type: 'mrkdwn',
             type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+            text: `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
           },
           },
           accessory: {
           accessory: {
             type: 'button',
             type: 'button',
@@ -121,42 +144,111 @@ module.exports = (crowi) => {
         },
         },
       ],
       ],
     };
     };
+    // show "Prev" button if previous page exists
+    // eslint-disable-next-line yoda
+    if (0 < offset) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: '< Prev',
+          },
+          action_id: 'search:showPrevResults',
+          value: JSON.stringify({ offset, growiCommandArgs }),
+        },
+      );
+    }
     // show "Next" button if next page exists
     // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
+    if (offset + PAGINGLIMIT < resultsTotal) {
       actionBlocks.elements.unshift(
       actionBlocks.elements.unshift(
         {
         {
           type: 'button',
           type: 'button',
           text: {
           text: {
             type: 'plain_text',
             type: 'plain_text',
-            text: 'Next',
+            text: 'Next >',
           },
           },
           action_id: 'search:showNextResults',
           action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, growiCommandArgs }),
+          value: JSON.stringify({ offset, growiCommandArgs }),
         },
         },
       );
       );
     }
     }
     blocks.push(actionBlocks);
     blocks.push(actionBlocks);
 
 
-    await respond(responseUrl, {
+    return {
       text: 'Successed To Search',
       text: 'Successed To Search',
       blocks,
       blocks,
-    });
+    };
+  }
+
+
+  async function buildRespondBody(growiCommandArgs) {
+    const firstKeyword = growiCommandArgs[0];
+
+    // enpty keyword
+    if (firstKeyword == null) {
+      return {
+        text: 'Input keywords',
+        blocks: [
+          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
+        ],
+      };
+    }
+
+    const searchResult = await retrieveSearchResults(growiCommandArgs);
+
+    // no search results
+    if (searchResult.resultsTotal === 0) {
+      const keywords = getKeywords(growiCommandArgs);
+      logger.info(`No page found with "${keywords}"`);
+
+      return {
+        text: `No page found with "${keywords}"`,
+        blocks: [
+          markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
+          markdownSectionBlock(':mag: *Help: Searching*'),
+          divider(),
+          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
+          divider(),
+          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
+          divider(),
+          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
+          divider(),
+          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
+          divider(),
+          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
+          divider(),
+          markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
+          divider(),
+          markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
+        ],
+      };
+    }
+
+    return buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
+  }
+
+
+  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+    const { growiCommandArgs } = growiCommand;
+
+    const respondBody = await buildRespondBody(growiCommandArgs);
+    await respondUtil.respond(respondBody);
   };
   };
 
 
-  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
-    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
   };
   };
 
 
-  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor) {
+  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor, respondUtil) {
     const { user } = payload;
     const { user } = payload;
-    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
 
     const appUrl = crowi.appService.getSiteUrl();
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
 
 
     const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
     const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
     if (value == null) {
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
         blocks: [
         blocks: [
           markdownSectionBlock('Failed to share the result.'),
           markdownSectionBlock('Failed to share the result.'),
@@ -165,22 +257,26 @@ module.exports = (crowi) => {
       return;
       return;
     }
     }
 
 
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+
     // restore page data from value
     // restore page data from value
-    const { page, href, pathname } = JSON.parse(value);
+    const { page, href, pathname } = parsedValue;
     const { updatedAt, commentCount } = page;
     const { updatedAt, commentCount } = page;
 
 
     // share
     // share
     const now = new Date();
     const now = new Date();
-    return respond(responseUrl, {
+    return respondUtil.respondInChannel({
       blocks: [
       blocks: [
         { type: 'divider' },
         { type: 'divider' },
-        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
         {
         {
           type: 'context',
           type: 'context',
           elements: [
           elements: [
             {
             {
               type: 'mrkdwn',
               type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>`
+                + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\``
+                + `  |  Shared by *${user.username}*`,
             },
             },
           ],
           ],
         },
         },
@@ -188,12 +284,11 @@ module.exports = (crowi) => {
     });
     });
   };
   };
 
 
-  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
-    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true, respondUtil) {
 
 
     const value = interactionPayloadAccessor.firstAction()?.value;
     const value = interactionPayloadAccessor.firstAction()?.value;
     if (value == null) {
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
         blocks: [
         blocks: [
           markdownSectionBlock('Failed to show the next results.'),
           markdownSectionBlock('Failed to show the next results.'),
@@ -201,221 +296,29 @@ module.exports = (crowi) => {
       });
       });
       return;
       return;
     }
     }
-    const parsedValue = JSON.parse(value);
-
-    const { body, growiCommandArgs, offset: offsetNum } = parsedValue;
-    const newOffsetNum = offsetNum + 10;
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(responseUrl, client, body, growiCommandArgs, newOffsetNum);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      throw new SlackbotError({
-        method: 'postEphemeral',
-        to: 'channel',
-        popupMessage: 'Failed To Search',
-        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
-      });
-    }
-
-    const appUrl = crowi.appService.getSiteUrl();
-    const appTitle = crowi.appService.getAppTitle();
-
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
-
-    const keywords = this.getKeywords(growiCommandArgs);
 
 
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
 
-    let searchResultsDesc;
+    const { growiCommandArgs, offset: offsetNum } = parsedValue;
+    const newOffsetNum = isNext
+      ? offsetNum + PAGINGLIMIT
+      : offsetNum - PAGINGLIMIT;
 
 
-    if (resultsTotal === 0 || resultsTotal == null) {
-      if (keywords === '') return;
-      await respond(responseUrl, {
-        text: 'No page found.',
-        blocks: [
-          markdownSectionBlock('Please try with other keywords.'),
-        ],
-      });
-      return;
-    }
-    switch (resultsTotal) {
-      case 1:
-        searchResultsDesc = `*${resultsTotal}* page is found.`;
-        break;
-      default:
-        searchResultsDesc = `*${resultsTotal}* pages are found.`;
-        break;
-    }
+    const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
 
 
-    const contextBlock = {
-      type: 'context',
-      elements: [
-        {
-          type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
-        },
-      ],
-    };
+    await respondUtil.replaceOriginal(buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+  }
 
 
-    const now = new Date();
-    const blocks = [
-      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
-      contextBlock,
-      { type: 'divider' },
-      // create an array by map and extract
-      ...pages.map((page) => {
-        const { path, updatedAt, commentCount } = page;
-        // generate URL
-        const url = new URL(path, appUrl);
-        const { href, pathname } = url;
-
-        return {
-          type: 'section',
-          text: {
-            type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
-          },
-          accessory: {
-            type: 'button',
-            action_id: 'search:shareSinglePageResult',
-            text: {
-              type: 'plain_text',
-              text: 'Share',
-            },
-            value: JSON.stringify({ page, href, pathname }),
-          },
-        };
-      }),
-      { type: 'divider' },
-      contextBlock,
-    ];
-
-    const actionBlocks = {
-      type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'search:dismissSearchResults',
-        },
-      ],
-    };
-    // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
-      actionBlocks.elements.unshift(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Next',
-          },
-          action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, growiCommandArgs }),
-        },
-      );
-    }
-    blocks.push(actionBlocks);
-
-    await respond(responseUrl, {
-      text: 'Successed To Search',
-      blocks,
-    });
-  };
-
-  handler.dismissSearchResults = async function(client, payload) {
-    const { response_url: responseUrl } = payload;
-
-    return deleteOriginal(responseUrl, {
-      delete_original: true,
-    });
+  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
   };
   };
 
 
-  handler.retrieveSearchResults = async function(responseUrl, client, body, growiCommandArgs, offset = 0) {
-    const firstKeyword = growiCommandArgs[0];
-    if (firstKeyword == null) {
-      await respond(responseUrl, {
-        text: 'Input keywords',
-        blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      return { pages: [] };
-    }
-
-    const keywords = this.getKeywords(growiCommandArgs);
-
-    const { searchService } = crowi;
-    const options = { limit: 10, offset };
-    const results = await searchService.searchKeyword(keywords, null, {}, options);
-    const resultsTotal = results.meta.total;
-
-    // no search results
-    if (results.data.length === 0) {
-      logger.info(`No page found with "${keywords}"`);
-      await respond(responseUrl, {
-        text: `No page found with "${keywords}"`,
-        blocks: [
-          markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
-          markdownSectionBlock(':mag: *Help: Searching*'),
-          divider(),
-          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
-          divider(),
-          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
-          divider(),
-          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
-          divider(),
-          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
-          divider(),
-          markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
-        ],
-      });
-      return { pages: [] };
-    }
-
-    const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
-      return { path, updatedAt, commentCount };
-    });
-
-    return {
-      pages, offset, resultsTotal,
-    };
-  };
-
-  handler.getKeywords = function(growiCommandArgs) {
-    const keywords = growiCommandArgs.join(' ');
-    return keywords;
-  };
-
-  handler.appendSpeechBaloon = function(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
-      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
-      : mrkdwn;
-  };
-
-  handler.generatePageLinkMrkdwn = function(pathname, href) {
-    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  handler.showNextResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
   };
   };
 
 
-  handler.generateLastUpdateMrkdwn = function(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
+  handler.dismissSearchResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return respondUtil.deleteOriginal();
   };
   };
 
 
   return handler;
   return handler;

+ 83 - 88
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -7,7 +7,7 @@ const {
 } = require('@growi/slack');
 } = require('@growi/slack');
 const { parse, format } = require('date-fns');
 const { parse, format } = require('date-fns');
 const axios = require('axios');
 const axios = require('axios');
-const SlackbotError = require('../../models/vo/slackbot-error');
+const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const CreatePageService = require('./create-page-service');
   const CreatePageService = require('./create-page-service');
@@ -37,22 +37,17 @@ module.exports = (crowi) => {
     let result = [];
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const userChannelId = payload.user.id;
     const userChannelId = payload.user.id;
-    try {
-      // validate form
-      const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor);
-      // get messages
-      result = await this.togetterGetMessages(client, channelId, newest, oldest);
-      // clean messages
-      const cleanedContents = await this.togetterCleanMessages(result.messages);
-
-      const contentsBody = cleanedContents.join('');
-      // create and send url message
-      await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody);
-    }
-    catch (err) {
-      logger.error('Error occured by togetter.');
-      throw err;
-    }
+
+    // validate form
+    const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor);
+    // get messages
+    result = await this.togetterGetMessages(client, channelId, newest, oldest);
+    // clean messages
+    const cleanedContents = await this.togetterCleanMessages(result.messages);
+
+    const contentsBody = cleanedContents.join('');
+    // create and send url message
+    await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody);
   };
   };
 
 
   handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) {
   handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) {
@@ -60,71 +55,88 @@ module.exports = (crowi) => {
     const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
     const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
     let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
     let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
     let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
     let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
-    oldest = oldest.trim();
-    newest = newest.trim();
-    if (path == null) {
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Page path is required.',
-        mainMessage: 'Page path is required.',
-      });
+
+    if (oldest == null || newest == null || path == null) {
+      throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
     }
     }
+
     /**
     /**
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * RegExp for datetime yyyy/MM/dd-HH:mm
      * @see https://regex101.com/r/XbxdNo/1
      * @see https://regex101.com/r/XbxdNo/1
      */
      */
     const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
     const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
 
 
-    if (!regexpDatetime.test(oldest)) {
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
-        mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
-      });
+    if (!regexpDatetime.test(oldest.trim())) {
+      throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
     }
     }
-    if (!regexpDatetime.test(newest)) {
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Datetime format for newest must be yyyy/MM/dd-HH:mm',
-        mainMessage: 'Datetime format for newest must be yyyy/MM/dd-HH:mm',
-      });
+    if (!regexpDatetime.test(newest.trim())) {
+      throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
     }
     }
     oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
     oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
     newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
     newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
 
 
     if (oldest > newest) {
     if (oldest > newest) {
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Oldest datetime must be older than the newest date time.',
-        mainMessage: 'Oldest datetime must be older than the newest date time.',
-      });
+      throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
     }
     }
 
 
     return { path, oldest, newest };
     return { path, oldest, newest };
   };
   };
 
 
-  handler.togetterGetMessages = async function(client, channelId, newest, oldest) {
-    const result = await client.conversations.history({
+  async function retrieveHistory(client, channelId, newest, oldest) {
+    return client.conversations.history({
       channel: channelId,
       channel: channelId,
       newest,
       newest,
       oldest,
       oldest,
       limit: 100,
       limit: 100,
       inclusive: true,
       inclusive: true,
     });
     });
+  }
+
+  handler.togetterGetMessages = async function(client, channelId, newest, oldest) {
+    let result;
+
+    // first attempt
+    try {
+      result = await retrieveHistory(client, channelId, newest, oldest);
+    }
+    catch (err) {
+      const errorCode = err.data?.errorCode;
+
+      if (errorCode === 'not_in_channel') {
+        // join and retry
+        await client.conversations.join({
+          channel: channelId,
+        });
+        result = await retrieveHistory(client, channelId, newest, oldest);
+      }
+      else if (errorCode === 'channel_not_found') {
+
+        const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
+          + '\nPlease add GROWI bot to this channel.'
+          + '\n';
+        throw new SlackCommandHandlerError(message, {
+          respondBody: {
+            text: message,
+            blocks: [
+              markdownSectionBlock(message),
+              {
+                type: 'image',
+                image_url: 'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png',
+                alt_text: 'Add app to this channel',
+              },
+            ],
+          },
+        });
+      }
+      else {
+        throw err;
+      }
+    }
 
 
     // return if no message found
     // return if no message found
     if (result.messages.length === 0) {
     if (result.messages.length === 0) {
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'No message found from togetter command. Try different datetime.',
-        mainMessage: 'No message found from togetter command. Try different datetime.',
-      });
+      throw new SlackCommandHandlerError('No message found from togetter command. Try different datetime.');
     }
     }
     return result;
     return result;
   };
   };
@@ -157,40 +169,23 @@ module.exports = (crowi) => {
   };
   };
 
 
   handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) {
   handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) {
-    try {
-      await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
-    }
-    catch (err) {
-      logger.error('Error occurred while creating a page.');
-      throw err;
-    }
-
-    try {
-      // send preview to dm
-      await client.chat.postMessage({
-        channel: userChannelId,
-        text: 'Preview from togetter command',
-        blocks: [
-          markdownSectionBlock('*Preview*'),
-          divider(),
-          markdownSectionBlock(contentsBody),
-          divider(),
-        ],
-      });
-      // dismiss
-      await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
-        delete_original: true,
-      });
-    }
-    catch (err) {
-      logger.error('Error occurred while creating a page.', err);
-      throw new SlackbotError({
-        method: 'postMessage',
-        to: 'dm',
-        popupMessage: 'Error occurred while creating a page.',
-        mainMessage: 'Error occurred while creating a page.',
-      });
-    }
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+
+    // send preview to dm
+    await client.chat.postMessage({
+      channel: userChannelId,
+      text: 'Preview from togetter command',
+      blocks: [
+        markdownSectionBlock('*Preview*'),
+        divider(),
+        markdownSectionBlock(contentsBody),
+        divider(),
+      ],
+    });
+    // dismiss
+    await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
+      delete_original: true,
+    });
   };
   };
 
 
   handler.togetterMessageBlocks = function() {
   handler.togetterMessageBlocks = function() {

+ 40 - 26
packages/app/src/server/service/slack-integration.ts

@@ -4,7 +4,8 @@ import { IncomingWebhookSendArguments } from '@slack/webhook';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 
 
 import {
 import {
-  generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, respond, SlackbotType,
+  generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
+  RespondUtil,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -14,6 +15,7 @@ import S2sMessage from '../models/vo/s2s-message';
 import ConfigManager from './config-manager';
 import ConfigManager from './config-manager';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 
 
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');
@@ -237,59 +239,71 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
   /**
    * Handle /commands endpoint
    * Handle /commands endpoint
    */
    */
-  async handleCommandRequest(growiCommand: GrowiCommand, client, body) {
+  async handleCommandRequest(growiCommand: GrowiCommand, client, body, respondUtil: RespondUtil): Promise<void> {
     const { growiCommandType } = growiCommand;
     const { growiCommandType } = growiCommand;
     const module = `./slack-command-handler/${growiCommandType}`;
     const module = `./slack-command-handler/${growiCommandType}`;
 
 
+    let handler;
     try {
     try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(growiCommand, client, body);
+      handler = require(module)(this.crowi);
     }
     }
     catch (err) {
     catch (err) {
-      await this.notCommand(growiCommand);
-      throw err;
+      const text = `*No command.*\n \`command: ${growiCommand.text}\``;
+      logger.error(err);
+      throw new SlackCommandHandlerError(text, {
+        respondBody: {
+          text,
+          blocks: [
+            markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+          ],
+        },
+      });
     }
     }
+
+    // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
+    return handler.handleCommand(growiCommand, client, body, respondUtil);
   }
   }
 
 
-  async handleBlockActionsRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+  async handleBlockActionsRequest(
+      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+  ): Promise<void> {
     const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = actionId.split(':')[0];
     const commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
     const handlerMethodName = actionId.split(':')[1];
+
     const module = `./slack-command-handler/${commandName}`;
     const module = `./slack-command-handler/${commandName}`;
+
+    let handler;
     try {
     try {
-      const handler = require(module)(this.crowi);
-      await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
+      handler = require(module)(this.crowi);
     }
     }
     catch (err) {
     catch (err) {
-      throw err;
+      throw new SlackCommandHandlerError(`No interaction.\n \`actionId: ${actionId}\``);
     }
     }
-    return;
+
+    // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
+    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
   }
 
 
-  async handleViewSubmissionRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+  async handleViewSubmissionRequest(
+      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+  ): Promise<void> {
     const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = callbackId.split(':')[0];
     const commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
     const handlerMethodName = callbackId.split(':')[1];
+
     const module = `./slack-command-handler/${commandName}`;
     const module = `./slack-command-handler/${commandName}`;
+
+    let handler;
     try {
     try {
-      const handler = require(module)(this.crowi);
-      await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
+      handler = require(module)(this.crowi);
     }
     }
     catch (err) {
     catch (err) {
-      throw err;
+      throw new SlackCommandHandlerError(`No interaction.\n \`callbackId: ${callbackId}\``);
     }
     }
-    return;
-  }
 
 
-  async notCommand(growiCommand: GrowiCommand): Promise<void> {
-    logger.error('Invalid first argument');
-    await respond(growiCommand.responseUrl, {
-      text: 'No command',
-      blocks: [
-        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    return;
+    // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
+    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
   }
 
 
 }
 }

+ 5 - 0
packages/app/src/server/util/slack-integration.ts

@@ -7,6 +7,11 @@ export const checkPermission = (
 ):boolean => {
 ):boolean => {
   let isPermitted = false;
   let isPermitted = false;
 
 
+  // help
+  if (commandOrActionIdOrCallbackId === 'help') {
+    return true;
+  }
+
   Object.entries(commandPermission).forEach((entry) => {
   Object.entries(commandPermission).forEach((entry) => {
     const [command, value] = entry;
     const [command, value] = entry;
     const permission = value;
     const permission = value;

+ 2 - 2
packages/app/src/styles/_wiki.scss

@@ -239,14 +239,14 @@ div.body {
     }
     }
   }
   }
 
 
-  .grw-togetter {
+  .grw-keep {
     padding: 7%;
     padding: 7%;
     padding-bottom: 3%;
     padding-bottom: 3%;
     margin: 0 7%;
     margin: 0 7%;
     background-color: rgba(200, 200, 200, 0.2);
     background-color: rgba(200, 200, 200, 0.2);
     border-radius: 10px;
     border-radius: 10px;
 
 
-    .grw-togetter-time {
+    .grw-keep-time {
       float: right;
       float: right;
       font-size: 0.8em;
       font-size: 0.8em;
       font-weight: normal;
       font-weight: normal;

+ 6 - 6
packages/app/src/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts

@@ -87,8 +87,8 @@ describe('migrate-slack-app-integration-schema', () => {
       },
       },
       permissionsForSingleUseCommands: {
       permissionsForSingleUseCommands: {
         bar: true,
         bar: true,
-        create: false,
-        togetter: false,
+        note: false,
+        keep: false,
       },
       },
     });
     });
     expect(fixedDoc2).toStrictEqual({
     expect(fixedDoc2).toStrictEqual({
@@ -101,8 +101,8 @@ describe('migrate-slack-app-integration-schema', () => {
       },
       },
       permissionsForSingleUseCommands: {
       permissionsForSingleUseCommands: {
         bar: true,
         bar: true,
-        create: false,
-        togetter: false,
+        note: false,
+        keep: false,
       },
       },
     });
     });
     expect(fixedDoc3).toStrictEqual({
     expect(fixedDoc3).toStrictEqual({
@@ -113,8 +113,8 @@ describe('migrate-slack-app-integration-schema', () => {
         search: true,
         search: true,
       },
       },
       permissionsForSingleUseCommands: {
       permissionsForSingleUseCommands: {
-        create: true,
-        togetter: true,
+        note: true,
+        keep: true,
       },
       },
     });
     });
   });
   });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/codemirror-textlint",
   "name": "@growi/codemirror-textlint",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-attachment-refs",
   "name": "@growi/plugin-attachment-refs",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-lsx",
   "name": "@growi/plugin-lsx",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "GROWI plugin to list pages",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 3 - 2
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slack",
   "name": "@growi/slack",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "license": "MIT",
   "license": "MIT",
   "main": "dist/index.js",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",
   "typings": "dist/index.d.ts",
@@ -19,7 +19,8 @@
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^1.8.0",
     "http-errors": "^1.8.0",
-    "universal-bunyan": "^0.9.2"
+    "universal-bunyan": "^0.9.2",
+    "url-join": "^4.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

+ 8 - 5
packages/slack/src/index.ts

@@ -8,8 +8,8 @@ export const supportedSlackCommands: string[] = [
 
 
 export const supportedGrowiCommands: string[] = [
 export const supportedGrowiCommands: string[] = [
   'search',
   'search',
-  'create',
-  'togetter',
+  'note',
+  'keep',
   'help',
   'help',
 ];
 ];
 
 
@@ -18,8 +18,8 @@ export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
 ];
 ];
 
 
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
-  'create',
-  'togetter',
+  'note',
+  'keep',
 ];
 ];
 
 
 export * from './interfaces/growi-command-processor';
 export * from './interfaces/growi-command-processor';
@@ -27,7 +27,10 @@ export * from './interfaces/growi-interaction-processor';
 export * from './interfaces/growi-command';
 export * from './interfaces/growi-command';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/request-from-slack';
+export * from './interfaces/response-url';
 export * from './interfaces/slackbot-types';
 export * from './interfaces/slackbot-types';
+export * from './interfaces/response-url';
+export * from './interfaces/respond-util';
 export * from './models/errors';
 export * from './models/errors';
 export * from './middlewares/parse-slack-interaction-request';
 export * from './middlewares/parse-slack-interaction-request';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-growi-to-slack-request';
@@ -41,7 +44,7 @@ export * from './utils/reshape-contents-body';
 export * from './utils/response-url';
 export * from './utils/response-url';
 export * from './utils/slash-command-parser';
 export * from './utils/slash-command-parser';
 export * from './utils/webclient-factory';
 export * from './utils/webclient-factory';
-export * from './utils/welcome-message';
 export * from './utils/required-scopes';
 export * from './utils/required-scopes';
 export * from './utils/interaction-payload-accessor';
 export * from './utils/interaction-payload-accessor';
 export * from './utils/payload-interaction-id-helpers';
 export * from './utils/payload-interaction-id-helpers';
+export * from './utils/respond-util-factory';

+ 9 - 7
packages/slack/src/interfaces/request-between-growi-and-proxy.ts

@@ -1,17 +1,19 @@
 import { Request } from 'express';
 import { Request } from 'express';
 
 
-export type RequestFromGrowi = Request & {
-  // appended by GROWI
-  headers:{'x-growi-gtop-tokens'?:string},
-
-  // will be extracted from header
-  tokenGtoPs: string[],
-
+export interface BlockKitRequest {
   // Block Kit properties
   // Block Kit properties
   body: {
   body: {
     view?: string,
     view?: string,
     blocks?: string
     blocks?: string
   },
   },
+}
+
+export type RequestFromGrowi = Request & BlockKitRequest & {
+  // appended by GROWI
+  headers:{'x-growi-gtop-tokens'?:string},
+
+  // will be extracted from header
+  tokenGtoPs: string[],
 };
 };
 
 
 export type RequestFromProxy = Request & {
 export type RequestFromProxy = Request & {

+ 8 - 0
packages/slack/src/interfaces/respond-util.ts

@@ -0,0 +1,8 @@
+import { RespondBodyForResponseUrl } from './response-url';
+
+export interface IRespondUtil {
+  respond(body: RespondBodyForResponseUrl): Promise<void>,
+  respondInChannel(body: RespondBodyForResponseUrl): Promise<void>,
+  replaceOriginal(body: RespondBodyForResponseUrl): Promise<void>,
+  deleteOriginal(): Promise<void>,
+}

+ 26 - 4
packages/slack/src/utils/interaction-payload-accessor.ts

@@ -1,4 +1,8 @@
+import assert from 'assert';
 import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
 import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+import loggerFactory from './logger';
+
+const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');
 
 
 
 
 export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
 export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
@@ -27,11 +31,10 @@ export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
     }
     }
 
 
     const responseUrls = this.payload.response_urls;
     const responseUrls = this.payload.response_urls;
-    if (responseUrls != null && responseUrls[0] != null) {
-      return responseUrls[0].response_url;
-    }
+    assert(responseUrls != null);
+    assert(responseUrls[0] != null);
 
 
-    return '';
+    return responseUrls[0].response_url;
   }
   }
 
 
   getStateValues(): any | null {
   getStateValues(): any | null {
@@ -80,4 +83,23 @@ export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
     return null;
     return null;
   }
   }
 
 
+  getOriginalData(): any | null {
+    const value = this.firstAction()?.value;
+    if (value == null) return null;
+
+    const { originalData } = JSON.parse(value);
+    if (originalData == null) return JSON.parse(value);
+
+    let parsedOriginalData;
+    try {
+      parsedOriginalData = JSON.parse(originalData);
+    }
+    catch (err) {
+      logger.error('Failed to parse original data:\n', err);
+      return null;
+    }
+
+    return parsedOriginalData;
+  }
+
 }
 }

+ 6 - 6
packages/slack/src/utils/reshape-contents-body.test.ts

@@ -41,9 +41,9 @@ some messages...
 some messages...`;
 some messages...`;
 
 
       const output = `
       const output = `
-<div class="grw-togetter">
+<div class="grw-keep">
 
 
-## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+## **taichi-m**<span class="grw-keep-time">  12:23 PM</span>
 \u0020\u0020
 \u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
@@ -72,9 +72,9 @@ some messages...
 some messages...`;
 some messages...`;
 
 
       const output = `
       const output = `
-<div class="grw-togetter">
+<div class="grw-keep">
 
 
-## **taichi-m**<span class="grw-togetter-time">  12:23</span>
+## **taichi-m**<span class="grw-keep-time">  12:23</span>
 \u0020\u0020
 \u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
@@ -99,9 +99,9 @@ taichi-m  12:23 PM
 some messages...`;
 some messages...`;
 
 
       const output = `some messages...
       const output = `some messages...
-<div class="grw-togetter">
+<div class="grw-keep">
 
 
-## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+## **taichi-m**<span class="grw-keep-time">  12:23 PM</span>
 \u0020\u0020
 \u0020\u0020
 some messages...\u0020\u0020
 some messages...\u0020\u0020
 </div>\u0020\u0020
 </div>\u0020\u0020

+ 2 - 2
packages/slack/src/utils/reshape-contents-body.ts

@@ -64,7 +64,7 @@ export const reshapeContentsBody = (str: string): string => {
       }
       }
       // ##*username*  HH:mm AM
       // ##*username*  HH:mm AM
       copyline = '\n## **'.concat(copyline);
       copyline = '\n## **'.concat(copyline);
-      copyline = copyline.replace(regexpTime, '**<span class="grw-togetter-time">'.concat(time, '</span>\n'));
+      copyline = copyline.replace(regexpTime, '**<span class="grw-keep-time">'.concat(time, '</span>\n'));
     }
     }
     // Check 3: Is this line a short time(HH:mm)?
     // Check 3: Is this line a short time(HH:mm)?
     else if (regexpShortTime.test(copyline)) {
     else if (regexpShortTime.test(copyline)) {
@@ -82,7 +82,7 @@ export const reshapeContentsBody = (str: string): string => {
   // remove all blanks
   // remove all blanks
   const blanksRemoved = reshapedArray.filter(line => line !== '');
   const blanksRemoved = reshapedArray.filter(line => line !== '');
   // add <div> to the first line & add </div> to the last line
   // add <div> to the first line & add </div> to the last line
-  blanksRemoved[0] = '\n<div class="grw-togetter">\n'.concat(blanksRemoved[0]);
+  blanksRemoved[0] = '\n<div class="grw-keep">\n'.concat(blanksRemoved[0]);
   blanksRemoved.push('</div>');
   blanksRemoved.push('</div>');
   // Add 2 spaces and 1 enter to all lines
   // Add 2 spaces and 1 enter to all lines
   const completedArray = blanksRemoved.map(line => line.concat('  \n'));
   const completedArray = blanksRemoved.map(line => line.concat('  \n'));

+ 71 - 0
packages/slack/src/utils/respond-util-factory.ts

@@ -0,0 +1,71 @@
+import axios from 'axios';
+import urljoin from 'url-join';
+import { RespondBodyForResponseUrl } from '../interfaces/response-url';
+import { IRespondUtil } from '../interfaces/respond-util';
+
+type AxiosOptions = {
+  headers?: {
+    [header:string]: string,
+  }
+}
+
+function getResponseUrlForProxy(proxyUri: string, responseUrl: string): string {
+  return urljoin(proxyUri, `/g2s/respond?response_url=${responseUrl}`);
+}
+
+function getUrl(responseUrl: string, proxyUri: string | null): string {
+  return proxyUri == null ? responseUrl : getResponseUrlForProxy(proxyUri, responseUrl);
+}
+
+export class RespondUtil implements IRespondUtil {
+
+  url!: string;
+
+  options!: AxiosOptions;
+
+  constructor(responseUrl: string, proxyUri: string | null, appSiteUrl: string) {
+    this.url = getUrl(responseUrl, proxyUri);
+
+    this.options = {
+      headers: {
+        'x-growi-app-site-url': appSiteUrl,
+      },
+    };
+  }
+
+  async respond(body: RespondBodyForResponseUrl): Promise<void> {
+    return axios.post(this.url, {
+      replace_original: false,
+      text: body.text,
+      blocks: body.blocks,
+    }, this.options);
+  }
+
+  async respondInChannel(body: RespondBodyForResponseUrl): Promise<void> {
+    return axios.post(this.url, {
+      response_type: 'in_channel',
+      replace_original: false,
+      text: body.text,
+      blocks: body.blocks,
+    }, this.options);
+  }
+
+  async replaceOriginal(body: RespondBodyForResponseUrl): Promise<void> {
+    return axios.post(this.url, {
+      replace_original: true,
+      text: body.text,
+      blocks: body.blocks,
+    }, this.options);
+  }
+
+  async deleteOriginal(): Promise<void> {
+    return axios.post(this.url, {
+      delete_original: true,
+    }, this.options);
+  }
+
+}
+
+export function generateRespondUtil(responseUrl: string, proxyUri: string | null, appSiteUrl: string): RespondUtil {
+  return new RespondUtil(responseUrl, proxyUri, appSiteUrl);
+}

+ 9 - 0
packages/slack/src/utils/response-url.ts

@@ -10,6 +10,15 @@ export async function respond(responseUrl: string, body: RespondBodyForResponseU
   });
   });
 }
 }
 
 
+export async function respondInChannel(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
+  return axios.post(responseUrl, {
+    response_type: 'in_channel',
+    replace_original: false,
+    text: body.text,
+    blocks: body.blocks,
+  });
+}
+
 export async function replaceOriginal(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
 export async function replaceOriginal(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
   return axios.post(responseUrl, {
   return axios.post(responseUrl, {
     replace_original: true,
     replace_original: true,

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

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

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

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "4.4.7-slackbot-proxy.1",
+  "version": "4.4.8-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^4.4.7-RC.0",
+    "@growi/slack": "^4.4.8-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 41 - 8
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -1,5 +1,5 @@
 import {
 import {
-  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put,
+  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put, QueryParams,
 } from '@tsed/common';
 } from '@tsed/common';
 import axios from 'axios';
 import axios from 'axios';
 import createError from 'http-errors';
 import createError from 'http-errors';
@@ -8,7 +8,7 @@ import { addHours } from 'date-fns';
 import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 
 
 import {
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
@@ -27,6 +27,14 @@ import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/Sect
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 
 
+export type RespondReqFromGrowi = Req & BlockKitRequest & {
+  // appended by GROWI
+  headers:{ 'x-growi-app-site-url'?: string },
+
+  // will be extracted from header
+  appSiteUrl: string,
+}
+
 @Controller('/g2s')
 @Controller('/g2s')
 export class GrowiToSlackCtrl {
 export class GrowiToSlackCtrl {
 
 
@@ -51,8 +59,8 @@ export class GrowiToSlackCtrl {
   @Inject()
   @Inject()
   sectionBlockPayloadDelegator: SectionBlockPayloadDelegator;
   sectionBlockPayloadDelegator: SectionBlockPayloadDelegator;
 
 
-  async requestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
-    const url = new URL('/_api/v3/slack-integration/proxied/commands', growiUrl);
+  async urlVerificationRequestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
+    const url = new URL('/_api/v3/slack-integration/proxied/verify', growiUrl);
     await axios.post(url.toString(), {
     await axios.post(url.toString(), {
       type: 'url_verification',
       type: 'url_verification',
       challenge: 'this_is_my_challenge_token',
       challenge: 'this_is_my_challenge_token',
@@ -141,7 +149,7 @@ export class GrowiToSlackCtrl {
       }
       }
 
 
       try {
       try {
-        await this.requestToGrowi(relation.growiUri, relation.tokenPtoG);
+        await this.urlVerificationRequestToGrowi(relation.growiUri, relation.tokenPtoG);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -170,7 +178,7 @@ export class GrowiToSlackCtrl {
 
 
     // Access the GROWI URL saved in the Order record and check if the GtoP token is valid.
     // Access the GROWI URL saved in the Order record and check if the GtoP token is valid.
     try {
     try {
-      await this.requestToGrowi(order.growiUrl, order.tokenPtoG);
+      await this.urlVerificationRequestToGrowi(order.growiUrl, order.tokenPtoG);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -217,7 +225,7 @@ export class GrowiToSlackCtrl {
     return res.send({ relation: generatedRelation, slackBotToken: token });
     return res.send({ relation: generatedRelation, slackBotToken: token });
   }
   }
 
 
-  injectGrowiUri(req: GrowiReq, growiUri: string): void {
+  injectGrowiUri(req: BlockKitRequest, growiUri: string): void {
     if (req.body.view == null && req.body.blocks == null) {
     if (req.body.view == null && req.body.blocks == null) {
       return;
       return;
     }
     }
@@ -231,7 +239,7 @@ export class GrowiToSlackCtrl {
       }
       }
     }
     }
     else if (req.body.blocks != null) {
     else if (req.body.blocks != null) {
-      const parsedElement = JSON.parse(req.body.blocks);
+      const parsedElement = (typeof req.body.blocks === 'string') ? JSON.parse(req.body.blocks) : req.body.blocks;
       // delegate to ActionsBlockPayloadDelegator
       // delegate to ActionsBlockPayloadDelegator
       if (this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
       if (this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
         this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
         this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
@@ -245,6 +253,31 @@ export class GrowiToSlackCtrl {
     }
     }
   }
   }
 
 
+  @Post('/respond')
+  async respondUsingResponseUrl(
+    @QueryParams('response_url') responseUrl: string, @Req() req: RespondReqFromGrowi, @Res() res: WebclientRes,
+  ): Promise<WebclientRes> {
+
+    // get growi url from header
+    const growiUri = req.headers['x-growi-app-site-url'];
+
+    if (growiUri == null) {
+      logger.error('Request to this endpoint requires the x-growi-app-site-url header.');
+      return res.status(400).send('Failed to respond.');
+    }
+
+    try {
+      this.injectGrowiUri(req, growiUri);
+    }
+    catch (err) {
+      logger.error('Error occurred while injecting GROWI uri:\n', err);
+
+      return res.status(400).send('Failed to respond.');
+    }
+
+    return axios.post(responseUrl, req.body);
+  }
+
   @Post('/:method')
   @Post('/:method')
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(
   async callSlackApi(

+ 31 - 14
packages/slackbot-proxy/src/controllers/slack.ts

@@ -10,9 +10,9 @@ import { Installation } from '@slack/oauth';
 
 
 import {
 import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
   markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
-  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
+  InvalidGrowiCommandError, requiredScopes, REQUEST_TIMEOUT_FOR_PTOG,
   parseSlackInteractionRequest, verifySlackRequest,
   parseSlackInteractionRequest, verifySlackRequest,
-  respond,
+  respond, supportedGrowiCommands,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import { Relation } from '~/entities/relation';
 import { Relation } from '~/entities/relation';
@@ -26,13 +26,13 @@ import {
 } from '~/middlewares/slack-to-growi/authorizer';
 } from '~/middlewares/slack-to-growi/authorizer';
 import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
-import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { RegisterService } from '~/services/RegisterService';
 import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
 import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { UnregisterService } from '~/services/UnregisterService';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { postInstallSuccessMessage, postWelcomeMessageOnce } from '~/utils/welcome-message';
 
 
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
@@ -130,7 +130,7 @@ export class SlackCtrl {
 
 
 
 
   @Post('/commands')
   @Post('/commands')
-  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware, JoinToConversationMiddleware)
+  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware)
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     const { body, authorizeResult } = req;
     const { body, authorizeResult } = req;
 
 
@@ -178,6 +178,7 @@ export class SlackCtrl {
       return this.unregisterService.processCommand(growiCommand, authorizeResult);
       return this.unregisterService.processCommand(growiCommand, authorizeResult);
     }
     }
 
 
+    // get relations
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
@@ -205,11 +206,22 @@ export class SlackCtrl {
       });
       });
     }
     }
 
 
-    await respond(growiCommand.responseUrl, {
-      blocks: [
-        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.text}"* ...`),
-      ],
-    });
+    // not supported commands
+    if (!supportedGrowiCommands.includes(growiCommand.growiCommandType)) {
+      return respond(growiCommand.responseUrl, {
+        text: 'Command is not supported',
+        blocks: [
+          markdownSectionBlock('*Command is not supported*'),
+          // eslint-disable-next-line max-len
+          markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
+        ],
+      });
+    }
+
+    // help
+    if (growiCommand.growiCommandType === 'help') {
+      return this.sendCommand(growiCommand, relations, body);
+    }
 
 
     const allowedRelationsForSingleUse:Relation[] = [];
     const allowedRelationsForSingleUse:Relation[] = [];
     const allowedRelationsForBroadcastUse:Relation[] = [];
     const allowedRelationsForBroadcastUse:Relation[] = [];
@@ -283,7 +295,7 @@ export class SlackCtrl {
     logger.debug('receive interaction', req.body);
     logger.debug('receive interaction', req.body);
 
 
     const {
     const {
-      body, authorizeResult, interactionPayload, interactionPayloadAccessor,
+      body, authorizeResult, interactionPayload, interactionPayloadAccessor, growiUri,
     } = req;
     } = req;
 
 
     // pass
     // pass
@@ -317,6 +329,7 @@ export class SlackCtrl {
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
     const relations = await this.relationRepository.createQueryBuilder('relation')
     const relations = await this.relationRepository.createQueryBuilder('relation')
       .where('relation.installationId = :id', { id: installation?.id })
       .where('relation.installationId = :id', { id: installation?.id })
+      .andWhere('relation.growiUri = :uri', { uri: growiUri })
       .leftJoinAndSelect('relation.installation', 'installation')
       .leftJoinAndSelect('relation.installation', 'installation')
       .getMany();
       .getMany();
 
 
@@ -375,12 +388,16 @@ export class SlackCtrl {
   @Post('/events')
   @Post('/events')
   @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
   @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
   async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
   async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
-
     const { authorizeResult } = req;
     const { authorizeResult } = req;
     const client = generateWebClient(authorizeResult.botToken);
     const client = generateWebClient(authorizeResult.botToken);
 
 
     if (req.body.event.type === 'app_home_opened') {
     if (req.body.event.type === 'app_home_opened') {
-      await postWelcomeMessage(client, req.body.event.channel);
+      try {
+        await postWelcomeMessageOnce(client, req.body.event.channel);
+      }
+      catch (err) {
+        logger.error('Failed to post welcome message', err);
+      }
     }
     }
 
 
     return;
     return;
@@ -435,9 +452,9 @@ export class SlackCtrl {
 
 
         await Promise.all([
         await Promise.all([
           // post message
           // post message
-          postWelcomeMessage(client, userId),
+          postInstallSuccessMessage(client, userId),
           // publish home
           // publish home
-          // TODO When Home tab show off, use bellow.
+          // TODO: When Home tab show off, use bellow.
           // publishInitialHomeView(client, userId),
           // publishInitialHomeView(client, userId),
         ]);
         ]);
       }
       }

+ 24 - 0
packages/slackbot-proxy/src/entities/system-information.ts

@@ -0,0 +1,24 @@
+import {
+  Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn,
+} from 'typeorm';
+
+@Entity()
+export class SystemInformation {
+
+  @PrimaryGeneratedColumn()
+  readonly id: number;
+
+  @Column({ nullable: false })
+  version: string;
+
+  @CreateDateColumn()
+  readonly createdAt: Date;
+
+  @UpdateDateColumn()
+  readonly updatedAt: Date;
+
+  setVersion(version: string): void {
+    this.version = version;
+  }
+
+}

+ 23 - 0
packages/slackbot-proxy/src/repositories/system-information.ts

@@ -0,0 +1,23 @@
+import {
+  Repository, EntityRepository,
+} from 'typeorm';
+
+import { SystemInformation } from '~/entities/system-information';
+
+@EntityRepository(SystemInformation)
+export class SystemInformationRepository extends Repository<SystemInformation> {
+
+  async createOrUpdateUniqueRecordWithVersion(systemInfo: SystemInformation | undefined, proxyVersion: string): Promise<void> {
+    // update the version if it exists
+    if (systemInfo != null) {
+      systemInfo.setVersion(proxyVersion);
+      await this.save(systemInfo);
+      return;
+    }
+    // create new system information object if it didn't exist
+    const newSystemInfo = new SystemInformation();
+    newSystemInfo.setVersion(proxyVersion);
+    await this.save(newSystemInfo);
+  }
+
+}

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

@@ -30,6 +30,10 @@ export class RelationsService {
   @Inject()
   @Inject()
   relationRepository: RelationRepository;
   relationRepository: RelationRepository;
 
 
+  async resetAllExpiredAtCommands(): Promise<void> {
+    await this.relationRepository.update({}, { expiredAtCommands: new Date('2000-01-01') });
+  }
+
   private async getSupportedGrowiCommands(relation:Relation):Promise<any> {
   private async getSupportedGrowiCommands(relation:Relation):Promise<any> {
     // generate API URL
     // generate API URL
     const url = new URL('/_api/v3/slack-integration/supported-commands', relation.growiUri);
     const url = new URL('/_api/v3/slack-integration/supported-commands', relation.growiUri);

+ 1 - 1
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -170,7 +170,7 @@ export class SelectGrowiService implements GrowiCommandProcessor<SelectGrowiComm
     await replaceOriginal(responseUrl, {
     await replaceOriginal(responseUrl, {
       text: `Accepted ${growiCommand.growiCommandType} command.`,
       text: `Accepted ${growiCommand.growiCommandType} command.`,
       blocks: [
       blocks: [
-        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${growiUri} ...`),
+        markdownSectionBlock(`Forwarding your request *"/growi ${growiCommand.growiCommandType}"* on GROWI to ${growiUri} ...`),
       ],
       ],
     });
     });
 
 

+ 47 - 0
packages/slackbot-proxy/src/services/SystemInformationService.ts

@@ -0,0 +1,47 @@
+import { Inject, Service } from '@tsed/di';
+
+import readPkgUp from 'read-pkg-up';
+
+import { SystemInformation } from '~/entities/system-information';
+import { SystemInformationRepository } from '~/repositories/system-information';
+import { RelationsService } from './RelationsService';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('slackbot-proxy:services:SystemInformationService');
+
+@Service()
+export class SystemInformationService {
+
+  @Inject()
+  private readonly repository: SystemInformationRepository;
+
+  @Inject()
+  relationsService: RelationsService;
+
+  async $onInit(): Promise<void> {
+    await this.onInitCheckVersion();
+  }
+
+  /*
+   * updates version or create new system information record
+   * make all relations expired if the previous version was <= 4.4.8
+   */
+  async onInitCheckVersion(): Promise<void> {
+    const readPkgUpResult = await readPkgUp();
+    const proxyVersion = readPkgUpResult?.packageJson.version;
+    if (proxyVersion == null) return logger.error('version is null');
+
+    const systemInfo: SystemInformation | undefined = await this.repository.findOne();
+
+    // return if the version didn't change
+    if (systemInfo != null && systemInfo.version === proxyVersion) {
+      return;
+    }
+
+    await this.repository.createOrUpdateUniqueRecordWithVersion(systemInfo, proxyVersion);
+
+    // make relations expired
+    await this.relationsService.resetAllExpiredAtCommands();
+  }
+
+}

+ 4 - 2
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -50,7 +50,7 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
     }
     }
 
 
     const staticSelectElement: MultiStaticSelect = {
     const staticSelectElement: MultiStaticSelect = {
-      action_id: 'selectedGrowiUris',
+      action_id: 'unregister:selectedGrowiUris',
       type: 'multi_static_select',
       type: 'multi_static_select',
       placeholder: {
       placeholder: {
         type: 'plain_text',
         type: 'plain_text',
@@ -106,6 +106,8 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
       case 'unregister:cancel':
       case 'unregister:cancel':
         interactionHandledResult.result = await this.handleUnregisterCancelInteraction(interactionPayloadAccessor);
         interactionHandledResult.result = await this.handleUnregisterCancelInteraction(interactionPayloadAccessor);
         break;
         break;
+      case 'unregister:selectedGrowiUris':
+        break;
       default:
       default:
         logger.error('This unregister interaction is not implemented.');
         logger.error('This unregister interaction is not implemented.');
         break;
         break;
@@ -122,7 +124,7 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
   ):Promise<void> {
   ):Promise<void> {
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
 
-    const selectedOptions = interactionPayloadAccessor.getStateValues()?.growiUris?.selectedGrowiUris?.selected_options;
+    const selectedOptions = interactionPayloadAccessor.getStateValues()?.growiUris?.['unregister:selectedGrowiUris']?.selected_options;
     if (!Array.isArray(selectedOptions)) {
     if (!Array.isArray(selectedOptions)) {
       logger.error('Unregisteration failed: Mulformed object was detected\n');
       logger.error('Unregisteration failed: Mulformed object was detected\n');
       await respond(responseUrl, {
       await respond(responseUrl, {

+ 38 - 0
packages/slackbot-proxy/src/utils/welcome-message.ts

@@ -0,0 +1,38 @@
+import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
+import { markdownSectionBlock } from '@growi/slack';
+
+export const postWelcomeMessageOnce = async(client: WebClient, channel: string): Promise<void|ChatPostMessageResponse> => {
+  const history = await client.conversations.history({
+    channel,
+    limit: 1,
+  });
+
+  // skip posting on the second time or later
+  if (history.messages != null && history.messages.length > 0) {
+    return;
+  }
+
+  return client.chat.postMessage({
+    channel,
+    blocks: [
+      markdownSectionBlock('Hi! This is GROWI bot.\n'
+        + 'You can invoke any feature with `/growi [command]` in any channel. Type `/growi help` to check the available features.'),
+      markdownSectionBlock('Looking for additional help? '
+        // eslint-disable-next-line max-len
+        + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.'),
+    ],
+  });
+};
+
+export const postInstallSuccessMessage = async(client: WebClient, userId: string): Promise<ChatPostMessageResponse> => {
+  return client.chat.postMessage({
+    channel: userId,
+    blocks: [
+      markdownSectionBlock(':tada: You have successfully installed GROWI bot on this Slack workspace.\n'
+      + 'At first you do `/growi register` in the channel that you want to use.'),
+      markdownSectionBlock('Looking for additional help? '
+        // eslint-disable-next-line max-len
+        + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.'),
+    ],
+  });
+};

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/ui",
   "name": "@growi/ui",
-  "version": "4.4.7-RC.0",
+  "version": "4.4.8-RC.0",
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [