link-shared.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import urljoin from 'url-join';
  2. import { format } from 'date-fns';
  3. import {
  4. MessageAttachment, LinkUnfurls, WebClient,
  5. } from '@slack/web-api';
  6. import { GrowiBotEvent } from '@growi/slack';
  7. import { SlackEventHandler } from './base-event-handler';
  8. import {
  9. DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
  10. } from '../../interfaces/slack-integration/link-shared-unfurl';
  11. import loggerFactory from '~/utils/logger';
  12. import { EventActionsPermission } from '~/server/interfaces/slack-integration/events';
  13. const logger = loggerFactory('growi:service:SlackEventHandler:link-shared');
  14. export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEvent> {
  15. crowi!: any;
  16. constructor(crowi) {
  17. this.crowi = crowi;
  18. }
  19. shouldHandle(eventType: string, permission: EventActionsPermission, channel: string): boolean {
  20. if (eventType !== 'link_shared') return false;
  21. const unfurlPermission = permission.get('unfurl');
  22. if (!Array.isArray(unfurlPermission)) {
  23. return unfurlPermission as boolean;
  24. }
  25. return unfurlPermission.includes(channel);
  26. }
  27. async handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent<UnfurlRequestEvent>, data?: {origin: string}): Promise<void> {
  28. const { event } = growiBotEvent;
  29. const origin = data?.origin || this.crowi.appService.getSiteUrl();
  30. const { channel, message_ts: ts, links } = event;
  31. let unfurlData: DataForUnfurl[];
  32. try {
  33. unfurlData = await this.generateUnfurlsObject(links);
  34. }
  35. catch (err) {
  36. logger.error('Failed to generate unfurl data:', err);
  37. throw err;
  38. }
  39. // unfurl
  40. const unfurlResults = await Promise.allSettled(unfurlData.map(async(data: DataForUnfurl) => {
  41. const toUrl = urljoin(origin, data.id);
  42. let targetUrl;
  43. if (data.isPermalink) {
  44. targetUrl = urljoin(origin, data.id);
  45. }
  46. else {
  47. targetUrl = urljoin(origin, data.path);
  48. }
  49. let unfurls: LinkUnfurls;
  50. if (data.isPublic === false) {
  51. unfurls = {
  52. [targetUrl]: {
  53. text: 'Page is not public.',
  54. },
  55. };
  56. }
  57. else {
  58. unfurls = this.generateLinkUnfurls(data as PublicData, targetUrl, toUrl);
  59. }
  60. await client.chat.unfurl({
  61. channel,
  62. ts,
  63. unfurls,
  64. });
  65. }));
  66. this.logErrorRejectedResults(unfurlResults);
  67. }
  68. // builder method for unfurl parameter
  69. generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
  70. const { pageBody: text, updatedAt, commentCount } = body;
  71. const siteUrl = this.crowi.appService.getSiteUrl();
  72. const updatedAtFormatted = format(updatedAt, 'yyyy-MM-dd HH:mm');
  73. const footer = `URL: ${siteUrl} Updated at: ${updatedAtFormatted}`;
  74. const attachment: MessageAttachment = {
  75. title: body.path,
  76. title_link: toUrl, // permalink
  77. text,
  78. footer,
  79. };
  80. const unfurls: LinkUnfurls = {
  81. [growiTargetUrl]: attachment,
  82. };
  83. return unfurls;
  84. }
  85. async generateUnfurlsObject(links: UnfurlEventLink[]): Promise<DataForUnfurl[]> {
  86. // generate paths array
  87. const pathOrIds: string[] = links.map((link) => {
  88. const { url: growiTargetUrl } = link;
  89. const urlObject = new URL(growiTargetUrl);
  90. return decodeURI(urlObject.pathname);
  91. });
  92. const idRegExp = /^\/[0-9a-z]{24}$/;
  93. const paths = pathOrIds.filter(pathOrId => !idRegExp.test(pathOrId));
  94. const ids = pathOrIds.filter(pathOrId => idRegExp.test(pathOrId)).map(id => id.replace('/', '')); // remove a slash
  95. // get pages with revision
  96. const Page = this.crowi.model('Page');
  97. const { PageQueryBuilder } = Page;
  98. const pageQueryBuilderByPaths = new PageQueryBuilder(Page.find());
  99. const pagesByPaths = await pageQueryBuilderByPaths
  100. .addConditionToListByPathsArray(paths)
  101. .query
  102. .populate('revision')
  103. .lean()
  104. .exec();
  105. const pageQueryBuilderByIds = new PageQueryBuilder(Page.find());
  106. const pagesByIds = await pageQueryBuilderByIds
  107. .addConditionToListByPageIdsArray(ids)
  108. .query
  109. .populate('revision')
  110. .lean()
  111. .exec();
  112. const unfurlDataFromNormalLinks = this.generateDataForUnfurl(pagesByPaths, false);
  113. const unfurlDataFromPermalinks = this.generateDataForUnfurl(pagesByIds, true);
  114. return [...unfurlDataFromNormalLinks, ...unfurlDataFromPermalinks];
  115. }
  116. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  117. private generateDataForUnfurl(pages: any, isPermalink: boolean): DataForUnfurl[] {
  118. const Page = this.crowi.model('Page');
  119. const unfurlData: DataForUnfurl[] = [];
  120. pages.forEach((page) => {
  121. // not send non-public page
  122. if (page.grant !== Page.GRANT_PUBLIC) {
  123. return unfurlData.push({
  124. isPublic: false, isPermalink, id: page._id.toString(), path: page.path,
  125. });
  126. }
  127. // public page
  128. const { updatedAt, commentCount } = page;
  129. const { body } = page.revision;
  130. unfurlData.push({
  131. isPublic: true, isPermalink, id: page._id.toString(), path: page.path, pageBody: body, updatedAt, commentCount,
  132. });
  133. });
  134. return unfurlData;
  135. }
  136. // Promise util method to output rejected results
  137. private logErrorRejectedResults<T>(results: PromiseSettledResult<T>[]): void {
  138. const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
  139. rejectedResults.forEach((rejected, i) => {
  140. logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString());
  141. });
  142. }
  143. }