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

Merge remote-tracking branch 'origin/master' into support/make-app-monorepo

Yuki Takei 4 лет назад
Родитель
Сommit
1907e0de6a

+ 18 - 14
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -104,20 +104,7 @@ module.exports = (crowi) => {
     const command = args[0];
     const command = args[0];
 
 
     try {
     try {
-      switch (command) {
-        case 'search':
-          await crowi.slackBotService.showEphemeralSearchResults(client, body, args);
-          break;
-        case 'create':
-          await crowi.slackBotService.createModal(client, body);
-          break;
-        case 'help':
-          await crowi.slackBotService.helpCommand(client, body);
-          break;
-        default:
-          await crowi.slackBotService.notCommand(client, body);
-          break;
-      }
+      await crowi.slackBotService.handleCommand(command, client, body, args);
     }
     }
     catch (error) {
     catch (error) {
       logger.error(error);
       logger.error(error);
@@ -161,6 +148,23 @@ module.exports = (crowi) => {
         await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
         await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
         break;
         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:
       default:
         break;
         break;
     }
     }

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

@@ -0,0 +1,52 @@
+const { markdownSectionBlock, inputSectionBlock } = require('@growi/slack');
+const logger = require('@alias/logger')('growi:service:SlackCommandHandler:create');
+
+module.exports = () => {
+  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 }),
+        },
+      });
+    }
+    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}`),
+        ],
+      });
+      throw err;
+    }
+  };
+
+  return handler;
+};

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

@@ -0,0 +1,26 @@
+const { markdownSectionBlock } = require('@growi/slack');
+
+module.exports = () => {
+  const BaseSlackCommandHandler = require('./slack-command-handler');
+  const handler = new BaseSlackCommandHandler();
+
+  handler.handleCommand = (client, body) => {
+    // adjust spacing
+    let message = '*Help*\n\n';
+    message += 'Usage:     `/growi [command] [args]`\n\n';
+    message += 'Commands:\n\n';
+    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\n\n';
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Help',
+      blocks: [
+        markdownSectionBlock(message),
+      ],
+    });
+  };
+
+  return handler;
+};

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

@@ -0,0 +1,250 @@
+const logger = require('@alias/logger')('growi:service:SlackCommandHandler:search');
+
+const { markdownSectionBlock, divider } = require('@growi/slack');
+const { formatDistanceStrict } = require('date-fns');
+
+const PAGINGLIMIT = 10;
+
+module.exports = (crowi) => {
+  const BaseSlackCommandHandler = require('./slack-command-handler');
+  const handler = new BaseSlackCommandHandler(crowi);
+
+  handler.handleCommand = async function(client, body, args) {
+    let searchResult;
+    try {
+      searchResult = await this.retrieveSearchResults(client, body, args);
+    }
+    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',
+    //     },
+    //   ],
+    // };
+    const actionBlocks = {
+      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);
+
+    try {
+      await client.chat.postEphemeral({
+        channel: body.channel_id,
+        user: body.user_id,
+        text: 'Successed To Search',
+        blocks,
+      });
+    }
+    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);
+    }
+  };
+
+  handler.retrieveSearchResults = async function(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,
+    };
+  };
+
+  handler.getKeywords = function(args) {
+    const keywordsArr = args.slice(1);
+    const keywords = keywordsArr.join(' ');
+    return keywords;
+  };
+
+  handler.appendSpeechBaloon = function(mrkdwn, commentCount) {
+    return (commentCount != null && commentCount > 0)
+      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
+      : mrkdwn;
+  };
+
+  handler.generatePageLinkMrkdwn = function(pathname, href) {
+    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  };
+
+  handler.generateLastUpdateMrkdwn = function(updatedAt, baseDate) {
+    if (updatedAt != null) {
+      // cast to date
+      const date = new Date(updatedAt);
+      return formatDistanceStrict(date, baseDate);
+    }
+    return '';
+  };
+
+  return handler;
+};

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

@@ -0,0 +1,15 @@
+// Any slack command handler should inherit BaseSlackCommandHandler
+class BaseSlackCommandHandler {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * Handle /commands endpoint
+   */
+  handleCommand(client, body, ...opt) { throw new Error('Implement this') }
+
+}
+
+module.exports = BaseSlackCommandHandler;

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

@@ -0,0 +1,66 @@
+const {
+  inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
+} = require('@growi/slack');
+const { format } = require('date-fns');
+
+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,
+      text: 'Select messages to use.',
+      blocks: this.togetterMessageBlocks(result.messages, body, args, limit),
+    });
+    return;
+  };
+
+  handler.togetterMessageBlocks = function(messages, body, args, limit) {
+    return [
+      markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),
+      inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'),
+      inputBlock(this.plainTextInputElementWithInitialTime('latest'), 'latest', 'Latest datetime'),
+      inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
+      actionsBlock(
+        buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
+        buttonElement({ text: 'Create page', actionId: 'togetter:createPage', style: 'primary' }),
+      ),
+    ];
+  };
+
+  /**
+   * Plain-text input element
+   * https://api.slack.com/reference/block-kit/block-elements#input
+   */
+  handler.togetterInputBlockElement = function(actionId, placeholderText = 'Write something ...') {
+    return {
+      type: 'plain_text_input',
+      placeholder: {
+        type: 'plain_text',
+        text: placeholderText,
+      },
+      action_id: actionId,
+    };
+  };
+
+  handler.plainTextInputElementWithInitialTime = function(actionId) {
+    const tzDateSec = new Date().getTime();
+    const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
+    const initialDateTime = format(new Date(tzDateSec - grwTzoffset), 'yyyy/MM/dd-HH:mm');
+    return {
+      type: 'plain_text_input',
+      action_id: actionId,
+      initial_value: initialDateTime,
+    };
+  };
+
+  return handler;
+};

+ 223 - 161
packages/app/src/server/service/slackbot.ts

@@ -6,14 +6,15 @@ import { S2sMessageHandlable } from './s2s-messaging/handlable';
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const axios = require('axios');
 const axios = require('axios');
-const { formatDistanceStrict } = require('date-fns');
-
-const PAGINGLIMIT = 10;
 
 
+const { markdownSectionBlock, divider } = require('@growi/slack');
 const { reshapeContentsBody } = require('@growi/slack');
 const { reshapeContentsBody } = require('@growi/slack');
+const { formatDistanceStrict, parse, format } = require('date-fns');
 
 
 const S2sMessage = require('../models/vo/s2s-message');
 const S2sMessage = require('../models/vo/s2s-message');
 
 
+const PAGINGLIMIT = 10;
+
 class SlackBotService implements S2sMessageHandlable {
 class SlackBotService implements S2sMessageHandlable {
 
 
   crowi!: any;
   crowi!: any;
@@ -72,6 +73,20 @@ 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) {
   async notCommand(client, body) {
     logger.error('Invalid first argument');
     logger.error('Invalid first argument');
     client.chat.postEphemeral({
     client.chat.postEphemeral({
@@ -79,91 +94,12 @@ class SlackBotService implements S2sMessageHandlable {
       user: body.user_id,
       user: body.user_id,
       text: 'No command',
       text: 'No command',
       blocks: [
       blocks: [
-        this.generateMarkdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
       ],
       ],
     });
     });
     return;
     return;
   }
   }
 
 
-  async helpCommand(client, body) {
-    const message = '*Help*\n growi-bot usage\n `/growi [command] [args]`\n\n Create new page\n `create`\n\n Search pages\n `search [keyword]`';
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'Help',
-      blocks: [
-        this.generateMarkdownSectionBlock(message),
-      ],
-    });
-    return;
-  }
-
-  getKeywords(args) {
-    const keywordsArr = args.slice(1);
-    const keywords = keywordsArr.join(' ');
-    return keywords;
-  }
-
-  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: [
-          this.generateMarkdownSectionBlock('*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: [
-          this.generateMarkdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
-          this.generateMarkdownSectionBlock(':mag: *Help: Searching*'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
-          this.divider(),
-          this.generateMarkdownSectionBlock('`-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,
-    };
-  }
-
   generatePageLinkMrkdwn(pathname, href) {
   generatePageLinkMrkdwn(pathname, href) {
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
   }
   }
