togetter.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import loggerFactory from '~/utils/logger';
  2. const logger = loggerFactory('growi:service:SlackBotService:togetter');
  3. const {
  4. inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider, respond,
  5. deleteOriginal,
  6. } = require('@growi/slack');
  7. const { parse, format } = require('date-fns');
  8. const axios = require('axios');
  9. const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
  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. handler.handleCommand = async function(growiCommand, client, body) {
  16. await respond(growiCommand.responseUrl, {
  17. text: 'Select messages to use.',
  18. blocks: this.togetterMessageBlocks(),
  19. });
  20. return;
  21. };
  22. handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) {
  23. await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor);
  24. };
  25. handler.cancel = async function(client, payload, interactionPayloadAccessor) {
  26. await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
  27. delete_original: true,
  28. });
  29. };
  30. handler.createPage = async function(client, payload, interactionPayloadAccessor) {
  31. let result = [];
  32. const channelId = payload.channel.id; // this must exist since the type is always block_actions
  33. const userChannelId = payload.user.id;
  34. // validate form
  35. const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor);
  36. // get messages
  37. result = await this.togetterGetMessages(client, channelId, newest, oldest);
  38. // clean messages
  39. const cleanedContents = await this.togetterCleanMessages(result.messages);
  40. const contentsBody = cleanedContents.join('');
  41. // create and send url message
  42. await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody);
  43. };
  44. handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) {
  45. const grwTzoffset = crowi.appService.getTzoffset() * 60;
  46. const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value;
  47. let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value;
  48. let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value;
  49. if (oldest == null || newest == null || path == null) {
  50. throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)');
  51. }
  52. /**
  53. * RegExp for datetime yyyy/MM/dd-HH:mm
  54. * @see https://regex101.com/r/XbxdNo/1
  55. */
  56. 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]$/);
  57. if (!regexpDatetime.test(oldest.trim())) {
  58. throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm');
  59. }
  60. if (!regexpDatetime.test(newest.trim())) {
  61. throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm');
  62. }
  63. oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
  64. // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
  65. newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
  66. if (oldest > newest) {
  67. throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.');
  68. }
  69. return { path, oldest, newest };
  70. };
  71. async function retrieveHistory(client, channelId, newest, oldest) {
  72. return client.conversations.history({
  73. channel: channelId,
  74. newest,
  75. oldest,
  76. limit: 100,
  77. inclusive: true,
  78. });
  79. }
  80. handler.togetterGetMessages = async function(client, channelId, newest, oldest) {
  81. let result;
  82. // first attempt
  83. try {
  84. result = await retrieveHistory(client, channelId, newest, oldest);
  85. }
  86. catch (err) {
  87. const errorCode = err.data?.errorCode;
  88. if (errorCode === 'not_in_channel') {
  89. // join and retry
  90. await client.conversations.join({
  91. channel: channelId,
  92. });
  93. result = await retrieveHistory(client, channelId, newest, oldest);
  94. }
  95. else if (errorCode === 'channel_not_found') {
  96. const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.'
  97. + '\nPlease add GROWI bot to this channel.'
  98. + '\n';
  99. throw new SlackCommandHandlerError(message, {
  100. respondBody: {
  101. text: message,
  102. blocks: [
  103. markdownSectionBlock(message),
  104. {
  105. type: 'image',
  106. image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png',
  107. alt_text: 'Add app to this channel',
  108. },
  109. ],
  110. },
  111. });
  112. }
  113. else {
  114. throw err;
  115. }
  116. }
  117. // return if no message found
  118. if (result.messages.length === 0) {
  119. throw new SlackCommandHandlerError('No message found from togetter command. Try different datetime.');
  120. }
  121. return result;
  122. };
  123. handler.togetterCleanMessages = async function(messages) {
  124. const cleanedContents = [];
  125. let lastMessage = {};
  126. const grwTzoffset = crowi.appService.getTzoffset() * 60;
  127. messages
  128. .sort((a, b) => {
  129. return a.ts - b.ts;
  130. })
  131. .forEach((message) => {
  132. // increment contentsBody while removing the same headers
  133. // exclude header
  134. const lastMessageTs = Math.floor(lastMessage.ts / 60);
  135. const messageTs = Math.floor(message.ts / 60);
  136. if (lastMessage.user === message.user && lastMessageTs === messageTs) {
  137. cleanedContents.push(`${message.text}\n`);
  138. }
  139. // include header
  140. else {
  141. const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
  142. const time = format(new Date(ts), 'h:mm a');
  143. cleanedContents.push(`${message.user} ${time}\n${message.text}\n`);
  144. lastMessage = message;
  145. }
  146. });
  147. return cleanedContents;
  148. };
  149. handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) {
  150. await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody);
  151. // send preview to dm
  152. await client.chat.postMessage({
  153. channel: userChannelId,
  154. text: 'Preview from togetter command',
  155. blocks: [
  156. markdownSectionBlock('*Preview*'),
  157. divider(),
  158. markdownSectionBlock(contentsBody),
  159. divider(),
  160. ],
  161. });
  162. // dismiss
  163. await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), {
  164. delete_original: true,
  165. });
  166. };
  167. handler.togetterMessageBlocks = function() {
  168. return [
  169. markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
  170. inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'),
  171. inputBlock(this.plainTextInputElementWithInitialTime('newest'), 'newest', 'Newest datetime'),
  172. inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
  173. actionsBlock(
  174. buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
  175. buttonElement({ text: 'Create page', actionId: 'togetter:createPage', style: 'primary' }),
  176. ),
  177. ];
  178. };
  179. /**
  180. * Plain-text input element
  181. * https://api.slack.com/reference/block-kit/block-elements#input
  182. */
  183. handler.togetterInputBlockElement = function(actionId, placeholderText = 'Write something ...') {
  184. return {
  185. type: 'plain_text_input',
  186. placeholder: {
  187. type: 'plain_text',
  188. text: placeholderText,
  189. },
  190. action_id: actionId,
  191. };
  192. };
  193. handler.plainTextInputElementWithInitialTime = function(actionId) {
  194. const tzDateSec = new Date().getTime();
  195. const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000;
  196. const initialDateTime = format(new Date(tzDateSec - grwTzoffset), 'yyyy/MM/dd-HH:mm');
  197. return {
  198. type: 'plain_text_input',
  199. action_id: actionId,
  200. initial_value: initialDateTime,
  201. };
  202. };
  203. return handler;
  204. };