Przeglądaj źródła

Merge pull request #4423 from weseek/master

Release v4.4.8
Yuki Takei 4 lat temu
rodzic
commit
e07ac0f044
64 zmienionych plików z 1499 dodań i 546 usunięć
  1. 6 0
      .github/dependabot.yml
  2. 12 1
      SECURITY.md
  3. 1 1
      lerna.json
  4. 1 1
      package.json
  5. 7 7
      packages/app/package.json
  6. 5 0
      packages/app/resource/locales/en_US/translation.json
  7. 5 0
      packages/app/resource/locales/ja_JP/translation.json
  8. 5 0
      packages/app/resource/locales/zh_CN/translation.json
  9. 1 3
      packages/app/src/client/services/CommentContainer.js
  10. 9 2
      packages/app/src/client/services/EditorContainer.js
  11. 2 2
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  12. 1 1
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  13. 1 5
      packages/app/src/components/PageComment/Comment.jsx
  14. 75 0
      packages/app/src/components/PageEditor/DownloadDictModal.tsx
  15. 41 8
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  16. 0 1
      packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  17. 1 1
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  18. 84 0
      packages/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  19. 97 0
      packages/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  20. 14 6
      packages/app/src/server/middlewares/http-error-handler.js
  21. 0 10
      packages/app/src/server/models/comment.js
  22. 37 0
      packages/app/src/server/models/vo/slack-command-handler-error.ts
  23. 0 22
      packages/app/src/server/models/vo/slackbot-error.js
  24. 6 3
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  25. 180 77
      packages/app/src/server/routes/apiv3/slack-integration.js
  26. 28 17
      packages/app/src/server/routes/comment.js
  27. 5 5
      packages/app/src/server/routes/login-passport.js
  28. 18 29
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  29. 69 0
      packages/app/src/server/service/slack-command-handler/error-handler.ts
  30. 16 6
      packages/app/src/server/service/slack-command-handler/help.js
  31. 229 0
      packages/app/src/server/service/slack-command-handler/keep.js
  32. 12 12
      packages/app/src/server/service/slack-command-handler/note.js
  33. 0 66
      packages/app/src/server/service/slack-command-handler/respond-if-slackbot-error.js
  34. 38 40
      packages/app/src/server/service/slack-command-handler/search.js
  35. 83 88
      packages/app/src/server/service/slack-command-handler/togetter.js
  36. 36 44
      packages/app/src/server/service/slack-integration.ts
  37. 5 0
      packages/app/src/server/util/slack-integration.ts
  38. 2 2
      packages/app/src/styles/_wiki.scss
  39. 6 6
      packages/app/src/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts
  40. 1 1
      packages/codemirror-textlint/package.json
  41. 1 1
      packages/core/package.json
  42. 1 1
      packages/plugin-attachment-refs/package.json
  43. 1 1
      packages/plugin-lsx/package.json
  44. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  45. 3 2
      packages/slack/package.json
  46. 8 5
      packages/slack/src/index.ts
  47. 9 7
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  48. 8 0
      packages/slack/src/interfaces/respond-util.ts
  49. 26 4
      packages/slack/src/utils/interaction-payload-accessor.ts
  50. 6 6
      packages/slack/src/utils/reshape-contents-body.test.ts
  51. 2 2
      packages/slack/src/utils/reshape-contents-body.ts
  52. 71 0
      packages/slack/src/utils/respond-util-factory.ts
  53. 0 21
      packages/slack/src/utils/welcome-message.ts
  54. 2 2
      packages/slackbot-proxy/package.json
  55. 48 8
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  56. 31 14
      packages/slackbot-proxy/src/controllers/slack.ts
  57. 24 0
      packages/slackbot-proxy/src/entities/system-information.ts
  58. 23 0
      packages/slackbot-proxy/src/repositories/system-information.ts
  59. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  60. 1 1
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  61. 47 0
      packages/slackbot-proxy/src/services/SystemInformationService.ts
  62. 4 2
      packages/slackbot-proxy/src/services/UnregisterService.ts
  63. 38 0
      packages/slackbot-proxy/src/utils/welcome-message.ts
  64. 1 1
      packages/ui/package.json

+ 6 - 0
.github/dependabot.yml

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

