Sfoglia il codice sorgente

Merge pull request #3997 from weseek/imprv/6601-reshapecontentsbody-test

Imprv/6601 reshapecontentsbody test
stevenfukase 4 anni fa
parent
commit
51b75a7628
21 ha cambiato i file con 715 aggiunte e 197 eliminazioni
  1. 1 0
      packages/slack/package.json
  2. 6 0
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  3. 114 0
      packages/slack/src/utils/reshape-contents-body.test.ts
  4. 43 37
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  5. 28 0
      packages/slackbot-proxy/src/interfaces/growi-uri-injector.ts
  6. 0 5
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts
  7. 20 23
      packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts
  8. 84 0
      packages/slackbot-proxy/src/services/growi-uri-injector/ActionsBlockPayloadDelegator.ts
  9. 0 19
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts
  10. 0 7
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts
  11. 0 18
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts
  12. 67 0
      packages/slackbot-proxy/src/services/growi-uri-injector/SectionBlockPayloadDelegator.ts
  13. 62 0
      packages/slackbot-proxy/src/services/growi-uri-injector/ViewInteractionPayloadDelegator.ts
  14. 43 0
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts
  15. 57 0
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/CheckboxesActionPayloadDelegator.ts
  16. 0 10
      packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts
  17. 0 7
      packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts
  18. 6 4
      src/server/routes/apiv3/slack-integration.js
  19. 2 2
      src/server/service/search-delegator/elasticsearch.js
  20. 177 65
      src/server/service/slackbot.js
  21. 5 0
      yarn.lock

+ 1 - 0
packages/slack/package.json

@@ -15,6 +15,7 @@
     "test:lint:fix": "eslint src --ext .ts --fix"
     "test:lint:fix": "eslint src --ext .ts --fix"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@babel/helper-validator-identifier": "^7.14.5",
     "axios": "^0.21.1",
     "axios": "^0.21.1",
     "browser-bunyan": "^1.6.3",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",

+ 6 - 0
packages/slack/src/interfaces/request-between-growi-and-proxy.ts

@@ -6,6 +6,12 @@ export type RequestFromGrowi = Request & {
 
 
   // will be extracted from header
   // will be extracted from header
   tokenGtoPs: string[],
   tokenGtoPs: string[],
+
+  // Block Kit properties
+  body: {
+    view?: string,
+    blocks?: string
+  },
 };
 };
 
 
 export type RequestFromProxy = Request & {
 export type RequestFromProxy = Request & {

+ 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);
+    });
+  });
+
+});

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

@@ -3,7 +3,7 @@ import {
 } from '@tsed/common';
 } from '@tsed/common';
 import axios from 'axios';
 import axios from 'axios';
 
 
