Explorar o código

Merge pull request #4119 from weseek/imprv/slackbot-arch

Imprv/slackbot arch
Yuki Takei %!s(int64=4) %!d(string=hai) anos
pai
achega
9dd6ec6e9c

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

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

+ 17 - 60
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -9,6 +9,7 @@ const { verifySlackRequest, generateWebClient } = require('@growi/slack');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
 
 module.exports = (crowi) => {
   this.app = crowi.express;
@@ -104,11 +105,12 @@ module.exports = (crowi) => {
     const command = args[0];
 
     try {
-      await crowi.slackBotService.handleCommand(command, client, body, args);
+      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
     }
-    catch (error) {
-      logger.error(error);
+    catch (err) {
+      await respondIfSlackbotError(client, body, err);
     }
+
   }
 
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
@@ -127,61 +129,6 @@ module.exports = (crowi) => {
     return handleCommands(req, res);
   });
 
-
-  const handleBlockActions = async(client, payload) => {
-    const { action_id: actionId } = payload.actions[0];
-
-    switch (actionId) {
-      case 'shareSingleSearchResult': {
-        await crowi.slackBotService.shareSinglePage(client, payload);
-        break;
-      }
-      case 'dismissSearchResults': {
-        await crowi.slackBotService.dismissSearchResults(client, payload);
-        break;
-      }
-      case 'showNextResults': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-
-        const { body, args, offset } = parsedValue;
-        const newOffset = offset + 10;
-        await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
-        break;
-      }
-      case 'togetterShowMore': {
-        const parsedValue = JSON.parse(payload.actions[0].value);
-        const togetterHandler = require('../../service/slack-command-handler/togetter')(crowi);
-
-        const { body, args, limit } = parsedValue;
-        const newLimit = limit + 1;
-        await togetterHandler.handleCommand(client, body, args, newLimit);
-        break;
-      }
-      case 'togetter:createPage': {
-        await crowi.slackBotService.togetterCreatePageInGrowi(client, payload);
-        break;
-      }
-      case 'togetter:cancel': {
-        await crowi.slackBotService.togetterCancel(client, payload);
-        break;
-      }
-      default:
-        break;
-    }
-  };
-
-  const handleViewSubmission = async(client, payload) => {
-    const { callback_id: callbackId } = payload.view;
-
-    switch (callbackId) {
-      case 'createPage':
-        await crowi.slackBotService.createPageInGrowi(client, payload);
-        break;
-      default:
-        break;
-    }
-  };
-
   async function handleInteractions(req, res) {
 
     // Send response immediately to avoid opelation_timeout error
@@ -206,10 +153,20 @@ module.exports = (crowi) => {
     try {
       switch (type) {
         case 'block_actions':
-          await handleBlockActions(client, payload);
+          try {
+            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
         case 'view_submission':
-          await handleViewSubmission(client, payload);
+          try {
+            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+          }
+          catch (err) {
+            await respondIfSlackbotError(client, req.body, err);
+          }
           break;
         default:
           break;

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

@@ -0,0 +1,48 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:CreatePageService');
+const { reshapeContentsBody } = require('@growi/slack');
+const mongoose = require('mongoose');
+const pathUtils = require('growi-commons').pathUtils;
+const SlackbotError = require('../../models/vo/slackbot-error');
+
+class CreatePageService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async createPageInGrowi(client, payload, path, channelId, contentsBody) {
+    const Page = this.crowi.model('Page');
+    const reshapedContentsBody = reshapeContentsBody(contentsBody);
+    try {
+      // sanitize path
+      const sanitizedPath = this.crowi.xss.process(path);
+      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
+
+      // generate a dummy id because Operation to create a page needs ObjectId
+      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+
+      // Send a message when page creation is complete
+      const growiUri = this.crowi.appService.getSiteUrl();
+      await client.chat.postEphemeral({
+        channel: channelId,
+        user: payload.user.id,
+        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create page in GROWI.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Cannot create new page to existed path.',
+        mainMessage: `Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`,
+      });
+    }
+  }
+
+}
+
+module.exports = CreatePageService;

+ 37 - 38
packages/app/src/server/service/slack-command-handler/create.js

@@ -4,51 +4,50 @@ const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
 
 const logger = loggerFactory('growi:service:SlackCommandHandler:create');
 
-module.exports = () => {
+module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
   handler.handleCommand = async(client, body) => {
-    try {
-      await client.views.open({
-        trigger_id: body.trigger_id,
-
-        view: {
-          type: 'modal',
-          callback_id: 'createPage',
-          title: {
-            type: 'plain_text',
-            text: 'Create Page',
-          },
-          submit: {
-            type: 'plain_text',
-            text: 'Submit',
-          },
-          close: {
-            type: 'plain_text',
-            text: 'Cancel',
-          },
-          blocks: [
-            markdownSectionBlock('Create new page.'),
-            inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
-            inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
-          ],
-          private_metadata: JSON.stringify({ channelId: body.channel_id }),
+    await client.views.open({
+      trigger_id: body.trigger_id,
+
+      view: {
+        type: 'modal',
+        callback_id: 'create:createPage',
+        title: {
+          type: 'plain_text',
+          text: 'Create Page',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Cancel',
         },
-      });
-    }
-    catch (err) {
-      logger.error('Failed to create a page.');
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Create',
         blocks: [
-          markdownSectionBlock(`*Failed to create new page.*\n ${err}`),
+          markdownSectionBlock('Create new page.'),
+          inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
+          inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
-      });
-      throw err;
-    }
+        private_metadata: JSON.stringify({ channelId: body.channel_id }),
+      },
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  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);
   };
 
   return handler;

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

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

+ 198 - 29
packages/app/src/server/service/slack-command-handler/search.js

@@ -4,6 +4,8 @@ const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const { markdownSectionBlock, divider } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 const PAGINGLIMIT = 10;
 
@@ -18,19 +20,16 @@ module.exports = (crowi) => {
     }
     catch (err) {
       logger.error('Failed to get search results.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Search',
-        blocks: [
-          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
-        ],
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
-      throw new Error('/growi command:search: Failed to search');
     }
 
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
 
     const {
       pages, offset, resultsTotal,
@@ -83,7 +82,7 @@ module.exports = (crowi) => {
           },
           accessory: {
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
               type: 'plain_text',
               text: 'Share',
@@ -121,7 +120,7 @@ module.exports = (crowi) => {
             text: 'Dismiss',
           },
           style: 'danger',
-          action_id: 'dismissSearchResults',
+          action_id: 'search:dismissSearchResults',
         },
       ],
     };
@@ -134,33 +133,203 @@ module.exports = (crowi) => {
             type: 'plain_text',
             text: 'Next',
           },
-          action_id: 'showNextResults',
+          action_id: 'search:showNextResults',
           value: JSON.stringify({ offset, body, args }),
         },
       );
     }
     blocks.push(actionBlocks);
 
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.shareSinglePageResult = async function(client, payload) {
+    const { channel, user, actions } = payload;
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const channelId = channel.id;
+    const action = actions[0]; // shareSinglePage action must have button action
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(action.value);
+    const { updatedAt, commentCount } = page;
+
+    // share
+    const now = new Date();
+    return client.chat.postMessage({
+      channel: channelId,
+      blocks: [
+        { type: 'divider' },
+        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+            },
+          ],
+        },
+      ],
+    });
+  };
+
+  handler.showNextResults = async function(client, payload) {
+    const parsedValue = JSON.parse(payload.actions[0].value);
+
+    const { body, args, offsetNum } = parsedValue;
+    const newOffsetNum = offsetNum + 10;
+    let searchResult;
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      searchResult = await this.retrieveSearchResults(client, body, args, newOffsetNum);
     }
     catch (err) {
-      logger.error('Failed to post ephemeral message.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed to post ephemeral message.',
-        blocks: [
-          markdownSectionBlock(err.toString()),
-        ],
+      logger.error('Failed to get search results.', err);
+      throw new SlackbotError({
+        method: 'postEphemeral',
+        to: 'channel',
+        popupMessage: 'Failed To Search',
+        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
       });
-      throw new Error(err);
     }
+
+    const appUrl = crowi.appService.getSiteUrl();
+    const appTitle = crowi.appService.getAppTitle();
+
+    const {
+      pages, offset, resultsTotal,
+    } = searchResult;
+
+    const keywords = this.getKeywords(args);
+
+
+    let searchResultsDesc;
+
+    switch (resultsTotal) {
+      case 1:
+        searchResultsDesc = `*${resultsTotal}* page is found.`;
+        break;
+
+      default:
+        searchResultsDesc = `*${resultsTotal}* pages are found.`;
+        break;
+    }
+
+
+    const contextBlock = {
+      type: 'context',
+      elements: [
+        {
+          type: 'mrkdwn',
+          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+        },
+      ],
+    };
+
+    const now = new Date();
+    const blocks = [
+      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      contextBlock,
+      { type: 'divider' },
+      // create an array by map and extract
+      ...pages.map((page) => {
+        const { path, updatedAt, commentCount } = page;
+        // generate URL
+        const url = new URL(path, appUrl);
+        const { href, pathname } = url;
+
+        return {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+          },
+          accessory: {
+            type: 'button',
+            action_id: 'shareSingleSearchResult',
+            text: {
+              type: 'plain_text',
+              text: 'Share',
+            },
+            value: JSON.stringify({ page, href, pathname }),
+          },
+        };
+      }),
+      { type: 'divider' },
+      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: [
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Dismiss',
+          },
+          style: 'danger',
+          action_id: 'search:dismissSearchResults',
+        },
+      ],
+    };
+    // show "Next" button if next page exists
+    if (resultsTotal > offset + PAGINGLIMIT) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Next',
+          },
+          action_id: 'search:showNextResults',
+          value: JSON.stringify({ offset, body, args }),
+        },
+      );
+    }
+    blocks.push(actionBlocks);
+
+    await client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Successed To Search',
+      blocks,
+    });
+  };
+
+  handler.dismissSearchResults = async function(client, payload) {
+    const { response_url: responseUrl } = payload;
+
+    return axios.post(responseUrl, {
+      delete_original: true,
+    });
   };
 
   handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
