link-shared.ts 5.5 KB

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