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

fix: Slackbot error/command handling (#4463)

* WIP

* typescriptize

* refactor SlackbotError

* typescriptize

* Worked with one GROWI

* impl overloaded handleError

* ensure getResponseUrl() not to return empty string

* replace respondIfSlackbotError with handleError

* Removed unnecessary comment

* Removed unnecessary comment

* clean code

* rename SlackError -> SlackCommandHandlerError

* clean code

* simplify error handling for SlackIntegrationService

* improve handleError

* improve default message

* simplify togetter error handling

* BugFix

* improve default responce message

* Fixed help and unregister

* BugFix for getResponseUrl

* clean code

* autojoin when channel is public and show errors when channel is private

* Worked with a single growi & disabled togetter preview

* Modified

* Added error handling

* Added an export statement

* Added url-join to dependencies

* Deleted unnecessary code

* Improved

* Refactored growi side

* Modified

* Added a comment

* Modified

* togetter -> keep

* Added await

* Modified

* Refactored

* Deleted async await

* create -> note

* modified

* improve togetterMessageBlocks

* Migration for renaming

* Added migration for without proxy config

* remove seconds from page path for keep

* Added onInit hook to relations expire

* Added system information to compare version

* Use readPkgUp

* Modified

* Modified condition

* Refactored code & deleted unnecessary deps from yarn.lock

* Deleted unnecessary import

* Fixed

* Revert "Fixed"

This reverts commit b396c7eab9c5082047cb431823aba3ab7b3968c5.

* Revert "Deleted unnecessary import"

This reverts commit 800dcef5f7f04424f3403ba183af7aed3845a754.

* Fixed

* Worked with without proxy

* skip posting on the second time or later

* Modified relation test endpoint & modified help command permission condition

* Refactored & Worked

* Refactored res.send()

* Modified & added comments

* Refactored processing messages

* Resolved

Co-authored-by: Yuki Takei <yuki@weseek.co.jp>
Haku Mizuki 4 лет назад
Родитель
Сommit
d844f8a471
35 измененных файлов с 1002 добавлено и 197 удалено
  1. 2 2
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  2. 1 1
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  3. 0 1
      packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  4. 1 1
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  5. 84 0
      packages/app/src/migrations/20211005120030-slack-app-integration-rename-keys.js
  6. 89 0
      packages/app/src/migrations/20211005131430-config-without-proxy-command-permission-for-renaming.js
  7. 14 6
      packages/app/src/server/middlewares/http-error-handler.js
  8. 170 62
      packages/app/src/server/routes/apiv3/slack-integration.js
  9. 2 2
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  10. 11 5
      packages/app/src/server/service/slack-command-handler/help.js
  11. 229 0
      packages/app/src/server/service/slack-command-handler/keep.js
  12. 12 12
      packages/app/src/server/service/slack-command-handler/note.js
  13. 23 26
      packages/app/src/server/service/slack-command-handler/search.js
  14. 12 6
      packages/app/src/server/service/slack-integration.ts
  15. 5 0
      packages/app/src/server/util/slack-integration.ts
  16. 2 2
      packages/app/src/styles/_wiki.scss
  17. 6 6
      packages/app/src/test/integration/migrations/20210913153942-migrate-slack-app-integration-schema.test.ts
  18. 2 1
      packages/slack/package.json
  19. 7 5
      packages/slack/src/index.ts
  20. 9 7
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  21. 8 0
      packages/slack/src/interfaces/respond-util.ts
  22. 22 0
      packages/slack/src/utils/interaction-payload-accessor.ts
  23. 6 6
      packages/slack/src/utils/reshape-contents-body.test.ts
  24. 2 2
      packages/slack/src/utils/reshape-contents-body.ts
  25. 71 0
      packages/slack/src/utils/respond-util-factory.ts
  26. 0 21
      packages/slack/src/utils/welcome-message.ts
  27. 41 8
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  28. 30 12
      packages/slackbot-proxy/src/controllers/slack.ts
  29. 24 0
      packages/slackbot-proxy/src/entities/system-information.ts
  30. 23 0
      packages/slackbot-proxy/src/repositories/system-information.ts
  31. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  32. 1 1
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  33. 47 0
      packages/slackbot-proxy/src/services/SystemInformationService.ts
  34. 4 2
      packages/slackbot-proxy/src/services/UnregisterService.ts
  35. 38 0
      packages/slackbot-proxy/src/utils/welcome-message.ts

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,89 @@
+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 = {};
+    Object.entries(commandPermission).forEach((key, value) => {
+      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 = {};
+    Object.entries(commandPermission).forEach((key, value) => {
+      switch (key) {
+        case 'note':
+          newCommandPermission.create = value;
+          break;
+        case 'keep':
+          newCommandPermission.togetter = value;
+          break;
+        default:
+          newCommandPermission[key] = value;
+          break;
+      }
+    });
+
+    await Config.findOneAndUpdate(
+      { key: 'slackbot:withoutProxy:commandPermission' },
+      {
+        $set: {
+          value: JSON.stringify(newCommandPermission),
+        },
+      },
+    );
+
+    next();
+    logger.info('Migration rollback has successfully applied');
+  },
+};

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

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

+ 170 - 62
packages/app/src/server/routes/apiv3/slack-integration.js

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

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

@@ -11,7 +11,7 @@ class CreatePageService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
 
@@ -25,7 +25,7 @@ class CreatePageService {
 
 
     // Send a message when page creation is complete
     // Send a message when page creation is complete
     const growiUri = this.crowi.appService.getSiteUrl();
     const growiUri = this.crowi.appService.getSiteUrl();
-    await respond(interactionPayloadAccessor.getResponseUrl(), {
+    await respondUtil.respond({
       text: 'Page has been created',
       text: 'Page has been created',
       blocks: [
       blocks: [
         markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
         markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),

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

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

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

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

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

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

+ 23 - 26
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 
 const {
 const {
-  markdownSectionBlock, divider, respond, respondInChannel, replaceOriginal, deleteOriginal,
+  markdownSectionBlock, divider,
 } = require('@growi/slack');
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 const { formatDistanceStrict } = require('date-fns');
 
 
@@ -229,27 +229,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);
     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 { user } = payload;
-    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
 
     const appUrl = crowi.appService.getSiteUrl();
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
     const appTitle = crowi.appService.getAppTitle();
 
 
     const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
     const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
     if (value == null) {
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
         blocks: [
         blocks: [
           markdownSectionBlock('Failed to share the result.'),
           markdownSectionBlock('Failed to share the result.'),
@@ -258,13 +257,15 @@ module.exports = (crowi) => {
       return;
       return;
     }
     }
 
 
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
+
     // restore page data from value
     // restore page data from value
-    const { page, href, pathname } = JSON.parse(value);
+    const { page, href, pathname } = parsedValue;
     const { updatedAt, commentCount } = page;
     const { updatedAt, commentCount } = page;
 
 
     // share
     // share
     const now = new Date();
     const now = new Date();
-    return respondInChannel(responseUrl, {
+    return respondUtil.respondInChannel({
       blocks: [
       blocks: [
         { type: 'divider' },
         { type: 'divider' },
         markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
         markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
@@ -283,12 +284,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;
     const value = interactionPayloadAccessor.firstAction()?.value;
     if (value == null) {
     if (value == null) {
-      await respond(responseUrl, {
+      await respondUtil.respond({
         text: 'Error occurred',
         text: 'Error occurred',
         blocks: [
         blocks: [
           markdownSectionBlock('Failed to show the next results.'),
           markdownSectionBlock('Failed to show the next results.'),
@@ -296,7 +296,8 @@ module.exports = (crowi) => {
       });
       });
       return;
       return;
     }
     }
-    const parsedValue = JSON.parse(value);
+
+    const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value);
 
 
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const { growiCommandArgs, offset: offsetNum } = parsedValue;
     const newOffsetNum = isNext
     const newOffsetNum = isNext
@@ -305,23 +306,19 @@ module.exports = (crowi) => {
 
 
     const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
     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;
   return handler;

+ 12 - 6
packages/app/src/server/service/slack-integration.ts

@@ -5,6 +5,7 @@ import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 
 
 import {
 import {
   generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
   generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
+  RespondUtil,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -238,7 +239,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
   /**
    * Handle /commands endpoint
    * 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 { growiCommandType } = growiCommand;
     const module = `./slack-command-handler/${growiCommandType}`;
     const module = `./slack-command-handler/${growiCommandType}`;
 
 
@@ -248,6 +249,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
     }
     catch (err) {
     catch (err) {
       const text = `*No command.*\n \`command: ${growiCommand.text}\``;
       const text = `*No command.*\n \`command: ${growiCommand.text}\``;
+      logger.error(err);
       throw new SlackCommandHandlerError(text, {
       throw new SlackCommandHandlerError(text, {
         respondBody: {
         respondBody: {
           text,
           text,
@@ -259,10 +261,12 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
     }
 
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
-    return handler.handleCommand(growiCommand, client, body);
+    return handler.handleCommand(growiCommand, client, body, respondUtil);
   }
   }
 
 
-  async handleBlockActionsRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+  async handleBlockActionsRequest(
+      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+  ): Promise<void> {
     const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = actionId.split(':')[0];
     const commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
     const handlerMethodName = actionId.split(':')[1];
@@ -278,10 +282,12 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
     }
 
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
     // 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);
+    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
   }
 
 
-  async handleViewSubmissionRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+  async handleViewSubmissionRequest(
+      client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor, respondUtil: RespondUtil,
+  ): Promise<void> {
     const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = callbackId.split(':')[0];
     const commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
     const handlerMethodName = callbackId.split(':')[1];
@@ -297,7 +303,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     }
     }
 
 
     // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
     // 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);
+    return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil);
   }
   }
 
 
 }
 }

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

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

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

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

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

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

+ 2 - 1
packages/slack/package.json

@@ -19,7 +19,8 @@
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^1.8.0",
     "http-errors": "^1.8.0",
-    "universal-bunyan": "^0.9.2"
+    "universal-bunyan": "^0.9.2",
+    "url-join": "^4.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

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

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

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

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

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

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

+ 22 - 0
packages/slack/src/utils/interaction-payload-accessor.ts

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

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

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

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

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

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

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

+ 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>.',
-        },
-      },
-    ],
-  });
-};

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

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

+ 30 - 12
packages/slackbot-proxy/src/controllers/slack.ts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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