@@ -179,7 +348,7 @@ module.exports = (crowi) => {
 
     const keywords = this.getKeywords(args);
 
-    const { searchService } = this.crowi;
+    const { searchService } = crowi;
     const options = { limit: 10, offset };
     const results = await searchService.searchKeyword(keywords, null, {}, options);
     const resultsTotal = results.meta.total;

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

@@ -10,6 +10,16 @@ class BaseSlackCommandHandler {
    */
   handleCommand(client, body, ...opt) { throw new Error('Implement this') }
 
+  /**
+   * Handle /interactions endpoint 'block_actions'
+   */
+  handleBlockActions(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
+  /**
+   * Handle /interactions endpoint 'view_submission'
+   */
+  handleViewSubmission(client, payload, handlerMethodName) { throw new Error('Implement this') }
+
 }
 
 module.exports = BaseSlackCommandHandler;

+ 173 - 2
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -1,9 +1,16 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:service:SlackBotService:togetter');
 const {
-  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } = require('@growi/slack');
-const { format } = require('date-fns');
+const { parse, format } = require('date-fns');
+const axios = require('axios');
+const SlackbotError = require('../../models/vo/slackbot-error');
 
 module.exports = (crowi) => {
+  const CreatePageService = require('./create-page-service');
+  const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
 
@@ -23,6 +30,170 @@ module.exports = (crowi) => {
     return;
   };
 
+  handler.handleBlockActions = async function(client, payload, handlerMethodName) {
+    await this[handlerMethodName](client, payload);
+  };
+
+  handler.cancel = async function(client, payload) {
+    const responseUrl = payload.response_url;
+    axios.post(responseUrl, {
+      delete_original: true,
+    });
+  };
+
+  handler.createPage = async function(client, payload) {
+    let result = [];
+    const channel = payload.channel.id;
+    try {
+      // validate form
+      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
+      // get messages
+      result = await this.togetterGetMessages(client, payload, channel, path, latest, 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);
+    }
+    catch (err) {
+      logger.error('Error occured by togetter.');
+      throw err;
+    }
+  };
+
+  handler.togetterValidateForm = async function(client, payload) {
+    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 latest = payload.state.values.latest.latest.value;
+    oldest = oldest.trim();
+    latest = latest.trim();
+    if (!path) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Page path is required.',
+        mainMessage: 'Page path is required.',
+      });
+    }
+    /**
+     * RegExp for datetime yyyy/MM/dd-HH:mm
+     * @see https://regex101.com/r/XbxdNo/1
+     */
+    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
+
+    if (!regexpDatetime.test(oldest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    if (!regexpDatetime.test(latest)) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+      });
+    }
+    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
+    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
+    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+
+    if (oldest > latest) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Oldest datetime must be older than the latest date time.',
+        mainMessage: 'Oldest datetime must be older than the latest date time.',
+      });
+    }
+
+    return { path, oldest, latest };
+  };
+
+  handler.togetterGetMessages = async function(client, payload, channel, path, latest, oldest) {
+    const result = await client.conversations.history({
+      channel,
+      latest,
+      oldest,
+      limit: 100,
+      inclusive: true,
+    });
+
+    // return if no message found
+    if (!result.messages.length) {
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'No message found from togetter command. Try different datetime.',
+        mainMessage: 'No message found from togetter command. Try different datetime.',
+      });
+    }
+    return result;
+  };
+
+  handler.togetterCleanMessages = async function(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    messages
+      .sort((a, b) => {
+        return a.ts - b.ts;
+      })
+      .forEach((message) => {
+        // increment contentsBody while removing the same headers
+        // exclude header
+        const lastMessageTs = Math.floor(lastMessage.ts / 60);
+        const messageTs = Math.floor(message.ts / 60);
+        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
+          cleanedContents.push(`${message.text}\n`);
+        }
+        // include header
+        else {
+          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
+          const time = format(new Date(ts), 'h:mm a');
+          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
+          lastMessage = message;
+        }
+      });
+    return cleanedContents;
+  };
+
+  handler.togetterCreatePageAndSendPreview = async function(client, payload, path, channel, contentsBody) {
+    try {
+      await createPageService.createPageInGrowi(client, payload, path, channel, contentsBody);
+      // send preview to dm
+      await client.chat.postMessage({
+        channel: payload.user.id,
+        text: 'Preview from togetter command',
+        blocks: [
+          markdownSectionBlock('*Preview*'),
+          divider(),
+          markdownSectionBlock(contentsBody),
+          divider(),
+        ],
+      });
+      // dismiss message
+      const responseUrl = payload.response_url;
+      axios.post(responseUrl, {
+        delete_original: true,
+      });
+    }
+    catch (err) {
+      logger.error('Error occurred while creating a page.', err);
+      throw new SlackbotError({
+        method: 'postMessage',
+        to: 'dm',
+        popupMessage: 'Error occurred while creating a page.',
+        mainMessage: 'Error occurred while creating a page.',
+      });
+    }
+  };
+
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
     return [
       markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),

