slack-integration.ts 11 KB

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