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

Merge pull request #4011 from weseek/fix/search-with-slackbot

Fix/search with slackbot
Yuki Takei 4 лет назад
Родитель
Сommit
6f18ba3fb5

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

@@ -20,6 +20,7 @@ import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
 import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
 import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
 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');
@@ -48,6 +49,9 @@ export class GrowiToSlackCtrl {
   @Inject()
   @Inject()
   actionsBlockPayloadDelegator: ActionsBlockPayloadDelegator;
   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(), {
@@ -197,6 +201,11 @@ export class GrowiToSlackCtrl {
         this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
         this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
         req.body.blocks = JSON.stringify(parsedElement);
         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);
+      }
     }
     }
   }
   }
 
 
@@ -207,6 +216,8 @@ export class GrowiToSlackCtrl {
   ): 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');
     }
     }
@@ -236,17 +247,12 @@ export class GrowiToSlackCtrl {
       const opt = req.body;
       const opt = req.body;
       opt.headers = req.headers;
       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();
   }
   }
 
 
 }
 }

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

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

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

@@ -145,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': {
@@ -209,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;
     }
     }

+ 136 - 43
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,22 +146,76 @@ 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 dismissSearchResults(client, payload) {
+    const { response_url: responseUrl } = payload;
+
+    return axios.post(responseUrl, {
+      delete_original: true,
     });
     });
   }
   }
 
 
@@ -167,7 +223,7 @@ class SlackBotService extends S2sMessageHandlable {
 
 
     let searchResult;
     let searchResult;
     try {
     try {
-      searchResult = await this.getSearchResultPaths(client, body, args, offsetNum);
+      searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to get search results.', err);
       logger.error('Failed to get search results.', err);
@@ -182,44 +238,88 @@ class SlackBotService extends S2sMessageHandlable {
       throw new Error('/growi command:search: Failed to search');
       throw new Error('/growi command:search: Failed to search');
     }
     }
 
 
+    const appUrl = this.crowi.appService.getSiteUrl();
+    const appTitle = this.crowi.appService.getAppTitle();
+
     const {
     const {
-      resultPaths, offset, resultsTotal,
+      pages, offset, resultsTotal,
     } = searchResult;
     } = searchResult;
 
 
     const keywords = this.getKeywords(args);
     const keywords = this.getKeywords(args);
 
 
-    if (resultPaths.length === 0) {
-      return;
-    }
-
-    const appUrl = this.crowi.appService.getSiteUrl();
-    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 searchResultsNum = resultPaths.length;
     let searchResultsDesc;
     let searchResultsDesc;
 
 
-    switch (searchResultsNum) {
-      case 10:
-        searchResultsDesc = 'Maximum number of results that can be displayed is 10';
-        break;
-
+    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}.`;
+
+    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
     // DEFAULT show "Share" button
+    // const actionBlocks = {
+    //   type: 'actions',
+    //   elements: [
+    //     {
+    //       type: 'button',
+    //       text: {
+    //         type: 'plain_text',
+    //         text: 'Share',
+    //       },
+    //       style: 'primary',
+    //       action_id: 'shareSearchResults',
+    //     },
+    //   ],
+    // };
     const actionBlocks = {
     const actionBlocks = {
       type: 'actions',
       type: 'actions',
       elements: [
       elements: [
@@ -227,13 +327,10 @@ class SlackBotService extends S2sMessageHandlable {
           type: 'button',
           type: 'button',
           text: {
           text: {
             type: 'plain_text',
             type: 'plain_text',
-            text: 'Share',
+            text: 'Dismiss',
           },
           },
-          style: 'primary',
-          action_id: 'shareSearchResults',
-          value: JSON.stringify({
-            offset, body, args, pageList: `${keywordsAndDesc} \n\n ${urls.join('\n')}`,
-          }),
+          style: 'danger',
+          action_id: 'dismissSearchResults',
         },
         },
       ],
       ],
     };
     };
@@ -251,18 +348,14 @@ class SlackBotService extends S2sMessageHandlable {
         },
         },
       );
       );
     }
     }
+    blocks.push(actionBlocks);
 
 
     try {
     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) {