+ 35 - 461
packages/app/src/server/service/slackbot.ts

@@ -1,19 +1,13 @@
-
 import loggerFactory from '~/utils/logger';
 import { S2sMessagingService } from './s2s-messaging/base';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:SlackBotService');
-const mongoose = require('mongoose');
-const axios = require('axios');
 
-const { markdownSectionBlock, divider } = require('@growi/slack');
-const { reshapeContentsBody } = require('@growi/slack');
-const { formatDistanceStrict, parse, format } = require('date-fns');
+const { markdownSectionBlock } = require('@growi/slack');
 
 const S2sMessage = require('../models/vo/s2s-message');
 
-const PAGINGLIMIT = 10;
 
 class SlackBotService implements S2sMessageHandlable {
 
@@ -76,485 +70,65 @@ class SlackBotService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommand(command, client, body, ...opt) {
-    const module = `./slack-command-handler/${command}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(client, body, ...opt);
-    }
-    catch (err) {
-      this.notCommand(client, body);
-    }
-  }
-
-  async notCommand(client, body) {
-    logger.error('Invalid first argument');
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'No command',
-      blocks: [
-        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    return;
-  }
-
-  generatePageLinkMrkdwn(pathname, href) {
-    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
-  }
-
-  appendSpeechBaloon(mrkdwn, commentCount) {
-    return (commentCount != null && commentCount > 0)
-      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
-      : mrkdwn;
-  }
-
-  generateLastUpdateMrkdwn(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
-  }
-
-
-  async shareSinglePage(client, payload) {
-    const { channel, user, actions } = payload;
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
-
-    const channelId = channel.id;
-    const action = actions[0]; // shareSinglePage action must have button action
-
-    // restore page data from value
-    const { page, href, pathname } = JSON.parse(action.value);
-    const { updatedAt, commentCount } = page;
-
-    // share
-    const now = new Date();
-    return client.chat.postMessage({
-      channel: channelId,
-      blocks: [
-        { type: 'divider' },
-        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
-        {
-          type: 'context',
-          elements: [
-            {
-              type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
-            },
-          ],
-        },
-      ],
-    });
-  }
-
-  async dismissSearchResults(client, payload) {
-    const { response_url: responseUrl } = payload;
-
-    return axios.post(responseUrl, {
-      delete_original: true,
-    });
-  }
-
-  async showEphemeralSearchResults(client, body, args, offsetNum) {
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed To Search',
-        blocks: [
-          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      throw new Error('/growi command:search: Failed to search');
-    }
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    const appTitle = this.crowi.appService.getAppTitle();
-
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
-
-    const keywords = this.getKeywords(args);
-
-
-    let searchResultsDesc;
-
-    switch (resultsTotal) {
-      case 1:
-        searchResultsDesc = `*${resultsTotal}* page is found.`;
-        break;
-
-      default:
-        searchResultsDesc = `*${resultsTotal}* pages are found.`;
-        break;
-    }
-
-
-    const contextBlock = {
-      type: 'context',
-      elements: [
-        {
-          type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
-        },
-      ],
-    };
-
-    const now = new Date();
-    const blocks = [
-      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
-      contextBlock,
-      { type: 'divider' },
-      // create an array by map and extract
-      ...pages.map((page) => {
-        const { path, updatedAt, commentCount } = page;
-        // generate URL
-        const url = new URL(path, appUrl);
-        const { href, pathname } = url;
-
-        return {
-          type: 'section',
-          text: {
-            type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
-          },
-          accessory: {
-            type: 'button',
-            action_id: 'shareSingleSearchResult',
-            text: {
-              type: 'plain_text',
-              text: 'Share',
-            },
-            value: JSON.stringify({ page, href, pathname }),
-          },
-        };
-      }),
-      { type: 'divider' },
-      contextBlock,
-    ];
-
-    // DEFAULT show "Share" button
-    // const actionBlocks = {
-    //   type: 'actions',
-    //   elements: [
-    //     {
-    //       type: 'button',
-    //       text: {
-    //         type: 'plain_text',
-    //         text: 'Share',
-    //       },
-    //       style: 'primary',
-    //       action_id: 'shareSearchResults',
-    //     },
-    //   ],
-    // };
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const actionBlocks: any = {
-      type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'dismissSearchResults',
-        },
-      ],
-    };
-    // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
-      actionBlocks.elements.unshift(
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Next',
-          },
-          action_id: 'showNextResults',
-          value: JSON.stringify({ offset, body, args }),
-        },
-      );
-    }
-    blocks.push(actionBlocks);
-
+  async handleCommandRequest(command, client, body, ...opt) {
+    let module;
     try {
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Successed To Search',
-        blocks,
-      });
+      module = `./slack-command-handler/${command}`;
     }
     catch (err) {
-      logger.error('Failed to post ephemeral message.', err);
-      await client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Failed to post ephemeral message.',
-        blocks: [
-          markdownSectionBlock(err.toString()),
-        ],
-      });
-      throw new Error(err);
+      await this.notCommand(client, body);
     }
-  }
-
-  async retrieveSearchResults(client, body, args, offset = 0) {
-    const firstKeyword = args[1];
-    if (firstKeyword == null) {
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: 'Input keywords',
-        blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      return;
-    }
-
-    const keywords = this.getKeywords(args);
-
-    const { searchService } = this.crowi;
-    const options = { limit: 10, offset };
-    const results = await searchService.searchKeyword(keywords, null, {}, options);
-    const resultsTotal = results.meta.total;
-
-    // no search results
-    if (results.data.length === 0) {
-      logger.info(`No page found with "${keywords}"`);
-      client.chat.postEphemeral({
-        channel: body.channel_id,
-        user: body.user_id,
-        text: `No page found with "${keywords}"`,
-        blocks: [
-          markdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
-          markdownSectionBlock(':mag: *Help: Searching*'),
-          divider(),
-          markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
-          divider(),
-          markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
-          divider(),
-          markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
-          divider(),
-          markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
-          divider(),
-          markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
-          divider(),
-          markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
-        ],
-      });
-      return { pages: [] };
-    }
-
-    const pages = results.data.map((data) => {
-      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
-      return { path, updatedAt, commentCount };
-    });
 
-    return {
-      pages, offset, resultsTotal,
-    };
-  }
-
-  getKeywords(args) {
-    const keywordsArr = args.slice(1);
-    const keywords = keywordsArr.join(' ');
-    return keywords;
-  }
-
-  // Submit action in create Modal
-  async createPage(client, payload, path, channelId, contentsBody) {
-    const Page = this.crowi.model('Page');
-    const pathUtils = require('growi-commons').pathUtils;
-    const reshapedContentsBody = reshapeContentsBody(contentsBody);
     try {
-      // sanitize path
-      const sanitizedPath = this.crowi.xss.process(path);
-      const normalizedPath = pathUtils.normalizePath(sanitizedPath);
-
-      // generate a dummy id because Operation to create a page needs ObjectId
-      const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
-      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
-
-      // Send a message when page creation is complete
-      const growiUri = this.crowi.appService.getSiteUrl();
-      await client.chat.postEphemeral({
-        channel: channelId,
-        user: payload.user.id,
-        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
-      });
+      const handler = require(module)(this.crowi);
+      await handler.handleCommand(client, body, ...opt);
     }
     catch (err) {
-      client.chat.postMessage({
-        channel: payload.user.id,
-        blocks: [
-          markdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`)],
-      });
-      logger.error('Failed to create page in GROWI.');
       throw err;
     }
   }
 
