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

imprv: Slackbot response flow (#4296)

* add responseUrl to GrowiCommand

* WIP: improve handleCommand

* add utils for response_url

* use respond method

* mv GrowiCommandProcessor to slack package

* re-impl with buttons

* impl parseSlackInteractionRequest, InteractionPayloadAccessor

* add GrowiInteractionProcessor

* WIP

* Abolished replyToSlack method and use response_url

* Unregister worked

* Deleted unnecessary code

* Added error handling

* Fixed linting

* Deleted unnecessary code

* WIP: added todos

* Modified GrowiInteractionProcessor

* WIP: refactoring code

* Worked

* Fixed unregister

* WIP memo

* Implemented processors

* Modified

* Renamed isTerminate -> isTerminated

* WIP

* Use generics & renamed parameters' name

* Added export

* Select growi command worked

* Deleted unnecessary code

* Refactored

* Fixed warning

* Deleted Unnecessary code

* Fixed ci error

* Modified

* Inject interactionPayload and interactionPayloadAccessor to req

* Enabled to use response_url on growi side /interactions

* WIP

* Added error handling

* Use response_url for /commands

* Commands worked using response_url, but need bug fix

* Use utils

* Interactions worked using response_url

* Fixed bug

* Normalized create command

* Proxy worked

* Worked except postEphemeralErrors

* Worked

* renamed

* Proxy worked

* Without worked

* Improved search

* Added @slack/oauth to package.json

* Modified

Co-authored-by: Taichi Masuyama <montanha.masu536@gmail.com>
Co-authored-by: Haku Mizuki <58432773+hakumizuki@users.noreply.github.com>
Yuki Takei 4 лет назад
Родитель
Сommit
2edabcf0ca
32 измененных файлов с 1008 добавлено и 471 удалено
  1. 98 46
      packages/app/src/server/routes/apiv3/slack-integration.js
  2. 7 6
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  3. 27 9
      packages/app/src/server/service/slack-command-handler/create.js
  4. 3 5
      packages/app/src/server/service/slack-command-handler/help.js
  5. 74 77
      packages/app/src/server/service/slack-command-handler/search.js
  6. 3 8
      packages/app/src/server/service/slack-command-handler/slack-command-handler.js
  7. 36 37
      packages/app/src/server/service/slack-command-handler/togetter.js
  8. 16 20
      packages/app/src/server/service/slack-integration.ts
  9. 1 0
      packages/slack/package.json
  10. 6 0
      packages/slack/src/index.ts
  11. 9 0
      packages/slack/src/interfaces/growi-command-processor.ts
  12. 1 0
      packages/slack/src/interfaces/growi-command.ts
  13. 17 0
      packages/slack/src/interfaces/growi-interaction-processor.ts
  14. 7 0
      packages/slack/src/interfaces/request-from-slack.ts
  15. 6 0
      packages/slack/src/interfaces/response-url.ts
  16. 16 0
      packages/slack/src/middlewares/parse-slack-interaction-request.ts
  17. 83 0
      packages/slack/src/utils/interaction-payload-accessor.ts
  18. 3 0
      packages/slack/src/utils/payload-interaction-id-helpers.ts
  19. 4 11
      packages/slack/src/utils/post-ephemeral-errors.ts
  20. 25 0
      packages/slack/src/utils/response-url.ts
  21. 1 0
      packages/slack/src/utils/slash-command-parser.ts
  22. 83 82
      packages/slackbot-proxy/src/controllers/slack.ts
  23. 0 6
      packages/slackbot-proxy/src/interfaces/slack-to-growi/growi-command-processor.ts
  24. 2 1
      packages/slackbot-proxy/src/interfaces/slack-to-growi/slack-oauth-req.ts
  25. 2 3
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  26. 6 8
      packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts
  27. 23 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/parse-interaction-req.ts
  28. 98 38
      packages/slackbot-proxy/src/services/RegisterService.ts
  29. 0 1
      packages/slackbot-proxy/src/services/RelationsService.ts
  30. 183 63
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  31. 163 48
      packages/slackbot-proxy/src/services/UnregisterService.ts
  32. 5 2
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts

+ 98 - 46
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,10 +1,13 @@
+import { markdownSectionBlock, InvalidGrowiCommandError } from '@growi/slack';
 import loggerFactory from '~/utils/logger';
 
 const express = require('express');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const { verifySlackRequest, parseSlashCommand } = require('@growi/slack');
+const {
+  verifySlackRequest, parseSlashCommand, InteractionPayloadAccessor, respond,
+} = require('@growi/slack');
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
@@ -56,45 +59,51 @@ module.exports = (crowi) => {
 
   // REFACTORIMG THIS MIDDLEWARE GW-7441
   async function checkCommandsPermission(req, res, next) {
-    if (req.body.text == null) return next(); // when /relation-test
+    let { growiCommand } = req.body;
+
+    // when /relation-test or from proxy
+    if (req.body.text == null && growiCommand == null) return next();
+
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
+    const fromChannel = req.body.channel_name;
+    const siteUrl = crowi.appService.getSiteUrl();
 
     let commandPermission;
     if (extractPermissions != null) { // with proxy
       const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-    }
-    else { // without proxy
-      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+      const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+      if (isPermitted) return next();
+      return res.status(403).send(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`);
     }
 
-    const growiCommand = parseSlashCommand(req.body);
-    const fromChannel = req.body.channel_name;
-    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
-    if (isPermitted) return next();
+    // without proxy
+    growiCommand = parseSlashCommand(req.body);
+    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
 
-    // IT IS NOT WORKING. FIX THIS GW-7441
-    return res.status(403).send('It is not allowed to run the command to this GROWI.');
+    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    if (isPermitted) {
+      return next();
+    }
+    // show ephemeral error message if not permitted
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Command forbidden',
+      blocks: [
+        markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
+      ],
+    });
   }
 
   // REFACTORIMG THIS MIDDLEWARE GW-7441
   async function checkInteractionsPermission(req, res, next) {
-    const payload = JSON.parse(req.body.payload);
-    if (payload == null) return next(); // when /relation-test
-
-    let actionId = '';
-    let callbackId = '';
-    let fromChannel = '';
+    const { interactionPayload, interactionPayloadAccessor } = req;
+    const siteUrl = crowi.appService.getSiteUrl();
 
-    if (payload.actions) { // when request is to /interactions && block_actions
-      actionId = payload.actions[0].action_id;
-      fromChannel = payload.channel.name;
-    }
-    else { // when request is to /interactions && view_submission
-      callbackId = payload.view.callback_id;
-      fromChannel = JSON.parse(payload.view.private_metadata).channelName;
-    }
+    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const callbacIdkOrActionId = callbackId || actionId;
+    const fromChannel = interactionPayloadAccessor.getChannelName();
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const extractPermissions = await extractPermissionsCommands(tokenPtoG);
@@ -102,17 +111,27 @@ module.exports = (crowi) => {
     if (extractPermissions != null) { // with proxy
       const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
       commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
-    }
-    else { // without proxy
-      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+      const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+      if (isPermitted) return next();
+
+      return res.status(403).send(`This interaction is forbidden on this GROWI: ${siteUrl}`);
     }
 
-    const callbacIdkOrActionId = callbackId || actionId;
-    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
-    if (isPermitted) return next();
+    // without proxy
+    commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
 
-    // IT IS NOT WORKING FIX. THIS GW-7441
-    return res.status(403).send('It is not allowed to run the command to this GROWI.');
+    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    if (isPermitted) {
+      return next();
+    }
+    // show ephemeral error message if not permitted
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Interaction forbidden',
+      blocks: [
+        markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
+      ],
+    });
   }
 
   const addSigningSecretToReq = (req, res, next) => {
@@ -120,10 +139,43 @@ module.exports = (crowi) => {
     return next();
   };
 
+  const parseSlackInteractionRequest = (req, res, next) => {
+    if (req.body.payload == null) {
+      return next(new Error('The payload is not in the request from slack or proxy.'));
+    }
+
+    req.interactionPayload = JSON.parse(req.body.payload);
+    req.interactionPayloadAccessor = new InteractionPayloadAccessor(req.interactionPayload);
+
+    return next();
+  };
+
   async function handleCommands(req, res, client) {
     const { body } = req;
+    let { growiCommand } = body;
+
+    if (growiCommand == null) {
+      try {
+        growiCommand = parseSlashCommand(body);
+      }
+      catch (err) {
+        if (err instanceof InvalidGrowiCommandError) {
+          res.json({
+            blocks: [
+              markdownSectionBlock('*Command type is not specified.*'),
+              markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+            ],
+          });
+        }
+        logger.error(err.message);
+        return;
+      }
+    }
+
+    const { text } = growiCommand;
+
 
-    if (body.text == null) {
+    if (text == null) {
       return 'No text.';
     }
 
@@ -138,11 +190,9 @@ module.exports = (crowi) => {
       text: 'Processing your request ...',
     });
 
-    const args = body.text.split(' ');
-    const command = args[0];
 
     try {
-      await crowi.slackIntegrationService.handleCommandRequest(command, client, body, args);
+      await crowi.slackIntegrationService.handleCommandRequest(growiCommand, client, body);
     }
     catch (err) {
       await respondIfSlackbotError(client, body, err);
@@ -150,6 +200,7 @@ module.exports = (crowi) => {
 
   }
 
+  // TODO: do investigation and fix if needed GW-7519
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleCommands(req, res, client);
@@ -168,20 +219,20 @@ module.exports = (crowi) => {
     return handleCommands(req, res, client);
   });
 
-  async function handleInteractions(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 payload = JSON.parse(req.body.payload);
-    const { type } = payload;
+    const { interactionPayload, interactionPayloadAccessor } = req;
+    const { type } = interactionPayload;
 
     try {
       switch (type) {
         case 'block_actions':
           try {
-            await crowi.slackIntegrationService.handleBlockActionsRequest(client, payload);
+            await crowi.slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor);
           }
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
@@ -189,7 +240,7 @@ module.exports = (crowi) => {
           break;
         case 'view_submission':
           try {
-            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, payload);
+            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor);
           }
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
@@ -205,16 +256,17 @@ module.exports = (crowi) => {
 
   }
 
-  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, checkInteractionsPermission, async(req, res) => {
+  // TODO: do investigation and fix if needed GW-7519
+  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
-    return handleInteractions(req, res, client);
+    return handleInteractionsRequest(req, res, client);
   });
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkInteractionsPermission, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
 
-    return handleInteractions(req, res, client);
+    return handleInteractionsRequest(req, res, client);
   });
 
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {

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

@@ -1,7 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:CreatePageService');
-const { reshapeContentsBody } = require('@growi/slack');
+const { reshapeContentsBody, respond, markdownSectionBlock } = require('@growi/slack');
 const mongoose = require('mongoose');
 const pathUtils = require('growi-commons').pathUtils;
 const SlackbotError = require('../../models/vo/slackbot-error');
@@ -12,7 +12,7 @@ class CreatePageService {
     this.crowi = crowi;
   }
 
-  async createPageInGrowi(client, payload, path, channelId, contentsBody) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody) {
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
     try {
@@ -26,10 +26,11 @@ class CreatePageService {
 
       // Send a message when page creation is complete
       const growiUri = this.crowi.appService.getSiteUrl();
-      await client.chat.postEphemeral({
-        channel: channelId,
-        user: payload.user.id,
-        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
+      await respond(interactionPayloadAccessor.getResponseUrl(), {
+        text: 'Page has been created',
+        blocks: [
+          markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`),
+        ],
       });
     }
     catch (err) {

+ 27 - 9
packages/app/src/server/service/slack-command-handler/create.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 
-const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
+const {
+  markdownSectionBlock, inputSectionBlock, respond, inputBlock,
+} = require('@growi/slack');
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 
@@ -9,8 +11,14 @@ module.exports = (crowi) => {
   const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
+  const conversationsSelectElement = {
+    action_id: 'conversation',
+    type: 'conversations_select',
+    response_url_enabled: true,
+    default_to_current_conversation: true,
+  };
 
-  handler.handleCommand = async(client, body) => {
+  handler.handleCommand = async(growiCommand, client, body) => {
     await client.views.open({
       trigger_id: body.trigger_id,
 
@@ -31,6 +39,7 @@ module.exports = (crowi) => {
         },
         blocks: [
           markdownSectionBlock('Create new page.'),
+          inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'),
           inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
           inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
@@ -39,15 +48,24 @@ module.exports = (crowi) => {
     });
   };
 
-  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
-    await this[handlerMethodName](client, payload);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
   };
 
-  handler.createPage = async function(client, payload) {
-    const path = payload.view.state.values.path.path_input.value;
-    const channelId = JSON.parse(payload.view.private_metadata).channelId;
-    const contentsBody = payload.view.state.values.contents.contents_input.value;
-    await createPageService.createPageInGrowi(client, payload, path, channelId, contentsBody);
+  handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor) {
+    const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value;
+    const privateMetadata = interactionPayloadAccessor.getViewPrivateMetaData();
+    if (privateMetadata == null) {
+      await respond(interactionPayloadAccessor.getResponseUrl(), {
+        text: 'Error occurred',
+        blocks: [
+          markdownSectionBlock('Failed to create a page.'),
+        ],
+      });
+      return;
+    }
+    const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value;
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
   };
 
   return handler;

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

@@ -1,10 +1,10 @@
-const { markdownSectionBlock } = require('@growi/slack');
+const { markdownSectionBlock, respond } = require('@growi/slack');
 
 module.exports = () => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
-  handler.handleCommand = (client, body) => {
+  handler.handleCommand = (growiCommand, client, body) => {
     // adjust spacing
     let message = '*Help*\n\n';
     message += 'Usage:     `/growi [command] [args]`\n\n';
@@ -12,9 +12,7 @@ module.exports = () => {
     message += '`/growi create`                          Create new page\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
     message += '`/growi togetter`                      Create new page with existing slack conversations (Alpha)\n\n';
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+    await respond(growiCommand.responseUrl, {
       text: 'Help',
       blocks: [
         markdownSectionBlock(message),

+ 74 - 77
packages/app/src/server/service/slack-command-handler/search.js

@@ -2,9 +2,10 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
-const { markdownSectionBlock, divider } = require('@growi/slack');
+const {
+  markdownSectionBlock, divider, respond, deleteOriginal,
+} = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
-const axios = require('axios');
 const SlackbotError = require('../../models/vo/slackbot-error');
 
 const PAGINGLIMIT = 10;
@@ -13,10 +14,11 @@ module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler(crowi);
 
-  handler.handleCommand = async function(client, body, args) {
+  handler.handleCommand = async function(growiCommand, client, body) {
+    const { responseUrl, growiCommandArgs } = growiCommand;
     let searchResult;
     try {
-      searchResult = await this.retrieveSearchResults(client, body, args);
+      searchResult = await this.retrieveSearchResults(responseUrl, client, body, growiCommandArgs);
     }
     catch (err) {
       logger.error('Failed to get search results.', err);
@@ -35,20 +37,26 @@ module.exports = (crowi) => {
       pages, offset, resultsTotal,
     } = searchResult;
 
-    if (pages.length === 0) {
-      return;
-    }
-
-    const keywords = this.getKeywords(args);
+    const keywords = this.getKeywords(growiCommandArgs);
 
 
     let searchResultsDesc;
 
+    if (resultsTotal === 0 || resultsTotal == null) {
+      if (keywords === '') return;
+      await respond(responseUrl, {
+        text: 'No page found.',
+        blocks: [
+          markdownSectionBlock(`No page found. keyword(s): *"${keywords}"*`),
+          markdownSectionBlock('Please try other keywords.'),
+        ],
+      });
+      return;
+    }
     switch (resultsTotal) {
       case 1:
         searchResultsDesc = `*${resultsTotal}* page is found.`;
         break;
-
       default:
         searchResultsDesc = `*${resultsTotal}* pages are found.`;
         break;
@@ -99,21 +107,6 @@ module.exports = (crowi) => {
       contextBlock,
     ];
 
-    // DEFAULT show "Share" button
-    // const actionBlocks = {
-    //   type: 'actions',
-    //   elements: [
-    //     {
-    //       type: 'button',
-    //       text: {
-    //         type: 'plain_text',
-    //         text: 'Share',
-    //       },
-    //       style: 'primary',
-    //       action_id: 'shareSearchResults',
-    //     },
-    //   ],
-    // };
     const actionBlocks = {
       type: 'actions',
       elements: [
@@ -138,41 +131,47 @@ module.exports = (crowi) => {
             text: 'Next',
           },
           action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, args }),
+          value: JSON.stringify({ offset, body, growiCommandArgs }),
         },
       );
     }
     blocks.push(actionBlocks);
 
-    await client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+    await respond(responseUrl, {
       text: 'Successed To Search',
       blocks,
     });
   };
 
-  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
-    await this[handlerMethodName](client, payload);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
   };
 
-  handler.shareSinglePageResult = async function(client, payload) {
-    const { channel, user, actions } = payload;
+  handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor) {
+    const { user } = payload;
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
 
-    const channelId = channel.id;
-    const action = actions[0]; // shareSinglePage action must have button action
+    const value = interactionPayloadAccessor.firstAction()?.value; // shareSinglePage action must have button action
+    if (value == null) {
+      await respond(responseUrl, {
+        text: 'Error occurred',
+        blocks: [
+          markdownSectionBlock('Failed to share the result.'),
+        ],
+      });
+      return;
+    }
 
     // restore page data from value
-    const { page, href, pathname } = JSON.parse(action.value);
+    const { page, href, pathname } = JSON.parse(value);
     const { updatedAt, commentCount } = page;
 
     // share
     const now = new Date();
-    return client.chat.postMessage({
-      channel: channelId,
+    return respond(responseUrl, {
       blocks: [
         { type: 'divider' },
         markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
@@ -189,14 +188,26 @@ module.exports = (crowi) => {
     });
   };
 
-  handler.showNextResults = async function(client, payload) {
-    const parsedValue = JSON.parse(payload.actions[0].value);
+  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
-    const { body, args, offset: offsetNum } = parsedValue;
+    const value = interactionPayloadAccessor.firstAction()?.value;
+    if (value == null) {
+      await respond(responseUrl, {
+        text: 'Error occurred',
+        blocks: [
+          markdownSectionBlock('Failed to show the next results.'),
+        ],
+      });
+      return;
+    }
+    const parsedValue = JSON.parse(value);
+
+    const { body, growiCommandArgs, offset: offsetNum } = parsedValue;
     const newOffsetNum = offsetNum + 10;
     let searchResult;
     try {
-      searchResult = await this.retrieveSearchResults(client, body, args, newOffsetNum);
+      searchResult = await this.retrieveSearchResults(responseUrl, client, body, growiCommandArgs, newOffsetNum);
     }
     catch (err) {
       logger.error('Failed to get search results.', err);
@@ -215,22 +226,30 @@ module.exports = (crowi) => {
       pages, offset, resultsTotal,
     } = searchResult;
 
-    const keywords = this.getKeywords(args);
+    const keywords = this.getKeywords(growiCommandArgs);
 
 
     let searchResultsDesc;
 
+    if (resultsTotal === 0 || resultsTotal == null) {
+      if (keywords === '') return;
+      await respond(responseUrl, {
+        text: 'No page found.',
+        blocks: [
+          markdownSectionBlock('Please try with other keywords.'),
+        ],
+      });
+      return;
+    }
     switch (resultsTotal) {
       case 1:
         searchResultsDesc = `*${resultsTotal}* page is found.`;
         break;
-
       default:
         searchResultsDesc = `*${resultsTotal}* pages are found.`;
         break;
     }
 
-
     const contextBlock = {
       type: 'context',
       elements: [
@@ -275,21 +294,6 @@ module.exports = (crowi) => {
       contextBlock,
     ];
 
-    // DEFAULT show "Share" button
-    // const actionBlocks = {
-    //   type: 'actions',
-    //   elements: [
-    //     {
-    //       type: 'button',
-    //       text: {
-    //         type: 'plain_text',
-    //         text: 'Share',
-    //       },
-    //       style: 'primary',
-    //       action_id: 'shareSearchResults',
-    //     },
-    //   ],
-    // };
     const actionBlocks = {
       type: 'actions',
       elements: [
@@ -314,15 +318,13 @@ module.exports = (crowi) => {
             text: 'Next',
           },
           action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, args }),
+          value: JSON.stringify({ offset, body, growiCommandArgs }),
         },
       );
     }
     blocks.push(actionBlocks);
 
-    await client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+    await respond(responseUrl, {
       text: 'Successed To Search',
       blocks,
     });
@@ -331,17 +333,15 @@ module.exports = (crowi) => {
   handler.dismissSearchResults = async function(client, payload) {
     const { response_url: responseUrl } = payload;
 
-    return axios.post(responseUrl, {
+    return deleteOriginal(responseUrl, {
       delete_original: true,
     });
   };
 
-  handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
-    const firstKeyword = args[1];
+  handler.retrieveSearchResults = async function(responseUrl, client, body, growiCommandArgs, offset = 0) {
+    const firstKeyword = growiCommandArgs[0];
     if (firstKeyword == null) {
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
+      await respond(responseUrl, {
         text: 'Input keywords',
         blocks: [
           markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
@@ -350,7 +350,7 @@ module.exports = (crowi) => {
       return { pages: [] };
     }
 
-    const keywords = this.getKeywords(args);
+    const keywords = this.getKeywords(growiCommandArgs);
 
     const { searchService } = crowi;
     const options = { limit: 10, offset };
@@ -360,9 +360,7 @@ module.exports = (crowi) => {
     // no search results
     if (results.data.length === 0) {
       logger.info(`No page found with "${keywords}"`);
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
+      await respond(responseUrl, {
         text: `No page found with "${keywords}"`,
         blocks: [
           markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
@@ -396,9 +394,8 @@ module.exports = (crowi) => {
     };
   };
 
-  handler.getKeywords = function(args) {
-    const keywordsArr = args.slice(1);
-    const keywords = keywordsArr.join(' ');
+  handler.getKeywords = function(growiCommandArgs) {
+    const keywords = growiCommandArgs.join(' ');
     return keywords;
   };
 

+ 3 - 8
packages/app/src/server/service/slack-command-handler/slack-command-handler.js

@@ -8,17 +8,12 @@ class BaseSlackCommandHandler {
   /**
    * Handle /commands endpoint
    */
-  handleCommand(client, body, ...opt) { throw new Error('Implement this') }
+  handleCommand(growiCommand, client, body) { throw new Error('Implement this') }
 
   /**
-   * Handle /interactions endpoint 'block_actions'
+   * Handle interactions
    */
-  handleBlockActions(client, payload, handlerMethodName) { throw new Error('Implement this') }
-
-  /**
-   * Handle /interactions endpoint 'view_submission'
-   */
-  handleViewSubmission(client, payload, handlerMethodName) { throw new Error('Implement this') }
+  handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) { throw new Error('Implement this') }
 
 }
 

+ 36 - 37
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -2,7 +2,8 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:SlackBotService:togetter');
 const {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider, respond,
+  deleteOriginal,
 } = require('@growi/slack');
 const { parse, format } = require('date-fns');
 const axios = require('axios');
@@ -14,47 +15,39 @@ module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
-  handler.handleCommand = async function(client, body, args, limit = 10) {
-    // TODO GW-6721 Get the time from args
-    const result = await client.conversations.history({
-      channel: body.channel_id,
-      limit,
-    });
-      // Return Checkbox Message
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+  handler.handleCommand = async function(growiCommand, client, body) {
+    await respond(growiCommand.responseUrl, {
       text: 'Select messages to use.',
-      blocks: this.togetterMessageBlocks(result.messages, body, args, limit),
+      blocks: this.togetterMessageBlocks(),
     });
     return;
   };
 
-  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
-    await this[handlerMethodName](client, payload);
+  handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
+    await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
   };
 
-  handler.cancel = async function(client, payload) {
-    const responseUrl = payload.response_url;
-    axios.post(responseUrl, {
+  handler.cancel = async function(client, payload, interactionPayloadAccessor) {
+    await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
       delete_original: true,
     });
   };
 
-  handler.createPage = async function(client, payload) {
+  handler.createPage = async function(client, payload, interactionPayloadAccessor) {
     let result = [];
-    const channel = payload.channel.id;
+    const channelId = payload.channel.id; // this must exist since the type is always block_actions
+    const userChannelId = payload.user.id;
     try {
       // validate form
-      const { path, oldest, newest } = await this.togetterValidateForm(client, payload);
+      const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor);
       // get messages
-      result = await this.togetterGetMessages(client, payload, channel, path, newest, oldest);
+      result = await this.togetterGetMessages(client, channelId, newest, oldest);
       // clean messages
       const cleanedContents = await this.togetterCleanMessages(result.messages);
 
       const contentsBody = cleanedContents.join('');
       // create and send url message
-      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+      await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody);
     }
     catch (err) {
       logger.error('Error occured by togetter.');
@@ -62,14 +55,14 @@ module.exports = (crowi) => {
     }
   };
 
-  handler.togetterValidateForm = async function(client, payload) {
+  handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
-    const path = payload.state.values.page_path.page_path.value;
-    let oldest = payload.state.values.oldest.oldest.value;
-    let newest = payload.state.values.newest.newest.value;
+    const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
+    let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
+    let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
     oldest = oldest.trim();
     newest = newest.trim();
-    if (!path) {
+    if (path == null) {
       throw new SlackbotError({
         method: 'postMessage',
         to: 'dm',
@@ -115,9 +108,9 @@ module.exports = (crowi) => {
     return { path, oldest, newest };
   };
 
-  handler.togetterGetMessages = async function(client, payload, channel, path, newest, oldest) {
+  handler.togetterGetMessages = async function(client, channelId, newest, oldest) {
     const result = await client.conversations.history({
-      channel,
+      channel: channelId,
       newest,
       oldest,
       limit: 100,
@@ -125,7 +118,7 @@ module.exports = (crowi) => {
     });
 
     // return if no message found
-    if (!result.messages.length) {
+    if (result.messages.length === 0) {
       throw new SlackbotError({
         method: 'postMessage',
         to: 'dm',
@@ -163,12 +156,19 @@ module.exports = (crowi) => {
     return cleanedContents;
   };
 
-  handler.togetterCreatePageAndSendPreview = async function(client, payload, path, channel, contentsBody) {
+  handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) {
+    try {
+      await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
+    }
+    catch (err) {
+      logger.error('Error occurred while creating a page.');
+      throw err;
+    }
+
     try {
-      await createPageService.createPageInGrowi(client, payload, path, channel, contentsBody);
       // send preview to dm
       await client.chat.postMessage({
-        channel: payload.user.id,
+        channel: userChannelId,
         text: 'Preview from togetter command',
         blocks: [
           markdownSectionBlock('*Preview*'),
@@ -177,9 +177,8 @@ module.exports = (crowi) => {
           divider(),
         ],
       });
-      // dismiss message
-      const responseUrl = payload.response_url;
-      axios.post(responseUrl, {
+      // dismiss
+      await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
         delete_original: true,
       });
     }
@@ -194,7 +193,7 @@ module.exports = (crowi) => {
     }
   };
 
-  handler.togetterMessageBlocks = function(messages, body, args, limit) {
+  handler.togetterMessageBlocks = function() {
     return [
       markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
       inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'),

+ 16 - 20
packages/app/src/server/service/slack-integration.ts

@@ -3,7 +3,9 @@ import mongoose from 'mongoose';
 import { IncomingWebhookSendArguments } from '@slack/webhook';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 
-import { generateWebClient, markdownSectionBlock, SlackbotType } from '@growi/slack';
+import {
+  generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, respond, SlackbotType,
+} from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
 
@@ -235,32 +237,28 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommandRequest(command, client, body, ...opt) {
-    let module;
-    try {
-      module = `./slack-command-handler/${command}`;
-    }
-    catch (err) {
-      await this.notCommand(client, body);
-    }
+  async handleCommandRequest(growiCommand: GrowiCommand, client, body) {
+    const { growiCommandType } = growiCommand;
+    const module = `./slack-command-handler/${growiCommandType}`;
 
     try {
       const handler = require(module)(this.crowi);
-      await handler.handleCommand(client, body, ...opt);
+      await handler.handleCommand(growiCommand, client, body);
     }
     catch (err) {
+      await this.notCommand(growiCommand);
       throw err;
     }
   }
 
-  async handleBlockActionsRequest(client, payload) {
-    const { action_id: actionId } = payload.actions[0];
+  async handleBlockActionsRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+    const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = actionId.split(':')[0];
     const handlerMethodName = actionId.split(':')[1];
     const module = `./slack-command-handler/${commandName}`;
     try {
       const handler = require(module)(this.crowi);
-      await handler.handleBlockActions(client, payload, handlerMethodName);
+      await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
     }
     catch (err) {
       throw err;
@@ -268,14 +266,14 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return;
   }
 
-  async handleViewSubmissionRequest(client, payload) {
-    const { callback_id: callbackId } = payload.view;
+  async handleViewSubmissionRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+    const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
     const commandName = callbackId.split(':')[0];
     const handlerMethodName = callbackId.split(':')[1];
     const module = `./slack-command-handler/${commandName}`;
     try {
       const handler = require(module)(this.crowi);
-      await handler.handleBlockActions(client, payload, handlerMethodName);
+      await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
     }
     catch (err) {
       throw err;
@@ -283,11 +281,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return;
   }
 
-  async notCommand(client, body) {
+  async notCommand(growiCommand: GrowiCommand): Promise<void> {
     logger.error('Invalid first argument');
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
+    await respond(growiCommand.responseUrl, {
       text: 'No command',
       blocks: [
         markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),

+ 1 - 0
packages/slack/package.json

@@ -13,6 +13,7 @@
     "lint:fix": "eslint src --ext .ts --fix"
   },
   "dependencies": {
+    "@slack/oauth": "^2.0.1",
     "axios": "^0.21.1",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",

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

@@ -22,11 +22,14 @@ export const defaultSupportedCommandsNameForSingleUse: string[] = [
   'togetter',
 ];
 
+export * from './interfaces/growi-command-processor';
+export * from './interfaces/growi-interaction-processor';
 export * from './interfaces/growi-command';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/slackbot-types';
 export * from './models/errors';
+export * from './middlewares/parse-slack-interaction-request';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './utils/block-kit-builder';
@@ -35,7 +38,10 @@ export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/publish-initial-home-view';
 export * from './utils/reshape-contents-body';
+export * from './utils/response-url';
 export * from './utils/slash-command-parser';
 export * from './utils/webclient-factory';
 export * from './utils/welcome-message';
 export * from './utils/required-scopes';
+export * from './utils/interaction-payload-accessor';
+export * from './utils/payload-interaction-id-helpers';

+ 9 - 0
packages/slack/src/interfaces/growi-command-processor.ts

@@ -0,0 +1,9 @@
+import { AuthorizeResult } from '@slack/oauth';
+
+import { GrowiCommand } from './growi-command';
+
+export interface GrowiCommandProcessor<ProcessCommandContext = {[key: string]: string}> {
+  shouldHandleCommand(growiCommand?: GrowiCommand): boolean;
+
+  processCommand(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, context?: ProcessCommandContext): Promise<void>
+}

+ 1 - 0
packages/slack/src/interfaces/growi-command.ts

@@ -1,5 +1,6 @@
 export type GrowiCommand = {
   text: string,
+  responseUrl: string,
   growiCommandType: string,
   growiCommandArgs: string[],
 };

+ 17 - 0
packages/slack/src/interfaces/growi-interaction-processor.ts

@@ -0,0 +1,17 @@
+import { AuthorizeResult } from '@slack/oauth';
+import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
+
+
+export interface InteractionHandledResult<V> {
+  result?: V;
+  isTerminated: boolean;
+}
+
+export interface GrowiInteractionProcessor<V> {
+
+  shouldHandleInteraction(interactionPayloadAccessor: InteractionPayloadAccessor): boolean;
+
+  processInteraction(
+    authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<InteractionHandledResult<V>>;
+
+}

+ 7 - 0
packages/slack/src/interfaces/request-from-slack.ts

@@ -1,9 +1,16 @@
 import { Request } from 'express';
 
+export interface IInteractionPayloadAccessor {
+  firstAction(): any;
+}
+
 export type RequestFromSlack = Request & {
   // appended by slack
   headers:{'x-slack-signature'?:string, 'x-slack-request-timestamp':number},
 
   // appended by GROWI or slackbot-proxy
   slackSigningSecret?:string,
+
+  interactionPayload?: any,
+  interactionPayloadAccessor?: any,
 };

+ 6 - 0
packages/slack/src/interfaces/response-url.ts

@@ -0,0 +1,6 @@
+import { KnownBlock, Block } from '@slack/web-api';
+
+export type RespondBodyForResponseUrl = {
+  text?: string,
+  blocks?: (KnownBlock | Block)[],
+};

+ 16 - 0
packages/slack/src/middlewares/parse-slack-interaction-request.ts

@@ -0,0 +1,16 @@
+import { Response, NextFunction } from 'express';
+import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
+
+import { RequestFromSlack } from '../interfaces/request-from-slack';
+
+export const parseSlackInteractionRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
+  // There is no payload in the request from slack
+  if (req.body.payload == null) {
+    return next();
+  }
+
+  req.interactionPayload = JSON.parse(req.body.payload);
+  req.interactionPayloadAccessor = new InteractionPayloadAccessor(req.interactionPayload);
+
+  return next();
+};

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

@@ -0,0 +1,83 @@
+import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+
+
+export class InteractionPayloadAccessor implements IInteractionPayloadAccessor {
+
+  private payload: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(payload: any) {
+    this.payload = payload;
+  }
+
+  firstAction(): any | null {
+    const actions = this.payload.actions;
+
+    if (actions != null && actions[0] != null) {
+      return actions[0];
+    }
+
+    return null;
+  }
+
+  getResponseUrl(): string {
+    const responseUrl = this.payload.response_url;
+    if (responseUrl != null) {
+      return responseUrl;
+    }
+
+    const responseUrls = this.payload.response_urls;
+    if (responseUrls != null && responseUrls[0] != null) {
+      return responseUrls[0].response_url;
+    }
+
+    return '';
+  }
+
+  getStateValues(): any | null {
+    const state = this.payload.state;
+    if (state != null && state.values != null) {
+      return state.values;
+    }
+
+    const view = this.payload.view;
+    if (view != null && view.state != null && view.state.values != null) {
+      return view.state.values;
+    }
+
+    return null;
+  }
+
+  getViewPrivateMetaData(): any | null {
+    const view = this.payload.view;
+
+    if (view != null && view.private_metadata) {
+      return JSON.parse(view.private_metadata);
+    }
+
+    return null;
+  }
+
+  getActionIdAndCallbackIdFromPayLoad(): {[key: string]: string} {
+    const actionId = this.firstAction()?.action_id || '';
+    const callbackId = this.payload.view?.callback_id || '';
+
+    return { actionId, callbackId };
+  }
+
+  getChannelName(): string | null {
+    // private_metadata should have the channelName parameter when view_submission
+    const privateMetadata = this.getViewPrivateMetaData();
+    if (privateMetadata != null && privateMetadata.channelName != null) {
+      return privateMetadata.channelName;
+    }
+
+    const channel = this.payload.channel;
+    if (channel != null) {
+      return this.payload.channel.name;
+    }
+
+    return null;
+  }
+
+}

+ 3 - 0
packages/slack/src/utils/payload-interaction-id-helpers.ts

@@ -0,0 +1,3 @@
+export const getInteractionIdRegexpFromCommandName = (commandname: string): RegExp => {
+  return new RegExp(`^${commandname}:\\w+`);
+};

+ 4 - 11
packages/slack/src/utils/post-ephemeral-errors.ts

@@ -1,23 +1,16 @@
 import { WebAPICallResult } from '@slack/web-api';
+import { respond } from './response-url';
 
 import { markdownSectionBlock } from './block-kit-builder';
-import { generateWebClient } from './webclient-factory';
 
-export const postEphemeralErrors = async(
+export const respondRejectedErrors = async(
     rejectedResults: PromiseRejectedResult[],
-    channelId: string,
-    userId: string,
-    botToken: string,
+    responseUrl: string,
 ): Promise<WebAPICallResult|void> => {
 
   if (rejectedResults.length > 0) {
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const client = generateWebClient(botToken);
-
-    return client.chat.postEphemeral({
+    await respond(responseUrl, {
       text: 'Error occured.',
-      channel: channelId,
-      user: userId,
       blocks: [
         markdownSectionBlock('*Error occured:*'),
         ...rejectedResults.map((rejectedResult) => {

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

@@ -0,0 +1,25 @@
+import axios from 'axios';
+
+import { RespondBodyForResponseUrl } from '../interfaces/response-url';
+
+export async function respond(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
+  return axios.post(responseUrl, {
+    replace_original: false,
+    text: body.text,
+    blocks: body.blocks,
+  });
+}
+
+export async function replaceOriginal(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
+  return axios.post(responseUrl, {
+    replace_original: true,
+    text: body.text,
+    blocks: body.blocks,
+  });
+}
+
+export async function deleteOriginal(responseUrl: string): Promise<void> {
+  return axios.post(responseUrl, {
+    delete_original: true,
+  });
+}

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

@@ -15,6 +15,7 @@ export const parseSlashCommand = (slashCommand:{[key:string]:string}): GrowiComm
 
   return {
     text: slashCommand.text,
+    responseUrl: slashCommand.response_url,
     growiCommandType: splitted[0],
     growiCommandArgs: splitted.slice(1),
   };

+ 83 - 82
packages/slackbot-proxy/src/controllers/slack.ts

@@ -4,13 +4,15 @@ import {
 
 import axios from 'axios';
 
-import { WebAPICallResult, WebClient } from '@slack/web-api';
+import { WebAPICallResult } from '@slack/web-api';
 import { Installation } from '@slack/oauth';
 
 
 import {
-  markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
+  markdownSectionBlock, GrowiCommand, parseSlashCommand, respondRejectedErrors, generateWebClient,
   InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
+  parseSlackInteractionRequest, verifySlackRequest,
+  respond,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -24,19 +26,18 @@ import {
 } from '~/middlewares/slack-to-growi/authorizer';
 import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
+import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
 import { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
-import { InvalidUrlError } from '../models/errors';
 import loggerFactory from '~/utils/logger';
-import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
 
-const postNotAllowedMessage = async(client:WebClient, channelId:string, userId:string, disallowedGrowiUrls:Set<string>, commandName:string):Promise<void> => {
+const postNotAllowedMessage = async(responseUrl, disallowedGrowiUrls:Set<string>, commandName:string):Promise<void> => {
 
   const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
     return '\n'
@@ -46,10 +47,8 @@ const postNotAllowedMessage = async(client:WebClient, channelId:string, userId:s
   const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
 
 
-  await client.chat.postEphemeral({
+  await respond(responseUrl, {
     text: 'Error occured.',
-    channel: channelId,
-    user: userId,
     blocks: [
       markdownSectionBlock('*None of GROWI permitted the command.*'),
       markdownSectionBlock(`*'${commandName}'* command was not allowed.`),
@@ -103,7 +102,6 @@ export class SlackCtrl {
       throw new Error('relations must be set');
     }
 
-    const botToken = relations[0].installation?.data.bot?.token; // relations[0] should be exist
     const promises = relations.map((relation: Relation) => {
       // generate API URL
       const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
@@ -123,8 +121,7 @@ export class SlackCtrl {
     const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
 
     try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      return postEphemeralErrors(rejectedResults, body.channel_id, body.user_id, botToken!);
+      return respondRejectedErrors(rejectedResults, growiCommand.responseUrl);
     }
     catch (err) {
       logger.error(err);
@@ -137,8 +134,20 @@ export class SlackCtrl {
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     const { body, authorizeResult } = req;
 
-    let growiCommand;
+    // retrieve bot token
+    const { botToken } = authorizeResult;
+    if (botToken == null) {
+      const serverUri = process.env.SERVER_URI;
+      res.json({
+        blocks: [
+          markdownSectionBlock('*Installation might be failed.*'),
+          markdownSectionBlock(`Access to ${serverUri} and re-install GROWI App`),
+        ],
+      });
+    }
 
+    // parse /growi command
+    let growiCommand: GrowiCommand;
     try {
       growiCommand = parseSlashCommand(body);
     }
@@ -155,21 +164,18 @@ export class SlackCtrl {
       return;
     }
 
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    res.json();
+
     // register
-    if (growiCommand.growiCommandType === 'register') {
-      return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
+    if (this.registerService.shouldHandleCommand(growiCommand)) {
+      return this.registerService.processCommand(growiCommand, authorizeResult, body);
     }
 
     // unregister
-    if (growiCommand.growiCommandType === 'unregister') {
-      if (growiCommand.growiCommandArgs.length === 0) {
-        return 'GROWI Urls is required.';
-      }
-      if (!growiCommand.growiCommandArgs.every(v => v.match(/^(https?:\/\/)/))) {
-        return 'GROWI Urls must be urls.';
-      }
-
-      return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
+    if (this.unregisterService.shouldHandleCommand(growiCommand)) {
+      return this.unregisterService.processCommand(growiCommand, authorizeResult);
     }
 
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
@@ -181,7 +187,7 @@ export class SlackCtrl {
       .getMany();
 
     if (relations.length === 0) {
-      return res.json({
+      return respond(growiCommand.responseUrl, {
         blocks: [
           markdownSectionBlock('*No relation found.*'),
           markdownSectionBlock('Run `/growi register` first.'),
@@ -191,7 +197,7 @@ export class SlackCtrl {
 
     // status
     if (growiCommand.growiCommandType === 'status') {
-      return res.json({
+      return respond(growiCommand.responseUrl, {
         blocks: [
           markdownSectionBlock('*Found Relations to GROWI.*'),
           ...relations.map(relation => markdownSectionBlock(`GROWI url: ${relation.growiUri}`)),
@@ -199,11 +205,10 @@ export class SlackCtrl {
       });
     }
 
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.json({
-      response_type: 'ephemeral',
-      text: 'Processing your request ...',
+    await respond(growiCommand.responseUrl, {
+      blocks: [
+        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.text}"* ...`),
+      ],
     });
 
     const baseDate = new Date();
@@ -238,15 +243,32 @@ export class SlackCtrl {
 
     // when all of GROWI disallowed
     if (relations.length === disallowedGrowiUrls.size) {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const client = generateWebClient(authorizeResult.botToken!);
-      return postNotAllowedMessage(client, body.channel_id, body.user_id, disallowedGrowiUrls, growiCommand.growiCommandType);
+      const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
+        return '\n'
+          + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
+      });
+
+      const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
+
+      return respond(growiCommand.responseUrl, {
+        text: 'Command not permitted.',
+        blocks: [
+          markdownSectionBlock('*None of GROWI permitted the command.*'),
+          markdownSectionBlock(`*'${growiCommand.growiCommandType}'* command was not allowed.`),
+          markdownSectionBlock(
+            `To use this command, modify settings from following pages: ${linkUrlList}`,
+          ),
+          markdownSectionBlock(
+            `Or, if your GROWI version is 4.3.0 or below, upgrade GROWI to use commands and permission settings: ${growiDocsLink}`,
+          ),
+        ],
+      });
     }
 
     // select GROWI
     if (allowedRelationsForSingleUse.length > 0) {
       body.growiUrisForSingleUse = allowedRelationsForSingleUse.map(v => v.growiUri);
-      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
+      return this.selectGrowiService.processCommand(growiCommand, authorizeResult, body);
     }
 
     // forward to GROWI server
@@ -257,73 +279,51 @@ export class SlackCtrl {
 
 
   @Post('/interactions')
-  @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
+  @UseBefore(AddSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
   async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     logger.info('receive interaction', req.authorizeResult);
     logger.debug('receive interaction', req.body);
 
-    const { body, authorizeResult } = req;
+    const {
+      body, authorizeResult, interactionPayload, interactionPayloadAccessor,
+    } = req;
 
     // pass
     if (body.ssl_check != null) {
       return;
     }
-
-    const payload:any = JSON.parse(body.payload);
-    const callbackId:string = payload?.view?.callback_id;
-    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
-
-    // register
-    if (callbackId === 'register') {
-      try {
-        await this.registerService.insertOrderRecord(installation, authorizeResult.botToken, payload);
-      }
-      catch (err) {
-        if (err instanceof InvalidUrlError) {
-          logger.info(err.message);
-          return;
-        }
-        logger.error(err);
-      }
-
-      await this.registerService.notifyServerUriToSlack(authorizeResult.botToken, payload);
+    if (interactionPayload == null) {
       return;
     }
 
+    // register
+    const registerResult = await this.registerService.processInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
+    if (registerResult.isTerminated) return;
     // unregister
-    if (callbackId === 'unregister') {
-      await this.unregisterService.unregister(installation, authorizeResult, payload);
-      return;
-    }
-
-    let privateMeta:any;
-
-    if (payload.view != null) {
-      privateMeta = JSON.parse(payload?.view?.private_metadata);
-    }
-
-    const channelName = payload.channel?.name || privateMeta?.body?.channel_name || privateMeta?.channelName;
+    const unregisterResult = await this.unregisterService.processInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
+    if (unregisterResult.isTerminated) return;
 
-    // forward to GROWI server
-    if (callbackId === 'select_growi') {
-      // Send response immediately to avoid opelation_timeout error
-      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-      res.send();
+    // immediate response to slack
+    res.send();
 
-      const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
+    // select growi
+    const selectGrowiResult = await this.selectGrowiService.processInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
+    const selectedGrowiInformation = selectGrowiResult.result;
+    if (!selectGrowiResult.isTerminated && selectedGrowiInformation != null) {
       return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
     }
 
     // check permission
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
     const relations = await this.relationRepository.createQueryBuilder('relation')
       .where('relation.installationId = :id', { id: installation?.id })
       .leftJoinAndSelect('relation.installation', 'installation')
       .getMany();
 
     if (relations.length === 0) {
-      return res.json({
+      return respond(interactionPayloadAccessor.getResponseUrl(), {
         blocks: [
           markdownSectionBlock('*No relation found.*'),
           markdownSectionBlock('Run `/growi register` first.'),
@@ -331,24 +331,25 @@ export class SlackCtrl {
       });
     }
 
-    const actionId:string = payload?.actions?.[0].action_id;
+    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+
+    const privateMeta = interactionPayloadAccessor.getViewPrivateMetaData();
+
+    const channelName = interactionPayload.channel?.name || privateMeta?.body?.channel_name || privateMeta?.channelName;
     const permission = await this.relationsService.checkPermissionForInteractions(relations, actionId, callbackId, channelName);
     const {
       allowedRelations, disallowedGrowiUrls, commandName, rejectedResults,
     } = permission;
 
     try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      await postEphemeralErrors(rejectedResults, payload.channel.id, payload.user.id, authorizeResult.botToken!);
+      await respondRejectedErrors(rejectedResults, interactionPayloadAccessor.getResponseUrl());
     }
     catch (err) {
       logger.error(err);
     }
 
     if (relations.length === disallowedGrowiUrls.size) {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const client = generateWebClient(authorizeResult.botToken!);
-      return postNotAllowedMessage(client, payload.channel.id, payload.user.id, disallowedGrowiUrls, commandName);
+      return postNotAllowedMessage(interactionPayloadAccessor.getResponseUrl(), disallowedGrowiUrls, commandName);
     }
 
     /*

+ 0 - 6
packages/slackbot-proxy/src/interfaces/slack-to-growi/growi-command-processor.ts

@@ -1,6 +0,0 @@
-import { AuthorizeResult } from '@slack/oauth';
-import { GrowiCommand } from '@growi/slack';
-
-export interface GrowiCommandProcessor {
-  process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void>
-}

+ 2 - 1
packages/slackbot-proxy/src/interfaces/slack-to-growi/slack-oauth-req.ts

@@ -1,7 +1,8 @@
+import { RequestFromSlack } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 import { Req } from '@tsed/common';
 
-export type SlackOauthReq = Req & {
+export type SlackOauthReq = Req & RequestFromSlack & {
   authorizeResult: AuthorizeResult,
   growiUri?: string,
 };

+ 2 - 3
packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts

@@ -83,13 +83,12 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
     installerService: InstallerService;
 
     async use(@Req() req: SlackOauthReq, @Res() res:Res, @Next() next: Next): Promise<void|Res> {
-      const { body } = req;
 
-      if (body.payload == null) {
+      if (req.interactionPayload == null) {
         return next(createError(400, 'The request has no payload.'));
       }
 
-      const payload = JSON.parse(body.payload);
+      const payload = req.interactionPayload;
 
       // extract id from body.payload
       const teamId = payload.team?.id;

+ 6 - 8
packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts

@@ -19,23 +19,21 @@ export class ExtractGrowiUriFromReq implements IMiddleware {
   use(@Req() req: SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
 
     // There is no payload in the request from slack
-    if (req.body.payload == null) {
+    if (req.interactionPayload == null) {
       return next();
     }
 
-    const parsedPayload = JSON.parse(req.body.payload);
+    const payload = req.interactionPayload;
 
-    if (this.viewInteractionPayloadDelegator.shouldHandleToExtract(parsedPayload)) {
-      const data = this.viewInteractionPayloadDelegator.extract(parsedPayload);
+    if (this.viewInteractionPayloadDelegator.shouldHandleToExtract(payload)) {
+      const data = this.viewInteractionPayloadDelegator.extract(payload);
       req.growiUri = data.growiUri;
     }
-    else if (this.actionsBlockPayloadDelegator.shouldHandleToExtract(parsedPayload)) {
-      const data = this.actionsBlockPayloadDelegator.extract(parsedPayload);
+    else if (this.actionsBlockPayloadDelegator.shouldHandleToExtract(payload)) {
+      const data = this.actionsBlockPayloadDelegator.extract(payload);
       req.growiUri = data.growiUri;
     }
 
-    req.body.payload = JSON.stringify(parsedPayload);
-
     return next();
   }
 

+ 23 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/parse-interaction-req.ts

@@ -0,0 +1,23 @@
+import {
+  IMiddleware, Middleware, Next, Req,
+} from '@tsed/common';
+
+import { RequestFromSlack } from '@growi/slack';
+
+
+@Middleware()
+export class ParseInteractionPayloadMiddleare implements IMiddleware {
+
+  use(@Req() req: RequestFromSlack, @Next() next: Next): void {
+
+    // There is no payload in the request from slack
+    if (req.body.payload == null) {
+      return next();
+    }
+
+    req.interactionPayload = JSON.parse(req.body.payload);
+
+    return next();
+  }
+
+}

+ 98 - 38
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,32 +1,59 @@
 import { Inject, Service } from '@tsed/di';
-import { WebClient, LogLevel, Block } from '@slack/web-api';
 import {
-  markdownSectionBlock, markdownHeaderBlock, inputSectionBlock, GrowiCommand,
+  WebClient, LogLevel, Block, ConversationsSelect,
+} from '@slack/web-api';
+import {
+  markdownSectionBlock, markdownHeaderBlock, inputSectionBlock, GrowiCommand, inputBlock,
+  respond, GrowiCommandProcessor, GrowiInteractionProcessor,
+  getInteractionIdRegexpFromCommandName, InteractionHandledResult, InteractionPayloadAccessor,
 } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
-import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { OrderRepository } from '~/repositories/order';
-import { Installation } from '~/entities/installation';
 import { InvalidUrlError } from '../models/errors';
+import { InstallationRepository } from '~/repositories/installation';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('slackbot-proxy:services:RegisterService');
 
 const isProduction = process.env.NODE_ENV === 'production';
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
 
+export type RegisterCommandBody = {
+  // eslint-disable-next-line camelcase
+  trigger_id: string,
+  // eslint-disable-next-line camelcase
+  channel_name: string,
+}
+
 @Service()
-export class RegisterService implements GrowiCommandProcessor {
+export class RegisterService implements GrowiCommandProcessor<RegisterCommandBody>, GrowiInteractionProcessor<void> {
 
   @Inject()
   orderRepository: OrderRepository;
 
-  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void> {
+  @Inject()
+  installationRepository: InstallationRepository;
+
+  shouldHandleCommand(growiCommand: GrowiCommand): boolean {
+    return growiCommand.growiCommandType === 'register';
+  }
+
+  async processCommand(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, context: RegisterCommandBody): Promise<void> {
     const { botToken } = authorizeResult;
 
     const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+
+    const conversationsSelectElement: ConversationsSelect = {
+      action_id: 'conversation',
+      type: 'conversations_select',
+      response_url_enabled: true,
+      default_to_current_conversation: true,
+    };
     await client.views.open({
-      trigger_id: body.trigger_id,
+      trigger_id: context.trigger_id,
       view: {
         type: 'modal',
-        callback_id: 'register',
+        callback_id: 'register:register',
         title: {
           type: 'plain_text',
           text: 'Register Credentials',
@@ -39,9 +66,10 @@ export class RegisterService implements GrowiCommandProcessor {
           type: 'plain_text',
           text: 'Close',
         },
-        private_metadata: JSON.stringify({ channel: body.channel_name }),
+        private_metadata: JSON.stringify({ channel: context.channel_name }),
 
         blocks: [
+          inputBlock(conversationsSelectElement, 'conversation', 'Channel to which you want to add'),
           inputSectionBlock('growiUrl', 'GROWI domain', 'contents_input', false, 'https://example.com'),
           inputSectionBlock('tokenPtoG', 'Access Token Proxy to GROWI', 'contents_input', false, 'jBMZvpk.....'),
           inputSectionBlock('tokenGtoP', 'Access Token GROWI to Proxy', 'contents_input', false, 'sdg15av.....'),
@@ -50,45 +78,74 @@ export class RegisterService implements GrowiCommandProcessor {
     });
   }
 
-  async replyToSlack(client: WebClient, channel: string, user: string, text: string, blocks: Array<Block>): Promise<void> {
-    await client.chat.postEphemeral({
-      channel,
-      user,
-      // Recommended including 'text' to provide a fallback when using blocks
-      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
-      text,
-      blocks,
-    });
-    return;
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleInteraction(interactionPayloadAccessor: InteractionPayloadAccessor): boolean {
+    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const registerRegexp: RegExp = getInteractionIdRegexpFromCommandName('register');
+    return registerRegexp.test(actionId) || registerRegexp.test(callbackId);
+  }
+
+  async processInteraction(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): Promise<InteractionHandledResult<void>> {
+    const interactionHandledResult: InteractionHandledResult<void> = {
+      isTerminated: false,
+    };
+    if (!this.shouldHandleInteraction(interactionPayloadAccessor)) return interactionHandledResult;
+
+    interactionHandledResult.result = await this.handleRegisterInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
+    interactionHandledResult.isTerminated = true;
+
+    return interactionHandledResult as InteractionHandledResult<void>;
+  }
+
+  async handleRegisterInteraction(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): Promise<void> {
+    try {
+      await this.insertOrderRecord(authorizeResult, interactionPayloadAccessor);
+    }
+    catch (err) {
+      if (err instanceof InvalidUrlError) {
+        logger.error('Failed to register:\n', err);
+        await respond(interactionPayloadAccessor.getResponseUrl(), {
+          text: 'Invalid URL',
+          blocks: [
+            markdownSectionBlock('Please enter a valid URL'),
+          ],
+        });
+        return;
+      }
+
+      logger.error('Error occurred while insertOrderRecord:\n', err);
+    }
+
+    await this.notifyServerUriToSlack(interactionPayloadAccessor);
   }
 
   async insertOrderRecord(
-      installation: Installation | undefined,
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-      botToken: string | undefined, payload: any,
+      authorizeResult: AuthorizeResult, interactionPayloadAccessor: InteractionPayloadAccessor,
   ): Promise<void> {
-    const inputValues = payload.view.state.values;
+    const inputValues = interactionPayloadAccessor.getStateValues();
     const growiUrl = inputValues.growiUrl.contents_input.value;
     const tokenPtoG = inputValues.tokenPtoG.contents_input.value;
     const tokenGtoP = inputValues.tokenGtoP.contents_input.value;
 
-    const { channel } = JSON.parse(payload.view.private_metadata);
-
-    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
-
     try {
       // eslint-disable-next-line @typescript-eslint/no-unused-vars
       const url = new URL(growiUrl);
     }
     catch (error) {
-      const invalidErrorMsg = 'Please enter a valid URL';
-      const blocks = [
-        markdownSectionBlock(invalidErrorMsg),
-      ];
-      await this.replyToSlack(client, channel, payload.user.id, 'Invalid URL', blocks);
       throw new InvalidUrlError(growiUrl);
     }
 
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
+
     this.orderRepository.save({
       installation, growiUrl, tokenPtoG, tokenGtoP,
     });
@@ -96,14 +153,11 @@ export class RegisterService implements GrowiCommandProcessor {
 
   async notifyServerUriToSlack(
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-      botToken: string | undefined, payload: any,
+      interactionPayloadAccessor: InteractionPayloadAccessor,
   ): Promise<void> {
 
-    const { channel } = JSON.parse(payload.view.private_metadata);
-
     const serverUri = process.env.SERVER_URI;
-
-    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     const blocks: Block[] = [];
 
@@ -115,7 +169,10 @@ export class RegisterService implements GrowiCommandProcessor {
       blocks.push(markdownSectionBlock('*Test Connection* to complete the registration in your GROWI.'));
       blocks.push(markdownHeaderBlock(':white_large_square: 4. (Opt) Manage GROWI commands'));
       blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
-      await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
+      await respond(responseUrl, {
+        text: 'Proxy URL',
+        blocks,
+      });
       return;
 
     }
@@ -131,7 +188,10 @@ export class RegisterService implements GrowiCommandProcessor {
     blocks.push(markdownSectionBlock('And *Test Connection* to complete the registration in your GROWI.'));
     blocks.push(markdownHeaderBlock(':white_large_square: 6. (Opt) Manage GROWI commands'));
     blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
-    await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
+    await respond(responseUrl, {
+      text: 'Proxy URL',
+      blocks,
+    });
     return;
   }
 

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

@@ -28,7 +28,6 @@ type CheckEachRelationResult = {
 export class RelationsService {
 
   @Inject()
-
   relationRepository: RelationRepository;
 
   async getSupportedGrowiCommands(relation:Relation):Promise<any> {

+ 183 - 63
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -1,98 +1,195 @@
 import { Inject, Service } from '@tsed/di';
 
-import { GrowiCommand, generateWebClient } from '@growi/slack';
+import {
+  getInteractionIdRegexpFromCommandName,
+  GrowiCommand, GrowiCommandProcessor, GrowiInteractionProcessor,
+  InteractionHandledResult, markdownSectionBlock, replaceOriginal, respond, InteractionPayloadAccessor,
+} from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 
-import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { Installation } from '~/entities/installation';
 import { Relation } from '~/entities/relation';
 import { RelationRepository } from '~/repositories/relation';
+import { InstallationRepository } from '~/repositories/installation';
+import loggerFactory from '~/utils/logger';
 
+const logger = loggerFactory('slackbot-proxy:services:UnregisterService');
+
+export type SelectGrowiCommandBody = {
+  growiUrisForSingleUse: string[],
+}
+
+type SelectValue = {
+  growiCommand: GrowiCommand,
+  growiUri: any,
+}
+
+type SendCommandBody = {
+  // eslint-disable-next-line camelcase
+  trigger_id: string,
+  // eslint-disable-next-line camelcase
+  channel_name: string,
+}
 
 export type SelectedGrowiInformation = {
   relation: Relation,
   growiCommand: GrowiCommand,
-  sendCommandBody: any,
+  sendCommandBody: SendCommandBody,
 }
 
 @Service()
-export class SelectGrowiService implements GrowiCommandProcessor {
+export class SelectGrowiService implements GrowiCommandProcessor<SelectGrowiCommandBody | null>, GrowiInteractionProcessor<SelectedGrowiInformation> {
 
   @Inject()
   relationRepository: RelationRepository;
 
-  // eslint-disable-next-line max-len
-  async process(growiCommand: GrowiCommand | string, authorizeResult: AuthorizeResult, body: {[key:string]:string } & {growiUrisForSingleUse:string[]}): Promise<void> {
-    const { botToken } = authorizeResult;
+  @Inject()
+  installationRepository: InstallationRepository;
 
-    if (botToken == null) {
-      throw new Error('botToken is required.');
-    }
+  private generateGrowiSelectValue(growiCommand: GrowiCommand, growiUri: string): SelectValue {
+    return {
+      growiCommand,
+      growiUri,
+    };
+  }
 
-    const client = generateWebClient(botToken);
+  shouldHandleCommand(): boolean {
+    // TODO: consider to use the default supported commands for single use
+    return true;
+  }
 
-    await client.views.open({
-      trigger_id: body.trigger_id,
-      view: {
-        type: 'modal',
-        callback_id: 'select_growi',
-        title: {
-          type: 'plain_text',
-          text: 'Select GROWI Url',
+  async processCommand(
+      growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, context: SelectGrowiCommandBody,
+  ): Promise<void> {
+    const growiUrls = context.growiUrisForSingleUse;
+
+    const chooseSection = growiUrls.map((growiUri) => {
+      const value = this.generateGrowiSelectValue(growiCommand, growiUri);
+      return ({
+        type: 'section',
+        text: {
+          type: 'mrkdwn',
+          text: growiUri,
         },
-        submit: {
-          type: 'plain_text',
-          text: 'Submit',
-        },
-        close: {
-          type: 'plain_text',
-          text: 'Close',
+        accessory: {
+          type: 'button',
+          action_id: 'select_growi:select_growi',
+          text: {
+            type: 'plain_text',
+            text: 'Choose',
+          },
+          value: JSON.stringify(value),
         },
-        private_metadata: JSON.stringify({ body, growiCommand }),
+      });
+    });
 
-        blocks: [
-          {
-            type: 'input',
-            block_id: 'select_growi',
-            label: {
-              type: 'plain_text',
-              text: 'GROWI App',
-            },
-            element: {
-              type: 'static_select',
-              action_id: 'growi_app',
-              options: body.growiUrisForSingleUse.map((growiUri) => {
-                return ({
-                  text: {
-                    type: 'plain_text',
-                    text: growiUri,
-                  },
-                  value: growiUri,
-                });
-              }),
-            },
+    return respond(growiCommand.responseUrl, {
+      blocks: [
+        {
+          type: 'header',
+          text: {
+            type: 'plain_text',
+            text: 'Select target GROWI',
           },
-        ],
-      },
+        },
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `Request: \`/growi ${growiCommand.text}\` to:`,
+            },
+          ],
+        },
+        {
+          type: 'divider',
+        },
+        ...chooseSection,
+      ],
     });
+
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async handleSelectInteraction(installation:Installation | undefined, payload:any): Promise<SelectedGrowiInformation> {
-    const { trigger_id: triggerId } = payload;
-    const { state, private_metadata: privateMetadata } = payload?.view;
-    const { value: growiUri } = state?.values?.select_growi?.growi_app?.selected_option;
+  shouldHandleInteraction(interactionPayloadAccessor: InteractionPayloadAccessor): boolean {
+    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const registerRegexp: RegExp = getInteractionIdRegexpFromCommandName('select_growi');
+    return registerRegexp.test(actionId) || registerRegexp.test(callbackId);
+  }
 
-    const parsedPrivateMetadata = JSON.parse(privateMetadata);
-    const { growiCommand, body: sendCommandBody } = parsedPrivateMetadata;
+  async processInteraction(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): Promise<InteractionHandledResult<SelectedGrowiInformation>> {
+    const interactionHandledResult: InteractionHandledResult<SelectedGrowiInformation> = {
+      isTerminated: false,
+    };
+    if (!this.shouldHandleInteraction(interactionPayloadAccessor)) return interactionHandledResult;
+
+    const selectGrowiInformation = await this.handleSelectInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
+    if (selectGrowiInformation != null) {
+      interactionHandledResult.result = selectGrowiInformation;
+    }
+    interactionHandledResult.isTerminated = false;
 
-    if (growiCommand == null || sendCommandBody == null) {
-      // TODO: postEphemeralErrors
-      throw new Error('growiCommand and body params are required in private_metadata.');
+    return interactionHandledResult as InteractionHandledResult<SelectedGrowiInformation>;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async handleSelectInteraction(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): Promise<SelectedGrowiInformation | null> {
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+
+    const selectGrowiValue = interactionPayloadAccessor.firstAction()?.value;
+    if (selectGrowiValue == null) {
+      logger.error('Growi command failed: The first action element must have the value parameter.');
+      await respond(responseUrl, {
+        text: 'Growi command failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while processing GROWI command.'),
+        ],
+      });
+      return null;
+    }
+    const { growiUri, growiCommand } = JSON.parse(selectGrowiValue);
+
+
+    if (growiCommand == null) {
+      logger.error('Growi command failed: The first action value must have growiCommand parameter.');
+      await respond(responseUrl, {
+        text: 'Growi command failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while processing GROWI command.'),
+        ],
+      });
+      return null;
     }
 
-    // ovverride trigger_id
-    sendCommandBody.trigger_id = triggerId;
+    await replaceOriginal(responseUrl, {
+      text: `Accepted ${growiCommand.growiCommandType} command.`,
+      blocks: [
+        markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${growiUri} ...`),
+      ],
+    });
+
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    let installation: Installation | undefined;
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
+    }
+    catch (err) {
+      logger.error('Growi command failed: No installation found.\n', err);
+      await respond(responseUrl, {
+        text: 'Growi command failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while processing GROWI command.'),
+        ],
+      });
+      return null;
+    }
 
     const relation = await this.relationRepository.createQueryBuilder('relation')
       .where('relation.growiUri =:growiUri', { growiUri })
@@ -101,9 +198,32 @@ export class SelectGrowiService implements GrowiCommandProcessor {
       .getOne();
 
     if (relation == null) {
-      // TODO: postEphemeralErrors
-      throw new Error('No relation found.');
+      logger.error('Growi command failed: No installation found.');
+      await respond(responseUrl, {
+        text: 'Growi command failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while processing GROWI command.'),
+        ],
+      });
+      return null;
+    }
+
+    // increment sendCommandBody
+    const channelName = interactionPayloadAccessor.getChannelName();
+    if (channelName == null) {
+      logger.error('Growi command failed: channelName not found.');
+      await respond(responseUrl, {
+        text: 'Growi command failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while processing GROWI command.'),
+        ],
+      });
+      return null;
     }
+    const sendCommandBody: SendCommandBody = {
+      trigger_id: interactionPayload.trigger_id,
+      channel_name: channelName,
+    };
 
     return {
       relation,

+ 163 - 48
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -1,75 +1,190 @@
+import axios from 'axios';
 import { Inject, Service } from '@tsed/di';
-import { WebClient, LogLevel } from '@slack/web-api';
-import { GrowiCommand, markdownSectionBlock } from '@growi/slack';
+import { MultiStaticSelect } from '@slack/web-api';
+import {
+  actionsBlock, buttonElement, getInteractionIdRegexpFromCommandName,
+  GrowiCommand, GrowiCommandProcessor, GrowiInteractionProcessor,
+  inputBlock, InteractionHandledResult, markdownSectionBlock, respond, InteractionPayloadAccessor, replaceOriginal,
+} from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
-import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
+import { DeleteResult } from 'typeorm';
 import { RelationRepository } from '~/repositories/relation';
 import { Installation } from '~/entities/installation';
+import { InstallationRepository } from '~/repositories/installation';
+import loggerFactory from '~/utils/logger';
 
-const isProduction = process.env.NODE_ENV === 'production';
+const logger = loggerFactory('slackbot-proxy:services:UnregisterService');
 
 @Service()
-export class UnregisterService implements GrowiCommandProcessor {
+export class UnregisterService implements GrowiCommandProcessor, GrowiInteractionProcessor<void> {
 
   @Inject()
   relationRepository: RelationRepository;
 
-  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void> {
-    const { botToken } = authorizeResult;
-    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
-    const growiUrls = growiCommand.growiCommandArgs;
-    await client.views.open({
-      trigger_id: body.trigger_id,
-      view: {
-        type: 'modal',
-        callback_id: 'unregister',
-        title: {
-          type: 'plain_text',
-          text: 'Unregister Credentials',
-        },
-        submit: {
-          type: 'plain_text',
-          text: 'Submit',
-        },
-        close: {
-          type: 'plain_text',
-          text: 'Close',
-        },
-        private_metadata: JSON.stringify({ channel: body.channel_name, growiUrls }),
+  @Inject()
+  installationRepository: InstallationRepository;
+
+  shouldHandleCommand(growiCommand: GrowiCommand): boolean {
+    return growiCommand.growiCommandType === 'unregister';
+  }
+
+  async processCommand(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult): Promise<void> {
+    // get growi urls
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
+    const relations = await this.relationRepository.createQueryBuilder('relation')
+      .where('relation.installationId = :id', { id: installation?.id })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getMany();
 
+    if (relations.length === 0) {
+      await respond(growiCommand.responseUrl, {
+        text: 'No GROWI found to unregister.',
         blocks: [
-          ...growiUrls.map(growiCommandArg => markdownSectionBlock(`GROWI url: ${growiCommandArg}.`)),
+          markdownSectionBlock('You haven\'t registered any GROWI to this workspace.'),
+          markdownSectionBlock('Send `/growi register` to register.'),
         ],
+      });
+      return;
+    }
+
+    const staticSelectElement: MultiStaticSelect = {
+      action_id: 'selectedGrowiUris',
+      type: 'multi_static_select',
+      placeholder: {
+        type: 'plain_text',
+        text: 'Select GROWI URLs to unregister',
       },
+      options: relations.map((relation) => {
+        return {
+          text: {
+            type: 'plain_text',
+            text: relation.growiUri,
+          },
+          value: relation.growiUri,
+        };
+      }),
+    };
+
+    await respond(growiCommand.responseUrl, {
+      text: 'Select GROWI URLs to unregister.',
+      blocks: [
+        inputBlock(staticSelectElement, 'growiUris', 'GROWI URL to unregister'),
+        actionsBlock(
+          buttonElement({ text: 'Cancel', actionId: 'unregister:cancel', value: JSON.stringify({}) }),
+          buttonElement({
+            text: 'Unregister', actionId: 'unregister:unregister', style: 'danger', value: JSON.stringify({}),
+          }),
+        ),
+      ],
     });
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  async unregister(installation: Installation | undefined, authorizeResult: AuthorizeResult, payload: any):Promise<void> {
-    const { botToken } = authorizeResult;
-    const { channel, growiUrls } = JSON.parse(payload.view.private_metadata);
-    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
-
-    const deleteResult = await this.relationRepository.createQueryBuilder('relation')
-      .where('relation.growiUri IN (:uris)', { uris: growiUrls })
-      .andWhere('relation.installationId = :installationId', { installationId: installation?.id })
-      .delete()
-      .execute();
-
-    await client.chat.postEphemeral({
-      channel,
-      user: payload.user.id,
-      // Recommended including 'text' to provide a fallback when using blocks
-      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
-      text: 'Delete Relations',
+  shouldHandleInteraction(interactionPayloadAccessor: InteractionPayloadAccessor): boolean {
+    const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+    const registerRegexp: RegExp = getInteractionIdRegexpFromCommandName('unregister');
+    return registerRegexp.test(actionId) || registerRegexp.test(callbackId);
+  }
+
+  async processInteraction(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): Promise<InteractionHandledResult<void>> {
+    const interactionHandledResult: InteractionHandledResult<void> = {
+      isTerminated: false,
+    };
+    if (!this.shouldHandleInteraction(interactionPayloadAccessor)) return interactionHandledResult;
+
+    const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
+
+    switch (actionId) {
+      case 'unregister:unregister':
+        interactionHandledResult.result = await this.handleUnregisterInteraction(authorizeResult, interactionPayload, interactionPayloadAccessor);
+        break;
+      case 'unregister:cancel':
+        interactionHandledResult.result = await this.handleUnregisterCancelInteraction(interactionPayloadAccessor);
+        break;
+      default:
+        logger.error('This unregister interaction is not implemented.');
+        break;
+    }
+    interactionHandledResult.isTerminated = true;
+
+    return interactionHandledResult as InteractionHandledResult<void>;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async handleUnregisterInteraction(
+      // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+      authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor,
+  ):Promise<void> {
+    const responseUrl = interactionPayloadAccessor.getResponseUrl();
+
+    const selectedOptions = interactionPayloadAccessor.getStateValues()?.growiUris?.selectedGrowiUris?.selected_options;
+    if (!Array.isArray(selectedOptions)) {
+      logger.error('Unregisteration failed: Mulformed object was detected\n');
+      await respond(responseUrl, {
+        text: 'Unregistration failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while unregistering GROWI.'),
+        ],
+      });
+      return;
+    }
+    const growiUris = selectedOptions.map(selectedOption => selectedOption.value);
+
+    const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
+    let installation: Installation | undefined;
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
+    }
+    catch (err) {
+      logger.error('Unregisteration failed:\n', err);
+      await respond(responseUrl, {
+        text: 'Unregistration failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while unregistering GROWI.'),
+        ],
+      });
+      return;
+    }
+
+    let deleteResult: DeleteResult;
+    try {
+      deleteResult = await this.relationRepository.createQueryBuilder('relation')
+        .where('relation.growiUri IN (:uris)', { uris: growiUris })
+        .andWhere('relation.installationId = :installationId', { installationId: installation?.id })
+        .delete()
+        .execute();
+    }
+    catch (err) {
+      logger.error('Unregisteration failed\n', err);
+      await respond(responseUrl, {
+        text: 'Unregistration failed',
+        blocks: [
+          markdownSectionBlock('Error occurred while unregistering GROWI.'),
+        ],
+      });
+      return;
+    }
+
+    await replaceOriginal(responseUrl, {
+      text: 'Unregistration completed',
       blocks: [
-        markdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
+        markdownSectionBlock(`Unregistered *${deleteResult.affected}* GROWI from this workspace.`),
       ],
     });
-
     return;
-
   }
 
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async handleUnregisterCancelInteraction(interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
+    await axios.post(interactionPayloadAccessor.getResponseUrl(), {
+      delete_original: true,
+    });
+  }
 
 }

+ 5 - 2
packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts

@@ -33,8 +33,11 @@ export class ButtonActionPayloadDelegator implements GrowiUriInjector<TypedBlock
   }
 
   extract(action: ButtonActionPayload): GrowiUriWithOriginalData {
-    const restoredData: GrowiUriWithOriginalData = JSON.parse(action.value);
-    action.value = restoredData.originalData;
+    const restoredData: GrowiUriWithOriginalData = JSON.parse(action.value || '{}');
+
+    if (restoredData.originalData != null) {
+      action.value = restoredData.originalData;
+    }
 
     return restoredData;
   }