@@ -183,6 +119,7 @@ class SlackBotService implements S2sMessageHandlable {
     return '';
     return '';
   }
   }
 
 
+
   async shareSinglePage(client, payload) {
   async shareSinglePage(client, payload) {
     const { channel, user, actions } = payload;
     const { channel, user, actions } = payload;
 
 
@@ -202,7 +139,7 @@ class SlackBotService implements S2sMessageHandlable {
       channel: channelId,
       channel: channelId,
       blocks: [
       blocks: [
         { type: 'divider' },
         { type: 'divider' },
-        this.generateMarkdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
         {
         {
           type: 'context',
           type: 'context',
           elements: [
           elements: [
@@ -225,7 +162,6 @@ class SlackBotService implements S2sMessageHandlable {
   }
   }
 
 
   async showEphemeralSearchResults(client, body, args, offsetNum) {
   async showEphemeralSearchResults(client, body, args, offsetNum) {
-
     let searchResult;
     let searchResult;
     try {
     try {
       searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
       searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
@@ -237,7 +173,7 @@ class SlackBotService implements S2sMessageHandlable {
         user: body.user_id,
         user: body.user_id,
         text: 'Failed To Search',
         text: 'Failed To Search',
         blocks: [
         blocks: [
-          this.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
+          markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
         ],
         ],
       });
       });
       throw new Error('/growi command:search: Failed to search');
       throw new Error('/growi command:search: Failed to search');
@@ -278,7 +214,7 @@ class SlackBotService implements S2sMessageHandlable {
 
 
     const now = new Date();
     const now = new Date();
     const blocks = [
     const blocks = [
-      this.generateMarkdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
       contextBlock,
       contextBlock,
       { type: 'divider' },
       { type: 'divider' },
       // create an array by map and extract
       // create an array by map and extract
@@ -370,126 +306,252 @@ class SlackBotService implements S2sMessageHandlable {
         user: body.user_id,
         user: body.user_id,
         text: 'Failed to post ephemeral message.',
         text: 'Failed to post ephemeral message.',
         blocks: [
         blocks: [
-          this.generateMarkdownSectionBlock(err.toString()),
+          markdownSectionBlock(err.toString()),
         ],
         ],
       });
       });
       throw new Error(err);
       throw new Error(err);
     }
     }
   }
   }
 
 
-  async createModal(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: [
-            this.generateMarkdownSectionBlock('Create new page.'),
-            this.generateInputSectionBlock('path', 'Path', 'path_input', false, '/path'),
-            this.generateInputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
-          ],
-          private_metadata: JSON.stringify({ channelId: body.channel_id }),
-        },
+  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;
     }
     }
-    catch (err) {
-      logger.error('Failed to create a page.');
-      await client.chat.postEphemeral({
+
+    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,
         channel: body.channel_id,
         user: body.user_id,
         user: body.user_id,
-        text: 'Failed To Create',
+        text: `No page found with "${keywords}"`,
         blocks: [
         blocks: [
-          this.generateMarkdownSectionBlock(`*Failed to create new page.*\n ${err}`),
+          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'),
         ],
         ],
       });
       });
-      throw err;
+      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
   // Submit action in create Modal
-  async createPageInGrowi(client, payload) {
+  async createPage(client, payload, path, channelId, contentsBody) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const pathUtils = require('growi-commons').pathUtils;
     const pathUtils = require('growi-commons').pathUtils;
-    const contentsBody = reshapeContentsBody(payload.view.state.values.contents.contents_input.value);
-
+    const reshapedContentsBody = reshapeContentsBody(contentsBody);
     try {
     try {
-      let path = payload.view.state.values.path.path_input.value;
       // sanitize path
       // sanitize path
-      path = this.crowi.xss.process(path);
-      path = pathUtils.normalizePath(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
       // generate a dummy id because Operation to create a page needs ObjectId
       const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
       const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
-      const page = await Page.create(path, contentsBody, dummyObjectIdOfUser, {});
+      const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
 
       // Send a message when page creation is complete
       // Send a message when page creation is complete
       const growiUri = this.crowi.appService.getSiteUrl();
       const growiUri = this.crowi.appService.getSiteUrl();
-      const channelId = JSON.parse(payload.view.private_metadata).channelId;
       await client.chat.postEphemeral({
       await client.chat.postEphemeral({
         channel: channelId,
         channel: channelId,
         user: payload.user.id,
         user: payload.user.id,
-        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + path)}`)}> has been created.`,
+        text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
       client.chat.postMessage({
       client.chat.postMessage({
         channel: payload.user.id,
         channel: payload.user.id,
         blocks: [
         blocks: [
-          this.generateMarkdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${contentsBody}`)],
+          markdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`)],
       });
       });
       logger.error('Failed to create page in GROWI.');
       logger.error('Failed to create page in GROWI.');
       throw err;
       throw err;
     }
     }
   }
   }
 
 
-  generateMarkdownSectionBlock(blocks) {
-    return {
-      type: 'section',
-      text: {
-        type: 'mrkdwn',
-        text: blocks,
-      },
-    };
+  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);
   }
   }
 
 
-  divider() {
-    return {
-      type: 'divider',
-    };
+  async togetterCreatePageInGrowi(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) {
+      await client.chat.postMessage({
+        channel: payload.user.id,
+        text: err.message,
+        blocks: [
+          markdownSectionBlock(err.message),
+        ],
+      });
+      return;
+    }
   }
   }
 
 
-  generateInputSectionBlock(blockId, labelText, actionId, isMultiline, placeholder) {
-    return {
-      type: 'input',
-      block_id: blockId,
-      label: {
-        type: 'plain_text',
-        text: labelText,
-      },
-      element: {
-        type: 'plain_text_input',
-        action_id: actionId,
-        multiline: isMultiline,
-        placeholder: {
-          type: 'plain_text',
-          text: placeholder,
-        },
-      },
-    };
+  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.');
+    }
+
+    return { path, oldest, latest };
+  }
+
+  async togetterCleanMessages(messages) {
+    const cleanedContents = [];
+    let lastMessage = {};
+    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;
+  }
+
+  async togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody) {
+    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,
+      });
+    }
+    catch (err) {
+      throw new Error('Error occurred while creating a page.');
+    }
+  }
+
+  async togetterCancel(client, payload) {
+    const responseUrl = payload.response_url;
+    axios.post(responseUrl, {
+      delete_original: true,
+    });
   }
   }
 
 
 }
 }

