growi-to-slack.ts 12 KB

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