link-shared.ts 5.7 KB

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