bolt.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. const logger = require('@alias/logger')('growi:service:BoltService');
  2. const mongoose = require('mongoose');
  3. const PAGINGLIMIT = 10;
  4. class BoltReciever {
  5. init(app) {
  6. this.bolt = app;
  7. }
  8. async requestHandler(body) {
  9. if (this.bolt === undefined) {
  10. throw new Error('Slack Bot service is not setup');
  11. }
  12. let ackCalled = false;
  13. const payload = body.payload;
  14. let reqBody;
  15. if (payload != null) {
  16. reqBody = JSON.parse(payload);
  17. }
  18. else {
  19. reqBody = body;
  20. }
  21. const event = {
  22. body: reqBody,
  23. ack: (response) => {
  24. if (ackCalled) {
  25. return;
  26. }
  27. ackCalled = true;
  28. if (response instanceof Error) {
  29. const message = response.message || 'Error occurred';
  30. throw new Error(message);
  31. }
  32. return;
  33. },
  34. };
  35. await this.bolt.processEvent(event);
  36. }
  37. }
  38. const { App } = require('@slack/bolt');
  39. const { WebClient, LogLevel } = require('@slack/web-api');
  40. const S2sMessage = require('../models/vo/s2s-message');
  41. const S2sMessageHandlable = require('./s2s-messaging/handlable');
  42. class BoltService extends S2sMessageHandlable {
  43. constructor(crowi) {
  44. super();
  45. this.crowi = crowi;
  46. this.s2sMessagingService = crowi.s2sMessagingService;
  47. this.receiver = new BoltReciever();
  48. this.client = null;
  49. this.isBoltSetup = false;
  50. this.lastLoadedAt = null;
  51. this.initialize();
  52. }
  53. initialize() {
  54. this.isBoltSetup = false;
  55. const token = this.crowi.configManager.getConfig('crowi', 'slackbot:token');
  56. const signingSecret = this.crowi.configManager.getConfig('crowi', 'slackbot:signingSecret');
  57. this.client = new WebClient(token, { logLevel: LogLevel.DEBUG });
  58. if (token == null || signingSecret == null) {
  59. this.bolt = null;
  60. return;
  61. }
  62. this.bolt = new App({
  63. token,
  64. signingSecret,
  65. receiver: this.receiver,
  66. });
  67. this.setupRoute();
  68. logger.debug('SlackBot: setup is done');
  69. this.isBoltSetup = true;
  70. this.lastLoadedAt = new Date();
  71. }
  72. /**
  73. * @inheritdoc
  74. */
  75. shouldHandleS2sMessage(s2sMessage) {
  76. const { eventName, updatedAt } = s2sMessage;
  77. if (eventName !== 'boltServiceUpdated' || updatedAt == null) {
  78. return false;
  79. }
  80. return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
  81. }
  82. /**
  83. * @inheritdoc
  84. */
  85. async handleS2sMessage() {
  86. const { configManager } = this.crowi;
  87. logger.info('Reset bolt by pubsub notification');
  88. await configManager.loadConfigs();
  89. this.initialize();
  90. }
  91. async publishUpdatedMessage() {
  92. const { s2sMessagingService } = this;
  93. if (s2sMessagingService != null) {
  94. const s2sMessage = new S2sMessage('boltServiceUpdated', { updatedAt: new Date() });
  95. try {
  96. await s2sMessagingService.publish(s2sMessage);
  97. }
  98. catch (e) {
  99. logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
  100. }
  101. }
  102. }
  103. setupRoute() {
  104. this.bolt.command('/growi', async({
  105. command, client, body, ack,
  106. }) => {
  107. await ack();
  108. const args = command.text.split(' ');
  109. const firstArg = args[0];
  110. switch (firstArg) {
  111. case 'search':
  112. await this.showEphemeralSearchResults(command, args);
  113. break;
  114. case 'create':
  115. await this.createModal(command, client, body);
  116. break;
  117. default:
  118. this.notCommand(command);
  119. break;
  120. }
  121. });
  122. this.bolt.view('createPage', async({
  123. ack, view, body, client,
  124. }) => {
  125. await ack();
  126. await this.createPageInGrowi(view, body);
  127. });
  128. this.bolt.action('showNextResults', async({
  129. ack, action,
  130. }) => {
  131. await ack();
  132. const parsedValue = JSON.parse(action.value);
  133. const command = parsedValue.command;
  134. const args = parsedValue.args;
  135. const offset = parsedValue.offset;
  136. const newOffset = offset + 10;
  137. this.showEphemeralSearchResults(command, args, newOffset);
  138. });
  139. this.bolt.action('shareSearchResults', async({
  140. body, ack, say, action,
  141. }) => {
  142. await ack();
  143. await say(action.value);
  144. });
  145. }
  146. notCommand(command) {
  147. logger.error('Invalid first argument');
  148. this.client.chat.postEphemeral({
  149. channel: command.channel_id,
  150. user: command.user_id,
  151. blocks: [
  152. this.generateMarkdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
  153. ],
  154. });
  155. return;
  156. }
  157. getKeywords(args) {
  158. const keywordsArr = args.slice(1);
  159. const keywords = keywordsArr.join(' ');
  160. return keywords;
  161. }
  162. async getSearchResultPaths(command, args, offset = 0) {
  163. const firstKeyword = args[1];
  164. if (firstKeyword == null) {
  165. this.client.chat.postEphemeral({
  166. channel: command.channel_id,
  167. user: command.user_id,
  168. blocks: [
  169. this.generateMarkdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
  170. ],
  171. });
  172. return;
  173. }
  174. const keywords = this.getKeywords(args);
  175. const { searchService } = this.crowi;
  176. const options = { limit: 10, offset };
  177. const results = await searchService.searchKeyword(keywords, null, {}, options);
  178. const resultsTotal = results.meta.total;
  179. // no search results
  180. if (results.data.length === 0) {
  181. logger.info(`No page found with "${keywords}"`);
  182. this.client.chat.postEphemeral({
  183. channel: command.channel_id,
  184. user: command.user_id,
  185. blocks: [
  186. this.generateMarkdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
  187. this.generateMarkdownSectionBlock(':mag: *Help: Searching*'),
  188. this.divider(),
  189. this.generateMarkdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),
  190. this.divider(),
  191. this.generateMarkdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'),
  192. this.divider(),
  193. this.generateMarkdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'),
  194. this.divider(),
  195. this.generateMarkdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'),
  196. this.divider(),
  197. this.generateMarkdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'),
  198. this.divider(),
  199. this.generateMarkdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'),
  200. this.divider(),
  201. this.generateMarkdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
  202. ],
  203. });
  204. return { resultPaths: [] };
  205. }
  206. const resultPaths = results.data.map((data) => {
  207. return data._source.path;
  208. });
  209. return {
  210. resultPaths, offset, resultsTotal,
  211. };
  212. }
  213. async showEphemeralSearchResults(command, args, offsetNum) {
  214. const {
  215. resultPaths, offset, resultsTotal,
  216. } = await this.getSearchResultPaths(command, args, offsetNum);
  217. const keywords = this.getKeywords(args);
  218. if (resultPaths.length === 0) {
  219. return;
  220. }
  221. const base = this.crowi.appService.getSiteUrl();
  222. const urls = resultPaths.map((path) => {
  223. const url = new URL(path, base);
  224. return `<${decodeURI(url.href)} | ${decodeURI(url.pathname)}>`;
  225. });
  226. const searchResultsNum = resultPaths.length;
  227. let searchResultsDesc;
  228. switch (searchResultsNum) {
  229. case 10:
  230. searchResultsDesc = 'Maximum number of results that can be displayed is 10';
  231. break;
  232. case 1:
  233. searchResultsDesc = `${searchResultsNum} page is found`;
  234. break;
  235. default:
  236. searchResultsDesc = `${searchResultsNum} pages are found`;
  237. break;
  238. }
  239. const keywordsAndDesc = `keyword(s) : "${keywords}" \n ${searchResultsDesc}.`;
  240. try {
  241. // DEFAULT show "Share" button
  242. const actionBlocks = {
  243. type: 'actions',
  244. elements: [
  245. {
  246. type: 'button',
  247. text: {
  248. type: 'plain_text',
  249. text: 'Share',
  250. },
  251. style: 'primary',
  252. action_id: 'shareSearchResults',
  253. value: `${keywordsAndDesc} \n\n ${urls.join('\n')}`,
  254. },
  255. ],
  256. };
  257. // show "Next" button if next page exists
  258. if (resultsTotal > offset + PAGINGLIMIT) {
  259. actionBlocks.elements.unshift(
  260. {
  261. type: 'button',
  262. text: {
  263. type: 'plain_text',
  264. text: 'Next',
  265. },
  266. action_id: 'showNextResults',
  267. value: JSON.stringify({ offset, command, args }),
  268. },
  269. );
  270. }
  271. await this.client.chat.postEphemeral({
  272. channel: command.channel_id,
  273. user: command.user_id,
  274. blocks: [
  275. this.generateMarkdownSectionBlock(keywordsAndDesc),
  276. this.generateMarkdownSectionBlock(`${urls.join('\n')}`),
  277. actionBlocks,
  278. ],
  279. });
  280. }
  281. catch {
  282. logger.error('Failed to get search results.');
  283. await this.client.chat.postEphemeral({
  284. channel: command.channel_id,
  285. user: command.user_id,
  286. blocks: [
  287. this.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
  288. ],
  289. });
  290. throw new Error('/growi command:search: Failed to search');
  291. }
  292. }
  293. async createModal(command, client, body) {
  294. try {
  295. await client.views.open({
  296. trigger_id: body.trigger_id,
  297. view: {
  298. type: 'modal',
  299. callback_id: 'createPage',
  300. title: {
  301. type: 'plain_text',
  302. text: 'Create Page',
  303. },
  304. submit: {
  305. type: 'plain_text',
  306. text: 'Submit',
  307. },
  308. close: {
  309. type: 'plain_text',
  310. text: 'Cancel',
  311. },
  312. blocks: [
  313. this.generateMarkdownSectionBlock('Create new page.'),
  314. this.generateInputSectionBlock('path', 'Path', 'path_input', false, '/path'),
  315. this.generateInputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
  316. ],
  317. },
  318. });
  319. }
  320. catch (err) {
  321. logger.error('Failed to create a page.');
  322. await this.client.chat.postEphemeral({
  323. channel: command.channel_id,
  324. user: command.user_id,
  325. blocks: [
  326. this.generateMarkdownSectionBlock(`*Failed to create new page.*\n ${err}`),
  327. ],
  328. });
  329. throw err;
  330. }
  331. }
  332. // Submit action in create Modal
  333. async createPageInGrowi(view, body) {
  334. const Page = this.crowi.model('Page');
  335. const pathUtils = require('growi-commons').pathUtils;
  336. const contentsBody = view.state.values.contents.contents_input.value;
  337. try {
  338. let path = view.state.values.path.path_input.value;
  339. // sanitize path
  340. path = this.crowi.xss.process(path);
  341. path = pathUtils.normalizePath(path);
  342. // generate a dummy id because Operation to create a page needs ObjectId
  343. const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
  344. await Page.create(path, contentsBody, dummyObjectIdOfUser, {});
  345. }
  346. catch (err) {
  347. this.client.chat.postMessage({
  348. channel: body.user.id,
  349. blocks: [
  350. this.generateMarkdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${contentsBody}`)],
  351. });
  352. logger.error('Failed to create page in GROWI.');
  353. throw err;
  354. }
  355. }
  356. generateMarkdownSectionBlock(blocks) {
  357. return {
  358. type: 'section',
  359. text: {
  360. type: 'mrkdwn',
  361. text: blocks,
  362. },
  363. };
  364. }
  365. divider() {
  366. return {
  367. type: 'divider',
  368. };
  369. }
  370. generateInputSectionBlock(blockId, labelText, actionId, isMultiline, placeholder) {
  371. return {
  372. type: 'input',
  373. block_id: blockId,
  374. label: {
  375. type: 'plain_text',
  376. text: labelText,
  377. },
  378. element: {
  379. type: 'plain_text_input',
  380. action_id: actionId,
  381. multiline: isMultiline,
  382. placeholder: {
  383. type: 'plain_text',
  384. text: placeholder,
  385. },
  386. },
  387. };
  388. }
  389. }
  390. module.exports = BoltService;