+ 1 - 1
packages/slack/src/index.ts

@@ -14,7 +14,7 @@ export * from './interfaces/request-from-slack';
 export * from './models/errors';
 export * from './models/errors';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './middlewares/verify-slack-request';
-export * from './utils/block-creater';
+export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
 export * from './utils/check-communicable';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/reshape-contents-body';
 export * from './utils/reshape-contents-body';

+ 0 - 31
packages/slack/src/utils/block-creater.ts

@@ -1,31 +0,0 @@
-import { SectionBlock, InputBlock } from '@slack/types';
-
-export const generateMarkdownSectionBlock = (blocks:string):SectionBlock => {
-  return {
-    type: 'section',
-    text: {
-      type: 'mrkdwn',
-      text: blocks,
-    },
-  };
-};
-
-export const generateInputSectionBlock = (blockId:string, labelText:string, actionId:string, isMultiline:boolean, placeholder:string):InputBlock => {
-  return {
-    type: 'input',
-    block_id: blockId,
-    label: {
-      type: 'plain_text',
-      text: labelText,
-    },
-    element: {
-      type: 'plain_text_input',
-      action_id: actionId,
-      multiline: isMultiline,
-      placeholder: {
-        type: 'plain_text',
-        text: placeholder,
-      },
-    },
-  };
-};