+ 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.
 
   * [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
 
 Communication in English and Japanese is possible.  
 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",
   "useWorkspaces": true,
-  "version": "4.4.7",
+  "version": "4.4.8-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

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

+ 7 - 7
packages/app/package.json

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

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

@@ -454,6 +454,11 @@
       "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": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

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

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

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

@@ -433,6 +433,11 @@
 			"Post": "提交"
 		}
 	},
+  "modal_enable_textlint": {
+    "confirm_download_dict_and_enable_textlint": "您确定要启用 Textlint 吗?这将下载 20MB 的字典文件。",
+    "enable_textlint": "启用Textlint",
+    "dont_ask_again": "不要再问"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "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', {
       commentForm: {
         comment,
-        page_id: pageId,
-        revision_id: revisionId,
         is_markdown: isMarkdown,
+        revision_id: revisionId,
         comment_id: commentId,
-        author,
       },
     })
       .then((res) => {

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

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

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

@@ -72,8 +72,8 @@ const ManageCommandsProcess = ({
     search: permissionsForBroadcastUseCommands.search,
   });
   const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
-    create: permissionsForSingleUseCommands.create,
-    togetter: permissionsForSingleUseCommands.togetter,
+    note: permissionsForSingleUseCommands.note,
+    keep: permissionsForSingleUseCommands.keep,
   });
   const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
     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', {
         commandPermission: editingCommandPermission,
       });
-      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+      toastSuccess(t('toaster.update_successed', { target: 'the permission for commands' }));
     }
     catch (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);
   }
 