-import { WebAPICallOptions, WebAPICallResult } from '@slack/web-api';
+import { WebAPICallResult } from '@slack/web-api';
 
 
 import {
 import {
   verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
   verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
@@ -18,8 +18,9 @@ import { OrderRepository } from '~/repositories/order';
 
 
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import { findInjectorByType } from '~/services/growi-uri-injector/GrowiUriInjectorFactory';
-import { injectGrowiUriToView } from '~/utils/injectGrowiUriToView';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
+import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
+import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/SectionBlockPayloadDelegator';
 
 
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
@@ -42,6 +43,15 @@ export class GrowiToSlackCtrl {
   @Inject()
   @Inject()
   orderRepository: OrderRepository;
   orderRepository: OrderRepository;
 
 
+  @Inject()
+  viewInteractionPayloadDelegator: ViewInteractionPayloadDelegator;
+
+  @Inject()
+  actionsBlockPayloadDelegator: ActionsBlockPayloadDelegator;
+
+  @Inject()
+  sectionBlockPayloadDelegator: SectionBlockPayloadDelegator;
+
   async requestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
   async requestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
     const url = new URL('/_api/v3/slack-integration/proxied/commands', growiUrl);
     const url = new URL('/_api/v3/slack-integration/proxied/commands', growiUrl);
     await axios.post(url.toString(), {
     await axios.post(url.toString(), {
@@ -171,45 +181,43 @@ export class GrowiToSlackCtrl {
     return res.send({ relation: createdRelation, slackBotToken: token });
     return res.send({ relation: createdRelation, slackBotToken: token });
   }
   }
 
 
-  injectGrowiUri(req:GrowiReq, growiUri:string):WebAPICallOptions {
+  injectGrowiUri(req: GrowiReq, growiUri: string): void {
+    if (req.body.view == null && req.body.blocks == null) {
+      return;
+    }
 
 
     if (req.body.view != null) {
     if (req.body.view != null) {
-      injectGrowiUriToView(req.body, growiUri);
+      const parsedElement = JSON.parse(req.body.view);
+      // delegate to ViewInteractionPayloadDelegator
+      if (this.viewInteractionPayloadDelegator.shouldHandleToInject(parsedElement)) {
+        this.viewInteractionPayloadDelegator.inject(parsedElement, growiUri);
+        req.body.view = JSON.stringify(parsedElement);
+      }
     }
     }
-
-    if (req.body.blocks != null) {
-      const parsedBlocks = JSON.parse(req.body.blocks as string);
-
-      parsedBlocks.forEach((parsedBlock) => {
-        if (parsedBlock.type !== 'actions') {
-          return;
-        }
-        parsedBlock.elements.forEach((element) => {
-          const growiUriInjector = findInjectorByType(element.type);
-          if (growiUriInjector != null) {
-            growiUriInjector.inject(element, growiUri);
-          }
-        });
-
-        return;
-      });
-
-      req.body.blocks = JSON.stringify(parsedBlocks);
+    else if (req.body.blocks != null) {
+      const parsedElement = JSON.parse(req.body.blocks);
+      // delegate to ActionsBlockPayloadDelegator
+      if (this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
+        this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
+        req.body.blocks = JSON.stringify(parsedElement);
+      }
+      // delegate to SectionBlockPayloadDelegator
+      if (this.sectionBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
+        this.sectionBlockPayloadDelegator.inject(parsedElement, growiUri);
+        req.body.blocks = JSON.stringify(parsedElement);
+      }
     }
     }
-
-    const opt = req.body;
-    opt.headers = req.headers;
-
-    return opt;
   }
   }
 
 
   @Post('/:method')
   @Post('/:method')
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
-  async postResult(
+  async callSlackApi(
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
   ): Promise<void|string|Res|WebAPICallResult> {
   ): Promise<void|string|Res|WebAPICallResult> {
     const { tokenGtoPs } = req;
     const { tokenGtoPs } = req;
 
 
+    logger.debug('Slack API called: ', { method });
+
     if (tokenGtoPs.length !== 1) {
     if (tokenGtoPs.length !== 1) {
       return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
       return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
     }
     }
@@ -234,19 +242,17 @@ export class GrowiToSlackCtrl {
     const client = generateWebClient(token);
     const client = generateWebClient(token);
 
 
     try {
     try {
-      const opt = this.injectGrowiUri(req, relation.growiUri);
+      this.injectGrowiUri(req, relation.growiUri);
+
+      const opt = req.body;
+      opt.headers = req.headers;
 
 
-      await client.apiCall(method, opt);
+      return client.apiCall(method, opt);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
       return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
     }
     }
-
-    logger.debug('send to slack is success');
-
-    // required to return ok for apiCall
-    return res.webClient();
   }
   }
 
 
 }
 }

+ 28 - 0
packages/slackbot-proxy/src/interfaces/growi-uri-injector.ts

@@ -0,0 +1,28 @@
+export type GrowiUriWithOriginalData = {
+  growiUri: string,
+  originalData: string,
+}
+
+export type TypedBlock = {
+  type: string,
+}
+
+/**
+ * Type guard for GrowiUriWithOriginalData
+ * @param data
+ * @returns
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const isGrowiUriWithOriginalData = (data: any): data is GrowiUriWithOriginalData => {
+  return data.growiUri != null && data.originalData != null;
+};
+
+export interface GrowiUriInjector<ISDATA, IDATA, ESDATA, EDATA> {
+
+  shouldHandleToInject(data: ISDATA & any): data is IDATA;
+  inject(data: IDATA, growiUri:string): void;
+
+  shouldHandleToExtract(data: ESDATA & any): data is EDATA;
+  extract(data: EDATA): GrowiUriWithOriginalData;
+
+}

+ 0 - 5
packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts

@@ -4,7 +4,6 @@ import {
 
 
 
 
 export type WebclientRes = Res & {
 export type WebclientRes = Res & {
-  webClient: () => void,
   webClientErr: (message?:string, errorCode?:string) => void
   webClientErr: (message?:string, errorCode?:string) => void
 };
 };
 
 
@@ -14,10 +13,6 @@ export class AddWebclientResponseToRes implements IMiddleware {
 
 
   use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
   use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
 
 
-    res.webClient = () => {
-      return res.send({ ok: true });
-    };
-
     res.webClientErr = (error?:string, errorCode?:string) => {
     res.webClientErr = (error?:string, errorCode?:string) => {
       return res.send({ ok: false, error, errorCode });
       return res.send({ ok: false, error, errorCode });
     };
     };

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

@@ -1,43 +1,40 @@
 import {
 import {
-  IMiddleware, Middleware, Next, Req, Res,
+  IMiddleware, Inject, Middleware, Next, Req, Res,
 } from '@tsed/common';
 } from '@tsed/common';
+
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { growiUriInjectorFactory } from '~/services/growi-uri-injector/GrowiUriInjectorFactory';
-import { extractGrowiUriFromView } from '~/utils/extractGrowiUriFromView';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
+import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
+
 
 
 @Middleware()
 @Middleware()
 export class ExtractGrowiUriFromReq implements IMiddleware {
 export class ExtractGrowiUriFromReq implements IMiddleware {
 
 
-  use(@Req() req: Req & SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
+  @Inject()
+  viewInteractionPayloadDelegator: ViewInteractionPayloadDelegator;
+
+  @Inject()
+  actionsBlockPayloadDelegator: ActionsBlockPayloadDelegator;
+
+  use(@Req() req: SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
 
 
     // There is no payload in the request from slack
     // There is no payload in the request from slack
     if (req.body.payload == null) {
     if (req.body.payload == null) {
       return next();
       return next();
     }
     }
 
 
-    const payload = JSON.parse(req.body.payload);
+    const parsedPayload = JSON.parse(req.body.payload);
 
 
-    // extract for modal
-    if (payload.view != null) {
-      const extractedValues = extractGrowiUriFromView(payload.view);
-      req.growiUri = extractedValues.growiUri;
-      payload.view.private_metadata = extractedValues.originalData;
+    if (this.viewInteractionPayloadDelegator.shouldHandleToExtract(parsedPayload)) {
+      const data = this.viewInteractionPayloadDelegator.extract(parsedPayload);
+      req.growiUri = data.growiUri;
     }
     }
-    else {
-      // break when uri is found
-      for (const type of Object.keys(growiUriInjectorFactory)) {
-        const growiUriInjector = growiUriInjectorFactory[type]();
-        const extractedValues = growiUriInjector.extract(payload.actions[0]);
-
-        if (extractedValues.growiUri != null) {
-          req.growiUri = extractedValues.growiUri;
-          payload.actions[0].value = JSON.stringify(extractedValues.originalData);
-          break;
-        }
-      }
+    else if (this.actionsBlockPayloadDelegator.shouldHandleToExtract(parsedPayload)) {
+      const data = this.actionsBlockPayloadDelegator.extract(parsedPayload);
+      req.growiUri = data.growiUri;
     }
     }
 
 
-    req.body.payload = JSON.stringify(payload);
+    req.body.payload = JSON.stringify(parsedPayload);
 
 
     return next();
     return next();
   }
   }

+ 84 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/ActionsBlockPayloadDelegator.ts

@@ -0,0 +1,84 @@
+import { Inject, OnInit, Service } from '@tsed/di';
+import {
+  GrowiUriInjector, GrowiUriWithOriginalData, TypedBlock,
+} from '~/interfaces/growi-uri-injector';
+import { ButtonActionPayloadDelegator } from './block-elements/ButtonActionPayloadDelegator';
+import { CheckboxesActionPayloadDelegator } from './block-elements/CheckboxesActionPayloadDelegator';
+
+
+// see: https://api.slack.com/reference/block-kit/blocks
+type BlockElement = TypedBlock & {
+  elements: (TypedBlock & any)[],
+}
+
+// see: https://api.slack.com/reference/interaction-payloads/block-actions
+type BlockActionsPayload = TypedBlock & {
+  actions: TypedBlock[],
+}
+
+@Service()
+export class ActionsBlockPayloadDelegator implements GrowiUriInjector<any, BlockElement[], any, BlockActionsPayload>, OnInit {
+
+  @Inject()
+  buttonActionPayloadDelegator: ButtonActionPayloadDelegator;
+
+  @Inject()
+  checkboxesActionPayloadDelegator: CheckboxesActionPayloadDelegator;
+
+  private childDelegators: GrowiUriInjector<TypedBlock[], any, TypedBlock, any>[] = [];
+
+  $onInit(): void | Promise<any> {
+    this.childDelegators.push(
+      this.buttonActionPayloadDelegator,
+      this.checkboxesActionPayloadDelegator,
+    );
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToInject(data: any): data is BlockElement[] {
+    const actionsBlocks = data.filter(blockElement => blockElement.type === 'actions');
+    return actionsBlocks.length > 0;
+  }
+
+  inject(data: BlockElement[], growiUri: string): void {
+    const actionsBlocks = data.filter(blockElement => blockElement.type === 'actions');
+
+    // collect elements
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const elements = actionsBlocks.flatMap(actionBlock => actionBlock.elements!);
+
+    this.childDelegators.forEach((delegator) => {
+      if (delegator.shouldHandleToInject(elements)) {
+        delegator.inject(elements, growiUri);
+      }
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToExtract(data: any): data is BlockActionsPayload {
+    if (data.actions == null || data.actions.length === 0) {
+      return false;
+    }
+
+    const action = data.actions[0];
+    return this.childDelegators
+      .map(delegator => delegator.shouldHandleToExtract(action))
+      .includes(true);
+  }
+
+  extract(data: BlockActionsPayload): GrowiUriWithOriginalData {
+    let growiUriWithOriginalData: GrowiUriWithOriginalData;
+
+    const action = data.actions[0];
+    for (const delegator of this.childDelegators) {
+      if (delegator.shouldHandleToExtract(action)) {
+        growiUriWithOriginalData = delegator.extract(action);
+        break;
+      }
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return growiUriWithOriginalData!;
+  }
+
+}

+ 0 - 19
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts

@@ -1,19 +0,0 @@
-import { GrowiUriInjector } from './GrowiUriInjector';
-
-export class GrowiUriInjectionButtonDelegator implements GrowiUriInjector {
-
-  inject(element: {value:string}, growiUri:string): void {
-    const parsedValue = JSON.parse(element.value);
-    const originalData = JSON.stringify(parsedValue);
-    element.value = JSON.stringify({ growiUri, originalData });
-  }
-
-  extract(action: {value:string}): {growiUri?:string, originalData:any} {
-    const parsedValues = JSON.parse(action.value);
-    if (parsedValues.originalData != null) {
-      parsedValues.originalData = JSON.parse(parsedValues.originalData);
-    }
-    return parsedValues;
-  }
-
-}

+ 0 - 7
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts

@@ -1,7 +0,0 @@
-
-export interface GrowiUriInjector {
-
-  inject(body: any, growiUri:string): void;
-
-  extract(body: any):any;
-}

+ 0 - 18
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts

@@ -1,18 +0,0 @@
-import { GrowiUriInjector } from './GrowiUriInjector';
-import { GrowiUriInjectionButtonDelegator } from './GrowiUriInjectionButtonDelegator';
-
-/**
- * Instanciate GrowiUriInjector
- */
-export const growiUriInjectorFactory = {
-  button: (): GrowiUriInjector => {
-    return new GrowiUriInjectionButtonDelegator();
-  },
-};
-
-export const findInjectorByType = (type:string): null|GrowiUriInjector => {
-  if (!Object.keys(growiUriInjectorFactory).includes(type)) {
-    return null;
-  }
-  return growiUriInjectorFactory[type]();
-};

+ 67 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/SectionBlockPayloadDelegator.ts

@@ -0,0 +1,67 @@
+import { Inject, OnInit, Service } from '@tsed/di';
+import {
+  GrowiUriInjector, GrowiUriWithOriginalData, TypedBlock,
+} from '~/interfaces/growi-uri-injector';
+import { ButtonActionPayloadDelegator } from './block-elements/ButtonActionPayloadDelegator';
+import { CheckboxesActionPayloadDelegator } from './block-elements/CheckboxesActionPayloadDelegator';
+
+
+// see: https://api.slack.com/reference/block-kit/blocks#section
+type SectionWithAccessoryElement = TypedBlock & {
+  accessory: TypedBlock & any,
+}
+
+// see: https://api.slack.com/reference/interaction-payloads/block-actions
+type BlockActionsPayload = TypedBlock & {
+  actions: TypedBlock[],
+}
+
+@Service()
+export class SectionBlockPayloadDelegator implements GrowiUriInjector<any, SectionWithAccessoryElement[], any, BlockActionsPayload>, OnInit {
+
+  @Inject()
+  buttonActionPayloadDelegator: ButtonActionPayloadDelegator;
+
+  @Inject()
+  checkboxesActionPayloadDelegator: CheckboxesActionPayloadDelegator;
+
+  private childDelegators: GrowiUriInjector<TypedBlock[], any, TypedBlock, any>[] = [];
+
+  $onInit(): void | Promise<any> {
+    this.childDelegators.push(
+      this.buttonActionPayloadDelegator,
+      this.checkboxesActionPayloadDelegator,
+    );
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToInject(data: any): data is SectionWithAccessoryElement[] {
+    const sectionBlocks = data.filter(blockElement => blockElement.type === 'section' && blockElement.accessory != null);
+    return sectionBlocks.length > 0;
+  }
+
+  inject(data: SectionWithAccessoryElement[], growiUri: string): void {
+    const sectionBlocks = data.filter(blockElement => blockElement.type === 'section' && blockElement.accessory != null);
+
+    // collect elements
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const accessories = sectionBlocks.flatMap(sectionBlock => sectionBlock.accessory);
+
+    this.childDelegators.forEach((delegator) => {
+      if (delegator.shouldHandleToInject(accessories)) {
+        delegator.inject(accessories, growiUri);
+      }
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToExtract(data: any): data is BlockActionsPayload {
+    return false;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  extract(data: BlockActionsPayload): GrowiUriWithOriginalData {
+    throw new Error('No need to implement. Use ActionsBlockPayloadDelegator');
+  }
+
+}

+ 62 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/ViewInteractionPayloadDelegator.ts

@@ -0,0 +1,62 @@
+import { Service } from '@tsed/di';
+import {
+  GrowiUriInjector, GrowiUriWithOriginalData, isGrowiUriWithOriginalData, TypedBlock,
+} from '~/interfaces/growi-uri-injector';
+
+// see: https://api.slack.com/reference/interaction-payloads/views
+type ViewElement = TypedBlock & {
+  'private_metadata'?: any,
+}
+
+// see: https://api.slack.com/reference/interaction-payloads/views
+type ViewInteractionPayload = TypedBlock & {
+  view: {
+    'private_metadata'?: any,
+  },
+}
+
+@Service()
+export class ViewInteractionPayloadDelegator implements GrowiUriInjector<any, ViewElement, any, ViewInteractionPayload> {
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToInject(data: any): data is ViewElement {
+    return data.type != null && data.private_metadata != null;
+  }
+
+  inject(data: ViewElement, growiUri :string): void {
+    const originalData = data.private_metadata;
+
+    const urlWithOrgData: GrowiUriWithOriginalData = { growiUri, originalData };
+
+    data.private_metadata = JSON.stringify(urlWithOrgData);
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToExtract(data: any): data is ViewInteractionPayload {
+    const { type, view } = data;
+    if (type !== 'view_submission') {
+      return false;
+    }
+    if (view.private_metadata == null) {
+      return false;
+    }
+
+    try {
+      const restoredData: any = JSON.parse(view.private_metadata);
+      return isGrowiUriWithOriginalData(restoredData);
+    }
+    // when parsing failed
+    catch (err) {
+      return false;
+    }
+  }
+
+  extract(data: ViewInteractionPayload): GrowiUriWithOriginalData {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const restoredData: GrowiUriWithOriginalData = JSON.parse(data.view.private_metadata!); // private_metadata must not be null at this moment
+    data.view.private_metadata = restoredData.originalData;
+
+    return restoredData;
+  }
+
+}

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

@@ -0,0 +1,43 @@
+import { Service } from '@tsed/di';
+import { GrowiUriWithOriginalData, GrowiUriInjector, TypedBlock } from '~/interfaces/growi-uri-injector';
+
+
+type ButtonElement = TypedBlock & {
+  value: string,
+}
+
+type ButtonActionPayload = TypedBlock & {
+  value: string,
+}
+
+@Service()
+export class ButtonActionPayloadDelegator implements GrowiUriInjector<TypedBlock[], ButtonElement[], TypedBlock, ButtonActionPayload> {
+
+  shouldHandleToInject(elements: TypedBlock[]): elements is ButtonElement[] {
+    const buttonElements = elements.filter(element => element.type === 'button');
+    return buttonElements.length > 0;
+  }
+
+  inject(elements: ButtonElement[], growiUri: string): void {
+    const buttonElements = elements.filter(blockElement => blockElement.type === 'button');
+
+    buttonElements
+      .forEach((element) => {
+        const urlWithOrgData: GrowiUriWithOriginalData = { growiUri, originalData: element.value };
+        element.value = JSON.stringify(urlWithOrgData);
+      });
+  }
+
+  shouldHandleToExtract(action: TypedBlock): action is ButtonActionPayload {
+    return action.type === 'button';
+  }
+
+  extract(action: ButtonActionPayload): GrowiUriWithOriginalData {
+    const restoredData: GrowiUriWithOriginalData = JSON.parse(action.value);
+    action.value = restoredData.originalData;
+
+    return restoredData;
+  }
+
+
+}

+ 57 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/CheckboxesActionPayloadDelegator.ts

@@ -0,0 +1,57 @@
+import { Service } from '@tsed/di';
+import { GrowiUriWithOriginalData, GrowiUriInjector, TypedBlock } from '~/interfaces/growi-uri-injector';
+
+
+type CheckboxesElement = TypedBlock & {
+  options: { value: string }[],
+}
+
+type CheckboxesActionPayload = TypedBlock & {
+  'selected_options': { value: string }[],
+}
+
+@Service()
+export class CheckboxesActionPayloadDelegator implements GrowiUriInjector<TypedBlock[], CheckboxesElement[], TypedBlock, CheckboxesActionPayload> {
+
+  shouldHandleToInject(elements: TypedBlock[]): elements is CheckboxesElement[] {
+    const buttonElements = elements.filter(element => element.type === 'checkboxes');
+    return buttonElements.length > 0;
+  }
+
+  inject(elements: CheckboxesElement[], growiUri: string): void {
+    const cbElements = elements.filter(blockElement => blockElement.type === 'checkboxes');
+
+    cbElements.forEach((element) => {
+      element.options.forEach((option) => {
+        const urlWithOrgData: GrowiUriWithOriginalData = { growiUri, originalData: option.value };
+        option.value = JSON.stringify(urlWithOrgData);
+      });
+    });
+  }
+
+  shouldHandleToExtract(action: TypedBlock): action is CheckboxesActionPayload {
+    return (
+      action.type === 'checkboxes'
+      && (action as CheckboxesActionPayload).selected_options != null
+      // ...Unsolved problem...
+      // slackbot-proxy can't determine growiUri when selected_options is empty -- 2021.07.12 Yuki Takei
+      && (action as CheckboxesActionPayload).selected_options.length > 0
+    );
+  }
+
+  extract(action: CheckboxesActionPayload): GrowiUriWithOriginalData {
+    let oneOfRestoredData: GrowiUriWithOriginalData;
+
+    action.selected_options.forEach((selectedOption) => {
+      const restoredData = JSON.parse(selectedOption.value);
+      selectedOption.value = restoredData.originalData;
+
+      // update oneOfRestoredData
+      oneOfRestoredData = restoredData;
+    });
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return oneOfRestoredData!;
+  }
+
+}

+ 0 - 10
packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts

@@ -1,10 +0,0 @@
-export const extractGrowiUriFromView = (view:{'private_metadata': string}): {growiUri?:string, originalData:{[key:string]:any}} => {
-  const parsedValues = JSON.parse(view.private_metadata);
-  if (parsedValues.originalData != null) {
-    parsedValues.originalData = JSON.parse(parsedValues.originalData);
-  }
-  else {
-    parsedValues.originalData = view.private_metadata;
-  }
-  return parsedValues;
-};

+ 0 - 7
packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts

@@ -1,7 +0,0 @@
-export const injectGrowiUriToView = (body: {view:string}, growiUri:string): void => {
-  const parsedView = JSON.parse(body.view);
-  const originalData = JSON.stringify(parsedView.private_metadata);
-
-  parsedView.private_metadata = JSON.stringify({ growiUri, originalData });
-  body.view = JSON.stringify(parsedView);
-};

+ 6 - 4
src/server/routes/apiv3/slack-integration.js

@@ -121,7 +121,6 @@ module.exports = (crowi) => {
     }
     }
     catch (error) {
     catch (error) {
       logger.error(error);
       logger.error(error);
-      return res.send(error.message);
     }
     }
   }
   }
 
 
@@ -146,8 +145,12 @@ module.exports = (crowi) => {
     const { action_id: actionId } = payload.actions[0];
     const { action_id: actionId } = payload.actions[0];
 
 
     switch (actionId) {
     switch (actionId) {
-      case 'shareSearchResults': {
-        await crowi.slackBotService.shareSearchResults(client, payload);
+      case 'shareSingleSearchResult': {
+        await crowi.slackBotService.shareSinglePage(client, payload);
+        break;
+      }
+      case 'dismissSearchResults': {
+        await crowi.slackBotService.dismissSearchResults(client, payload);
         break;
         break;
       }
       }
       case 'showNextResults': {
       case 'showNextResults': {
@@ -210,7 +213,6 @@ module.exports = (crowi) => {
     }
     }
     catch (error) {
     catch (error) {
       logger.error(error);
       logger.error(error);
-      return res.send(error.message);
     }
     }
 
 
   }
   }

+ 2 - 2
src/server/service/search-delegator/elasticsearch.js

@@ -556,7 +556,7 @@ class ElasticsearchDelegator {
 
 
   createSearchQuerySortedByUpdatedAt(option) {
   createSearchQuerySortedByUpdatedAt(option) {
     // getting path by default is almost for debug
     // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
     if (option) {
     if (option) {
       fields = option.fields || fields;
       fields = option.fields || fields;
     }
     }
@@ -577,7 +577,7 @@ class ElasticsearchDelegator {
   }
   }
 
 
   createSearchQuerySortedByScore(option) {
   createSearchQuerySortedByScore(option) {
-    let fields = ['path', 'bookmark_count', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
     if (option) {
     if (option) {
       fields = option.fields || fields;
       fields = option.fields || fields;
     }
     }

+ 177 - 65
src/server/service/slackbot.js

@@ -1,6 +1,8 @@
 
 
 const logger = require('@alias/logger')('growi:service:SlackBotService');
 const logger = require('@alias/logger')('growi:service:SlackBotService');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
+const axios = require('axios');
+const { formatDistanceStrict } = require('date-fns');
 
 
 const PAGINGLIMIT = 10;
 const PAGINGLIMIT = 10;
 
 
@@ -97,7 +99,7 @@ class SlackBotService extends S2sMessageHandlable {
     return keywords;
     return keywords;
   }
   }
 
 
-  async getSearchResultPaths(client, body, args, offset = 0) {
+  async retrieveSearchResults(client, body, args, offset = 0) {
     const firstKeyword = args[1];
     const firstKeyword = args[1];
     if (firstKeyword == null) {
     if (firstKeyword == null) {
       client.chat.postEphemeral({
       client.chat.postEphemeral({
@@ -144,119 +146,229 @@ class SlackBotService extends S2sMessageHandlable {
           this.generateMarkdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
           this.generateMarkdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
         ],
         ],
       });
       });
-      return { resultPaths: [] };
+      return { pages: [] };
     }
     }
 
 
-    const resultPaths = results.data.map((data) => {
-      return data._source.path;
+    const pages = results.data.map((data) => {
+      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      return { path, updatedAt, commentCount };
     });
     });
 
 
     return {
     return {
-      resultPaths, offset, resultsTotal,
+      pages, offset, resultsTotal,
     };
     };
   }
   }
 
 
-  async shareSearchResults(client, payload) {
-    client.chat.postMessage({
-      channel: payload.channel.id,
-      text: JSON.parse(payload.actions[0].value).pageList,
+  generatePageLinkMrkdwn(pathname, href) {
+    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  }
+
+  appendSpeechBaloon(mrkdwn, commentCount) {
+    return (commentCount != null && commentCount > 0)
+      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
+      : mrkdwn;
+  }
+
+  generateLastUpdateMrkdwn(updatedAt, baseDate) {
+    if (updatedAt != null) {
+      // cast to date
+      const date = new Date(updatedAt);
+      return formatDistanceStrict(date, baseDate);
+    }
+    return '';
+  }
+
+  async shareSinglePage(client, payload) {
+    const { channel, user, actions } = payload;
+
+    const appUrl = this.crowi.appService.getSiteUrl();
+    const appTitle = this.crowi.appService.getAppTitle();
+
+    const channelId = channel.id;
+    const action = actions[0]; // shareSinglePage action must have button action
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(action.value);
+    const { updatedAt, commentCount } = page;
+
+    // share
+    const now = new Date();
+    return client.chat.postMessage({
+      channel: channelId,
+      blocks: [
+        { type: 'divider' },
+        this.generateMarkdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+            },
+          ],
+        },
+      ],
     });
     });
   }
   }
 
 
-  async showEphemeralSearchResults(client, body, args, offsetNum) {
-    const {
-      resultPaths, offset, resultsTotal,
-    } = await this.getSearchResultPaths(client, body, args, offsetNum);
+  async dismissSearchResults(client, payload) {
+    const { response_url: responseUrl } = payload;
 
 
-    const keywords = this.getKeywords(args);
+    return axios.post(responseUrl, {
+      delete_original: true,
+    });
+  }
 
 
-    if (resultPaths.length === 0) {
-      return;
+  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 appUrl = this.crowi.appService.getSiteUrl();
     const appTitle = this.crowi.appService.getAppTitle();
     const appTitle = this.crowi.appService.getAppTitle();
 
 
-    const urls = resultPaths.map((path) => {
-      const url = new URL(path, appUrl);
-      return `<${decodeURI(url.href)} | ${decodeURI(url.pathname)}>`;
-    });
+    const {
+      pages, offset, resultsTotal,
+    } = searchResult;
 
 
-    const searchResultsNum = resultPaths.length;
-    let searchResultsDesc;
+    const keywords = this.getKeywords(args);
 
 
-    switch (searchResultsNum) {
-      case 10:
-        searchResultsDesc = 'Maximum number of results that can be displayed is 10';
-        break;
 
 
+    let searchResultsDesc;
+
+    switch (resultsTotal) {
       case 1:
       case 1:
-        searchResultsDesc = `${searchResultsNum} page is found`;
+        searchResultsDesc = `*${resultsTotal}* page is found.`;
         break;
         break;
 
 
       default:
       default:
-        searchResultsDesc = `${searchResultsNum} pages are found`;
+        searchResultsDesc = `*${resultsTotal}* pages are found.`;
         break;
         break;
     }
     }
 
 
-    const keywordsAndDesc = `keyword(s) : "${keywords}" \n ${searchResultsDesc}.`;
 
 
-    try {
-      // DEFAULT show "Share" button
-      const actionBlocks = {
-        type: 'actions',
-        elements: [
-          {
+    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',
             type: 'button',
+            action_id: 'shareSingleSearchResult',
             text: {
             text: {
               type: 'plain_text',
               type: 'plain_text',
               text: 'Share',
               text: 'Share',
             },
             },
-            style: 'primary',
-            action_id: 'shareSearchResults',
-            value: JSON.stringify({
-              offset, body, args, pageList: `${keywordsAndDesc} \n\n ${urls.join('\n')}`,
-            }),
+            value: JSON.stringify({ page, href, pathname }),
           },
           },
-        ],
-      };
-      // 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 }),
+        };
+      }),
+      { 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({
       await client.chat.postEphemeral({
         channel: body.channel_id,
         channel: body.channel_id,
         user: body.user_id,
         user: body.user_id,
         text: 'Successed To Search',
         text: 'Successed To Search',
-        blocks: [
-          this.generateMarkdownSectionBlock(`<${decodeURI(appUrl)}|*${appTitle}*>`),
-          this.generateMarkdownSectionBlock(keywordsAndDesc),
-          this.generateMarkdownSectionBlock(`${urls.join('\n')}`),
-          actionBlocks,
-        ],
+        blocks,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('Failed to get search results.', err);
+      logger.error('Failed to post ephemeral message.', err);
       await client.chat.postEphemeral({
       await client.chat.postEphemeral({
         channel: body.channel_id,
         channel: body.channel_id,
         user: body.user_id,
         user: body.user_id,
-        text: 'Failed To Search',
+        text: 'Failed to post ephemeral message.',
         blocks: [
         blocks: [
-          this.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
+          this.generateMarkdownSectionBlock(err.toString()),
         ],
         ],
       });
       });
-      throw new Error('/growi command:search: Failed to search');
+      throw new Error(err);
     }
     }
   }
   }
 
 

+ 5 - 0
yarn.lock

@@ -569,6 +569,11 @@
   dependencies:
   dependencies:
     "@babel/types" "^7.8.3"
     "@babel/types" "^7.8.3"
 
 
+"@babel/helper-validator-identifier@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz#d0f0e277c512e0c938277faa85a3968c9a44c0e8"
+  integrity sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==
+
 "@babel/helper-wrap-function@^7.1.0":
 "@babel/helper-wrap-function@^7.1.0":
   version "7.2.0"
   version "7.2.0"
   resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa"
   resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa"