slackbot.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. import loggerFactory from '~/utils/logger';
  2. import { S2sMessagingService } from './s2s-messaging/base';
  3. import { S2sMessageHandlable } from './s2s-messaging/handlable';
  4. const logger = loggerFactory('growi:service:SlackBotService');
  5. const mongoose = require('mongoose');
  6. const axios = require('axios');
  7. const { markdownSectionBlock, divider } = require('@growi/slack');
  8. const { reshapeContentsBody } = require('@growi/slack');
  9. const { formatDistanceStrict, parse, format } = require('date-fns');
  10. const S2sMessage = require('../models/vo/s2s-message');
  11. const PAGINGLIMIT = 10;
  12. class SlackBotService implements S2sMessageHandlable {
  13. crowi!: any;
  14. s2sMessagingService!: S2sMessagingService;
  15. lastLoadedAt?: Date;
  16. constructor(crowi) {
  17. this.crowi = crowi;
  18. this.s2sMessagingService = crowi.s2sMessagingService;
  19. this.initialize();
  20. }
  21. initialize() {
  22. this.lastLoadedAt = new Date();
  23. }
  24. /**
  25. * @inheritdoc
  26. */
  27. shouldHandleS2sMessage(s2sMessage) {
  28. const { eventName, updatedAt } = s2sMessage;
  29. if (eventName !== 'slackBotServiceUpdated' || updatedAt == null) {
  30. return false;
  31. }
  32. return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
  33. }
  34. /**
  35. * @inheritdoc
  36. */
  37. async handleS2sMessage() {
  38. const { configManager } = this.crowi;
  39. logger.info('Reset slack bot by pubsub notification');
  40. await configManager.loadConfigs();
  41. this.initialize();
  42. }
  43. async publishUpdatedMessage() {
  44. const { s2sMessagingService } = this;
  45. if (s2sMessagingService != null) {
  46. const s2sMessage = new S2sMessage('slackBotServiceUpdated', { updatedAt: new Date() });
  47. try {
  48. await s2sMessagingService.publish(s2sMessage);
  49. }
  50. catch (e) {
  51. logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
  52. }
  53. }
  54. }
  55. /**
  56. * Handle /commands endpoint
  57. */
  58. async handleCommand(command, client, body, ...opt) {
  59. const module = `./slack-command-handler/${command}`;
  60. try {
  61. const handler = require(module)(this.crowi);
  62. await handler.handleCommand(client, body, ...opt);
  63. }
  64. catch (err) {
  65. this.notCommand(client, body);
  66. }
  67. }
  68. async notCommand(client, body) {
  69. logger.error('Invalid first argument');
  70. client.chat.postEphemeral({
  71. channel: body.channel_id,
  72. user: body.user_id,
  73. text: 'No command',
  74. blocks: [
  75. markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
  76. ],
  77. });
  78. return;
  79. }
  80. generatePageLinkMrkdwn(pathname, href) {
  81. return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
  82. }
  83. appendSpeechBaloon(mrkdwn, commentCount) {
  84. return (commentCount != null && commentCount > 0)
  85. ? `${mrkdwn} :speech_balloon: ${commentCount}`
  86. : mrkdwn;
  87. }
  88. generateLastUpdateMrkdwn(updatedAt, baseDate) {
  89. if (updatedAt != null) {
  90. // cast to date
  91. const date = new Date(updatedAt);
  92. return formatDistanceStrict(date, baseDate);
  93. }
  94. return '';
  95. }
  96. async shareSinglePage(client, payload) {
  97. const { channel, user, actions } = payload;
  98. const appUrl = this.crowi.appService.getSiteUrl();
  99. const appTitle = this.crowi.appService.getAppTitle();
  100. const channelId = channel.id;
  101. const action = actions[0]; // shareSinglePage action must have button action
  102. // restore page data from value
  103. const { page, href, pathname } = JSON.parse(action.value);
  104. const { updatedAt, commentCount } = page;
  105. // share
  106. const now = new Date();
  107. return client.chat.postMessage({
  108. channel: channelId,
  109. blocks: [
  110. { type: 'divider' },
  111. markdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
  112. {
  113. type: 'context',
  114. elements: [
  115. {
  116. type: 'mrkdwn',
  117. text: `<${decodeURI(appUrl)}|*${appTitle}*> | Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)} | Shared by *${user.username}*`,
  118. },
  119. ],
  120. },
  121. ],
  122. });
  123. }
  124. async dismissSearchResults(client, payload) {
  125. const { response_url: responseUrl } = payload;
  126. return axios.post(responseUrl, {
  127. delete_original: true,
  128. });
  129. }
  130. async showEphemeralSearchResults(client, body, args, offsetNum) {
  131. let searchResult;
  132. try {
  133. searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
  134. }
  135. catch (err) {
  136. logger.error('Failed to get search results.', err);
  137. await client.chat.postEphemeral({
  138. channel: body.channel_id,
  139. user: body.user_id,
  140. text: 'Failed To Search',
  141. blocks: [
  142. markdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
  143. ],
  144. });
  145. throw new Error('/growi command:search: Failed to search');
  146. }
  147. const appUrl = this.crowi.appService.getSiteUrl();
  148. const appTitle = this.crowi.appService.getAppTitle();
  149. const {
  150. pages, offset, resultsTotal,
  151. } = searchResult;
  152. const keywords = this.getKeywords(args);
  153. let searchResultsDesc;
  154. switch (resultsTotal) {
  155. case 1:
  156. searchResultsDesc = `*${resultsTotal}* page is found.`;
  157. break;
  158. default:
  159. searchResultsDesc = `*${resultsTotal}* pages are found.`;
  160. break;
  161. }
  162. const contextBlock = {
  163. type: 'context',
  164. elements: [
  165. {
  166. type: 'mrkdwn',
  167. text: `keyword(s) : *"${keywords}"* | Current: ${offset + 1} - ${offset + pages.length} | Total ${resultsTotal} pages`,
  168. },
  169. ],
  170. };
  171. const now = new Date();
  172. const blocks = [
  173. markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
  174. contextBlock,
  175. { type: 'divider' },
  176. // create an array by map and extract
  177. ...pages.map((page) => {
  178. const { path, updatedAt, commentCount } = page;
  179. // generate URL
  180. const url = new URL(path, appUrl);
  181. const { href, pathname } = url;
  182. return {
  183. type: 'section',
  184. text: {
  185. type: 'mrkdwn',
  186. text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
  187. + `\n Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
  188. },
  189. accessory: {
  190. type: 'button',
  191. action_id: 'shareSingleSearchResult',
  192. text: {
  193. type: 'plain_text',
  194. text: 'Share',
  195. },
  196. value: JSON.stringify({ page, href, pathname }),
  197. },
  198. };
  199. }),
  200. { type: 'divider' },
  201. contextBlock,
  202. ];
  203. // DEFAULT show "Share" button
  204. // const actionBlocks = {
  205. // type: 'actions',
  206. // elements: [
  207. // {
  208. // type: 'button',
  209. // text: {
  210. // type: 'plain_text',
  211. // text: 'Share',
  212. // },
  213. // style: 'primary',
  214. // action_id: 'shareSearchResults',
  215. // },
  216. // ],
  217. // };
  218. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  219. const actionBlocks: any = {
  220. type: 'actions',
  221. elements: [
  222. {
  223. type: 'button',
  224. text: {
  225. type: 'plain_text',
  226. text: 'Dismiss',
  227. },
  228. style: 'danger',
  229. action_id: 'dismissSearchResults',
  230. },
  231. ],
  232. };
  233. // show "Next" button if next page exists
  234. if (resultsTotal > offset + PAGINGLIMIT) {
  235. actionBlocks.elements.unshift(
  236. {
  237. type: 'button',
  238. text: {
  239. type: 'plain_text',
  240. text: 'Next',
  241. },
  242. action_id: 'showNextResults',
  243. value: JSON.stringify({ offset, body, args }),
  244. },
  245. );
  246. }
  247. blocks.push(actionBlocks);
  248. try {
  249. await client.chat.postEphemeral({
  250. channel: body.channel_id,
  251. user: body.user_id,
  252. text: 'Successed To Search',
  253. blocks,
  254. });
  255. }
  256. catch (err) {
  257. logger.error('Failed to post ephemeral message.', err);
  258. await client.chat.postEphemeral({
  259. channel: body.channel_id,
  260. user: body.user_id,
  261. text: 'Failed to post ephemeral message.',
  262. blocks: [
  263. markdownSectionBlock(err.toString()),
  264. ],
  265. });
  266. throw new Error(err);
  267. }
  268. }
  269. async retrieveSearchResults(client, body, args, offset = 0) {
  270. const firstKeyword = args[1];
  271. if (firstKeyword == null) {
  272. client.chat.postEphemeral({
  273. channel: body.channel_id,
  274. user: body.user_id,
  275. text: 'Input keywords',
  276. blocks: [
  277. markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
  278. ],
  279. });
  280. return;
  281. }
  282. const keywords = this.getKeywords(args);
  283. const { searchService } = this.crowi;
  284. const options = { limit: 10, offset };
  285. const results = await searchService.searchKeyword(keywords, null, {}, options);
  286. const resultsTotal = results.meta.total;
  287. // no search results
  288. if (results.data.length === 0) {
  289. logger.info(`No page found with "${keywords}"`);
  290. client.chat.postEphemeral({
  291. channel: body.channel_id,
  292. user: body.user_id,
  293. text: `No page found with "${keywords}"`,
  294. blocks: [
  295. markdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
  296. markdownSectionBlock(':mag: *Help: Searching*'),
  297. divider(),
  298. markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
  299. divider(),
  300. markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
  301. divider(),
  302. markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
  303. divider(),
  304. markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
  305. divider(),
  306. markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
  307. divider(),
  308. markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
  309. divider(),
  310. markdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
  311. ],
  312. });
  313. return { pages: [] };
  314. }
  315. const pages = results.data.map((data) => {
  316. const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
  317. return { path, updatedAt, commentCount };
  318. });
  319. return {
  320. pages, offset, resultsTotal,
  321. };
  322. }
  323. getKeywords(args) {
  324. const keywordsArr = args.slice(1);
  325. const keywords = keywordsArr.join(' ');
  326. return keywords;
  327. }
  328. // Submit action in create Modal
  329. async createPage(client, payload, path, channelId, contentsBody) {
  330. const Page = this.crowi.model('Page');
  331. const pathUtils = require('growi-commons').pathUtils;
  332. const reshapedContentsBody = reshapeContentsBody(contentsBody);
  333. try {
  334. // sanitize path
  335. const sanitizedPath = this.crowi.xss.process(path);
  336. const normalizedPath = pathUtils.normalizePath(sanitizedPath);
  337. // generate a dummy id because Operation to create a page needs ObjectId
  338. const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
  339. const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
  340. // Send a message when page creation is complete
  341. const growiUri = this.crowi.appService.getSiteUrl();
  342. await client.chat.postEphemeral({
  343. channel: channelId,
  344. user: payload.user.id,
  345. text: `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`,
  346. });
  347. }
  348. catch (err) {
  349. client.chat.postMessage({
  350. channel: payload.user.id,
  351. blocks: [
  352. markdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${reshapedContentsBody}`)],
  353. });
  354. logger.error('Failed to create page in GROWI.');
  355. throw err;
  356. }
  357. }
  358. async createPageInGrowi(client, payload) {
  359. const path = payload.view.state.values.path.path_input.value;
  360. const channelId = JSON.parse(payload.view.private_metadata).channelId;
  361. const contentsBody = payload.view.state.values.contents.contents_input.value;
  362. await this.createPage(client, payload, path, channelId, contentsBody);
  363. }
  364. async togetterCreatePageInGrowi(client, payload) {
  365. let result = [];
  366. const channel = payload.channel.id;
  367. try {
  368. // validate form
  369. const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
  370. // get messages
  371. result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
  372. // clean messages
  373. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  374. const cleanedContents = await this.togetterCleanMessages((result as any).messages);
  375. const contentsBody = cleanedContents.join('');
  376. // create and send url message
  377. await this.togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody);
  378. }
  379. catch (err) {
  380. await client.chat.postMessage({
  381. channel: payload.user.id,
  382. text: err.message,
  383. blocks: [
  384. markdownSectionBlock(err.message),
  385. ],
  386. });
  387. return;
  388. }
  389. }
  390. async togetterGetMessages(client, payload, channel, path, latest, oldest) {
  391. const result = await client.conversations.history({
  392. channel,
  393. latest,
  394. oldest,
  395. limit: 100,
  396. inclusive: true,
  397. });
  398. // return if no message found
  399. if (!result.messages.length) {
  400. throw new Error('No message found from togetter command. Try again.');
  401. }
  402. return result;
  403. }
  404. async togetterValidateForm(client, payload) {
  405. const grwTzoffset = this.crowi.appService.getTzoffset() * 60;
  406. const path = payload.state.values.page_path.page_path.value;
  407. let oldest = payload.state.values.oldest.oldest.value;
  408. let latest = payload.state.values.latest.latest.value;
  409. oldest = oldest.trim();
  410. latest = latest.trim();
  411. if (!path) {
  412. throw new Error('Page path is required.');
  413. }
  414. /**
  415. * RegExp for datetime yyyy/MM/dd-HH:mm
  416. * @see https://regex101.com/r/XbxdNo/1
  417. */
  418. const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
  419. if (!regexpDatetime.test(oldest)) {
  420. throw new Error('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
  421. }
  422. if (!regexpDatetime.test(latest)) {
  423. throw new Error('Datetime format for latest must be yyyy/MM/dd-HH:mm');
  424. }
  425. oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
  426. // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
  427. latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
  428. if (oldest > latest) {
  429. throw new Error('Oldest datetime must be older than the latest date time.');
  430. }
  431. return { path, oldest, latest };
  432. }
  433. async togetterCleanMessages(messages) {
  434. const cleanedContents: string[] = [];
  435. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  436. let lastMessage: any = {};
  437. const grwTzoffset = this.crowi.appService.getTzoffset() * 60;
  438. messages
  439. .sort((a, b) => {
  440. return a.ts - b.ts;
  441. })
  442. .forEach((message) => {
  443. // increment contentsBody while removing the same headers
  444. // exclude header
  445. const lastMessageTs = Math.floor(lastMessage.ts / 60);
  446. const messageTs = Math.floor(message.ts / 60);
  447. if (lastMessage.user === message.user && lastMessageTs === messageTs) {
  448. cleanedContents.push(`${message.text}\n`);
  449. }
  450. // include header
  451. else {
  452. const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
  453. const time = format(new Date(ts), 'h:mm a');
  454. cleanedContents.push(`${message.user} ${time}\n${message.text}\n`);
  455. lastMessage = message;
  456. }
  457. });
  458. return cleanedContents;
  459. }
  460. async togetterCreatePageAndSendPreview(client, payload, path, channel, contentsBody) {
  461. try {
  462. await this.createPage(client, payload, path, channel, contentsBody);
  463. // send preview to dm
  464. await client.chat.postMessage({
  465. channel: payload.user.id,
  466. text: 'Preview from togetter command',
  467. blocks: [
  468. markdownSectionBlock('*Preview*'),
  469. divider(),
  470. markdownSectionBlock(contentsBody),
  471. divider(),
  472. ],
  473. });
  474. // dismiss message
  475. const responseUrl = payload.response_url;
  476. axios.post(responseUrl, {
  477. delete_original: true,
  478. });
  479. }
  480. catch (err) {
  481. throw new Error('Error occurred while creating a page.');
  482. }
  483. }
  484. async togetterCancel(client, payload) {
  485. const responseUrl = payload.response_url;
  486. axios.post(responseUrl, {
  487. delete_original: true,
  488. });
  489. }
  490. }
  491. module.exports = SlackBotService;