slack-integration.ts 11 KB

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