-  async createPageInGrowi(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 this.createPage(client, payload, path, channelId, contentsBody);
-  }
-
-  async togetterCreatePageInGrowi(client, payload) {
-    let result = [];
-    const channel = payload.channel.id;
+  async handleBlockActionsRequest(client, payload) {
+    const { action_id: actionId } = payload.actions[0];
+    const commandName = actionId.split(':')[0];
+    const handlerMethodName = actionId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
     try {
-      // validate form
-      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
-      // get messages
-      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
-      // clean messages
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const cleanedContents = await this.togetterCleanMessages((result as any).messages);
-
-      const contentsBody = cleanedContents.join('');
-      // create and send url message
-      await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
     }
     catch (err) {
-      await client.chat.postMessage({
-        channel: payload.user.id,
-        text: err.message,
-        blocks: [
-          markdownSectionBlock(err.message),
-        ],
-      });
-      return;
-    }
-  }
-
-  async togetterGetMessages(client, payload, channel, path, latest, oldest) {
-    const result = await client.conversations.history({
-      channel,
-      latest,
-      oldest,
-      limit: 100,
-      inclusive: true,
-    });
-
-    // return if no message found
-    if (!result.messages.length) {
-      throw new Error('No message found from togetter command. Try again.');
-    }
-    return result;
-  }
-
-  async togetterValidateForm(client, payload) {
-    const grwTzoffset = this.crowi.appService.getTzoffset() * 60;
-    const path = payload.state.values.page_path.page_path.value;
-    let oldest = payload.state.values.oldest.oldest.value;
-    let latest = payload.state.values.latest.latest.value;
-    oldest = oldest.trim();
-    latest = latest.trim();
-    if (!path) {
-      throw new Error('Page path is required.');
-    }
-    /**
-     * RegExp for datetime yyyy/MM/dd-HH:mm
-     * @see https://regex101.com/r/XbxdNo/1
-     */
-    const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
-
-    if (!regexpDatetime.test(oldest)) {
-      throw new Error('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
-    }
-    if (!regexpDatetime.test(latest)) {
-      throw new Error('Datetime format for latest must be yyyy/MM/dd-HH:mm');
-    }
-    oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
-    // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
-
-    if (oldest > latest) {
-      throw new Error('Oldest datetime must be older than the latest date time.');
+      throw err;
     }
-
-    return { path, oldest, latest };
-  }
-
-  async togetterCleanMessages(messages) {
-    const cleanedContents: string[] = [];
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    let lastMessage: any = {};
-    const grwTzoffset = this.crowi.appService.getTzoffset() * 60;
-    messages
-      .sort((a, b) => {
-        return a.ts - b.ts;
-      })
-      .forEach((message) => {
-        // increment contentsBody while removing the same headers
-        // exclude header
-        const lastMessageTs = Math.floor(lastMessage.ts / 60);
-        const messageTs = Math.floor(message.ts / 60);
-        if (lastMessage.user === message.user && lastMessageTs === messageTs) {
-          cleanedContents.push(`${message.text}\n`);
-        }
-        // include header
-        else {
-          const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
-          const time = format(new Date(ts), 'h:mm a');
-          cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
-          lastMessage = message;
-        }
-      });
-    return cleanedContents;
+    return;
   }
 
-  async togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody) {
+  async handleViewSubmissionRequest(client, payload) {
+    const { callback_id: callbackId } = payload.view;
+    const commandName = callbackId.split(':')[0];
+    const handlerMethodName = callbackId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
     try {
-      await this.createPage(client, payload, path, channel, contentsBody);
-      // send preview to dm
-      await client.chat.postMessage({
-        channel: payload.user.id,
-        text: 'Preview from togetter command',
-        blocks: [
-          markdownSectionBlock('*Preview*'),
-          divider(),
-          markdownSectionBlock(contentsBody),
-          divider(),
-        ],
-      });
-      // dismiss message
-      const responseUrl = payload.response_url;
-      axios.post(responseUrl, {
-        delete_original: true,
-      });
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
     }
     catch (err) {
-      throw new Error('Error occurred while creating a page.');
+      throw err;
     }
+    return;
   }
 
-  async togetterCancel(client, payload) {
-    const responseUrl = payload.response_url;
-    axios.post(responseUrl, {
-      delete_original: true,
+  async notCommand(client, body) {
+    logger.error('Invalid first argument');
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'No command',
+      blocks: [
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+      ],
     });
+    return;
   }
 
 }