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

imprv: Slackbot search (#4420)

* improve layout

* improve paging size

* commonize generating search result blocks

* add prev button

* replace original with prev/next button

* respond with in_channel type when share button is clicked

* add a link to result page
Yuki Takei 4 лет назад
Родитель
Сommit
a7ed758450

+ 151 - 244
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,33 +3,68 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const {
-  markdownSectionBlock, divider, respond, deleteOriginal,
+  markdownSectionBlock, divider, respond, respondInChannel, replaceOriginal, deleteOriginal,
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 const SlackbotError = require('../../models/vo/slackbot-error');
 
-const PAGINGLIMIT = 10;
+const PAGINGLIMIT = 7;
+
 
 module.exports = (crowi) => {
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler(crowi);
 
-  handler.handleCommand = async function(growiCommand, client, body) {
-    const { responseUrl, growiCommandArgs } = growiCommand;
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(responseUrl, client, body, growiCommandArgs);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      throw new SlackbotError({
-        method: 'postEphemeral',
-        to: 'channel',
-        popupMessage: 'Failed To Search',
-        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
-      });
+
+  function getKeywords(growiCommandArgs) {
+    const keywords = growiCommandArgs.join(' ');
+    return keywords;
+  }
+
+  function appendSpeechBaloon(mrkdwn, commentCount) {
+    return (commentCount != null && commentCount > 0)
+      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
+      : mrkdwn;
+  }
+
+  function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) {
+    const url = new URL('/_search', appUrl);
+    url.searchParams.append('q', growiCommandArgs.map(kwd => encodeURIComponent(kwd)).join('+'));
+    return `<${url.href} | Results page>`;
+  }
+
+  function generatePageLinkMrkdwn(pathname, href) {
+    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  }
+
+  function generateLastUpdateMrkdwn(updatedAt, baseDate) {
+    if (updatedAt != null) {
+      // cast to date
+      const date = new Date(updatedAt);
+      return formatDistanceStrict(date, baseDate);
     }
+    return '';
+  }
+
+  async function retrieveSearchResults(growiCommandArgs, offset = 0) {
+    const keywords = getKeywords(growiCommandArgs);
+
+    const { searchService } = crowi;
+    const options = { limit: PAGINGLIMIT, offset };
+    const results = await searchService.searchKeyword(keywords, null, {}, options);
+    const resultsTotal = results.meta.total;
 
+    const pages = results.data.map((data) => {
+      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      return { path, updatedAt, commentCount };
+    });
+
+    return {
+      pages, offset, resultsTotal,
+    };
+  }
+
+  function buildRespondBodyForSearchResult(searchResult, growiCommandArgs) {
     const appUrl = crowi.appService.getSiteUrl();
     const appTitle = crowi.appService.getAppTitle();
 
@@ -37,22 +72,9 @@ module.exports = (crowi) => {
       pages, offset, resultsTotal,
     } = searchResult;
 
-    const keywords = this.getKeywords(growiCommandArgs);
-
+    const keywords = getKeywords(growiCommandArgs);
 
     let searchResultsDesc;
-
-    if (resultsTotal === 0 || resultsTotal == null) {
-      if (keywords === '') return;
-      await respond(responseUrl, {
-        text: 'No page found.',
-        blocks: [
-          markdownSectionBlock(`No page found. keyword(s): *"${keywords}"*`),
-          markdownSectionBlock('Please try other keywords.'),
-        ],
-      });
-      return;
-    }
     switch (resultsTotal) {
       case 1:
         searchResultsDesc = `*${resultsTotal}* page is found.`;
@@ -62,13 +84,15 @@ module.exports = (crowi) => {
         break;
     }
 
-
     const contextBlock = {
       type: 'context',
       elements: [
         {
           type: 'mrkdwn',
-          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+          text: `keyword(s) : *"${keywords}"*`
+          + `  |  Total ${resultsTotal} pages`
+          + `  |  Current: ${offset + 1} - ${offset + pages.length}`
+          + `  |  ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`,
         },
       ],
     };
@@ -89,8 +113,8 @@ module.exports = (crowi) => {
           type: 'section',
           text: {
             type: 'mrkdwn',
-            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
-              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+            text: `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `  \`${generateLastUpdateMrkdwn(updatedAt, now)}\``,
           },
           accessory: {
             type: 'button',
@@ -121,26 +145,96 @@ module.exports = (crowi) => {
         },
       ],
     };
+    // show "Prev" button if previous page exists
+    // eslint-disable-next-line yoda
+    if (0 < offset) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: '< Prev',
+          },
+          action_id: 'search:showPrevResults',
+          value: JSON.stringify({ offset, growiCommandArgs }),
+        },
+      );
+    }
     // show "Next" button if next page exists
-    if (resultsTotal > offset + PAGINGLIMIT) {
+    if (offset + PAGINGLIMIT < resultsTotal) {
       actionBlocks.elements.unshift(
         {
           type: 'button',
           text: {
             type: 'plain_text',
-            text: 'Next',
+            text: 'Next >',
           },
           action_id: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, growiCommandArgs }),
+          value: JSON.stringify({ offset, growiCommandArgs }),
         },
       );
     }
     blocks.push(actionBlocks);
 
