growi-to-slack.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import { type BlockKitRequest, REQUEST_TIMEOUT_FOR_PTOG } from '@growi/slack';
  2. import { verifyGrowiToSlackRequest } from '@growi/slack/dist/middlewares';
  3. import {
  4. getConnectionStatus,
  5. getConnectionStatuses,
  6. } from '@growi/slack/dist/utils/check-communicable';
  7. import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
  8. import { ErrorCode, WebAPICallResult } from '@slack/web-api';
  9. import {
  10. Controller,
  11. Get,
  12. Inject,
  13. PathParams,
  14. Post,
  15. Put,
  16. QueryParams,
  17. Req,
  18. Res,
  19. UseBefore,
  20. } from '@tsed/common';
  21. import axios from 'axios';
  22. import { addHours } from 'date-fns/addHours';
  23. import createError from 'http-errors';
  24. import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
  25. import {
  26. AddWebclientResponseToRes,
  27. WebclientRes,
  28. } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
  29. import { InstallationRepository } from '~/repositories/installation';
  30. import { OrderRepository } from '~/repositories/order';
  31. import { RelationRepository } from '~/repositories/relation';
  32. import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
  33. import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/SectionBlockPayloadDelegator';
  34. import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
  35. import { InstallerService } from '~/services/InstallerService';
  36. import loggerFactory from '~/utils/logger';
  37. const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
  38. export type RespondReqFromGrowi = Req &
  39. BlockKitRequest & {
  40. // appended by GROWI
  41. headers: { 'x-growi-app-site-url'?: string };
  42. // will be extracted from header
  43. appSiteUrl: string;
  44. };
  45. @Controller('/g2s')
  46. export class GrowiToSlackCtrl {
  47. @Inject()
  48. installerService: InstallerService;
  49. @Inject()
  50. installationRepository: InstallationRepository;
  51. @Inject()
  52. relationRepository: RelationRepository;
  53. @Inject()
  54. orderRepository: OrderRepository;
  55. @Inject()
  56. viewInteractionPayloadDelegator: ViewInteractionPayloadDelegator;
  57. @Inject()
  58. actionsBlockPayloadDelegator: ActionsBlockPayloadDelegator;
  59. @Inject()
  60. sectionBlockPayloadDelegator: SectionBlockPayloadDelegator;
  61. async urlVerificationRequestToGrowi(
  62. growiUrl: string,
  63. tokenPtoG: string,
  64. ): Promise<void> {
  65. const url = new URL('/_api/v3/slack-integration/proxied/verify', growiUrl);
  66. await axios.post(
  67. url.toString(),
  68. {
  69. type: 'url_verification',
  70. challenge: 'this_is_my_challenge_token',
  71. },
  72. {
  73. headers: {
  74. 'x-growi-ptog-tokens': tokenPtoG,
  75. },
  76. timeout: REQUEST_TIMEOUT_FOR_PTOG,
  77. },
  78. );
  79. }
  80. @Get('/connection-status')
  81. @UseBefore(verifyGrowiToSlackRequest)
  82. async getConnectionStatuses(
  83. @Req() req: GrowiReq,
  84. @Res() res: Res,
  85. ): Promise<undefined | string | Res | WebAPICallResult> {
  86. // asserted (tokenGtoPs.length > 0) by verifyGrowiToSlackRequest
  87. const { tokenGtoPs } = req;
  88. // retrieve Relation with Installation
  89. const relations = await this.relationRepository
  90. .createQueryBuilder('relation')
  91. .where('relation.tokenGtoP IN (:...tokens)', { tokens: tokenGtoPs })
  92. .leftJoinAndSelect('relation.installation', 'installation')
  93. .getMany();
  94. logger.debug(`${relations.length} relations found`, relations);
  95. // key: tokenGtoP, value: botToken
  96. const botTokenResolverMapping: { [tokenGtoP: string]: string } = {};
  97. for (const relation of relations) {
  98. const botToken = relation.installation?.data?.bot?.token;
  99. if (botToken != null) {
  100. botTokenResolverMapping[relation.tokenGtoP] = botToken;
  101. }
  102. }
  103. const connectionStatuses = await getConnectionStatuses(
  104. Object.keys(botTokenResolverMapping),
  105. (tokenGtoP: string) => botTokenResolverMapping[tokenGtoP],
  106. );
  107. return res.send({ connectionStatuses });
  108. }
  109. @Put('/supported-commands')
  110. @UseBefore(verifyGrowiToSlackRequest)
  111. async putSupportedCommands(
  112. @Req() req: GrowiReq,
  113. @Res() res: Res,
  114. ): Promise<undefined | string | Res | WebAPICallResult> {
  115. // asserted (tokenGtoPs.length > 0) by verifyGrowiToSlackRequest
  116. const { tokenGtoPs } = req;
  117. const {
  118. permissionsForBroadcastUseCommands,
  119. permissionsForSingleUseCommands,
  120. } = req.body;
  121. if (tokenGtoPs.length !== 1) {
  122. throw createError(400, 'installation is invalid');
  123. }
  124. const tokenGtoP = tokenGtoPs[0];
  125. const relation = await this.relationRepository.update(
  126. { tokenGtoP },
  127. { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands },
  128. );
  129. return res.send({ relation });
  130. }
  131. @Post('/relation-test')
  132. @UseBefore(verifyGrowiToSlackRequest)
  133. async postRelation(
  134. @Req() req: GrowiReq,
  135. @Res() res: Res,
  136. ): Promise<undefined | string | Res | WebAPICallResult> {
  137. const { tokenGtoPs } = req;
  138. if (tokenGtoPs.length !== 1) {
  139. throw createError(400, 'installation is invalid');
  140. }
  141. const tokenGtoP = tokenGtoPs[0];
  142. // retrieve relation with Installation
  143. const relation = await this.relationRepository
  144. .createQueryBuilder('relation')
  145. .where('tokenGtoP = :token', { token: tokenGtoP })
  146. .leftJoinAndSelect('relation.installation', 'installation')
  147. .getOne();
  148. // Returns the result of the test if it already exists
  149. if (relation != null) {
  150. logger.debug('relation found', relation);
  151. const token = relation.installation.data.bot?.token;
  152. if (token == null) {
  153. throw createError(400, 'installation is invalid');
  154. }
  155. try {
  156. await this.urlVerificationRequestToGrowi(
  157. relation.growiUri,
  158. relation.tokenPtoG,
  159. );
  160. } catch (err) {
  161. logger.error(err);
  162. throw createError(
  163. 400,
  164. `failed to request to GROWI. err: ${err.message}`,
  165. );
  166. }
  167. const status = await getConnectionStatus(token);
  168. if (status.error != null) {
  169. throw createError(
  170. 400,
  171. `failed to get connection. err: ${status.error}`,
  172. );
  173. }
  174. return res.send({ relation, slackBotToken: token });
  175. }
  176. // retrieve latest Order with Installation
  177. const order = await this.orderRepository
  178. .createQueryBuilder('order')
  179. .orderBy('order.createdAt', 'DESC')
  180. .where('tokenGtoP = :token', { token: tokenGtoP })
  181. .leftJoinAndSelect('order.installation', 'installation')
  182. .getOne();
  183. if (order == null || order.isExpired()) {
  184. throw createError(400, 'order has expired or does not exist.');
  185. }
  186. // Access the GROWI URL saved in the Order record and check if the GtoP token is valid.
  187. try {
  188. await this.urlVerificationRequestToGrowi(order.growiUrl, order.tokenPtoG);
  189. } catch (err) {
  190. logger.error(err);
  191. throw createError(400, `failed to request to GROWI. err: ${err.message}`);
  192. }
  193. logger.debug('order found', order);
  194. const token = order.installation.data.bot?.token;
  195. if (token == null) {
  196. throw createError(400, 'installation is invalid');
  197. }
  198. const status = await getConnectionStatus(token);
  199. if (status.error != null) {
  200. throw createError(400, `failed to get connection. err: ${status.error}`);
  201. }
  202. logger.debug('relation test is success', order);
  203. // temporary cache for 48 hours
  204. const expiredAtCommands = addHours(new Date(), 48);
  205. const response = await this.relationRepository
  206. .createQueryBuilder('relation')
  207. .insert()
  208. .values({
  209. installation: order.installation,
  210. tokenGtoP: order.tokenGtoP,
  211. tokenPtoG: order.tokenPtoG,
  212. growiUri: order.growiUrl,
  213. permissionsForBroadcastUseCommands:
  214. req.body.permissionsForBroadcastUseCommands,
  215. permissionsForSingleUseCommands:
  216. req.body.permissionsForSingleUseCommands,
  217. expiredAtCommands,
  218. })
  219. // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
  220. .orUpdate({
  221. conflict_target: ['installation', 'growiUri'],
  222. overwrite: [
  223. 'tokenGtoP',
  224. 'tokenPtoG',
  225. 'permissionsForBroadcastUseCommands',
  226. 'permissionsForSingleUseCommands',
  227. ],
  228. })
  229. .execute();
  230. const generatedRelation = await this.relationRepository.findOne({
  231. id: response.identifiers[0].id,
  232. });
  233. return res.send({ relation: generatedRelation, slackBotToken: token });
  234. }
  235. injectGrowiUri(req: BlockKitRequest, growiUri: string): void {
  236. if (req.body.view == null && req.body.blocks == null) {
  237. return;
  238. }
  239. if (req.body.view != null) {
  240. const parsedElement = JSON.parse(req.body.view);
  241. // delegate to ViewInteractionPayloadDelegator
  242. if (
  243. this.viewInteractionPayloadDelegator.shouldHandleToInject(parsedElement)
  244. ) {
  245. this.viewInteractionPayloadDelegator.inject(parsedElement, growiUri);
  246. req.body.view = JSON.stringify(parsedElement);
  247. }
  248. } else if (req.body.blocks != null) {
  249. const parsedElement =
  250. typeof req.body.blocks === 'string'
  251. ? JSON.parse(req.body.blocks)
  252. : req.body.blocks;
  253. // delegate to ActionsBlockPayloadDelegator
  254. if (
  255. this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)
  256. ) {
  257. this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
  258. req.body.blocks = JSON.stringify(parsedElement);
  259. }
  260. // delegate to SectionBlockPayloadDelegator
  261. if (
  262. this.sectionBlockPayloadDelegator.shouldHandleToInject(parsedElement)
  263. ) {
  264. this.sectionBlockPayloadDelegator.inject(parsedElement, growiUri);
  265. req.body.blocks = JSON.stringify(parsedElement);
  266. }
  267. }
  268. }
  269. @Post('/respond')
  270. async respondUsingResponseUrl(
  271. @QueryParams('response_url') responseUrl: string,
  272. @Req() req: RespondReqFromGrowi,
  273. @Res() res: WebclientRes,
  274. ): Promise<WebclientRes> {
  275. // get growi url from header
  276. const growiUri = req.headers['x-growi-app-site-url'];
  277. if (growiUri == null) {
  278. logger.error(
  279. 'Request to this endpoint requires the x-growi-app-site-url header.',
  280. );
  281. return res.status(400).send('Failed to respond.');
  282. }
  283. try {
  284. this.injectGrowiUri(req, growiUri);
  285. } catch (err) {
  286. logger.error('Error occurred while injecting GROWI uri:\n', err);
  287. return res.status(400).send('Failed to respond.');
  288. }
  289. try {
  290. await axios.post(responseUrl, req.body);
  291. } catch (err) {
  292. logger.error('Error occurred while request via axios:', err);
  293. return res.status(502).send(err.message);
  294. }
  295. return res.send();
  296. }
  297. @Post('/:method')
  298. @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
  299. async callSlackApi(
  300. @PathParams('method') method: string,
  301. @Req() req: GrowiReq,
  302. @Res() res: WebclientRes,
  303. ): Promise<WebclientRes> {
  304. const { tokenGtoPs } = req;
  305. logger.debug('Slack API called: ', { method });
  306. if (tokenGtoPs.length !== 1) {
  307. return res.simulateWebAPIPlatformError(
  308. 'tokenGtoPs is invalid',
  309. 'invalid_tokenGtoP',
  310. );
  311. }
  312. const tokenGtoP = tokenGtoPs[0];
  313. // retrieve relation with Installation
  314. const relation = await this.relationRepository
  315. .createQueryBuilder('relation')
  316. .where('tokenGtoP = :token', { token: tokenGtoP })
  317. .leftJoinAndSelect('relation.installation', 'installation')
  318. .getOne();
  319. if (relation == null) {
  320. return res.simulateWebAPIPlatformError(
  321. 'relation is invalid',
  322. 'invalid_relation',
  323. );
  324. }
  325. const token = relation.installation.data.bot?.token;
  326. if (token == null) {
  327. return res.simulateWebAPIPlatformError(
  328. 'installation is invalid',
  329. 'invalid_installation',
  330. );
  331. }
  332. // generate WebClient with no retry because GROWI main side will do
  333. const client = generateWebClient(token, {
  334. retryConfig: { retries: 0 },
  335. });
  336. try {
  337. this.injectGrowiUri(req, relation.growiUri);
  338. const opt = req.body;
  339. opt.headers = req.headers;
  340. logger.debug({ method, opt });
  341. // !! DO NOT REMOVE `await ` or it does not enter catch block even when axios error occured !! -- 2021.08.22 Yuki Takei
  342. const result = await client.apiCall(method, opt);
  343. return res.send(result);
  344. } catch (err) {
  345. logger.error(err);
  346. if (err.code === ErrorCode.PlatformError) {
  347. return res.simulateWebAPIPlatformError(err.message, err.data.error);
  348. }
  349. return res.simulateWebAPIRequestError(err.message, err.response?.status);
  350. }
  351. }
  352. }