keep.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import {
  2. inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
  3. } from '@growi/slack/dist/utils/block-kit-builder';
  4. import { format } from 'date-fns/format';
  5. import { parse } from 'date-fns/parse';
  6. import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
  7. import loggerFactory from '~/utils/logger';
  8. const logger = loggerFactory('growi:service:SlackBotService:keep');
  9. /** @param {import('~/server/crowi').default} crowi Crowi instance */
  10. module.exports = (crowi) => {
  11. const CreatePageService = require('./create-page-service');
  12. const createPageService = new CreatePageService(crowi);
  13. const BaseSlackCommandHandler = require('./slack-command-handler');
  14. const handler = new BaseSlackCommandHandler();
  15. const { User } = crowi.models;
  16. handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
  17. await respondUtil.respond({
  18. text: 'Select messages to use.',
  19. blocks: this.keepMessageBlocks(body.channel_name),
  20. });
  21. return;
  22. };
  23. handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) {
  24. await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil);
  25. };
  26. handler.cancel = async function(client, payload, interactionPayloadAccessor, respondUtil) {
  27. await respondUtil.deleteOriginal();
  28. };
  29. handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
  30. let result = [];
  31. const channelId = payload.channel.id; // this must exist since the type is always block_actions
  32. const user = await User.findUserBySlackMemberId(payload.user.id);
  33. // validate form
  34. const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
  35. // get messages
  36. result = await this.keepGetMessages(client, channelId, newest, oldest);
  37. // clean messages
  38. const cleanedContents = await this.keepCleanMessages(result.messages);
  39. const contentsBody = cleanedContents.join('');
  40. // create and send url message
  41. await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil);
  42. };
  43. handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
  44. const grwTzoffset = crowi.appService.getTzoffset() * 60;
  45. const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
  46. let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
  47. let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
  48. if (oldest == null || newest == null || path == null) {
  49. throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
  50. }
  51. /**
  52. * RegExp for datetime yyyy/MM/dd-HH:mm
  53. * @see https://regex101.com/r/XbxdNo/1
  54. */
  55. const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/);
  56. if (!regexpDatetime.test(oldest.trim())) {
  57. throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
  58. }
  59. if (!regexpDatetime.test(newest.trim())) {
  60. throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
  61. }
  62. oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
  63. // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
  64. newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
  65. if (oldest > newest) {
  66. throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
  67. }
  68. return { path, oldest, newest };
  69. };
  70. async function retrieveHistory(client, channelId, newest, oldest) {
  71. return client.conversations.history({
  72. channel: channelId,
  73. newest,
  74. oldest,
  75. limit: 100,
  76. inclusive: true,
  77. });
  78. }
  79. handler.keepGetMessages = async function(client, channelId, newest, oldest) {
  80. let result;
  81. // first attempt
  82. try {
  83. result = await retrieveHistory(client, channelId, newest, oldest);
  84. }
  85. catch (err) {
  86. const errorCode = err.data?.errorCode;
  87. if (errorCode === 'not_in_channel') {
  88. // join and retry
  89. await client.conversations.join({
  90. channel: channelId,
  91. });
  92. result = await retrieveHistory(client, channelId, newest, oldest);
  93. }
  94. else if (errorCode === 'channel_not_found') {
  95. const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
  96. + '\nPlease add GROWI bot to this channel.'
  97. + '\n';
  98. throw new SlackCommandHandlerError(message, {
  99. respondBody: {
  100. text: message,
  101. blocks: [
  102. markdownSectionBlock(message),
  103. {
  104. type: 'image',
  105. image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
  106. alt_text: 'Add app to this channel',
  107. },
  108. ],
  109. },
  110. });
  111. }
  112. else {
  113. throw err;
  114. }
  115. }
  116. // return if no message found
  117. if (result.messages.length === 0) {
  118. throw new SlackCommandHandlerError('No message found from keep command. Try different datetime.');
  119. }
  120. return result;
  121. };
  122. /**
  123. * Get all growi users from messages
  124. * @param {*} messages (array of messages)
  125. * @returns users object with matching Slack Member ID
  126. */
  127. handler.getGrowiUsersFromMessages = async function(messages) {
  128. const users = messages.map((message) => {
  129. return message.user;
  130. });
  131. const growiUsers = await User.findUsersBySlackMemberIds(users);
  132. return growiUsers;
  133. };
  134. /**
  135. * Convert slack member ID to growi user if slack member ID is found in messages
  136. * @param {*} messages
  137. */
  138. handler.injectGrowiUsernameToMessages = async function(messages) {
  139. const growiUsers = await this.getGrowiUsersFromMessages(messages);
  140. messages.map(async(message) => {
  141. const growiUser = growiUsers.find(user => user.slackMemberId === message.user);
  142. if (growiUser != null) {
  143. message.user = `${growiUser.name} (@${growiUser.username})`;
  144. }
  145. else {
  146. message.user = `This slack member ID is not registered (${message.user})`;
  147. }
  148. });
  149. };
  150. handler.keepCleanMessages = async function(messages) {
  151. const cleanedContents = [];
  152. let lastMessage = {};
  153. const grwTzoffset = crowi.appService.getTzoffset() * 60;
  154. await this.injectGrowiUsernameToMessages(messages);
  155. messages
  156. .sort((a, b) => {
  157. return a.ts - b.ts;
  158. })
  159. .forEach((message) => {
  160. // increment contentsBody while removing the same headers
  161. // exclude header
  162. const lastMessageTs = Math.floor(lastMessage.ts / 60);
  163. const messageTs = Math.floor(message.ts / 60);
  164. if (lastMessage.user === message.user && lastMessageTs === messageTs) {
  165. cleanedContents.push(`${message.text}\n`);
  166. }
  167. // include header
  168. else {
  169. const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
  170. const time = format(new Date(ts), 'h:mm a');
  171. cleanedContents.push(`${message.user} ${time}\n${message.text}\n`);
  172. lastMessage = message;
  173. }
  174. });
  175. return cleanedContents;
  176. };
  177. handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil) {
  178. await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user);
  179. // TODO: contentsBody text characters must be less than 3001
  180. // send preview to dm
  181. // await client.chat.postMessage({
  182. // channel: userChannelId,
  183. // text: 'Preview from keep command',
  184. // blocks: [
  185. // markdownSectionBlock('*Preview*'),
  186. // divider(),
  187. // markdownSectionBlock(contentsBody),
  188. // divider(),
  189. // ],
  190. // });
  191. // dismiss
  192. await respondUtil.deleteOriginal();
  193. };
  194. handler.keepMessageBlocks = function(channelName) {
  195. const tzDateSec = new Date().getTime();
  196. const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
  197. const now = tzDateSec - grwTzoffset;
  198. const oldest = now - 60 * 60 * 1000;
  199. const newest = now;
  200. const initialOldest = format(oldest, 'yyyy/MM/dd-HH:mm');
  201. const initialNewest = format(newest, 'yyyy/MM/dd-HH:mm');
  202. const initialPagePath = `/slack/keep/${channelName}/${format(oldest, 'yyyyMMdd-HH:mm')} - ${format(newest, 'yyyyMMdd-HH:mm')}`;
  203. return [
  204. markdownSectionBlock('*The keep command is in alpha.*'),
  205. markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
  206. inputBlock({
  207. type: 'plain_text_input',
  208. action_id: 'oldest',
  209. initial_value: initialOldest,
  210. }, 'oldest', 'Oldest datetime'),
  211. inputBlock({
  212. type: 'plain_text_input',
  213. action_id: 'newest',
  214. initial_value: initialNewest,
  215. }, 'newest', 'Newest datetime'),
  216. inputBlock({
  217. type: 'plain_text_input',
  218. placeholder: {
  219. type: 'plain_text',
  220. text: 'Input page path to create.',
  221. },
  222. initial_value: initialPagePath,
  223. action_id: 'page_path',
  224. }, 'page_path', 'Page path'),
  225. actionsBlock(
  226. buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }),
  227. buttonElement({ text: 'Create page', actionId: 'keep:createPage', style: 'primary' }),
  228. ),
  229. ];
  230. };
  231. return handler;
  232. };