-    await respond(responseUrl, {
+    return {
       text: 'Successed To Search',
       blocks,
-    });
+    };
+  }
+
+
+  async function buildRespondBody(growiCommandArgs) {
+    const firstKeyword = growiCommandArgs[0];
+
+    // enpty keyword
+    if (firstKeyword == null) {
+      return {
+        text: 'Input keywords',
+        blocks: [
+          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
+        ],
+      };
+    }
+
+    const searchResult = await retrieveSearchResults(growiCommandArgs);
+
+    // no search results
+    if (searchResult.resultsTotal === 0) {
+      const keywords = getKeywords(growiCommandArgs);
+      logger.info(`No page found with "${keywords}"`);
+
+      return {
+        text: `No page found with "${keywords}"`,
+        blocks: [
+          markdownSectionBlock(`*No page 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 buildRespondBodyForSearchResult(searchResult, growiCommandArgs);
+  }
+
+
+  handler.handleCommand = async function(growiCommand, client, body) {
+    const { responseUrl, growiCommandArgs } = growiCommand;
+
+    const respondBody = await buildRespondBody(growiCommandArgs);
+    await respond(responseUrl, respondBody);
   };
 
   handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
@@ -171,16 +265,18 @@ module.exports = (crowi) => {
 
     // share
     const now = new Date();
-    return respond(responseUrl, {
+    return respondInChannel(responseUrl, {
       blocks: [
         { type: 'divider' },
-        markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
         {
           type: 'context',
           elements: [
             {
               type: 'mrkdwn',
-              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>`
+                + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\``
+                + `  |  Shared by *${user.username}*`,
             },
           ],
         },
@@ -188,7 +284,7 @@ module.exports = (crowi) => {
     });
   };
 
-  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
+  async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true) {
     const responseUrl = interactionPayloadAccessor.getResponseUrl();
 
     const value = interactionPayloadAccessor.firstAction()?.value;
@@ -203,131 +299,22 @@ module.exports = (crowi) => {
     }
     const parsedValue = JSON.parse(value);
 
-    const { body, growiCommandArgs, offset: offsetNum } = parsedValue;
-    const newOffsetNum = offsetNum + 10;
-    let searchResult;
-    try {
-      searchResult = await this.retrieveSearchResults(responseUrl, client, body, growiCommandArgs, newOffsetNum);
-    }
-    catch (err) {
-      logger.error('Failed to get search results.', err);
-      throw new SlackbotError({
-        method: 'postEphemeral',
-        to: 'channel',
-        popupMessage: 'Failed To Search',
-        mainMessage: '*Failed to search.*\n Hint\n `/growi search [keyword]`',
-      });
-    }
+    const { growiCommandArgs, offset: offsetNum } = parsedValue;
+    const newOffsetNum = isNext
+      ? offsetNum + PAGINGLIMIT
+      : offsetNum - PAGINGLIMIT;
 
-    const appUrl = crowi.appService.getSiteUrl();
-    const appTitle = crowi.appService.getAppTitle();
+    const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum);
 
-    const {
-      pages, offset, resultsTotal,
-    } = searchResult;
-
-    const keywords = this.getKeywords(growiCommandArgs);
-
-
-    let searchResultsDesc;
-
-    if (resultsTotal === 0 || resultsTotal == null) {
-      if (keywords === '') return;
-      await respond(responseUrl, {
-        text: 'No page found.',
-        blocks: [
-          markdownSectionBlock('Please try with other keywords.'),
-        ],
-      });
-      return;
-    }
-    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`,
-        },
-      ],
-    };
+    await replaceOriginal(responseUrl, buildRespondBodyForSearchResult(searchResult, growiCommandArgs));
+  }
 
-    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: 'search:shareSinglePageResult',
-            text: {
-              type: 'plain_text',
-              text: 'Share',
-            },
-            value: JSON.stringify({ page, href, pathname }),
-          },
-        };
-      }),
-      { type: 'divider' },
-      contextBlock,
-    ];
-
-    const actionBlocks = {
-      type: 'actions',
-      elements: [
-        {
-          type: 'button',
-          text: {
-            type: 'plain_text',
-            text: 'Dismiss',
-          },
-          style: 'danger',
-          action_id: 'search: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: 'search:showNextResults',
-          value: JSON.stringify({ offset, body, growiCommandArgs }),
-        },
-      );
-    }
-    blocks.push(actionBlocks);
+  handler.showPrevResults = async function(client, payload, interactionPayloadAccessor) {
+    return showPrevOrNextResults(interactionPayloadAccessor, false);
+  };
 
-    await respond(responseUrl, {
-      text: 'Successed To Search',
-      blocks,
-    });
+  handler.showNextResults = async function(client, payload, interactionPayloadAccessor) {
+    return showPrevOrNextResults(interactionPayloadAccessor, true);
   };
 
   handler.dismissSearchResults = async function(client, payload) {
@@ -338,85 +325,5 @@ module.exports = (crowi) => {
     });
   };
 
-  handler.retrieveSearchResults = async function(responseUrl, client, body, growiCommandArgs, offset = 0) {
-    const firstKeyword = growiCommandArgs[0];
-    if (firstKeyword == null) {
-      await respond(responseUrl, {
-        text: 'Input keywords',
-        blocks: [
-          markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
-        ],
-      });
-      return { pages: [] };
-    }
-
-    const keywords = this.getKeywords(growiCommandArgs);
-
-    const { searchService } = 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}"`);
-      await respond(responseUrl, {
-        text: `No page found with "${keywords}"`,
-        blocks: [
-          markdownSectionBlock(`*No page 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(growiCommandArgs) {
-    const keywords = growiCommandArgs.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;
 };

+ 28 - 6
packages/app/src/server/service/slack-integration.ts

@@ -237,17 +237,25 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   /**
    * Handle /commands endpoint
    */
-  async handleCommandRequest(growiCommand: GrowiCommand, client, body) {
+  async handleCommandRequest(growiCommand: GrowiCommand, client, body): Promise<void> {
     const { growiCommandType } = growiCommand;
     const module = `./slack-command-handler/${growiCommandType}`;
 
+    let handler;
     try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(growiCommand, client, body);
+      handler = require(module)(this.crowi);
     }
     catch (err) {
+      logger.error(err);
       await this.notCommand(growiCommand);
-      throw err;
+    }
+
+    try {
+      await handler.handleCommand(growiCommand, client, body);
+    }
+    catch (err) {
+      logger.error(err);
+      await this.notifyInternalError(growiCommand.responseUrl, err);
     }
   }
 
@@ -261,7 +269,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
     }
     catch (err) {
-      throw err;
+      logger.error(err);
+      const responseUrl = interactionPayloadAccessor.getResponseUrl();
+      await this.notifyInternalError(responseUrl, err);
     }
     return;
   }
@@ -276,7 +286,9 @@ export class SlackIntegrationService implements S2sMessageHandlable {
       await handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
     }
     catch (err) {
-      throw err;
+      logger.error(err);
+      const responseUrl = interactionPayloadAccessor.getResponseUrl();
+      await this.notifyInternalError(responseUrl, err);
     }
     return;
   }
@@ -292,4 +304,14 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     return;
   }
 
+  async notifyInternalError(responseUrl: string, error: Error): Promise<void> {
+    await respond(responseUrl, {
+      text: 'Internal Server Error',
+      blocks: [
+        markdownSectionBlock(`*Internal Server Error*\n \`${error.message}\``),
+      ],
+    });
+    return;
+  }
+
 }

+ 9 - 0
packages/slack/src/utils/response-url.ts

@@ -10,6 +10,15 @@ export async function respond(responseUrl: string, body: RespondBodyForResponseU
   });
 }
 
+export async function respondInChannel(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
+  return axios.post(responseUrl, {
+    response_type: 'in_channel',
+    replace_original: false,
+    text: body.text,
+    blocks: body.blocks,
+  });
+}
+
 export async function replaceOriginal(responseUrl: string, body: RespondBodyForResponseUrl): Promise<void> {
   return axios.post(responseUrl, {
     replace_original: true,