slack-integration.ts 10 KB

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