slack-integration.ts 9.0 KB

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