+ 111 - 0
packages/slack/src/utils/block-kit-builder.ts

@@ -0,0 +1,111 @@
+import {
+  SectionBlock, InputBlock, DividerBlock, ActionsBlock,
+  Button, Overflow, Datepicker, Select, RadioButtons, Checkboxes, Action, MultiSelect, PlainTextInput, Option,
+} from '@slack/types';
+
+
+export function divider(): DividerBlock {
+  return {
+    type: 'divider',
+  };
+}
+
+export function markdownSectionBlock(text: string): SectionBlock {
+  return {
+    type: 'section',
+    text: {
+      type: 'mrkdwn',
+      text,
+    },
+  };
+}
+
+export function inputSectionBlock(blockId: string, labelText: string, actionId: string, isMultiline: boolean, placeholder: string): InputBlock {
+  return {
+    type: 'input',
+    block_id: blockId,
+    label: {
+      type: 'plain_text',
+      text: labelText,
+    },
+    element: {
+      type: 'plain_text_input',
+      action_id: actionId,
+      multiline: isMultiline,
+      placeholder: {
+        type: 'plain_text',
+        text: placeholder,
+      },
+    },
+  };
+}
+
+export function actionsBlock(...elements: (Button | Overflow | Datepicker | Select | RadioButtons | Checkboxes | Action)[]): ActionsBlock {
+  return {
+    type: 'actions',
+    elements: [
+      ...elements,
+    ],
+  };
+}
+
+export function inputBlock(
+    element: Select | MultiSelect | Datepicker | PlainTextInput | RadioButtons | Checkboxes, blockId: string, labelText: string,
+): InputBlock {
+  return {
+    type: 'input',
+    block_id: blockId,
+    element,
+    label: {
+      type: 'plain_text',
+      text: labelText,
+    },
+  };
+}
+
+type ButtonElement = {
+  text: string,
+  actionId: string,
+  style?: string,
+  value?:string
+}
+
+/**
+ * Button element
+ * https://api.slack.com/reference/block-kit/block-elements#button
+ */
+export function buttonElement({
+  text, actionId, style, value,
+}:ButtonElement): Button {
+  const button: Button = {
+    type: 'button',
+    text: {
+      type: 'plain_text',
+      text,
+    },
+    action_id: actionId,
+    value,
+  };
+  if (style === 'primary' || style === 'danger') {
+    button.style = style;
+  }
+  return button;
+}
+
+/**
+ * Option object
+ * https://api.slack.com/reference/block-kit/composition-objects#option
+ */
+export function checkboxesElementOption(text: string, description: string, value: string): Option {
+  return {
+    text: {
+      type: 'mrkdwn',
+      text,
+    },
+    description: {
+      type: 'plain_text',
+      text: description,
+    },
+    value,
+  };
+}

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