-  checkPermissionToControlComment() {
-    return this.props.appContainer.isAdmin || this.isCurrentUserEqualsToAuthor();
-  }
-
   isCurrentUserEqualsToAuthor() {
     const { creator } = this.props.comment;
     if (creator == null) {
@@ -210,7 +206,7 @@ class Comment extends React.PureComponent {
                   </UncontrolledTooltip>
                 </span>
               </div>
-              {this.checkPermissionToControlComment() && (
+              {this.isCurrentUserEqualsToAuthor() && (
                 <CommentControl
                   onClickDeleteBtn={this.deleteBtnClickedHandler}
                   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 EditorContainer from '~/client/services/EditorContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { DownloadDictModal } from './DownloadDictModal';
 
 
 export const defaultEditorOptions = {
@@ -34,6 +35,8 @@ class OptionsSelector extends React.Component {
     this.state = {
       isCddMenuOpened: false,
       isMathJaxEnabled,
+      isDownloadDictModalShown: false,
+      isSkipAskingAgainChecked: false,
     };
 
     this.availableThemes = [
@@ -53,6 +56,8 @@ class OptionsSelector extends React.Component {
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.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.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.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 newVal = !editorContainer.state.isTextlintEnabled;
     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) {
@@ -359,12 +382,22 @@ class OptionsSelector extends React.Component {
 
   render() {
     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 { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 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';
 
 
-const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+const logger = loggerFactory('growi:migrate:migrate-slack-app-integration-schema');
 
 // create default data
 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 loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middleware:htto-error-handler');
 
 const isHttpError = (val) => {
   if (!val || typeof val !== 'object') {
@@ -20,12 +23,17 @@ module.exports = async(err, req, res, next) => {
   if (isHttpError(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);

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

+ 180 - 77
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 { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 
 const express = require('express');
 const mongoose = require('mongoose');
@@ -12,7 +16,7 @@ const {
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 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');
 
 module.exports = (crowi) => {
@@ -27,7 +31,7 @@ module.exports = (crowi) => {
     if (tokenPtoG == null) {
       const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
       logger.warn(message, { body: req.body });
-      return res.status(400).send({ message });
+      return next(createError(400, message));
     }
 
     const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
@@ -57,12 +61,37 @@ module.exports = (crowi) => {
     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) {
-    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 extractPermissions = await extractPermissionsCommands(tokenPtoG);
@@ -75,29 +104,38 @@ module.exports = (crowi) => {
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
       const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
       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
-    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);
     if (isPermitted) {
       return next();
     }
     // 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) {
+    // 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 siteUrl = crowi.appService.getSiteUrl();
 
@@ -114,24 +152,27 @@ module.exports = (crowi) => {
       const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
       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
-    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    commandPermission = configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission');
 
     const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
     if (isPermitted) {
       return next();
     }
     // 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) => {
@@ -150,113 +191,164 @@ module.exports = (crowi) => {
     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) {
       try {
         growiCommand = parseSlashCommand(body);
       }
       catch (err) {
         if (err instanceof InvalidGrowiCommandError) {
-          res.json({
-            blocks: [
-              markdownSectionBlock('*Command type is not specified.*'),
-              markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
-            ],
-          });
+          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;
+  }
 
-    const { text } = 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;
 
     if (text == null) {
       return 'No text.';
     }
 
-    /*
-     * TODO: use parseSlashCommand
-     */
-
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Processing your request ...',
-    });
-
+    const appSiteUrl = crowi.appService.getSiteUrl();
+    try {
+      await respondUtil.respond({
+        text: 'Processing your request ...',
+        blocks: [
+          markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`),
+        ],
+      });
+    }
+    catch (err) {
+      logger.error('Error occurred while request via axios:', err);
+    }
 
     try {
-      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body);
+      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
     }
     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) => {
-    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;
+
     // 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
     if (body.type === 'url_verification') {
       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 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) {
 
-    // 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 { type } = interactionPayload;
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     try {
+      const respondUtil = getRespondUtil(responseUrl);
       switch (type) {
         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;
         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;
         default:
           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) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleInteractionsRequest(req, res, client);
@@ -265,7 +357,6 @@ module.exports = (crowi) => {
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-
     return handleInteractionsRequest(req, res, client);
   });
 
@@ -277,5 +368,17 @@ module.exports = (crowi) => {
     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;
 };

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

@@ -310,10 +310,10 @@ module.exports = function(crowi, app) {
    *                            $ref: '#/components/schemas/Page/properties/_id'
    *                          revision_id:
    *                            $ref: '#/components/schemas/Revision/properties/_id'
+   *                          comment_id:
+   *                            $ref: '#/components/schemas/Comment/properties/_id'
    *                          comment:
    *                            $ref: '#/components/schemas/Comment/properties/comment'
-   *                          comment_position:
-   *                            $ref: '#/components/schemas/Comment/properties/commentPosition'
    *                required:
    *                  - form
    *        responses:
@@ -340,13 +340,12 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const { commentForm } = req.body;
 
-    const pageId = commentForm.page_id;
-    const comment = commentForm.comment;
+    const commentStr = commentForm.comment;
     const isMarkdown = commentForm.is_markdown;
     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'));
     }
 
@@ -354,19 +353,28 @@ module.exports = function(crowi, app) {
       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;
     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) {
       logger.error(err);
@@ -438,6 +446,9 @@ module.exports = function(crowi, app) {
       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.');
+      }
 
       await comment.removeWithReplies();
       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();
     }
 
+    // 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);
     if (!externalAccount) {
       return loginFailureHandler(req, res);
@@ -474,11 +479,6 @@ module.exports = function(crowi, app) {
 
     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
     req.logIn(user, (err) => {
       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 mongoose = require('mongoose');
 const pathUtils = require('growi-commons').pathUtils;
-const SlackbotError = require('../../models/vo/slackbot-error');
 
 class CreatePageService {
 
@@ -12,36 +11,26 @@ class CreatePageService {
     this.crowi = crowi;
   }
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
     const Page = this.crowi.model('Page');
     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]);
+}

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

@@ -1,18 +1,28 @@
-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
+ */
 
-module.exports = () => {
+const { markdownSectionBlock } = require('@growi/slack');
+
+module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
-  handler.handleCommand = (growiCommand, client, body) => {
+  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
+    const appTitle = crowi.appService.getAppTitle();
+    const appSiteUrl = crowi.appService.getSiteUrl();
     // adjust spacing
     let message = '*Help*\n\n';
+    message += `GROWI App Title: *${appTitle}*`;
+    message += `GROWI Url: ${appSiteUrl}`;
     message += 'Usage:     `/growi [command] [args]`\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 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',
       blocks: [
         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';
 
 const {
-  markdownSectionBlock, inputSectionBlock, respond, inputBlock,
+  markdownSectionBlock, inputSectionBlock, inputBlock,
 } = require('@growi/slack');
 
-const logger = loggerFactory('growi:service:SlackCommandHandler:create');
+const logger = loggerFactory('growi:service:SlackCommandHandler:note');
 
 module.exports = (crowi) => {
   const CreatePageService = require('./create-page-service');
@@ -18,16 +18,16 @@ module.exports = (crowi) => {
     default_to_current_conversation: true,
   };
 
-  handler.handleCommand = async(growiCommand, client, body) => {
+  handler.handleCommand = async(growiCommand, client, body, respondUtil) => {
     await client.views.open({
       trigger_id: body.trigger_id,
 
       view: {
         type: 'modal',
-        callback_id: 'create:createPage',
+        callback_id: 'note:createPage',
         title: {
           type: 'plain_text',
-          text: 'Create Page',
+          text: 'Take a note',
         },
         submit: {
           type: 'plain_text',
@@ -38,9 +38,9 @@ module.exports = (crowi) => {
           text: 'Cancel',
         },
         blocks: [
-          markdownSectionBlock('Create new page.'),
+          markdownSectionBlock('Take a note on GROWI'),
           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...'),
         ],
         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 privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
     if (privateMetadata == null) {
-      await respond(interactionPayloadAccessor.getResponseUrl(), {
+      await respondUtil.respond({
         text: 'Error occurred',
         blocks: [
           markdownSectionBlock('Failed to create a page.'),
@@ -65,7 +65,7 @@ module.exports = (crowi) => {
       return;
     }
     const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
   };
 
   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 };

+ 38 - 40
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,10 +3,9 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const {
-  markdownSectionBlock, divider, respond, respondInChannel, replaceOriginal, deleteOriginal,
+  markdownSectionBlock, divider,
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
-const SlackbotError = require('../../models/vo/slackbot-error');
 
 const PAGINGLIMIT = 7;
 
@@ -133,22 +132,24 @@ module.exports = (crowi) => {
 
     const actionBlocks = {
       type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'search:dismissSearchResults',
-        },
-      ],
+      elements: [],
     };
+    // add "Dismiss" button
+    actionBlocks.elements.push(
+      {
+        type: 'button',
+        text: {
+          type: 'plain_text',
+          text: 'Dismiss',
+        },
+        style: 'danger',
+        action_id: 'search:dismissSearchResults',
+      },
+    );
     // show "Prev" button if previous page exists
     // eslint-disable-next-line yoda
     if (0 < offset) {
-      actionBlocks.elements.unshift(
+      actionBlocks.elements.push(
         {
           type: 'button',
           text: {
@@ -162,7 +163,7 @@ module.exports = (crowi) => {
     }
     // show "Next" button if next page exists
     if (offset + PAGINGLIMIT < resultsTotal) {
-      actionBlocks.elements.unshift(
+      actionBlocks.elements.push(
         {
           type: 'button',
           text: {
@@ -230,27 +231,26 @@ module.exports = (crowi) => {
   }
 
 
-  handler.handleCommand = async function(growiCommand, client, body) {
-    const { responseUrl, growiCommandArgs } = growiCommand;
+  handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
+    const { growiCommandArgs } = growiCommand;
 
     const respondBody = await buildRespondBody(growiCommandArgs);
-    await respond(responseUrl, respondBody);
+    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 responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
 
     const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         blocks: [
           markdownSectionBlock('Failed to share the result.'),
@@ -259,13 +259,15 @@ module.exports = (crowi) => {
       return;
     }
 
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+
     // restore page data from value
-    const { page, href, pathname } = JSON.parse(value);
+    const { page, href, pathname } = parsedValue;
     const { updatedAt, commentCount } = page;
 
     // share
     const now = new Date();
-    return respondInChannel(responseUrl, {
+    return respondUtil.respondInChannel({
       blocks: [
         { type: 'divider' },
         markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
@@ -284,12 +286,11 @@ module.exports = (crowi) => {
     });
   };
 
-  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true) {
-    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true, respondUtil) {
 
     const value = interactionPayloadAccessor.firstAction()?.value;
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         blocks: [
           markdownSectionBlock('Failed to show the next results.'),
@@ -297,7 +298,8 @@ module.exports = (crowi) => {
       });
       return;
     }
-    const parsedValue = JSON.parse(value);
+
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const newOffsetNum = isNext
@@ -306,23 +308,19 @@ module.exports = (crowi) => {
 
     const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
 
-    await replaceOriginal(responseUrl, buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+    await respondUtil.replaceOriginal(buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
   }
 
-  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor) {
-    return showPrevOrNextResults(interactionPayloadAccessor, false);
+  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil);
   };
 
-  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
-    return showPrevOrNextResults(interactionPayloadAccessor, true);
+  handler.showNextResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil);
   };
 
-  handler.dismissSearchResults = async function(client, payload) {
-    const { response_url: responseUrl } = payload;
-
-    return deleteOriginal(responseUrl, {
-      delete_original: true,
-    });
+  handler.dismissSearchResults = async function(client, payload, interactionPayloadAccessor, respondUtil) {
+    return respondUtil.deleteOriginal();
   };
 
   return handler;

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

@@ -7,7 +7,7 @@ const {
 } = require('@growi/slack');
 const { parse, format } = require('date-fns');
 const axios = require('axios');
-const SlackbotError = require('../../models/vo/slackbot-error');
+const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
 module.exports = (crowi) => {
   const CreatePageService = require('./create-page-service');
@@ -37,22 +37,17 @@ module.exports = (crowi) => {
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     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) {
@@ -60,71 +55,88 @@ module.exports = (crowi) => {
     const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
     let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.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
      * @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)) {
-      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;
     // + 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 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 };
   };
 
-  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,
       newest,
       oldest,
       limit: 100,
       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
     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;
   };
@@ -157,40 +169,23 @@ module.exports = (crowi) => {
   };
 
   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() {

+ 36 - 44
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 {
-  generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, respond, SlackbotType,
+  generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
+  RespondUtil,
 } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
@@ -14,6 +15,7 @@ import S2sMessage from '../models/vo/s2s-message';
 import ConfigManager from './config-manager';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
@@ -237,7 +239,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommandRequest(growiCommand: GrowiCommand, client, body): Promise<void> {
+  async handleCommandRequest(growiCommand: GrowiCommand, client, body, respondUtil: RespondUtil): Promise<void> {
     const { growiCommandType } = growiCommand;
     const module = `./slack-command-handler/${growiCommandType}`;
 
@@ -246,72 +248,62 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       handler = require(module)(this.crowi);
     }
     catch (err) {
+      const text = `*No command.*\n \`command: ${growiCommand.text}\``;
       logger.error(err);
-      await this.notCommand(growiCommand);
+      throw new SlackCommandHandlerError(text, {
+        respondBody: {
+          text,
+          blocks: [
+            markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+          ],
+        },
+      });
     }
 
-    try {
-      await handler.handleCommand(growiCommand, client, body);
-    }
-    catch (err) {
-      logger.error(err);
-      await this.notifyInternalError(growiCommand.responseUrl, err);
-    }
+    // 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 commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
+
     const module = `./slack-command-handler/${commandName}`;
+
+    let handler;
     try {
-      const handler = require(module)(this.crowi);
-      await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
+      handler = require(module)(this.crowi);
     }
     catch (err) {
-      logger.error(err);
-      const responseUrl = interactionPayloadAccessor.getResponseUrl();
-      await this.notifyInternalError(responseUrl, 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 commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
+
     const module = `./slack-command-handler/${commandName}`;
+
+    let handler;
     try {
-      const handler = require(module)(this.crowi);
-      await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
+      handler = require(module)(this.crowi);
     }
     catch (err) {
-      logger.error(err);
-      const responseUrl = interactionPayloadAccessor.getResponseUrl();
-      await this.notifyInternalError(responseUrl, 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;
-  }
 
-  async notifyInternalError(responseUrl: string, error: Error): Promise<void> {
-    await respond(responseUrl, {
-      text: 'Internal Server Error',
-      blocks: [
-        markdownSectionBlock(`*Internal Server Error*\n \`${error.message}\``),
-      ],
-    });
-    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 => {
   let isPermitted = false;
 
+  // help
+  if (commandOrActionIdOrCallbackId === 'help') {
+    return true;
+  }
+
   Object.entries(commandPermission).forEach((entry) => {
     const [command, value] = entry;
     const permission = value;

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

@@ -239,14 +239,14 @@ div.body {
     }
   }
 
-  .grw-togetter {
+  .grw-keep {
     padding: 7%;
     padding-bottom: 3%;
     margin: 0 7%;
     background-color: rgba(200, 200, 200, 0.2);
     border-radius: 10px;
 
-    .grw-togetter-time {
+    .grw-keep-time {
       float: right;
       font-size: 0.8em;
       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: {
         bar: true,
-        create: false,
-        togetter: false,
+        note: false,
+        keep: false,
       },
     });
     expect(fixedDoc2).toStrictEqual({
@@ -101,8 +101,8 @@ describe('migrate-slack-app-integration-schema', () => {
       },
       permissionsForSingleUseCommands: {
         bar: true,
-        create: false,
-        togetter: false,
+        note: false,
+        keep: false,
       },
     });
     expect(fixedDoc3).toStrictEqual({
@@ -113,8 +113,8 @@ describe('migrate-slack-app-integration-schema', () => {
         search: true,
       },
       permissionsForSingleUseCommands: {
-        create: true,
-        togetter: true,
+        note: true,
+        keep: true,
       },
     });
   });

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

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

+ 3 - 2
packages/slack/package.json

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

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

@@ -8,8 +8,8 @@ export const supportedSlackCommands: string[] = [
 
 export const supportedGrowiCommands: string[] = [
   'search',
-  'create',
-  'togetter',
+  'note',
+  'keep',
   'help',
 ];
 
@@ -18,8 +18,8 @@ export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
 ];
 
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
-  'create',
-  'togetter',
+  'note',
+  'keep',
 ];
 
 export * from './interfaces/growi-command-processor';
@@ -27,7 +27,10 @@ export * from './interfaces/growi-interaction-processor';
 export * from './interfaces/growi-command';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
+export * from './interfaces/response-url';
 export * from './interfaces/slackbot-types';
+export * from './interfaces/response-url';
+export * from './interfaces/respond-util';
 export * from './models/errors';
 export * from './middlewares/parse-slack-interaction-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/slash-command-parser';
 export * from './utils/webclient-factory';
-export * from './utils/welcome-message';
 export * from './utils/required-scopes';
 export * from './utils/interaction-payload-accessor';
 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';
 
-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
   body: {
     view?: 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 & {

+ 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 loggerFactory from './logger';
+
+const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');
 
 
 export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
@@ -27,11 +31,10 @@ export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
     }
 
     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 {
@@ -80,4 +83,23 @@ export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
     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...`;
 
       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
 some messages...\u0020\u0020
 some messages...\u0020\u0020
@@ -72,9 +72,9 @@ some messages...
 some messages...`;
 
       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
 some messages...\u0020\u0020
 some messages...\u0020\u0020
@@ -99,9 +99,9 @@ taichi-m  12:23 PM
 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
 some messages...\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
       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)?
     else if (regexpShortTime.test(copyline)) {
@@ -82,7 +82,7 @@ export const reshapeContentsBody = (str: string): string => {
   // remove all blanks
   const blanksRemoved = reshapedArray.filter(line => 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>');
   // Add 2 spaces and 1 enter to all lines
   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);
+}

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

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

@@ -1,5 +1,5 @@
 import {
-  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put,
+  Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put, QueryParams,
 } from '@tsed/common';
 import axios from 'axios';
 import createError from 'http-errors';
@@ -8,7 +8,7 @@ import { addHours } from 'date-fns';
 import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
 } from '@growi/slack';
 
 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');
 
+export type RespondReqFromGrowi = Req & BlockKitRequest & {
+  // appended by GROWI
+  headers:{ 'x-growi-app-site-url'?: string },
+
+  // will be extracted from header
+  appSiteUrl: string,
+}
+
 @Controller('/g2s')
 export class GrowiToSlackCtrl {
 
@@ -51,8 +59,8 @@ export class GrowiToSlackCtrl {
   @Inject()
   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(), {
       type: 'url_verification',
       challenge: 'this_is_my_challenge_token',
@@ -141,7 +149,7 @@ export class GrowiToSlackCtrl {
       }
 
       try {
-        await this.requestToGrowi(relation.growiUri, relation.tokenPtoG);
+        await this.urlVerificationRequestToGrowi(relation.growiUri, relation.tokenPtoG);
       }
       catch (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.
     try {
-      await this.requestToGrowi(order.growiUrl, order.tokenPtoG);
+      await this.urlVerificationRequestToGrowi(order.growiUrl, order.tokenPtoG);
     }
     catch (err) {
       logger.error(err);
@@ -217,7 +225,7 @@ export class GrowiToSlackCtrl {
     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) {
       return;
     }
@@ -231,7 +239,7 @@ export class GrowiToSlackCtrl {
       }
     }
     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
       if (this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
         this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
@@ -245,6 +253,38 @@ 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.');
+    }
+
+    try {
+      await axios.post(responseUrl, req.body);
+    }
+    catch (err) {
+      logger.error('Error occurred while request via axios:', err);
+      return res.status(502).send(err.message);
+    }
+    return res.send();
+  }
+
   @Post('/:method')
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(

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

@@ -10,9 +10,9 @@ import { Installation } from '@slack/oauth';
 
 import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
-  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
+  InvalidGrowiCommandError, requiredScopes, REQUEST_TIMEOUT_FOR_PTOG,
   parseSlackInteractionRequest, verifySlackRequest,
-  respond,
+  respond, supportedGrowiCommands,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -26,13 +26,13 @@ import {
 } from '~/middlewares/slack-to-growi/authorizer';
 import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 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 { SelectGrowiService } from '~/services/SelectGrowiService';
 import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
 import loggerFactory from '~/utils/logger';
+import { postInstallSuccessMessage, postWelcomeMessageOnce } from '~/utils/welcome-message';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
@@ -130,7 +130,7 @@ export class SlackCtrl {
 
 
   @Post('/commands')
-  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware, JoinToConversationMiddleware)
+  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware)
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     const { body, authorizeResult } = req;
 
@@ -178,6 +178,7 @@ export class SlackCtrl {
       return this.unregisterService.processCommand(growiCommand, authorizeResult);
     }
 
+    // get relations
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     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 allowedRelationsForBroadcastUse:Relation[] = [];
@@ -283,7 +295,7 @@ export class SlackCtrl {
     logger.debug('receive interaction', req.body);
 
     const {
-      body, authorizeResult, interactionPayload, interactionPayloadAccessor,
+      body, authorizeResult, interactionPayload, interactionPayloadAccessor, growiUri,
     } = req;
 
     // pass
@@ -317,6 +329,7 @@ export class SlackCtrl {
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
     const relations = await this.relationRepository.createQueryBuilder('relation')
       .where('relation.installationId = :id', { id: installation?.id })
+      .andWhere('relation.growiUri = :uri', { uri: growiUri })
       .leftJoinAndSelect('relation.installation', 'installation')
       .getMany();
 
@@ -375,12 +388,16 @@ export class SlackCtrl {
   @Post('/events')
   @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
   async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
-
     const { authorizeResult } = req;
     const client = generateWebClient(authorizeResult.botToken);
 
     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;
@@ -435,9 +452,9 @@ export class SlackCtrl {
 
         await Promise.all([
           // post message
-          postWelcomeMessage(client, userId),
+          postInstallSuccessMessage(client, userId),
           // publish home
-          // TODO When Home tab show off, use bellow.
+          // TODO: When Home tab show off, use bellow.
           // 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()
   relationRepository: RelationRepository;
 
+  async resetAllExpiredAtCommands(): Promise<void> {
+    await this.relationRepository.update({}, { expiredAtCommands: new Date('2000-01-01') });
+  }
+
   private async getSupportedGrowiCommands(relation:Relation):Promise<any> {
     // generate API URL
     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, {
       text: `Accepted ${growiCommand.growiCommandType} command.`,
       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 = {
-      action_id: 'selectedGrowiUris',
+      action_id: 'unregister:selectedGrowiUris',
       type: 'multi_static_select',
       placeholder: {
         type: 'plain_text',
@@ -106,6 +106,8 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
       case 'unregister:cancel':
         interactionHandledResult.result = await this.handleUnregisterCancelInteraction(interactionPayloadAccessor);
         break;
+      case 'unregister:selectedGrowiUris':
+        break;
       default:
         logger.error('This unregister interaction is not implemented.');
         break;
@@ -122,7 +124,7 @@ export class UnregisterService implements GrowiCommandProcessor, GrowiInteractio
   ):Promise<void> {
     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)) {
       logger.error('Unregisteration failed: Mulformed object was detected\n');
       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",
-  "version": "4.4.7",
+  "version": "4.4.8-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [