search.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. const logger = require('@alias/logger')('growi:service:SlackCommandHandler:search');
  2. const { BlockKitBuilder: B } = require('@growi/slack');
  3. const { formatDistanceStrict } = require('date-fns');
  4. const PAGINGLIMIT = 10;
  5. module.exports = (crowi) => {
  6. const BaseSlackCommandHandler = require('./slack-command-handler');
  7. const handler = new BaseSlackCommandHandler(crowi);
  8. handler.handleCommand = async function(client, body, args) {
  9. let searchResult;
  10. try {
  11. searchResult = await this.retrieveSearchResults(client, body, args);
  12. }
  13. catch (err) {
  14. logger.error('Failed to get search results.', err);
  15. await client.chat.postEphemeral({
  16. channel: body.channel_id,
  17. user: body.user_id,
  18. text: 'Failed To Search',
  19. blocks: [
  20. B.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
  21. ],
  22. });
  23. throw new Error('/growi command:search: Failed to search');
  24. }
  25. const appUrl = this.crowi.appService.getSiteUrl();
  26. const appTitle = this.crowi.appService.getAppTitle();
  27. const {
  28. pages, offset, resultsTotal,
  29. } = searchResult;
  30. const keywords = this.getKeywords(args);
  31. let searchResultsDesc;
  32. switch (resultsTotal) {
  33. case 1:
  34. searchResultsDesc = `*${resultsTotal}* page is found.`;
  35. break;
  36. default:
  37. searchResultsDesc = `*${resultsTotal}* pages are found.`;
  38. break;
  39. }
  40. const contextBlock = {
  41. type: 'context',
  42. elements: [
  43. {
  44. type: 'mrkdwn',
  45. text: `keyword(s) : *"${keywords}"* | Current: ${offset + 1} - ${offset + pages.length} | Total ${resultsTotal} pages`,
  46. },
  47. ],
  48. };
  49. const now = new Date();
  50. const blocks = [
  51. B.generateMarkdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
  52. contextBlock,
  53. { type: 'divider' },
  54. // create an array by map and extract
  55. ...pages.map((page) => {
  56. const { path, updatedAt, commentCount } = page;
  57. // generate URL
  58. const url = new URL(path, appUrl);
  59. const { href, pathname } = url;
  60. return {
  61. type: 'section',
  62. text: {
  63. type: 'mrkdwn',
  64. text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
  65. + `\n Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
  66. },
  67. accessory: {
  68. type: 'button',
  69. action_id: 'shareSingleSearchResult',
  70. text: {
  71. type: 'plain_text',
  72. text: 'Share',
  73. },
  74. value: JSON.stringify({ page, href, pathname }),
  75. },
  76. };
  77. }),
  78. { type: 'divider' },
  79. contextBlock,
  80. ];
  81. // DEFAULT show "Share" button
  82. // const actionBlocks = {
  83. // type: 'actions',
  84. // elements: [
  85. // {
  86. // type: 'button',
  87. // text: {
  88. // type: 'plain_text',
  89. // text: 'Share',
  90. // },
  91. // style: 'primary',
  92. // action_id: 'shareSearchResults',
  93. // },
  94. // ],
  95. // };
  96. const actionBlocks = {
  97. type: 'actions',
  98. elements: [
  99. {
  100. type: 'button',
  101. text: {
  102. type: 'plain_text',
  103. text: 'Dismiss',
  104. },
  105. style: 'danger',
  106. action_id: 'dismissSearchResults',
  107. },
  108. ],
  109. };
  110. // show "Next" button if next page exists
  111. if (resultsTotal > offset + PAGINGLIMIT) {
  112. actionBlocks.elements.unshift(
  113. {
  114. type: 'button',
  115. text: {
  116. type: 'plain_text',
  117. text: 'Next',
  118. },
  119. action_id: 'showNextResults',
  120. value: JSON.stringify({ offset, body, args }),
  121. },
  122. );
  123. }
  124. blocks.push(actionBlocks);
  125. try {
  126. await client.chat.postEphemeral({
  127. channel: body.channel_id,
  128. user: body.user_id,
  129. text: 'Successed To Search',
  130. blocks,
  131. });
  132. }
  133. catch (err) {
  134. logger.error('Failed to post ephemeral message.', err);
  135. await client.chat.postEphemeral({
  136. channel: body.channel_id,
  137. user: body.user_id,
  138. text: 'Failed to post ephemeral message.',
  139. blocks: [
  140. B.generateMarkdownSectionBlock(err.toString()),
  141. ],
  142. });
  143. throw new Error(err);
  144. }
  145. };
  146. handler.retrieveSearchResults = async function(client, body, args, offset = 0) {
  147. const firstKeyword = args[1];
  148. if (firstKeyword == null) {
  149. client.chat.postEphemeral({
  150. channel: body.channel_id,
  151. user: body.user_id,
  152. text: 'Input keywords',
  153. blocks: [
  154. B.generateMarkdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
  155. ],
  156. });
  157. return;
  158. }
  159. const keywords = this.getKeywords(args);
  160. const { searchService } = this.crowi;
  161. const options = { limit: 10, offset };
  162. const results = await searchService.searchKeyword(keywords, null, {}, options);
  163. const resultsTotal = results.meta.total;
  164. // no search results
  165. if (results.data.length === 0) {
  166. logger.info(`No page found with "${keywords}"`);
  167. client.chat.postEphemeral({
  168. channel: body.channel_id,
  169. user: body.user_id,
  170. text: `No page found with "${keywords}"`,
  171. blocks: [
  172. B.generateMarkdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
  173. B.generateMarkdownSectionBlock(':mag: *Help: Searching*'),
  174. B.divider(),
  175. B.generateMarkdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
  176. B.divider(),
  177. B.generateMarkdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
  178. B.divider(),
  179. B.generateMarkdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
  180. B.divider(),
  181. B.generateMarkdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
  182. B.divider(),
  183. B.generateMarkdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
  184. B.divider(),
  185. B.generateMarkdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
  186. B.divider(),
  187. B.generateMarkdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
  188. ],
  189. });
  190. return { pages: [] };
  191. }
  192. const pages = results.data.map((data) => {
  193. const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
  194. return { path, updatedAt, commentCount };
  195. });
  196. return {
  197. pages, offset, resultsTotal,
  198. };
  199. };
  200. handler.getKeywords = function(args) {
  201. const keywordsArr = args.slice(1);
  202. const keywords = keywordsArr.join(' ');
  203. return keywords;
  204. };
  205. handler.appendSpeechBaloon = function(mrkdwn, commentCount) {
  206. return (commentCount != null && commentCount > 0)
  207. ? `${mrkdwn} :speech_balloon: ${commentCount}`
  208. : mrkdwn;
  209. };
  210. handler.generatePageLinkMrkdwn = function(pathname, href) {
  211. return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
  212. };
  213. handler.generateLastUpdateMrkdwn = function(updatedAt, baseDate) {
  214. if (updatedAt != null) {
  215. // cast to date
  216. const date = new Date(updatedAt);
  217. return formatDistanceStrict(date, baseDate);
  218. }
  219. return '';
  220. };
  221. return handler;
  222. };