togetter.js 8.4 KB

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