slack-integration.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import mongoose from 'mongoose';
  2. import { IncomingWebhookSendArguments } from '@slack/webhook';
  3. import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
  4. import {
  5. generateWebClient, GrowiCommand, InteractionPayloadAccessor, markdownSectionBlock, SlackbotType,
  6. } from '@growi/slack';
  7. import loggerFactory from '~/utils/logger';
  8. import S2sMessage from '../models/vo/s2s-message';
  9. import ConfigManager from './config-manager';
  10. import { S2sMessagingService } from './s2s-messaging/base';
  11. import { S2sMessageHandlable } from './s2s-messaging/handlable';
  12. import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
  13. const logger = loggerFactory('growi:service:SlackBotService');
  14. const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
  15. type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
  16. export class SlackIntegrationService implements S2sMessageHandlable {
  17. crowi!: any;
  18. configManager!: ConfigManager;
  19. s2sMessagingService!: S2sMessagingService;
  20. lastLoadedAt?: Date;
  21. constructor(crowi) {
  22. this.crowi = crowi;
  23. this.configManager = crowi.configManager;
  24. this.s2sMessagingService = crowi.s2sMessagingService;
  25. this.initialize();
  26. }
  27. initialize() {
  28. this.lastLoadedAt = new Date();
  29. }
  30. /**
  31. * @inheritdoc
  32. */
  33. shouldHandleS2sMessage(s2sMessage: S2sMessageForSlackIntegration): boolean {
  34. const { eventName, updatedAt } = s2sMessage;
  35. if (eventName !== 'slackIntegrationServiceUpdated' || updatedAt == null) {
  36. return false;
  37. }
  38. return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
  39. }
  40. /**
  41. * @inheritdoc
  42. */
  43. async handleS2sMessage(): Promise<void> {
  44. const { configManager } = this.crowi;
  45. logger.info('Reset slack bot by pubsub notification');
  46. await configManager.loadConfigs();
  47. this.initialize();
  48. }
  49. async publishUpdatedMessage(): Promise<void> {
  50. const { s2sMessagingService } = this;
  51. if (s2sMessagingService != null) {
  52. const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', { updatedAt: new Date() });
  53. try {
  54. await s2sMessagingService.publish(s2sMessage);
  55. }
  56. catch (e) {
  57. logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
  58. }
  59. }
  60. }
  61. get isSlackConfigured(): boolean {
  62. return this.isSlackbotConfigured || this.isSlackLegacyConfigured;
  63. }
  64. get isSlackbotConfigured(): boolean {
  65. const hasSlackbotType = !!this.configManager.getConfig('crowi', 'slackbot:currentBotType');
  66. return hasSlackbotType;
  67. }
  68. get isSlackLegacyConfigured(): boolean {
  69. // for legacy util
  70. const hasSlackToken = !!this.configManager.getConfig('notification', 'slack:token');
  71. const hasSlackIwhUrl = !!this.configManager.getConfig('notification', 'slack:incomingWebhookUrl');
  72. return hasSlackToken || hasSlackIwhUrl;
  73. }
  74. private isCheckTypeValid(): boolean {
  75. const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
  76. if (currentBotType == null) {
  77. throw new Error('The config \'SLACKBOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
  78. }
  79. return true;
  80. }
  81. get proxyUriForCurrentType(): string {
  82. const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
  83. // TODO assert currentBotType is not null and CUSTOM_WITHOUT_PROXY
  84. let proxyUri: string;
  85. switch (currentBotType) {
  86. case SlackbotType.OFFICIAL:
  87. proxyUri = OFFICIAL_SLACKBOT_PROXY_URI;
  88. break;
  89. default:
  90. proxyUri = this.configManager.getConfig('crowi', 'slackbot:proxyUri');
  91. break;
  92. }
  93. return proxyUri;
  94. }
  95. /**
  96. * generate WebClient instance for CUSTOM_WITHOUT_PROXY type
  97. */
  98. async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
  99. this.isCheckTypeValid();
  100. const token = this.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
  101. if (token == null) {
  102. throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:withoutProxy:botToken\') must be set.');
  103. }
  104. return generateWebClient(token);
  105. }
  106. /**
  107. * generate WebClient instance by tokenPtoG
  108. * @param tokenPtoG
  109. */
  110. async generateClientByTokenPtoG(tokenPtoG: string): Promise<WebClient> {
  111. this.isCheckTypeValid();
  112. const SlackAppIntegration = mongoose.model('SlackAppIntegration');
  113. const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
  114. if (slackAppIntegration == null) {
  115. throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
  116. }
  117. return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
  118. }
  119. /**
  120. * generate WebClient instance by tokenPtoG
  121. * @param tokenPtoG
  122. */
  123. async generateClientForPrimaryWorkspace(): Promise<WebClient> {
  124. this.isCheckTypeValid();
  125. const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
  126. if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
  127. return this.generateClientForCustomBotWithoutProxy();
  128. }
  129. // retrieve primary SlackAppIntegration
  130. const SlackAppIntegration = mongoose.model('SlackAppIntegration');
  131. const slackAppIntegration = await SlackAppIntegration.findOne({ isPrimary: true });
  132. if (slackAppIntegration == null) {
  133. throw new Error('None of the primary SlackAppIntegration exists.');
  134. }
  135. return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
  136. }
  137. /**
  138. * generate WebClient instance by SlackAppIntegration
  139. * @param slackAppIntegration
  140. */
  141. async generateClientBySlackAppIntegration(slackAppIntegration: { tokenGtoP: string }): Promise<WebClient> {
  142. this.isCheckTypeValid();
  143. // connect to proxy
  144. const serverUri = new URL('/g2s', this.proxyUriForCurrentType);
  145. const headers = {
  146. 'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
  147. };
  148. return generateWebClient(undefined, serverUri.toString(), headers);
  149. }
  150. async postMessage(messageArgs: ChatPostMessageArguments, slackAppIntegration?: { tokenGtoP: string; }): Promise<void> {
  151. // use legacy slack configuration
  152. if (this.isSlackLegacyConfigured && !this.isSlackbotConfigured) {
  153. return this.postMessageWithLegacyUtil(messageArgs);
  154. }
  155. const client = slackAppIntegration == null
  156. ? await this.generateClientForPrimaryWorkspace()
  157. : await this.generateClientBySlackAppIntegration(slackAppIntegration);
  158. try {
  159. await client.chat.postMessage(messageArgs);
  160. }
  161. catch (error) {
  162. logger.debug('Post error', error);
  163. logger.debug('Sent data to slack is:', messageArgs);
  164. throw error;
  165. }
  166. }
  167. private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
  168. const slackLegacyUtil = require('../util/slack-legacy')(this.crowi);
  169. try {
  170. await slackLegacyUtil.postMessage(messageArgs);
  171. }
  172. catch (error) {
  173. logger.debug('Post error', error);
  174. logger.debug('Sent data to slack is:', messageArgs);
  175. throw error;
  176. }
  177. }
  178. /**
  179. * Handle /commands endpoint
  180. */
  181. async handleCommandRequest(growiCommand: GrowiCommand, client, body): Promise<void> {
  182. const { growiCommandType } = growiCommand;
  183. const module = `./slack-command-handler/${growiCommandType}`;
  184. let handler;
  185. try {
  186. handler = require(module)(this.crowi);
  187. }
  188. catch (err) {
  189. const text = `*No command.*\n \`command: ${growiCommand.text}\``;
  190. throw new SlackCommandHandlerError(text, {
  191. respondBody: {
  192. text,
  193. blocks: [
  194. markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
  195. ],
  196. },
  197. });
  198. }
  199. // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
  200. return handler.handleCommand(growiCommand, client, body);
  201. }
  202. async handleBlockActionsRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
  203. const { actionId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
  204. const commandName = actionId.split(':')[0];
  205. const handlerMethodName = actionId.split(':')[1];
  206. const module = `./slack-command-handler/${commandName}`;
  207. let handler;
  208. try {
  209. handler = require(module)(this.crowi);
  210. }
  211. catch (err) {
  212. throw new SlackCommandHandlerError(`No interaction.\n \`actionId: ${actionId}\``);
  213. }
  214. // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
  215. return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
  216. }
  217. async handleViewSubmissionRequest(client, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<void> {
  218. const { callbackId } = interactionPayloadAccessor.getActionIdAndCallbackIdFromPayLoad();
  219. const commandName = callbackId.split(':')[0];
  220. const handlerMethodName = callbackId.split(':')[1];
  221. const module = `./slack-command-handler/${commandName}`;
  222. let handler;
  223. try {
  224. handler = require(module)(this.crowi);
  225. }
  226. catch (err) {
  227. throw new SlackCommandHandlerError(`No interaction.\n \`callbackId: ${callbackId}\``);
  228. }
  229. // Do not wrap with try-catch. Errors thrown by slack-command-handler modules will be handled in router.
  230. return handler.handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName);
  231. }
  232. }