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

Merge branch 'master' into feat/admin-disable-link-sharing

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

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