@@ -1,6 +1,6 @@
 import { WebAPICallResult } from '@slack/web-api';
 import { WebAPICallResult } from '@slack/web-api';
 
 
-import { generateMarkdownSectionBlock } from './block-creater';
+import { markdownSectionBlock } from './block-kit-builder';
 import { generateWebClient } from './webclient-factory';
 import { generateWebClient } from './webclient-factory';
 
 
 export const postEphemeralErrors = async(
 export const postEphemeralErrors = async(
@@ -19,7 +19,7 @@ export const postEphemeralErrors = async(
       channel: channelId,
       channel: channelId,
       user: userId,
       user: userId,
       blocks: [
       blocks: [
-        generateMarkdownSectionBlock('*Error occured:*'),
+        markdownSectionBlock('*Error occured:*'),
         ...rejectedResults.map((rejectedResult) => {
         ...rejectedResults.map((rejectedResult) => {
           const reason = rejectedResult.reason.toString();
           const reason = rejectedResult.reason.toString();
           const resData = rejectedResult.reason.response?.data;
           const resData = rejectedResult.reason.response?.data;
@@ -30,7 +30,7 @@ export const postEphemeralErrors = async(
             errorMessage += `\n  Cause: ${resDataMessage}`;
             errorMessage += `\n  Cause: ${resDataMessage}`;
           }
           }
 
 
-          return generateMarkdownSectionBlock(errorMessage);
+          return markdownSectionBlock(errorMessage);
         }),
         }),
       ],
       ],
     });
     });

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

@@ -27,7 +27,7 @@ import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/Sect
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 
 
 // temporarily save for selection to growi
 // temporarily save for selection to growi
-const temporarySinglePostCommands = ['create'];
+const temporarySinglePostCommands = ['create', 'togetter'];
 
 
 @Controller('/g2s')
 @Controller('/g2s')
 export class GrowiToSlackCtrl {
 export class GrowiToSlackCtrl {

+ 5 - 5
packages/slackbot-proxy/src/controllers/slack.ts

@@ -7,7 +7,7 @@ import axios from 'axios';
 import { WebAPICallResult } from '@slack/web-api';
 import { WebAPICallResult } from '@slack/web-api';
 
 
 import {
 import {
-  generateMarkdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
+  markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
 } from '@growi/slack';
 } from '@growi/slack';
 
 
 import { Relation } from '~/entities/relation';
 import { Relation } from '~/entities/relation';
@@ -139,8 +139,8 @@ export class SlackCtrl {
     if (relations.length === 0) {
     if (relations.length === 0) {
       return res.json({
       return res.json({
         blocks: [
         blocks: [
-          generateMarkdownSectionBlock('*No relation found.*'),
-          generateMarkdownSectionBlock('Run `/growi register` first.'),
+          markdownSectionBlock('*No relation found.*'),
+          markdownSectionBlock('Run `/growi register` first.'),
         ],
         ],
       });
       });
     }
     }
@@ -149,8 +149,8 @@ export class SlackCtrl {
     if (growiCommand.growiCommandType === 'status') {
     if (growiCommand.growiCommandType === 'status') {
       return res.json({
       return res.json({
         blocks: [
         blocks: [
-          generateMarkdownSectionBlock('*Found Relations to GROWI.*'),
-          ...relations.map(relation => generateMarkdownSectionBlock(`GROWI url: ${relation.growiUri}`)),
+          markdownSectionBlock('*Found Relations to GROWI.*'),
+          ...relations.map(relation => markdownSectionBlock(`GROWI url: ${relation.growiUri}`)),
         ],
         ],
       });
       });
     }
     }

+ 8 - 8
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,6 +1,6 @@
 import { Inject, Service } from '@tsed/di';
 import { Inject, Service } from '@tsed/di';
 import { WebClient, LogLevel, Block } from '@slack/web-api';
 import { WebClient, LogLevel, Block } from '@slack/web-api';
-import { generateInputSectionBlock, GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
+import { markdownSectionBlock, inputSectionBlock, GrowiCommand } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 import { AuthorizeResult } from '@slack/oauth';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { OrderRepository } from '~/repositories/order';
 import { OrderRepository } from '~/repositories/order';
@@ -40,9 +40,9 @@ export class RegisterService implements GrowiCommandProcessor {
         private_metadata: JSON.stringify({ channel: body.channel_name }),
         private_metadata: JSON.stringify({ channel: body.channel_name }),
 
 
         blocks: [
         blocks: [
-          generateInputSectionBlock('growiUrl', 'GROWI domain', 'contents_input', false, 'https://example.com'),
-          generateInputSectionBlock('tokenPtoG', 'Access Token Proxy to GROWI', 'contents_input', false, 'jBMZvpk.....'),
-          generateInputSectionBlock('tokenGtoP', 'Access Token GROWI to Proxy', 'contents_input', false, 'sdg15av.....'),
+          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.....'),
         ],
         ],
       },
       },
     });
     });
