2
0

togetter.js 9.0 KB

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