Преглед изворни кода

Merge branch 'feat/GW-6710-slackbot-togetter-command' into feat/GW-6710-slackbot-togetter-command-parse-reshape-then-create-page

hakumizuki пре 4 година
родитељ
комит
2fd83fe6df
35 измењених фајлова са 889 додато и 409 уклоњено
  1. 5 0
      CHANGES.md
  2. 0 1
      packages/slack/.eslintrc.js
  3. 1 1
      packages/slack/src/index.ts
  4. 0 31
      packages/slack/src/utils/block-creater.ts
  5. 101 0
      packages/slack/src/utils/block-kit-builder.ts
  6. 3 3
      packages/slack/src/utils/post-ephemeral-errors.ts
  7. 114 0
      packages/slack/src/utils/reshape-contents-body.test.ts
  8. 2 2
      packages/slack/src/utils/reshape-contents-body.ts
  9. 0 1
      packages/slackbot-proxy/.eslintrc.js
  10. 17 9
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  11. 6 6
      packages/slackbot-proxy/src/controllers/slack.ts
  12. 8 8
      packages/slackbot-proxy/src/services/RegisterService.ts
  13. 3 3
      packages/slackbot-proxy/src/services/UnregisterService.ts
  14. 7 1
      resource/locales/en_US/translation.json
  15. 7 1
      resource/locales/ja_JP/translation.json
  16. 7 1
      resource/locales/zh_CN/translation.json
  17. 2 2
      src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx
  18. 34 1
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  19. 3 2
      src/client/js/components/PageAccessoriesModal.jsx
  20. 6 2
      src/client/js/components/PageAccessoriesModalControl.jsx
  21. 8 0
      src/client/js/components/PageList.jsx
  22. 22 1
      src/client/js/services/AdminGeneralSecurityContainer.js
  23. 6 0
      src/client/styles/scss/_wiki.scss
  24. 2 0
      src/server/models/config.js
  25. 53 0
      src/server/routes/apiv3/security-setting.js
  26. 15 2
      src/server/routes/apiv3/share-links.js
  27. 21 14
      src/server/routes/apiv3/slack-integration.js
  28. 3 0
      src/server/routes/page.js
  29. 6 0
      src/server/service/config-loader.js
  30. 52 0
      src/server/service/slack-command-handler/create.js
  31. 20 0
      src/server/service/slack-command-handler/help.js
  32. 250 0
      src/server/service/slack-command-handler/search.js
  33. 15 0
      src/server/service/slack-command-handler/slack-command-handler.js
  34. 70 0
      src/server/service/slack-command-handler/togetter.js
  35. 20 317
      src/server/service/slackbot.js

+ 5 - 0
CHANGES.md

@@ -17,6 +17,11 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
     * Searching GROWI pages from Slack
     * Creating GROWI pages from Slack
         * Easy record conversations
+* Feature: Enable/Disable option for share link
+* Feature: Re-send invitation mail from user management page
+* Improvement: Mark users who failed to send invitation emails
+* Fix: lsx plugin in the custom sidebar does not work when showing search result page
+* Support: Switch the official docker base image from Alpine based to Ubuntu based
 
 ## v4.2.21
 

+ 0 - 1
packages/slack/.eslintrc.js

@@ -6,7 +6,6 @@ module.exports = {
     'plugin:jest/recommended',
   ],
   env: {
-    jquery: true,
     'jest/globals': true,
   },
   globals: {

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

@@ -14,7 +14,7 @@ export * from './interfaces/request-from-slack';
 export * from './models/errors';
 export * from './middlewares/verify-growi-to-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/post-ephemeral-errors';
 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,
-      },
-    },
-  };
-};

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