@@ -81,7 +81,7 @@ export class RegisterService implements GrowiCommandProcessor {
     catch (error) {
     catch (error) {
       const invalidErrorMsg = 'Please enter a valid URL';
       const invalidErrorMsg = 'Please enter a valid URL';
       const blocks = [
       const blocks = [
-        generateMarkdownSectionBlock(invalidErrorMsg),
+        markdownSectionBlock(invalidErrorMsg),
       ];
       ];
       await this.replyToSlack(client, channel, payload.user.id, 'Invalid URL', blocks);
       await this.replyToSlack(client, channel, payload.user.id, 'Invalid URL', blocks);
       throw new InvalidUrlError(growiUrl);
       throw new InvalidUrlError(growiUrl);
@@ -105,7 +105,7 @@ export class RegisterService implements GrowiCommandProcessor {
 
 
     if (isOfficialMode) {
     if (isOfficialMode) {
       const blocks = [
       const blocks = [
-        generateMarkdownSectionBlock('Successfully registered with the proxy! Please check test connection in your GROWI'),
+        markdownSectionBlock('Successfully registered with the proxy! Please check test connection in your GROWI'),
       ];
       ];
       await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
       await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
       return;
       return;
@@ -113,8 +113,8 @@ export class RegisterService implements GrowiCommandProcessor {
     }
     }
 
 
     const blocks = [
     const blocks = [
-      generateMarkdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
-      generateMarkdownSectionBlock(`Proxy URL: ${serverUri}`),
+      markdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
+      markdownSectionBlock(`Proxy URL: ${serverUri}`),
     ];
     ];
     await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
     await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
     return;
     return;

+ 3 - 3
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -1,6 +1,6 @@
 import { Inject, Service } from '@tsed/di';
 import { Inject, Service } from '@tsed/di';
 import { WebClient, LogLevel } from '@slack/web-api';
 import { WebClient, LogLevel } from '@slack/web-api';
-import { GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
+import { GrowiCommand, markdownSectionBlock } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 import { AuthorizeResult } from '@slack/oauth';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { RelationRepository } from '~/repositories/relation';
 import { RelationRepository } from '~/repositories/relation';
@@ -38,7 +38,7 @@ export class UnregisterService implements GrowiCommandProcessor {
         private_metadata: JSON.stringify({ channel: body.channel_name, growiUrls }),
         private_metadata: JSON.stringify({ channel: body.channel_name, growiUrls }),
 
 
         blocks: [
         blocks: [
-          ...growiUrls.map(growiCommandArg => generateMarkdownSectionBlock(`GROWI url: ${growiCommandArg}.`)),
+          ...growiUrls.map(growiCommandArg => markdownSectionBlock(`GROWI url: ${growiCommandArg}.`)),
         ],
         ],
       },
       },
     });
     });
@@ -63,7 +63,7 @@ export class UnregisterService implements GrowiCommandProcessor {
       // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
       // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
       text: 'Delete Relations',
       text: 'Delete Relations',
       blocks: [
       blocks: [
-        generateMarkdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
+        markdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
       ],
       ],
     });
     });