slack-integration.ts 11 KB

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