slack-integration.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  1. import { ErrorV3 } from '@growi/core/dist/models';
  2. import { supportedGrowiCommands } from '@growi/slack';
  3. import { verifySlackRequest } from '@growi/slack/dist/middlewares';
  4. import { InvalidGrowiCommandError } from '@growi/slack/dist/models';
  5. import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
  6. import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
  7. import { generateRespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
  8. import { parseSlashCommand } from '@growi/slack/dist/utils/slash-command-parser';
  9. import createError from 'http-errors';
  10. import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
  11. import { configManager } from '~/server/service/config-manager';
  12. import { growiInfoService } from '~/server/service/growi-info';
  13. import loggerFactory from '~/utils/logger';
  14. const express = require('express');
  15. const { body } = require('express-validator');
  16. const mongoose = require('mongoose');
  17. const logger = loggerFactory('growi:routes:apiv3:slack-integration');
  18. const router = express.Router();
  19. const SlackAppIntegration = mongoose.model('SlackAppIntegration');
  20. const { handleError } = require('../../service/slack-command-handler/error-handler');
  21. const { checkPermission } = require('../../util/slack-integration');
  22. /** @param {import('~/server/crowi').default} crowi Crowi instance */
  23. module.exports = (crowi) => {
  24. const { slackIntegrationService } = crowi;
  25. // Check if the access token is correct
  26. async function verifyAccessTokenFromProxy(req, res, next) {
  27. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  28. if (tokenPtoG == null) {
  29. const message = 'The value of header \'x-growi-ptog-tokens\' must not be empty.';
  30. logger.warn(message, { body: req.body });
  31. return next(createError(400, message));
  32. }
  33. const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
  34. logger.debug('verifyAccessTokenFromProxy', {
  35. tokenPtoG,
  36. SlackAppIntegrationCount,
  37. });
  38. if (SlackAppIntegrationCount === 0) {
  39. return res.status(403).send({
  40. message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n'
  41. + 'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. '
  42. + 'Please unregister the information registered in the proxy and setup `/growi register` again.',
  43. });
  44. }
  45. next();
  46. }
  47. async function extractPermissionsCommands(tokenPtoG) {
  48. const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
  49. if (slackAppIntegration == null) return null;
  50. const permissionsForBroadcastUseCommands = slackAppIntegration.permissionsForBroadcastUseCommands;
  51. const permissionsForSingleUseCommands = slackAppIntegration.permissionsForSingleUseCommands;
  52. return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
  53. }
  54. // TODO: move this middleware to each controller
  55. // no res.send() is allowed after this middleware
  56. async function checkCommandsPermission(req, res, next) {
  57. // Send response immediately to avoid opelation_timeout error
  58. // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
  59. // for without proxy
  60. res.send();
  61. let growiCommand;
  62. try {
  63. growiCommand = getGrowiCommand(req.body);
  64. }
  65. catch (err) {
  66. logger.error(err.message);
  67. return next(err);
  68. }
  69. // not supported commands
  70. if (!supportedGrowiCommands.includes(growiCommand.growiCommandType)) {
  71. const options = {
  72. respondBody: {
  73. text: 'Command is not supported',
  74. blocks: [
  75. markdownSectionBlock('*Command is not supported*'),
  76. // eslint-disable-next-line max-len
  77. markdownSectionBlock(`\`/growi ${growiCommand.growiCommandType}\` command is not supported in this version of GROWI bot. Run \`/growi help\` to see all supported commands.`),
  78. ],
  79. },
  80. };
  81. return next(new SlackCommandHandlerError('Command type is not specified', options));
  82. }
  83. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  84. const extractPermissions = await extractPermissionsCommands(tokenPtoG);
  85. const fromChannel = {
  86. id: req.body.channel_id,
  87. name: req.body.channel_name,
  88. };
  89. const siteUrl = growiInfoService.getSiteUrl();
  90. let commandPermission;
  91. if (extractPermissions != null) { // with proxy
  92. const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
  93. commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
  94. const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
  95. if (isPermitted) return next();
  96. return next(createError(403, `It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`));
  97. }
  98. // without proxy
  99. commandPermission = configManager.getConfig('slackbot:withoutProxy:commandPermission');
  100. const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
  101. if (isPermitted) {
  102. return next();
  103. }
  104. // show ephemeral error message if not permitted
  105. const options = {
  106. respondBody: {
  107. text: 'Command forbidden',
  108. blocks: [
  109. markdownSectionBlock('*Command is not supported*'),
  110. markdownSectionBlock(`It is not allowed to send \`/growi ${growiCommand.growiCommandType}\` command to this GROWI: ${siteUrl}`),
  111. ],
  112. },
  113. };
  114. return next(new SlackCommandHandlerError('Command type is not specified', options));
  115. }
  116. // TODO: move this middleware to each controller
  117. // no res.send() is allowed after this middleware
  118. async function checkInteractionsPermission(req, res, next) {
  119. // Send response immediately to avoid opelation_timeout error
  120. // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
  121. // for without proxy
  122. res.send();
  123. const { interactionPayloadAccessor } = req;
  124. const siteUrl = growiInfoService.getSiteUrl();
  125. const { actionId, callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
  126. const callbacIdkOrActionId = callbackId || actionId;
  127. const fromChannel = interactionPayloadAccessor.getChannel();
  128. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  129. const extractPermissions = await extractPermissionsCommands(tokenPtoG);
  130. let commandPermission;
  131. if (extractPermissions != null) { // with proxy
  132. const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
  133. commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
  134. const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
  135. if (isPermitted) return next();
  136. return next(createError(403, `This interaction is forbidden on this GROWI: ${siteUrl}`));
  137. }
  138. // without proxy
  139. commandPermission = configManager.getConfig('slackbot:withoutProxy:commandPermission');
  140. const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
  141. if (isPermitted) {
  142. return next();
  143. }
  144. // show ephemeral error message if not permitted
  145. const options = {
  146. respondBody: {
  147. text: 'Interaction forbidden',
  148. blocks: [
  149. markdownSectionBlock('*Interaction forbidden*'),
  150. markdownSectionBlock(`This interaction is forbidden on this GROWI: ${siteUrl}`),
  151. ],
  152. },
  153. };
  154. return next(new SlackCommandHandlerError('Interaction forbidden', options));
  155. }
  156. const addSigningSecretToReq = (req, res, next) => {
  157. req.slackSigningSecret = configManager.getConfig('slackbot:withoutProxy:signingSecret');
  158. return next();
  159. };
  160. const verifyUrlMiddleware = (req, res, next) => {
  161. const { body } = req;
  162. // eslint-disable-next-line max-len
  163. // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
  164. if (body.type === 'url_verification') {
  165. return res.send({ challenge: body.challenge });
  166. }
  167. next();
  168. };
  169. const parseSlackInteractionRequest = (req, res, next) => {
  170. if (req.body.payload == null) {
  171. return next(new Error('The payload is not in the request from slack or proxy.'));
  172. }
  173. req.interactionPayload = JSON.parse(req.body.payload);
  174. req.interactionPayloadAccessor = new InteractionPayloadAccessor(req.interactionPayload);
  175. return next();
  176. };
  177. function getRespondUtil(responseUrl) {
  178. const proxyUri = slackIntegrationService.proxyUriForCurrentType ?? null; // can be null
  179. const appSiteUrl = growiInfoService.getSiteUrl();
  180. if (appSiteUrl == null || appSiteUrl === '') {
  181. logger.error('App site url must exist.');
  182. throw SlackCommandHandlerError('App site url must exist.');
  183. }
  184. return generateRespondUtil(responseUrl, proxyUri, appSiteUrl);
  185. }
  186. function getGrowiCommand(body) {
  187. let { growiCommand } = body;
  188. if (growiCommand == null) {
  189. try {
  190. growiCommand = parseSlashCommand(body);
  191. }
  192. catch (err) {
  193. if (err instanceof InvalidGrowiCommandError) {
  194. const options = {
  195. respondBody: {
  196. text: 'Command type is not specified',
  197. blocks: [
  198. markdownSectionBlock('*Command type is not specified.*'),
  199. markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
  200. ],
  201. },
  202. };
  203. throw new SlackCommandHandlerError('Command type is not specified', options);
  204. }
  205. throw err;
  206. }
  207. }
  208. return growiCommand;
  209. }
  210. async function handleCommands(body, res, client, responseUrl) {
  211. let growiCommand;
  212. let respondUtil;
  213. try {
  214. growiCommand = getGrowiCommand(body);
  215. respondUtil = getRespondUtil(responseUrl);
  216. }
  217. catch (err) {
  218. logger.error(err.message);
  219. return handleError(err, responseUrl);
  220. }
  221. const { text } = growiCommand;
  222. if (text == null) {
  223. return 'No text.';
  224. }
  225. // Send response immediately to avoid opelation_timeout error
  226. // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
  227. const appSiteUrl = growiInfoService.getSiteUrl();
  228. try {
  229. await respondUtil.respond({
  230. text: 'Processing your request ...',
  231. blocks: [
  232. markdownSectionBlock(`Processing your request *"/growi ${growiCommand.growiCommandType}"* on GROWI at ${appSiteUrl} ...`),
  233. ],
  234. });
  235. }
  236. catch (err) {
  237. logger.error('Error occurred while request via axios:', err);
  238. }
  239. try {
  240. await slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
  241. }
  242. catch (err) {
  243. return handleError(err, responseUrl);
  244. }
  245. }
  246. // TODO: this method will be a middleware when typescriptize in the future
  247. function getResponseUrl(req) {
  248. const { body } = req;
  249. const responseUrl = body?.growiCommand?.responseUrl;
  250. if (responseUrl == null) {
  251. return body.response_url; // may be null
  252. }
  253. return responseUrl;
  254. }
  255. /**
  256. * @swagger
  257. *
  258. * /slack-integration/commands:
  259. * post:
  260. * tags: [SlackIntegration]
  261. * security:
  262. * - cookieAuth: []
  263. * summary: /slack-integration/commands
  264. * description: Handle Slack commands
  265. * requestBody:
  266. * required: true
  267. * content:
  268. * application/json:
  269. * schema:
  270. * type: object
  271. * responses:
  272. * 200:
  273. * description: OK
  274. * content:
  275. * application/json:
  276. * schema:
  277. * type: string
  278. * example: "No text."
  279. */
  280. router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
  281. const { body } = req;
  282. const responseUrl = getResponseUrl(req);
  283. let client;
  284. try {
  285. client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
  286. }
  287. catch (err) {
  288. logger.error(err.message);
  289. return handleError(err, responseUrl);
  290. }
  291. return handleCommands(body, res, client, responseUrl);
  292. });
  293. // when relation test
  294. /**
  295. * @swagger
  296. *
  297. * /slack-integration/proxied/verify:
  298. * post:
  299. * tags: [SlackIntegration]
  300. * security:
  301. * - cookieAuth: []
  302. * summary: /slack-integration/proxied/verify
  303. * description: Verify the access token
  304. * requestBody:
  305. * required: true
  306. * content:
  307. * application/json:
  308. * schema:
  309. * type: object
  310. * properties:
  311. * type:
  312. * type: string
  313. * challenge:
  314. * type: string
  315. * responses:
  316. * 200:
  317. * description: OK
  318. * content:
  319. * application/json:
  320. * schema:
  321. * type: object
  322. * properties:
  323. * challenge:
  324. * type: string
  325. */
  326. router.post('/proxied/verify', verifyAccessTokenFromProxy, async(req, res) => {
  327. const { body } = req;
  328. // eslint-disable-next-line max-len
  329. // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
  330. if (body.type === 'url_verification') {
  331. return res.send({ challenge: body.challenge });
  332. }
  333. });
  334. /**
  335. * @swagger
  336. *
  337. * /slack-integration/proxied/commands:
  338. * post:
  339. * tags: [SlackIntegration]
  340. * security:
  341. * - cookieAuth: []
  342. * summary: /slack-integration/proxied/commands
  343. * description: Handle Slack commands
  344. * requestBody:
  345. * required: true
  346. * content:
  347. * application/json:
  348. * schema:
  349. * type: object
  350. * responses:
  351. * 200:
  352. * description: OK
  353. * content:
  354. * application/json:
  355. * schema:
  356. * type: string
  357. * example: "No text."
  358. */
  359. router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
  360. const { body } = req;
  361. const responseUrl = getResponseUrl(req);
  362. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  363. let client;
  364. try {
  365. client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
  366. }
  367. catch (err) {
  368. return handleError(err, responseUrl);
  369. }
  370. return handleCommands(body, res, client, responseUrl);
  371. });
  372. async function handleInteractionsRequest(req, res, client) {
  373. const { interactionPayload, interactionPayloadAccessor } = req;
  374. const { type } = interactionPayload;
  375. const responseUrl = interactionPayloadAccessor.getResponseUrl();
  376. try {
  377. const respondUtil = getRespondUtil(responseUrl);
  378. switch (type) {
  379. case 'block_actions':
  380. await slackIntegrationService.handleBlockActionsRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
  381. break;
  382. case 'view_submission':
  383. await slackIntegrationService.handleViewSubmissionRequest(client, interactionPayload, interactionPayloadAccessor, respondUtil);
  384. break;
  385. default:
  386. break;
  387. }
  388. }
  389. catch (err) {
  390. logger.error(err);
  391. return handleError(err, responseUrl);
  392. }
  393. }
  394. /**
  395. * @swagger
  396. *
  397. * /slack-integration/interactions:
  398. * post:
  399. * tags: [SlackIntegration]
  400. * security:
  401. * - cookieAuth: []
  402. * summary: /slack-integration/interactions
  403. * description: Handle Slack interactions
  404. * requestBody:
  405. * required: true
  406. * content:
  407. * application/json:
  408. * schema:
  409. * type: object
  410. * responses:
  411. * 200:
  412. * description: OK
  413. */
  414. router.post('/interactions', addSigningSecretToReq, verifySlackRequest, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
  415. const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
  416. return handleInteractionsRequest(req, res, client);
  417. });
  418. /**
  419. * @swagger
  420. *
  421. * /slack-integration/proxied/interactions:
  422. * post:
  423. * tags: [SlackIntegration]
  424. * security:
  425. * - cookieAuth: []
  426. * summary: /slack-integration/proxied/interactions
  427. * description: Handle Slack interactions
  428. * requestBody:
  429. * required: true
  430. * content:
  431. * application/json:
  432. * schema:
  433. * type: object
  434. * responses:
  435. * 200:
  436. * description: OK
  437. */
  438. router.post('/proxied/interactions', verifyAccessTokenFromProxy, parseSlackInteractionRequest, checkInteractionsPermission, async(req, res) => {
  439. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  440. const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
  441. return handleInteractionsRequest(req, res, client);
  442. });
  443. /**
  444. * @swagger
  445. *
  446. * /slack-integration/supported-commands:
  447. * get:
  448. * tags: [SlackIntegration]
  449. * security:
  450. * - cookieAuth: []
  451. * summary: /slack-integration/supported-commands
  452. * description: Get supported commands
  453. * responses:
  454. * 200:
  455. * description: Supported commands
  456. * content:
  457. * application/json:
  458. * schema:
  459. * type: object
  460. * properties:
  461. * permissionsForBroadcastUseCommands:
  462. * type: array
  463. * items:
  464. * type: object
  465. * permissionsForSingleUseCommands:
  466. * type: array
  467. * items:
  468. * type: object
  469. */
  470. router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
  471. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  472. const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
  473. const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = slackAppIntegration;
  474. return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
  475. });
  476. /**
  477. * @swagger
  478. *
  479. * /slack-integration/events:
  480. * post:
  481. * tags: [SlackIntegration]
  482. * security:
  483. * - cookieAuth: []
  484. * summary: /slack-integration/events
  485. * description: Handle Slack events
  486. * requestBody:
  487. * required: true
  488. * content:
  489. * application/json:
  490. * schema:
  491. * type: object
  492. * properties:
  493. * event:
  494. * type: object
  495. * responses:
  496. * 200:
  497. * description: OK
  498. * content:
  499. * application/json:
  500. * schema:
  501. * type: object
  502. */
  503. router.post('/events', verifyUrlMiddleware, addSigningSecretToReq, verifySlackRequest, async(req, res) => {
  504. const { event } = req.body;
  505. const growiBotEvent = {
  506. eventType: event.type,
  507. event,
  508. };
  509. try {
  510. const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
  511. // convert permission object to map
  512. const permission = new Map(Object.entries(crowi.configManager.getConfig('slackbot:withoutProxy:eventActionsPermission')));
  513. await crowi.slackIntegrationService.handleEventsRequest(client, growiBotEvent, permission);
  514. return res.apiv3({});
  515. }
  516. catch (err) {
  517. logger.error('Error occurred while handling event request.', err);
  518. return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
  519. }
  520. });
  521. const validator = {
  522. validateEventRequest: [
  523. body('growiBotEvent').exists(),
  524. body('data').exists(),
  525. ],
  526. };
  527. /**
  528. * @swagger
  529. *
  530. * /slack-integration/proxied/events:
  531. * post:
  532. * tags: [SlackIntegration]
  533. * security:
  534. * - cookieAuth: []
  535. * summary: /slack-integration/proxied/events
  536. * description: Handle Slack events
  537. * requestBody:
  538. * required: true
  539. * content:
  540. * application/json:
  541. * schema:
  542. * type: object
  543. * properties:
  544. * growiBotEvent:
  545. * type: object
  546. * data:
  547. * type: object
  548. * responses:
  549. * 200:
  550. * description: OK
  551. * content:
  552. * application/json:
  553. * schema:
  554. * type: object
  555. */
  556. router.post('/proxied/events', verifyAccessTokenFromProxy, validator.validateEventRequest, async(req, res) => {
  557. const { growiBotEvent, data } = req.body;
  558. try {
  559. const tokenPtoG = req.headers['x-growi-ptog-tokens'];
  560. const SlackAppIntegration = mongoose.model('SlackAppIntegration');
  561. const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
  562. if (slackAppIntegration == null) {
  563. throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
  564. }
  565. const client = await slackIntegrationService.generateClientBySlackAppIntegration(slackAppIntegration);
  566. const { permissionsForSlackEventActions } = slackAppIntegration;
  567. await slackIntegrationService.handleEventsRequest(client, growiBotEvent, permissionsForSlackEventActions, data);
  568. return res.apiv3({});
  569. }
  570. catch (err) {
  571. logger.error('Error occurred while handling event request.', err);
  572. return res.apiv3Err(new ErrorV3('Error occurred while handling event request.'));
  573. }
  574. });
  575. // error handler
  576. router.use(async(err, req, res, next) => {
  577. const responseUrl = getResponseUrl(req);
  578. if (responseUrl == null) {
  579. // pass err to global error handler
  580. return next(err);
  581. }
  582. await handleError(err, responseUrl);
  583. return;
  584. });
  585. return router;
  586. };