@@ -0,0 +1,101 @@
+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,
+    },
+  };
+}
+
+/**
+ * Button element
+ * https://api.slack.com/reference/block-kit/block-elements#button
+ */
+export function buttonElement(text: string, actionId: string, style?: string): Button {
+  const button: Button = {
+    type: 'button',
+    text: {
+      type: 'plain_text',
+      text,
+    },
+    action_id: actionId,
+  };
+  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 { generateMarkdownSectionBlock } from './block-creater';
+import { markdownSectionBlock } from './block-kit-builder';
 import { generateWebClient } from './webclient-factory';
 
 export const postEphemeralErrors = async(
@@ -19,7 +19,7 @@ export const postEphemeralErrors = async(
       channel: channelId,
       user: userId,
       blocks: [
-        generateMarkdownSectionBlock('*Error occured:*'),
+        markdownSectionBlock('*Error occured:*'),
         ...rejectedResults.map((rejectedResult) => {
           const reason = rejectedResult.reason.toString();
           const resData = rejectedResult.reason.response?.data;
@@ -30,7 +30,7 @@ export const postEphemeralErrors = async(
             errorMessage += `\n  Cause: ${resDataMessage}`;
           }
 
-          return generateMarkdownSectionBlock(errorMessage);
+          return markdownSectionBlock(errorMessage);
         }),
       ],
     });

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

@@ -0,0 +1,114 @@
+import { reshapeContentsBody } from './reshape-contents-body';
+
+describe('reshapeContentsBody', () => {
+
+  describe('Markdown only', () => {
+    test('Return the same input', () => {
+      const input = `
+      # Title\u0020\u0020
+      ## Section\u0020\u0020
+      I tested this code at 12:00 AM.\u0020\u0020
+      **bold** text
+      some texts`;
+
+      expect(reshapeContentsBody(input)).toBe(input);
+    });
+  });
+
+  describe('Contains time but no headers', () => {
+    test('Return the same input', () => {
+      const input = `
+12:23
+some messages...
+12:23
+some messages...
+12:23`;
+
+      expect(reshapeContentsBody(input)).toBe(input);
+    });
+  });
+
+  describe('Copied from Slack only', () => {
+    test('Reshape', () => {
+      const input = `
+taichi-m  12:23 PM
+some messages...
+some messages...
+some messages...
+12:23
+some messages...
+12:23
+some messages...`;
+
+      const output = `
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+  describe('Copied from Slack only (24 hours format)', () => {
+    test('Reshape', () => {
+      const input = `
+taichi-m  12:23
+some messages...
+some messages...
+some messages...
+12:23
+some messages...
+12:23
+some messages...`;
+
+      const output = `
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23</span>
+\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+  describe('Markdown and copied from Slack', () => {
+    test('Reshape only after the first header', () => {
+      const input = `
+some messages...
+
+taichi-m  12:23 PM
+some messages...`;
+
+      const output = `some messages...
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+});

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

@@ -42,7 +42,7 @@ export const reshapeContentsBody = (str: string): string => {
   const splitted = str.split('\n');
   const { linesBeforeFirstHeader, linesAfterFirstHeader } = devideLinesBeforeAfterFirstHeader(splitted);
   if (linesAfterFirstHeader.length === 0) {
-    return linesBeforeFirstHeader.join('');
+    return linesBeforeFirstHeader.join('\n');
   }
 
   let didReactionRemoved = false;
@@ -64,7 +64,7 @@ export const reshapeContentsBody = (str: string): string => {
       }
       // ##*username*  HH:mm AM
       copyline = '\n## **'.concat(copyline);
-      copyline = copyline.replace(regexpTime, '**'.concat(time));
+      copyline = copyline.replace(regexpTime, '**<span class="grw-togetter-time">'.concat(time, '</span>\n'));
     }
     // Check 3: Is this line a short time(HH:mm)?
     else if (regexpShortTime.test(copyline)) {

+ 0 - 1
packages/slackbot-proxy/.eslintrc.js

@@ -6,7 +6,6 @@ module.exports = {
     'plugin:jest/recommended',
   ],
   env: {
-    jquery: true,
     'jest/globals': true,
   },
   globals: {

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

@@ -170,15 +170,23 @@ export class GrowiToSlackCtrl {
     logger.debug('relation test is success', order);
 
     // Transaction is not considered because it is used infrequently,
-    const createdRelation = await this.relationRepository.save({
-      installation: order.installation,
-      tokenGtoP: order.tokenGtoP,
-      tokenPtoG: order.tokenPtoG,
-      growiUri: order.growiUrl,
-      siglePostCommands: temporarySinglePostCommands,
-    });
-
-    return res.send({ relation: createdRelation, slackBotToken: token });
+    const response = await this.relationRepository.createQueryBuilder('relation')
+      .insert()
+      .values({
+        installation: order.installation,
+        tokenGtoP: order.tokenGtoP,
+        tokenPtoG: order.tokenPtoG,
+        growiUri: order.growiUrl,
+        siglePostCommands: temporarySinglePostCommands,
+      })
+      // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
+      .orUpdate({ conflict_target: ['installation', 'growiUri'], overwrite: ['tokenGtoP', 'tokenPtoG', 'siglePostCommands'] })
+      .execute();
+
+    // Find the generated relation
+    const generatedRelation = await this.relationRepository.findOne({ id: response.identifiers[0].id });
+
+    return res.send({ relation: generatedRelation, slackBotToken: token });
   }
 
   injectGrowiUri(req: GrowiReq, growiUri: string): void {

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

@@ -7,7 +7,7 @@ import axios from 'axios';
 import { WebAPICallResult } from '@slack/web-api';
 
 import {
-  generateMarkdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
+  markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -139,8 +139,8 @@ export class SlackCtrl {
     if (relations.length === 0) {
       return res.json({
         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') {
       return res.json({
         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}`)),
         ],
       });
     }
@@ -256,7 +256,7 @@ export class SlackCtrl {
   }
 
   @Post('/events')
-  async handleEvent(@BodyParams() body:{[key:string]:string}, @Res() res: Res): Promise<void|string> {
+  async handleEvent(@BodyParams() body:{[key:string]:string} /* , @Res() res: Res */): Promise<void|string> {
     // eslint-disable-next-line max-len
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
     if (body.type === 'url_verification') {

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

@@ -1,6 +1,6 @@
 import { Inject, Service } from '@tsed/di';
 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 { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { OrderRepository } from '~/repositories/order';
@@ -40,9 +40,9 @@ export class RegisterService implements GrowiCommandProcessor {
         private_metadata: JSON.stringify({ channel: body.channel_name }),
 
         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) {
       const invalidErrorMsg = 'Please enter a valid URL';
       const blocks = [
-        generateMarkdownSectionBlock(invalidErrorMsg),
+        markdownSectionBlock(invalidErrorMsg),
       ];
       await this.replyToSlack(client, channel, payload.user.id, 'Invalid URL', blocks);
       throw new InvalidUrlError(growiUrl);
@@ -105,7 +105,7 @@ export class RegisterService implements GrowiCommandProcessor {
 
     if (isOfficialMode) {
       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);
       return;
@@ -113,8 +113,8 @@ export class RegisterService implements GrowiCommandProcessor {
     }
 
     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);
     return;

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

@@ -1,6 +1,6 @@
 import { Inject, Service } from '@tsed/di';
 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 { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { RelationRepository } from '~/repositories/relation';
@@ -38,7 +38,7 @@ export class UnregisterService implements GrowiCommandProcessor {
         private_metadata: JSON.stringify({ channel: body.channel_name, growiUrls }),
 
         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
       text: 'Delete Relations',
       blocks: [
-        generateMarkdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
+        markdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
       ],
     });
 

+ 7 - 1
resource/locales/en_US/translation.json

@@ -151,6 +151,7 @@
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
+  "Link sharing is disabled": "Link sharing is disabled",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -170,7 +171,8 @@
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_page_list": "There are no pages under this page.",
+    "link_sharing_is_disabled": "Link sharing is disabled."
   },
   "installer": {
     "setup": "Setup",
@@ -444,6 +446,7 @@
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
+    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
     "failed_to_reset_password":"Failed to reset password"
   },
   "template": {
@@ -587,6 +590,9 @@
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
+    "share_link_rights": "Share link rights",
+    "enable_link_sharing": "Enable link sharing",
+    "all_share_links": "All share links",
     "configuration": " Configuration",
     "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",

+ 7 - 1
resource/locales/ja_JP/translation.json

@@ -154,6 +154,7 @@
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
+  "Link sharing is disabled": "リンクのシェアは無効化されています",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -173,7 +174,8 @@
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_page_list": "このページの配下にはページが存在しません。",
+    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "installer": {
     "setup": "セットアップ",
@@ -446,6 +448,7 @@
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
     "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {
@@ -586,6 +589,9 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
+    "share_link_rights": "シェアリンクの権限",
+    "enable_link_sharing": "リンクのシェアを許可",
+    "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",

+ 7 - 1
resource/locales/zh_CN/translation.json

@@ -160,6 +160,7 @@
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
+  "Link sharing is disabled": "你不允许分享该链接",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -171,7 +172,8 @@
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_page_list": "There are no pages under this page.",
+    "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"installer": {
 		"setup": "安装",
@@ -421,6 +423,7 @@
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",
     "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password"
   },
 	"template": {
@@ -575,6 +578,9 @@
 			"restricted": "受限(需要管理员批准)",
 			"closed": "已关闭(仅限邀请)"
 		},
+    "share_link_rights": "分享链接权",
+    "enable_link_sharing": "启用链接共享",
+    "all_share_links": "所有共享链接",
 		"configuration": " 配置",
 		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",

+ 2 - 2
src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -225,9 +225,9 @@ class LdapSecuritySettingContents extends React.Component {
                 <p className="form-text text-muted">
                   <small>
                     {t('security_setting.example')}1 - {t('security_setting.ldap.search_filter_example1')}:
-                    <code>(|(uid={'{{ username }}'})(mail={'{{ username }}'}))</code><br />
+                    <code>(|(uid={'{{username}}'})(mail={'{{username}}'}))</code><br />
                     {t('security_setting.example')}2 - {t('security_setting.ldap.search_filter_example2')}:
-                    <code>(sAMAccountName={'{{ username }}'})</code>
+                    <code>(sAMAccountName={'{{username}}'})</code>
                   </small>
                 </p>
               </div>

+ 34 - 1
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -51,6 +51,7 @@ class ShareLinkSetting extends React.Component {
     this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
     this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
     this.deleteLinkById = this.deleteLinkById.bind(this);
+    this.switchDisableLinkSharing = this.switchDisableLinkSharing.bind(this);
   }
 
   componentWillMount() {
@@ -105,11 +106,22 @@ class ShareLinkSetting extends React.Component {
     this.getShareLinkList(shareLinksActivePage);
   }
 
+  async switchDisableLinkSharing() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+    try {
+      await adminGeneralSecurityContainer.switchDisableLinkSharing();
+      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
 
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
     const {
-      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit, disableLinkSharing,
     } = adminGeneralSecurityContainer.state;
 
     return (
@@ -125,6 +137,27 @@ class ShareLinkSetting extends React.Component {
           </button>
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
+        <h4>{t('security_setting.share_link_rights')}</h4>
+        <div className="row mb-5">
+          <div className="col-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="disableLinkSharing"
+                checked={!disableLinkSharing}
+                onChange={() => this.switchDisableLinkSharing()}
+              />
+              <label className="custom-control-label" htmlFor="disableLinkSharing">
+                {t('security_setting.enable_link_sharing')}
+              </label>
+            </div>
+            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && disableLinkSharing && (
+              <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>
+            )}
+          </div>
+        </div>
+        <h4>{t('security_setting.all_share_links')}</h4>
         <Pager
           links={shareLinks}
           activePage={shareLinksActivePage}

+ 3 - 2
src/client/js/components/PageAccessoriesModal.jsx

@@ -26,6 +26,7 @@ const PageAccessoriesModal = (props) => {
   const {
     t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
+  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
@@ -60,10 +61,10 @@ const PageAccessoriesModal = (props) => {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage && !isLinkSharingDisabled,
       },
     };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {

+ 6 - 2
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -19,6 +19,7 @@ const PageAccessoriesModalControl = (props) => {
   const {
     t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
+  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
   const accessoriesBtnList = useMemo(() => {
     return [
@@ -49,11 +50,11 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -62,6 +63,9 @@ const PageAccessoriesModalControl = (props) => {
         let tooltipMessage;
         if (accessory.disabled) {
           tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
+            tooltipMessage = t('Link sharing is disabled');
+          }
         }
         else {
           tooltipMessage = accessory.i18n;

+ 8 - 0
src/client/js/components/PageList.jsx

@@ -64,6 +64,14 @@ const PageList = (props) => {
       </div>
     );
   }
+  if (appContainer.config.disableLinkSharing) {
+    return (
+      <div className="mt-2">
+        {/* eslint-disable-next-line react/no-danger */}
+        <p>{t('custom_navigation.link_sharing_is_disabled')}</p>
+      </div>
+    );
+  }
 
   return (
     <div className="page-list">

+ 22 - 1
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -35,6 +35,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isGitHubEnabled: false,
       isTwitterEnabled: false,
       setupStrategies: [],
+      disableLinkSharing: false,
       shareLinks: [],
       totalshareLinks: 0,
       shareLinksPagingLimit: Infinity,
@@ -46,7 +47,7 @@ export default class AdminGeneralSecurityContainer extends Container {
   async retrieveSecurityData() {
     await this.retrieveSetupStratedies();
     const response = await this.appContainer.apiv3.get('/security-setting/');
-    const { generalSetting, generalAuth } = response.data.securityParams;
+    const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
@@ -54,6 +55,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
+      disableLinkSharing: shareLinkSetting.disableLinkSharing,
       isLocalEnabled: generalAuth.isLocalEnabled,
       isLdapEnabled: generalAuth.isLdapEnabled,
       isSamlEnabled: generalAuth.isSamlEnabled,
@@ -88,6 +90,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ sessionMaxAge });
   }
 
+  /**
+   * setter for disableLinkSharing
+   */
+  setDisableLinkSharing(disableLinkSharing) {
+    this.setState({ disableLinkSharing });
+  }
+
   /**
    * Change restrictGuestMode
    */
@@ -137,6 +146,18 @@ export default class AdminGeneralSecurityContainer extends Container {
     return securitySettingParams;
   }
 
+  /**
+   * Switch disableLinkSharing
+   */
+  async switchDisableLinkSharing() {
+    const requestParams = {
+      disableLinkSharing: !this.state.disableLinkSharing,
+    };
+    const response = await this.appContainer.apiv3.put('/security-setting/share-link-setting', requestParams);
+    this.setDisableLinkSharing(!this.state.disableLinkSharing);
+    return response;
+  }
+
   /**
    * Switch authentication
    */

+ 6 - 0
src/client/styles/scss/_wiki.scss

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

+ 2 - 0
src/server/models/config.js

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
       'security:list-policy:hideRestrictedByOwner' : false,
       'security:list-policy:hideRestrictedByGroup' : false,
       'security:pageCompleteDeletionAuthority' : undefined,
+      'security:disableLinkSharing' : false,
 
       'security:passport-local:isEnabled' : true,
       'security:passport-ldap:isEnabled' : false,
@@ -193,6 +194,7 @@ module.exports = function(crowi) {
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
       registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+      disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),

+ 53 - 0
src/server/routes/apiv3/security-setting.js

@@ -22,6 +22,9 @@ const validator = {
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
   ],
+  shareLinkSetting: [
+    body('disableLinkSharing').if(value => value != null).isBoolean(),
+  ],
   authenticationSetting: [
     body('isEnabled').if(value => value != null).isBoolean(),
     body('authId').isString().isIn([
@@ -129,6 +132,12 @@ const validator = {
  *          hideRestrictedByGroup:
  *            type: boolean
  *            description: enable hide by group
+ *      ShareLinkSetting:
+ *        type: object
+ *        properties:
+ *          disableLinkSharing:
+ *            type: boolean
+ *            description: disable link sharing
  *      LocalSetting:
  *        type: object
  *        properties:
@@ -364,6 +373,9 @@ module.exports = (crowi) => {
         wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
+      shareLinkSetting: {
+        disableLinkSharing: await crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+      },
       localSetting: {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
@@ -589,6 +601,47 @@ module.exports = (crowi) => {
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };
+
+      return res.apiv3({ securitySettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating security setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/share-link-setting:
+   *      put:
+   *        tags: [SecuritySetting, apiv3]
+   *        description: Update ShareLink Setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/ShareLinkSetting'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update ShareLink Setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/ShareLinkSetting'
+   */
+  router.put('/share-link-setting', loginRequiredStrictly, adminRequired, csrf, validator.generalSetting, apiV3FormValidator, async(req, res) => {
+    const updateData = {
+      'security:disableLinkSharing': req.body.disableLinkSharing,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      const securitySettingParams = {
+        disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+      };
+
       return res.apiv3({ securitySettingParams });
     }
     catch (err) {

+ 15 - 2
src/server/routes/apiv3/share-links.js

@@ -30,6 +30,19 @@ module.exports = (crowi) => {
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 
+  /**
+   * middleware to limit link sharing
+   */
+  const linkSharingRequired = (req, res, next) => {
+    const isLinkSharingDisabled = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+    logger.debug(`isLinkSharingDisabled: ${isLinkSharingDisabled}`);
+
+    if (isLinkSharingDisabled) {
+      return res.apiv3Err(new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'));
+    }
+    next();
+  };
+
   validator.getShareLinks = [
     // validate the page id is MongoId
     query('relatedPage').isMongoId().withMessage('Page Id is required'),
@@ -54,7 +67,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get share links
    */
-  router.get('/', loginRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
+  router.get('/', loginRequired, linkSharingRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -115,7 +128,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, linkSharingRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);

+ 21 - 14
src/server/routes/apiv3/slack-integration.js

@@ -104,20 +104,7 @@ module.exports = (crowi) => {
     const command = args[0];
 
     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) {
       logger.error(error);
@@ -161,6 +148,26 @@ module.exports = (crowi) => {
         await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
         break;
       }
+      case 'togetterShowMore': {
+        console.log('Show more here');
+        break;
+      }
+      case 'togetterCreatePage': {
+        console.log('Create page and delete the original message here');
+        break;
+      }
+      case 'togetterCancelPageCreation': {
+        console.log('Cancel here');
+        break;
+      }
+      case 'showMoreTogetterResults': {
+        const parsedValue = JSON.parse(payload.actions[0].value);
+
+        const { body, args, limit } = parsedValue;
+        const newLimit = limit + 10;
+        await crowi.slackBotService.togetterCommand(client, body, args, newLimit);
+        break;
+      }
       default:
         break;
     }

+ 3 - 0
src/server/routes/page.js

@@ -413,6 +413,9 @@ module.exports = function(crowi, app) {
       // page or sharelink are not found
       return res.render('layout-growi/not_found_shared_page');
     }
+    if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
+      return res.render('layout-growi/forbidden');
+    }
 
     const renderVars = {};
 

+ 6 - 0
src/server/service/config-loader.js

@@ -254,6 +254,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  DISABLE_LINK_SHARING: {
+    ns:      'crowi',
+    key:     'security:disableSharing',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   LOCAL_STRATEGY_ENABLED: {
     ns:      'crowi',
     key:     'security:passport-local:isEnabled',

+ 52 - 0
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;
+};

+ 20 - 0
src/server/service/slack-command-handler/help.js

@@ -0,0 +1,20 @@
+const { markdownSectionBlock } = require('@growi/slack');
+
+module.exports = () => {
+  const BaseSlackCommandHandler = require('./slack-command-handler');
+  const handler = new BaseSlackCommandHandler();
+
+  handler.handleCommand = (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: [
+        markdownSectionBlock(message),
+      ],
+    });
+  };
+
+  return handler;
+};

+ 250 - 0
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
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;

+ 70 - 0
src/server/service/slack-command-handler/togetter.js

@@ -0,0 +1,70 @@
+const {
+  inputBlock, actionsBlock, buttonElement, checkboxesElementOption,
+} = require('@growi/slack');
+
+module.exports = () => {
+  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 reusult = await client.conversations.history({
+      channel: body.channel_id,
+      limit,
+    });
+    console.log(reusult);
+    // Return Checkbox Message
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Select messages to use.',
+      blocks: this.togetterMessageBlocks(),
+    });
+    return;
+  };
+
+  handler.togetterMessageBlocks = function() {
+    return [
+      inputBlock(this.togetterCheckboxesElement(), 'selected_messages', 'Select massages to use.'),
+      actionsBlock(buttonElement('Show more', 'togetterShowMore')),
+      inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
+      actionsBlock(buttonElement('Cancel', 'togetterCancelPageCreation'), buttonElement('Create page', 'togetterCreatePage', 'primary')),
+    ];
+  };
+
+  handler.togetterCheckboxesElement = function() {
+    return {
+      type: 'checkboxes',
+      options: this.togetterCheckboxesElementOptions(),
+      action_id: 'checkboxes_changed',
+    };
+  };
+
+  handler.togetterCheckboxesElementOptions = function() {
+    // increment options with results from conversations.history
+    const options = [];
+    // temporary code
+    for (let i = 0; i < 10; i++) {
+      const option = checkboxesElementOption('*username*  12:00PM', 'sample slack messages ... :star:', `selected-${i}`);
+      options.push(option);
+    }
+    return options;
+  };
+
+  /**
+   * 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,
+    };
+  };
+
+  return handler;
+};

+ 20 - 317
src/server/service/slackbot.js

@@ -2,11 +2,10 @@
 const logger = require('@alias/logger')('growi:service:SlackBotService');
 const mongoose = require('mongoose');
 const axios = require('axios');
-const { formatDistanceStrict } = require('date-fns');
-
-const PAGINGLIMIT = 10;
 
+const { markdownSectionBlock } = require('@growi/slack');
 const { reshapeContentsBody } = require('@growi/slack');
+const { formatDistanceStrict } = require('date-fns');
 
 const S2sMessage = require('../models/vo/s2s-message');
 const S2sMessageHandlable = require('./s2s-messaging/handlable');
@@ -67,6 +66,20 @@ class SlackBotService extends 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({
@@ -74,91 +87,12 @@ class SlackBotService extends S2sMessageHandlable {
       user: body.user_id,
       text: 'No command',
       blocks: [
-        this.generateMarkdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    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),
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
       ],
     });
     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) {
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
   }
@@ -178,6 +112,7 @@ class SlackBotService extends S2sMessageHandlable {
     return '';
   }
 
+
   async shareSinglePage(client, payload) {
     const { channel, user, actions } = payload;
 
@@ -197,7 +132,7 @@ class SlackBotService extends S2sMessageHandlable {
       channel: channelId,
       blocks: [
         { type: 'divider' },
-        this.generateMarkdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
         {
           type: 'context',
           elements: [
@@ -219,202 +154,6 @@ class SlackBotService extends S2sMessageHandlable {
     });
   }
 
-  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: [
-          this.generateMarkdownSectionBlock('*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 = [
-      this.generateMarkdownSectionBlock(`: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: [
-          this.generateMarkdownSectionBlock(err.toString()),
-        ],
-      });
-      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 }),
-        },
-      });
-    }
-    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: [
-          this.generateMarkdownSectionBlock(`*Failed to create new page.*\n ${err}`),
-        ],
-      });
-      throw err;
-    }
-  }
-
   // Submit action in create Modal
   async createPageInGrowi(client, payload) {
     const Page = this.crowi.model('Page');
@@ -444,49 +183,13 @@ class SlackBotService extends S2sMessageHandlable {
       client.chat.postMessage({
         channel: payload.user.id,
         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 ${contentsBody}`)],
       });
       logger.error('Failed to create page in GROWI.');
       throw err;
     }
   }
 
-  generateMarkdownSectionBlock(blocks) {
-    return {
-      type: 'section',
-      text: {
-        type: 'mrkdwn',
-        text: blocks,
-      },
-    };
-  }
-
-  divider() {
-    return {
-      type: 'divider',
-    };
-  }
-
-  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,
-        },
-      },
-    };
-  }
-
 }
 
 module.exports = SlackBotService;